feat: audit log / history timeline on instance detail page
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 14:35:35 -04:00
parent b48d5fb836
commit cb01573cdf
8 changed files with 168 additions and 10 deletions

View File

@@ -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) {
</div>
`).join('');
document.getElementById('detail-timestamps').innerHTML = `
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.created_at)}</span></div>
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updated_at)}</span></div>
`;
document.getElementById('detail-timestamps').innerHTML = history.length
? history.map(e => {
if (e.field === 'created') return `
<div class="tl-item tl-created">
<span class="tl-time">${fmtDateFull(e.changed_at)}</span>
<span class="tl-field">created</span>
<span class="tl-change">—</span>
</div>`;
return `
<div class="tl-item">
<span class="tl-time">${fmtDateFull(e.changed_at)}</span>
<span class="tl-field">${esc(e.field)}</span>
<span class="tl-change">
<span class="tl-old">${fmtHistVal(e.field, e.old_value)}</span>
<span class="tl-arrow">→</span>
<span class="tl-new ${stateClass(e.field, e.new_value)}">${fmtHistVal(e.field, e.new_value)}</span>
</span>
</div>`;
}).join('')
: '<div class="tl-empty">no history yet</div>';
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst);