feat: include history in export/import backup #42
4
js/ui.js
4
js/ui.js
@@ -384,11 +384,11 @@ async function importDB() {
|
|||||||
document.getElementById('confirm-ok').onclick = async () => {
|
document.getElementById('confirm-ok').onclick = async () => {
|
||||||
closeConfirm();
|
closeConfirm();
|
||||||
try {
|
try {
|
||||||
const { instances } = JSON.parse(await file.text());
|
const { instances, history = [] } = JSON.parse(await file.text());
|
||||||
const res = await fetch('/api/import', {
|
const res = await fetch('/api/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ instances }),
|
body: JSON.stringify({ instances, history }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; }
|
if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; }
|
||||||
|
|||||||
12
server/db.js
12
server/db.js
@@ -155,7 +155,7 @@ export function deleteInstance(vmid) {
|
|||||||
db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid);
|
db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importInstances(rows) {
|
export function importInstances(rows, historyRows = []) {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
db.exec('DELETE FROM instance_history');
|
db.exec('DELETE FROM instance_history');
|
||||||
db.exec('DELETE FROM instances');
|
db.exec('DELETE FROM instances');
|
||||||
@@ -168,6 +168,12 @@ export function importInstances(rows) {
|
|||||||
@tailscale, @andromeda, @tailscale_ip, @hardware_acceleration)
|
@tailscale, @andromeda, @tailscale_ip, @hardware_acceleration)
|
||||||
`);
|
`);
|
||||||
for (const row of rows) insert.run(row);
|
for (const row of rows) insert.run(row);
|
||||||
|
if (historyRows.length) {
|
||||||
|
const insertHist = db.prepare(
|
||||||
|
`INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const h of historyRows) insertHist.run(h.vmid, h.field, h.old_value ?? null, h.new_value ?? null, h.changed_at);
|
||||||
|
}
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +183,10 @@ export function getInstanceHistory(vmid) {
|
|||||||
).all(vmid);
|
).all(vmid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllHistory() {
|
||||||
|
return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test helpers ──────────────────────────────────────────────────────────────
|
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function _resetForTest() {
|
export function _resetForTest() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import {
|
import {
|
||||||
getInstances, getInstance, getDistinctStacks,
|
getInstances, getInstance, getDistinctStacks,
|
||||||
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory,
|
createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
|
||||||
export const router = Router();
|
export const router = Router();
|
||||||
@@ -116,14 +116,15 @@ router.put('/instances/:vmid', (req, res) => {
|
|||||||
// GET /api/export
|
// GET /api/export
|
||||||
router.get('/export', (_req, res) => {
|
router.get('/export', (_req, res) => {
|
||||||
const instances = getInstances();
|
const instances = getInstances();
|
||||||
|
const history = getAllHistory();
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`);
|
res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`);
|
||||||
res.json({ version: 1, exported_at: new Date().toISOString(), instances });
|
res.json({ version: 2, exported_at: new Date().toISOString(), instances, history });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/import
|
// POST /api/import
|
||||||
router.post('/import', (req, res) => {
|
router.post('/import', (req, res) => {
|
||||||
const { instances } = req.body ?? {};
|
const { instances, history = [] } = req.body ?? {};
|
||||||
if (!Array.isArray(instances)) {
|
if (!Array.isArray(instances)) {
|
||||||
return res.status(400).json({ error: 'body must contain an instances array' });
|
return res.status(400).json({ error: 'body must contain an instances array' });
|
||||||
}
|
}
|
||||||
@@ -134,7 +135,7 @@ router.post('/import', (req, res) => {
|
|||||||
}
|
}
|
||||||
if (errors.length) return res.status(400).json({ errors });
|
if (errors.length) return res.status(400).json({ errors });
|
||||||
try {
|
try {
|
||||||
importInstances(instances.map(normalise));
|
importInstances(instances.map(normalise), Array.isArray(history) ? history : []);
|
||||||
res.json({ imported: instances.length });
|
res.json({ imported: instances.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('POST /api/import', e);
|
console.error('POST /api/import', e);
|
||||||
|
|||||||
@@ -275,6 +275,18 @@ describe('GET /api/export', () => {
|
|||||||
const res = await request(app).get('/api/export')
|
const res = await request(app).get('/api/export')
|
||||||
expect(res.body.instances).toEqual([])
|
expect(res.body.instances).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns version 2', async () => {
|
||||||
|
const res = await request(app).get('/api/export')
|
||||||
|
expect(res.body.version).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes a history array', async () => {
|
||||||
|
await request(app).post('/api/instances').send(base)
|
||||||
|
const res = await request(app).get('/api/export')
|
||||||
|
expect(res.body.history).toBeInstanceOf(Array)
|
||||||
|
expect(res.body.history.some(e => e.field === 'created')).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/import ──────────────────────────────────────────────────────────
|
// ── POST /api/import ──────────────────────────────────────────────────────────
|
||||||
@@ -309,6 +321,26 @@ describe('POST /api/import', () => {
|
|||||||
.send({ instances: [{ ...base, name: undefined, vmid: 1 }] })
|
.send({ instances: [{ ...base, name: undefined, vmid: 1 }] })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores history when history array is provided', async () => {
|
||||||
|
await request(app).post('/api/instances').send(base)
|
||||||
|
const exp = await request(app).get('/api/export')
|
||||||
|
await request(app).post('/api/instances').send({ ...base, vmid: 999, name: 'other' })
|
||||||
|
const res = await request(app).post('/api/import').send({
|
||||||
|
instances: exp.body.instances,
|
||||||
|
history: exp.body.history,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const hist = await request(app).get('/api/instances/100/history')
|
||||||
|
expect(hist.body.some(e => e.field === 'created')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('succeeds with a v1 backup that has no history key', async () => {
|
||||||
|
const res = await request(app).post('/api/import')
|
||||||
|
.send({ instances: [{ ...base, vmid: 1, name: 'legacy' }] })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.imported).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Static assets & SPA routing ───────────────────────────────────────────────
|
// ── Static assets & SPA routing ───────────────────────────────────────────────
|
||||||
|
|||||||
@@ -202,6 +202,15 @@ describe('importInstances', () => {
|
|||||||
importInstances([{ ...base, name: 'new', vmid: 2 }]);
|
importInstances([{ ...base, name: 'new', vmid: 2 }]);
|
||||||
expect(getInstanceHistory(1)).toHaveLength(0);
|
expect(getInstanceHistory(1)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores history rows when provided', () => {
|
||||||
|
importInstances(
|
||||||
|
[{ ...base, name: 'a', vmid: 1 }],
|
||||||
|
[{ vmid: 1, field: 'created', old_value: null, new_value: null, changed_at: '2026-01-01 00:00:00' }]
|
||||||
|
);
|
||||||
|
const h = getInstanceHistory(1);
|
||||||
|
expect(h.some(e => e.field === 'created')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── instance history ─────────────────────────────────────────────────────────
|
// ── instance history ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user