From 367034c2d6aaf3ae53c235fd14cf03e345bf7780 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:01:58 -0700 Subject: [PATCH] feat: config history (diff/rollback) + friendly backup scheduling (v0.12.3-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 15 +- backend/package.json | 2 +- backend/src/db/migrate.ts | 17 + backend/src/index.ts | 2 + backend/src/routes/alerts.ts | 1 + backend/src/routes/configHistory.ts | 147 +++++++++ backend/src/services/AlertService.ts | 5 +- backend/src/services/BackupService.ts | 25 +- backend/src/services/PollerService.ts | 69 +++- .../src/services/mikrotik/DeviceCollector.ts | 155 +++++++-- frontend/package.json | 2 +- .../device-detail/ConfigHistoryTab.tsx | 296 ++++++++++++++++++ frontend/src/pages/BackupsPage.tsx | 97 +++++- frontend/src/pages/DeviceDetailPage.tsx | 5 +- frontend/src/pages/SettingsPage.tsx | 144 ++++++--- frontend/src/services/api.ts | 20 ++ frontend/src/types/index.ts | 34 +- frontend/src/utils/backupSchedule.ts | 75 +++++ frontend/src/utils/lineDiff.ts | 43 +++ frontend/src/version.ts | 2 +- 20 files changed, 1075 insertions(+), 81 deletions(-) create mode 100644 backend/src/routes/configHistory.ts create mode 100644 frontend/src/components/device-detail/ConfigHistoryTab.tsx create mode 100644 frontend/src/utils/backupSchedule.ts create mode 100644 frontend/src/utils/lineDiff.ts diff --git a/README.md b/README.md index 1e83b47..a66f764 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.11.11_Beta-blue) +![Version](https://img.shields.io/badge/version-0.12.3_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) @@ -168,9 +168,19 @@ Per-device diagnostic and testing tools accessible from the device detail Tools ### Backups - Trigger RouterOS backups on demand via SSH -- **Scheduled automatic backups** — configurable cron schedule (e.g. nightly at 2 AM) with toggle in Settings; runs for all online devices +- **Scheduled automatic backups** — pick a daily, weekly, or monthly schedule and time in Settings (no cron knowledge required); runs for all online devices - Download and manage backup files from the UI +### Configuration History +- Per-device config snapshots based on the device's full RouterOS `/export` — config-only, so counters and operational state never create noise — captured automatically whenever the configuration changes (deduplicated by content hash, so a snapshot is only stored on a real change) +- **Config History** tab on each device with a timeline of snapshots and a one-line summary of what changed (e.g. `+8 / −3 lines`) +- Side-by-side **line diff** between any two snapshots, rendered as readable RouterOS commands +- **Capture snapshot** button for an on-demand checkpoint, with honest feedback when nothing has changed since the last one +- **One-click rollback** — each snapshot links a restorable `.rsc` backup, so rolling back reuses the proven restore path +- The snapshot and its backup are one artifact: snapshot backups are clearly badged **Config Snapshot** on the Backups page, and deleting one (from either place) removes the other so they never drift apart +- Fires a `config_drift` alert (off by default) when a device's configuration changes +- Snapshot cadence and retention configurable via `config_snapshot_interval_min` / `config_snapshot_retention` + ### Alerts Configurable alert rules with cooldown periods: - Device online / offline @@ -179,6 +189,7 @@ Configurable alert rules with cooldown periods: - Firmware update available - RouterOS log errors and warnings - New device discovered +- Configuration changed (drift detection) Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram** diff --git a/backend/package.json b/backend/package.json index b823465..fb56ae1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.11.11-beta", + "version": "0.12.3-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 73d19c0..9892f9b 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -183,6 +183,20 @@ ALTER TABLE devices ADD COLUMN IF NOT EXISTS latest_ros_version VARCHAR(20); ALTER TABLE devices ADD COLUMN IF NOT EXISTS routerboard_upgrade_available BOOLEAN DEFAULT FALSE; ALTER TABLE devices ADD COLUMN IF NOT EXISTS upgrade_firmware_version VARCHAR(20); +-- Config history / drift detection: each snapshot stores the canonical /export +-- .rsc text (config_text), deduped by content hash, and links to the restorable +-- backup that holds the same .rsc. The snapshot and its backup are one artifact, +-- so deleting the backup cascades to remove the snapshot (kept consistent). +ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS config_hash VARCHAR(64); +ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS change_summary TEXT; +ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS config_text TEXT; +ALTER TABLE device_configs ADD COLUMN IF NOT EXISTS backup_id INTEGER REFERENCES backups(id) ON DELETE SET NULL; +-- Upgrade the backup_id FK from SET NULL to CASCADE so a snapshot and its backup +-- stay in lockstep (deleting the backup removes the now-unrestorable snapshot). +ALTER TABLE device_configs DROP CONSTRAINT IF EXISTS device_configs_backup_id_fkey; +ALTER TABLE device_configs ADD CONSTRAINT device_configs_backup_id_fkey + FOREIGN KEY (backup_id) REFERENCES backups(id) ON DELETE CASCADE; + -- Cached IPv4/IPv6 addresses from /ip/address (per device) for topology resolution: -- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even -- when the address is not the device's management IP. @@ -407,6 +421,9 @@ const DEFAULT_SETTINGS = [ { key: 'ap_scan_interval_hours', value: 24 }, { key: 'login_rate_limit_window_sec', value: 60 }, { key: 'login_rate_limit_max', value: 10 }, + { key: 'config_snapshot_enabled', value: true }, + { key: 'config_snapshot_interval_min', value: 60 }, + { key: 'config_snapshot_retention', value: 30 }, ]; export async function runMigrations(): Promise { diff --git a/backend/src/index.ts b/backend/src/index.ts index f6bb679..2027760 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,6 +44,7 @@ import switchesRoutes from './routes/switches'; import routersRoutes from './routes/routers'; import alertsRoutes from './routes/alerts'; import configTemplatesRoutes from './routes/configTemplates'; +import configHistoryRoutes from './routes/configHistory'; import wirelessRoutes from './routes/wireless'; import networkServicesRoutes from './routes/networkServices'; import credentialPresetsRoutes from './routes/credentialPresets'; @@ -218,6 +219,7 @@ app.use('/api/audit-log', auditLogRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/maintenance-windows', maintenanceWindowsRoutes); app.use('/api/config-templates', configTemplatesRoutes); +app.use('/api/config-history', configHistoryRoutes); app.use('/api/system', systemRoutes); // ─── Error Handler ──────────────────────────────────────────────────────────── diff --git a/backend/src/routes/alerts.ts b/backend/src/routes/alerts.ts index b7d24b8..8d41b20 100644 --- a/backend/src/routes/alerts.ts +++ b/backend/src/routes/alerts.ts @@ -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 { diff --git a/backend/src/routes/configHistory.ts b/backend/src/routes/configHistory.ts new file mode 100644 index 0000000..ff19476 --- /dev/null +++ b/backend/src/routes/configHistory.ts @@ -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; + 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 | 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( + `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( + `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( + `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(`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; diff --git a/backend/src/services/AlertService.ts b/backend/src/services/AlertService.ts index 969ae54..9136cd4 100644 --- a/backend/src/services/AlertService.ts +++ b/backend/src/services/AlertService.ts @@ -12,7 +12,8 @@ export type AlertEventType = | 'high_memory' | 'cert_expiry' | 'device_discovered' - | 'firmware_update_available'; + | 'firmware_update_available' + | 'config_drift'; export interface AlertContext { deviceId?: number; @@ -51,6 +52,7 @@ const EVENT_LABELS: Record = { cert_expiry: 'Certificate Expiring Soon', device_discovered: 'New Device Discovered', firmware_update_available: 'Firmware Update Available', + config_drift: 'Configuration Changed', }; const EVENT_EMOJI: Record = { @@ -63,6 +65,7 @@ const EVENT_EMOJI: Record = { cert_expiry: '🔐', device_discovered: '🔍', firmware_update_available: '🔄', + config_drift: '📝', }; export class AlertService { diff --git a/backend/src/services/BackupService.ts b/backend/src/services/BackupService.ts index fea41c3..5bba669 100644 --- a/backend/src/services/BackupService.ts +++ b/backend/src/services/BackupService.ts @@ -25,19 +25,23 @@ export class BackupService { } } - async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise { + /** Run `/export compact` over SSH and return the raw .rsc text (no file written). */ + async exportConfig(device: BackupDevice): Promise { const sshUser = device.ssh_username || device.api_username; const sshPass = device.ssh_password_encrypted ? decrypt(device.ssh_password_encrypted) : decrypt(device.api_password_encrypted); - const exportContent = await this.sshExport( - device.ip_address, - device.ssh_port || 22, - sshUser, - sshPass - ); + return this.sshExport(device.ip_address, device.ssh_port || 22, sshUser, sshPass); + } + /** Persist already-fetched .rsc text as a backup file + DB row. Returns the backup id. */ + async createBackupFromContent( + device: BackupDevice, + content: string, + notes?: string, + type: string = 'manual' + ): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`; const filePath = path.join(BACKUPS_DIR, String(device.id), filename); @@ -48,7 +52,7 @@ export class BackupService { fs.mkdirSync(deviceDir, { recursive: true }); } - fs.writeFileSync(filePath, exportContent, 'utf8'); + fs.writeFileSync(filePath, content, 'utf8'); const stats = fs.statSync(filePath); const rows = await query<{ id: number }>( @@ -60,6 +64,11 @@ export class BackupService { return rows[0].id; } + async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise { + const exportContent = await this.exportConfig(device); + return this.createBackupFromContent(device, exportContent, notes, type); + } + async restoreBackup(backupId: number): Promise { const backup = await query<{ file_path: string; diff --git a/backend/src/services/PollerService.ts b/backend/src/services/PollerService.ts index bd20dfd..89fc2fb 100644 --- a/backend/src/services/PollerService.ts +++ b/backend/src/services/PollerService.ts @@ -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( `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 { @@ -366,10 +403,10 @@ export class PollerService { } } - private async setTimestamp(key: string, ts: number): Promise { + private async setTimestamp(key: string, ts: number, ttlSec = 600): Promise { 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 { + 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 { 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; diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index d349430..3f55bf8 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -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 { + /** + * 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 => { + const m = new Map(); + 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 { + 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 { 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 { + await this.snapshotConfig('full-sync'); + } + async updateDeviceStatus(status: string): Promise { await query( `UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`, diff --git a/frontend/package.json b/frontend/package.json index 0948ba3..ad42f98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.11.11-beta", + "version": "0.12.3-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/device-detail/ConfigHistoryTab.tsx b/frontend/src/components/device-detail/ConfigHistoryTab.tsx new file mode 100644 index 0000000..5c50be5 --- /dev/null +++ b/frontend/src/components/device-detail/ConfigHistoryTab.tsx @@ -0,0 +1,296 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + History, Camera, RotateCcw, Trash2, AlertCircle, CheckCircle, Loader2, GitCompare, +} from 'lucide-react'; +import { configHistoryApi } from '../../services/api'; +import { useCanWrite } from '../../hooks/useCanWrite'; +import { lineDiff } from '../../utils/lineDiff'; +import { format } from 'date-fns'; +import clsx from 'clsx'; + +interface Props { + deviceId: number; +} + +export default function ConfigHistoryTab({ deviceId }: Props) { + const queryClient = useQueryClient(); + const canWrite = useCanWrite(); + + const [fromId, setFromId] = useState(null); + const [toId, setToId] = useState(null); + const [rollbackConfirm, setRollbackConfirm] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const { data: history = [], isLoading } = useQuery({ + queryKey: ['config-history', deviceId], + queryFn: () => configHistoryApi.list(deviceId).then((r) => r.data), + refetchInterval: 60_000, + }); + + // Default the comparison to "previous → latest" until the user picks otherwise. + // Derived during render (no effect) so an unselected dropdown still shows a diff. + const effectiveToId = toId ?? history[0]?.id ?? null; + const effectiveFromId = fromId ?? history[1]?.id ?? null; + + const { data: diff } = useQuery({ + queryKey: ['config-diff', deviceId, effectiveFromId, effectiveToId], + queryFn: () => configHistoryApi.diff(deviceId, effectiveFromId!, effectiveToId!).then((r) => r.data), + enabled: effectiveFromId !== null && effectiveToId !== null && effectiveFromId !== effectiveToId, + }); + + const flash = (msg: string) => { + setSuccess(msg); + setTimeout(() => setSuccess(''), 4000); + }; + const fail = (err: unknown, fallback: string) => { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || fallback); + setTimeout(() => setError(''), 6000); + }; + + const captureMutation = useMutation({ + mutationFn: () => configHistoryApi.capture(deviceId), + onSuccess: (r) => { + queryClient.invalidateQueries({ queryKey: ['config-history', deviceId] }); + flash(r.data.message || 'Snapshot captured'); + }, + onError: (err) => fail(err, 'Capture failed'), + }); + + const rollbackMutation = useMutation({ + mutationFn: (id: number) => configHistoryApi.rollback(deviceId, id), + onSuccess: () => { + setRollbackConfirm(null); + flash('Rollback initiated — the device is importing the restored configuration.'); + }, + onError: (err) => { + setRollbackConfirm(null); + fail(err, 'Rollback failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => configHistoryApi.delete(deviceId, id), + onSuccess: () => { + setDeleteConfirm(null); + queryClient.invalidateQueries({ queryKey: ['config-history', deviceId] }); + }, + onError: (err) => { + setDeleteConfirm(null); + fail(err, 'Delete failed'); + }, + }); + + const diffRows = diff ? lineDiff(diff.from.text, diff.to.text) : []; + const addCount = diffRows.filter((r) => r.type === 'add').length; + const delCount = diffRows.filter((r) => r.type === 'del').length; + + return ( +
+ {/* Header */} +
+
+ +

Configuration History

+
+ {canWrite && ( + + )} +
+ +

+ Snapshots are captured automatically when the device configuration changes. Each one links a restorable + backup so you can roll back to it. +

+ + {error && ( +
+ +

{error}

+
+ )} + {success && ( +
+ +

{success}

+
+ )} + + {/* History table */} + {isLoading ? ( +
+ +
+ ) : history.length === 0 ? ( +
+ +
+

No configuration snapshots yet

+

+ One will appear after the next config change, or capture one now. +

+
+
+ ) : ( +
+ + + + + + + + + + + {history.map((s) => ( + + + + + + + ))} + +
CapturedChangeRollbackActions
+ {format(new Date(s.collected_at), 'MMM d, yyyy HH:mm:ss')} + + {s.change_summary || '—'} + + {s.has_backup ? ( + + available + + ) : ( + + no backup + + )} + +
+ {canWrite && (rollbackConfirm === s.id ? ( + + + + + ) : ( + + ))} + {canWrite && (deleteConfirm === s.id ? ( + + + + + ) : ( + + ))} +
+
+
+ )} + + {/* Compare / diff */} + {history.length >= 2 && ( +
+
+ +

Compare snapshots

+
+
+ + → + + {diff && ( + + +{addCount}{' '} + −{delCount} + + )} +
+ + {effectiveFromId === effectiveToId ? ( +

Select two different snapshots to compare.

+ ) : diff ? ( + addCount + delCount === 0 ? ( +

No differences between these snapshots.

+ ) : ( +
+                {diffRows.map((row, idx) => (
+                  
+ {row.type === 'add' ? '+ ' : row.type === 'del' ? '- ' : ' '} + {row.text} +
+ ))} +
+ ) + ) : ( +
+ Loading diff… +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/BackupsPage.tsx b/frontend/src/pages/BackupsPage.tsx index a267c7d..6e97f0e 100644 --- a/frontend/src/pages/BackupsPage.tsx +++ b/frontend/src/pages/BackupsPage.tsx @@ -1,8 +1,9 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, Loader2 } from 'lucide-react'; +import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react'; import { backupsApi, devicesApi } from '../services/api'; import { useCanWrite } from '../hooks/useCanWrite'; +import type { Backup } from '../types'; import { format } from 'date-fns'; import clsx from 'clsx'; @@ -20,6 +21,8 @@ export default function BackupsPage() { const [showCreateForm, setShowCreateForm] = useState(false); const [restoreConfirm, setRestoreConfirm] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); + // Config-snapshot backups are confirmed via a modal (deleting also removes the snapshot). + const [snapshotDeleteTarget, setSnapshotDeleteTarget] = useState(null); const [error, setError] = useState(''); const { data: backups = [], isLoading } = useQuery({ @@ -61,6 +64,12 @@ export default function BackupsPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['backups'] }); setDeleteConfirm(null); + setSnapshotDeleteTarget(null); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(msg || 'Delete failed'); + setSnapshotDeleteTarget(null); }, }); @@ -180,6 +189,7 @@ export default function BackupsPage() { Device Filename + Type Size Created Notes @@ -195,6 +205,30 @@ export default function BackupsPage() { {backup.filename} + + + {backup.backup_type === 'config-snapshot' + ? 'Config Snapshot' + : backup.backup_type === 'scheduled' + ? 'Scheduled' + : 'Manual'} + + {formatBytes(backup.size_bytes)} @@ -257,7 +291,11 @@ export default function BackupsPage() { ) : ( + +
+

+ This backup is tied to a Config History snapshot for{' '} + {snapshotDeleteTarget.device_name || 'this device'}. +

+
+

+ Deleting it will also permanently remove that snapshot from the + device's Config History, and you will no longer be able to roll back to it. +

+
+

+ {snapshotDeleteTarget.filename} +

+
+ + +
+
+ + + )} ); } diff --git a/frontend/src/pages/DeviceDetailPage.tsx b/frontend/src/pages/DeviceDetailPage.tsx index 753be5a..cb425b0 100644 --- a/frontend/src/pages/DeviceDetailPage.tsx +++ b/frontend/src/pages/DeviceDetailPage.tsx @@ -13,12 +13,13 @@ import VlansTab from '../components/device-detail/VlansTab'; import RoutingTab from '../components/device-detail/RoutingTab'; import FirewallTab from '../components/device-detail/FirewallTab'; import SystemConfigTab from '../components/device-detail/SystemConfigTab'; +import ConfigHistoryTab from '../components/device-detail/ConfigHistoryTab'; import HardwareTab from '../components/device-detail/HardwareTab'; import ToolsTab from '../components/device-detail/ToolsTab'; import RadiosTab from '../components/device-detail/RadiosTab'; import clsx from 'clsx'; -type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'config' | 'hardware' | 'tools' | 'radios'; +type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'config' | 'config-history' | 'hardware' | 'tools' | 'radios'; function formatUptime(raw: string): string { if (!raw) return '—'; @@ -106,6 +107,7 @@ export default function DeviceDetailPage() { { key: 'routing', label: 'Routing' }, { key: 'firewall', label: 'Firewall' }, { key: 'config', label: 'Config' }, + { key: 'config-history', label: 'Config History' }, { key: 'hardware', label: 'Hardware' }, ...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []), { key: 'tools', label: 'Tools' }, @@ -370,6 +372,7 @@ export default function DeviceDetailPage() { {activeTab === 'routing' && } {activeTab === 'firewall' && } {activeTab === 'config' && } + {activeTab === 'config-history' && } {activeTab === 'hardware' && } {activeTab === 'radios' && } {activeTab === 'tools' && } diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 138b6aa..522c2fb 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -10,6 +10,10 @@ import type { CertInfo, AlertRule, AlertChannel } from '../services/api'; import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import type { User, UserRole } from '../types'; +import { + parseBackupCron, scheduleToCron, describeBackupSchedule, WEEKDAY_NAMES, ordinal, + type BackupFrequency, +} from '../utils/backupSchedule'; import clsx from 'clsx'; import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings'; @@ -689,47 +693,113 @@ export default function SettingsPage() {

Scheduled Backups

- Automatically backs up all online devices on a cron schedule via SSH export. + Automatically backs up all online devices on a schedule via SSH export. Backups appear in the Backups section with type scheduled.

-
-
-
Enable scheduled backups
- +
+ +
+ {/* Frequency */} +
+ + +
+ + {/* Day selector — weekly or monthly */} + {sched.frequency === 'weekly' && ( +
+ + +
+ )} + {sched.frequency === 'monthly' && ( +
+ + +
+ )} + + {/* Time of day */} +
+ + { + const [h, m] = e.target.value.split(':').map((n) => parseInt(n, 10)); + if (Number.isNaN(h) || Number.isNaN(m)) return; + apply({ ...sched, hour: h, minute: m }); + }} + /> +
+
+ +

+ {enabled + ? <>Runs {describeBackupSchedule(sched).toLowerCase()}, server time. + : 'Enable to choose a backup schedule.'} +

+ {sched.frequency === 'monthly' && ( +

+ Days are limited to 1–28 so the backup runs every month, including February. +

)} - > - - -
-
-
-
Cron schedule
-
Standard 5-part cron expression (minute hour day month weekday)
- updateSettingsMutation.mutate({ backup_schedule_cron: e.target.value })} - placeholder="0 2 * * *" - disabled={!isAdmin || !settings['backup_schedule_enabled']} - /> -
-

- Examples: 0 2 * * * = daily at 2:00 AM  ·  - 0 3 * * 0 = weekly Sunday at 3:00 AM  ·  - 0 */6 * * * = every 6 hours -

-
+ ); + })()} )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index cbfaca1..b2e1f51 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -17,6 +17,10 @@ import type { IpAddress, PortVlanConfig, PortMonitorData, + ConfigSnapshotMeta, + ConfigSnapshotFull, + ConfigDiffResponse, + ConfigCaptureResult, } from '../types'; const api = axios.create({ @@ -376,6 +380,22 @@ export const backupsApi = { delete: (id: number) => api.delete(`/backups/${id}`), }; +// ─── Config History ──────────────────────────────────────────────────────────── +export const configHistoryApi = { + list: (deviceId: number) => + api.get(`/config-history/${deviceId}`), + get: (deviceId: number, id: number) => + api.get(`/config-history/${deviceId}/${id}`), + diff: (deviceId: number, fromId: number, toId: number) => + api.get(`/config-history/${deviceId}/${fromId}/diff/${toId}`), + capture: (deviceId: number) => + api.post(`/config-history/${deviceId}/capture`), + rollback: (deviceId: number, id: number) => + api.post<{ message: string }>(`/config-history/${deviceId}/${id}/rollback`), + delete: (deviceId: number, id: number) => + api.delete(`/config-history/${deviceId}/${id}`), +}; + // ─── Metrics ───────────────────────────────────────────────────────────────── export const metricsApi = { summary: () => api.get('/metrics/summary'), diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c76266d..50c8fe3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -187,7 +187,7 @@ export interface Backup { device_name?: string; filename: string; size_bytes?: number; - backup_type: 'manual' | 'scheduled'; + backup_type: 'manual' | 'scheduled' | 'config-snapshot'; notes?: string; created_at: string; } @@ -339,3 +339,35 @@ export interface WirelessMetricPoint { registered_clients?: number; noise_floor?: number; } + +export interface ConfigSnapshotMeta { + id: number; + device_id: number; + config_hash: string | null; + change_summary: string | null; + backup_id: number | null; + has_backup: boolean; + collected_at: string; +} + +export interface ConfigSnapshotFull extends ConfigSnapshotMeta { + config_text: string | null; + text: string; +} + +export interface ConfigDiffSide { + id: number; + text: string; + collected_at: string; +} + +export interface ConfigDiffResponse { + from: ConfigDiffSide; + to: ConfigDiffSide; +} + +export interface ConfigCaptureResult { + created: boolean; + message: string; + snapshot: ConfigSnapshotMeta; +} diff --git a/frontend/src/utils/backupSchedule.ts b/frontend/src/utils/backupSchedule.ts new file mode 100644 index 0000000..252d0a5 --- /dev/null +++ b/frontend/src/utils/backupSchedule.ts @@ -0,0 +1,75 @@ +// Friendly <-> cron translation for the Scheduled Backups setting. The backend +// still consumes a 5-field cron string (backup_schedule_cron); this keeps the UI +// to simple Frequency / time / day choices and hides cron from the user. + +export type BackupFrequency = 'daily' | 'weekly' | 'monthly'; + +export interface BackupSchedule { + frequency: BackupFrequency; + hour: number; // 0-23 + minute: number; // 0-59 + weekday: number; // 0=Sun .. 6=Sat (used when weekly) + dayOfMonth: number; // 1-28 (used when monthly) +} + +export const WEEKDAY_NAMES = [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', +]; + +const DEFAULT_SCHEDULE: BackupSchedule = { + frequency: 'daily', + hour: 2, + minute: 0, + weekday: 0, + dayOfMonth: 1, +}; + +function intOr(field: string, fallback: number): number { + return /^\d+$/.test(field) ? parseInt(field, 10) : fallback; +} + +/** Parse a stored cron string into friendly schedule fields (best effort). */ +export function parseBackupCron(cron: string | undefined | null): BackupSchedule { + if (!cron) return { ...DEFAULT_SCHEDULE }; + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return { ...DEFAULT_SCHEDULE }; + + const [m, h, dom, , dow] = parts; + const minute = intOr(m, DEFAULT_SCHEDULE.minute); + const hour = intOr(h, DEFAULT_SCHEDULE.hour); + + if (dow !== '*' && /^\d+$/.test(dow)) { + return { ...DEFAULT_SCHEDULE, frequency: 'weekly', hour, minute, weekday: parseInt(dow, 10) % 7 }; + } + if (dom !== '*' && /^\d+$/.test(dom)) { + return { ...DEFAULT_SCHEDULE, frequency: 'monthly', hour, minute, dayOfMonth: parseInt(dom, 10) }; + } + return { ...DEFAULT_SCHEDULE, frequency: 'daily', hour, minute }; +} + +/** Build a 5-field cron string from friendly schedule fields. */ +export function scheduleToCron(s: BackupSchedule): string { + if (s.frequency === 'weekly') return `${s.minute} ${s.hour} * * ${s.weekday}`; + if (s.frequency === 'monthly') return `${s.minute} ${s.hour} ${s.dayOfMonth} * *`; + return `${s.minute} ${s.hour} * * *`; +} + +export function ordinal(n: number): string { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return n + (s[(v - 20) % 10] || s[v] || s[0]); +} + +export function formatTime(hour: number, minute: number): string { + const ampm = hour < 12 ? 'AM' : 'PM'; + const hr = hour % 12 === 0 ? 12 : hour % 12; + return `${hr}:${String(minute).padStart(2, '0')} ${ampm}`; +} + +/** Human description, e.g. "Every Sunday at 3:00 AM". */ +export function describeBackupSchedule(s: BackupSchedule): string { + const t = formatTime(s.hour, s.minute); + if (s.frequency === 'weekly') return `Every ${WEEKDAY_NAMES[s.weekday]} at ${t}`; + if (s.frequency === 'monthly') return `Monthly on the ${ordinal(s.dayOfMonth)} at ${t}`; + return `Every day at ${t}`; +} diff --git a/frontend/src/utils/lineDiff.ts b/frontend/src/utils/lineDiff.ts new file mode 100644 index 0000000..d36fc6d --- /dev/null +++ b/frontend/src/utils/lineDiff.ts @@ -0,0 +1,43 @@ +// Minimal, dependency-free unified line diff (LCS based). Config snapshots are +// modest in size (hundreds of lines), so the O(n*m) table is fine here. + +export type DiffRowType = 'add' | 'del' | 'ctx'; +export interface DiffRow { + type: DiffRowType; + text: string; +} + +export function lineDiff(oldText: string, newText: string): DiffRow[] { + const a = oldText.split('\n'); + const b = newText.split('\n'); + const n = a.length; + const m = b.length; + + // dp[i][j] = length of LCS of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const rows: DiffRow[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + rows.push({ type: 'ctx', text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + rows.push({ type: 'del', text: a[i] }); + i++; + } else { + rows.push({ type: 'add', text: b[j] }); + j++; + } + } + while (i < n) rows.push({ type: 'del', text: a[i++] }); + while (j < m) rows.push({ type: 'add', text: b[j++] }); + return rows; +} diff --git a/frontend/src/version.ts b/frontend/src/version.ts index eaf87d1..5e65fd9 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.11.11 Beta'; +export const APP_VERSION = 'v0.12.3 Beta';