mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
06182c6fce
commit
a2e49eb26d
10 files changed
+426
-16
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user