feat(backups): filter, preview, compare and bulk delete (v0.24.2-beta)
Closes #134. Backups are `/export compact` output — plain RouterOS script, not the binary `.backup` blob — which is what makes the reporter's "it's just a text, after all" correct and most of this cheap. - Filters: device, type, date range and free text over device name, filename and notes. Dates are inclusive at both ends, because a picker showing "to: 3 Sep" that stops at that day's first instant is a trap. - Preview in place, on row click. No round trip through the downloads folder to see what a backup contains. - Compare any two, reusing the lineDiff that Config History already uses rather than adding a second diff implementation. - Bulk delete, with selection and a confirmation that names what goes. Two deliberate choices. Bulk delete had to respect an existing invariant: a backup and its config snapshot are one artifact, joined by ON DELETE CASCADE so they cannot drift apart. Removing fifty in one click can therefore destroy restore points nobody looked at. The confirmation is built from a server-side preview that reports the backups per device *and* how many config snapshots will go with them, and it is capped at 200 per call. The action is also site-scoped, so it cannot reach into another site's history. The Filename column is gone from the table but the filename is not gone: it is the natural handle for a file — device and timestamp, the thing you would quote to a colleague — so it moved to the preview and diff headers where you are actually looking at that file, rather than being dropped. Diff direction is extracted and tested. Selection order in a table is whatever the user happened to click and carries no meaning, so the pair is ordered oldest-first; rendered the other way round every addition reads as a deletion, and nothing about the output looks wrong while it does. Incidentally fixed: 'pre-upgrade' backups were labelled "Manual" in the table because the badge only knew three types. Type wording now has one home. The list query's placeholder counter is replaced by a bind() helper deriving from params.length. The counter's final increment was dead, and whoever added the next filter would have inherited a stale index. Verified against 110 real backups: every filter and combination returns the expected counts and partitions cleanly, preview and diff read real .rsc with file_path withheld, and bulk delete removed three throwaway backups plus their files and left the other 110 untouched. Guards checked: empty, missing, non-numeric and injected ids, unknown ids, and the 200 cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63b5677522
commit
719563dd22
@@ -0,0 +1,172 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, FileText, GitCompare, Download, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
import { backupsApi } from '../../services/api';
|
||||
import { lineDiff } from '../../utils/lineDiff';
|
||||
|
||||
/**
|
||||
* Reads a backup in place, or compares two (#134).
|
||||
*
|
||||
* Backups are `/export compact` output — plain RouterOS script, not the binary
|
||||
* `.backup` blob — so both are just text. The diff reuses the same lineDiff
|
||||
* that Config History already uses, rather than introducing a second one.
|
||||
*/
|
||||
interface Props {
|
||||
/** One id previews; two compare, oldest first. */
|
||||
ids: number[];
|
||||
onClose: () => void;
|
||||
onDownload: (id: number, filename: string) => void;
|
||||
}
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
const err = e as { response?: { data?: { error?: string } }; message?: string };
|
||||
return err.response?.data?.error ?? err.message ?? 'Could not read this backup.';
|
||||
}
|
||||
|
||||
export default function BackupViewerModal({ ids, onClose, onDownload }: Props) {
|
||||
const comparing = ids.length === 2;
|
||||
|
||||
const single = useQuery({
|
||||
queryKey: ['backup-content', ids[0]],
|
||||
queryFn: () => backupsApi.content(ids[0]).then((r) => r.data),
|
||||
enabled: !comparing && ids.length === 1,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const pair = useQuery({
|
||||
queryKey: ['backup-diff', ids[0], ids[1]],
|
||||
queryFn: () => backupsApi.diff(ids[0], ids[1]).then((r) => r.data),
|
||||
enabled: comparing,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const loading = comparing ? pair.isLoading : single.isLoading;
|
||||
const error = comparing ? pair.error : single.error;
|
||||
|
||||
const rows = pair.data ? lineDiff(pair.data.from.text, pair.data.to.text) : [];
|
||||
const added = rows.filter((r) => r.type === 'add').length;
|
||||
const removed = rows.filter((r) => r.type === 'del').length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card w-full max-w-5xl max-h-[90vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4 border-b border-gray-200 dark:border-slate-700">
|
||||
{comparing
|
||||
? <GitCompare className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
: <FileText className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
{comparing ? (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Comparing backups
|
||||
</h3>
|
||||
{pair.data && (
|
||||
// The filename lives here rather than in the table: it is how you
|
||||
// would refer to this file to someone else, so it belongs where
|
||||
// you are actually looking at the file.
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 break-all mt-0.5">
|
||||
{pair.data.from.filename} → {pair.data.to.filename}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{single.data?.device_name ?? 'Backup'}
|
||||
</h3>
|
||||
{single.data && (
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 break-all mt-0.5">
|
||||
{single.data.filename}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!comparing && single.data && (
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
|
||||
{format(new Date(single.data.created_at), 'MMM d, yyyy HH:mm')}
|
||||
{single.data.notes ? ` · ${single.data.notes}` : ''}
|
||||
</p>
|
||||
)}
|
||||
{comparing && pair.data && (
|
||||
<p className="text-xs mt-0.5">
|
||||
<span className="text-green-600 dark:text-green-400">+{added}</span>{' '}
|
||||
<span className="text-red-600 dark:text-red-400">−{removed}</span>
|
||||
<span className="text-gray-400 dark:text-slate-500">
|
||||
{' '}· {format(new Date(pair.data.from.created_at), 'MMM d HH:mm')}
|
||||
{' → '}{format(new Date(pair.data.to.created_at), 'MMM d HH:mm')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!comparing && single.data && (
|
||||
<button
|
||||
onClick={() => onDownload(single.data!.id, single.data!.filename)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-gray-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Reading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errorText(error)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && comparing && (
|
||||
added + removed === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-8 text-center">
|
||||
These two backups are identical.
|
||||
</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 leading-relaxed">
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
row.type === 'add' && 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400',
|
||||
row.type === 'del' && 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400',
|
||||
row.type === 'ctx' && 'text-gray-500 dark:text-slate-400'
|
||||
)}
|
||||
>
|
||||
{row.type === 'add' ? '+' : row.type === 'del' ? '−' : ' '} {row.text}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
|
||||
{!loading && !error && !comparing && single.data && (
|
||||
<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 leading-relaxed whitespace-pre-wrap">
|
||||
{single.data.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, AlertTriangle, Loader2, X } from 'lucide-react';
|
||||
import {
|
||||
HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, AlertTriangle,
|
||||
Loader2, X, Search, GitCompare, FileText, FilterX,
|
||||
} from 'lucide-react';
|
||||
import { backupsApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import type { Backup } from '../types';
|
||||
import BackupViewerModal from '../components/backups/BackupViewerModal';
|
||||
import { orderForDiff } from '../utils/backupCompare';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** One home for backup-type wording, used by the filter and the table badge. */
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
'config-snapshot': 'Config Snapshot',
|
||||
scheduled: 'Scheduled',
|
||||
manual: 'Manual',
|
||||
'pre-upgrade': 'Pre-upgrade',
|
||||
};
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -25,11 +38,63 @@ export default function BackupsPage() {
|
||||
const [snapshotDeleteTarget, setSnapshotDeleteTarget] = useState<Backup | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Filters (#134). Held together so the query key is a single object.
|
||||
const [filters, setFilters] = useState({ deviceId: '', type: '', from: '', to: '', search: '' });
|
||||
const activeFilterCount = Object.values(filters).filter(Boolean).length;
|
||||
const clearFilters = () => setFilters({ deviceId: '', type: '', from: '', to: '', search: '' });
|
||||
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [viewing, setViewing] = useState<number[] | null>(null);
|
||||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||
|
||||
const { data: backups = [], isLoading } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: () => backupsApi.list().then((r) => r.data),
|
||||
queryKey: ['backups', filters],
|
||||
queryFn: () => backupsApi.list(filters).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: types = [] } = useQuery({
|
||||
queryKey: ['backup-types'],
|
||||
queryFn: () => backupsApi.types().then((r) => r.data),
|
||||
});
|
||||
|
||||
// What a bulk delete would actually remove, including the config snapshots
|
||||
// that go with it. Fetched before asking, so the confirmation can be specific
|
||||
// rather than "are you sure?".
|
||||
const { data: bulkPreview } = useQuery({
|
||||
queryKey: ['backup-bulk-preview', selected],
|
||||
queryFn: () => backupsApi.bulkDeletePreview(selected).then((r) => r.data),
|
||||
enabled: bulkConfirm && selected.length > 0,
|
||||
});
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: () => backupsApi.bulkDelete(selected).then((r) => r.data),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-types'] });
|
||||
setBulkConfirm(false);
|
||||
setSelected([]);
|
||||
if (res.failures.length > 0) {
|
||||
setError(`${res.deleted} deleted, ${res.failures.length} failed.`);
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Bulk delete failed');
|
||||
setBulkConfirm(false);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = (id: number) =>
|
||||
setSelected((cur) => (cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id]));
|
||||
const allShownSelected = backups.length > 0 && backups.every((b) => selected.includes(b.id));
|
||||
const toggleAll = () =>
|
||||
setSelected(allShownSelected ? [] : backups.map((b) => b.id));
|
||||
|
||||
// Exactly two, oldest first, so the diff reads forwards in time (tested in
|
||||
// utils/backupCompare — a reversed diff shows additions as deletions and
|
||||
// looks perfectly normal while doing it).
|
||||
const comparePair = orderForDiff(selected, backups);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
@@ -169,16 +234,110 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters (#134) */}
|
||||
<div className="card p-3 flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="label text-[11px]">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
className="input pl-8"
|
||||
placeholder="Device, filename or note…"
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[150px]">
|
||||
<label className="label text-[11px]">Device</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.deviceId}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, deviceId: e.target.value }))}
|
||||
>
|
||||
<option value="">All devices</option>
|
||||
{devices.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[140px]">
|
||||
<label className="label text-[11px]">Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={filters.type}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, type: e.target.value }))}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{types.map((t) => (
|
||||
<option key={t.type} value={t.type}>{TYPE_LABEL[t.type] ?? t.type} ({t.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-[11px]">From</label>
|
||||
<input type="date" className="input"
|
||||
value={filters.from}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-[11px]">To</label>
|
||||
<input type="date" className="input"
|
||||
value={filters.to}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value }))} />
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button onClick={clearFilters} className="btn-secondary flex items-center gap-1.5 text-xs">
|
||||
<FilterX className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection actions */}
|
||||
{selected.length > 0 && (
|
||||
<div className="card px-4 py-2.5 flex flex-wrap items-center gap-3 border-blue-200 dark:border-blue-800">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selected.length} selected
|
||||
</span>
|
||||
<button onClick={() => setSelected([])} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
Clear
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => comparePair && setViewing(comparePair)}
|
||||
disabled={!comparePair}
|
||||
title={comparePair ? 'Compare the two selected backups' : 'Select exactly two backups to compare'}
|
||||
className="btn-secondary flex items-center gap-1.5 text-xs disabled:opacity-40"
|
||||
>
|
||||
<GitCompare className="w-3.5 h-3.5" /> Compare
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setBulkConfirm(true)}
|
||||
className="btn-danger flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete {selected.length}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<HardDrive className="w-16 h-16 text-gray-300 dark:text-slate-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">No backups yet</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Create a backup to save your device configurations
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">
|
||||
{activeFilterCount > 0 ? 'No backups match these filters' : 'No backups yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
{activeFilterCount > 0
|
||||
? 'Try widening the date range or clearing a filter.'
|
||||
: 'Create a backup to save your device configurations'}
|
||||
</p>
|
||||
{activeFilterCount > 0 && (
|
||||
<button onClick={clearFilters} className="btn-secondary mt-3 text-xs">Clear filters</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -187,24 +346,46 @@ export default function BackupsPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-3 py-2.5 w-9">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all shown"
|
||||
checked={allShownSelected}
|
||||
onChange={toggleAll}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</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">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>
|
||||
<th className="table-header px-4 py-2.5 text-left w-32">Actions</th>
|
||||
<th className="table-header px-4 py-2.5 text-left w-36">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{backups.map((backup) => (
|
||||
<tr key={backup.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<tr
|
||||
key={backup.id}
|
||||
onClick={() => setViewing([backup.id])}
|
||||
title="Click to preview"
|
||||
className={clsx(
|
||||
'cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
selected.includes(backup.id) && 'bg-blue-50/60 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${backup.filename}`}
|
||||
checked={selected.includes(backup.id)}
|
||||
onChange={() => toggle(backup.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{backup.device_name || '—'}
|
||||
</td>
|
||||
<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(
|
||||
@@ -222,11 +403,7 @@ export default function BackupsPage() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{backup.backup_type === 'config-snapshot'
|
||||
? 'Config Snapshot'
|
||||
: backup.backup_type === 'scheduled'
|
||||
? 'Scheduled'
|
||||
: 'Manual'}
|
||||
{TYPE_LABEL[backup.backup_type ?? ''] ?? backup.backup_type ?? 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
@@ -238,8 +415,15 @@ export default function BackupsPage() {
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500 italic">
|
||||
{backup.notes || ''}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setViewing([backup.id])}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Preview"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadBackup(backup.id, backup.filename)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
@@ -312,6 +496,97 @@ export default function BackupsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<BackupViewerModal
|
||||
ids={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
onDownload={downloadBackup}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk delete confirmation.
|
||||
Backups and their config snapshots are deliberately one artifact, so a
|
||||
bulk delete can remove restore points the operator never looked at.
|
||||
The confirmation therefore names exactly what goes, per device, and
|
||||
says plainly that there is no undo. */}
|
||||
{bulkConfirm && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="card w-full max-w-lg">
|
||||
<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 {selected.length} backup{selected.length === 1 ? '' : 's'}?
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setBulkConfirm(false)}
|
||||
disabled={bulkDeleteMutation.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">
|
||||
{!bulkPreview ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400 py-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Checking what this removes…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-gray-600 dark:text-slate-300">
|
||||
<ul className="space-y-1">
|
||||
{bulkPreview.devices.map((d) => (
|
||||
<li key={d.name} className="flex justify-between gap-4">
|
||||
<span className="text-gray-900 dark:text-white">{d.name}</span>
|
||||
<span className="text-gray-400">{d.count} backup{d.count === 1 ? '' : 's'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{bulkPreview.snapshots > 0 && (
|
||||
<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">
|
||||
<span className="font-semibold">{bulkPreview.snapshots}</span> of these
|
||||
{bulkPreview.snapshots === 1 ? ' is' : ' are'} tied to a{' '}
|
||||
<span className="font-medium">Config History snapshot</span>. Deleting
|
||||
{bulkPreview.snapshots === 1 ? ' it' : ' them'} also removes
|
||||
{bulkPreview.snapshots === 1 ? ' that snapshot' : ' those snapshots'} and the
|
||||
ability to roll back to {bulkPreview.snapshots === 1 ? 'it' : 'them'}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
This cannot be undone.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setBulkConfirm(false)}
|
||||
disabled={bulkDeleteMutation.isPending}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => bulkDeleteMutation.mutate()}
|
||||
disabled={bulkDeleteMutation.isPending || !bulkPreview}
|
||||
className="btn-danger inline-flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{bulkDeleteMutation.isPending
|
||||
? <Loader2 className="w-4 h-4 animate-spin" />
|
||||
: <Trash2 className="w-4 h-4" />}
|
||||
Delete {bulkPreview?.backups ?? selected.length}
|
||||
{bulkPreview && bulkPreview.snapshots > 0 ? ' + snapshots' : ''}
|
||||
</button>
|
||||
</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">
|
||||
|
||||
@@ -965,15 +965,60 @@ export const operationsApi = {
|
||||
syncAll: () => api.post<OpsActionResult>('/operations/sync-all', undefined, { timeout: 180_000 }),
|
||||
};
|
||||
|
||||
export interface BackupFilters {
|
||||
deviceId?: number | string;
|
||||
type?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface BackupContent {
|
||||
id: number;
|
||||
filename: string;
|
||||
device_name: string;
|
||||
device_id: number;
|
||||
backup_type: string | null;
|
||||
notes: string | null;
|
||||
size_bytes: number | null;
|
||||
created_at: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BackupDiffSide {
|
||||
id: number;
|
||||
filename: string;
|
||||
device_name: string;
|
||||
created_at: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const backupsApi = {
|
||||
list: (deviceId?: number) =>
|
||||
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
|
||||
list: (filters: BackupFilters = {}) => {
|
||||
// Drop empty values so an unset filter doesn't become `?type=`.
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(filters).filter(([, v]) => v !== undefined && v !== '')
|
||||
);
|
||||
return api.get<Backup[]>('/backups', { params });
|
||||
},
|
||||
types: () => api.get<{ type: string; count: number }[]>('/backups/types'),
|
||||
create: (deviceId: number, notes?: string) =>
|
||||
api.post<Backup>('/backups', { deviceId, notes }),
|
||||
download: (id: number) =>
|
||||
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
|
||||
content: (id: number) => api.get<BackupContent>(`/backups/${id}/content`),
|
||||
diff: (fromId: number, toId: number) =>
|
||||
api.get<{ from: BackupDiffSide; to: BackupDiffSide }>(`/backups/${fromId}/diff/${toId}`),
|
||||
restore: (id: number) => api.post(`/backups/${id}/restore`),
|
||||
delete: (id: number) => api.delete(`/backups/${id}`),
|
||||
bulkDeletePreview: (ids: number[]) =>
|
||||
api.get<{ backups: number; snapshots: number; devices: { name: string; count: number }[] }>(
|
||||
'/backups/bulk-delete/preview', { params: { ids: ids.join(',') } }
|
||||
),
|
||||
bulkDelete: (ids: number[]) =>
|
||||
api.post<{ deleted: number; requested: number; failures: { id: number; error: string }[] }>(
|
||||
'/backups/bulk-delete', { ids }
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Config History ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { orderForDiff, type ComparableBackup } from './backupCompare';
|
||||
|
||||
const backups: ComparableBackup[] = [
|
||||
{ id: 10, created_at: '2026-09-01T10:00:00Z' },
|
||||
{ id: 20, created_at: '2026-09-05T10:00:00Z' },
|
||||
{ id: 30, created_at: '2026-09-08T10:00:00Z' },
|
||||
];
|
||||
|
||||
describe('orderForDiff', () => {
|
||||
// The property that matters: whichever order the user clicked in, the diff
|
||||
// reads forwards in time. Selecting newest-then-oldest must not invert it.
|
||||
it('always returns [older, newer]', () => {
|
||||
expect(orderForDiff([10, 30], backups)).toEqual([10, 30]);
|
||||
expect(orderForDiff([30, 10], backups)).toEqual([10, 30]);
|
||||
expect(orderForDiff([30, 20], backups)).toEqual([20, 30]);
|
||||
});
|
||||
|
||||
it('refuses anything other than a pair', () => {
|
||||
expect(orderForDiff([], backups)).toBeNull();
|
||||
expect(orderForDiff([10], backups)).toBeNull();
|
||||
expect(orderForDiff([10, 20, 30], backups)).toBeNull();
|
||||
});
|
||||
|
||||
// Two backups taken in the same second is unlikely but possible; the result
|
||||
// must still be stable rather than depending on click order.
|
||||
it('is stable when timestamps tie', () => {
|
||||
const tied: ComparableBackup[] = [
|
||||
{ id: 7, created_at: '2026-09-01T10:00:00Z' },
|
||||
{ id: 9, created_at: '2026-09-01T10:00:00Z' },
|
||||
];
|
||||
expect(orderForDiff([9, 7], tied)).toEqual([7, 9]);
|
||||
expect(orderForDiff([7, 9], tied)).toEqual([7, 9]);
|
||||
});
|
||||
|
||||
it('falls back to id when a backup is missing or undated', () => {
|
||||
expect(orderForDiff([99, 98], [])).toEqual([98, 99]);
|
||||
const bad: ComparableBackup[] = [
|
||||
{ id: 1, created_at: 'not-a-date' },
|
||||
{ id: 2, created_at: 'not-a-date' },
|
||||
];
|
||||
expect(orderForDiff([2, 1], bad)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Ordering two selected backups for comparison (#134).
|
||||
*
|
||||
* A diff is directional: rendered the wrong way round, every addition reads as a
|
||||
* deletion and vice versa. Nothing about the output looks broken when this is
|
||||
* backwards, which is exactly why it is worth pinning down — selection order in
|
||||
* the table is whatever the user happened to click, and carries no meaning.
|
||||
*/
|
||||
|
||||
export interface ComparableBackup {
|
||||
id: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [older, newer] so the diff reads forwards in time.
|
||||
*
|
||||
* Returns null unless exactly two are selected: comparing one thing, or three,
|
||||
* is not a diff, and the caller should disable the action rather than guess.
|
||||
*/
|
||||
export function orderForDiff(
|
||||
selectedIds: number[],
|
||||
backups: ComparableBackup[]
|
||||
): [number, number] | null {
|
||||
if (selectedIds.length !== 2) return null;
|
||||
|
||||
const at = (id: number): number => {
|
||||
const b = backups.find((x) => x.id === id);
|
||||
const t = b ? new Date(b.created_at).getTime() : NaN;
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
};
|
||||
|
||||
const [a, b] = selectedIds;
|
||||
if (at(a) === at(b)) {
|
||||
// Same timestamp, or both unknown: fall back to id, which still increases
|
||||
// with time, so the direction stays meaningful rather than arbitrary.
|
||||
return a <= b ? [a, b] : [b, a];
|
||||
}
|
||||
return at(a) < at(b) ? [a, b] : [b, a];
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.24.1 Beta';
|
||||
export const APP_VERSION = 'v0.24.2 Beta';
|
||||
|
||||
Reference in New Issue
Block a user