10 Commits

Author SHA1 Message Date
josh 07cef73fae Merge pull request 'v1.4.0' (#44) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 39s
CI / build-dev (push) Has been skipped
Reviewed-on: #44
2026-03-28 16:16:46 -04:00
josh 1a84edc064 Merge pull request 'chore: bump version to 1.4.0' (#43) from chore/bump-v1.4.0 into dev
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 24s
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #43
2026-03-28 16:15:32 -04:00
josh bfb2c26821 chore: bump version to 1.4.0
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:09:08 -04:00
josh a985268987 Merge pull request 'feat: include history in export/import backup' (#42) from feat/export-import-history into dev
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 21s
Reviewed-on: #42
2026-03-28 16:06:21 -04:00
josh 218cdb08c5 feat: include history in export/import backup
CI / test (pull_request) Successful in 15s
CI / build-dev (pull_request) Has been skipped
Export now returns version 2 with a history array alongside instances.
Import accepts the history array and restores all audit events. v1 backups
without a history key still import cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 16:04:53 -04:00
josh e3d089a71f Merge pull request 'v1.3.1' (#39) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 42s
CI / build-dev (push) Has been skipped
Reviewed-on: #39
2026-03-28 15:42:00 -04:00
josh 120b61a423 Merge pull request 'v1.3.0' (#35) from dev into main
CI / test (push) Successful in 14s
Release / release (push) Successful in 40s
CI / build-dev (push) Has been skipped
Reviewed-on: #35
2026-03-28 15:31:57 -04:00
josh cd16b7ea28 Merge pull request 'v1.2.2' (#16) from dev into main
CI / test (push) Successful in 13s
Release / release (push) Successful in 34s
CI / build-dev (push) Has been skipped
Reviewed-on: #16
2026-03-28 14:01:33 -04:00
josh afbdefa549 Merge pull request 'v1.2.1' (#13) from dev into main
CI / test (push) Successful in 14s
CI / build-dev (push) Has been skipped
Reviewed-on: #13
2026-03-28 13:55:34 -04:00
josh f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
CI / test (push) Successful in 13s
Release / release (push) Failing after 5m14s
CI / build-dev (push) Has been skipped
Reviewed-on: #10
2026-03-28 13:24:34 -04:00
7 changed files with 61 additions and 9 deletions
+2 -2
View File
@@ -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; }
+1 -1
View File
@@ -1 +1 @@
const VERSION = "1.3.1"; const VERSION = "1.4.0";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.3.1", "version": "1.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
+11 -1
View File
@@ -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() {
+5 -4
View File
@@ -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);
+32
View File
@@ -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 ───────────────────────────────────────────────
+9
View File
@@ -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 ─────────────────────────────────────────────────────────