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>
148 lines
5.9 KiB
TypeScript
148 lines
5.9 KiB
TypeScript
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;
|