The previous version snapshotted last_run_id after the 201 response, but jobs fire immediately server-side — by the time the client fetched /api/jobs the runs were already complete, so the baseline matched the new state and the poll loop never detected completion. Baseline is now captured before the creation POST so it always reflects pre-run state regardless of job speed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Catalyst
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
- Dashboard — filterable, searchable instance list with state and stack badges
- 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
- Production safeguard — only development instances can be deleted; production instances must be demoted first
- Export / import — JSON backup and restore via the settings modal
- REST API — every operation is a plain HTTP call
- 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
docker compose up -d
Open 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
All endpoints are under /api. Request and response bodies are JSON.
Instances
GET /api/instances
Returns all instances sorted by name. All query parameters are optional.
| Parameter | Type | Description |
|---|---|---|
search |
string | Partial match on name, vmid, or stack |
state |
string | Exact match: deployed, testing, degraded |
stack |
string | Exact match: production, development |
GET /api/instances?search=plex&state=deployed
[
{
"vmid": 117,
"name": "plex",
"state": "deployed",
"stack": "production",
"tailscale_ip": "100.64.0.1",
"atlas": 1, "argus": 1, "semaphore": 0,
"patchmon": 1, "tailscale": 1, "andromeda": 0,
"hardware_acceleration": 1,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-03-10T14:22:00"
}
]
GET /api/instances/stacks
Returns a sorted array of distinct stack names present in the registry.
GET /api/instances/stacks
→ ["development", "production"]
GET /api/instances/:vmid
Returns a single instance by VMID.
| Status | Condition |
|---|---|
200 |
Instance found |
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 |
[
{
"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"
}
]
POST /api/instances
Creates a new instance. Returns the created record.
| Status | Condition |
|---|---|
201 |
Created successfully |
400 |
Validation error — see errors array in response |
409 |
VMID already exists |
Request body:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | |
vmid |
integer | yes | Must be > 0 and unique |
state |
string | yes | deployed, testing, or degraded |
stack |
string | yes | production or development |
tailscale_ip |
string | no | Valid IPv4 or empty string |
atlas |
0|1 | no | |
argus |
0|1 | no | |
semaphore |
0|1 | no | |
patchmon |
0|1 | no | |
tailscale |
0|1 | no | |
andromeda |
0|1 | no | |
hardware_acceleration |
0|1 | no |
PUT /api/instances/: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 |
|---|---|
200 |
Updated successfully |
400 |
Validation error |
404 |
No instance with that VMID |
409 |
New VMID conflicts with an existing instance |
DELETE /api/instances/:vmid
Deletes an instance. Only instances on the development stack may be deleted.
| Status | Condition |
|---|---|
204 |
Deleted successfully |
400 |
VMID is not a valid integer |
404 |
No instance with that VMID |
422 |
Instance is on the production stack |
Backup
GET /api/export
Downloads a JSON backup of all instances as a file attachment.
{
"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 |
Development
npm install
npm test # run all tests once
npm run test:watch # watch mode
npm start # start the server on :3000
Tests are split across three files:
| File | What it covers |
|---|---|
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/helpers.test.js |
UI helpers — esc() XSS contract, date formatting, history formatters |
Versioning
Catalyst uses semantic versioning. package.json is the single source of truth.
| Change | Bump |
|---|---|
| Bug fix | patch |
| New feature, backward compatible | minor |
| Breaking change | major |
Pushing a tag triggers the CI pipeline: test → build → release.
Docker images are tagged :x.y.z, :x.y, and :latest.