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
+1
View File
@@ -20,6 +20,7 @@ const DEFAULT_RULES = [
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
{ event_type: 'firmware_update_available', enabled: true, threshold: null, cooldown_min: 1440 },
{ event_type: 'config_drift', enabled: false, threshold: null, cooldown_min: 15 },
];
async function ensureDefaultRules(): Promise<void> {
+147
View File
@@ -0,0 +1,147 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { BackupService } from '../services/BackupService';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
const backupService = new BackupService();
const SECTIONS = ['interfaces', 'vlans', 'routes', 'firewall', 'dhcp', 'dns'] as const;
interface SnapshotRow {
id: number;
device_id: number;
config_json: Record<string, unknown[]>;
config_text: string | null;
config_hash: string | null;
change_summary: string | null;
backup_id: number | null;
collected_at: string;
}
/**
* The .rsc text for a snapshot. New snapshots store it in `config_text`; legacy
* snapshots (captured before the export-based rewrite) only have the old
* section JSON, which we render to a readable form so old↔new diffs still work.
*/
function snapshotText(row: { config_text: string | null; config_json: Record<string, unknown[]> | null }): string {
if (row.config_text) return row.config_text;
const cfg = row.config_json || {};
const parts: string[] = ['# (legacy snapshot — reconstructed from collected data)'];
for (const name of SECTIONS) {
parts.push(`# ${name}`, JSON.stringify(cfg[name] ?? [], null, 2), '');
}
return parts.join('\n');
}
// GET /api/config-history/:deviceId — list snapshots (metadata only, no full payload)
router.get('/:deviceId', async (req: Request, res: Response) => {
const rows = await query(
`SELECT id, device_id, config_hash, change_summary, backup_id, collected_at,
(backup_id IS NOT NULL) AS has_backup
FROM device_configs
WHERE device_id = $1
ORDER BY collected_at DESC`,
[req.params.deviceId]
);
res.json(rows);
});
// GET /api/config-history/:deviceId/:id — full config for a single snapshot
router.get('/:deviceId/:id', async (req: Request, res: Response) => {
const row = await queryOne<SnapshotRow>(
`SELECT id, device_id, config_json, config_text, config_hash, change_summary, backup_id, collected_at
FROM device_configs WHERE id = $1 AND device_id = $2`,
[req.params.id, req.params.deviceId]
);
if (!row) return res.status(404).json({ error: 'Snapshot not found' });
return res.json({ ...row, text: snapshotText(row) });
});
// GET /api/config-history/:deviceId/:fromId/diff/:toId — both snapshots' .rsc text
router.get('/:deviceId/:fromId/diff/:toId', async (req: Request, res: Response) => {
const [from, to] = await Promise.all([
queryOne<SnapshotRow>(
`SELECT id, config_text, config_json, change_summary, collected_at FROM device_configs WHERE id = $1 AND device_id = $2`,
[req.params.fromId, req.params.deviceId]
),
queryOne<SnapshotRow>(
`SELECT id, config_text, config_json, change_summary, collected_at FROM device_configs WHERE id = $1 AND device_id = $2`,
[req.params.toId, req.params.deviceId]
),
]);
if (!from || !to) return res.status(404).json({ error: 'Snapshot not found' });
return res.json({
from: { id: from.id, text: snapshotText(from), collected_at: from.collected_at },
to: { id: to.id, text: snapshotText(to), collected_at: to.collected_at },
});
});
// POST /api/config-history/:deviceId/capture — capture a fresh snapshot on demand
router.post('/:deviceId/capture', requireWrite, async (req: Request, res: Response) => {
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.deviceId]);
if (!device) return res.status(404).json({ error: 'Device not found' });
const collector = new DeviceCollector(device);
try {
await collector.connect();
const created = await collector.snapshotConfig('manual');
const latest = await queryOne(
`SELECT id, config_hash, change_summary, backup_id, collected_at,
(backup_id IS NOT NULL) AS has_backup
FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 1`,
[device.id]
);
return res.json({
created,
message: created
? 'Snapshot captured'
: 'No configuration changes since the last snapshot',
snapshot: latest,
});
} catch (err) {
return res.status(500).json({ error: `Capture failed: ${(err as Error).message}` });
} finally {
collector.disconnect();
}
});
// POST /api/config-history/:deviceId/:id/rollback — restore the .rsc backup linked to this snapshot
router.post('/:deviceId/:id/rollback', requireWrite, async (req: Request, res: Response) => {
const snap = await queryOne<{ backup_id: number | null }>(
`SELECT backup_id FROM device_configs WHERE id = $1 AND device_id = $2`,
[req.params.id, req.params.deviceId]
);
if (!snap) return res.status(404).json({ error: 'Snapshot not found' });
if (!snap.backup_id) {
return res.status(400).json({ error: 'No restorable backup is linked to this snapshot' });
}
try {
await backupService.restoreBackup(snap.backup_id);
return res.json({ message: 'Rollback initiated successfully' });
} catch (err) {
return res.status(500).json({ error: `Rollback failed: ${(err as Error).message}` });
}
});
// DELETE /api/config-history/:deviceId/:id — delete a snapshot and its linked backup
router.delete('/:deviceId/:id', requireWrite, async (req: Request, res: Response) => {
const snap = await queryOne<{ id: number; backup_id: number | null }>(
`SELECT id, backup_id FROM device_configs WHERE id = $1 AND device_id = $2`,
[req.params.id, req.params.deviceId]
);
if (!snap) return res.status(404).json({ error: 'Snapshot not found' });
if (snap.backup_id) {
await backupService.deleteBackup(snap.backup_id).catch(() => { /* best-effort */ });
}
await query(`DELETE FROM device_configs WHERE id = $1`, [snap.id]);
return res.json({ message: 'Snapshot deleted' });
});
export default router;