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:
co-authored by
Claude Opus 4.8
parent
4ff5ed805c
commit
367034c2d6
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user