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
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -168,9 +168,19 @@ Per-device diagnostic and testing tools accessible from the device detail Tools
|
|||||||
|
|
||||||
### Backups
|
### Backups
|
||||||
- Trigger RouterOS backups on demand via SSH
|
- 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
|
- 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
|
### Alerts
|
||||||
Configurable alert rules with cooldown periods:
|
Configurable alert rules with cooldown periods:
|
||||||
- Device online / offline
|
- Device online / offline
|
||||||
@@ -179,6 +189,7 @@ Configurable alert rules with cooldown periods:
|
|||||||
- Firmware update available
|
- Firmware update available
|
||||||
- RouterOS log errors and warnings
|
- RouterOS log errors and warnings
|
||||||
- New device discovered
|
- New device discovered
|
||||||
|
- Configuration changed (drift detection)
|
||||||
|
|
||||||
Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram**
|
Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram**
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.11.11-beta",
|
"version": "0.12.3-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 routerboard_upgrade_available BOOLEAN DEFAULT FALSE;
|
||||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS upgrade_firmware_version VARCHAR(20);
|
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:
|
-- 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
|
-- neighbors seen only by IP (CDP/MNDP) can be matched to managed devices even
|
||||||
-- when the address is not the device's management IP.
|
-- 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: 'ap_scan_interval_hours', value: 24 },
|
||||||
{ key: 'login_rate_limit_window_sec', value: 60 },
|
{ key: 'login_rate_limit_window_sec', value: 60 },
|
||||||
{ key: 'login_rate_limit_max', value: 10 },
|
{ 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<void> {
|
export async function runMigrations(): Promise<void> {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import switchesRoutes from './routes/switches';
|
|||||||
import routersRoutes from './routes/routers';
|
import routersRoutes from './routes/routers';
|
||||||
import alertsRoutes from './routes/alerts';
|
import alertsRoutes from './routes/alerts';
|
||||||
import configTemplatesRoutes from './routes/configTemplates';
|
import configTemplatesRoutes from './routes/configTemplates';
|
||||||
|
import configHistoryRoutes from './routes/configHistory';
|
||||||
import wirelessRoutes from './routes/wireless';
|
import wirelessRoutes from './routes/wireless';
|
||||||
import networkServicesRoutes from './routes/networkServices';
|
import networkServicesRoutes from './routes/networkServices';
|
||||||
import credentialPresetsRoutes from './routes/credentialPresets';
|
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||||
@@ -218,6 +219,7 @@ app.use('/api/audit-log', auditLogRoutes);
|
|||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
app.use('/api/maintenance-windows', maintenanceWindowsRoutes);
|
||||||
app.use('/api/config-templates', configTemplatesRoutes);
|
app.use('/api/config-templates', configTemplatesRoutes);
|
||||||
|
app.use('/api/config-history', configHistoryRoutes);
|
||||||
app.use('/api/system', systemRoutes);
|
app.use('/api/system', systemRoutes);
|
||||||
|
|
||||||
// ─── Error Handler ────────────────────────────────────────────────────────────
|
// ─── Error Handler ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const DEFAULT_RULES = [
|
|||||||
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
|
{ event_type: 'cert_expiry', enabled: false, threshold: 14, cooldown_min: 1440 },
|
||||||
{ event_type: 'device_discovered', enabled: false, threshold: null, cooldown_min: 60 },
|
{ 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: '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> {
|
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;
|
||||||
@@ -12,7 +12,8 @@ export type AlertEventType =
|
|||||||
| 'high_memory'
|
| 'high_memory'
|
||||||
| 'cert_expiry'
|
| 'cert_expiry'
|
||||||
| 'device_discovered'
|
| 'device_discovered'
|
||||||
| 'firmware_update_available';
|
| 'firmware_update_available'
|
||||||
|
| 'config_drift';
|
||||||
|
|
||||||
export interface AlertContext {
|
export interface AlertContext {
|
||||||
deviceId?: number;
|
deviceId?: number;
|
||||||
@@ -51,6 +52,7 @@ const EVENT_LABELS: Record<string, string> = {
|
|||||||
cert_expiry: 'Certificate Expiring Soon',
|
cert_expiry: 'Certificate Expiring Soon',
|
||||||
device_discovered: 'New Device Discovered',
|
device_discovered: 'New Device Discovered',
|
||||||
firmware_update_available: 'Firmware Update Available',
|
firmware_update_available: 'Firmware Update Available',
|
||||||
|
config_drift: 'Configuration Changed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EVENT_EMOJI: Record<string, string> = {
|
const EVENT_EMOJI: Record<string, string> = {
|
||||||
@@ -63,6 +65,7 @@ const EVENT_EMOJI: Record<string, string> = {
|
|||||||
cert_expiry: '🔐',
|
cert_expiry: '🔐',
|
||||||
device_discovered: '🔍',
|
device_discovered: '🔍',
|
||||||
firmware_update_available: '🔄',
|
firmware_update_available: '🔄',
|
||||||
|
config_drift: '📝',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AlertService {
|
export class AlertService {
|
||||||
|
|||||||
@@ -25,19 +25,23 @@ export class BackupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise<number> {
|
/** Run `/export compact` over SSH and return the raw .rsc text (no file written). */
|
||||||
|
async exportConfig(device: BackupDevice): Promise<string> {
|
||||||
const sshUser = device.ssh_username || device.api_username;
|
const sshUser = device.ssh_username || device.api_username;
|
||||||
const sshPass = device.ssh_password_encrypted
|
const sshPass = device.ssh_password_encrypted
|
||||||
? decrypt(device.ssh_password_encrypted)
|
? decrypt(device.ssh_password_encrypted)
|
||||||
: decrypt(device.api_password_encrypted);
|
: decrypt(device.api_password_encrypted);
|
||||||
|
|
||||||
const exportContent = await this.sshExport(
|
return this.sshExport(device.ip_address, device.ssh_port || 22, sshUser, sshPass);
|
||||||
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<number> {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`;
|
const filename = `${device.name.replace(/[^a-z0-9]/gi, '_')}_${timestamp}.rsc`;
|
||||||
const filePath = path.join(BACKUPS_DIR, String(device.id), filename);
|
const filePath = path.join(BACKUPS_DIR, String(device.id), filename);
|
||||||
@@ -48,7 +52,7 @@ export class BackupService {
|
|||||||
fs.mkdirSync(deviceDir, { recursive: true });
|
fs.mkdirSync(deviceDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(filePath, exportContent, 'utf8');
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
|
|
||||||
const rows = await query<{ id: number }>(
|
const rows = await query<{ id: number }>(
|
||||||
@@ -60,6 +64,11 @@ export class BackupService {
|
|||||||
return rows[0].id;
|
return rows[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createBackup(device: BackupDevice, notes?: string, type: string = 'manual'): Promise<number> {
|
||||||
|
const exportContent = await this.exportConfig(device);
|
||||||
|
return this.createBackupFromContent(device, exportContent, notes, type);
|
||||||
|
}
|
||||||
|
|
||||||
async restoreBackup(backupId: number): Promise<void> {
|
async restoreBackup(backupId: number): Promise<void> {
|
||||||
const backup = await query<{
|
const backup = await query<{
|
||||||
file_path: string;
|
file_path: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { alertService } from './AlertService';
|
|||||||
|
|
||||||
interface PollJob {
|
interface PollJob {
|
||||||
deviceId: number;
|
deviceId: number;
|
||||||
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan';
|
type: 'fast' | 'slow' | 'logs' | 'full' | 'macscan' | 'spectral' | 'apscan' | 'configsnap';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PollerService {
|
export class PollerService {
|
||||||
@@ -73,6 +73,8 @@ export class PollerService {
|
|||||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
||||||
} else if (type === 'apscan') {
|
} else if (type === 'apscan') {
|
||||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
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 apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
|
||||||
const backupScheduleEnabled = appSettings['backup_schedule_enabled'] === true;
|
const backupScheduleEnabled = appSettings['backup_schedule_enabled'] === true;
|
||||||
const backupScheduleCron = (appSettings['backup_schedule_cron'] as string) || '0 2 * * *';
|
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>(
|
const devices = await query<DeviceRow>(
|
||||||
`SELECT * FROM devices WHERE status != 'disabled'`
|
`SELECT * FROM devices WHERE status != 'disabled'`
|
||||||
@@ -153,6 +157,20 @@ export class PollerService {
|
|||||||
await this.setTimestamp(apScanKey, now);
|
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
|
// 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.
|
// Returns true if the 5-part cron expression matches the current time.
|
||||||
// Supports: *, exact numbers, comma lists, ranges (a-b), and step values (*/n).
|
// 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 {
|
private cronMatchesNow(cron: string): boolean {
|
||||||
const parts = cron.trim().split(/\s+/);
|
const parts = cron.trim().split(/\s+/);
|
||||||
if (parts.length < 5) return false;
|
if (parts.length < 5) return false;
|
||||||
const [minuteField, hourField] = parts;
|
const [minuteField, hourField, domField, monthField, dowField] = parts;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const matchField = (field: string, val: number): boolean => {
|
const matchField = (field: string, val: number): boolean => {
|
||||||
if (field === '*') return true;
|
if (field === '*') return true;
|
||||||
@@ -257,7 +280,21 @@ export class PollerService {
|
|||||||
return Number(f) === val;
|
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> {
|
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 {
|
try {
|
||||||
const { redis } = await import('../config/redis');
|
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 */ }
|
} 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> {
|
private async processSlowJob(data: PollJob): Promise<void> {
|
||||||
if (data.type === 'spectral') {
|
if (data.type === 'spectral') {
|
||||||
return this.processSpectralJob(data);
|
return this.processSpectralJob(data);
|
||||||
@@ -604,6 +656,9 @@ export class PollerService {
|
|||||||
if (data.type === 'apscan') {
|
if (data.type === 'apscan') {
|
||||||
return this.processApScanJob(data);
|
return this.processApScanJob(data);
|
||||||
}
|
}
|
||||||
|
if (data.type === 'configsnap') {
|
||||||
|
return this.processConfigSnapJob(data);
|
||||||
|
}
|
||||||
|
|
||||||
const device = await this.getDevice(data.deviceId);
|
const device = await this.getDevice(data.deviceId);
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
import { RouterOSClient } from './RouterOSClient';
|
import { RouterOSClient } from './RouterOSClient';
|
||||||
import { query, queryOne } from '../../config/database';
|
import { query, queryOne } from '../../config/database';
|
||||||
import { getWriteApi } from '../../config/influxdb';
|
import { getWriteApi } from '../../config/influxdb';
|
||||||
@@ -5,6 +6,8 @@ import { Point } from '@influxdata/influxdb-client';
|
|||||||
import { decrypt } from '../../utils/crypto';
|
import { decrypt } from '../../utils/crypto';
|
||||||
import { lookupVendor } from '../../utils/oui';
|
import { lookupVendor } from '../../utils/oui';
|
||||||
import { buildServerArpMap } from '../../utils/serverArp';
|
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. */
|
/** DB column limits for topology_links (see migrate.ts); reject oversize rows instead of silent truncation. */
|
||||||
const TOPOLOGY_LINK_LIMITS = {
|
const TOPOLOGY_LINK_LIMITS = {
|
||||||
@@ -1061,36 +1064,150 @@ export class DeviceCollector {
|
|||||||
|
|
||||||
// ─── Full config snapshot ─────────────────────────────────────────────────
|
// ─── Full config snapshot ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async saveFullConfig(): Promise<void> {
|
/**
|
||||||
try {
|
* Strip the volatile header RouterOS stamps onto every `/export` (a line like
|
||||||
const [interfaces, vlans, routes, firewall, dhcp, dns] = await Promise.all([
|
* `# 2026-06-06 14:26:09 by RouterOS 7.23.1`). Everything else in the export is
|
||||||
this.client.execute('/interface/print', { detail: '' }).catch(() => []),
|
* configuration, so what remains hashes stably across exports of an unchanged
|
||||||
this.client.execute('/interface/bridge/vlan/print', { detail: '' }).catch(() => []),
|
* device. We also trim trailing whitespace for a clean diff.
|
||||||
this.client.execute('/ip/route/print', { detail: '' }).catch(() => []),
|
*/
|
||||||
this.client.execute('/ip/firewall/filter/print', { detail: '' }).catch(() => []),
|
private static normalizeRsc(rsc: string): string {
|
||||||
this.client.execute('/ip/dhcp-server/print', { detail: '' }).catch(() => []),
|
return rsc
|
||||||
this.client.execute('/ip/dns/print', { detail: '' }).catch(() => []),
|
.split('\n')
|
||||||
]);
|
.filter((line) => !/^# \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} by RouterOS/.test(line))
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\s+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
const config = { interfaces, vlans, routes, firewall, dhcp, dns };
|
/** Compact summary of an .rsc change as added/removed line counts. */
|
||||||
|
private static summarizeRscDiff(oldText: string, newText: string): string {
|
||||||
|
const counts = (text: string): Map<string, number> => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
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'}`;
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
/** Drop snapshots beyond the retention count, removing any linked .rsc backups too. */
|
||||||
`INSERT INTO device_configs (device_id, config_json) VALUES ($1, $2)`,
|
private async pruneConfigSnapshots(): Promise<void> {
|
||||||
[this.device.id, JSON.stringify(config)]
|
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;
|
||||||
|
|
||||||
// Keep only last 10 full config snapshots per device
|
const stale = await query<{ id: number; backup_id: number | null }>(
|
||||||
await query(
|
`SELECT id, backup_id FROM device_configs
|
||||||
`DELETE FROM device_configs WHERE device_id = $1 AND id NOT IN (
|
WHERE device_id = $1 AND id NOT IN (
|
||||||
SELECT id FROM device_configs WHERE device_id = $1 ORDER BY collected_at DESC LIMIT 10
|
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<boolean> {
|
||||||
|
try {
|
||||||
|
const backupService = new BackupService();
|
||||||
|
const device = this.device as unknown as import('../BackupService').BackupDevice;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = DeviceCollector.normalizeRsc(rsc);
|
||||||
|
const hash = createHash('sha256').update(text).digest('hex');
|
||||||
|
|
||||||
|
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]
|
[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) {
|
} catch (err) {
|
||||||
console.error(`[${this.device.name}] Failed to save config:`, 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<void> {
|
||||||
|
await this.snapshotConfig('full-sync');
|
||||||
|
}
|
||||||
|
|
||||||
async updateDeviceStatus(status: string): Promise<void> {
|
async updateDeviceStatus(status: string): Promise<void> {
|
||||||
await query(
|
await query(
|
||||||
`UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`,
|
`UPDATE devices SET status = $1, last_seen = NOW(), updated_at = NOW() WHERE id = $2`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.11.11-beta",
|
"version": "0.12.3-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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<number | null>(null);
|
||||||
|
const [toId, setToId] = useState<number | null>(null);
|
||||||
|
const [rollbackConfirm, setRollbackConfirm] = useState<number | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<History className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Configuration History</h2>
|
||||||
|
</div>
|
||||||
|
{canWrite && (
|
||||||
|
<button
|
||||||
|
onClick={() => captureMutation.mutate()}
|
||||||
|
disabled={captureMutation.isPending}
|
||||||
|
className="btn-primary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{captureMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Camera className="w-4 h-4" />}
|
||||||
|
Capture snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||||
|
Snapshots are captured automatically when the device configuration changes. Each one links a restorable
|
||||||
|
backup so you can roll back to it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
<p className="text-sm text-green-700 dark:text-green-400">{success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-40 text-gray-400">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||||
|
<History className="w-14 h-14 text-gray-300 dark:text-slate-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-700 dark:text-slate-300">No configuration snapshots yet</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||||
|
One will appear after the next config change, or capture one now.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Captured</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Change</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Rollback</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left w-40">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||||
|
{history.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||||
|
<td className="px-4 py-2.5 text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
|
{format(new Date(s.collected_at), 'MMM d, yyyy HH:mm:ss')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-gray-600 dark:text-slate-300 text-xs">
|
||||||
|
{s.change_summary || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{s.has_backup ? (
|
||||||
|
<span className="inline-flex text-xs font-medium px-2 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
|
||||||
|
available
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex text-xs font-medium px-2 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">
|
||||||
|
no backup
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{canWrite && (rollbackConfirm === s.id ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => rollbackMutation.mutate(s.id)}
|
||||||
|
disabled={rollbackMutation.isPending}
|
||||||
|
className="text-orange-600 hover:text-orange-700 font-medium"
|
||||||
|
>
|
||||||
|
{rollbackMutation.isPending ? 'Rolling back…' : 'Confirm rollback'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setRollbackConfirm(null)} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setRollbackConfirm(s.id)}
|
||||||
|
disabled={!s.has_backup}
|
||||||
|
title={s.has_backup ? 'Roll back to this configuration' : 'No backup linked to this snapshot'}
|
||||||
|
className="p-1.5 rounded text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{canWrite && (deleteConfirm === s.id ? (
|
||||||
|
<span className="flex items-center gap-1 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(s.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="text-red-600 hover:text-red-700 font-medium"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? '…' : 'Delete?'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeleteConfirm(null)} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(s.id)}
|
||||||
|
title="Delete this snapshot"
|
||||||
|
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compare / diff */}
|
||||||
|
{history.length >= 2 && (
|
||||||
|
<div className="card p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GitCompare className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<h3 className="font-semibold text-sm text-gray-900 dark:text-white">Compare snapshots</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<select
|
||||||
|
className="input max-w-xs"
|
||||||
|
value={effectiveFromId ?? ''}
|
||||||
|
onChange={(e) => setFromId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
>
|
||||||
|
<option value="">Base (older)…</option>
|
||||||
|
{history.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{format(new Date(s.collected_at), 'MMM d HH:mm:ss')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-gray-400">→</span>
|
||||||
|
<select
|
||||||
|
className="input max-w-xs"
|
||||||
|
value={effectiveToId ?? ''}
|
||||||
|
onChange={(e) => setToId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
>
|
||||||
|
<option value="">Compare (newer)…</option>
|
||||||
|
{history.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{format(new Date(s.collected_at), 'MMM d HH:mm:ss')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{diff && (
|
||||||
|
<span className="text-xs ml-1">
|
||||||
|
<span className="text-green-600 dark:text-green-400">+{addCount}</span>{' '}
|
||||||
|
<span className="text-red-600 dark:text-red-400">−{delCount}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{effectiveFromId === effectiveToId ? (
|
||||||
|
<p className="text-sm text-gray-400">Select two different snapshots to compare.</p>
|
||||||
|
) : diff ? (
|
||||||
|
addCount + delCount === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No differences between these snapshots.</p>
|
||||||
|
) : (
|
||||||
|
<pre className="text-xs font-mono bg-gray-50 dark:bg-slate-900/60 border border-gray-200 dark:border-slate-700 rounded-lg p-3 overflow-auto max-h-[28rem] leading-relaxed">
|
||||||
|
{diffRows.map((row, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={clsx(
|
||||||
|
'whitespace-pre-wrap',
|
||||||
|
row.type === 'add' && 'bg-green-100/70 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
||||||
|
row.type === 'del' && 'bg-red-100/70 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
||||||
|
row.type === 'ctx' && 'text-gray-500 dark:text-slate-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.type === 'add' ? '+ ' : row.type === 'del' ? '- ' : ' '}
|
||||||
|
{row.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading diff…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { backupsApi, devicesApi } from '../services/api';
|
||||||
import { useCanWrite } from '../hooks/useCanWrite';
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
|
import type { Backup } from '../types';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ export default function BackupsPage() {
|
|||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [restoreConfirm, setRestoreConfirm] = useState<number | null>(null);
|
const [restoreConfirm, setRestoreConfirm] = useState<number | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||||
|
// Config-snapshot backups are confirmed via a modal (deleting also removes the snapshot).
|
||||||
|
const [snapshotDeleteTarget, setSnapshotDeleteTarget] = useState<Backup | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const { data: backups = [], isLoading } = useQuery({
|
const { data: backups = [], isLoading } = useQuery({
|
||||||
@@ -61,6 +64,12 @@ export default function BackupsPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||||
setDeleteConfirm(null);
|
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() {
|
|||||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||||
<th className="table-header px-4 py-2.5 text-left">Filename</th>
|
<th className="table-header px-4 py-2.5 text-left">Filename</th>
|
||||||
|
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||||
<th className="table-header px-4 py-2.5 text-left">Size</th>
|
<th className="table-header px-4 py-2.5 text-left">Size</th>
|
||||||
<th className="table-header px-4 py-2.5 text-left">Created</th>
|
<th className="table-header px-4 py-2.5 text-left">Created</th>
|
||||||
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
||||||
@@ -195,6 +205,30 @@ export default function BackupsPage() {
|
|||||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||||
{backup.filename}
|
{backup.filename}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex text-xs font-medium px-2 py-0.5 rounded whitespace-nowrap',
|
||||||
|
backup.backup_type === 'config-snapshot' &&
|
||||||
|
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400',
|
||||||
|
backup.backup_type === 'scheduled' &&
|
||||||
|
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||||
|
backup.backup_type === 'manual' &&
|
||||||
|
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
backup.backup_type === 'config-snapshot'
|
||||||
|
? 'Auto-created by Config History — tied to a config snapshot'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{backup.backup_type === 'config-snapshot'
|
||||||
|
? 'Config Snapshot'
|
||||||
|
: backup.backup_type === 'scheduled'
|
||||||
|
? 'Scheduled'
|
||||||
|
: 'Manual'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||||
{formatBytes(backup.size_bytes)}
|
{formatBytes(backup.size_bytes)}
|
||||||
</td>
|
</td>
|
||||||
@@ -257,7 +291,11 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(backup.id)}
|
onClick={() =>
|
||||||
|
backup.backup_type === 'config-snapshot'
|
||||||
|
? setSnapshotDeleteTarget(backup)
|
||||||
|
: setDeleteConfirm(backup.id)
|
||||||
|
}
|
||||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
@@ -273,6 +311,61 @@ export default function BackupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirm deletion of a snapshot-linked backup (removes the config snapshot too) */}
|
||||||
|
{snapshotDeleteTarget && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="card w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500" />
|
||||||
|
Delete config snapshot backup?
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSnapshotDeleteTarget(null)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-slate-300">
|
||||||
|
This backup is tied to a <span className="font-medium">Config History snapshot</span> for{' '}
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{snapshotDeleteTarget.device_name || 'this device'}</span>.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
Deleting it will also <span className="font-semibold">permanently remove that snapshot from the
|
||||||
|
device's Config History</span>, and you will no longer be able to roll back to it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400 font-mono break-all">
|
||||||
|
{snapshotDeleteTarget.filename}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSnapshotDeleteTarget(null)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => deleteMutation.mutate(snapshotDeleteTarget.id)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="btn-danger inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
|
Delete backup & snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import VlansTab from '../components/device-detail/VlansTab';
|
|||||||
import RoutingTab from '../components/device-detail/RoutingTab';
|
import RoutingTab from '../components/device-detail/RoutingTab';
|
||||||
import FirewallTab from '../components/device-detail/FirewallTab';
|
import FirewallTab from '../components/device-detail/FirewallTab';
|
||||||
import SystemConfigTab from '../components/device-detail/SystemConfigTab';
|
import SystemConfigTab from '../components/device-detail/SystemConfigTab';
|
||||||
|
import ConfigHistoryTab from '../components/device-detail/ConfigHistoryTab';
|
||||||
import HardwareTab from '../components/device-detail/HardwareTab';
|
import HardwareTab from '../components/device-detail/HardwareTab';
|
||||||
import ToolsTab from '../components/device-detail/ToolsTab';
|
import ToolsTab from '../components/device-detail/ToolsTab';
|
||||||
import RadiosTab from '../components/device-detail/RadiosTab';
|
import RadiosTab from '../components/device-detail/RadiosTab';
|
||||||
import clsx from 'clsx';
|
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 {
|
function formatUptime(raw: string): string {
|
||||||
if (!raw) return '—';
|
if (!raw) return '—';
|
||||||
@@ -106,6 +107,7 @@ export default function DeviceDetailPage() {
|
|||||||
{ key: 'routing', label: 'Routing' },
|
{ key: 'routing', label: 'Routing' },
|
||||||
{ key: 'firewall', label: 'Firewall' },
|
{ key: 'firewall', label: 'Firewall' },
|
||||||
{ key: 'config', label: 'Config' },
|
{ key: 'config', label: 'Config' },
|
||||||
|
{ key: 'config-history', label: 'Config History' },
|
||||||
{ key: 'hardware', label: 'Hardware' },
|
{ key: 'hardware', label: 'Hardware' },
|
||||||
...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []),
|
...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []),
|
||||||
{ key: 'tools', label: 'Tools' },
|
{ key: 'tools', label: 'Tools' },
|
||||||
@@ -370,6 +372,7 @@ export default function DeviceDetailPage() {
|
|||||||
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
|
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
|
||||||
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
|
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
|
||||||
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
|
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
|
||||||
|
{activeTab === 'config-history' && <ConfigHistoryTab deviceId={deviceId} />}
|
||||||
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
|
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
|
||||||
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
|
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
|
||||||
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
|
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
|||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import type { User, UserRole } from '../types';
|
import type { User, UserRole } from '../types';
|
||||||
|
import {
|
||||||
|
parseBackupCron, scheduleToCron, describeBackupSchedule, WEEKDAY_NAMES, ordinal,
|
||||||
|
type BackupFrequency,
|
||||||
|
} from '../utils/backupSchedule';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
||||||
|
|
||||||
@@ -689,47 +693,113 @@ export default function SettingsPage() {
|
|||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">Scheduled Backups</h3>
|
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">Scheduled Backups</h3>
|
||||||
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
|
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
|
||||||
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 <code className="font-mono">scheduled</code>.
|
Backups appear in the Backups section with type <code className="font-mono">scheduled</code>.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
{(() => {
|
||||||
|
const enabled = !!settings['backup_schedule_enabled'];
|
||||||
|
const sched = parseBackupCron(settings['backup_schedule_cron'] as string);
|
||||||
|
const controlsDisabled = !isAdmin || !enabled;
|
||||||
|
const apply = (next: typeof sched) =>
|
||||||
|
updateSettingsMutation.mutate({ backup_schedule_cron: scheduleToCron(next) });
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-300">Enable scheduled backups</div>
|
<div className="text-sm font-medium text-gray-700 dark:text-slate-300">Enable scheduled backups</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => isAdmin && updateSettingsMutation.mutate({ backup_schedule_enabled: !settings['backup_schedule_enabled'] })}
|
onClick={() => isAdmin && updateSettingsMutation.mutate({ backup_schedule_enabled: !enabled })}
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
|
'relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200',
|
||||||
isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-50',
|
isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-50',
|
||||||
settings['backup_schedule_enabled'] ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200',
|
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200',
|
||||||
settings['backup_schedule_enabled'] ? 'translate-x-5' : 'translate-x-0'
|
enabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
)} />
|
)} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{/* Frequency */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-300">Cron schedule</div>
|
<label className="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Frequency</label>
|
||||||
<div className="text-xs text-gray-400">Standard 5-part cron expression (minute hour day month weekday)</div>
|
<select
|
||||||
|
className="input w-full text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
value={sched.frequency}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onChange={(e) => apply({ ...sched, frequency: e.target.value as BackupFrequency })}
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Day selector — weekly or monthly */}
|
||||||
|
{sched.frequency === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Day of week</label>
|
||||||
|
<select
|
||||||
|
className="input w-full text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
value={sched.weekday}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onChange={(e) => apply({ ...sched, weekday: parseInt(e.target.value, 10) })}
|
||||||
|
>
|
||||||
|
{WEEKDAY_NAMES.map((name, i) => (
|
||||||
|
<option key={i} value={i}>{name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sched.frequency === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Day of month</label>
|
||||||
|
<select
|
||||||
|
className="input w-full text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
value={sched.dayOfMonth}
|
||||||
|
disabled={controlsDisabled}
|
||||||
|
onChange={(e) => apply({ ...sched, dayOfMonth: parseInt(e.target.value, 10) })}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
|
||||||
|
<option key={d} value={d}>{ordinal(d)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time of day */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Time</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="time"
|
||||||
className="input w-36 text-center font-mono text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
className="input w-full text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
value={(settings['backup_schedule_cron'] as string) ?? '0 2 * * *'}
|
value={`${String(sched.hour).padStart(2, '0')}:${String(sched.minute).padStart(2, '0')}`}
|
||||||
onChange={(e) => updateSettingsMutation.mutate({ backup_schedule_cron: e.target.value })}
|
disabled={controlsDisabled}
|
||||||
placeholder="0 2 * * *"
|
onChange={(e) => {
|
||||||
disabled={!isAdmin || !settings['backup_schedule_enabled']}
|
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 });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 dark:text-slate-500">
|
|
||||||
Examples: <code className="font-mono">0 2 * * *</code> = daily at 2:00 AM ·
|
|
||||||
<code className="font-mono">0 3 * * 0</code> = weekly Sunday at 3:00 AM ·
|
|
||||||
<code className="font-mono">0 */6 * * *</code> = every 6 hours
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className={clsx('text-xs', enabled ? 'text-gray-500 dark:text-slate-400' : 'text-gray-400 dark:text-slate-600')}>
|
||||||
|
{enabled
|
||||||
|
? <>Runs <span className="font-medium text-gray-700 dark:text-slate-300">{describeBackupSchedule(sched).toLowerCase()}</span>, server time.</>
|
||||||
|
: 'Enable to choose a backup schedule.'}
|
||||||
|
</p>
|
||||||
|
{sched.frequency === 'monthly' && (
|
||||||
|
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||||
|
Days are limited to 1–28 so the backup runs every month, including February.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import type {
|
|||||||
IpAddress,
|
IpAddress,
|
||||||
PortVlanConfig,
|
PortVlanConfig,
|
||||||
PortMonitorData,
|
PortMonitorData,
|
||||||
|
ConfigSnapshotMeta,
|
||||||
|
ConfigSnapshotFull,
|
||||||
|
ConfigDiffResponse,
|
||||||
|
ConfigCaptureResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
@@ -376,6 +380,22 @@ export const backupsApi = {
|
|||||||
delete: (id: number) => api.delete(`/backups/${id}`),
|
delete: (id: number) => api.delete(`/backups/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Config History ────────────────────────────────────────────────────────────
|
||||||
|
export const configHistoryApi = {
|
||||||
|
list: (deviceId: number) =>
|
||||||
|
api.get<ConfigSnapshotMeta[]>(`/config-history/${deviceId}`),
|
||||||
|
get: (deviceId: number, id: number) =>
|
||||||
|
api.get<ConfigSnapshotFull>(`/config-history/${deviceId}/${id}`),
|
||||||
|
diff: (deviceId: number, fromId: number, toId: number) =>
|
||||||
|
api.get<ConfigDiffResponse>(`/config-history/${deviceId}/${fromId}/diff/${toId}`),
|
||||||
|
capture: (deviceId: number) =>
|
||||||
|
api.post<ConfigCaptureResult>(`/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 ─────────────────────────────────────────────────────────────────
|
// ─── Metrics ─────────────────────────────────────────────────────────────────
|
||||||
export const metricsApi = {
|
export const metricsApi = {
|
||||||
summary: () => api.get<MetricsSummary>('/metrics/summary'),
|
summary: () => api.get<MetricsSummary>('/metrics/summary'),
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export interface Backup {
|
|||||||
device_name?: string;
|
device_name?: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
size_bytes?: number;
|
size_bytes?: number;
|
||||||
backup_type: 'manual' | 'scheduled';
|
backup_type: 'manual' | 'scheduled' | 'config-snapshot';
|
||||||
notes?: string;
|
notes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -339,3 +339,35 @@ export interface WirelessMetricPoint {
|
|||||||
registered_clients?: number;
|
registered_clients?: number;
|
||||||
noise_floor?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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<number>(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;
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.11.11 Beta';
|
export const APP_VERSION = 'v0.12.3 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user