Syncs patchmon field on instances by querying the Patchmon hosts API
and matching hostnames. API token masked as REDACTED in responses.
seedJobs now uses INSERT OR IGNORE so new jobs are seeded on existing
installs without re-running the full seed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces ad-hoc Tailscale config tracking with a proper jobs system.
Jobs get their own nav page (master/detail layout), a dedicated DB
table, and full run history persisted forever. Tailscale connection
settings move from the Settings modal into the Jobs page. Registry
pattern makes adding future jobs straightforward.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a background job system that polls the Tailscale API on a configurable
interval and syncs tailscale status and IPs to instances by hostname match.
- New config table (key/value) in SQLite for persistent server-side settings
- New server/jobs.js: runTailscaleSync + restartJobs scheduler
- GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET
- POST /api/jobs/tailscale/run — immediate manual sync
- Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
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>
Adds a gear button to the nav that opens a settings modal with:
- Export: GET /api/export returns all instances as a JSON backup file
with a Content-Disposition attachment header
- Import: POST /api/import validates and bulk-replaces all instances;
client uses FileReader to POST the parsed JSON, with a confirm dialog
before destructive replace
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Vitest runs test files in parallel workers. Each worker imports server/db.js,
which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers
racing to open the same SQLite file caused "database is locked", followed
by process.exit(1) killing the worker — surfacing as:
Error: process.exit unexpectedly called with "1"
Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets
NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest())
initialises its own :memory: database, so no file coordination is needed.
process.exit(1) is also guarded by the same condition — it must never
fire inside a test runner process.
TDD: two regression tests added to tests/db.test.js documenting the
expected boot behaviour and proving the module loads cleanly in parallel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause of the 500 on create/update/delete: the non-root app user in
the Docker container lacked write permission to the volume mount point.
Docker volume mounts are owned by root by default; the app user (added
in a previous commit) could read the database but not write to it.
Fixes:
1. Dockerfile — RUN mkdir -p /app/data before chown so the directory
exists in the image with correct ownership. Docker uses this as a
seed when initialising a new named volume, ensuring the app user
owns the mount point from the start.
NOTE: existing volumes from before the non-root user was introduced
will still be root-owned. Fix with:
docker run --rm -v catalyst-data:/data alpine chown -R 1000:1000 /data
2. server/routes.js — replace bare `throw e` in POST/PUT catch blocks
with console.error (route context + error) + explicit 500 response.
Add try-catch to DELETE handler which previously had none. Unexpected
DB errors now log the route they came from and return a clean JSON
body instead of relying on the generic Express error handler.
3. server/db.js — wrap the boot init() call in try-catch. Fatal startup
errors (e.g. data directory not writable) now print a clear message
pointing to the cause before exiting, instead of a raw stack trace.
TDD: tests written first (RED), then fixed (GREEN). Six new tests in
tests/api.test.js verify that unexpected DB errors on POST, PUT, and
DELETE return 500 with { error: 'internal server error' } and call
console.error with the route context string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three root causes addressed:
1. Added <base href="/"> to index.html so all relative asset paths
(css/app.css, js/*.js) resolve from the root regardless of the
current SPA route. Without this, /instance/117 requested
/instance/css/app.css, which hit the SPA fallback and returned
HTML; helmet's nosniff then refused it as a stylesheet.
2. Removed upgrade-insecure-requests from the CSP (useDefaults: false).
This directive told browsers to upgrade HTTP→HTTPS for every asset
request, breaking all resource loading on HTTP-only deployments.
3. Changed script-src-attr from 'none' to 'unsafe-inline' to allow
the inline onclick handlers used throughout the UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dockerfile — creates a non-root app user and runs the process under it
server/routes.js — tailscale_ip validated against IPv4 regex (empty string still allowed)
index.html — sql.js CDN script tag already removed earlier in this session