47 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
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
ec60d53767 Merge pull request 'feat: timezone setting — display timestamps in selected local timezone' (#24) 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: #24
2026-03-28 14:56:01 -04:00
ad81d7ace7 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 14:55:35 -04:00
7c31ee3327 Merge pull request 'chore: maintenance — test coverage, route cleanup, README rewrite' (#23) from chore/maintenance into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 23s
Reviewed-on: #23
2026-03-28 14:47:27 -04:00
0ecfa7dbc9 chore: maintenance — test coverage, route cleanup, README rewrite
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
- 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 <noreply@anthropic.com>
2026-03-28 14:46:48 -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
12 changed files with 399 additions and 109 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

187
README.md
View File

@@ -1,39 +1,38 @@
# Catalyst # 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 ## Features
- **Dashboard** — filterable, searchable instance list with state and stack badges - **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 - **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 - **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 - **Export / import** — JSON backup and restore via the settings modal
- **Persistent storage** — SQLite database on a Docker named volume; survives restarts and upgrades - **REST API** — every operation is a plain HTTP call
- **Zero native dependencies** — SQLite via Node's built-in `node:sqlite`. No compilation, no binaries. - **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 ## 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 ```bash
docker compose up -d docker compose up -d
``` ```
Open [http://localhost:3000](http://localhost:3000). 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 ## REST API
@@ -44,13 +43,13 @@ All endpoints are under `/api`. Request and response bodies are JSON.
#### `GET /api/instances` #### `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 | | Parameter | Type | Description |
|-----------|--------|-----------------------------------------| |---|---|---|
| `search` | string | Partial match on `name` or `vmid` | | `search` | string | Partial match on `name`, `vmid`, or `stack` |
| `state` | string | Exact match: `deployed`, `testing`, `degraded` | | `state` | string | Exact match: `deployed`, `testing`, `degraded` |
| `stack` | string | Exact match: `production`, `development` | | `stack` | string | Exact match: `production`, `development` |
``` ```
GET /api/instances?search=plex&state=deployed GET /api/instances?search=plex&state=deployed
@@ -64,11 +63,11 @@ GET /api/instances?search=plex&state=deployed
"state": "deployed", "state": "deployed",
"stack": "production", "stack": "production",
"tailscale_ip": "100.64.0.1", "tailscale_ip": "100.64.0.1",
"atlas": 1, "argus": 0, "semaphore": 0, "atlas": 1, "argus": 1, "semaphore": 0,
"patchmon": 1, "tailscale": 1, "andromeda": 0, "patchmon": 1, "tailscale": 1, "andromeda": 0,
"hardware_acceleration": 1, "hardware_acceleration": 1,
"created_at": "2024-01-15T10:30:00.000Z", "created_at": "2024-01-15T10:30:00",
"updated_at": "2024-03-10T14:22:00.000Z" "updated_at": "2024-03-10T14:22:00"
} }
] ]
``` ```
@@ -91,10 +90,43 @@ GET /api/instances/stacks
Returns a single instance by VMID. Returns a single instance by VMID.
| Status | Condition | | Status | Condition |
|--------|-----------| |---|---|
| `200` | Instance found | | `200` | Instance found |
| `404` | No instance with that VMID | | `400` | VMID is not a valid integer |
| `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. Creates a new instance. Returns the created record.
| Status | Condition | | Status | Condition |
|--------|-----------| |---|---|
| `201` | Created successfully | | `201` | Created successfully |
| `400` | Validation error (see `errors` array in response) | | `400` | Validation error see `errors` array in response |
| `409` | VMID already exists | | `409` | VMID already exists |
**Request body:** **Request body:**
| Field | Type | Required | Notes | | Field | Type | Required | Notes |
|-------|------|----------|-------| |---|---|---|---|
| `name` | string | yes | | | `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` | | `state` | string | yes | `deployed`, `testing`, or `degraded` |
| `stack` | string | yes | `production` or `development` | | `stack` | string | yes | `production` or `development` |
| `tailscale_ip` | string | no | Defaults to `""` | | `tailscale_ip` | string | no | Valid IPv4 or empty string |
| `atlas` | 0\|1 | no | Defaults to `0` | | `atlas` | 0\|1 | no | |
| `argus` | 0\|1 | no | | | `argus` | 0\|1 | no | |
| `semaphore` | 0\|1 | no | | | `semaphore` | 0\|1 | no | |
| `patchmon` | 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. 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 | | Status | Condition |
|--------|-----------| |---|---|
| `200` | Updated successfully | | `200` | Updated successfully |
| `400` | Validation error | | `400` | Validation error |
| `404` | No instance with that VMID | | `404` | No instance with that VMID |
| `409` | New VMID conflicts with an existing instance | | `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. Deletes an instance. Only instances on the `development` stack may be deleted.
| Status | Condition | | Status | Condition |
|--------|-----------| |---|---|
| `204` | Deleted successfully | | `204` | Deleted successfully |
| `404` | No instance with that VMID | | `400` | VMID is not a valid integer |
| `422` | Instance is on the `production` stack | | `404` | No instance with that VMID |
| `400` | VMID is not a valid integer | | `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 ```bash
npm install npm install
npm test # run all tests once npm test # run all tests once
npm run test:watch # watch mode 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: Tests are split across three files:
| File | What it covers | | 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/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 ## 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 | | Change | Bump |
|--------|------|---------| |---|---|
| Bug fix | patch | `1.0.0``1.0.1` | | Bug fix | patch |
| New feature, backward compatible | minor | `1.0.0``1.1.0` | | New feature, backward compatible | minor |
| Breaking change | major | `1.0.0``2.0.0` | | Breaking change | major |
### Cutting a release Pushing a tag triggers the CI pipeline: **test → build → release**.
Docker images are tagged `:x.y.z`, `:x.y`, and `:latest`.
```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`

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; }
@@ -699,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; }
} }
@@ -156,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;
@@ -169,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>
`; `;
@@ -197,7 +206,7 @@ async function renderDetailPage(vmid) {
<span class="tl-time">${fmtDateFull(e.changed_at)}</span> <span class="tl-time">${fmtDateFull(e.changed_at)}</span>
</div>`; </div>`;
const label = FIELD_LABELS[e.field] ?? esc(e.field); const label = FIELD_LABELS[e.field] ?? esc(e.field);
const newCls = e.field === 'state' const newCls = (e.field === 'state' || e.field === 'stack')
? `badge ${esc(e.new_value)}` ? `badge ${esc(e.new_value)}`
: `tl-new ${stateClass(e.field, e.new_value)}`; : `tl-new ${stateClass(e.field, e.new_value)}`;
return ` return `
@@ -375,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();
@@ -28,9 +28,16 @@ function validate(body) {
return errors; 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) { function normalise(body) {
const row = { const row = {
name: body.name.trim(), name: (body.name ?? '').trim(),
state: body.state, state: body.state,
stack: body.stack, stack: body.stack,
vmid: body.vmid, vmid: body.vmid,
@@ -84,10 +91,7 @@ router.post('/instances', (req, res) => {
const created = getInstance(data.vmid); const created = getInstance(data.vmid);
res.status(201).json(created); res.status(201).json(created);
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); handleDbError('POST /api/instances', e, res);
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' });
} }
}); });
@@ -105,24 +109,22 @@ router.put('/instances/:vmid', (req, res) => {
updateInstance(vmid, data); updateInstance(vmid, data);
res.json(getInstance(data.vmid)); res.json(getInstance(data.vmid));
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'vmid already exists' }); handleDbError('PUT /api/instances/:vmid', e, res);
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' });
} }
}); });
// 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' });
} }
@@ -133,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);
@@ -155,7 +157,6 @@ router.delete('/instances/:vmid', (req, res) => {
deleteInstance(vmid); deleteInstance(vmid);
res.status(204).end(); res.status(204).end();
} catch (e) { } catch (e) {
console.error('DELETE /api/instances/:vmid', e); handleDbError('DELETE /api/instances/:vmid', e, res);
res.status(500).json({ error: 'internal server error' });
} }
}); });

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 ──────────────────────────────────────────────────────────
@@ -303,6 +315,32 @@ describe('POST /api/import', () => {
it('returns 400 if body has no instances key', async () => { it('returns 400 if body has no instances key', async () => {
expect((await request(app).post('/api/import').send({})).status).toBe(400) 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)
})
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('—')
}) })
@@ -145,6 +157,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 ─────────────────────────────────────────────────────────── // ── CSS regressions ───────────────────────────────────────────────────────────
const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8') const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')