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
@@ -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": {
|
||||
|
||||
+192
-17
@@ -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<string, string | undefined>;
|
||||
|
||||
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<BackupMeta>(
|
||||
`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;
|
||||
|
||||
Reference in New Issue
Block a user