From af207339a43043b6f8feb3b57c58d44434930d70 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:10:59 -0400 Subject: [PATCH 01/18] feat: settings modal with database export and import Adds a gear button to the nav that opens a settings modal with: - Export: GET /api/export returns all instances as a JSON backup file with a Content-Disposition attachment header - Import: POST /api/import validates and bulk-replaces all instances; client uses FileReader to POST the parsed JSON, with a confirm dialog before destructive replace Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 38 +++++++++++++++++++++++++++++++ index.html | 26 +++++++++++++++++++++ js/ui.js | 57 +++++++++++++++++++++++++++++++++++++++++++++-- server/db.js | 15 +++++++++++++ server/routes.js | 31 +++++++++++++++++++++++++- tests/api.test.js | 46 ++++++++++++++++++++++++++++++++++++++ tests/db.test.js | 21 ++++++++++++++++- 7 files changed, 230 insertions(+), 4 deletions(-) diff --git a/css/app.css b/css/app.css index d21a23a..5595d55 100644 --- a/css/app.css +++ b/css/app.css @@ -70,6 +70,19 @@ nav { .nav-sep { flex: 1; } +.nav-btn { + background: none; + border: 1px solid var(--border2); + color: var(--text2); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + margin-left: 10px; + line-height: 1; +} +.nav-btn:hover { border-color: var(--accent); color: var(--accent); } + .nav-divider { color: var(--border2); } .nav-status { @@ -615,6 +628,31 @@ select:focus { border-color: var(--accent); } .confirm-actions { display: flex; justify-content: flex-end; gap: 10px; } +/* ── SETTINGS MODAL ── */ +.settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } +.settings-section:last-child { border-bottom: none; padding-bottom: 0; } +.settings-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text3); + margin-bottom: 8px; +} +.settings-desc { font-size: 12px; color: var(--text2); margin: 0 0 14px; line-height: 1.6; } +.import-row { display: flex; gap: 10px; align-items: center; } +.import-file-input { flex: 1; } + +.btn-secondary { + background: var(--bg3); + border-color: var(--border2); + color: var(--text); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + +.btn-danger { background: var(--red2); border-color: var(--red); color: var(--text); } +.btn-danger:hover { background: var(--red); } + /* ── SCROLLBAR ── */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/index.html b/index.html index ab3c809..2fdbb6a 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ · +
@@ -171,6 +172,31 @@ + + +
diff --git a/js/ui.js b/js/ui.js index 70f70de..ab5b332 100644 --- a/js/ui.js +++ b/js/ui.js @@ -258,12 +258,62 @@ function showToast(msg, type = 'success') { toastTimer = setTimeout(() => t.classList.remove('show'), 3000); } +// ── Settings Modal ──────────────────────────────────────────────────────────── + +function openSettingsModal() { + document.getElementById('settings-modal').classList.add('open'); +} + +function closeSettingsModal() { + document.getElementById('settings-modal').classList.remove('open'); + document.getElementById('import-file').value = ''; +} + +async function exportDB() { + const res = await fetch('/api/export'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `catalyst-backup-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +async function importDB() { + const file = document.getElementById('import-file').files[0]; + if (!file) { showToast('Select a backup file first', 'error'); return; } + document.getElementById('confirm-title').textContent = 'Replace all instances?'; + document.getElementById('confirm-msg').textContent = + `This will delete all current instances and replace them with the contents of "${file.name}". This cannot be undone.`; + document.getElementById('confirm-overlay').classList.add('open'); + document.getElementById('confirm-ok').onclick = async () => { + closeConfirm(); + try { + const { instances } = JSON.parse(await file.text()); + const res = await fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instances }), + }); + const data = await res.json(); + if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } + showToast(`Imported ${data.imported} instance${data.imported !== 1 ? 's' : ''}`, 'success'); + closeSettingsModal(); + renderDashboard(); + } catch { + showToast('Invalid backup file', 'error'); + } + }; +} + // ── Keyboard / backdrop ─────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; - if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } - if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } + if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } + if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } + if (document.getElementById('settings-modal').classList.contains('open')) { closeSettingsModal(); return; } }); document.getElementById('instance-modal').addEventListener('click', e => { @@ -272,3 +322,6 @@ document.getElementById('instance-modal').addEventListener('click', e => { document.getElementById('confirm-overlay').addEventListener('click', e => { if (e.target === document.getElementById('confirm-overlay')) closeConfirm(); }); +document.getElementById('settings-modal').addEventListener('click', e => { + if (e.target === document.getElementById('settings-modal')) closeSettingsModal(); +}); diff --git a/server/db.js b/server/db.js index b88e974..1a2e373 100644 --- a/server/db.js +++ b/server/db.js @@ -125,6 +125,21 @@ export function deleteInstance(vmid) { return db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid); } +export function importInstances(rows) { + db.exec('BEGIN'); + db.exec('DELETE FROM instances'); + const insert = db.prepare(` + INSERT INTO instances + (name, state, stack, vmid, atlas, argus, semaphore, patchmon, + tailscale, andromeda, tailscale_ip, hardware_acceleration) + VALUES + (@name, @state, @stack, @vmid, @atlas, @argus, @semaphore, @patchmon, + @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) + `); + for (const row of rows) insert.run(row); + db.exec('COMMIT'); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/routes.js b/server/routes.js index 79eaad6..4ad9048 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, } from './db.js'; export const router = Router(); @@ -104,6 +104,35 @@ router.put('/instances/:vmid', (req, res) => { } }); +// GET /api/export +router.get('/export', (_req, res) => { + const instances = getInstances(); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); + res.json({ version: 1, exported_at: new Date().toISOString(), instances }); +}); + +// POST /api/import +router.post('/import', (req, res) => { + const { instances } = req.body ?? {}; + if (!Array.isArray(instances)) { + return res.status(400).json({ error: 'body must contain an instances array' }); + } + const errors = []; + for (const [i, row] of instances.entries()) { + const errs = validate(normalise(row)); + if (errs.length) errors.push({ index: i, errors: errs }); + } + if (errors.length) return res.status(400).json({ errors }); + try { + importInstances(instances.map(normalise)); + res.json({ imported: instances.length }); + } catch (e) { + console.error('POST /api/import', e); + res.status(500).json({ error: 'internal server error' }); + } +}); + // DELETE /api/instances/:vmid router.delete('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); diff --git a/tests/api.test.js b/tests/api.test.js index c807c30..8742ae1 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -239,6 +239,52 @@ describe('DELETE /api/instances/:vmid', () => { }) }) +// ── GET /api/export ─────────────────────────────────────────────────────────── + +describe('GET /api/export', () => { + it('returns 200 with instances array and attachment header', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/export') + expect(res.status).toBe(200) + expect(res.headers['content-disposition']).toMatch(/attachment/) + expect(res.body.instances).toHaveLength(1) + expect(res.body.instances[0].name).toBe('traefik') + }) + + it('returns empty instances array when no data', async () => { + const res = await request(app).get('/api/export') + expect(res.body.instances).toEqual([]) + }) +}) + +// ── POST /api/import ────────────────────────────────────────────────────────── + +describe('POST /api/import', () => { + it('replaces all instances and returns imported count', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 999, name: 'imported' }] }) + expect(res.status).toBe(200) + expect(res.body.imported).toBe(1) + expect((await request(app).get('/api/instances')).body[0].name).toBe('imported') + }) + + it('returns 400 if instances is not an array', async () => { + expect((await request(app).post('/api/import').send({ instances: 'bad' })).status).toBe(400) + }) + + it('returns 400 with per-row errors for invalid rows', async () => { + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, name: '', vmid: 1 }] }) + expect(res.status).toBe(400) + expect(res.body.errors[0].index).toBe(0) + }) + + it('returns 400 if body has no instances key', async () => { + expect((await request(app).post('/api/import').send({})).status).toBe(400) + }) +}) + // ── Static assets & SPA routing ─────────────────────────────────────────────── describe('static assets and SPA routing', () => { diff --git a/tests/db.test.js b/tests/db.test.js index 746f2b5..7237615 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { _resetForTest, getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -166,6 +166,25 @@ describe('deleteInstance', () => { }); }); +// ── importInstances ─────────────────────────────────────────────────────────── + +describe('importInstances', () => { + const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }; + + it('replaces all existing instances with the imported set', () => { + createInstance({ ...base, name: 'old', vmid: 1 }); + importInstances([{ ...base, name: 'new', vmid: 2 }]); + expect(getInstance(1)).toBeNull(); + expect(getInstance(2)).not.toBeNull(); + }); + + it('clears all instances when passed an empty array', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + importInstances([]); + expect(getInstances()).toEqual([]); + }); +}); + // ── Test environment boot isolation ─────────────────────────────────────────── describe('test environment boot isolation', () => { From 0985d9d481e49348d0ccdc48dafb590f2476799b Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:15:25 -0400 Subject: [PATCH 02/18] fix: remove top gap above first settings section padding-top on the first .settings-section created a visible gap above the Export title. Fixed with :first-child { padding-top: 0 }. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/css/app.css b/css/app.css index 5595d55..06b8bb6 100644 --- a/css/app.css +++ b/css/app.css @@ -630,6 +630,7 @@ select:focus { border-color: var(--accent); } /* ── SETTINGS MODAL ── */ .settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } +.settings-section:first-child { padding-top: 0; } .settings-section:last-child { border-bottom: none; padding-bottom: 0; } .settings-section-title { font-size: 10px; From 6e1e9f7153bdd1132ea6d66e36dcd0a4578060e4 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:19:39 -0400 Subject: [PATCH 03/18] fix: remove top padding from settings modal body The modal-body's 22px padding-top created a visible gap between the header divider and the Export section title. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 1 + 1 file changed, 1 insertion(+) diff --git a/css/app.css b/css/app.css index 06b8bb6..8fcc37b 100644 --- a/css/app.css +++ b/css/app.css @@ -629,6 +629,7 @@ select:focus { border-color: var(--accent); } .confirm-actions { display: flex; justify-content: flex-end; gap: 10px; } /* ── SETTINGS MODAL ── */ +#settings-modal .modal-body { padding-top: 0; } .settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } .settings-section:first-child { padding-top: 0; } .settings-section:last-child { border-bottom: none; padding-bottom: 0; } From 71c2c68fbcfbb083832c996d473e00092c549f6d Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:23:14 -0400 Subject: [PATCH 04/18] fix: uniform 16px spacing above all settings sections Removing the :first-child { padding-top: 0 } override lets every section use the same padding: 16px 0, so the gap above Export matches the gap above Import (and any future sections). Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 1 - 1 file changed, 1 deletion(-) diff --git a/css/app.css b/css/app.css index 8fcc37b..f282f14 100644 --- a/css/app.css +++ b/css/app.css @@ -631,7 +631,6 @@ select:focus { border-color: var(--accent); } /* ── SETTINGS MODAL ── */ #settings-modal .modal-body { padding-top: 0; } .settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } -.settings-section:first-child { padding-top: 0; } .settings-section:last-child { border-bottom: none; padding-bottom: 0; } .settings-section-title { font-size: 10px; From 6e124576cbbc39dc061a34f3cfa6d6c77ce3a053 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:27:43 -0400 Subject: [PATCH 05/18] fix: remove stacks count from stats bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks are always just production/development — counting them adds no useful information to the dashboard summary. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/ui.js b/js/ui.js index ab5b332..1427690 100644 --- a/js/ui.js +++ b/js/ui.js @@ -39,7 +39,6 @@ async function renderDashboard() {
deployed
${states['deployed'] || 0}
testing
${states['testing'] || 0}
degraded
${states['degraded'] || 0}
-
stacks
${(await getDistinctStacks()).length}
`; await populateStackFilter(); From cb01573cdf00920fbd6aa3567ff631cb9adc7aef Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:35:35 -0400 Subject: [PATCH 06/18] feat: audit log / history timeline on instance detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an instance_history table that records every field change: - createInstance logs a 'created' event - updateInstance diffs old vs new and logs one row per changed field (name, state, stack, vmid, tailscale_ip, all service flags) - History is stored under the new vmid when vmid changes New endpoint: GET /api/instances/:vmid/history The 'timestamps' section on the detail page is replaced with a grid timeline showing timestamp | field | old → new for each event. State changes are colour-coded (deployed=green, testing=amber, degraded=red). Boolean service flags display as on/off. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 24 ++++++++++++++++++++++++ index.html | 2 +- js/db.js | 5 +++++ js/ui.js | 39 ++++++++++++++++++++++++++++++++++----- server/db.js | 39 +++++++++++++++++++++++++++++++++++++-- server/routes.js | 10 +++++++++- tests/api.test.js | 20 ++++++++++++++++++++ tests/db.test.js | 39 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 168 insertions(+), 10 deletions(-) diff --git a/css/app.css b/css/app.css index f282f14..eac9a12 100644 --- a/css/app.css +++ b/css/app.css @@ -628,6 +628,30 @@ select:focus { border-color: var(--accent); } .confirm-actions { display: flex; justify-content: flex-end; gap: 10px; } +/* ── HISTORY TIMELINE ── */ +.tl-item { + display: grid; + grid-template-columns: 160px 140px 1fr; + gap: 0 12px; + padding: 7px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + align-items: baseline; +} +.tl-item:last-child { border-bottom: none; } +.tl-time { color: var(--text3); font-size: 11px; white-space: nowrap; } +.tl-field { color: var(--text2); } +.tl-change { display: flex; align-items: baseline; gap: 6px; } +.tl-old { color: var(--text3); text-decoration: line-through; } +.tl-arrow { color: var(--text3); } +.tl-new { color: var(--text); } +.tl-deployed { color: var(--accent); } +.tl-testing { color: var(--amber); } +.tl-degraded { color: var(--red); } +.tl-created .tl-field { color: var(--accent); } +.tl-created .tl-change { color: var(--text3); } +.tl-empty { color: var(--text3); font-size: 12px; padding: 8px 0; } + /* ── SETTINGS MODAL ── */ #settings-modal .modal-body { padding-top: 0; } .settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } diff --git a/index.html b/index.html index 2fdbb6a..f15f9db 100644 --- a/index.html +++ b/index.html @@ -92,7 +92,7 @@
-
timestamps
+
history
diff --git a/js/db.js b/js/db.js index e3c69b3..ea0c527 100644 --- a/js/db.js +++ b/js/db.js @@ -55,3 +55,8 @@ async function updateInstance(vmid, data) { async function deleteInstance(vmid) { await api(`/instances/${vmid}`, { method: 'DELETE' }); } + +async function getInstanceHistory(vmid) { + const res = await fetch(`${BASE}/instances/${vmid}/history`); + return res.json(); +} diff --git a/js/ui.js b/js/ui.js index 1427690..af30325 100644 --- a/js/ui.js +++ b/js/ui.js @@ -99,8 +99,21 @@ async function filterInstances() { // ── Detail Page ─────────────────────────────────────────────────────────────── +const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration']; + +function stateClass(field, val) { + if (field !== 'state') return ''; + return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? ''; +} + +function fmtHistVal(field, val) { + if (val == null || val === '') return '—'; + if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off'; + return esc(val); +} + async function renderDetailPage(vmid) { - const inst = await getInstance(vmid); + const [inst, history] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid)]); if (!inst) { navigate('dashboard'); return; } currentVmid = vmid; @@ -133,10 +146,26 @@ async function renderDetailPage(vmid) { `).join(''); - document.getElementById('detail-timestamps').innerHTML = ` -
created${fmtDateFull(inst.created_at)}
-
updated${fmtDateFull(inst.updated_at)}
- `; + document.getElementById('detail-timestamps').innerHTML = history.length + ? history.map(e => { + if (e.field === 'created') return ` +
+ ${fmtDateFull(e.changed_at)} + created + +
`; + return ` +
+ ${fmtDateFull(e.changed_at)} + ${esc(e.field)} + + ${fmtHistVal(e.field, e.old_value)} + + ${fmtHistVal(e.field, e.new_value)} + +
`; + }).join('') + : '
no history yet
'; document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid); document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst); diff --git a/server/db.js b/server/db.js index 1a2e373..bf0e99e 100644 --- a/server/db.js +++ b/server/db.js @@ -43,6 +43,16 @@ function createSchema() { ); CREATE INDEX IF NOT EXISTS idx_instances_state ON instances(state); CREATE INDEX IF NOT EXISTS idx_instances_stack ON instances(stack); + + CREATE TABLE IF NOT EXISTS instance_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vmid INTEGER NOT NULL, + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_history_vmid ON instance_history(vmid); `); } @@ -99,8 +109,14 @@ export function getDistinctStacks() { // ── Mutations ───────────────────────────────────────────────────────────────── +const HISTORY_FIELDS = [ + 'name', 'state', 'stack', 'vmid', 'tailscale_ip', + 'atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda', + 'hardware_acceleration', +]; + export function createInstance(data) { - return db.prepare(` + db.prepare(` INSERT INTO instances (name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration) @@ -108,10 +124,14 @@ export function createInstance(data) { (@name, @state, @stack, @vmid, @atlas, @argus, @semaphore, @patchmon, @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) `).run(data); + db.prepare( + `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, 'created', NULL, NULL)` + ).run(data.vmid); } export function updateInstance(vmid, data) { - return db.prepare(` + const old = getInstance(vmid); + db.prepare(` UPDATE instances SET name=@name, state=@state, stack=@stack, vmid=@newVmid, atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon, @@ -119,6 +139,15 @@ export function updateInstance(vmid, data) { hardware_acceleration=@hardware_acceleration, updated_at=datetime('now') WHERE vmid=@vmid `).run({ ...data, newVmid: data.vmid, vmid }); + const newVmid = data.vmid; + const insertEvt = db.prepare( + `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, ?, ?, ?)` + ); + for (const field of HISTORY_FIELDS) { + const oldVal = String(old[field] ?? ''); + const newVal = String(field === 'vmid' ? newVmid : (data[field] ?? '')); + if (oldVal !== newVal) insertEvt.run(newVmid, field, oldVal, newVal); + } } export function deleteInstance(vmid) { @@ -140,6 +169,12 @@ export function importInstances(rows) { db.exec('COMMIT'); } +export function getInstanceHistory(vmid) { + return db.prepare( + 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at DESC' + ).all(vmid); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/routes.js b/server/routes.js index 4ad9048..a2132d4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, importInstances, + createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, } from './db.js'; export const router = Router(); @@ -54,6 +54,14 @@ router.get('/instances', (req, res) => { res.json(getInstances({ search, state, stack })); }); +// GET /api/instances/:vmid/history +router.get('/instances/:vmid/history', (req, res) => { + const vmid = parseInt(req.params.vmid, 10); + if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); + if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' }); + res.json(getInstanceHistory(vmid)); +}); + // GET /api/instances/:vmid router.get('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); diff --git a/tests/api.test.js b/tests/api.test.js index 8742ae1..1209ec9 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -239,6 +239,26 @@ describe('DELETE /api/instances/:vmid', () => { }) }) +// ── GET /api/instances/:vmid/history ───────────────────────────────────────── + +describe('GET /api/instances/:vmid/history', () => { + it('returns history events for a known vmid', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/instances/100/history') + expect(res.status).toBe(200) + expect(res.body).toBeInstanceOf(Array) + expect(res.body[0].field).toBe('created') + }) + + it('returns 404 for unknown vmid', async () => { + expect((await request(app).get('/api/instances/999/history')).status).toBe(404) + }) + + it('returns 400 for non-numeric vmid', async () => { + expect((await request(app).get('/api/instances/abc/history')).status).toBe(400) + }) +}) + // ── GET /api/export ─────────────────────────────────────────────────────────── describe('GET /api/export', () => { diff --git a/tests/db.test.js b/tests/db.test.js index 7237615..de5cef4 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { _resetForTest, getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, importInstances, + createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -185,6 +185,43 @@ describe('importInstances', () => { }); }); +// ── instance history ───────────────────────────────────────────────────────── + +describe('instance history', () => { + const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }; + + it('logs a created event when an instance is created', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + const h = getInstanceHistory(1); + expect(h).toHaveLength(1); + expect(h[0].field).toBe('created'); + }); + + it('logs changed fields when an instance is updated', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 1, state: 'degraded' }); + const h = getInstanceHistory(1); + const stateEvt = h.find(e => e.field === 'state'); + expect(stateEvt).toBeDefined(); + expect(stateEvt.old_value).toBe('deployed'); + expect(stateEvt.new_value).toBe('degraded'); + }); + + it('logs no events when nothing changes on update', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 1 }); + const h = getInstanceHistory(1).filter(e => e.field !== 'created'); + expect(h).toHaveLength(0); + }); + + it('records history under the new vmid when vmid changes', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 2 }); + expect(getInstanceHistory(2).some(e => e.field === 'vmid')).toBe(true); + expect(getInstanceHistory(1).filter(e => e.field !== 'created')).toHaveLength(0); + }); +}); + // ── Test environment boot isolation ─────────────────────────────────────────── describe('test environment boot isolation', () => { From 0ecfa7dbc994ef2226d0a72c28176ef77013eede Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:46:48 -0400 Subject: [PATCH 07/18] =?UTF-8?q?chore:=20maintenance=20=E2=80=94=20test?= =?UTF-8?q?=20coverage,=20route=20cleanup,=20README=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fmtHistVal and stateClass helper tests (7 new, 106 total) - Add import regression test: missing name field returns 400 not 500 - Fix normalise() crash on missing name: body.name.trim() → (body.name ?? '').trim() - Extract duplicate DB error handler into handleDbError() helper - Rewrite README from scratch with audit log, export/import, full API docs Co-Authored-By: Claude Sonnet 4.6 --- README.md | 187 ++++++++++++++++++++++++++---------------- server/routes.js | 22 ++--- tests/api.test.js | 6 ++ tests/helpers.test.js | 58 +++++++++++++ 4 files changed, 191 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index d012e11..3f1ddd4 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,38 @@ # Catalyst -A self-hosted infrastructure registry. Track every VM, container, and service across your homelab — their state, stack, and which internal services are running on them. +A self-hosted infrastructure registry for homelab Proxmox environments. Track virtual machines across stacks, monitor service health, and maintain a full audit log of every configuration change. --- ## Features - **Dashboard** — filterable, searchable instance list with state and stack badges -- **Detail pages** — per-instance view with service flags, Tailscale IP, and timestamps +- **Detail pages** — per-instance view with service flags, Tailscale IP, and a full change timeline +- **Audit log** — every field change is recorded with before/after values and a timestamp - **Full CRUD** — add, edit, and delete instances via a clean modal interface - **Production safeguard** — only development instances can be deleted; production instances must be demoted first -- **REST API** — every operation is a plain HTTP call; no magic, no framework lock-in -- **Persistent storage** — SQLite database on a Docker named volume; survives restarts and upgrades -- **Zero native dependencies** — SQLite via Node's built-in `node:sqlite`. No compilation, no binaries. +- **Export / import** — JSON backup and restore via the settings modal +- **REST API** — every operation is a plain HTTP call +- **Persistent storage** — SQLite on a Docker named volume; survives restarts and upgrades +- **Zero native dependencies** — SQLite via Node's built-in `node:sqlite`; no compilation, no binaries --- ## Quick start -```bash -docker run -d \ - --name catalyst \ - -p 3000:3000 \ - -v catalyst-data:/app/data \ - gitea.thewrightserver.net/josh/catalyst:latest -``` - -Or with the included Compose file: - ```bash docker compose up -d ``` Open [http://localhost:3000](http://localhost:3000). +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `PORT` | `3000` | HTTP port the server binds to | +| `DB_PATH` | `data/catalyst.db` | Path to the SQLite database file | + --- ## REST API @@ -44,13 +43,13 @@ All endpoints are under `/api`. Request and response bodies are JSON. #### `GET /api/instances` -Returns all instances, sorted by name. All query parameters are optional. +Returns all instances sorted by name. All query parameters are optional. -| Parameter | Type | Description | -|-----------|--------|-----------------------------------------| -| `search` | string | Partial match on `name` or `vmid` | -| `state` | string | Exact match: `deployed`, `testing`, `degraded` | -| `stack` | string | Exact match: `production`, `development` | +| Parameter | Type | Description | +|---|---|---| +| `search` | string | Partial match on `name`, `vmid`, or `stack` | +| `state` | string | Exact match: `deployed`, `testing`, `degraded` | +| `stack` | string | Exact match: `production`, `development` | ``` GET /api/instances?search=plex&state=deployed @@ -64,11 +63,11 @@ GET /api/instances?search=plex&state=deployed "state": "deployed", "stack": "production", "tailscale_ip": "100.64.0.1", - "atlas": 1, "argus": 0, "semaphore": 0, + "atlas": 1, "argus": 1, "semaphore": 0, "patchmon": 1, "tailscale": 1, "andromeda": 0, "hardware_acceleration": 1, - "created_at": "2024-01-15T10:30:00.000Z", - "updated_at": "2024-03-10T14:22:00.000Z" + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-03-10T14:22:00" } ] ``` @@ -91,10 +90,43 @@ GET /api/instances/stacks Returns a single instance by VMID. | Status | Condition | -|--------|-----------| -| `200` | Instance found | -| `404` | No instance with that VMID | -| `400` | VMID is not a valid integer | +|---|---| +| `200` | Instance found | +| `400` | VMID is not a valid integer | +| `404` | No instance with that VMID | + +--- + +#### `GET /api/instances/:vmid/history` + +Returns the audit log for an instance — newest events first. + +| Status | Condition | +|---|---| +| `200` | History returned (may be empty array) | +| `400` | VMID is not a valid integer | +| `404` | No instance with that VMID | + +```json +[ + { + "id": 3, + "vmid": 117, + "field": "state", + "old_value": "testing", + "new_value": "deployed", + "changed_at": "2024-03-10T14:22:00" + }, + { + "id": 1, + "vmid": 117, + "field": "created", + "old_value": null, + "new_value": null, + "changed_at": "2024-01-15T10:30:00" + } +] +``` --- @@ -103,21 +135,21 @@ Returns a single instance by VMID. Creates a new instance. Returns the created record. | Status | Condition | -|--------|-----------| -| `201` | Created successfully | -| `400` | Validation error (see `errors` array in response) | -| `409` | VMID already exists | +|---|---| +| `201` | Created successfully | +| `400` | Validation error — see `errors` array in response | +| `409` | VMID already exists | **Request body:** | Field | Type | Required | Notes | -|-------|------|----------|-------| +|---|---|---|---| | `name` | string | yes | | -| `vmid` | integer | yes | Must be > 0, unique | +| `vmid` | integer | yes | Must be > 0 and unique | | `state` | string | yes | `deployed`, `testing`, or `degraded` | | `stack` | string | yes | `production` or `development` | -| `tailscale_ip` | string | no | Defaults to `""` | -| `atlas` | 0\|1 | no | Defaults to `0` | +| `tailscale_ip` | string | no | Valid IPv4 or empty string | +| `atlas` | 0\|1 | no | | | `argus` | 0\|1 | no | | | `semaphore` | 0\|1 | no | | | `patchmon` | 0\|1 | no | | @@ -132,11 +164,11 @@ Creates a new instance. Returns the created record. Replaces all fields on an existing instance. Accepts the same body shape as `POST`. The `vmid` in the body may differ from the URL — this is how you change a VMID. | Status | Condition | -|--------|-----------| -| `200` | Updated successfully | -| `400` | Validation error | -| `404` | No instance with that VMID | -| `409` | New VMID conflicts with an existing instance | +|---|---| +| `200` | Updated successfully | +| `400` | Validation error | +| `404` | No instance with that VMID | +| `409` | New VMID conflicts with an existing instance | --- @@ -145,11 +177,36 @@ Replaces all fields on an existing instance. Accepts the same body shape as `POS Deletes an instance. Only instances on the `development` stack may be deleted. | Status | Condition | -|--------|-----------| -| `204` | Deleted successfully | -| `404` | No instance with that VMID | -| `422` | Instance is on the `production` stack | -| `400` | VMID is not a valid integer | +|---|---| +| `204` | Deleted successfully | +| `400` | VMID is not a valid integer | +| `404` | No instance with that VMID | +| `422` | Instance is on the `production` stack | + +--- + +### Backup + +#### `GET /api/export` + +Downloads a JSON backup of all instances as a file attachment. + +```json +{ + "version": 1, + "exported_at": "2024-03-10T14:22:00.000Z", + "instances": [ ... ] +} +``` + +#### `POST /api/import` + +Replaces all instances from a JSON backup. Validates every row before committing — if any row is invalid the entire import is rejected. + +| Status | Condition | +|---|---| +| `200` | Import successful — returns `{ "imported": N }` | +| `400` | Body missing `instances` array, or validation errors | --- @@ -157,42 +214,30 @@ Deletes an instance. Only instances on the `development` stack may be deleted. ```bash npm install -npm test # run all tests once +npm test # run all tests once npm run test:watch # watch mode -npm start # start the server on :3000 +npm start # start the server on :3000 ``` Tests are split across three files: | File | What it covers | -|------|----------------| -| `tests/db.test.js` | SQLite data layer — all CRUD operations, constraints, filters | +|---|---| +| `tests/db.test.js` | SQLite data layer — CRUD, constraints, filters, history logging | | `tests/api.test.js` | HTTP API — all endpoints, status codes, error cases | -| `tests/helpers.test.js` | UI helper functions — `esc()` XSS contract, `fmtDate()` | +| `tests/helpers.test.js` | UI helpers — `esc()` XSS contract, date formatting, history formatters | --- ## Versioning -Catalyst uses [semantic versioning](https://semver.org). `package.json` is the single source of truth for the version number. +Catalyst uses [semantic versioning](https://semver.org). `package.json` is the single source of truth. -| Change | Bump | Example | -|--------|------|---------| -| Bug fix | patch | `1.0.0` → `1.0.1` | -| New feature, backward compatible | minor | `1.0.0` → `1.1.0` | -| Breaking change | major | `1.0.0` → `2.0.0` | +| Change | Bump | +|---|---| +| Bug fix | patch | +| New feature, backward compatible | minor | +| Breaking change | major | -### Cutting a release - -```bash -# 1. Bump version in package.json, then: -git add package.json -git commit -m "chore: release v1.1.0" -git tag v1.1.0 -git push && git push --tags -``` - -Pushing a tag triggers the full CI pipeline: **test → build → release**. - -- Docker image tagged `:1.1.0`, `:1.1`, and `:latest` in the Gitea registry -- A Gitea release is created at `v1.1.0` +Pushing a tag triggers the CI pipeline: **test → build → release**. +Docker images are tagged `:x.y.z`, `:x.y`, and `:latest`. diff --git a/server/routes.js b/server/routes.js index a2132d4..c48ef93 100644 --- a/server/routes.js +++ b/server/routes.js @@ -28,9 +28,16 @@ function validate(body) { return errors; } +function handleDbError(context, e, res) { + if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); + if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); + console.error(context, e); + res.status(500).json({ error: 'internal server error' }); +} + function normalise(body) { const row = { - name: body.name.trim(), + name: (body.name ?? '').trim(), state: body.state, stack: body.stack, vmid: body.vmid, @@ -84,10 +91,7 @@ router.post('/instances', (req, res) => { const created = getInstance(data.vmid); res.status(201).json(created); } catch (e) { - if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); - if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); - console.error('POST /api/instances', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('POST /api/instances', e, res); } }); @@ -105,10 +109,7 @@ router.put('/instances/:vmid', (req, res) => { updateInstance(vmid, data); res.json(getInstance(data.vmid)); } catch (e) { - if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); - if (e.message.includes('CHECK')) return res.status(400).json({ error: 'invalid field value' }); - console.error('PUT /api/instances/:vmid', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('PUT /api/instances/:vmid', e, res); } }); @@ -155,7 +156,6 @@ router.delete('/instances/:vmid', (req, res) => { deleteInstance(vmid); res.status(204).end(); } catch (e) { - console.error('DELETE /api/instances/:vmid', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('DELETE /api/instances/:vmid', e, res); } }); diff --git a/tests/api.test.js b/tests/api.test.js index 1209ec9..66852ce 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -303,6 +303,12 @@ describe('POST /api/import', () => { it('returns 400 if body has no instances key', async () => { expect((await request(app).post('/api/import').send({})).status).toBe(400) }) + + it('returns 400 (not 500) when a row is missing name', async () => { + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, name: undefined, vmid: 1 }] }) + expect(res.status).toBe(400) + }) }) // ── Static assets & SPA routing ─────────────────────────────────────────────── diff --git a/tests/helpers.test.js b/tests/helpers.test.js index 26bdd4d..5aff55a 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -133,6 +133,64 @@ describe('version label formatting', () => { }) }) +// ── fmtHistVal() ───────────────────────────────────────────────────────────── +// Mirrors the logic in ui.js — formats history field values for display. + +const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration'] + +function fmtHistVal(field, val) { + if (val == null || val === '') return '—' + if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off' + return val +} + +describe('fmtHistVal', () => { + it('returns — for null', () => { + expect(fmtHistVal('state', null)).toBe('—') + }) + + it('returns — for empty string', () => { + expect(fmtHistVal('state', '')).toBe('—') + }) + + it('returns on/off for boolean service fields', () => { + expect(fmtHistVal('atlas', '1')).toBe('on') + expect(fmtHistVal('atlas', '0')).toBe('off') + expect(fmtHistVal('hardware_acceleration', '1')).toBe('on') + }) + + it('returns the value as-is for non-boolean fields', () => { + expect(fmtHistVal('state', 'deployed')).toBe('deployed') + expect(fmtHistVal('name', 'plex')).toBe('plex') + expect(fmtHistVal('tailscale_ip', '100.64.0.1')).toBe('100.64.0.1') + }) +}) + +// ── stateClass() ───────────────────────────────────────────────────────────── +// Mirrors the logic in ui.js — maps state values to timeline CSS classes. + +function stateClass(field, val) { + if (field !== 'state') return '' + return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? '' +} + +describe('stateClass', () => { + it('returns empty string for non-state fields', () => { + expect(stateClass('name', 'plex')).toBe('') + expect(stateClass('stack', 'production')).toBe('') + }) + + it('returns the correct colour class for each state value', () => { + expect(stateClass('state', 'deployed')).toBe('tl-deployed') + expect(stateClass('state', 'testing')).toBe('tl-testing') + expect(stateClass('state', 'degraded')).toBe('tl-degraded') + }) + + it('returns empty string for unknown state values', () => { + expect(stateClass('state', 'unknown')).toBe('') + }) +}) + // ── CSS regressions ─────────────────────────────────────────────────────────── const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8') From badd542bd7082b5509f9d99a7c0d1f9d4e39260e Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:53:20 -0400 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20timezone=20setting=20=E2=80=94=20?= =?UTF-8?q?display=20timestamps=20in=20selected=20local=20timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Display section to the settings modal with a timezone dropdown. Selection is persisted to localStorage and applied to all timestamps via fmtDate (date-only) and fmtDateFull (date + time + TZ abbreviation, e.g. "Mar 28, 2026, 2:48 PM EDT"). Changing the timezone live-re-renders the current page. Defaults to UTC. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 3 +++ index.html | 7 +++++++ js/ui.js | 49 +++++++++++++++++++++++++++++++++++++++++-- tests/helpers.test.js | 16 ++++++++++++-- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/css/app.css b/css/app.css index eac9a12..c0f1a23 100644 --- a/css/app.css +++ b/css/app.css @@ -665,6 +665,9 @@ select:focus { border-color: var(--accent); } margin-bottom: 8px; } .settings-desc { font-size: 12px; color: var(--text2); margin: 0 0 14px; line-height: 1.6; } +.settings-row { display: flex; align-items: center; gap: 12px; } +.settings-label { font-size: 13px; color: var(--text2); white-space: nowrap; min-width: 80px; } +.settings-select { flex: 1; } .import-row { display: flex; gap: 10px; align-items: center; } .import-file-input { flex: 1; } diff --git a/index.html b/index.html index f15f9db..246f199 100644 --- a/index.html +++ b/index.html @@ -180,6 +180,13 @@