From cb01573cdf00920fbd6aa3567ff631cb9adc7aef Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 14:35:35 -0400 Subject: [PATCH] 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', () => { -- 2.39.5