diff --git a/.gitignore b/.gitignore index 95f06bb..eb40447 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -js/version.js data/*.db data/*.db-shm data/*.db-wal 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/css/app.css b/css/app.css index d21a23a..3779d19 100644 --- a/css/app.css +++ b/css/app.css @@ -25,6 +25,10 @@ --mono: 'JetBrains Mono', 'IBM Plex Mono', monospace; } +html { + zoom: 1.1; +} + html, body { height: 100%; background: var(--bg); @@ -70,6 +74,19 @@ nav { .nav-sep { flex: 1; } +.nav-btn { + background: none; + border: 1px solid var(--border2); + color: var(--text2); + border-radius: 6px; + padding: 4px 8px; + font-size: 14px; + cursor: pointer; + margin-left: 10px; + line-height: 1; +} +.nav-btn:hover { border-color: var(--accent); color: var(--accent); } + .nav-divider { color: var(--border2); } .nav-status { @@ -361,16 +378,25 @@ select:focus { border-color: var(--accent); } } .detail-sub { - font-size: 12px; - color: var(--text3); - margin-top: 6px; + font-size: 13px; + margin-top: 8px; display: flex; - gap: 16px; + align-items: center; + gap: 0; } -.detail-sub span { display: flex; gap: 4px; } -.detail-sub .lbl { color: var(--text3); } -.detail-sub .val { color: var(--text2); } +.detail-sub > span { + display: flex; + 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; } @@ -615,6 +641,58 @@ select:focus { border-color: var(--accent); } .confirm-actions { display: flex; justify-content: flex-end; gap: 10px; } +/* ── HISTORY TIMELINE ── */ +.tl-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 9px 0; + border-bottom: 1px solid var(--border); +} +.tl-item:last-child { border-bottom: none; } +.tl-event { display: flex; align-items: center; gap: 7px; font-size: 13px; min-width: 0; } +.tl-label { color: var(--text2); } +.tl-sep { color: var(--text3); user-select: none; } +.tl-old { color: var(--text3); text-decoration: line-through; font-size: 12px; } +.tl-arrow { color: var(--text3); font-size: 11px; } +.tl-new { color: var(--text); font-weight: 500; } +.tl-time { color: var(--text3); font-size: 11px; white-space: nowrap; flex-shrink: 0; } +.tl-deployed { color: var(--accent); } +.tl-testing { color: var(--amber); } +.tl-degraded { color: var(--red); } +.tl-created .tl-event { color: var(--accent); font-weight: 500; } +.tl-empty { color: var(--text3); font-size: 12px; padding: 8px 0; } + +/* ── SETTINGS MODAL ── */ +#settings-modal .modal-body { padding-top: 0; } +.settings-section { padding: 16px 0; border-bottom: 1px solid var(--border); } +.settings-section:last-child { border-bottom: none; padding-bottom: 0; } +.settings-section-title { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text3); + margin-bottom: 8px; +} +.settings-desc { font-size: 12px; color: var(--text2); margin: 0 0 14px; line-height: 1.6; } +.settings-row { display: flex; align-items: center; gap: 12px; } +.settings-label { font-size: 13px; color: var(--text2); white-space: nowrap; min-width: 80px; } +.settings-select { flex: 1; } +.import-row { display: flex; gap: 10px; align-items: center; } +.import-file-input { flex: 1; } + +.btn-secondary { + background: var(--bg3); + border-color: var(--border2); + color: var(--text); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent); } + +.btn-danger { background: var(--red2); border-color: var(--red); color: var(--text); } +.btn-danger:hover { background: var(--red); } + /* ── SCROLLBAR ── */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: var(--bg); } diff --git a/index.html b/index.html index ab3c809..246f199 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ · +
@@ -91,7 +92,7 @@
-
timestamps
+
history
@@ -171,6 +172,38 @@ + + +
diff --git a/js/db.js b/js/db.js index e3c69b3..ea0c527 100644 --- a/js/db.js +++ b/js/db.js @@ -55,3 +55,8 @@ async function updateInstance(vmid, data) { async function deleteInstance(vmid) { await api(`/instances/${vmid}`, { method: 'DELETE' }); } + +async function getInstanceHistory(vmid) { + const res = await fetch(`${BASE}/instances/${vmid}/history`); + return res.json(); +} diff --git a/js/ui.js b/js/ui.js index 70f70de..865ec77 100644 --- a/js/ui.js +++ b/js/ui.js @@ -3,6 +3,34 @@ let editingVmid = null; let currentVmid = null; let toastTimer = null; +// ── Timezone ────────────────────────────────────────────────────────────────── + +const TIMEZONES = [ + { label: 'UTC', tz: 'UTC' }, + { label: 'Hawaii (HST)', tz: 'Pacific/Honolulu' }, + { label: 'Alaska (AKT)', tz: 'America/Anchorage' }, + { label: 'Pacific (PT)', tz: 'America/Los_Angeles' }, + { label: 'Mountain (MT)', tz: 'America/Denver' }, + { label: 'Central (CT)', tz: 'America/Chicago' }, + { label: 'Eastern (ET)', tz: 'America/New_York' }, + { label: 'Atlantic (AT)', tz: 'America/Halifax' }, + { label: 'London (GMT/BST)', tz: 'Europe/London' }, + { label: 'Paris / Berlin (CET)', tz: 'Europe/Paris' }, + { label: 'Helsinki (EET)', tz: 'Europe/Helsinki' }, + { label: 'Istanbul (TRT)', tz: 'Europe/Istanbul' }, + { label: 'Dubai (GST)', tz: 'Asia/Dubai' }, + { label: 'India (IST)', tz: 'Asia/Kolkata' }, + { label: 'Singapore (SGT)', tz: 'Asia/Singapore' }, + { label: 'China (CST)', tz: 'Asia/Shanghai' }, + { label: 'Japan / Korea (JST/KST)', tz: 'Asia/Tokyo' }, + { label: 'Sydney (AEST)', tz: 'Australia/Sydney' }, + { label: 'Auckland (NZST)', tz: 'Pacific/Auckland' }, +]; + +function getTimezone() { + return localStorage.getItem('catalyst_tz') || 'UTC'; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(str) { @@ -11,17 +39,25 @@ function esc(str) { 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) { if (!d) return '—'; 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: getTimezone() }); } catch (e) { return d; } } function fmtDateFull(d) { if (!d) return '—'; try { - return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + 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; } } @@ -39,7 +75,6 @@ async function renderDashboard() {
deployed
${states['deployed'] || 0}
testing
${states['testing'] || 0}
degraded
${states['degraded'] || 0}
-
stacks
${(await getDistinctStacks()).length}
`; await populateStackFilter(); @@ -100,10 +135,39 @@ async function filterInstances() { // ── Detail Page ─────────────────────────────────────────────────────────────── +const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration']; + +const FIELD_LABELS = { + name: 'name', + state: 'state', + stack: 'stack', + vmid: 'vmid', + tailscale_ip: 'tailscale ip', + atlas: 'atlas', + argus: 'argus', + semaphore: 'semaphore', + patchmon: 'patchmon', + tailscale: 'tailscale', + andromeda: 'andromeda', + hardware_acceleration: 'hw acceleration', +}; + +function stateClass(field, val) { + if (field !== 'state') return ''; + return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? ''; +} + +function fmtHistVal(field, val) { + if (val == null || val === '') return '—'; + if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off'; + return esc(val); +} + async function renderDetailPage(vmid) { - const inst = await getInstance(vmid); + const [inst, history, all] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid), getInstances()]); if (!inst) { navigate('dashboard'); return; } currentVmid = vmid; + document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`; document.getElementById('detail-vmid-crumb').textContent = vmid; document.getElementById('detail-name').textContent = inst.name; @@ -114,7 +178,7 @@ async function renderDetailPage(vmid) { document.getElementById('detail-identity').innerHTML = `
name${esc(inst.name)}
state${esc(inst.state)}
-
stack${esc(inst.stack) || '—'}
+
stack${esc(inst.stack) || '—'}
vmid${inst.vmid}
internal id${inst.id}
`; @@ -134,10 +198,30 @@ async function renderDetailPage(vmid) {
`).join(''); - document.getElementById('detail-timestamps').innerHTML = ` -
created${fmtDateFull(inst.created_at)}
-
updated${fmtDateFull(inst.updated_at)}
- `; + document.getElementById('detail-timestamps').innerHTML = history.length + ? history.map(e => { + if (e.field === 'created') return ` +
+ instance created + ${fmtDateFull(e.changed_at)} +
`; + const label = FIELD_LABELS[e.field] ?? esc(e.field); + const newCls = (e.field === 'state' || e.field === 'stack') + ? `badge ${esc(e.new_value)}` + : `tl-new ${stateClass(e.field, e.new_value)}`; + return ` +
+
+ ${label} + · + ${fmtHistVal(e.field, e.old_value)} + + ${fmtHistVal(e.field, e.new_value)} +
+ ${fmtDateFull(e.changed_at)} +
`; + }).join('') + : '
no history yet
'; document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid); document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst); @@ -258,12 +342,72 @@ function showToast(msg, type = 'success') { toastTimer = setTimeout(() => t.classList.remove('show'), 3000); } +// ── Settings Modal ──────────────────────────────────────────────────────────── + +function openSettingsModal() { + const sel = document.getElementById('tz-select'); + if (!sel.options.length) { + for (const { label, tz } of TIMEZONES) { + const opt = document.createElement('option'); + opt.value = tz; + opt.textContent = label; + sel.appendChild(opt); + } + } + sel.value = getTimezone(); + document.getElementById('settings-modal').classList.add('open'); +} + +function closeSettingsModal() { + document.getElementById('settings-modal').classList.remove('open'); + document.getElementById('import-file').value = ''; +} + +async function exportDB() { + const res = await fetch('/api/export'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `catalyst-backup-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +async function importDB() { + const file = document.getElementById('import-file').files[0]; + if (!file) { showToast('Select a backup file first', 'error'); return; } + document.getElementById('confirm-title').textContent = 'Replace all instances?'; + document.getElementById('confirm-msg').textContent = + `This will delete all current instances and replace them with the contents of "${file.name}". This cannot be undone.`; + document.getElementById('confirm-overlay').classList.add('open'); + document.getElementById('confirm-ok').onclick = async () => { + closeConfirm(); + try { + const { instances } = JSON.parse(await file.text()); + const res = await fetch('/api/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instances }), + }); + const data = await res.json(); + if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } + showToast(`Imported ${data.imported} instance${data.imported !== 1 ? 's' : ''}`, 'success'); + closeSettingsModal(); + renderDashboard(); + } catch { + showToast('Invalid backup file', 'error'); + } + }; +} + // ── Keyboard / backdrop ─────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; - if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } - if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } + if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } + if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } + if (document.getElementById('settings-modal').classList.contains('open')) { closeSettingsModal(); return; } }); document.getElementById('instance-modal').addEventListener('click', e => { @@ -272,3 +416,13 @@ document.getElementById('instance-modal').addEventListener('click', e => { document.getElementById('confirm-overlay').addEventListener('click', e => { if (e.target === document.getElementById('confirm-overlay')) closeConfirm(); }); +document.getElementById('settings-modal').addEventListener('click', e => { + if (e.target === document.getElementById('settings-modal')) closeSettingsModal(); +}); + +document.getElementById('tz-select').addEventListener('change', e => { + localStorage.setItem('catalyst_tz', e.target.value); + const m = window.location.pathname.match(/^\/instance\/(\d+)/); + if (m) renderDetailPage(parseInt(m[1], 10)); + else renderDashboard(); +}); diff --git a/js/version.js b/js/version.js new file mode 100644 index 0000000..538443c --- /dev/null +++ b/js/version.js @@ -0,0 +1 @@ +const VERSION = "1.3.0"; diff --git a/package.json b/package.json index 9ba6672..d04ccba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalyst", - "version": "1.2.2", + "version": "1.3.0", "type": "module", "scripts": { "start": "node server/server.js", diff --git a/server/db.js b/server/db.js index b88e974..bf0e99e 100644 --- a/server/db.js +++ b/server/db.js @@ -43,6 +43,16 @@ function createSchema() { ); CREATE INDEX IF NOT EXISTS idx_instances_state ON instances(state); CREATE INDEX IF NOT EXISTS idx_instances_stack ON instances(stack); + + CREATE TABLE IF NOT EXISTS instance_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + vmid INTEGER NOT NULL, + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_history_vmid ON instance_history(vmid); `); } @@ -99,8 +109,14 @@ export function getDistinctStacks() { // ── Mutations ───────────────────────────────────────────────────────────────── +const HISTORY_FIELDS = [ + 'name', 'state', 'stack', 'vmid', 'tailscale_ip', + 'atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda', + 'hardware_acceleration', +]; + export function createInstance(data) { - return db.prepare(` + db.prepare(` INSERT INTO instances (name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration) @@ -108,10 +124,14 @@ export function createInstance(data) { (@name, @state, @stack, @vmid, @atlas, @argus, @semaphore, @patchmon, @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) `).run(data); + db.prepare( + `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, 'created', NULL, NULL)` + ).run(data.vmid); } export function updateInstance(vmid, data) { - return db.prepare(` + const old = getInstance(vmid); + db.prepare(` UPDATE instances SET name=@name, state=@state, stack=@stack, vmid=@newVmid, atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon, @@ -119,12 +139,42 @@ export function updateInstance(vmid, data) { hardware_acceleration=@hardware_acceleration, updated_at=datetime('now') WHERE vmid=@vmid `).run({ ...data, newVmid: data.vmid, vmid }); + const newVmid = data.vmid; + const insertEvt = db.prepare( + `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, ?, ?, ?)` + ); + for (const field of HISTORY_FIELDS) { + const oldVal = String(old[field] ?? ''); + const newVal = String(field === 'vmid' ? newVmid : (data[field] ?? '')); + if (oldVal !== newVal) insertEvt.run(newVmid, field, oldVal, newVal); + } } export function deleteInstance(vmid) { return db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid); } +export function importInstances(rows) { + db.exec('BEGIN'); + db.exec('DELETE FROM instances'); + const insert = db.prepare(` + INSERT INTO instances + (name, state, stack, vmid, atlas, argus, semaphore, patchmon, + tailscale, andromeda, tailscale_ip, hardware_acceleration) + VALUES + (@name, @state, @stack, @vmid, @atlas, @argus, @semaphore, @patchmon, + @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) + `); + for (const row of rows) insert.run(row); + db.exec('COMMIT'); +} + +export function getInstanceHistory(vmid) { + return db.prepare( + 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at DESC' + ).all(vmid); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/routes.js b/server/routes.js index 79eaad6..c48ef93 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1,7 +1,7 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, } from './db.js'; export const router = Router(); @@ -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, @@ -54,6 +61,14 @@ router.get('/instances', (req, res) => { res.json(getInstances({ search, state, stack })); }); +// GET /api/instances/:vmid/history +router.get('/instances/:vmid/history', (req, res) => { + const vmid = parseInt(req.params.vmid, 10); + if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); + if (!getInstance(vmid)) return res.status(404).json({ error: 'instance not found' }); + res.json(getInstanceHistory(vmid)); +}); + // GET /api/instances/:vmid router.get('/instances/:vmid', (req, res) => { const vmid = parseInt(req.params.vmid, 10); @@ -76,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); } }); @@ -97,9 +109,35 @@ 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); + handleDbError('PUT /api/instances/:vmid', e, res); + } +}); + +// GET /api/export +router.get('/export', (_req, res) => { + const instances = getInstances(); + const date = new Date().toISOString().slice(0, 10); + res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); + res.json({ version: 1, exported_at: new Date().toISOString(), instances }); +}); + +// POST /api/import +router.post('/import', (req, res) => { + const { instances } = req.body ?? {}; + if (!Array.isArray(instances)) { + return res.status(400).json({ error: 'body must contain an instances array' }); + } + const errors = []; + for (const [i, row] of instances.entries()) { + const errs = validate(normalise(row)); + if (errs.length) errors.push({ index: i, errors: errs }); + } + if (errors.length) return res.status(400).json({ errors }); + try { + importInstances(instances.map(normalise)); + res.json({ imported: instances.length }); + } catch (e) { + console.error('POST /api/import', e); res.status(500).json({ error: 'internal server error' }); } }); @@ -118,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 c807c30..66852ce 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -239,6 +239,78 @@ describe('DELETE /api/instances/:vmid', () => { }) }) +// ── GET /api/instances/:vmid/history ───────────────────────────────────────── + +describe('GET /api/instances/:vmid/history', () => { + it('returns history events for a known vmid', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/instances/100/history') + expect(res.status).toBe(200) + expect(res.body).toBeInstanceOf(Array) + expect(res.body[0].field).toBe('created') + }) + + it('returns 404 for unknown vmid', async () => { + expect((await request(app).get('/api/instances/999/history')).status).toBe(404) + }) + + it('returns 400 for non-numeric vmid', async () => { + expect((await request(app).get('/api/instances/abc/history')).status).toBe(400) + }) +}) + +// ── GET /api/export ─────────────────────────────────────────────────────────── + +describe('GET /api/export', () => { + it('returns 200 with instances array and attachment header', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).get('/api/export') + expect(res.status).toBe(200) + expect(res.headers['content-disposition']).toMatch(/attachment/) + expect(res.body.instances).toHaveLength(1) + expect(res.body.instances[0].name).toBe('traefik') + }) + + it('returns empty instances array when no data', async () => { + const res = await request(app).get('/api/export') + expect(res.body.instances).toEqual([]) + }) +}) + +// ── POST /api/import ────────────────────────────────────────────────────────── + +describe('POST /api/import', () => { + it('replaces all instances and returns imported count', async () => { + await request(app).post('/api/instances').send(base) + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 999, name: 'imported' }] }) + expect(res.status).toBe(200) + expect(res.body.imported).toBe(1) + expect((await request(app).get('/api/instances')).body[0].name).toBe('imported') + }) + + it('returns 400 if instances is not an array', async () => { + expect((await request(app).post('/api/import').send({ instances: 'bad' })).status).toBe(400) + }) + + it('returns 400 with per-row errors for invalid rows', async () => { + const res = await request(app).post('/api/import') + .send({ instances: [{ ...base, name: '', vmid: 1 }] }) + expect(res.status).toBe(400) + expect(res.body.errors[0].index).toBe(0) + }) + + 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 ─────────────────────────────────────────────── describe('static assets and SPA routing', () => { diff --git a/tests/db.test.js b/tests/db.test.js index 746f2b5..de5cef4 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { _resetForTest, getInstances, getInstance, getDistinctStacks, - createInstance, updateInstance, deleteInstance, + createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -166,6 +166,62 @@ describe('deleteInstance', () => { }); }); +// ── importInstances ─────────────────────────────────────────────────────────── + +describe('importInstances', () => { + const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }; + + it('replaces all existing instances with the imported set', () => { + createInstance({ ...base, name: 'old', vmid: 1 }); + importInstances([{ ...base, name: 'new', vmid: 2 }]); + expect(getInstance(1)).toBeNull(); + expect(getInstance(2)).not.toBeNull(); + }); + + it('clears all instances when passed an empty array', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + importInstances([]); + expect(getInstances()).toEqual([]); + }); +}); + +// ── instance history ───────────────────────────────────────────────────────── + +describe('instance history', () => { + const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 }; + + it('logs a created event when an instance is created', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + const h = getInstanceHistory(1); + expect(h).toHaveLength(1); + expect(h[0].field).toBe('created'); + }); + + it('logs changed fields when an instance is updated', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 1, state: 'degraded' }); + const h = getInstanceHistory(1); + const stateEvt = h.find(e => e.field === 'state'); + expect(stateEvt).toBeDefined(); + expect(stateEvt.old_value).toBe('deployed'); + expect(stateEvt.new_value).toBe('degraded'); + }); + + it('logs no events when nothing changes on update', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 1 }); + const h = getInstanceHistory(1).filter(e => e.field !== 'created'); + expect(h).toHaveLength(0); + }); + + it('records history under the new vmid when vmid changes', () => { + createInstance({ ...base, name: 'a', vmid: 1 }); + updateInstance(1, { ...base, name: 'a', vmid: 2 }); + expect(getInstanceHistory(2).some(e => e.field === 'vmid')).toBe(true); + expect(getInstanceHistory(1).filter(e => e.field !== 'created')).toHaveLength(0); + }); +}); + // ── Test environment boot isolation ─────────────────────────────────────────── describe('test environment boot isolation', () => { diff --git a/tests/helpers.test.js b/tests/helpers.test.js index 26bdd4d..3b7c550 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -58,16 +58,22 @@ describe('esc', () => { // ── 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 '—' 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 } } describe('fmtDate', () => { 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(/15/) expect(result).toMatch(/2024/) @@ -88,24 +94,42 @@ describe('fmtDate', () => { // ── fmtDateFull() ───────────────────────────────────────────────────────────── -function fmtDateFull(d) { +function fmtDateFull(d, tz = 'UTC') { if (!d) return '—' try { - return new Date(d).toLocaleString('en-US', { + return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + timeZone: tz, timeZoneName: 'short', }) } catch (e) { return d } } describe('fmtDateFull', () => { it('includes date and time components', () => { - const result = fmtDateFull('2024-03-15T14:30:00') + const result = fmtDateFull('2024-03-15T14:30:00Z') expect(result).toMatch(/Mar/) expect(result).toMatch(/2024/) expect(result).toMatch(/\d{1,2}:\d{2}/) }) + it('includes the timezone abbreviation', () => { + expect(fmtDateFull('2024-03-15T14:30:00Z', 'UTC')).toMatch(/UTC/) + }) + + it('converts to the given timezone', () => { + // 2024-03-15 18:30 UTC = 2024-03-15 14:30 EDT (UTC-4 in March) + const result = fmtDateFull('2024-03-15T18:30:00Z', 'America/New_York') + expect(result).toMatch(/2:30/) + 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', () => { expect(fmtDateFull(null)).toBe('—') }) @@ -133,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 ─────────────────────────────────────────────────────────── const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')