45 Commits

Author SHA1 Message Date
07cef73fae Merge pull request 'v1.4.0' (#44) from dev into main
All checks were successful
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
1a84edc064 Merge pull request 'chore: bump version to 1.4.0' (#43) from chore/bump-v1.4.0 into dev
All checks were successful
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
bfb2c26821 chore: bump version to 1.4.0
All checks were successful
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
a985268987 Merge pull request 'feat: include history in export/import backup' (#42) from feat/export-import-history into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 21s
Reviewed-on: #42
2026-03-28 16:06:21 -04:00
218cdb08c5 feat: include history in export/import backup
All checks were successful
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
2855cc7f81 Merge pull request 'feat: mobile-responsive layout under 640px' (#41) from feat/mobile-responsive into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 23s
Reviewed-on: #41
2026-03-28 15:57:07 -04:00
07d2e215e4 Merge branch 'dev' into feat/mobile-responsive
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 15:56:38 -04:00
8ef839d6d0 Merge pull request 'fix: wrap image reference in backticks in release notes' (#40) from fix/release-image-codeblock into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 21s
Reviewed-on: #40
2026-03-28 15:55:05 -04:00
7af88328c8 feat: mobile-responsive layout under 640px
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Single breakpoint, no desktop changes. Key adjustments:
- Reset zoom: 1 (mobile browsers handle scaling)
- Padding drops from 32px to 16px throughout
- Toolbar wraps: search full-width, filters below
- Instance grid and detail grid collapse to single column
- Detail header stacks title above action buttons
- History timeline stacks timestamp above event
- Toggle grid drops from 3 to 2 columns
- Confirm box gets max-width: calc(100vw - 32px) to prevent overflow
- Toast stretches across bottom of screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:54:12 -04:00
096e2afb3d fix: wrap image reference in backticks in release notes
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:44:15 -04:00
e3d089a71f Merge pull request 'v1.3.1' (#39) from dev into main
All checks were successful
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
668e7c34bb Merge pull request 'chore: release v1.3.1' (#38) from chore/bump-v1.3.1 into dev
All checks were successful
CI / test (push) Successful in 15s
CI / build-dev (push) Successful in 25s
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #38
2026-03-28 15:40:46 -04:00
e796b4f400 chore: release v1.3.1
All checks were successful
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 15:40:16 -04:00
a4b5c20993 Merge pull request 'fix: clear instance history on delete and import' (#37) from fix/delete-clears-history into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 20s
Reviewed-on: #37
2026-03-28 15:38:15 -04:00
d17f364fc5 fix: clear instance history on delete and import
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
deleteInstance now removes history rows for that vmid before removing
the instance. importInstances clears all history before replacing
instances. Prevents stale history appearing when a vmid is reused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:37:45 -04:00
5f79eec3dd Merge pull request 'fix: categorize release notes into New Features / Bug Fixes, drop chores' (#36) from fix/release-notes-format into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 20s
Reviewed-on: #36
2026-03-28 15:36:27 -04:00
ed98bb57c0 fix: categorize release notes into New Features / Bug Fixes, drop chores
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:35:53 -04:00
120b61a423 Merge pull request 'v1.3.0' (#35) from dev into main
All checks were successful
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
074f0600af Merge pull request 'chore: release v1.3.0' (#34) from chore/bump-v1.3.0 into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 21s
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #34
2026-03-28 15:30:02 -04:00
e4f9407827 Merge branch 'dev' into chore/bump-v1.3.0
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 15:29:09 -04:00
fde5ce7dc1 Merge pull request 'chore: release v1.3.0' (#33) from chore/bump-v1.3.0 into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 28s
Reviewed-on: #33
2026-03-28 15:28:44 -04:00
20df10b333 chore: release v1.3.0
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:28:39 -04:00
c906511bfc chore: release v1.3.0
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:27:30 -04:00
745e5920ad Merge pull request 'fix: set html zoom 1.1 so default scale matches browser 110%' (#32) from fix/base-zoom into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 20s
Reviewed-on: #32
2026-03-28 15:25:32 -04:00
90e0a98914 fix: set html zoom 1.1 so default scale matches browser 110%
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:24:58 -04:00
cba4b73798 Merge pull request 'fix: use badge for stack on detail overview, consistent across all views' (#31) from fix/detail-stack-badge into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 21s
Reviewed-on: #31
2026-03-28 15:22:13 -04:00
0d567472a9 fix: use badge for stack on detail overview, consistent across all views
All checks were successful
CI / test (pull_request) Successful in 12s
CI / build-dev (pull_request) Has been skipped
stack was plain highlighted text on the detail page but a coloured badge
on the home cards and in the history timeline. Now all three use the same
badge component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:21:25 -04:00
9f6b2ece52 Merge pull request 'fix: parse SQLite timestamps as UTC, not local time' (#30) from fix/sqlite-utc-timestamps into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 20s
Reviewed-on: #30
2026-03-28 15:20:15 -04:00
e3911157e9 fix: parse SQLite timestamps as UTC, not local time
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
SQLite datetime('now') returns 'YYYY-MM-DD HH:MM:SS' with no timezone
marker. JS was treating this as local time, so timestamps showed the
correct UTC digits but with the local TZ abbreviation attached (e.g.
'7:15 PM EDT' when the real local time was '3:15 PM EDT').

Add parseUtc() which appends 'Z' before parsing any string that has no
existing timezone marker, ensuring JS always treats them as UTC before
the display-timezone conversion is applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:19:35 -04:00
0589288dfe Merge pull request 'fix: populate nav instance count on direct detail page load/refresh' (#29) from fix/detail-nav-count into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 21s
Reviewed-on: #29
2026-03-28 15:16:32 -04:00
8ead7687e5 fix: populate nav instance count on direct detail page load/refresh
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
nav-count was only set in renderDashboard, so loading /instance/:vmid
directly left it showing "—". Add getInstances() to the parallel fetch
in renderDetailPage and set the count there too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:15:55 -04:00
0e1e9b6699 Merge pull request 'fix: show stack badge in history timeline, matching state treatment' (#28) from fix/history-stack-badge into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 22s
Reviewed-on: #28
2026-03-28 15:14:02 -04:00
3c008c5bce fix: show stack badge in history timeline, matching state treatment
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:13:27 -04:00
1582c28b28 Merge pull request 'fix: clean up instance detail subtitle — dividers, readable values' (#27) from feat/timezone-settings into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 21s
Reviewed-on: #27
2026-03-28 15:10:54 -04:00
bcd934f5b1 Merge branch 'dev' into feat/timezone-settings
All checks were successful
CI / test (pull_request) Successful in 12s
CI / build-dev (pull_request) Has been skipped
2026-03-28 15:10:26 -04:00
4c9acd20c7 fix: clean up instance detail subtitle — dividers, readable values
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Separate vmid / id / created with a subtle vertical border so they
don't run together. Bump font to 13px. Labels drop to 11px muted,
values use full --text colour so the actual data stands out clearly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:10:05 -04:00
520fb98d96 Merge pull request 'feat: redesign history timeline — single-line, timestamp right-aligned' (#26) from feat/timezone-settings into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 19s
Reviewed-on: #26
2026-03-28 15:07:01 -04:00
800184d2be Merge branch 'dev' into feat/timezone-settings
All checks were successful
CI / test (pull_request) Successful in 12s
CI / build-dev (pull_request) Has been skipped
2026-03-28 15:06:34 -04:00
82c314f85c feat: redesign history timeline — single-line, timestamp right-aligned
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Each event is now one row: label · old → new on the left, timestamp
right-aligned. Nothing is far from anything else. State changes use the
existing badge component for immediate visual recognition. The created
event reads 'instance created' in accent. Middle-dot separator keeps
field label and change value clearly associated without forced spacing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:06:09 -04:00
2fba532ec7 Merge pull request 'feat: rework history timeline for clarity' (#25) from feat/timezone-settings into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 20s
Reviewed-on: #25
2026-03-28 15:01:39 -04:00
9177578aaf Merge branch 'dev' into feat/timezone-settings
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 15:01:12 -04:00
94c4a0af51 feat: rework history timeline for clarity
All checks were successful
CI / test (pull_request) Successful in 12s
CI / build-dev (pull_request) Has been skipped
Timestamp now sits on its own line above each event so it's visually
separate from the change description. Field names use a friendly label
map (hardware_acceleration → hw acceleration, tailscale_ip → tailscale ip,
etc.). The created event reads "instance created" in accent colour instead
of a raw "created / —". Padding between rows increased.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:00:22 -04:00
cd16b7ea28 Merge pull request 'v1.2.2' (#16) from dev into main
All checks were successful
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
afbdefa549 Merge pull request 'v1.2.1' (#13) from dev into main
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Has been skipped
Reviewed-on: #13
2026-03-28 13:55:34 -04:00
f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
Some checks failed
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
11 changed files with 246 additions and 47 deletions

View File

@@ -79,7 +79,29 @@ jobs:
- name: Create Gitea release - name: Create Gitea release
run: | run: |
python3 -c "import json,os; v=os.environ['VERSION']; img=os.environ['IMAGE']; notes=open('/tmp/release_notes.txt').read(); open('/tmp/release_body.json','w').write(json.dumps({'tag_name':'v'+v,'name':'Catalyst v'+v,'body':'### Changes\n\n'+notes+'\n\n### Image\n\n'+img+':'+v,'draft':False,'prerelease':False}))" cat > /tmp/make_release.py << 'PYEOF'
import json, os
v = os.environ['VERSION']
img = os.environ['IMAGE']
raw = open('/tmp/release_notes.txt').read().strip()
feats, fixes = [], []
for line in raw.splitlines():
msg = line.lstrip('- ').strip()
if msg.startswith('feat:'):
feats.append('- ' + msg[5:].strip())
elif msg.startswith('fix:'):
fixes.append('- ' + msg[4:].strip())
sections = []
if feats:
sections.append('### New Features\n\n' + '\n'.join(feats))
if fixes:
sections.append('### Bug Fixes\n\n' + '\n'.join(fixes))
notes = '\n\n'.join(sections) or '_No changes_'
body = notes + '\n\n### Image\n\n`' + img + ':' + v + '`'
payload = {'tag_name': 'v'+v, 'name': 'Catalyst v'+v, 'body': body, 'draft': False, 'prerelease': False}
open('/tmp/release_body.json', 'w').write(json.dumps(payload))
PYEOF
python3 /tmp/make_release.py
curl -sf -X POST \ curl -sf -X POST \
-H "Authorization: token ${{ secrets.TOKEN }}" \ -H "Authorization: token ${{ secrets.TOKEN }}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
node_modules/ node_modules/
js/version.js
data/*.db data/*.db
data/*.db-shm data/*.db-shm
data/*.db-wal data/*.db-wal

View File

@@ -25,6 +25,10 @@
--mono: 'JetBrains Mono', 'IBM Plex Mono', monospace; --mono: 'JetBrains Mono', 'IBM Plex Mono', monospace;
} }
html {
zoom: 1.1;
}
html, body { html, body {
height: 100%; height: 100%;
background: var(--bg); background: var(--bg);
@@ -374,16 +378,25 @@ select:focus { border-color: var(--accent); }
} }
.detail-sub { .detail-sub {
font-size: 12px; font-size: 13px;
color: var(--text3); margin-top: 8px;
margin-top: 6px;
display: flex; display: flex;
gap: 16px; align-items: center;
gap: 0;
} }
.detail-sub span { display: flex; gap: 4px; } .detail-sub > span {
.detail-sub .lbl { color: var(--text3); } display: flex;
.detail-sub .val { color: var(--text2); } align-items: center;
gap: 6px;
}
.detail-sub > span + span {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--border);
}
.detail-sub .lbl { color: var(--text3); font-size: 11px; }
.detail-sub .val { color: var(--text); }
.detail-actions { display: flex; gap: 8px; } .detail-actions { display: flex; gap: 8px; }
@@ -630,26 +643,25 @@ select:focus { border-color: var(--accent); }
/* ── HISTORY TIMELINE ── */ /* ── HISTORY TIMELINE ── */
.tl-item { .tl-item {
display: grid; display: flex;
grid-template-columns: 160px 140px 1fr; align-items: center;
gap: 0 12px; justify-content: space-between;
padding: 7px 0; gap: 24px;
padding: 9px 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-size: 12px;
align-items: baseline;
} }
.tl-item:last-child { border-bottom: none; } .tl-item:last-child { border-bottom: none; }
.tl-time { color: var(--text3); font-size: 11px; white-space: nowrap; } .tl-event { display: flex; align-items: center; gap: 7px; font-size: 13px; min-width: 0; }
.tl-field { color: var(--text2); } .tl-label { color: var(--text2); }
.tl-change { display: flex; align-items: baseline; gap: 6px; } .tl-sep { color: var(--text3); user-select: none; }
.tl-old { color: var(--text3); text-decoration: line-through; } .tl-old { color: var(--text3); text-decoration: line-through; font-size: 12px; }
.tl-arrow { color: var(--text3); } .tl-arrow { color: var(--text3); font-size: 11px; }
.tl-new { color: var(--text); } .tl-new { color: var(--text); font-weight: 500; }
.tl-time { color: var(--text3); font-size: 11px; white-space: nowrap; flex-shrink: 0; }
.tl-deployed { color: var(--accent); } .tl-deployed { color: var(--accent); }
.tl-testing { color: var(--amber); } .tl-testing { color: var(--amber); }
.tl-degraded { color: var(--red); } .tl-degraded { color: var(--red); }
.tl-created .tl-field { color: var(--accent); } .tl-created .tl-event { color: var(--accent); font-weight: 500; }
.tl-created .tl-change { color: var(--text3); }
.tl-empty { color: var(--text3); font-size: 12px; padding: 8px 0; } .tl-empty { color: var(--text3); font-size: 12px; padding: 8px 0; }
/* ── SETTINGS MODAL ── */ /* ── SETTINGS MODAL ── */
@@ -700,3 +712,55 @@ select:focus { border-color: var(--accent); }
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0; } 50% { opacity: 0; }
} }
/* ── MOBILE ── */
@media (max-width: 640px) {
/* Reset desktop zoom — mobile browsers handle scaling themselves */
html { zoom: 1; }
/* Nav */
nav { padding: 0 16px; }
/* Dashboard header */
.dash-header { padding: 18px 16px 14px; }
/* Stats bar */
.stat-cell { padding: 10px 16px; }
/* Toolbar — search full-width on first row, filters + button below */
.toolbar { flex-wrap: wrap; padding: 10px 16px; gap: 8px; }
.search-wrap { max-width: 100%; }
.toolbar-right { margin-left: 0; width: 100%; justify-content: flex-end; }
/* Instance grid — single column */
.instance-grid {
grid-template-columns: 1fr;
padding: 12px 16px;
gap: 8px;
}
/* Detail page */
.detail-page { padding: 16px; }
/* Detail header — stack title block above actions */
.detail-header { flex-direction: column; align-items: flex-start; gap: 14px; }
/* Detail sub — wrap items when they don't fit */
.detail-sub { flex-wrap: wrap; row-gap: 4px; }
/* Detail grid — single column */
.detail-grid { grid-template-columns: 1fr; }
/* Toggle grid — 2 columns instead of 3 */
.toggle-grid { grid-template-columns: 1fr 1fr; }
/* Confirm box — no fixed width on mobile */
.confirm-box { width: auto; max-width: calc(100vw - 32px); padding: 18px; }
/* History timeline — stack timestamp above event */
.tl-item { flex-direction: column; align-items: flex-start; gap: 3px; }
.tl-time { order: -1; }
/* Toast — stretch across bottom */
.toast { right: 16px; left: 16px; bottom: 16px; }
}

View File

@@ -39,17 +39,25 @@ function esc(str) {
return d.innerHTML; return d.innerHTML;
} }
// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS' (UTC, no timezone marker).
// Appending 'Z' tells JS to parse it as UTC rather than local time.
function parseUtc(d) {
if (typeof d !== 'string') return new Date(d);
const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d);
return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z');
}
function fmtDate(d) { function fmtDate(d) {
if (!d) return '—'; if (!d) return '—';
try { try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() }); return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() });
} catch (e) { return d; } } catch (e) { return d; }
} }
function fmtDateFull(d) { function fmtDateFull(d) {
if (!d) return '—'; if (!d) return '—';
try { try {
return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' }); return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' });
} catch (e) { return d; } } catch (e) { return d; }
} }
@@ -129,6 +137,21 @@ async function filterInstances() {
const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration']; const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration'];
const FIELD_LABELS = {
name: 'name',
state: 'state',
stack: 'stack',
vmid: 'vmid',
tailscale_ip: 'tailscale ip',
atlas: 'atlas',
argus: 'argus',
semaphore: 'semaphore',
patchmon: 'patchmon',
tailscale: 'tailscale',
andromeda: 'andromeda',
hardware_acceleration: 'hw acceleration',
};
function stateClass(field, val) { function stateClass(field, val) {
if (field !== 'state') return ''; if (field !== 'state') return '';
return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? ''; return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? '';
@@ -141,9 +164,10 @@ function fmtHistVal(field, val) {
} }
async function renderDetailPage(vmid) { async function renderDetailPage(vmid) {
const [inst, history] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid)]); const [inst, history, all] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid), getInstances()]);
if (!inst) { navigate('dashboard'); return; } if (!inst) { navigate('dashboard'); return; }
currentVmid = vmid; currentVmid = vmid;
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
document.getElementById('detail-vmid-crumb').textContent = vmid; document.getElementById('detail-vmid-crumb').textContent = vmid;
document.getElementById('detail-name').textContent = inst.name; document.getElementById('detail-name').textContent = inst.name;
@@ -154,7 +178,7 @@ async function renderDetailPage(vmid) {
document.getElementById('detail-identity').innerHTML = ` document.getElementById('detail-identity').innerHTML = `
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div> <div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div>
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div> <div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div>
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val highlight">${esc(inst.stack) || '—'}</span></div> <div class="kv-row"><span class="kv-key">stack</span><span class="kv-val"><span class="badge ${esc(inst.stack)}">${esc(inst.stack) || '—'}</span></span></div>
<div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div> <div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div>
<div class="kv-row"><span class="kv-key">internal id</span><span class="kv-val">${inst.id}</span></div> <div class="kv-row"><span class="kv-key">internal id</span><span class="kv-val">${inst.id}</span></div>
`; `;
@@ -178,19 +202,23 @@ async function renderDetailPage(vmid) {
? history.map(e => { ? history.map(e => {
if (e.field === 'created') return ` if (e.field === 'created') return `
<div class="tl-item tl-created"> <div class="tl-item tl-created">
<span class="tl-event">instance created</span>
<span class="tl-time">${fmtDateFull(e.changed_at)}</span> <span class="tl-time">${fmtDateFull(e.changed_at)}</span>
<span class="tl-field">created</span>
<span class="tl-change">—</span>
</div>`; </div>`;
const label = FIELD_LABELS[e.field] ?? esc(e.field);
const newCls = (e.field === 'state' || e.field === 'stack')
? `badge ${esc(e.new_value)}`
: `tl-new ${stateClass(e.field, e.new_value)}`;
return ` return `
<div class="tl-item"> <div class="tl-item">
<span class="tl-time">${fmtDateFull(e.changed_at)}</span> <div class="tl-event">
<span class="tl-field">${esc(e.field)}</span> <span class="tl-label">${label}</span>
<span class="tl-change"> <span class="tl-sep">·</span>
<span class="tl-old">${fmtHistVal(e.field, e.old_value)}</span> <span class="tl-old">${fmtHistVal(e.field, e.old_value)}</span>
<span class="tl-arrow">→</span> <span class="tl-arrow">→</span>
<span class="tl-new ${stateClass(e.field, e.new_value)}">${fmtHistVal(e.field, e.new_value)}</span> <span class="${newCls}">${fmtHistVal(e.field, e.new_value)}</span>
</span> </div>
<span class="tl-time">${fmtDateFull(e.changed_at)}</span>
</div>`; </div>`;
}).join('') }).join('')
: '<div class="tl-empty">no history yet</div>'; : '<div class="tl-empty">no history yet</div>';
@@ -356,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
js/version.js Normal file
View File

@@ -0,0 +1 @@
const VERSION = "1.4.0";

View File

@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.2.2", "version": "1.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",

View File

@@ -151,11 +151,13 @@ export function updateInstance(vmid, data) {
} }
export function deleteInstance(vmid) { export function deleteInstance(vmid) {
return db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid); db.prepare('DELETE FROM instance_history 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 instances'); db.exec('DELETE FROM instances');
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO instances INSERT INTO instances
@@ -166,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');
} }
@@ -175,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() {

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);

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 ───────────────────────────────────────────────

View File

@@ -164,6 +164,19 @@ describe('deleteInstance', () => {
expect(getInstance(1)).toBeNull(); expect(getInstance(1)).toBeNull();
expect(getInstance(2)).not.toBeNull(); expect(getInstance(2)).not.toBeNull();
}); });
it('clears history for the deleted instance', () => {
createInstance({ ...base, name: 'a', vmid: 1 });
deleteInstance(1);
expect(getInstanceHistory(1)).toHaveLength(0);
});
it('does not clear history for other instances', () => {
createInstance({ ...base, name: 'a', vmid: 1 });
createInstance({ ...base, name: 'b', vmid: 2 });
deleteInstance(1);
expect(getInstanceHistory(2).length).toBeGreaterThan(0);
});
}); });
// ── importInstances ─────────────────────────────────────────────────────────── // ── importInstances ───────────────────────────────────────────────────────────
@@ -183,6 +196,21 @@ describe('importInstances', () => {
importInstances([]); importInstances([]);
expect(getInstances()).toEqual([]); expect(getInstances()).toEqual([]);
}); });
it('clears history for all replaced instances', () => {
createInstance({ ...base, name: 'old', vmid: 1 });
importInstances([{ ...base, name: 'new', vmid: 2 }]);
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 ─────────────────────────────────────────────────────────

View File

@@ -58,16 +58,22 @@ describe('esc', () => {
// ── fmtDate() ───────────────────────────────────────────────────────────────── // ── fmtDate() ─────────────────────────────────────────────────────────────────
function fmtDate(d) { function parseUtc(d) {
if (typeof d !== 'string') return new Date(d)
const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d)
return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z')
}
function fmtDate(d, tz = 'UTC') {
if (!d) return '—' if (!d) return '—'
try { try {
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: tz })
} catch (e) { return d } } catch (e) { return d }
} }
describe('fmtDate', () => { describe('fmtDate', () => {
it('formats a valid ISO date string', () => { it('formats a valid ISO date string', () => {
const result = fmtDate('2024-03-15T00:00:00') const result = fmtDate('2024-03-15T12:00:00Z')
expect(result).toMatch(/Mar/) expect(result).toMatch(/Mar/)
expect(result).toMatch(/15/) expect(result).toMatch(/15/)
expect(result).toMatch(/2024/) expect(result).toMatch(/2024/)
@@ -91,7 +97,7 @@ describe('fmtDate', () => {
function fmtDateFull(d, tz = 'UTC') { function fmtDateFull(d, tz = 'UTC') {
if (!d) return '—' if (!d) return '—'
try { try {
return new Date(d).toLocaleString('en-US', { return parseUtc(d).toLocaleString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', hour: '2-digit', minute: '2-digit',
timeZone: tz, timeZoneName: 'short', timeZone: tz, timeZoneName: 'short',
@@ -118,6 +124,12 @@ describe('fmtDateFull', () => {
expect(result).toMatch(/EDT/) expect(result).toMatch(/EDT/)
}) })
it('treats SQLite-format timestamps (space, no Z) as UTC', () => {
// SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS', no timezone marker.
// Must parse identically to the same moment expressed as ISO UTC.
expect(fmtDateFull('2024-03-15 18:30:00', 'UTC')).toBe(fmtDateFull('2024-03-15T18:30:00Z', 'UTC'))
})
it('returns — for null', () => { it('returns — for null', () => {
expect(fmtDateFull(null)).toBe('—') expect(fmtDateFull(null)).toBe('—')
}) })