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:
2GT-Rich
2026-06-06 16:01:58 -07:00
co-authored by Claude Opus 4.8
parent 4ff5ed805c
commit 367034c2d6
20 changed files with 1075 additions and 81 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.11.11-beta",
"version": "0.12.3-beta",
"private": true,
"type": "module",
"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>
);
}
+95 -2
View File
@@ -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<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 { 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() {
<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">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">Created</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">
{backup.filename}
</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">
{formatBytes(backup.size_bytes)}
</td>
@@ -257,7 +291,11 @@ export default function BackupsPage() {
</div>
) : (
<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"
title="Delete"
>
@@ -273,6 +311,61 @@ export default function BackupsPage() {
</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&apos;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 &amp; snapshot
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+4 -1
View File
@@ -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' && <RoutingTab deviceId={deviceId} />}
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} device={device} />}
{activeTab === 'config-history' && <ConfigHistoryTab deviceId={deviceId} />}
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
+107 -37
View File
@@ -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() {
<div className="card p-5">
<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">
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>.
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-700 dark:text-slate-300">Enable scheduled backups</div>
<button
onClick={() => isAdmin && updateSettingsMutation.mutate({ backup_schedule_enabled: !settings['backup_schedule_enabled'] })}
disabled={!isAdmin}
className={clsx(
'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',
settings['backup_schedule_enabled'] ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
{(() => {
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="text-sm font-medium text-gray-700 dark:text-slate-300">Enable scheduled backups</div>
<button
onClick={() => isAdmin && updateSettingsMutation.mutate({ backup_schedule_enabled: !enabled })}
disabled={!isAdmin}
className={clsx(
'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',
enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
)}
>
<span className={clsx(
'inline-block h-5 w-5 transform rounded-full bg-white shadow transition duration-200',
enabled ? 'translate-x-5' : 'translate-x-0'
)} />
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Frequency */}
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-slate-400 mb-1">Frequency</label>
<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>
{/* 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
type="time"
className="input w-full text-sm disabled:opacity-50 disabled:cursor-not-allowed"
value={`${String(sched.hour).padStart(2, '0')}:${String(sched.minute).padStart(2, '0')}`}
disabled={controlsDisabled}
onChange={(e) => {
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={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>
)}
>
<span className={clsx(
'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'
)} />
</button>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-gray-700 dark:text-slate-300">Cron schedule</div>
<div className="text-xs text-gray-400">Standard 5-part cron expression (minute hour day month weekday)</div>
</div>
<input
type="text"
className="input w-36 text-center font-mono text-sm disabled:opacity-50 disabled:cursor-not-allowed"
value={(settings['backup_schedule_cron'] as string) ?? '0 2 * * *'}
onChange={(e) => updateSettingsMutation.mutate({ backup_schedule_cron: e.target.value })}
placeholder="0 2 * * *"
disabled={!isAdmin || !settings['backup_schedule_enabled']}
/>
</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 &nbsp;·&nbsp;
<code className="font-mono">0 3 * * 0</code> = weekly Sunday at 3:00 AM &nbsp;·&nbsp;
<code className="font-mono">0 */6 * * *</code> = every 6 hours
</p>
</div>
);
})()}
</div>
</div>
)}
+20
View File
@@ -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<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 ─────────────────────────────────────────────────────────────────
export const metricsApi = {
summary: () => api.get<MetricsSummary>('/metrics/summary'),
+33 -1
View File
@@ -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;
}
+75
View File
@@ -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}`;
}
+43
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.11.11 Beta';
export const APP_VERSION = 'v0.12.3 Beta';