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
@@ -9,7 +9,7 @@ import { alertService } from './AlertService';
|
||||
|
||||
interface PollJob {
|
||||
deviceId: number;
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan';
|
||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan' | 'configsnap';
|
||||
}
|
||||
|
||||
export class PollerService {
|
||||
@@ -73,6 +73,8 @@ export class PollerService {
|
||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
||||
} else if (type === 'apscan') {
|
||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
||||
} else if (type === 'configsnap') {
|
||||
await this.slowQueue.add('device-configsnap', jobData, { attempts: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,8 @@ export class PollerService {
|
||||
const apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
|
||||
const backupScheduleEnabled = appSettings['backup_schedule_enabled'] === true;
|
||||
const backupScheduleCron = (appSettings['backup_schedule_cron'] as string) || '0 2 * * *';
|
||||
const configSnapEnabled = appSettings['config_snapshot_enabled'] !== false;
|
||||
const configSnapIntervalMin = (appSettings['config_snapshot_interval_min'] as number) || 60;
|
||||
|
||||
const devices = await query<DeviceRow>(
|
||||
`SELECT * FROM devices WHERE status != 'disabled'`
|
||||
@@ -153,6 +157,20 @@ export class PollerService {
|
||||
await this.setTimestamp(apScanKey, now);
|
||||
}
|
||||
}
|
||||
|
||||
// Config snapshot — capture config history & detect drift, user-configured
|
||||
// interval (default 60min). The snapshot itself dedups by content hash, so
|
||||
// this only stores a new row when the device config actually changed.
|
||||
if (configSnapEnabled) {
|
||||
const configKey = `poll:configsnap:${device.id}`;
|
||||
const lastConfig = await this.getTimestamp(configKey);
|
||||
if (now - lastConfig > configSnapIntervalMin * 60_000) {
|
||||
await this.scheduleDeviceSync(device.id, 'configsnap');
|
||||
// Keep the gate key alive for the full interval (+ buffer) so the
|
||||
// snapshot honours the configured cadence rather than the default TTL.
|
||||
await this.setTimestamp(configKey, now, configSnapIntervalMin * 60 + 120);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse DNS enrichment — global, runs every 5 minutes when enabled
|
||||
@@ -235,12 +253,17 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if the 5-part cron expression matches the current minute/hour.
|
||||
// Supports: *, exact numbers, comma lists, ranges (a-b), and step values (*/n).
|
||||
// Returns true if the 5-part cron expression matches the current time.
|
||||
// Evaluates all five fields — minute, hour, day-of-month, month, day-of-week —
|
||||
// so weekly (e.g. `0 3 * * 0`) and monthly (`0 3 1 * *`) schedules fire only on
|
||||
// the right day, not every day. Supports: *, exact numbers, comma lists,
|
||||
// ranges (a-b), and step values (*/n). Day-of-month and day-of-week follow the
|
||||
// standard cron rule: when both are restricted the job runs if either matches;
|
||||
// when only one is restricted, only that one must match.
|
||||
private cronMatchesNow(cron: string): boolean {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return false;
|
||||
const [minuteField, hourField] = parts;
|
||||
const [minuteField, hourField, domField, monthField, dowField] = parts;
|
||||
const now = new Date();
|
||||
const matchField = (field: string, val: number): boolean => {
|
||||
if (field === '*') return true;
|
||||
@@ -257,7 +280,21 @@ export class PollerService {
|
||||
return Number(f) === val;
|
||||
});
|
||||
};
|
||||
return matchField(minuteField, now.getMinutes()) && matchField(hourField, now.getHours());
|
||||
|
||||
if (!matchField(minuteField, now.getMinutes())) return false;
|
||||
if (!matchField(hourField, now.getHours())) return false;
|
||||
if (!matchField(monthField, now.getMonth() + 1)) return false; // cron months are 1-12
|
||||
|
||||
// Day-of-month (1-31) and day-of-week (0-6, Sun=0). getDay() returns 0 for Sunday.
|
||||
const domRestricted = domField !== '*';
|
||||
const dowRestricted = dowField !== '*';
|
||||
const domMatch = matchField(domField, now.getDate());
|
||||
const dowMatch = matchField(dowField, now.getDay());
|
||||
let dayMatch: boolean;
|
||||
if (!domRestricted && !dowRestricted) dayMatch = true;
|
||||
else if (domRestricted && dowRestricted) dayMatch = domMatch || dowMatch;
|
||||
else dayMatch = domRestricted ? domMatch : dowMatch;
|
||||
return dayMatch;
|
||||
}
|
||||
|
||||
private async runScheduledBackups(): Promise<void> {
|
||||
@@ -366,10 +403,10 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async setTimestamp(key: string, ts: number): Promise<void> {
|
||||
private async setTimestamp(key: string, ts: number, ttlSec = 600): Promise<void> {
|
||||
try {
|
||||
const { redis } = await import('../config/redis');
|
||||
await redis.set(key, String(ts), 'EX', 600);
|
||||
await redis.set(key, String(ts), 'EX', ttlSec);
|
||||
} catch { /* redis unavailable — timestamp not cached */ }
|
||||
}
|
||||
|
||||
@@ -597,6 +634,21 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async processConfigSnapJob(data: PollJob): Promise<void> {
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
await collector.snapshotConfig('scheduled');
|
||||
} catch (err) {
|
||||
console.error(`[Poller] Config snapshot failed for ${device.name}:`, (err as Error).message);
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async processSlowJob(data: PollJob): Promise<void> {
|
||||
if (data.type === 'spectral') {
|
||||
return this.processSpectralJob(data);
|
||||
@@ -604,6 +656,9 @@ export class PollerService {
|
||||
if (data.type === 'apscan') {
|
||||
return this.processApScanJob(data);
|
||||
}
|
||||
if (data.type === 'configsnap') {
|
||||
return this.processConfigSnapJob(data);
|
||||
}
|
||||
|
||||
const device = await this.getDevice(data.deviceId);
|
||||
if (!device) return;
|
||||
|
||||
Reference in New Issue
Block a user