Fix dashboard resync button wiring, attention color consistency, drift false-positives, and remove redundant Gateway Defaults callout (v0.15.2)
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.1-62E6A7">
|
<img alt="Version" src="https://img.shields.io/badge/version-0.15.2-62E6A7">
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
<a href="#why-site-gateway">Why Site Gateway</a> ·
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "site-gateway",
|
"name": "site-gateway",
|
||||||
"version": "0.15.1",
|
"version": "0.15.2",
|
||||||
"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",
|
||||||
|
|||||||
+11
-1
@@ -189,7 +189,7 @@ function renderDashboard() {
|
|||||||
$("#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);
|
applyTileAccents(data);
|
||||||
$("#dash-attention-chip").classList.toggle("accent-warning", data.attention.length > 0);
|
$("#dash-attention-chip").classList.toggle("accent-danger", 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;
|
||||||
@@ -602,6 +602,16 @@ $("#logout").addEventListener("click", async () => { await fetch("/api/logout",
|
|||||||
$("#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"; } });
|
$("#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) { const [view, adminTab] = target.split("/"); state.view = view; if (view === "administration" && adminTab) state.adminTab = adminTab; render(); loadFeatureView().catch(error => toast(error.message, "error")); } });
|
$("#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")); } });
|
||||||
|
$("#attention-list").addEventListener("click", async event => {
|
||||||
|
const button = event.target.closest("[data-drift-resync]");
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = true; button.textContent = "Resyncing…";
|
||||||
|
try {
|
||||||
|
await api("/api/gateway/resync", { method: "POST" });
|
||||||
|
toast("Gateway configuration re-synced.");
|
||||||
|
await refresh();
|
||||||
|
} catch (error) { toast(error.message, "error"); button.disabled = false; button.textContent = "Resync now"; }
|
||||||
|
});
|
||||||
|
|
||||||
// --- 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"); }); }
|
||||||
|
|||||||
@@ -326,27 +326,6 @@ document.addEventListener("click", async event => { const button = event.target.
|
|||||||
document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; });
|
document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; });
|
||||||
document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Log Retention</p><h2>Confirm pruning</h2></div></div><p class="muted">This will remove records older than your saved retention periods.</p><p class="muted">Access: <strong>${counts.access || 0}</strong> · Activity: <strong>${counts.activity || 0}</strong> · Certificates: <strong>${counts.certificate || 0}</strong> · Security: <strong>${counts.security || 0}</strong> · Audit: <strong>${counts.audit || 0}</strong></p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button primary">Confirm pruning</button></div></form>`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true);
|
document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `<form method="dialog" class="dialog-card compact"><div class="dialog-heading"><div><p class="eyebrow">Log Retention</p><h2>Confirm pruning</h2></div></div><p class="muted">This will remove records older than your saved retention periods.</p><p class="muted">Access: <strong>${counts.access || 0}</strong> · Activity: <strong>${counts.activity || 0}</strong> · Certificates: <strong>${counts.certificate || 0}</strong> · Security: <strong>${counts.security || 0}</strong> · Audit: <strong>${counts.audit || 0}</strong></p><div class="dialog-actions"><button value="cancel" class="button secondary">Cancel</button><button value="confirm" class="button primary">Confirm pruning</button></div></form>`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true);
|
||||||
|
|
||||||
// --- Config drift: show a resync callout in Administration > Default site when drift is detected --------
|
|
||||||
function renderConfigDriftCallout() {
|
|
||||||
const callout = document.querySelector("#config-drift-callout");
|
|
||||||
if (!callout) return;
|
|
||||||
const drift = (state.dashboard?.attention || []).some(item => item.kind === "drift");
|
|
||||||
callout.hidden = !drift;
|
|
||||||
}
|
|
||||||
setInterval(renderConfigDriftCallout, 1000);
|
|
||||||
document.addEventListener("click", async event => {
|
|
||||||
const button = event.target.closest("#config-resync");
|
|
||||||
if (!button) return;
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
await api("/api/gateway/resync", { method: "POST" });
|
|
||||||
toast("Gateway configuration re-synced.");
|
|
||||||
await refresh();
|
|
||||||
renderConfigDriftCallout();
|
|
||||||
} catch (error) { toast(error.message); } finally { button.disabled = false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================================
|
// ============================================================================================
|
||||||
// v0.15.0 additions: API access tokens, backup history, and the Docker container picker.
|
// v0.15.0 additions: API access tokens, backup history, and the Docker container picker.
|
||||||
// ============================================================================================
|
// ============================================================================================
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+20
-21
@@ -638,6 +638,10 @@ async function syncCaddy() {
|
|||||||
await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]);
|
await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]);
|
||||||
gatewayError = null;
|
gatewayError = null;
|
||||||
lastGatewayReload = new Date().toISOString();
|
lastGatewayReload = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const liveAfterReload = await caddyAdminRequest({ method: "GET", path: "/config/" });
|
||||||
|
if (liveAfterReload.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterReload.body);
|
||||||
|
} catch (error) { console.warn("Could not capture post-reload config baseline:", error.message); }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const rejectedReason = error.stderr || error.message;
|
const rejectedReason = error.stderr || error.message;
|
||||||
let rollbackSucceeded = false;
|
let rollbackSucceeded = false;
|
||||||
@@ -659,6 +663,7 @@ async function syncCaddy() {
|
|||||||
|
|
||||||
|
|
||||||
let configDrift = { checkedAt: null, drift: false, detail: null };
|
let configDrift = { checkedAt: null, drift: false, detail: null };
|
||||||
|
let lastKnownGoodCaddyConfig = null;
|
||||||
function caddyAdminRequest(options, body) {
|
function caddyAdminRequest(options, body) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = http.request({ host: "127.0.0.1", port: 2019, timeout: 5000, ...options }, response => {
|
const request = http.request({ host: "127.0.0.1", port: 2019, timeout: 5000, ...options }, response => {
|
||||||
@@ -682,28 +687,18 @@ function stableStringify(value) {
|
|||||||
async function checkConfigDrift() {
|
async function checkConfigDrift() {
|
||||||
const wasDrifting = configDrift.drift;
|
const wasDrifting = configDrift.drift;
|
||||||
try {
|
try {
|
||||||
const caddyfileContent = await fsp.readFile(caddyfilePath, "utf8").catch(() => null);
|
const live = await caddyAdminRequest({ method: "GET", path: "/config/" });
|
||||||
if (!caddyfileContent) return;
|
if (live.status !== 200) return;
|
||||||
const [adapted, live] = await Promise.all([
|
|
||||||
caddyAdminRequest({ method: "POST", path: "/adapt", headers: { "Content-Type": "text/caddyfile" } }, caddyfileContent),
|
|
||||||
caddyAdminRequest({ method: "GET", path: "/config/" })
|
|
||||||
]);
|
|
||||||
if (adapted.status !== 200 || live.status !== 200) return;
|
|
||||||
const adaptedParsed = JSON.parse(adapted.body);
|
|
||||||
const adaptedConfig = adaptedParsed && adaptedParsed.config !== undefined ? adaptedParsed.config : adaptedParsed;
|
|
||||||
const liveConfig = JSON.parse(live.body);
|
const liveConfig = JSON.parse(live.body);
|
||||||
// Compare with sorted-key serialization, not raw JSON.stringify: Caddy\u2019s freshly-adapted
|
if (!lastKnownGoodCaddyConfig) {
|
||||||
// config and its live running config can serialize object keys in a different order even when
|
lastKnownGoodCaddyConfig = liveConfig;
|
||||||
// they\u2019re semantically identical, which previously registered as a false-positive drift.
|
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
|
||||||
const drift = stableStringify(adaptedConfig) !== stableStringify(liveConfig);
|
return;
|
||||||
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),
|
const drift = stableStringify(lastKnownGoodCaddyConfig) !== stableStringify(liveConfig);
|
||||||
// so Gateway Events shows exactly when drift was (re-)detected \u2014 useful for confirming whether
|
configDrift = { checkedAt: new Date().toISOString(), drift, detail: drift ? "Caddy\u2019s live configuration no longer matches the last known-good configuration." : null };
|
||||||
// a report of drift reappearing "right after resync" actually lines up with a real check, or with
|
if (drift && !wasDrifting) recordActivity("Configuration drift detected: Caddy\u2019s live configuration no longer matches the last known-good configuration.", "warning");
|
||||||
// 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.
|
|
||||||
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
|
configDrift = { ...configDrift, checkedAt: new Date().toISOString() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1018,7 +1013,7 @@ async function dashboardSnapshot() {
|
|||||||
publicIp: publicIpState.address,
|
publicIp: publicIpState.address,
|
||||||
publicIpCheckedAt: publicIpState.checkedAt,
|
publicIpCheckedAt: publicIpState.checkedAt,
|
||||||
publicIpError: publicIpState.error,
|
publicIpError: publicIpState.error,
|
||||||
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }]
|
jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }, { name: "Public IP check", enabled: true, schedule: "60m" }, { name: "Configuration drift check", enabled: true, schedule: "10m" }]
|
||||||
},
|
},
|
||||||
activity: recentActivity
|
activity: recentActivity
|
||||||
};
|
};
|
||||||
@@ -1579,6 +1574,10 @@ app.post("/api/gateway/resync", async (req, res, next) => {
|
|||||||
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." });
|
||||||
try {
|
try {
|
||||||
await syncCaddy();
|
await syncCaddy();
|
||||||
|
try {
|
||||||
|
const liveAfterResync = await caddyAdminRequest({ method: "GET", path: "/config/" });
|
||||||
|
if (liveAfterResync.status === 200) lastKnownGoodCaddyConfig = JSON.parse(liveAfterResync.body);
|
||||||
|
} catch (error) { console.warn("Could not capture post-resync config baseline:", error.message); }
|
||||||
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
|
configDrift = { checkedAt: new Date().toISOString(), drift: false, detail: null };
|
||||||
recordActivity(`Gateway configuration re-synced by \u201c${req.user.username}\u201d.`);
|
recordActivity(`Gateway configuration re-synced by \u201c${req.user.username}\u201d.`);
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user