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:
marvin
2026-09-18 13:21:11 -04:00
parent 59f0708382
commit 005ebac2b5
7 changed files with 60 additions and 20 deletions
+7 -3
View File
@@ -193,7 +193,8 @@ function renderDashboard() {
$("#dash-attention-chip").classList.toggle("accent-green", data.attention.length === 0);
$("#dash-attention-icon").textContent = data.attention.length > 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");
overall.className = `health-badge ${hasErrors ? "error" : isChecking || hasNothingRunning ? "warning" : "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";
$("#attention-panel").classList.toggle("is-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>';
}
@@ -630,7 +634,7 @@ 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 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();
}
$("#performance-rows").addEventListener("click", event => {
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -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)}
#account-password-form .error{margin:0;min-height:.3em}
#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.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}
@@ -218,7 +218,7 @@ header{align-items:flex-end}
.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)}}
.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-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}
@@ -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)}
.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)}
.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 strong,.activity-copy strong{font-size:var(--font-size-md)}
.attention-copy small,.activity-copy small{color:var(--muted);font-size:.72rem}
+20 -4
View File
@@ -672,7 +672,15 @@ function caddyAdminRequest(options, body) {
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() {
const wasDrifting = configDrift.drift;
try {
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
if (!caddyfileContent) return;
@@ -684,8 +692,16 @@ async function checkConfigDrift() {
const adaptedParsed = JSON.parse(adapted.body);
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
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 };
// 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) {
// Caddy admin API unreachable, or transient error: don\u2019t flag drift on a check we couldn\u2019t complete.
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 (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." });
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 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.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` });
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.", 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}` : ""}.`, 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"}.` });
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);