feat: config history (diff/rollback) + friendly backup scheduling (v0.12.3-beta)

Configuration History:
- Per-device snapshots from the full RouterOS /export (.rsc) — config-only, so
  counters/operational state no longer cause phantom changes; deduped by content hash
- New Config History tab: snapshot timeline, readable line diff, on-demand Capture
  (with honest 'no changes' feedback), one-click rollback via the linked .rsc backup
- Snapshot and its backup are one artifact: badged 'Config Snapshot' on the Backups
  page, FK ON DELETE CASCADE keeps them in lockstep; delete is gated by a warning modal
- New config_drift alert event (off by default); periodic snapshot poll with
  configurable cadence/retention (config_snapshot_interval_min/_retention)
- New endpoints under /api/config-history (list/get/diff/capture/rollback/delete)

Scheduled Backups:
- Replaced the raw cron textbox in Settings with Frequency (Daily/Weekly/Monthly)
  + time + day pickers; cron is generated/parsed behind the scenes
- Fixed cronMatchesNow to evaluate day-of-month/month/day-of-week (it previously
  only checked minute/hour, so weekly/monthly schedules ran every day)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-06 16:01:58 -07:00
co-authored by Claude Opus 4.8
parent 4ff5ed805c
commit 367034c2d6
20 changed files with 1075 additions and 81 deletions
+136 -19
View File
@@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import { RouterOSClient } from './RouterOSClient';
import { query, queryOne } from '../../config/database';
import { getWriteApi } from '../../config/influxdb';
@@ -5,6 +6,8 @@ import { Point } from '@influxdata/influxdb-client';
import { decrypt } from '../../utils/crypto';
import { lookupVendor } from '../../utils/oui';
import { buildServerArpMap } from '../../utils/serverArp';
import { BackupService } from '../BackupService';
import { alertService } from '../AlertService';
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
const TOPOLOGY_LINK_LIMITS = {
@@ -1061,36 +1064,150 @@ export class DeviceCollector {
// ─── Full config snapshot ─────────────────────────────────────────────────
async saveFullConfig(): Promise<void> {
/**
* Strip the volatile header RouterOS stamps onto every `/export` (a line like
* `# 2026-06-06 14:26:09 by RouterOS 7.23.1`). Everything else in the export is
* configuration, so what remains hashes stably across exports of an unchanged
* device. We also trim trailing whitespace for a clean diff.
*/
private static normalizeRsc(rsc: string): string {
return rsc
.split('\n')
.filter((line) => !/^# \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} by RouterOS/.test(line))
.join('\n')
.replace(/\s+$/, '');
}
/** Compact summary of an .rsc change as added/removed line counts. */
private static summarizeRscDiff(oldText: string, newText: string): string {
const counts = (text: string): Map<string, number> => {
const m = new Map<string, number>();
for (const raw of text.split('\n')) {
const line = raw.trim();
if (line) m.set(line, (m.get(line) ?? 0) + 1);
}
return m;
};
const oldC = counts(oldText);
const newC = counts(newText);
let added = 0;
let removed = 0;
for (const [line, n] of newC) added += Math.max(0, n - (oldC.get(line) ?? 0));
for (const [line, n] of oldC) removed += Math.max(0, n - (newC.get(line) ?? 0));
if (!added && !removed) return 'Settings modified';
const parts: string[] = [];
if (added) parts.push(`+${added}`);
if (removed) parts.push(`−${removed}`);
return `${parts.join(' / ')} line${added + removed === 1 ? '' : 's'}`;
}
/** Drop snapshots beyond the retention count, removing any linked .rsc backups too. */
private async pruneConfigSnapshots(): Promise<void> {
const retRow = await queryOne<{ value: number }>(
`SELECT value FROM app_settings WHERE key = 'config_snapshot_retention'`
);
const retention = typeof retRow?.value === 'number' && retRow.value > 0 ? retRow.value : 30;
const stale = await query<{ id: number; backup_id: number | null }>(
`SELECT id, backup_id FROM device_configs
WHERE device_id = $1 AND id NOT IN (
SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT $2
)`,
[this.device.id, retention]
);
if (!stale.length) return;
const backupService = new BackupService();
for (const row of stale) {
// Deleting the backup cascades to the device_configs row (FK ON DELETE CASCADE).
if (row.backup_id) {
await backupService.deleteBackup(row.backup_id).catch(() => { /* best-effort */ });
}
}
// Remove any rows that had no linked backup (cascade only covers linked ones).
await query(`DELETE FROM device_configs WHERE id = ANY($1::int[])`, [stale.map((r) => r.id)]);
}
/**
* Capture a config snapshot from the device's `/export` .rsc — the canonical,
* config-only, restorable representation. Deduplicates by content hash (no-op
* when nothing changed), stores the .rsc as a linked backup so the snapshot is
* restorable, and fires a `config_drift` alert when the config changed.
* Returns true when a new snapshot row was created, false on dedup/failure.
*/
async snapshotConfig(reason = 'sync'): Promise<boolean> {
try {
const [interfaces, vlans, routes, firewall, dhcp, dns] = await Promise.all([
this.client.execute('/interface/print', { detail: '' }).catch(() => []),
this.client.execute('/interface/bridge/vlan/print', { detail: '' }).catch(() => []),
this.client.execute('/ip/route/print', { detail: '' }).catch(() => []),
this.client.execute('/ip/firewall/filter/print', { detail: '' }).catch(() => []),
this.client.execute('/ip/dhcp-server/print', { detail: '' }).catch(() => []),
this.client.execute('/ip/dns/print', { detail: '' }).catch(() => []),
]);
const backupService = new BackupService();
const device = this.device as unknown as import('../BackupService').BackupDevice;
const config = { interfaces, vlans, routes, firewall, dhcp, dns };
// The export requires SSH; without it there's no restorable snapshot to take.
let rsc: string;
try {
rsc = await backupService.exportConfig(device);
} catch (e) {
console.warn(`[${this.device.name}] config snapshot skipped — /export failed: ${(e as Error).message}`);
return false;
}
await query(
`INSERT INTO device_configs (device_id, config_json) VALUES ($1, $2)`,
[this.device.id, JSON.stringify(config)]
);
const text = DeviceCollector.normalizeRsc(rsc);
const hash = createHash('sha256').update(text).digest('hex');
// Keep only last 10 full config snapshots per device
await query(
`DELETE FROM device_configs WHERE device_id = $1 AND id NOT IN (
SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 10
)`,
const latest = await queryOne<{ config_hash: string | null; config_text: string | null }>(
`SELECT config_hash, config_text FROM device_configs
WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 1`,
[this.device.id]
);
// No change since the last snapshot — nothing to store.
if (latest && latest.config_hash === hash) return false;
const isFirst = !latest;
const summary = isFirst
? 'Initial snapshot'
: DeviceCollector.summarizeRscDiff(latest.config_text ?? '', text);
// Persist the exact .rsc we just hashed as a restorable backup; the snapshot
// and this backup are one artifact (FK cascade keeps them in lockstep).
let backupId: number | null = null;
try {
backupId = await backupService.createBackupFromContent(
device,
rsc,
`Config snapshot (${reason})`,
'config-snapshot'
);
} catch (e) {
console.warn(`[${this.device.name}] snapshot backup unavailable: ${(e as Error).message}`);
}
await query(
`INSERT INTO device_configs (device_id, config_json, config_text, config_hash, change_summary, backup_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[this.device.id, '{}', text, hash, summary, backupId]
);
await this.pruneConfigSnapshots();
// Alert on drift — skip the very first snapshot (nothing to compare against).
if (!isFirst) {
alertService.dispatch('config_drift', `Configuration changed on ${this.device.name}: ${summary}`, {
deviceId: this.device.id,
deviceName: this.device.name,
details: summary,
}).catch(() => { /* alerting is best-effort */ });
}
return true;
} catch (err) {
console.error(`[${this.device.name}] Failed to save config:`, err);
return false;
}
}
/** Back-compat entry point used by the full-sync flow. */
async saveFullConfig(): Promise<void> {
await this.snapshotConfig('full-sync');
}
async updateDeviceStatus(status: string): Promise<void> {
await query(
`UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`,