let configHistoryEntries = [], configHistorySelectedRevision = null, configHistorySelectedJson = null; function historyDate(value, options = {}) { const effective = { ...options }; if (Object.hasOwn(effective, "timeStyle") || Object.hasOwn(effective, "hour")) effective.hour12 = (adminPreferences?.time_format || "24h") === "12h"; try { return new Intl.DateTimeFormat(locale(), effective).format(new Date(Number(value) * 1000)); } catch { return formatDateTime(Number(value) * 1000); } } function historyDayKey(value) { const date = new Date(Number(value) * 1000); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); } function historyValue(value) { if (value === undefined) return t("configHistoryMissing"); const result = JSON.stringify(value); return result.length > 320 ? `${result.slice(0, 320)}…` : result; } function syncConfigHistoryRestoreButton() { const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision); if (!button) return; button.hidden = configHistorySelectedRevision === null; button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision); } // Compare leaf fields, not serialized object/array blobs. Object IDs are used // for stable array paths so reordering vhosts does not become a huge fake diff. function configDiff(before, after, path = "$", output = [], depth = 0) { if (output.length >= 200 || Object.is(before, after)) return output; if (depth >= 24) { if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before: "[nested value]", after: "[nested value]" }); return output; } const oldObj = before !== null && typeof before === "object"; const newObj = after !== null && typeof after === "object"; if (oldObj && newObj && !Array.isArray(before) && !Array.isArray(after)) { const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(); for (const key of keys) { if (output.length >= 200) break; configDiff(before[key], after[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1); } return output; } if (Array.isArray(before) && Array.isArray(after)) { const named = items => items.every(item => item && typeof item === "object" && !Array.isArray(item) && (typeof item.id === "string" || typeof item.name === "string")); const itemKey = item => String(item.id ?? item.name); const stable = named(before) && named(after) && new Set(before.map(itemKey)).size === before.length && new Set(after.map(itemKey)).size === after.length; if (stable) { const oldItems = new Map(before.map(item => [itemKey(item), item])); const newItems = new Map(after.map(item => [itemKey(item), item])); for (const id of new Set([...oldItems.keys(), ...newItems.keys()])) { if (output.length >= 200) break; configDiff(oldItems.get(id), newItems.get(id), `${path}[${JSON.stringify(id)}]`, output, depth + 1); } } else { for (let i = 0; i < Math.max(before.length, after.length) && output.length < 200; i++) { configDiff(before[i], after[i], `${path}[${i}]`, output, depth + 1); } } return output; } // New/deleted trees are traversed to show the affected fields only. const tree = oldObj ? before : newObj ? after : null; if (tree && !Array.isArray(tree)) { for (const key of Object.keys(tree).sort()) { if (output.length >= 200) break; configDiff(before?.[key], after?.[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1); } return output; } if (Array.isArray(tree)) { for (let i = 0; i < tree.length && output.length < 200; i++) { configDiff(before?.[i], after?.[i], `${path}[${i}]`, output, depth + 1); } return output; } if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before, after }); return output; } function renderConfigRevisionDetail(detail) { const entry = detail?.revision; if (!entry) return; configHistorySelectedRevision = Number(entry.revision); configHistorySelectedJson = entry.previous_config ? { before: entry.previous_config, after: entry.config } : null; const compareButton = $("config-history-compare-open"); compareButton.disabled = !configHistorySelectedJson; $("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`; $("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`; syncConfigHistoryRestoreButton(); document.querySelectorAll(".config-history-revision").forEach(button => button.classList.toggle("active", Number(button.dataset.revision) === Number(entry.revision))); const box = $("config-history-diff"); box.replaceChildren(); if (!entry.previous_config) { box.append(el("p", t("configHistoryBaseline"), "empty")); return; } const diff = configDiff(entry.previous_config, entry.config); if (!diff.length) { box.append(el("p", t("configHistoryNoChanges"), "empty")); return; } for (const change of diff) { const card = el("article", undefined, "config-history-change"); card.append(el("strong", change.path, "config-history-change-path")); const values = el("div", undefined, "config-history-change-values"); values.append(el("code", historyValue(change.before), "diff-before"), el("span", "→", "fine"), el("code", historyValue(change.after), "diff-after")); card.append(values); box.append(card); } if (diff.length >= 200) box.append(el("p", t("configHistoryDiffLimit"), "fine")); } async function loadConfigRevision(revision) { try { renderConfigRevisionDetail(await api(`/api/config/history?revision=${encodeURIComponent(revision)}`)); } catch (error) { toast(error.message, true); } } function selectConfigHistoryDay(dayKey, entries) { document.querySelectorAll(".config-history-day").forEach(button => button.classList.toggle("is-selected", button.dataset.day === dayKey)); const revisions = $("config-history-revisions"); revisions.replaceChildren(); const sorted = [...entries].sort((a, b) => b.revision - a.revision); for (const entry of sorted) { const button = el("button", `${t("configHistoryRevisionShort")} ${entry.revision}`, "quiet config-history-revision"); button.type = "button"; button.dataset.revision = entry.revision; button.title = `${entry.actor} · ${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })}`; button.addEventListener("click", () => void loadConfigRevision(entry.revision)); revisions.append(button); } if (sorted[0]) void loadConfigRevision(sorted[0].revision); } function renderConfigHistory() { const calendar = $("config-history-calendar"); calendar.replaceChildren(); const byMonth = new Map(), byDay = new Map(); for (const entry of configHistoryEntries) { const month = historyMonthKey(entry.time), day = historyDayKey(entry.time); if (!byMonth.has(month)) byMonth.set(month, []); byMonth.get(month).push(entry); if (!byDay.has(day)) byDay.set(day, []); byDay.get(day).push(entry); } $("config-history-summary").textContent = configHistoryEntries.length ? t("configHistorySummary").replace("{count}", configHistoryEntries.length) : t("configHistoryEmpty"); const months = [...byMonth.keys()].sort().reverse(); for (const key of months) { const [yearText, monthText] = key.split("-"), year = Number(yearText), month = Number(monthText) - 1; const panel = el("section", undefined, "config-history-month"); panel.append(el("h4", new Intl.DateTimeFormat(locale(), { month: "long", year: "numeric" }).format(new Date(year, month, 1)))); const weekdays = el("div", undefined, "config-history-weekdays"); for (let day = 0; day < 7; day++) weekdays.append(el("span", new Intl.DateTimeFormat(locale(), { weekday: "narrow" }).format(new Date(2024, 0, 7 + day)))); panel.append(weekdays); const days = el("div", undefined, "config-history-days"), first = new Date(year, month, 1).getDay(), count = new Date(year, month + 1, 0).getDate(); for (let blank = 0; blank < first; blank++) days.append(el("span", "", "config-history-day")); for (let day = 1; day <= count; day++) { const dayKey = `${yearText}-${monthText}-${String(day).padStart(2, "0")}`, entries = byDay.get(dayKey) || []; const node = el(entries.length ? "button" : "span", String(day), `config-history-day${entries.length ? " has-revisions" : ""}`); if (entries.length) { node.type = "button"; node.dataset.day = dayKey; node.append(el("small", `${entries.length} ${t("configHistoryEntries")}`, "config-history-day-count")); node.addEventListener("click", () => selectConfigHistoryDay(dayKey, entries)); } days.append(node); } panel.append(days); calendar.append(panel); } if (!months.length) calendar.append(el("p", t("configHistoryEmpty"), "empty")); } async function restoreConfigRevision() { const revision = Number(configHistorySelectedRevision); if (!envelope || busy || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision)) return; const expectedRevision = Number(envelope.revision); const message = t("configHistoryRestoreConfirm").replace("{revision}", revision).replace("{current}", expectedRevision); if (!(await appConfirm(message, { danger: true, confirmLabel: t("configHistoryRestoreAction") }))) return; const button = $("config-history-restore"), epoch = authEpoch; busy = true; syncConfigHistoryRestoreButton(); try { const value = await api("/api/config/history/restore", { method: "POST", body: JSON.stringify({ revision, expected_revision: expectedRevision }) }); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); toast(t("configHistoryRestored").replace("{revision}", revision).replace("{new_revision}", value.revision)); await refresh(true); if (authenticated && epoch === authEpoch) await loadConfigHistory(); } catch (error) { toast(error.message, true); if (error.status === 401) lock(); } finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); } } async function loadConfigHistory() { configHistorySelectedRevision = null; configHistorySelectedJson = null; $("config-history-compare-open").disabled = true; syncConfigHistoryRestoreButton(); $("config-history-summary").textContent = t("configHistoryLoading"); try { const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory(); const latest = configHistoryEntries[0]; if (latest) selectConfigHistoryDay(historyDayKey(latest.time), configHistoryEntries.filter(entry => historyDayKey(entry.time) === historyDayKey(latest.time))); } catch (error) { $("config-history-summary").textContent = t("configHistoryFailed"); toast(error.message, true); } } $("config-history-open").addEventListener("click", () => { const dialog = $("config-history-dialog"); if (!dialog.open) dialog.showModal(); void loadConfigHistory(); }); $("config-history-close").addEventListener("click", () => $("config-history-dialog").close()); $("config-history-reload").addEventListener("click", () => void loadConfigHistory()); $("config-history-restore").addEventListener("click", () => void restoreConfigRevision()); $("config-history-compare-open").addEventListener("click", () => { if (!configHistorySelectedJson) return; $("config-history-json-before").textContent = JSON.stringify(configHistorySelectedJson.before, null, 2); $("config-history-json-after").textContent = JSON.stringify(configHistorySelectedJson.after, null, 2); const dialog = $("config-history-json-dialog"); if (!dialog.open) dialog.showModal(); }); $("config-history-json-close").addEventListener("click", () => $("config-history-json-dialog").close());