diff --git a/README.md b/README.md index d012e11..3f1ddd4 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,38 @@ # 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 - **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 - **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 -- **Persistent storage** — SQLite database on a Docker named volume; survives restarts and upgrades -- **Zero native dependencies** — SQLite via Node's built-in `node:sqlite`. No compilation, no binaries. +- **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 -```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 docker compose up -d ``` 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 @@ -44,13 +43,13 @@ All endpoints are under `/api`. Request and response bodies are JSON. #### `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 | -|-----------|--------|-----------------------------------------| -| `search` | string | Partial match on `name` or `vmid` | -| `state` | string | Exact match: `deployed`, `testing`, `degraded` | -| `stack` | string | Exact match: `production`, `development` | +| 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 @@ -64,11 +63,11 @@ GET /api/instances?search=plex&state=deployed "state": "deployed", "stack": "production", "tailscale_ip": "100.64.0.1", - "atlas": 1, "argus": 0, "semaphore": 0, + "atlas": 1, "argus": 1, "semaphore": 0, "patchmon": 1, "tailscale": 1, "andromeda": 0, "hardware_acceleration": 1, - "created_at": "2024-01-15T10:30:00.000Z", - "updated_at": "2024-03-10T14:22:00.000Z" + "created_at": "2024-01-15T10:30:00", + "updated_at": "2024-03-10T14:22:00" } ] ``` @@ -91,10 +90,43 @@ GET /api/instances/stacks Returns a single instance by VMID. | Status | Condition | -|--------|-----------| -| `200` | Instance found | -| `404` | No instance with that VMID | -| `400` | VMID is not a valid integer | +|---|---| +| `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 | + +```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. | Status | Condition | -|--------|-----------| -| `201` | Created successfully | -| `400` | Validation error (see `errors` array in response) | -| `409` | VMID already exists | +|---|---| +| `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, unique | +| `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 | Defaults to `""` | -| `atlas` | 0\|1 | no | Defaults to `0` | +| `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 | | @@ -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. | Status | Condition | -|--------|-----------| -| `200` | Updated successfully | -| `400` | Validation error | -| `404` | No instance with that VMID | -| `409` | New VMID conflicts with an existing instance | +|---|---| +| `200` | Updated successfully | +| `400` | Validation error | +| `404` | No instance with that VMID | +| `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. | Status | Condition | -|--------|-----------| -| `204` | Deleted successfully | -| `404` | No instance with that VMID | -| `422` | Instance is on the `production` stack | -| `400` | VMID is not a valid integer | +|---|---| +| `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. + +```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 npm install -npm test # run all tests once +npm test # run all tests once 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: | 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/helpers.test.js` | UI helper functions — `esc()` XSS contract, `fmtDate()` | +| `tests/helpers.test.js` | UI helpers — `esc()` XSS contract, date formatting, history formatters | --- ## 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 | -|--------|------|---------| -| Bug fix | patch | `1.0.0` → `1.0.1` | -| New feature, backward compatible | minor | `1.0.0` → `1.1.0` | -| Breaking change | major | `1.0.0` → `2.0.0` | +| Change | Bump | +|---|---| +| Bug fix | patch | +| New feature, backward compatible | minor | +| Breaking change | major | -### Cutting a release - -```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` +Pushing a tag triggers the CI pipeline: **test → build → release**. +Docker images are tagged `:x.y.z`, `:x.y`, and `:latest`. diff --git a/server/routes.js b/server/routes.js index a2132d4..c48ef93 100644 --- a/server/routes.js +++ b/server/routes.js @@ -28,9 +28,16 @@ function validate(body) { 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) { const row = { - name: body.name.trim(), + name: (body.name ?? '').trim(), state: body.state, stack: body.stack, vmid: body.vmid, @@ -84,10 +91,7 @@ router.post('/instances', (req, res) => { const created = getInstance(data.vmid); res.status(201).json(created); } catch (e) { - 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('POST /api/instances', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('POST /api/instances', e, res); } }); @@ -105,10 +109,7 @@ router.put('/instances/:vmid', (req, res) => { updateInstance(vmid, data); res.json(getInstance(data.vmid)); } catch (e) { - 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('PUT /api/instances/:vmid', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('PUT /api/instances/:vmid', e, res); } }); @@ -155,7 +156,6 @@ router.delete('/instances/:vmid', (req, res) => { deleteInstance(vmid); res.status(204).end(); } catch (e) { - console.error('DELETE /api/instances/:vmid', e); - res.status(500).json({ error: 'internal server error' }); + handleDbError('DELETE /api/instances/:vmid', e, res); } }); diff --git a/tests/api.test.js b/tests/api.test.js index 1209ec9..66852ce 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -303,6 +303,12 @@ describe('POST /api/import', () => { it('returns 400 if body has no instances key', async () => { 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) + }) }) // ── Static assets & SPA routing ─────────────────────────────────────────────── diff --git a/tests/helpers.test.js b/tests/helpers.test.js index d6c1a4a..587aa14 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -145,6 +145,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 ─────────────────────────────────────────────────────────── const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')