This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 22:55:22 +02:00
1 parent 4cc994b3bc
commit 79fd198949
48 files changed
+922 -202

No files matched your search

+66 -14
View File
@@ -1,4 +1,4 @@
let configHistoryEntries = [], configHistorySelectedRevision = null;
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";
@@ -12,8 +12,8 @@ function historyDayKey(value) {
function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); }
function historyValue(value) {
if (value === undefined) return t("configHistoryMissing");
const text = JSON.stringify(value, null, 2);
return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text;
const result = JSON.stringify(value);
return result.length > 320 ? `${result.slice(0, 320)}…` : result;
}
function syncConfigHistoryRestoreButton() {
const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision);
@@ -21,14 +21,57 @@ function syncConfigHistoryRestoreButton() {
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) return output;
if (Object.is(before, after)) return output;
const beforeObject = before && typeof before === "object" && !Array.isArray(before);
const afterObject = after && typeof after === "object" && !Array.isArray(after);
if (beforeObject && afterObject && depth < 10) {
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) configDiff(before[key], after[key], `${path}.${key}`, output, depth + 1);
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 });
@@ -38,21 +81,22 @@ 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 diff = configDiff(entry.previous_config, entry.config);
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");
for (const [label, value] of [[t("configHistoryBefore"), change.before], [t("configHistoryAfter"), change.after]]) {
const side = el("div"); side.append(el("span", label, "small-label"), el("pre", historyValue(value))); values.append(side);
}
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"));
@@ -127,7 +171,7 @@ async function restoreConfigRevision() {
finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); }
}
async function loadConfigHistory() {
configHistorySelectedRevision = null; syncConfigHistoryRestoreButton();
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();
@@ -138,3 +182,11 @@ $("config-history-open").addEventListener("click", () => { const dialog = $("con
$("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());