feat(config-backup): compare backups with each other and with the running config

Settings -> System -> Config backups gains a Compare view: a backup against the
running config, a backup against the one before it, or any two backups. Each
differing file lists its changed keys as before/after, plus files that appeared
or vanished. GET /api/config-backups/diff?from=&to= serves it (`to` defaults to
`current`; admin only in multi-user mode).

Credential values are never returned: .env, users.json and push-keys.json report
names only, and any other key that looks like a token, secret, password,
credential or key is reported as changed without its value. A non-JSON file
reports only that it differs, a file is cut at 200 changes, and a self-refreshed
discovery timestamp does not count as a difference.
This commit is contained in:
Devvyn committed 2026-10-20 18:01:42 +08:00
1 parent 06182c6fce
commit a2e49eb26d
10 files changed
+426 -16

No files matched your search

+1 -1
View File
@@ -3276,7 +3276,7 @@
<div class="set-row" data-search="config backup now restore list">
<div class="set-row-text">
<span class="set-row-label">Backups</span>
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first.</span>
<span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span>
</div>
<button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button>
</div>
+61 -3
View File
@@ -1393,14 +1393,72 @@ Object.assign(CodemanApp.prototype, {
return;
}
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
const rows = data.backups.map((b) => {
this._configBackups = data.backups;
const rows = data.backups.map((b, i) => {
const when = new Date(b.createdAt).toLocaleString();
const names = b.files.map((f) => escapeHtml(f.name)).join(', ');
const id = escapeHtml(b.id);
// The list is newest first, so the next row is the backup this one followed.
const prev = data.backups[i + 1];
const vsPrev = prev ? `<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${escapeHtml(prev.id)}','${id}')">Changes in this backup</button>` : '';
return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span>
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${escapeHtml(b.id)}')">Restore</button>
<button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${id}')">Restore</button>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${id}','current')">Compare with current</button>
${vsPrev}
<div class="set-row-desc">${names}</div></li>`;
});
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div><ul>${rows.join('')}</ul>`;
const options = (sel) => [`<option value="current"${sel === 'current' ? ' selected' : ''}>Current config</option>`]
.concat(data.backups.map((b) => `<option value="${escapeHtml(b.id)}"${sel === b.id ? ' selected' : ''}>${escapeHtml(new Date(b.createdAt).toLocaleString())} (${escapeHtml(b.reason)})</option>`))
.join('');
const picker = data.backups.length
? `<div class="set-row-desc">Compare <select id="configDiffFrom">${options(data.backups[0].id)}</select> with <select id="configDiffTo">${options('current')}</select>
<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff(app.$('configDiffFrom').value, app.$('configDiffTo').value)">Show changes</button></div>`
: '';
out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div>${picker}<ul>${rows.join('')}</ul><div id="configBackupDiff"></div>`;
},
/** What differs between two states of the config files: a backup id or `current` (the running config). */
async showConfigDiff(from, to) {
const out = this.$('configBackupDiff');
if (!out) return;
out.textContent = 'Comparing…';
const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
out.textContent = body?.error || 'Could not compare these';
return;
}
const label = (id) => {
if (id === 'current') return 'the current config';
const b = (this._configBackups || []).find((x) => x.id === id);
return b ? new Date(b.createdAt).toLocaleString() : id;
};
const d = body.data;
const verb = { added: 'added', removed: 'removed', changed: 'changed' };
const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => {
let detail;
if (f.detail === 'bytes') {
detail = '<div class="set-row-desc">Differs (not a key/value file, so no detail).</div>';
} else {
const rows = f.changes.map((c) => {
const val = (v) => `<code>${escapeHtml(v ?? '')}</code>`;
const text = c.redacted ? `${verb[c.kind]} <span class="set-row-desc">(value hidden: credential)</span>`
: c.kind === 'added' ? `added ${val(c.after)}`
: c.kind === 'removed' ? `removed (was ${val(c.before)})`
: `${val(c.before)} → ${val(c.after)}`;
return `<li><code>${escapeHtml(c.path)}</code>: ${text}</li>`;
});
const more = f.truncated ? '<li class="set-row-desc">More changes not shown.</li>' : '';
detail = f.changes.length ? `<ul>${rows.join('')}${more}</ul>` : '';
}
const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : '';
return `<div><b>${escapeHtml(f.name)}</b>${whole}${detail}</div>`;
});
const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name));
out.innerHTML = `<div><b>${escapeHtml(label(d.from))}</b> → <b>${escapeHtml(label(d.to))}</b>: ${
d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}</div>${files.join('')}${
same.length ? `<div class="set-row-desc">Unchanged: ${same.join(', ')}</div>` : ''}`;
},
async backupConfigNow() {
+40 -2
View File
@@ -4,10 +4,14 @@
* GET /api/config-backups — settings in effect, the folder, and the snapshots in it.
* POST /api/config-backups — snapshot now (a manual request always makes one).
* POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot).
* GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the
* running config (`current`, the default for `to`).
*
* The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three
* are admin only. Responses carry file NAMES and sizes, never file content. Restore replaces files the
* running server may have cached: `restartRequired` says which ones only apply after a restart.
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json` and any key that looks
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
* may have cached: `restartRequired` says which ones only apply after a restart.
*/
import { createRequire } from 'node:module';
@@ -18,10 +22,12 @@ import { isMultiUserMode } from '../../config/multiuser.js';
import { getDataDir } from '../../config/instance.js';
import {
CONFIG_BACKUP_FILES,
CONFIG_DIFF_CURRENT,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
createConfigBackup,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
pruneConfigBackups,
readConfigBackupSettings,
@@ -30,6 +36,7 @@ import {
type ConfigBackupResult,
type ConfigBackupSettings,
type ConfigBackupSummary,
type ConfigDiff,
} from '../../config-backup.js';
// Only recorded in each manifest; a missing package.json just leaves it blank.
@@ -113,6 +120,37 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
}
});
app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => {
const denied = deny(req, reply);
if (denied) return denied;
const q = req.query as { from?: string; to?: string };
if (typeof q.from !== 'string' || !q.from) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { dir } = await readEffectiveBackupSettings();
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
from: q.from,
to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT,
});
return { success: true, data };
} catch (err) {
if (err instanceof ConfigBackupNotFoundError) {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ConfigBackupCorruptError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
app.post('/api/config-backups/:id/restore', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;