-
+
@@ -340,7 +340,7 @@ Each network service supports multi-device management with conflict detection:
### Fleet operations
- **Firmware orchestration** — fleet version overview, live update checks, and MikroTik's official release notes in-app. Staged rollouts run in **waves** (wave 1 = canary) through a verified pipeline per device: pre-upgrade backup → install → ride out the reboot → confirm it returned healthy on the new version → next. **Halt on failure** stops the rollout if any device fails, and a device that comes back on the *old* version counts as a failure. Rollouts can be scheduled, and cancelling never interrupts an in-flight flash
-- **Backups** — on demand or on a daily/weekly/monthly schedule for all online devices, downloadable from the UI. Config snapshots and their restorable `.rsc` are one artifact: delete either and both go, so they never drift apart
+- **Backups** — on demand or on a daily/weekly/monthly schedule for all online devices. Filter by device, type and date range; read one in place instead of downloading it; and compare any two as a diff — they are `/export` text, not opaque blobs. Config snapshots and their restorable `.rsc` are one artifact: delete either and both go, so they never drift apart, and a bulk delete says how many snapshots it will take with it
- **Configuration templates** — reusable sets (DNS, NTP, syslog) pushed to many devices with per-device result reporting
- **Bulk device add** — "Try All" on discovered devices runs as a server-side job that survives a closed browser tab, with live progress and cancel
- **Device organisation** — colour-coded tags, rack location, physical address with map support, and per-device notes
diff --git a/backend/package.json b/backend/package.json
index 6426ae8..acab926 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
- "version": "0.24.1-beta",
+ "version": "0.24.2-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts
index ad9f58e..fa5878f 100644
--- a/backend/src/routes/backups.ts
+++ b/backend/src/routes/backups.ts
@@ -12,27 +12,130 @@ router.use(requireAuth);
const backupService = new BackupService();
-// GET /api/backups
-router.get('/', async (req: Request, res: Response) => {
- const { deviceId } = req.query;
- let sql = `
- SELECT b.*, d.name as device_name
- FROM backups b JOIN devices d ON d.id = b.device_id
- WHERE 1=1
- `;
- const params: unknown[] = [];
- if (deviceId) {
- sql += ` AND b.device_id = $1`;
- params.push(deviceId);
- }
- const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
- if (siteFilter) sql += ` AND ${siteFilter}`;
- sql += ' ORDER BY b.created_at DESC';
+/** Preview reads the file into memory, so it needs a ceiling. */
+const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
+/** A bulk delete is irreversible; keep one click from reaching an unbounded set. */
+const MAX_BULK_DELETE = 200;
- const backups = await query(sql, params);
+// GET /api/backups — filter by device, type, date range and free text (#134)
+router.get('/', async (req: Request, res: Response) => {
+ const { deviceId, type, from, to, search } = req.query as Record;
+
+ const filters: string[] = [];
+ const params: unknown[] = [];
+ /**
+ * Bind a value and return its placeholder.
+ *
+ * Derived from params.length rather than a hand-kept counter: with a counter,
+ * the last filter's increment is dead and whoever adds the next one inherits a
+ * stale index. This cannot get out of step.
+ */
+ const bind = (v: unknown): string => { params.push(v); return `$${params.length}`; };
+
+ if (deviceId) filters.push(`b.device_id = ${bind(deviceId)}`);
+ if (type) filters.push(`b.backup_type = ${bind(type)}`);
+ // Dates are inclusive at both ends: a picker showing "to: 3 Sep" should include
+ // everything that happened on the 3rd, not stop at its first instant.
+ if (from) filters.push(`b.created_at >= ${bind(from)}`);
+ if (to) filters.push(`b.created_at < (${bind(to)}::date + INTERVAL '1 day')`);
+ if (search) {
+ const p = bind(`%${search}%`);
+ filters.push(`(b.filename ILIKE ${p} OR b.notes ILIKE ${p} OR d.name ILIKE ${p})`);
+ }
+
+ const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
+ if (siteFilter) filters.push(siteFilter);
+
+ const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
+ const backups = await query(
+ `SELECT b.*, d.name as device_name
+ FROM backups b JOIN devices d ON d.id = b.device_id
+ ${where}
+ ORDER BY b.created_at DESC`,
+ params
+ );
res.json(backups);
});
+// GET /api/backups/types — the backup types actually present, for the filter
+router.get('/types', async (req: Request, res: Response) => {
+ const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
+ const rows = await query<{ backup_type: string; n: string }>(
+ `SELECT b.backup_type, COUNT(*)::text AS n
+ FROM backups b
+ ${siteFilter ? `WHERE ${siteFilter}` : ''}
+ GROUP BY b.backup_type ORDER BY b.backup_type`
+ );
+ res.json(rows.map((r) => ({ type: r.backup_type, count: parseInt(r.n, 10) })));
+});
+
+/**
+ * POST /api/backups/bulk-delete
+ *
+ * Deleting a backup cascades to the config snapshot that shares it — they are
+ * deliberately one artifact so they cannot drift apart. Doing that fifty times
+ * from one click deserves to say so, so the count of snapshots that will go with
+ * them is reported here and shown in the confirmation.
+ */
+router.post('/bulk-delete', requireWrite, async (req: Request, res: Response) => {
+ const { ids } = req.body as { ids?: unknown };
+ if (!Array.isArray(ids) || ids.length === 0) {
+ return res.status(400).json({ error: 'ids array is required' });
+ }
+ const clean = ids.map((v) => parseInt(String(v), 10)).filter(Number.isInteger);
+ if (clean.length === 0) return res.status(400).json({ error: 'ids array is required' });
+ if (clean.length > MAX_BULK_DELETE) {
+ return res.status(400).json({ error: `Cannot delete more than ${MAX_BULK_DELETE} backups at once` });
+ }
+
+ // Only ever touch backups in the active site, so a bulk action cannot reach
+ // across into another site's history.
+ const siteFilter = siteScopeByDevice(activeSite(req), 'device_id');
+ const allowed = await query<{ id: number }>(
+ `SELECT id FROM backups WHERE id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}`,
+ [clean]
+ );
+ const allowedIds = allowed.map((r) => r.id);
+ if (allowedIds.length === 0) return res.status(404).json({ error: 'No matching backups' });
+
+ let deleted = 0;
+ const failures: { id: number; error: string }[] = [];
+ for (const id of allowedIds) {
+ try { await backupService.deleteBackup(id); deleted++; }
+ catch (e) { failures.push({ id, error: (e as Error).message }); }
+ }
+ return res.json({ deleted, requested: clean.length, failures });
+});
+
+// GET /api/backups/bulk-delete/preview?ids=1,2,3 — what a bulk delete would remove
+router.get('/bulk-delete/preview', async (req: Request, res: Response) => {
+ const ids = String(req.query.ids || '')
+ .split(',').map((v) => parseInt(v, 10)).filter(Number.isInteger);
+ if (ids.length === 0) return res.json({ backups: 0, snapshots: 0, devices: [] });
+
+ const siteFilter = siteScopeByDevice(activeSite(req), 'b.device_id');
+ const rows = await query<{ n: string; snapshots: string }>(
+ `SELECT COUNT(*)::text AS n,
+ COUNT(dc.id)::text AS snapshots
+ FROM backups b
+ LEFT JOIN device_configs dc ON dc.backup_id = b.id
+ WHERE b.id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}`,
+ [ids]
+ );
+ const devices = await query<{ name: string; n: string }>(
+ `SELECT d.name, COUNT(*)::text AS n
+ FROM backups b JOIN devices d ON d.id = b.device_id
+ WHERE b.id = ANY($1::int[]) ${siteFilter ? `AND ${siteFilter}` : ''}
+ GROUP BY d.name ORDER BY d.name`,
+ [ids]
+ );
+ return res.json({
+ backups: parseInt(rows[0]?.n || '0', 10),
+ snapshots: parseInt(rows[0]?.snapshots || '0', 10),
+ devices: devices.map((d) => ({ name: d.name, count: parseInt(d.n, 10) })),
+ });
+});
+
// POST /api/backups
router.post('/', requireWrite, async (req: Request, res: Response) => {
const { deviceId, notes } = req.body;
@@ -93,4 +196,76 @@ router.delete('/:id', requireWrite, async (req: Request, res: Response) => {
return res.json({ message: 'Backup deleted' });
});
+/**
+ * Read a backup's text.
+ *
+ * Backups are `/export compact` output, i.e. plain RouterOS script — not the
+ * binary `.backup` blob — so they can be read in place instead of downloaded
+ * and opened elsewhere (#134).
+ */
+async function readBackupText(id: string | number): Promise<
+ { meta: BackupMeta; text: string } | { error: string; status: number }
+> {
+ const meta = await queryOne(
+ `SELECT b.id, b.filename, b.size_bytes, b.backup_type, b.notes, b.created_at,
+ b.file_path, b.device_id, d.name AS device_name
+ FROM backups b JOIN devices d ON d.id = b.device_id
+ WHERE b.id = $1`,
+ [id]
+ );
+ if (!meta) return { error: 'Backup not found', status: 404 };
+ if (!fs.existsSync(meta.file_path)) {
+ return { error: 'Backup file not found on disk', status: 404 };
+ }
+ const stat = fs.statSync(meta.file_path);
+ if (stat.size > MAX_PREVIEW_BYTES) {
+ return {
+ error: `This backup is ${Math.round(stat.size / 1024)} KB, too large to preview. Download it instead.`,
+ status: 413,
+ };
+ }
+ return { meta, text: fs.readFileSync(meta.file_path, 'utf8') };
+}
+
+interface BackupMeta {
+ id: number;
+ filename: string;
+ size_bytes: number | null;
+ backup_type: string | null;
+ notes: string | null;
+ created_at: string;
+ file_path: string;
+ device_id: number;
+ device_name: string;
+}
+
+// GET /api/backups/:id/content — preview in place
+router.get('/:id/content', async (req: Request, res: Response) => {
+ const result = await readBackupText(req.params.id);
+ if ('error' in result) return res.status(result.status).json({ error: result.error });
+ const { file_path: _omit, ...meta } = result.meta;
+ return res.json({ ...meta, content: result.text });
+});
+
+// GET /api/backups/:fromId/diff/:toId — compare two backups
+// Mirrors the config-history diff contract: both texts are returned and the
+// browser renders them with the same lineDiff used for config snapshots.
+router.get('/:fromId/diff/:toId', async (req: Request, res: Response) => {
+ const [from, to] = await Promise.all([
+ readBackupText(req.params.fromId),
+ readBackupText(req.params.toId),
+ ]);
+ for (const r of [from, to]) {
+ if ('error' in r) return res.status(r.status).json({ error: r.error });
+ }
+ const f = from as { meta: BackupMeta; text: string };
+ const t = to as { meta: BackupMeta; text: string };
+ return res.json({
+ from: { id: f.meta.id, filename: f.meta.filename, device_name: f.meta.device_name,
+ created_at: f.meta.created_at, text: f.text },
+ to: { id: t.meta.id, filename: t.meta.filename, device_name: t.meta.device_name,
+ created_at: t.meta.created_at, text: t.text },
+ });
+});
+
export default router;
diff --git a/frontend/package.json b/frontend/package.json
index 68d2d57..093f279 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.24.1-beta",
+ "version": "0.24.2-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/backups/BackupViewerModal.tsx b/frontend/src/components/backups/BackupViewerModal.tsx
new file mode 100644
index 0000000..a551e3d
--- /dev/null
+++ b/frontend/src/components/backups/BackupViewerModal.tsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+
+ {comparing
+ ?
+ : }
+
+ {comparing ? (
+ <>
+
+ Comparing backups
+
+ {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.
+
@@ -238,8 +415,15 @@ export default function BackupsPage() {
{backup.notes || ''}
-
+
e.stopPropagation()}>
+
)}
+ {viewing && (
+ 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 && (
+
+ {bulkPreview.snapshots} of these
+ {bulkPreview.snapshots === 1 ? ' is' : ' are'} tied to a{' '}
+ Config History snapshot. 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'}.
+
+
+ )}
+
+
+ This cannot be undone.
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ )}
+
{/* Confirm deletion of a snapshot-linked backup (removes the config snapshot too) */}
{snapshotDeleteTarget && (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 114af6a..4b93351 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -965,15 +965,60 @@ export const operationsApi = {
syncAll: () => api.post('/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('/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('/backups', { params });
+ },
+ types: () => api.get<{ type: string; count: number }[]>('/backups/types'),
create: (deviceId: number, notes?: string) =>
api.post('/backups', { deviceId, notes }),
download: (id: number) =>
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
+ content: (id: number) => api.get(`/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 ────────────────────────────────────────────────────────────
diff --git a/frontend/src/utils/backupCompare.test.ts b/frontend/src/utils/backupCompare.test.ts
new file mode 100644
index 0000000..abb145b
--- /dev/null
+++ b/frontend/src/utils/backupCompare.test.ts
@@ -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]);
+ });
+});
diff --git a/frontend/src/utils/backupCompare.ts b/frontend/src/utils/backupCompare.ts
new file mode 100644
index 0000000..e2013dc
--- /dev/null
+++ b/frontend/src/utils/backupCompare.ts
@@ -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];
+}
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index c2458a5..ca409e8 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = 'v0.24.1 Beta';
+export const APP_VERSION = 'v0.24.2 Beta';