After creating an instance, if any jobs have run_on_create enabled,
the client polls /api/jobs every 500ms until each relevant job has a
new completed run (tracked via last_run_id baseline). The dashboard
or detail page then refreshes automatically. 30s timeout as a safety
net if a job hangs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
datetime('now') only stores to the second, making same-second events
indistinguishable. Switched all instance_history and job_runs writes
to strftime('%Y-%m-%dT%H:%M:%f', 'now') for millisecond precision.
Reverted getInstanceHistory to ORDER BY changed_at DESC, id DESC so
newest events appear at the top and instance creation (lowest id,
earliest timestamp) is always at the bottom.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the dot only updated when visiting the Jobs page.
Now a jobs fetch runs at bootstrap so the dot reflects status
immediately on any page, including after a hard refresh.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
runJobsOnCreate now awaits each job before starting the next,
ensuring they don't stomp each other's DB writes in parallel.
getInstanceHistory changed to ORDER BY changed_at ASC, id ASC so
the creation event (lowest id) is always first regardless of
same-second timestamps.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jobs with run_on_create=true in their config fire automatically
after a new instance is created. Runs fire-and-forget so they don't
delay the 201 response. Option exposed as a checkbox in each job's
detail panel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Export bumped to version 3, now includes jobs (with raw unmasked
config) and job_runs arrays. Import restores them when present and
restarts the scheduler. Payloads without a jobs key leave jobs
untouched, keeping v1/v2 backups fully compatible.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fetches Semaphore project inventory via Bearer auth, parses the
Ansible INI format to extract hostnames, and sets semaphore=1/0
on matching instances.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Removed from the instance subtitle and the overview kv grid. The auto-
increment ID is an implementation detail with no user-facing meaning.
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>
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>
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>
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>
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>
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>
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>
Add a Display section to the settings modal with a timezone dropdown.
Selection is persisted to localStorage and applied to all timestamps via
fmtDate (date-only) and fmtDateFull (date + time + TZ abbreviation, e.g.
"Mar 28, 2026, 2:48 PM EDT"). Changing the timezone live-re-renders the
current page. Defaults to UTC.
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>
Stacks are always just production/development — counting them adds
no useful information to the dashboard summary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removing the :first-child { padding-top: 0 } override lets every
section use the same padding: 16px 0, so the gap above Export matches
the gap above Import (and any future sections).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The modal-body's 22px padding-top created a visible gap between the
header divider and the Export section title.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
padding-top on the first .settings-section created a visible gap
above the Export title. Fixed with :first-child { padding-top: 0 }.
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>
Multi-line python3 -c "..." had unindented code outside the run: | block,
causing 'yaml: line 83: could not find expected :'. Collapsed to a single
indented line so the YAML parser sees it correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
cache: npm caused ~4min ETIMEDOUT on every run (cache service unreachable).
Commit messages containing backticks were shell-expanded inside the
curl -d "..." string, causing 'sha: No such file or directory'. Fixed by
writing release notes to a temp file and using python3 to build the JSON
payload, then passing it to curl with --data @file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
$GITEA_SHA is unset on Gitea runners — the nav showed "dev-" with an
empty SHA. git rev-parse --short HEAD works regardless of runner env vars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Gitea runner's cache service is unreachable, causing a ~4 minute
ETIMEDOUT on every run before falling back to a cold install anyway.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Production images continue to display the semver (v1.x.x). Dev images
built by CI now receive BUILD_VERSION=dev-<7-char-sha> via a Docker ARG,
and app.js skips the v prefix for non-semver strings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
.badge lacked text-align: center. Inside the card's flex-end right
column, badge text was left-justified within each pill, making state
labels (deployed / testing / degraded) appear skewed to the left.
TDD: CSS regression test added to tests/helpers.test.js — reads
css/app.css directly and asserts the rule is present, so this
cannot regress silently in future.
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>
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>
Adds a build-dev job to ci.yml that fires after tests pass on direct
pushes to dev (not PRs). Pushes two tags to the registry:
:dev — mutable, always the latest integrated dev state
:dev-<sha> — immutable, for tracing exactly which commit is running
Staging servers can pull :dev to test before a release PR is opened.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merges the initial ci.yml + release.yml workflow changes onto dev.
This is the first merge under the new feature-branch → dev → main model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>