Fix drift-alert usability, dashboard signal accuracy, dropdown theming, and toast/dialog stacking; document v0.15.0 and v0.15.1 (v0.15.1)
This commit is contained in:
@@ -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.15.0-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> ·
|
||||||
|
|||||||
+23
-6
@@ -6,15 +6,13 @@
|
|||||||
|
|
||||||
`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.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, and scopes out (but does not yet ship) a larger set of previously-discussed features tracked below as follow-up work.
|
`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 and a "Resync now" callout appears under Administration \u2192 Default site, both driven by a new `POST /api/gateway/resync` route that re-runs the normal Caddy sync and clears the flag.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
### Follow-up work carried from this release's planning
|
`v0.15.0` ships the full set of features scoped alongside `v0.14.0` and deferred at the time — nothing here was cut:
|
||||||
|
|
||||||
A larger feature set was scoped for `v0.14.0` and intentionally deferred rather than shipped partially-verified. These remain on the roadmap for a future release:
|
|
||||||
|
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -22,6 +20,19 @@ A larger feature set was scoped for `v0.14.0` and intentionally deferred rather
|
|||||||
- **"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.
|
- **"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.
|
- **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.
|
- **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
|
||||||
|
|
||||||
@@ -44,6 +55,7 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
- 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, 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.
|
- 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
|
||||||
|
|
||||||
@@ -55,7 +67,10 @@ 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 — the host filter applies to the throughput table as well as the trend chart, average response times display in seconds once they pass 1000ms, and per-domain error counts open a themed breakdown by status code.
|
- 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.
|
- 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.
|
||||||
@@ -65,6 +80,8 @@ Site Gateway stays simpler than a general-purpose proxy manager: one dashboard,
|
|||||||
- 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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.15.0",
|
"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",
|
||||||
|
|||||||
+7
-3
@@ -193,7 +193,8 @@ function renderDashboard() {
|
|||||||
$("#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";
|
||||||
@@ -226,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>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,7 +634,7 @@ function showTopPaths(host, paths) {
|
|||||||
let dialog = document.querySelector("#top-paths-dialog");
|
let dialog = document.querySelector("#top-paths-dialog");
|
||||||
if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "top-paths-dialog"; document.body.append(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("");
|
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 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.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();
|
dialog.showModal();
|
||||||
}
|
}
|
||||||
$("#performance-rows").addEventListener("click", event => {
|
$("#performance-rows").addEventListener("click", event => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -109,7 +109,7 @@ dialog::backdrop{background:rgba(var(--backdrop-rgb),.76);backdrop-filter:blur(5
|
|||||||
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:var(--space-5)}
|
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:var(--space-5)}
|
||||||
#account-password-form .error{margin:0;min-height:.3em}
|
#account-password-form .error{margin:0;min-height:.3em}
|
||||||
#account-password-form .dialog-actions{margin-top:var(--space-2)}
|
#account-password-form .dialog-actions{margin-top:var(--space-2)}
|
||||||
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none}
|
.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:var(--toast-bg);color:var(--toast-text);padding:11px var(--space-4);border-radius:var(--radius-sm);box-shadow:var(--shadow);transition:.2s;pointer-events:none;z-index:2147483647}
|
||||||
.toast.show{opacity:1;transform:translate(-50%,0)}
|
.toast.show{opacity:1;transform:translate(-50%,0)}
|
||||||
.toast.toast-error{background:var(--toast-error-bg);color:var(--toast-error-text)}
|
.toast.toast-error{background:var(--toast-error-bg);color:var(--toast-error-text)}
|
||||||
.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:var(--space-4);background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-md);padding:14px var(--space-4) 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}
|
.update-banner{position:fixed;left:50%;bottom:30px;transform:translate(-50%,0);display:flex;align-items:center;gap:var(--space-4);background:var(--surface-raised);border:1px solid var(--line);border-radius:var(--radius-md);padding:14px var(--space-4) 14px 20px;box-shadow:var(--shadow);z-index:5;color:var(--text);font-size:.88rem}
|
||||||
@@ -218,7 +218,7 @@ header{align-items:flex-end}
|
|||||||
.live-dot.checking{background:var(--warning);animation-duration:.9s}
|
.live-dot.checking{background:var(--warning);animation-duration:.9s}
|
||||||
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
|
@keyframes live-pulse{0%{box-shadow:0 0 0 0 rgba(var(--green-rgb),.5)}70%{box-shadow:0 0 0 6px rgba(var(--green-rgb),0)}100%{box-shadow:0 0 0 0 rgba(var(--green-rgb),0)}}
|
||||||
.system-panel{position:relative;overflow:hidden}
|
.system-panel{position:relative;overflow:hidden}
|
||||||
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--blue);opacity:.85}
|
.system-panel::before{content:"";position:absolute;inset:0 0 auto 0;height:3px;background:var(--green);opacity:.85}
|
||||||
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
|
.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0}
|
||||||
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
.system-tile{min-width:0;padding:13px 14px;border:1px solid var(--line);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
||||||
.system-grid dt{color:var(--muted);font-size:.72rem}
|
.system-grid dt{color:var(--muted);font-size:.72rem}
|
||||||
@@ -234,6 +234,8 @@ header{align-items:flex-end}
|
|||||||
.attention-tile{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:13px 14px;border:1px solid var(--line);border-left:3px solid var(--danger);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
.attention-tile{display:flex;align-items:center;gap:var(--space-3);min-width:0;padding:13px 14px;border:1px solid var(--line);border-left:3px solid var(--danger);border-radius:var(--radius-md);background:rgba(var(--bg-rgb),.28)}
|
||||||
.issue-link.attention-tile{cursor:pointer;transition:.2s}
|
.issue-link.attention-tile{cursor:pointer;transition:.2s}
|
||||||
.issue-link.attention-tile:hover{background:rgba(var(--danger-rgb),.08);border-color:var(--border-hover-alt)}
|
.issue-link.attention-tile:hover{background:rgba(var(--danger-rgb),.08);border-color:var(--border-hover-alt)}
|
||||||
|
.drift-tile .attention-copy{flex:1}
|
||||||
|
.drift-tile .button{padding:var(--space-2) 12px;font-size:.72rem;flex-shrink:0}
|
||||||
.attention-copy,.activity-copy{display:grid;gap:3px;min-width:0}
|
.attention-copy,.activity-copy{display:grid;gap:3px;min-width:0}
|
||||||
.attention-copy strong,.activity-copy strong{font-size:var(--font-size-md)}
|
.attention-copy strong,.activity-copy strong{font-size:var(--font-size-md)}
|
||||||
.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}
|
.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}
|
||||||
|
|||||||
+20
-4
@@ -672,7 +672,15 @@ function caddyAdminRequest(options, body) {
|
|||||||
request.end();
|
request.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function stableStringify(value) {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
async function checkConfigDrift() {
|
async function checkConfigDrift() {
|
||||||
|
const wasDrifting = configDrift.drift;
|
||||||
try {
|
try {
|
||||||
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
|
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
|
||||||
if (!caddyfileContent) return;
|
if (!caddyfileContent) return;
|
||||||
@@ -684,8 +692,16 @@ async function checkConfigDrift() {
|
|||||||
const adaptedParsed = JSON.parse(adapted.body);
|
const adaptedParsed = JSON.parse(adapted.body);
|
||||||
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
|
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
|
||||||
const liveConfig = JSON.parse(live.body);
|
const liveConfig = JSON.parse(live.body);
|
||||||
const drift = JSON.stringify(adaptedConfig) !== JSON.stringify(liveConfig);
|
// Compare with sorted-key serialization, not raw JSON.stringify: Caddy\u2019s freshly-adapted
|
||||||
|
// config and its live running config can serialize object keys in a different order even when
|
||||||
|
// they\u2019re semantically identical, which previously registered as a false-positive drift.
|
||||||
|
const drift = stableStringify(adaptedConfig) !== stableStringify(liveConfig);
|
||||||
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the saved configuration." : null };
|
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the saved configuration." : null };
|
||||||
|
// Log only the false->true transition (not every 10-minute check while it persists or is clear),
|
||||||
|
// so Gateway Events shows exactly when drift was (re-)detected \u2014 useful for confirming whether
|
||||||
|
// a report of drift reappearing "right after resync" actually lines up with a real check, or with
|
||||||
|
// this job\u2019s normal 10-minute cadence instead.
|
||||||
|
if (drift && !wasDrifting) recordActivity("Configuration drift detected: Caddy\u2019s live configuration no longer matches the saved configuration.", "warning");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Caddy admin API unreachable, or transient error: don\u2019t flag drift on a check we couldn\u2019t complete.
|
// Caddy admin API unreachable, or transient error: don\u2019t flag drift on a check we couldn\u2019t complete.
|
||||||
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
|
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
|
||||||
@@ -964,9 +980,9 @@ async function dashboardSnapshot() {
|
|||||||
if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." });
|
if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." });
|
||||||
if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." });
|
if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." });
|
||||||
if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." });
|
if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." });
|
||||||
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.` });
|
for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.`, target: "hosted" });
|
||||||
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." });
|
for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention.", target: "proxies" });
|
||||||
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` });
|
for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.`, target: "proxies" });
|
||||||
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
|
for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` });
|
||||||
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
|
if (configDrift.drift) attention.push({ kind: "drift", name: "Configuration drift", message: "Caddy\u2019s live configuration no longer matches the saved configuration.", target: "administration/defaults" });
|
||||||
const disk = await fsp.statfs(dataDir).catch(() => null);
|
const disk = await fsp.statfs(dataDir).catch(() => null);
|
||||||
|
|||||||
Reference in New Issue
Block a user