Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 005ebac2b5 | |||
| 59f0708382 | |||
| 968066d39a | |||
| b72afdcee3 | |||
| 43ba59b302 | |||
| 00990d5008 | |||
| e0463525ef | |||
| 5417d5eeed | |||
| 2890be6129 | |||
| 964b1dd2de | |||
| 26e2660a03 | |||
| 9c20253fa4 | |||
| 49d8bd83e6 | |||
| 7109967f67 | |||
| e26c1a3dec | |||
| f51067bceb | |||
| 31e50d0e14 | |||
| 98b79042bf | |||
| 72afecd539 |
@@ -12,7 +12,7 @@
|
|||||||
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white">
|
||||||
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
<img alt="Architectures" src="https://img.shields.io/badge/platform-amd64%20%7C%20arm64-5965F2">
|
||||||
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
<img alt="Caddy" src="https://img.shields.io/badge/powered%20by-Caddy-1F88C0">
|
||||||
<img alt="Version" src="https://img.shields.io/badge/version-0.11.101-62E6A7">
|
<img alt="Version" src="https://img.shields.io/badge/version-0.15.1-62E6A7">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||||
@@ -42,7 +42,7 @@ It's intentionally narrower than a general-purpose proxy manager. You describe *
|
|||||||
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
- **Automatic HTTPS** — Caddy issues and renews public certificates; internal, HTTP-only, and uploaded custom-certificate modes are also supported.
|
||||||
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
- **Live dashboard** — gateway/HTTP/HTTPS/storage health, hosted and proxy counts, certificate status, throughput, uptime, memory, disk, and version info at a glance.
|
||||||
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
- **Access Lists** — reusable login/network policies combining accounts, groups, and IP/CIDR rules across any host.
|
||||||
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes.
|
- **Two-factor authentication** — TOTP-based MFA for administrator and user accounts, with recovery codes, plus an administrator-side override to disable a locked-out user's 2FA when they've lost their authenticator and used up their recovery codes.
|
||||||
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
- **Users, groups, and roles** — Administrator and Standard User roles, with account lifecycle controls.
|
||||||
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
- **Backups** — configuration or complete `.sgbackup` archives, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted.
|
||||||
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
- **Certificates page** — issuer, expiration, days remaining, and renewal health for every managed and uploaded certificate.
|
||||||
|
|||||||
+44
-5
@@ -2,7 +2,37 @@
|
|||||||
|
|
||||||
## Current release status
|
## Current release status
|
||||||
|
|
||||||
`v0.11.101` is a stable, day-to-day release. The product has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
|
`v0.12.0` marks a shift from the earlier per-fix `0.11.x` patch numbering to ordinary semantic versioning going forward — a minor bump for a real batch of changes, a patch bump for a targeted fix, rather than incrementing the same trailing number for every single change regardless of size. The product itself has moved well past the original alpha creation flow described in earlier versions of this document — Hosted Sites, Proxy Hosts, Redirect Hosts, and Streaming Hosts are all implemented, along with authentication, access control, certificates, backups, and full dashboard reporting. This document reflects what's actually shipped and what's genuinely still ahead.
|
||||||
|
|
||||||
|
`v0.13.0` is a real batch under that same convention, not a targeted fix, even though none of it changes what the app *does*: the in-app light theme has been removed entirely (the app is dark-only now, including the two visitor-facing themed pages -- the default-site 404/welcome/custom-HTML page and the Access-List sign-in page, both previously following the visitor's OS light/dark preference and now fixed dark for consistency with the rest of the app), the full color/spacing/radius design-token system begun in v0.12.0 has been completed (zero hardcoded color literals remain anywhere outside the token definitions), and `styles.css` has been restructured into commented, page-aligned sections matching the convention already used in `app.js`/`features.js`/`server.js`. A handful of small pre-existing bugs (a duplicate CSS custom property, some dead/duplicate rules, a decorative background glow that rendered incorrectly at certain aspect ratios) were also found and fixed along the way.
|
||||||
|
|
||||||
|
`v0.14.0` adds config-drift detection, a backup-encryption readiness check, and an app-wide dialog cleanup.
|
||||||
|
|
||||||
|
- **Configuration drift detection** — a background check every 10 minutes compares Caddy's live running configuration (via its admin API `/config/` endpoint) against what Site Gateway's saved routes would currently generate (via `/adapt`). If they disagree \u2014 for example after a manual edit to the Caddyfile outside the app, or a Caddy restart that didn't pick up the latest reload \u2014 a "Configuration drift" item appears in the dashboard's Needs Attention list, driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
|
||||||
|
- **Backup-encryption readiness** — the "Encrypt scheduled backups" checkbox no longer lets you configure something that will silently fail later. `/api/config` now reports whether the `BACKUP_PASSWORD` environment variable is actually set; the checkbox is disabled with an explanatory message when it isn't, and if it was previously saved as enabled and `BACKUP_PASSWORD` has since been removed, it shows a distinct warning instead of failing quietly at the next scheduled run.
|
||||||
|
- **Dialog cleanup** \u2014 every themed popout dialog's redundant "\u00d7" close button (in the dialog-heading row) has been removed app-wide; each dialog already has a working Cancel/Close button in its actions row, so this is pure de-duplication with no loss of function. New dialogs are expected to follow this pattern going forward.
|
||||||
|
|
||||||
|
`v0.15.0` ships the full set of features scoped alongside `v0.14.0` and deferred at the time — nothing here was cut:
|
||||||
|
|
||||||
|
- **REST API with issuable tokens** \u2014 admin-issued bearer tokens (full or read-only scope) for scripting against the Site Gateway API outside the browser session, bound to the issuing user's session version so a password reset/deactivation revokes them automatically.
|
||||||
|
- **Backup/restore history** \u2014 a durable, database-backed history of every backup, restore, and deletion (including failed attempts), shown as a human-readable timeline that never displays raw backup filenames.
|
||||||
|
- **Docker container picker** \u2014 an opt-in integration (gated on the Docker socket being mounted and readable) that lets Proxy/Streaming targets be picked from the host's running containers instead of typed by hand, using each container's Docker DNS name.
|
||||||
|
- **"View Caddy config" popout** \u2014 a read-only, prettified view of the exact Caddy configuration block generated for a given site, proxy, or redirect, built from the same code path that generates the real deployed config so it can never drift from it.
|
||||||
|
- **Performance screen overhaul** \u2014 clock-aligned time-axis labels, a y-axis unit, hover tooltips with error counts, p95 latency, bandwidth and unique-visitor columns, a 4xx/5xx-colored error breakdown, top-paths-per-host, and a slowest-requests panel.
|
||||||
|
- **Dashboard tile color unification** \u2014 normalizing all "normal count" tiles to a shared green baseline that reacts to warning/danger states the same way the existing Needs Attention tile does.
|
||||||
|
- Two pre-existing bugs found and fixed along the way: `sessionVersion` was never actually rotated anywhere, meaning a password change, MFA disable, or admin-forced deactivation didn't invalidate existing sessions/API tokens as documented; and the redirect card's "Change icon" menu action was silently falling through to the enable/disable toggle handler instead of opening the icon picker.
|
||||||
|
- Also folds in the config-drift attention-tile click-through fix from `v0.14.1` (never separately released): clicking the dashboard's "Configuration drift" item now goes to Administration \u2192 Gateway Defaults, not the generic Administration landing tab.
|
||||||
|
|
||||||
|
`v0.15.1` is a fix-list batch from live testing of `v0.15.0`, not new features:
|
||||||
|
|
||||||
|
- Native `<select>` dropdowns (Performance's Range picker and ~36 others app-wide) now render in the app's dark theme instead of the browser's light default \u2014 root cause was a missing `color-scheme` meta tag, already present on the other two themed pages but never added to the main app shell.
|
||||||
|
- The Live Health dashboard panel's badge/border now derive only from its own 6 displayed checks (gateway, HTTP, HTTPS, storage, streaming ports, upstreams) instead of the site-wide Needs Attention count, so an unrelated issue (a certificate warning, a site error) no longer turns the whole panel red.
|
||||||
|
- Hosted-site, proxy, and upstream-health attention items are now clickable, linking to the Hosted/Proxies list — previously only certificate and drift items had a click target.
|
||||||
|
- The Configuration drift attention tile now has its own inline "Resync now" button, instead of requiring a click-through to Gateway Defaults to find the same action.
|
||||||
|
- Fixed a false-positive drift bug: the drift check compared `JSON.stringify()` output directly, which is sensitive to key order — two semantically identical configs could register as "drifted" solely because Caddy serialized their keys differently. Replaced with an order-independent comparison. Drift detection now also logs a Gateway Events entry on first detection (not on every repeated check), so a future report of drift reappearing can be confirmed against a timestamp instead of guesswork.
|
||||||
|
- Toast notifications no longer render hidden/blurred behind an open dialog (missing `z-index`, and an open `<dialog>` renders above normal page content by default).
|
||||||
|
- The Top Paths popout now states it's showing the top 10, matching the existing server-side cap.
|
||||||
|
- The Runtime/System dashboard panel's top accent bar changed from a stray `--blue` token to `--green`, matching the default accent already used by every other dashboard tile.
|
||||||
|
|
||||||
## Product direction
|
## Product direction
|
||||||
|
|
||||||
@@ -23,8 +53,9 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
|
- Local users with Administrator and Standard User roles, account lifecycle controls (disable/archive/restore).
|
||||||
- Groups, used to grant Access List membership without managing users one by one.
|
- Groups, used to grant Access List membership without managing users one by one.
|
||||||
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
|
- Access Lists combining accounts, groups, and IP/CIDR network rules behind a themed sign-in page.
|
||||||
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it.
|
- Optional two-factor authentication (TOTP) with a self-service My Account view for enrolling and managing it, plus an administrator-side override (Administration → Users → “•••” → Disable 2FA) for a user who's locked out with no recovery codes left. Logged to the Audit log.
|
||||||
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
|
- First-time setup flow that finalizes the persistent administrator account from bootstrap credentials.
|
||||||
|
- REST API with admin-issued bearer tokens (full or read-only scope), bound to the issuing user’s session version so a password reset or deactivation revokes them automatically.
|
||||||
|
|
||||||
### Certificates and TLS
|
### Certificates and TLS
|
||||||
|
|
||||||
@@ -36,27 +67,35 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
|
|
||||||
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
|
- Live dashboard health for the gateway, HTTP, HTTPS, and storage, plus hosted/proxy/certificate counts and throughput.
|
||||||
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
|
- System panel: uptime, memory, persistent-data size, disk space, installed app/Caddy versions, public IP.
|
||||||
- Performance view with request throughput, response times, and per-route breakdowns.
|
- Performance view with request throughput, response times, per-route breakdowns, p95 latency, bandwidth, and unique-visitor columns, a 4xx/5xx-colored error breakdown, a top-10-paths-per-host popout, and a slowest-requests panel — the host filter applies to the throughput table as well as the trend chart, and average response times display in seconds once they pass 1000ms.
|
||||||
|
- A read-only "View Caddy config" popout on Hosted Sites, Proxy Hosts, and Redirect Hosts, showing the exact Caddyfile block generated for that route, built from the same code path that generates the real deployed config so it can never drift from what’s shown.
|
||||||
|
- Dashboard tile colors are unified around a shared green baseline that reacts to warning/danger states, matching the existing Needs Attention tile’s behavior.
|
||||||
|
- Configuration drift detection compares Caddy’s live configuration against the saved routes every 10 minutes, flags a Needs Attention item with a one-click inline "Resync now" action, and logs a Gateway Events entry the first time drift is detected.
|
||||||
- Rotating access and activity logs.
|
- Rotating access and activity logs.
|
||||||
- Update-available banner when a newer image is deployed.
|
- Update-available banner when a newer image is deployed.
|
||||||
|
- A redacted support-report export exists (version, config health, certificate readiness, upstream checks, recent events) but its UI entry point is currently hidden pending a readability rewrite of the report's output format.
|
||||||
|
|
||||||
### Data and operations
|
### Data and operations
|
||||||
|
|
||||||
- Built-in SQLite persistence at `/data/database/site-gateway.sqlite` — no external database container.
|
- Built-in SQLite persistence at `/data/database/site-gateway.sqlite` — no external database container.
|
||||||
- Configuration and Complete backups, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted; pre-restore safety backups and configuration validation before activation.
|
- Configuration and Complete backups, downloadable, importable, schedulable, and optionally AES-256-GCM encrypted; pre-restore safety backups and configuration validation before activation.
|
||||||
- PUID/PGID-aware startup for Unraid and ZimaOS-style permission models.
|
- PUID/PGID-aware startup for Unraid and ZimaOS-style permission models.
|
||||||
|
- A durable, database-backed history of every backup, restore, and deletion attempt, shown as a human-readable timeline.
|
||||||
|
- An opt-in Docker container picker (gated on the Docker socket being mounted and readable) for choosing Proxy/Streaming targets from the host’s running containers instead of typing them by hand.
|
||||||
|
|
||||||
### Brand and docs
|
### Brand and docs
|
||||||
|
|
||||||
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
- Current icon and wordmark (v0.11.99) used consistently across the login screen, sidebar, themed default pages, and this README.
|
||||||
- Integrated, searchable in-app documentation covering every configurable field, including 2FA and the update-notification banner.
|
- A sitewide design-token system (colors, spacing, radius, and type scale defined once and reused everywhere) underpins the interface, so new UI stays visually consistent by default.
|
||||||
|
- Integrated, searchable in-app documentation covering every configurable field, including 2FA (self-service and the administrator override) and the update-notification banner.
|
||||||
|
- Toast notifications are color-coded — error toasts render distinctly from success/neutral ones, using the same token-driven theming as the rest of the interface.
|
||||||
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
|
- Companion marketing site with an installation guide covering Docker Compose, plain `docker run`, and Unraid.
|
||||||
|
|
||||||
## What's next
|
## What's next
|
||||||
|
|
||||||
Roughly in priority order:
|
Roughly in priority order:
|
||||||
|
|
||||||
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain, plus a redacted support-report export.
|
- **Richer certificate diagnostics** — on-demand checks that distinguish DNS, inbound port, TLS, and upstream failures per domain.
|
||||||
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
- **Wildcard/DNS-challenge certificates** — selected DNS-provider integrations for domains that can't use HTTP-01 validation. Needs encrypted secret storage for provider API credentials before it ships.
|
||||||
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
- **Browsable backup/restore history** — today a restore validates and rolls back safely, but there's no UI history of past backups beyond what's on disk.
|
||||||
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
- **Container picker for Proxy/Streaming targets** — letting a target be selected from a list of running Docker containers instead of typed as an IP/hostname, gated behind an opt-in Docker-socket mount since it needs real access to the Engine API. Also needs a shared Docker network between Site Gateway and the target container to actually be reachable, not just discoverable.
|
||||||
|
|||||||
@@ -32,5 +32,11 @@ services:
|
|||||||
# - "25565:25565/udp"
|
# - "25565:25565/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
|
- ${SITE_GATEWAY_DATA:-/DATA/AppData/site-gateway}:/data
|
||||||
|
# Optional: enables "Pick from running containers" for Proxy and Streaming
|
||||||
|
# host targets (Administration > Gateway defaults > Docker container selection).
|
||||||
|
# Read-only, but be deliberate: access to the Docker socket is effectively root
|
||||||
|
# on the host -- anything that can talk to it can start privileged containers and
|
||||||
|
# mount the host filesystem. Leave this commented out unless you want the feature.
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
|
|||||||
@@ -56,3 +56,9 @@ services:
|
|||||||
# - "25565:25565/udp"
|
# - "25565:25565/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
# Optional: enables "Pick from running containers" for Proxy and Streaming
|
||||||
|
# host targets (Administration > Gateway defaults > Docker container selection).
|
||||||
|
# Read-only, but be deliberate: access to the Docker socket is effectively root
|
||||||
|
# on the host -- anything that can talk to it can start privileged containers and
|
||||||
|
# mount the host filesystem. Leave this commented out unless you want the feature.
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.11.107",
|
"version": "0.15.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
"description": "Site Gateway: simple self-hosted website publishing, reverse proxying, and automatic HTTPS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+217
-52
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// app.js -- core client application: state, API helper, theme, dashboard,
|
// app.js -- core client application: state, API helper, dashboard,
|
||||||
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
|
// Hosted Sites & Proxy Hosts rendering, routing between views, dialogs (create/
|
||||||
// edit/icon/user/account/MFA), and every event listener for those areas. The
|
// edit/icon/user/account/MFA), and every event listener for those areas. The
|
||||||
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
|
// remaining views (Streaming, Redirects, Access Lists, Administration panels)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
// --- Shared DOM shortcut and app state ----------------------------------------
|
// --- Shared DOM shortcut and app state ----------------------------------------
|
||||||
const $ = selector => document.querySelector(selector);
|
const $ = selector => document.querySelector(selector);
|
||||||
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false };
|
const state = { sites: [], proxies: [], redirects: [], streams: [], accessLists: [], groups: [], backups: [], settings: null, dashboard: null, certificates: null, readiness: null, logs: null, users: [], user: null, config: null, view: "overview", loaded: false, pendingDelete: null, pendingReplace: null, editing: null, iconTarget: null, passwordTarget: null, healthTimer: null, updateCheckTimer: null, loadedVersion: null, updateAvailable: false, performanceErrorBreakdowns: {}, performanceTopPaths: {}, performancePoints: [], performanceCoords: [] };
|
||||||
|
|
||||||
// One-time DOM patches: move the Access List field into the create/settings
|
// One-time DOM patches: move the Access List field into the create/settings
|
||||||
// forms (features.js owns the Access List data, this file owns these forms).
|
// forms (features.js owns the Access List data, this file owns these forms).
|
||||||
@@ -19,19 +19,6 @@ const proxyAccessLabel = document.querySelector("#proxy-form [name=accessListId]
|
|||||||
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
|
const settingsAccessLabel = document.querySelector("#settings-access-list")?.closest("label"); const settingsTlsLabel = document.querySelector("#settings-form [name=tls]")?.closest("label"); if (settingsAccessLabel && settingsTlsLabel) settingsTlsLabel.before(settingsAccessLabel);
|
||||||
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
|
document.querySelector("#settings-advanced [name=accessListId]")?.closest("label")?.remove();
|
||||||
|
|
||||||
// --- Theme (light/dark/system) -------------------------------------------------
|
|
||||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
|
|
||||||
function applyTheme(preference) {
|
|
||||||
const effective = preference === "system" ? (systemTheme.matches ? "dark" : "light") : preference;
|
|
||||||
document.documentElement.dataset.theme = effective;
|
|
||||||
document.querySelector('meta[name="theme-color"]').content = effective === "dark" ? "#08101d" : "#f3f6fa";
|
|
||||||
}
|
|
||||||
const savedTheme = localStorage.getItem("webserver-theme") || "system";
|
|
||||||
$("#theme-select").value = savedTheme; applyTheme(savedTheme);
|
|
||||||
$("#theme-select").addEventListener("change", event => { localStorage.setItem("webserver-theme", event.target.value); applyTheme(event.target.value); });
|
|
||||||
systemTheme.addEventListener("change", () => { if ($("#theme-select").value === "system") applyTheme("system"); });
|
|
||||||
|
|
||||||
// --- API helper ------------------------------------------------------------------
|
// --- API helper ------------------------------------------------------------------
|
||||||
|
|
||||||
async function api(url, options = {}) {
|
async function api(url, options = {}) {
|
||||||
@@ -44,7 +31,7 @@ async function api(url, options = {}) {
|
|||||||
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
|
// --- Login/dashboard shell, toast, and small formatting helpers ------------------
|
||||||
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
function showLogin(message = "") { state.user = null; state.users = []; state.view = "overview"; const form = $("#login-form"); form.reset(); form.elements.username.value = ""; form.elements.password.value = ""; $("#login").classList.remove("hidden"); $("#dashboard").classList.add("hidden"); $("#login-error").textContent = message; $("#mfa-login-form").reset(); $("#mfa-login-form").classList.add("hidden"); $("#login-form").classList.remove("hidden"); $("#mfa-login-error").textContent = ""; setTimeout(() => form.elements.username.focus(), 0); }
|
||||||
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
function showDashboard() { $("#login").classList.add("hidden"); $("#dashboard").classList.remove("hidden"); }
|
||||||
function toast(message) { const el = $("#toast"); el.textContent = message; el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
function toast(message, type = "success") { const el = $("#toast"); el.textContent = message; el.classList.toggle("toast-error", type === "error"); el.classList.add("show"); setTimeout(() => el.classList.remove("show"), 2800); }
|
||||||
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
function escapeHtml(value) { const el = document.createElement("div"); el.textContent = value ?? ""; return el.innerHTML; }
|
||||||
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
|
function publicUrl(item) { return item.domain ? `${item.tls === "http" ? "http" : "https"}://${item.domain}` : `${location.protocol}//${location.hostname}:${item.port}`; }
|
||||||
|
|
||||||
@@ -161,6 +148,33 @@ function probeCopy(service, ready, error, unconfigured = "Not configured") {
|
|||||||
|
|
||||||
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
function renderDashboardJobs(system) { const columns = document.querySelector("#dashboard-view .dashboard-columns"), health = columns?.firstElementChild; if (!columns) return; let panel = document.querySelector("#dashboard-jobs"); if (!panel) { panel = document.createElement("section"); panel.id = "dashboard-jobs"; panel.className = "dashboard-panel dashboard-jobs-panel"; columns.insertBefore(panel, columns.children[1] || null); } if (health && health.parentElement === columns) columns.parentElement.insertBefore(health, columns); panel.innerHTML = `<div class="panel-heading"><div><p class="eyebrow">Operations</p><h2>Scheduled jobs</h2></div></div><div class="dashboard-jobs-list">${(system.jobs || []).map(job => `<div class="dashboard-list-item"><span class="status-dot ${job.enabled ? "running" : "idle"}"></span><span><strong>${escapeHtml(job.name)}</strong><small>${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}</small></span></div>`).join("")}</div>`; }
|
||||||
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
|
function updateDashboardUptime(seconds) { const started = window.__dashboardStartedAt || (window.__dashboardStartedAt = Date.now() - Number(seconds || 0) * 1000); const target = document.querySelector("#system-uptime"); if (!target) return; const elapsed = Math.max(0, Math.floor((Date.now() - started) / 1000)); target.textContent = formatDuration(elapsed); }
|
||||||
|
// Dashboard tiles share one baseline accent (green) and switch to the existing
|
||||||
|
// --warning / --danger tokens when the thing they count is actually in trouble --
|
||||||
|
// the same mechanism the "Needs attention" chip already used.
|
||||||
|
const TILE_ACCENT_CLASSES = ["accent-green", "accent-blue", "accent-amber", "accent-purple", "accent-warning", "accent-danger"];
|
||||||
|
function setTileAccent(valueId, level) {
|
||||||
|
const tile = $(valueId)?.closest(".metric-card, .metric-chip");
|
||||||
|
if (!tile) return;
|
||||||
|
tile.classList.remove(...TILE_ACCENT_CLASSES);
|
||||||
|
tile.classList.add(level === "danger" ? "accent-danger" : level === "warning" ? "accent-warning" : "accent-green");
|
||||||
|
}
|
||||||
|
function applyTileAccents(data) {
|
||||||
|
const group = value => !value?.total || !value.errors ? "green" : value.errors >= value.total ? "danger" : "warning";
|
||||||
|
setTileAccent("#dash-hosted-total", group(data.hosted));
|
||||||
|
const upstreams = data.upstreams || { total: 0, unhealthy: 0 };
|
||||||
|
const proxyLevel = group(data.proxies);
|
||||||
|
setTileAccent("#dash-proxy-total", proxyLevel !== "green" ? proxyLevel : upstreams.unhealthy > 0 ? (upstreams.unhealthy >= upstreams.total ? "danger" : "warning") : "green");
|
||||||
|
const certificates = data.certificates || {};
|
||||||
|
const certificateLevel = (certificates.expired || 0) + (certificates.mismatch || 0) > 0 ? "danger" : (certificates.warning || 0) + (certificates.critical || 0) > 0 ? "warning" : "green";
|
||||||
|
setTileAccent("#dash-tls-total", certificateLevel);
|
||||||
|
// Redirect hosts have no runtime failure state of their own, so they stay on the baseline.
|
||||||
|
setTileAccent("#dash-redirect-total", "green");
|
||||||
|
const streaming = data.streamingPorts || { total: 0, listening: 0 };
|
||||||
|
setTileAccent("#dash-stream-total", !streaming.total || streaming.listening === streaming.total ? "green" : streaming.listening === 0 ? "danger" : "warning");
|
||||||
|
// Throughput is a rate, not a health signal: there is no "bad" value to react to.
|
||||||
|
setTileAccent("#dash-throughput-total", "green");
|
||||||
|
}
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
const data = state.dashboard; if (!data) return;
|
const data = state.dashboard; if (!data) return;
|
||||||
if (data.system) renderDashboardJobsSafe(data.system);
|
if (data.system) renderDashboardJobsSafe(data.system);
|
||||||
@@ -174,11 +188,13 @@ function renderDashboard() {
|
|||||||
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
$("#dash-stream-total").textContent = state.streams?.length || 0;
|
||||||
$("#dash-attention-total").textContent = data.attention.length;
|
$("#dash-attention-total").textContent = data.attention.length;
|
||||||
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
|
$("#dash-attention-detail").textContent = data.attention.length ? `${data.attention.length} item${data.attention.length === 1 ? "" : "s"} to review` : "No current issues";
|
||||||
|
applyTileAccents(data);
|
||||||
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
|
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
|
||||||
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
|
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
|
||||||
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
|
$("#dash-attention-icon").textContent = data.attention.length > 0 ? "!" : "✓";
|
||||||
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
|
$("#dash-throughput-total").textContent = data.throughput?.liveRequests ?? 0;
|
||||||
const hasErrors = data.attention.length > 0, isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
|
const panelStreaming = data.streamingPorts || { total: 0, listening: 0 }, panelUpstreams = data.upstreams || { total: 0, healthy: 0, unhealthy: 0 };
|
||||||
|
const hasErrors = data.gateway.status === "error" || data.services.http.status === "error" || data.services.https.status === "error" || !data.services.storage.healthy || (panelStreaming.total > 0 && panelStreaming.listening !== panelStreaming.total) || (panelUpstreams.total > 0 && panelUpstreams.unhealthy > 0), isChecking = [data.gateway, data.services.http, data.services.https].some(service => service.status === "checking"), hasNothingRunning = !data.hosted.running && !data.proxies.running;
|
||||||
const overall = $("#overall-health");
|
const overall = $("#overall-health");
|
||||||
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
|
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "healthy"}`;
|
||||||
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
|
overall.textContent = hasErrors ? "Needs attention" : isChecking ? "Checking" : hasNothingRunning ? "Idle" : "Healthy";
|
||||||
@@ -211,7 +227,10 @@ function renderDashboard() {
|
|||||||
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
|
$("#system-public-ip-detail").textContent = data.system.publicIpError ? `Check failed · ${data.system.publicIpError}` : data.system.publicIpCheckedAt ? `Checked ${formatTime(data.system.publicIpCheckedAt)}` : "Not yet checked";
|
||||||
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
|
$("#attention-panel").classList.toggle("is-clear", data.attention.length === 0);
|
||||||
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
|
$("#dashboard-lower-columns").classList.toggle("attention-clear", data.attention.length === 0);
|
||||||
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
|
$("#attention-list").innerHTML = data.attention.length ? data.attention.map(item => item.kind === "drift"
|
||||||
|
? `<div class="attention-tile drift-tile"><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span><button type="button" class="button secondary" data-drift-resync>Resync now</button></div>`
|
||||||
|
: `<${item.target ? "button" : "div"} class="attention-tile ${item.target ? "issue-link" : ""}" ${item.target ? `data-issue-target="${escapeHtml(item.target)}"` : ""}><span class="status-dot error"></span><span class="attention-copy"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.message)}</small></span></${item.target ? "button" : "div"}>`
|
||||||
|
).join("") : '<div class="all-clear"><span class="status-dot running"></span><span>Everything looks good — no issues to review.</span></div>';
|
||||||
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
$("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `<div class="activity-tile"><span class="activity-mark ${item.status === "error" ? "bad" : item.status === "warning" ? "warn" : ""}">${item.status === "error" || item.status === "warning" ? "!" : "✓"}</span><span class="activity-copy"><strong>${escapeHtml(item.message)}</strong><small title="${escapeHtml(formatTime(item.at))}">${escapeHtml(formatRelativeTime(item.at))}</small></span></div>`).join("") : '<p class="quiet-state">No recent activity.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,14 +253,14 @@ function canAdmin() { return state.user?.role === "administrator"; }
|
|||||||
function hostedCard(site) {
|
function hostedCard(site) {
|
||||||
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
|
const status = site.status === "running" ? "running" : site.status === "error" ? "error" : "disabled";
|
||||||
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
|
const upstream = !site.enabled || site.upstream?.status === "unmonitored" ? "Monitoring paused" : !site.upstream || site.upstream.status === "pending" ? "Upstream check pending" : site.upstream.status === "healthy" ? `Upstream ${site.upstream.httpStatus} · ${site.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(site.upstream.error || "check failed")}`;
|
||||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Site options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Domain & TLS</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="replace">Replace files</button><button data-action="delete" class="danger-text">Delete site</button></div></div>` : "";
|
||||||
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
|
const toggle = canManage() ? `<button class="toggle ${site.enabled ? "on" : ""}" data-action="toggle" aria-label="${site.enabled ? "Disable" : "Enable"} ${escapeHtml(site.name)}"><span></span></button>` : "";
|
||||||
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
return `<article class="site-card" data-id="${site.id}" data-kind="hosted"><div class="card-top"><div class="site-icon">${iconMarkup(site)}</div>${menu}</div><h2>${escapeHtml(site.name)}</h2><p class="address">${escapeHtml(site.domain || `Port ${site.port}`)}</p>${site.domain ? `<p class="gateway-address ${site.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(site))}</p>` : ""}<p class="upstream-copy ${site.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(site)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(site.name)}">↗</a></div></div></article>`;
|
||||||
}
|
}
|
||||||
function proxyCard(proxy) {
|
function proxyCard(proxy) {
|
||||||
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
|
const status = proxy.status === "running" ? "running" : proxy.status === "error" ? "error" : "disabled";
|
||||||
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
|
const upstream = !proxy.enabled || proxy.upstream?.status === "unmonitored" ? "Monitoring paused" : !proxy.upstream || proxy.upstream.status === "pending" ? "Upstream check pending" : proxy.upstream.status === "healthy" ? `Upstream ${proxy.upstream.httpStatus} · ${proxy.upstream.responseMs} ms` : `Upstream unavailable · ${escapeHtml(proxy.upstream.error || "check failed")}`;
|
||||||
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
const menu = canManage() ? `<div class="menu-wrap"><button class="icon-button menu-button" aria-label="Proxy options" aria-expanded="false">•••</button><div class="menu"><button data-action="settings">Edit proxy</button><button data-action="icon">Change icon</button><button data-action="caddy-config">View Caddy config</button><button data-action="delete" class="danger-text">Delete proxy</button></div></div>` : "";
|
||||||
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
|
const toggle = canManage() ? `<button class="toggle ${proxy.enabled ? "on" : ""}" data-action="toggle" aria-label="${proxy.enabled ? "Disable" : "Enable"} ${escapeHtml(proxy.name)}"><span></span></button>` : "";
|
||||||
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
|
const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List";
|
||||||
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
return `<article class="site-card proxy" data-id="${proxy.id}" data-kind="proxy"><div class="card-top"><div class="site-icon">${iconMarkup(proxy)}</div>${menu}</div><h2>${escapeHtml(proxy.name)}</h2><p class="address">${escapeHtml(proxy.target)}</p><p class="gateway-address ${proxy.tls !== "http" ? "secure" : ""}">${escapeHtml(publicUrl(proxy))}</p><p class="upstream-copy ${proxy.upstream?.status === "unhealthy" ? "bad" : ""}">${upstream}</p><p class="access-summary">${escapeHtml(access)}</p><div class="card-footer"><span class="status-pill"><span class="status-dot ${status}"></span>${status === "error" ? "Needs attention" : status[0].toUpperCase() + status.slice(1)}</span><div class="card-actions">${toggle}<a class="launch" href="${publicUrl(proxy)}" target="_blank" rel="noopener" aria-label="Open ${escapeHtml(proxy.name)}">↗</a></div></div></article>`;
|
||||||
@@ -292,6 +311,20 @@ function renderLogs() {
|
|||||||
|
|
||||||
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
|
// --- Performance view: summary, request trend chart (hand-drawn SVG sparkline),
|
||||||
// and the per-domain throughput table -----------------------------------------------
|
// and the per-domain throughput table -----------------------------------------------
|
||||||
|
// Clock-boundary label spacing per selected range (hours -> minutes between labels).
|
||||||
|
const PERFORMANCE_LABEL_MINUTES = { 1: 15, 3: 30, 6: 60, 12: 120, 24: 180, 72: 720, 168: 1440 };
|
||||||
|
function formatChartTime(value, intervalMinutes) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
if (intervalMinutes >= 1440) return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
if (intervalMinutes >= 720) return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric" });
|
||||||
|
return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
function formatLatency(ms) { return ms == null ? "—" : ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; }
|
||||||
|
|
||||||
|
// Geometry shared by the chart renderer and the hover tooltip.
|
||||||
|
const PERFORMANCE_CHART = { left: 34, right: 8, top: 10, bottom: 20, width: 600, height: 140 };
|
||||||
|
|
||||||
function renderPerformance() {
|
function renderPerformance() {
|
||||||
const data = state.performance; if (!data) return;
|
const data = state.performance; if (!data) return;
|
||||||
const selected = $("#performance-host").value;
|
const selected = $("#performance-host").value;
|
||||||
@@ -301,9 +334,11 @@ function renderPerformance() {
|
|||||||
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
|
$("#performance-summary").innerHTML = `${data.liveRequests} request${data.liveRequests === 1 ? "" : "s"} in the last minute across ${label} · <span id="performance-last-checked">Checked ${escapeHtml(formatTime(data.checkedAt))}</span>`;
|
||||||
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
|
const rangeLabel = $("#performance-range").selectedOptions[0]?.textContent || "Last 6 hours";
|
||||||
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
|
$("#performance-trend-title").textContent = `Requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
|
||||||
|
$("#performance-slowest-title").textContent = `Slowest requests · ${rangeLabel.toLowerCase()}${selected ? ` · ${selected}` : ""}`;
|
||||||
const points = data.trend || [];
|
const points = data.trend || [];
|
||||||
|
state.performancePoints = points;
|
||||||
const max = Math.max(1, ...points.map(point => point.count));
|
const max = Math.max(1, ...points.map(point => point.count));
|
||||||
const left = 34, right = 8, top = 10, bottom = 20, width = 600, height = 140;
|
const { left, right, top, bottom, width, height } = PERFORMANCE_CHART;
|
||||||
const plotWidth = width - left - right, plotHeight = height - top - bottom;
|
const plotWidth = width - left - right, plotHeight = height - top - bottom;
|
||||||
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
|
const xAt = index => left + (points.length > 1 ? (index / (points.length - 1)) * plotWidth : plotWidth);
|
||||||
const yAt = count => top + plotHeight - (count / max) * plotHeight;
|
const yAt = count => top + plotHeight - (count / max) * plotHeight;
|
||||||
@@ -312,16 +347,34 @@ function renderPerformance() {
|
|||||||
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
|
const y = (top + plotHeight * (1 - fraction)).toFixed(1);
|
||||||
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
|
return `<line x1="${left}" y1="${y}" x2="${width - right}" y2="${y}" stroke="var(--line)" stroke-width="1" />`;
|
||||||
}).join("");
|
}).join("");
|
||||||
const leftPct = (left / width) * 100, topPct = 0, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
|
const leftPct = (left / width) * 100, plotWidthPct = (plotWidth / width) * 100, plotHeightPct = (plotHeight / height) * 100, topInsetPct = (top / height) * 100;
|
||||||
const axisLabels = gridFractions.map(fraction => {
|
const axisLabels = gridFractions.map(fraction => {
|
||||||
const value = Math.round(max * fraction);
|
const value = Math.round(max * fraction);
|
||||||
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
|
const yPct = topInsetPct + plotHeightPct * (1 - fraction);
|
||||||
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
|
return `<span class="axis-label" style="left:0;width:${(leftPct - 2).toFixed(2)}%;top:${yPct.toFixed(2)}%;text-align:right">${value}</span>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
const firstPoint = points[0], lastPoint = points[points.length - 1];
|
// Time axis: labels land on real clock boundaries scaled to the selected range, and the
|
||||||
const timeLabels = points.length ? `<span class="time-label" style="left:${leftPct.toFixed(2)}%">${escapeHtml(formatTime(firstPoint.at))}</span><span class="time-label time-label-end" style="left:${(100 - (right / width) * 100).toFixed(2)}%">${escapeHtml(formatTime(lastPoint.at))}</span>` : "";
|
// true first and last sample are always labelled so the window's edges stay readable.
|
||||||
|
const hours = Number($("#performance-range").value) || 6;
|
||||||
|
const intervalMinutes = PERFORMANCE_LABEL_MINUTES[hours] || 60;
|
||||||
|
const intervalMs = intervalMinutes * 60000;
|
||||||
|
let timeLabels = "";
|
||||||
|
if (points.length) {
|
||||||
|
const candidates = new Set([0, points.length - 1]);
|
||||||
|
const aligned = [];
|
||||||
|
points.forEach((point, index) => { const time = new Date(point.at).getTime(); if (!Number.isNaN(time) && time % intervalMs === 0) aligned.push(index); });
|
||||||
|
const stride = Math.max(1, Math.ceil(aligned.length / 8));
|
||||||
|
aligned.forEach((index, position) => { if (position % stride === 0) candidates.add(index); });
|
||||||
|
const ordered = [...candidates].sort((a, b) => a - b);
|
||||||
|
timeLabels = ordered.map(index => {
|
||||||
|
const leftEdge = leftPct + (points.length > 1 ? (index / (points.length - 1)) * plotWidthPct : plotWidthPct);
|
||||||
|
const alignment = index === 0 ? "" : index === points.length - 1 ? " time-label-end" : " time-label-mid";
|
||||||
|
return `<span class="time-label${alignment}" style="left:${leftEdge.toFixed(2)}%">${escapeHtml(formatChartTime(points[index].at, intervalMinutes))}</span>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
|
$("#performance-sparkline-labels").innerHTML = points.length ? `${axisLabels}${timeLabels}` : "";
|
||||||
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
|
const coords = points.map((point, index) => [xAt(index), yAt(point.count)]);
|
||||||
|
state.performanceCoords = coords;
|
||||||
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
|
const smoothLine = coords.length < 2 ? "" : coords.reduce((d, point, index) => {
|
||||||
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
|
if (index === 0) return `M${point[0].toFixed(1)},${point[1].toFixed(1)}`;
|
||||||
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
|
const p0 = coords[index - 2 >= 0 ? index - 2 : index - 1];
|
||||||
@@ -335,14 +388,61 @@ function renderPerformance() {
|
|||||||
const baseline = (top + plotHeight).toFixed(1);
|
const baseline = (top + plotHeight).toFixed(1);
|
||||||
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
|
const areaPath = coords.length ? `${smoothLine} L${coords[coords.length - 1][0].toFixed(1)},${baseline} L${coords[0][0].toFixed(1)},${baseline} Z` : "";
|
||||||
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
|
$("#performance-sparkline").setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />` : "";
|
$("#performance-sparkline").innerHTML = points.length ? `${gridLines}<path d="${areaPath}" fill="var(--green)" opacity="0.12" stroke="none" /><path d="${smoothLine}" fill="none" stroke="var(--green)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse id="performance-hover-dot" class="hidden" cx="0" cy="0" rx="3" ry="3" fill="var(--green)" stroke="var(--panel)" stroke-width="1.5" />` : "";
|
||||||
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
|
if (!points.length) $("#performance-sparkline-labels").innerHTML = '<span class="axis-label" style="left:0;width:100%;top:45%;text-align:center">No request data for this window yet.</span>';
|
||||||
const routes = data.routes || [];
|
hidePerformanceTooltip();
|
||||||
const countCell = (count, errors, breakdown) => { const title = breakdown?.length ? ` title="${escapeHtml(breakdown.map(item => `${item.status}: ${item.count.toLocaleString()}`).join(" · "))}"` : ""; return `${count.toLocaleString()}${errors ? ` <span class="count-divider">·</span> <span class="http-status bad"${title}>${errors.toLocaleString()}</span>` : ""}`; };
|
const routes = (data.routes || []).filter(route => !selected || route.host === selected);
|
||||||
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown)}</td><td>${route.dayAvgMs == null ? "—" : `${route.dayAvgMs} ms`}</td></tr>`).join("") : '<tr><td colspan="4" class="quiet-state">No requests have been logged yet.</td></tr>';
|
state.performanceErrorBreakdowns = {};
|
||||||
|
state.performanceTopPaths = {};
|
||||||
|
const countCell = (count, errors, breakdown, host) => { if (!errors) return `${count.toLocaleString()}`; if (!breakdown?.length) return `${count.toLocaleString()} <span class="count-divider">·</span> <span class="http-status bad">${errors.toLocaleString()}</span>`; state.performanceErrorBreakdowns[host] = { total: errors, breakdown }; return `${count.toLocaleString()} <span class="count-divider">·</span> <button type="button" class="http-status bad count-link-button" data-error-host="${escapeHtml(host)}">${errors.toLocaleString()}</button>`; };
|
||||||
|
const pathsCell = route => { if (!route.topPaths?.length) return "—"; state.performanceTopPaths[route.host] = route.topPaths; return `<button type="button" class="count-link-button neutral" data-paths-host="${escapeHtml(route.host)}">View</button>`; };
|
||||||
|
$("#performance-rows").innerHTML = routes.length ? routes.map(route => `<tr class="${selected && route.host === selected ? "row-highlight" : ""}"><td title="${escapeHtml(route.host)}">${escapeHtml(route.host)}</td><td>${countCell(route.hourRequests, route.hourErrors)}</td><td>${countCell(route.dayRequests, route.dayErrors, route.errorBreakdown, route.host)}</td><td>${formatLatency(route.dayAvgMs)}</td><td>${formatLatency(route.dayP95Ms)}</td><td>${route.dayBytes ? escapeHtml(formatBytes(route.dayBytes)) : "—"}</td><td>${(route.dayVisitors || 0).toLocaleString()}</td><td>${pathsCell(route)}</td></tr>`).join("") : '<tr><td colspan="8" class="quiet-state">No requests have been logged yet.</td></tr>';
|
||||||
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
if (selected) $(`#performance-rows tr.row-highlight`)?.scrollIntoView({ block: "nearest" });
|
||||||
|
renderSlowestRequests(data.slowest || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Performance: slowest individual requests -------------------------------------------
|
||||||
|
function renderSlowestRequests(entries) {
|
||||||
|
const list = $("#performance-slowest"); if (!list) return;
|
||||||
|
list.innerHTML = entries.length ? entries.map(entry => `<div class="activity-tile"><span class="slowest-copy"><strong title="${escapeHtml(`${entry.method || ""} ${entry.uri || ""}`)}">${escapeHtml(entry.method || "GET")} ${escapeHtml(entry.uri || "/")}</strong><small>${escapeHtml(entry.host || "—")} · ${entry.status ?? "—"} · ${escapeHtml(formatTime(entry.at))}</small></span><span class="slowest-duration">${escapeHtml(formatLatency(entry.durationMs))}</span></div>`).join("") : '<p class="quiet-state">No timed requests in this window yet.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Performance: hover tooltip on the request-trend chart -------------------------------
|
||||||
|
function hidePerformanceTooltip() {
|
||||||
|
$("#performance-tooltip")?.classList.add("hidden");
|
||||||
|
document.querySelector("#performance-hover-dot")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
function showPerformanceTooltip(event) {
|
||||||
|
const svg = $("#performance-sparkline"), tooltip = $("#performance-tooltip"), points = state.performancePoints || [], coords = state.performanceCoords || [];
|
||||||
|
if (!svg || !tooltip || !points.length || !coords.length) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
const { left, right, width, height } = PERFORMANCE_CHART;
|
||||||
|
const plotWidth = width - left - right;
|
||||||
|
const viewX = ((event.clientX - rect.left) / rect.width) * width;
|
||||||
|
const fraction = Math.min(1, Math.max(0, (viewX - left) / plotWidth));
|
||||||
|
const index = Math.min(points.length - 1, Math.max(0, Math.round(fraction * (points.length - 1))));
|
||||||
|
const point = points[index], coordinate = coords[index];
|
||||||
|
const pixelX = (coordinate[0] / width) * rect.width;
|
||||||
|
const pixelY = (coordinate[1] / height) * rect.height;
|
||||||
|
tooltip.innerHTML = `<strong>${point.count.toLocaleString()} request${point.count === 1 ? "" : "s"}</strong><span class="tooltip-errors${point.errors ? "" : " none"}">${(point.errors || 0).toLocaleString()} error${point.errors === 1 ? "" : "s"}</span><br>${escapeHtml(formatTime(point.at))}`;
|
||||||
|
tooltip.style.left = `${pixelX}px`;
|
||||||
|
tooltip.style.top = `${pixelY}px`;
|
||||||
|
tooltip.classList.remove("hidden");
|
||||||
|
const dot = document.querySelector("#performance-hover-dot");
|
||||||
|
if (dot) {
|
||||||
|
// preserveAspectRatio="none" stretches the viewBox, so compensate to keep the dot round.
|
||||||
|
dot.setAttribute("cx", coordinate[0].toFixed(1));
|
||||||
|
dot.setAttribute("cy", coordinate[1].toFixed(1));
|
||||||
|
dot.setAttribute("rx", (3.5 * (width / rect.width)).toFixed(2));
|
||||||
|
dot.setAttribute("ry", (3.5 * (height / rect.height)).toFixed(2));
|
||||||
|
dot.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("#performance-sparkline")?.addEventListener("mousemove", showPerformanceTooltip);
|
||||||
|
$("#performance-sparkline")?.addEventListener("mouseleave", hidePerformanceTooltip);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// --- Administration > Users view ---------------------------------------------------------
|
// --- Administration > Users view ---------------------------------------------------------
|
||||||
function renderUsers() {
|
function renderUsers() {
|
||||||
@@ -357,11 +457,11 @@ function renderUsers() {
|
|||||||
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
|
const roleLabel = user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User";
|
||||||
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
|
const lifecycle = user.status === "archived" ? `<button class="button secondary" data-user-action="status" data-value="active">Restore</button>` : `<button class="button secondary danger-text" data-user-action="status" data-value="archived">Archive</button>`;
|
||||||
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
|
const statusToggle = user.status === "archived" ? "" : `<button class="toggle ${user.status === "active" ? "on" : ""}" data-user-action="status" data-value="${user.status === "active" ? "disabled" : "active"}" aria-label="${user.status === "active" ? "Disable" : "Enable"} ${escapeHtml(user.username)}"><span></span></button>`;
|
||||||
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
|
const menu = `<div class="menu-wrap"><button class="icon-button menu-button" type="button" aria-label="User options" aria-expanded="false">•••</button><div class="menu"><button data-user-action="icon">Change icon</button>${!isSelf && user.mfaEnabled ? `<button data-user-action="mfa-disable">Disable 2FA</button>` : ""}${!isSelf ? `<button data-user-action="delete" class="danger-text">Delete</button>` : ""}</div></div>`;
|
||||||
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
|
return `<article class="user-card" data-user-id="${user.id}"><div class="user-card-head"><div class="user-avatar">${escapeHtml(initials(user.displayName))}</div><div class="user-head-actions"><span class="status-pill"><span class="status-dot ${statusClass}"></span>${escapeHtml(user.status)}</span>${menu}</div></div><h2>${escapeHtml(user.displayName)}${isSelf ? ' <small>You</small>' : ""}</h2><p class="address">${escapeHtml(user.username)}</p><div class="user-meta"><span>${roleLabel}</span><span>${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}</span></div><div class="user-actions"><button class="button secondary" data-user-action="role" data-value="${roleAction}">Make ${roleAction === "administrator" ? "Administrator" : roleAction === "viewer" ? "Viewer" : "Standard"}</button><button class="button secondary" data-user-action="password">Reset password</button>${lifecycle}</div><div class="card-footer">${statusToggle}</div></article>`;
|
||||||
}).join("") : '<p class="quiet-state">No users found.</p>';
|
}).join("") : '<p class="quiet-state">No users found.</p>';
|
||||||
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
|
document.querySelectorAll("#user-list .user-card").forEach(card => { card.style.position = "relative"; card.style.minHeight = "250px"; card.style.paddingBottom = "64px"; const head = card.querySelector(".user-card-head"), status = head?.querySelector(".status-pill"), footer = card.querySelector(".card-footer"); if (!head || !footer) return; if (status) footer.prepend(status); });
|
||||||
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message); } }); old.replaceWith(select); });
|
document.querySelectorAll("#user-list .user-card").forEach(card => { const user = state.users.find(item => item.id === card.dataset.userId); const old = card.querySelector('[data-user-action="role"]'); if (!user || !old) return; const select = document.createElement("select"); select.className = "user-role-select"; select.setAttribute("aria-label", `Role for ${user.username}`); select.innerHTML = '<option value="administrator">Administrator</option><option value="standard">Standard User</option><option value="viewer">Viewer</option>'; select.value = user.role; select.addEventListener("change", async () => { try { await api(`/api/users/${user.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ role:select.value }) }); await loadFeatureView(); toast("User role updated."); } catch (error) { select.value = user.role; toast(error.message, "error"); } }); old.replaceWith(select); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +574,7 @@ async function boot() {
|
|||||||
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
if (!state.loadedVersion) state.loadedVersion = state.config.version;
|
||||||
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
$("#port-range").textContent = `${state.config.minPort}–${state.config.maxPort}`; $("#port-help").textContent = `Direct LAN access range: ${state.config.minPort}–${state.config.maxPort}`;
|
||||||
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
$("#create-form [name=port]").min = state.config.minPort; $("#create-form [name=port]").max = state.config.maxPort; await refresh(); if (state.view !== "overview") await loadFeatureView();
|
||||||
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message)); }, 30000);
|
if (!state.healthTimer) state.healthTimer = setInterval(() => { if (state.view === "overview" && !$("#dashboard").classList.contains("hidden")) refreshDashboard().catch(error => toast(error.message, "error")); }, 30000);
|
||||||
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
if (!state.updateCheckTimer) state.updateCheckTimer = setInterval(() => { if (!$("#dashboard").classList.contains("hidden")) checkForUpdate().catch(() => {}); }, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,20 +599,50 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
|
|||||||
|
|
||||||
// --- Dashboard actions: run certificate check, download support report, jump to
|
// --- Dashboard actions: run certificate check, download support report, jump to
|
||||||
// an attention item's view -------------------------------------------------------------
|
// an attention item's view -------------------------------------------------------------
|
||||||
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
|
$("#check-health").addEventListener("click", async event => { const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…"; try { const result = await api("/api/health/check", { method:"POST" }); state.dashboard = result.dashboard; state.certificates = result.certificates; state.readiness = { routes:result.readiness }; renderCertificates(); toast("Certificate and domain checks completed."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; button.textContent = "Run certificate check"; } });
|
||||||
$("#download-support").addEventListener("click", () => { location.href = "/api/support-report"; });
|
$("#download-support")?.addEventListener("click", () => { location.href = "/api/support-report"; });
|
||||||
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { state.view = target; render(); loadFeatureView().catch(error => toast(error.message)); } });
|
$("#attention-list").addEventListener("click", event => { const target = event.target.closest("[data-issue-target]")?.dataset.issueTarget; if (target) { const [view, adminTab] = target.split("/"); state.view = view; if (view === "administration" && adminTab) state.adminTab = adminTab; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
|
||||||
|
|
||||||
// --- Primary navigation (sidebar view switching) -------------------------------------------
|
// --- Primary navigation (sidebar view switching) -------------------------------------------
|
||||||
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
|
function closeMenus() { document.querySelectorAll(".menu-open").forEach(card => { card.classList.remove("menu-open"); card.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); }
|
||||||
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); } }));
|
document.querySelectorAll("nav, .aside-utilities, .brand").forEach(nav => nav.addEventListener("click", event => { const button = event.target.closest("[data-view]"); if (button) { closeMenus(); state.view = button.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); } }));
|
||||||
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message)); });
|
$("#dashboard-view").addEventListener("click", event => { const target = event.target.closest("[data-target], [data-view]"); if (!target) return; state.view = target.dataset.target || target.dataset.view; render(); loadFeatureView().catch(error => toast(error.message, "error")); });
|
||||||
|
|
||||||
// --- Logs & Performance filter controls -----------------------------------------------------
|
// --- Logs & Performance filter controls -----------------------------------------------------
|
||||||
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#refresh-logs").addEventListener("click", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#log-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#performance-host").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message)));
|
$("#performance-range").addEventListener("change", () => loadFeatureView().catch(error => toast(error.message, "error")));
|
||||||
|
|
||||||
|
// --- Performance: themed error-breakdown popup, replacing the old hover tooltip ------------
|
||||||
|
function showErrorBreakdown(host, total, breakdown) {
|
||||||
|
let dialog = document.querySelector("#error-breakdown-dialog");
|
||||||
|
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "error-breakdown-dialog"; document.body.append(dialog); }
|
||||||
|
// Client (4xx) and server (5xx) failures mean very different things, so they are grouped
|
||||||
|
// and coloured separately instead of appearing as one flat red list.
|
||||||
|
const client = breakdown.filter(item => Number(item.status) < 500), server = breakdown.filter(item => Number(item.status) >= 500);
|
||||||
|
const rowsFor = (items, kind) => items.map(item => `<div class="error-breakdown-row ${kind}"><span>${escapeHtml(item.status)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
|
||||||
|
const clientTotal = client.reduce((sum, item) => sum + item.count, 0), serverTotal = server.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
const sections = [
|
||||||
|
client.length ? `<p class="error-breakdown-group">Client errors · 4xx · ${clientTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(client, "client-error")}</div>` : "",
|
||||||
|
server.length ? `<p class="error-breakdown-group">Server errors · 5xx · ${serverTotal.toLocaleString()}</p><div class="error-breakdown-list">${rowsFor(server, "server-error")}</div>` : ""
|
||||||
|
].join("");
|
||||||
|
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">${total.toLocaleString()} error response${total === 1 ? "" : "s"} in the last 24 hours, by status code.</p>${sections || '<p class="quiet-state">No status codes recorded.</p>'}<div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
function showTopPaths(host, paths) {
|
||||||
|
let dialog = document.querySelector("#top-paths-dialog");
|
||||||
|
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "top-paths-dialog"; document.body.append(dialog); }
|
||||||
|
const rows = paths.map(item => `<div class="top-paths-row"><span title="${escapeHtml(item.uri)}">${escapeHtml(item.uri)}</span><span>${item.count.toLocaleString()}</span></div>`).join("");
|
||||||
|
dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Performance · Last 24h</p><h2>${escapeHtml(host)}</h2></div></div><p class="muted">The top 10 most requested paths on this domain in the last 24 hours.</p><div class="top-paths-list">${rows || '<div class="top-paths-row"><span>No requests recorded.</span><span>0</span></div>'}</div><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
$("#performance-rows").addEventListener("click", event => {
|
||||||
|
const errorButton = event.target.closest("[data-error-host]");
|
||||||
|
if (errorButton) { const entry = state.performanceErrorBreakdowns[errorButton.dataset.errorHost]; if (entry) showErrorBreakdown(errorButton.dataset.errorHost, entry.total, entry.breakdown); return; }
|
||||||
|
const pathsButton = event.target.closest("[data-paths-host]");
|
||||||
|
if (pathsButton) { const paths = state.performanceTopPaths?.[pathsButton.dataset.pathsHost]; if (paths) showTopPaths(pathsButton.dataset.pathsHost, paths); }
|
||||||
|
});
|
||||||
$("#log-status").addEventListener("change", renderLogs);
|
$("#log-status").addEventListener("change", renderLogs);
|
||||||
$("#event-severity").addEventListener("change", renderLogs);
|
$("#event-severity").addEventListener("change", renderLogs);
|
||||||
$("#event-category").addEventListener("change", renderLogs);
|
$("#event-category").addEventListener("change", renderLogs);
|
||||||
@@ -534,7 +664,7 @@ document.addEventListener("keydown", event => { if (event.key === "Escape") clos
|
|||||||
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
document.querySelectorAll("dialog").forEach(dialog => dialog.addEventListener("close", () => { closeMenus(); dialog.querySelectorAll('input[type="password"]').forEach(input => input.value = ""); }));
|
||||||
|
|
||||||
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
// --- Hosted Sites & Proxy Hosts: create form submit handlers --------------------------------
|
||||||
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message)));
|
$("#refresh-health").addEventListener("click", () => refreshDashboard().catch(error => toast(error.message, "error")));
|
||||||
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
$("#create-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#create-error").textContent = ""; try { await api("/api/sites", { method: "POST", body: new FormData(event.target) }); $("#create-dialog").close(); await refresh(); toast("Hosted site created and gateway applied."); } catch (error) { $("#create-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||||
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
$("#proxy-form").addEventListener("submit", async event => { event.preventDefault(); const button = resolveSubmitter(event); button.disabled = true; button.textContent = "Publishing…"; $("#proxy-error").textContent = ""; const form = new FormData(event.target), certificate = form.get("certificateFile"), privateKey = form.get("privateKeyFile"), wantsCustom = form.get("tls") === "custom"; if (wantsCustom && (!certificate?.size || !privateKey?.size)) { $("#proxy-error").textContent = "Choose both the certificate and private key for Custom HTTPS."; button.disabled = false; button.textContent = "Create & publish"; return; } const body = advancedFormBody(form, Object.fromEntries(form)); delete body.certificateFile; delete body.privateKeyFile; if (wantsCustom) body.tls = "http"; try { const created = await api("/api/proxies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (wantsCustom) { const files = new FormData(); files.append("certificate", certificate); files.append("privateKey", privateKey); await api(`/api/proxies/${created.id}/certificate`, { method:"POST", body:files }); } $("#proxy-dialog").close(); await refresh(); toast(wantsCustom ? "Proxy host created with its custom certificate." : "Proxy host created. Certificate provisioning runs automatically."); } catch (error) { $("#proxy-error").textContent = error.message; } finally { button.disabled = false; button.textContent = "Create & publish"; } });
|
||||||
|
|
||||||
@@ -570,11 +700,12 @@ $("#site-grid").addEventListener("click", async event => {
|
|||||||
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
const card = event.target.closest(".site-card"); if (!card) return; const action = event.target.closest("[data-action]")?.dataset.action, kind = card.dataset.kind;
|
||||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button").setAttribute("aria-expanded", String(opening)); return; } if (!action) return;
|
||||||
closeMenus();
|
closeMenus();
|
||||||
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status."); } }
|
if (action === "toggle") { const toggleButton = event.target.closest(".toggle"), wasOn = toggleButton.classList.contains("on"); toggleButton.classList.toggle("on", !wasOn); toggleButton.disabled = true; const base = kind === "proxy" ? "proxies" : "sites"; try { await api(`/api/${base}/${card.dataset.id}/toggle`, { method: "POST" }); await refresh(); toast("Status and gateway configuration updated."); } catch (error) { toggleButton.classList.toggle("on", wasOn); toggleButton.disabled = false; toast(error.message || "Could not update status.", "error"); } }
|
||||||
if (action === "settings") openSettings(kind, card.dataset.id);
|
if (action === "settings") openSettings(kind, card.dataset.id);
|
||||||
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
|
if (action === "delete") { state.pendingDelete = { kind, id: card.dataset.id }; $("#confirm-title").textContent = kind === "proxy" ? "Delete this proxy host?" : "Delete this hosted site?"; $("#confirm-copy").textContent = kind === "proxy" ? "Its domain route will be removed from the gateway." : "Its route and uploaded files will be permanently removed."; $("#confirm-dialog").showModal(); }
|
||||||
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
|
if (action === "replace") { state.pendingReplace = card.dataset.id; $("#replace-files").click(); }
|
||||||
if (action === "icon") openIconPicker(kind, card.dataset.id);
|
if (action === "icon") openIconPicker(kind, card.dataset.id);
|
||||||
|
if (action === "caddy-config") openCaddyConfig(kind === "proxy" ? "proxies" : "sites", card.dataset.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
|
// --- Redirect card actions delegated from the site grid (menu open/close, edit/
|
||||||
@@ -583,11 +714,38 @@ document.querySelector("#redirect-list")?.addEventListener("click", event => {
|
|||||||
const card = event.target.closest(".redirect-card"); if (!card) return;
|
const card = event.target.closest(".redirect-card"); if (!card) return;
|
||||||
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
|
if (event.target.closest(".menu-button")) { const opening = !card.classList.contains("menu-open"); closeMenus(); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; }
|
||||||
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
|
const action = event.target.closest("[data-redirect-action]")?.dataset.redirectAction; if (action === "icon") { closeMenus(); openIconPicker("redirect", card.dataset.redirectId); }
|
||||||
|
if (action === "caddy-config") { closeMenus(); openCaddyConfig("redirects", card.dataset.redirectId); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- "View Caddy config" popout --------------------------------------------------------------
|
||||||
|
// Two overlapping rectangles, inline so it inherits currentColor from .icon-button.
|
||||||
|
const COPY_ICON_SVG = '<svg class="copy-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="5.4" y="1.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect><rect x="1.4" y="5.4" width="9.2" height="9.2" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.4"></rect></svg>';
|
||||||
|
function caddyConfigDialog() {
|
||||||
|
let dialog = document.querySelector("#caddy-config-dialog");
|
||||||
|
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "caddy-config-dialog"; document.body.append(dialog); }
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
async function openCaddyConfig(kind, id) {
|
||||||
|
const dialog = caddyConfigDialog();
|
||||||
|
dialog.innerHTML = '<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Loading…</h2></div></div><p class="muted">Reading the deployed configuration for this route.</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>';
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/${kind}/${encodeURIComponent(id)}/caddy-config`);
|
||||||
|
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>${escapeHtml(result.name)}</h2></div><button type="button" class="icon-button" id="copy-caddy-config" aria-label="Copy configuration" title="Copy configuration">${COPY_ICON_SVG}</button></div><p class="muted">This is the exact block Site Gateway writes into the Caddyfile for this route, annotated with what each directive does.</p><pre class="caddy-config-pre">${escapeHtml(result.config)}</pre><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||||
|
dialog.querySelector("#copy-caddy-config").addEventListener("click", async () => {
|
||||||
|
try { await navigator.clipboard.writeText(result.config); toast("Configuration copied."); }
|
||||||
|
catch { toast("Your browser blocked clipboard access.", "error"); }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
dialog.innerHTML = `<form method="dialog" class="dialog-card"><div class="dialog-heading"><div><p class="eyebrow">Gateway configuration</p><h2>Not available</h2></div></div><p class="muted">${escapeHtml(error.message)}</p><div class="dialog-actions"><button value="cancel" class="button secondary">Close</button></div></form>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.openCaddyConfig = openCaddyConfig;
|
||||||
|
|
||||||
|
|
||||||
// --- Delete confirmation dialog and replace-files handler ------------------------------------
|
// --- Delete confirmation dialog and replace-files handler ------------------------------------
|
||||||
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
|
$("#confirm-dialog").addEventListener("close", async () => { if ($("#confirm-dialog").returnValue === "confirm" && state.pendingDelete) { const base = state.pendingDelete.kind === "proxy" ? "proxies" : "sites"; await api(`/api/${base}/${state.pendingDelete.id}`, { method: "DELETE" }); await refresh(); toast("Entry deleted and gateway updated."); } state.pendingDelete = null; });
|
||||||
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message); } event.target.value = ""; state.pendingReplace = null; });
|
$("#replace-files").addEventListener("change", async event => { if (!event.target.files[0] || !state.pendingReplace) return; const data = new FormData(); data.append("files", event.target.files[0]); try { await api(`/api/sites/${state.pendingReplace}/files`, { method: "POST", body: data }); toast("Site files updated."); } catch (error) { toast(error.message, "error"); } event.target.value = ""; state.pendingReplace = null; });
|
||||||
|
|
||||||
|
|
||||||
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
|
// --- Icon picker dialog: search, upload, URL, and reset-to-fallback -------------------------
|
||||||
@@ -650,11 +808,18 @@ $("#user-list").addEventListener("click", async event => {
|
|||||||
closeMenus();
|
closeMenus();
|
||||||
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
|
state.passwordTarget = user.id; $("#password-form").reset(); $("#password-error").textContent = ""; $("#password-title").textContent = `Reset ${user.username} password`; $("#password-dialog").showModal(); return;
|
||||||
}
|
}
|
||||||
|
if (button.dataset.userAction === "mfa-disable") {
|
||||||
|
closeMenus();
|
||||||
|
if (!await themedUserConfirm(`Disable two-factor authentication for “${user.username}”? They’ll be able to sign in with just their password until they set it up again.`, "Disable 2FA")) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try { await api(`/api/users/${user.id}/mfa/disable`, { method: "POST" }); await loadFeatureView(); toast("Two-factor authentication disabled."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (button.dataset.userAction === "delete") {
|
if (button.dataset.userAction === "delete") {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
|
if (!await themedUserConfirm(`Permanently delete user “${user.username}”? This cannot be undone.`, "Delete user")) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message); } finally { button.disabled = false; }
|
try { await api(`/api/users/${user.id}`, { method: "DELETE" }); await loadFeatureView(); toast("User deleted."); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
@@ -662,7 +827,7 @@ $("#user-list").addEventListener("click", async event => {
|
|||||||
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
|
const body = button.dataset.userAction === "role" ? { role: button.dataset.value } : { status: button.dataset.value };
|
||||||
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
await api(`/api/users/${user.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||||
await loadFeatureView(); toast("User updated.");
|
await loadFeatureView(); toast("User updated.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
finally { button.disabled = false; }
|
finally { button.disabled = false; }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -683,10 +848,10 @@ window.addEventListener("hashchange", () => {
|
|||||||
if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
|
if (requestedHash.startsWith("administration/")) history.replaceState(null, "", `${location.pathname}${location.search}#administration`);
|
||||||
state.view = location.hash.slice(1) || "overview";
|
state.view = location.hash.slice(1) || "overview";
|
||||||
render();
|
render();
|
||||||
loadFeatureView().catch(error => toast(error.message));
|
loadFeatureView().catch(error => toast(error.message, "error"));
|
||||||
});
|
});
|
||||||
|
|
||||||
boot().catch(error => toast(error.message));
|
boot().catch(error => toast(error.message, "error"));
|
||||||
|
|
||||||
|
|
||||||
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
|
// --- Proxy form: keep the upstream-TLS fields in sync with the target URL scheme ------------
|
||||||
@@ -762,7 +927,7 @@ $("#account-mfa-enable").addEventListener("click", async () => {
|
|||||||
$("#mfa-setup-error").textContent = "";
|
$("#mfa-setup-error").textContent = "";
|
||||||
$("#mfa-setup-confirm-form").reset();
|
$("#mfa-setup-confirm-form").reset();
|
||||||
$("#mfa-setup-dialog").showModal();
|
$("#mfa-setup-dialog").showModal();
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
$("#mfa-setup-cancel").addEventListener("click", () => { $("#mfa-setup-dialog").close(); });
|
||||||
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
$("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
||||||
@@ -775,7 +940,7 @@ $("#mfa-setup-confirm-form").addEventListener("submit", async event => {
|
|||||||
state.user.mfaEnabled = true;
|
state.user.mfaEnabled = true;
|
||||||
renderAccount();
|
renderAccount();
|
||||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
$("#mfa-recovery-dialog").showModal();
|
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
|
||||||
toast("Two-factor authentication enabled.");
|
toast("Two-factor authentication enabled.");
|
||||||
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
} catch (error) { $("#mfa-setup-error").textContent = error.message; }
|
||||||
});
|
});
|
||||||
@@ -791,7 +956,7 @@ $("#account-mfa-disable").addEventListener("click", async () => {
|
|||||||
state.user.mfaEnabled = false;
|
state.user.mfaEnabled = false;
|
||||||
renderAccount();
|
renderAccount();
|
||||||
toast("Two-factor authentication disabled.");
|
toast("Two-factor authentication disabled.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
$("#account-mfa-recovery").addEventListener("click", async () => {
|
$("#account-mfa-recovery").addEventListener("click", async () => {
|
||||||
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
const password = await requestMfaPassword("Regenerate recovery codes", "Confirm your password to continue");
|
||||||
@@ -799,7 +964,7 @@ $("#account-mfa-recovery").addEventListener("click", async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
const result = await api("/api/account/mfa/recovery-codes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }) });
|
||||||
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
$("#mfa-recovery-codes").textContent = result.recoveryCodes.join("\n");
|
||||||
$("#mfa-recovery-dialog").showModal();
|
requestAnimationFrame(() => $("#mfa-recovery-dialog").showModal());
|
||||||
toast("Recovery codes regenerated. Your old codes no longer work.");
|
toast("Recovery codes regenerated. Your old codes no longer work.");
|
||||||
} catch (error) { toast(error.message); }
|
} catch (error) { toast(error.message, "error"); }
|
||||||
});
|
});
|
||||||
|
|||||||
+254
-14
File diff suppressed because one or more lines are too long
+31
-23
File diff suppressed because one or more lines are too long
+827
-164
File diff suppressed because one or more lines are too long
+403
-34
File diff suppressed because one or more lines are too long
+48
-6
@@ -68,6 +68,11 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
CREATE INDEX IF NOT EXISTS activity_events_instance_created ON activity_events(instance_id,created_at DESC);
|
||||||
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
CREATE TABLE IF NOT EXISTS access_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id), at TEXT, host TEXT, method TEXT, uri TEXT, status INTEGER, size INTEGER, duration_ms INTEGER, remote_ip TEXT, source TEXT, UNIQUE(instance_id,source));
|
||||||
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
CREATE INDEX IF NOT EXISTS access_events_instance_at ON access_events(instance_id,at DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (id TEXT PRIMARY KEY, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL, prefix TEXT NOT NULL, owner_user_id TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'full', session_version TEXT, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, revoked_at TEXT);
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_hash ON api_tokens(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS api_tokens_instance ON api_tokens(instance_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_events (id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id TEXT REFERENCES instances(id) ON DELETE CASCADE, type TEXT NOT NULL, filename TEXT, backup_type TEXT, size_bytes INTEGER, actor_user_id TEXT, created_at TEXT NOT NULL, safety_backup_filename TEXT, status TEXT NOT NULL DEFAULT 'success', error_message TEXT);
|
||||||
|
CREATE INDEX IF NOT EXISTS backup_events_instance_created ON backup_events(instance_id,created_at DESC);
|
||||||
`);
|
`);
|
||||||
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
try { db.exec("ALTER TABLE activity_events ADD COLUMN category TEXT NOT NULL DEFAULT 'activity'"); } catch { /* Column already exists. */ }
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
@@ -115,12 +120,16 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
SUM(CASE WHEN at>=? THEN 1 ELSE 0 END) AS hourRequests,
|
||||||
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
SUM(CASE WHEN at>=? AND status>=400 THEN 1 ELSE 0 END) AS hourErrors,
|
||||||
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
AVG(CASE WHEN at>=? THEN duration_ms END) AS hourAvgMs,
|
||||||
|
SUM(CASE WHEN at>=? THEN COALESCE(size,0) ELSE 0 END) AS hourBytes,
|
||||||
|
COUNT(DISTINCT CASE WHEN at>=? THEN remote_ip END) AS hourVisitors,
|
||||||
COUNT(*) AS dayRequests,
|
COUNT(*) AS dayRequests,
|
||||||
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
SUM(CASE WHEN status>=400 THEN 1 ELSE 0 END) AS dayErrors,
|
||||||
AVG(duration_ms) AS dayAvgMs
|
AVG(duration_ms) AS dayAvgMs,
|
||||||
|
SUM(COALESCE(size,0)) AS dayBytes,
|
||||||
|
COUNT(DISTINCT remote_ip) AS dayVisitors
|
||||||
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
|
FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!=''
|
||||||
GROUP BY host ORDER BY dayRequests DESC
|
GROUP BY host ORDER BY dayRequests DESC
|
||||||
`).all(hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
`).all(hourCutoff, hourCutoff, hourCutoff, hourCutoff, hourCutoff, instanceId, dayCutoff);
|
||||||
}
|
}
|
||||||
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
function performanceErrorBreakdown(instanceId = LOCAL_INSTANCE_ID) {
|
||||||
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||||
@@ -134,17 +143,50 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
|
const bucketMs = Math.max(1, Number(bucketMinutes) || 15) * 60000;
|
||||||
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
|
const windowMs = Math.max(1, Number(hours) || 6) * 3600000;
|
||||||
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
const cutoff = new Date(Date.now() - windowMs).toISOString();
|
||||||
const rows = db.prepare(`SELECT at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
const rows = db.prepare(`SELECT at,status FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?)`).all(instanceId, cutoff, host, host);
|
||||||
const buckets = new Map();
|
const buckets = new Map();
|
||||||
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; buckets.set(bucketStart, (buckets.get(bucketStart) || 0) + 1); }
|
for (const row of rows) { const t = new Date(row.at).getTime(); if (Number.isNaN(t)) continue; const bucketStart = Math.floor(t / bucketMs) * bucketMs; const entry = buckets.get(bucketStart) || { count: 0, errors: 0 }; entry.count += 1; if (Number(row.status) >= 400) entry.errors += 1; buckets.set(bucketStart, entry); }
|
||||||
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
const startBucket = Math.floor((Date.now() - windowMs) / bucketMs) * bucketMs, endBucket = Math.floor(Date.now() / bucketMs) * bucketMs;
|
||||||
const points = [];
|
const points = [];
|
||||||
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) points.push({ at: new Date(bucket).toISOString(), count: buckets.get(bucket) || 0 });
|
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketMs) { const entry = buckets.get(bucket); points.push({ at: new Date(bucket).toISOString(), count: entry?.count || 0, errors: entry?.errors || 0 }); }
|
||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
function pruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); return transaction(() => { const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) { const result = db.prepare(`DELETE FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).run(instanceId, cutoff(days)); counts[name] = Number(result.changes || 0); } return counts; }); }
|
||||||
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
function previewPruneEvents(policy = {}, instanceId = LOCAL_INSTANCE_ID) { const cutoff = days => new Date(Date.now() - Math.max(7, Number(days) || 30) * 86400000).toISOString(); const counts = {}; const jobs = [["access", "access_events", "at", policy.accessDays, ""], ["activity", "activity_events", "created_at", policy.activityDays, "category='activity'"], ["certificate", "activity_events", "created_at", policy.certificateDays, "category='certificate'"], ["security", "activity_events", "created_at", policy.securityDays, "category='security'"], ["audit", "audit_events", "created_at", policy.auditDays, ""]]; for (const [name, table, column, days, filter] of jobs) counts[name] = Number(db.prepare(`SELECT COUNT(*) AS count FROM ${table} WHERE instance_id=? AND ${column} < ?${filter ? ` AND ${filter}` : ""}`).get(instanceId, cutoff(days)).count || 0); return counts; }
|
||||||
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
|
function listAudit(filters = {}, instanceId = LOCAL_INSTANCE_ID) { const rows = db.prepare("SELECT id,actor_id,action,status,details,created_at FROM audit_events WHERE instance_id=? ORDER BY id DESC LIMIT 500").all(instanceId); return rows.filter(row => (!filters.user || row.actor_id === filters.user) && (!filters.action || row.action.toLowerCase().includes(filters.action.toLowerCase())) && (!filters.status || row.status === filters.status)).map(row => ({ ...row, details: row.details ? JSON.parse(row.details) : null })); }
|
||||||
|
// 95th-percentile latency per host. SQLite has no percentile aggregate, so the
|
||||||
|
// durations come back pre-sorted per host and the index is picked in JavaScript.
|
||||||
|
function performancePercentiles(percentile = 0.95, instanceId = LOCAL_INSTANCE_ID) {
|
||||||
|
const hourCutoff = new Date(Date.now() - 3600000).toISOString(), dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||||
|
const rows = db.prepare("SELECT host,at,duration_ms AS durationMs FROM access_events WHERE instance_id=? AND at>=? AND duration_ms IS NOT NULL AND host IS NOT NULL AND host!='' ORDER BY host, duration_ms").all(instanceId, dayCutoff);
|
||||||
|
const pick = values => { if (!values.length) return null; const index = Math.min(values.length - 1, Math.max(0, Math.ceil(percentile * values.length) - 1)); return Math.round(values[index]); };
|
||||||
|
const byHost = new Map();
|
||||||
|
for (const row of rows) { if (!byHost.has(row.host)) byHost.set(row.host, { day: [], hour: [] }); const entry = byHost.get(row.host); entry.day.push(row.durationMs); if (row.at >= hourCutoff) entry.hour.push(row.durationMs); }
|
||||||
|
return Object.fromEntries([...byHost].map(([host, entry]) => [host, { hourP95: pick(entry.hour), dayP95: pick(entry.day) }]));
|
||||||
|
}
|
||||||
|
function performanceTopPaths(limit = 10, instanceId = LOCAL_INSTANCE_ID) {
|
||||||
|
const dayCutoff = new Date(Date.now() - 86400000).toISOString();
|
||||||
|
const cap = Math.max(1, Math.min(Number(limit) || 10, 50));
|
||||||
|
const rows = db.prepare("SELECT host,uri,COUNT(*) AS count FROM access_events WHERE instance_id=? AND at>=? AND host IS NOT NULL AND host!='' GROUP BY host,uri ORDER BY host, count DESC").all(instanceId, dayCutoff);
|
||||||
|
const byHost = new Map();
|
||||||
|
for (const row of rows) { const list = byHost.get(row.host) || []; if (list.length < cap) list.push({ uri: row.uri || "/", count: row.count }); byHost.set(row.host, list); }
|
||||||
|
return Object.fromEntries(byHost);
|
||||||
|
}
|
||||||
|
function performanceSlowest(host = "", hours = 6, limit = 20, instanceId = LOCAL_INSTANCE_ID) {
|
||||||
|
const cutoff = new Date(Date.now() - Math.max(1, Number(hours) || 6) * 3600000).toISOString();
|
||||||
|
return db.prepare("SELECT host,uri,method,status,duration_ms AS durationMs,at FROM access_events WHERE instance_id=? AND at>=? AND (?='' OR host=?) AND duration_ms IS NOT NULL ORDER BY duration_ms DESC LIMIT ?").all(instanceId, cutoff, host, host, Math.max(1, Math.min(Number(limit) || 20, 50)));
|
||||||
|
}
|
||||||
|
// --- API tokens. Dedicated table (not the generic JSON-collection pattern) because
|
||||||
|
// every authenticated API request looks a token up by its SHA-256 hash.
|
||||||
|
function listApiTokens(instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? ORDER BY created_at DESC").all(instanceId); }
|
||||||
|
function createApiToken(row, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO api_tokens(id,instance_id,name,token_hash,prefix,owner_user_id,scope,session_version,created_at,last_used_at,expires_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,NULL,?,NULL)").run(row.id, instanceId, String(row.name), String(row.tokenHash), String(row.prefix), String(row.ownerUserId), row.scope === "read-only" ? "read-only" : "full", row.sessionVersion || null, now(), row.expiresAt || null); return listApiTokens(instanceId).find(item => item.id === row.id) || null; }
|
||||||
|
function findApiTokenByHash(tokenHash, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,name,prefix,owner_user_id AS ownerUserId,scope,session_version AS sessionVersion,created_at AS createdAt,last_used_at AS lastUsedAt,expires_at AS expiresAt,revoked_at AS revokedAt FROM api_tokens WHERE instance_id=? AND token_hash=?").get(instanceId, String(tokenHash)) || null; }
|
||||||
|
function revokeApiToken(id, instanceId = LOCAL_INSTANCE_ID) { return Number(db.prepare("UPDATE api_tokens SET revoked_at=? WHERE instance_id=? AND id=? AND revoked_at IS NULL").run(now(), instanceId, id).changes || 0) > 0; }
|
||||||
|
function touchApiToken(id, instanceId = LOCAL_INSTANCE_ID) { db.prepare("UPDATE api_tokens SET last_used_at=? WHERE instance_id=? AND id=?").run(now(), instanceId, id); }
|
||||||
|
// --- Backup history. Independent of what is on disk, so deleted backups and failed
|
||||||
|
// attempts stay visible in the timeline.
|
||||||
|
function recordBackupEvent(event, instanceId = LOCAL_INSTANCE_ID) { db.prepare("INSERT INTO backup_events(instance_id,type,filename,backup_type,size_bytes,actor_user_id,created_at,safety_backup_filename,status,error_message) VALUES(?,?,?,?,?,?,?,?,?,?)").run(instanceId, String(event.type), event.filename || null, event.backupType || null, event.sizeBytes ?? null, event.actorUserId || null, event.createdAt || now(), event.safetyBackupFilename || null, event.status === "failed" ? "failed" : "success", event.errorMessage ? String(event.errorMessage).slice(0, 500) : null); }
|
||||||
|
function listBackupEvents(limit = 500, instanceId = LOCAL_INSTANCE_ID) { return db.prepare("SELECT id,type,filename,backup_type AS backupType,size_bytes AS sizeBytes,actor_user_id AS actorUserId,created_at AS createdAt,safety_backup_filename AS safetyBackupFilename,status,error_message AS errorMessage FROM backup_events WHERE instance_id=? ORDER BY id DESC LIMIT ?").all(instanceId, Math.max(1, Math.min(Number(limit) || 500, 500))); }
|
||||||
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
|
function backupTo(filename) { try { fs.rmSync(filename, { force: true }); db.exec(`VACUUM INTO '${String(filename).replaceAll("'", "''")}'`); } catch (error) { throw new Error(`Could not create a consistent SQLite backup: ${error.message}`); } }
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
@@ -170,5 +212,5 @@ export async function openStorage(dataDir, backupsDir) {
|
|||||||
}
|
}
|
||||||
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
|
function humanizeGatewayErrors(instanceId = LOCAL_INSTANCE_ID) { const friendly = "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; const activity = db.prepare("SELECT id FROM activity_events WHERE instance_id=? AND message LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateActivity = db.prepare("UPDATE activity_events SET message=? WHERE id=?"); for (const row of activity) updateActivity.run(friendly, row.id); const audit = db.prepare("SELECT id FROM audit_events WHERE instance_id=? AND action LIKE '%upstream address scheme is HTTP but transport is configured for HTTP+TLS%'").all(instanceId); const updateAudit = db.prepare("UPDATE audit_events SET action=? WHERE id=?"); for (const row of audit) updateAudit.run(friendly, row.id); return activity.length + audit.length; }
|
||||||
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
|
const result = integrity(); if (result.length !== 1 || result[0] !== "ok") { db.close(); throw new Error(`SQLite integrity check failed: ${result.join(", ")}`); }
|
||||||
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, close: () => db.close() };
|
return { db, databasePath, isNew, snapshot, loadCollection, saveCollection, loadSettings, saveSettings, integrity, recordAudit, listAudit, recordActivity, listActivity, humanizeGatewayErrors, recordAccessEvents, listAccessEvents, pruneEvents, previewPruneEvents, backupTo, performanceLiveCount, performanceRoutes, performanceErrorBreakdown, performanceTrend, performancePercentiles, performanceTopPaths, performanceSlowest, listApiTokens, createApiToken, findApiTokenByHash, revokeApiToken, touchApiToken, recordBackupEvent, listBackupEvents, close: () => db.close() };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user