6 Commits

Author SHA1 Message Date
6e40413385 claude went crazy
All checks were successful
Build / test (push) Successful in 9m28s
Build / release (push) Successful in 1s
Build / build (push) Successful in 25s
2026-03-28 02:35:00 -04:00
d7d4bbc099 fixes version not showing up in web ui
All checks were successful
Build / test (push) Successful in 9m28s
Build / release (push) Successful in 1s
Build / build (push) Successful in 18s
2026-03-28 02:08:58 -04:00
aeb5fa4fb7 version bump
All checks were successful
Build / test (push) Successful in 9m33s
Build / release (push) Successful in 1s
Build / build (push) Successful in 18s
2026-03-28 01:44:29 -04:00
9576b847bd adds version to web ui
Some checks failed
Build / build (push) Has been cancelled
Build / release (push) Has been cancelled
Build / test (push) Has been cancelled
2026-03-28 01:44:09 -04:00
7b22b2abf2 adds docker-compose.yml
Some checks failed
Build / build (push) Has been cancelled
Build / release (push) Has been cancelled
Build / test (push) Has been cancelled
2026-03-28 01:40:18 -04:00
7f726c7d73 only push release on tag
All checks were successful
Build / test (push) Successful in 9m28s
Build / build (push) Has been skipped
Build / release (push) Has been skipped
2026-03-28 01:11:54 -04:00
24 changed files with 2187 additions and 785 deletions

View File

@@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm test:*)" "Bash(npm test:*)",
"Bash(npm install:*)"
] ]
} }
} }

View File

@@ -1,5 +1,10 @@
.git .git
.gitea .gitea
.gitignore
Dockerfile Dockerfile
.dockerignore .dockerignore
docker-compose.yml docker-compose.yml
node_modules
tests
vitest.config.js
data

View File

@@ -20,7 +20,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: lts/* node-version: 'lts/*'
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
@@ -32,6 +32,7 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
if: startsWith(gitea.ref, 'refs/tags/v')
steps: steps:
- name: Checkout - name: Checkout

4
.gitignore vendored
View File

@@ -1 +1,5 @@
node_modules/ node_modules/
js/version.js
data/*.db
data/*.db-shm
data/*.db-wal

View File

@@ -1,3 +1,12 @@
FROM nginx:alpine FROM node:lts-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf WORKDIR /app
COPY . /usr/share/nginx/html
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \
package.json > js/version.js
EXPOSE 3000
CMD ["node", "server/server.js"]

278
README.md
View File

@@ -1,134 +1,198 @@
# Catalyst # Catalyst
A lightweight instance registry for tracking self-hosted infrastructure. No backend, no framework — just a browser, a SQLite database compiled to WebAssembly, and a static file server. :) 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.
## Structure
```
index.html Entry point
css/app.css Styles
js/
config.js Service definitions and seed data
db.js Data layer
ui.js Rendering, modals, notifications
app.js Router
```
## Data layer
All reads and writes go through five functions in `js/db.js`. This is the boundary that would be replaced when wiring Catalyst to a real backend — nothing else in the codebase touches data directly.
### `getInstances(filters?)`
Returns an array of instances, sorted by name. All filters are optional.
```js
getInstances()
getInstances({ search: 'plex' })
getInstances({ state: 'degraded' })
getInstances({ stack: 'production' })
getInstances({ search: 'home', state: 'deployed', stack: 'production' })
```
`search` matches against `name`, `vmid`, and `stack`.
### `getInstance(vmid)`
Returns a single instance by VMID, or `null` if not found.
```js
getInstance(137) // → { id, name, vmid, state, stack, ...services, createdAt, updatedAt }
```
### `getDistinctStacks()`
Returns a sorted array of unique stack names present in the registry. Used to populate the stack filter dynamically.
```js
getDistinctStacks() // → ['development', 'production']
```
### `createInstance(data)`
Inserts a new instance. Returns `{ ok: true }` on success or `{ ok: false, error }` on failure (e.g. duplicate VMID).
```js
createInstance({
name: 'plex',
vmid: 117,
state: 'deployed', // 'deployed' | 'testing' | 'degraded'
stack: 'production',
tailscale_ip: '100.64.0.1',
atlas: 1,
argus: 1,
semaphore: 0,
patchmon: 1,
tailscale: 1,
andromeda: 0,
hardware_acceleration: 1,
})
```
### `updateInstance(id, data)`
Updates an existing instance by internal `id`. Accepts the same shape as `createInstance`. Returns `{ ok: true }` or `{ ok: false, error }`.
### `deleteInstance(id)`
Deletes an instance by internal `id`. Only instances on the `development` stack can be deleted — this is enforced in the UI before `deleteInstance` is ever called.
--- ---
## Instance shape ## Features
| Field | Type | Notes | - **Dashboard** — filterable, searchable instance list with state and stack badges
|---|---|---| - **Detail pages** — per-instance view with service flags, Tailscale IP, and timestamps
| `id` | integer | Internal autoincrement ID | - **Full CRUD** — add, edit, and delete instances via a clean modal interface
| `vmid` | integer | Unique. Used as the public identifier and in URLs (`/instance/137`) | - **Production safeguard** — only development instances can be deleted; production instances must be demoted first
| `name` | string | Display name | - **REST API** — every operation is a plain HTTP call; no magic, no framework lock-in
| `state` | string | `deployed`, `testing`, or `degraded` | - **Persistent storage** — SQLite database on a Docker named volume; survives restarts and upgrades
| `stack` | string | `production` or `development` | - **Zero native dependencies** — SQLite via Node's built-in `node:sqlite`. No compilation, no binaries.
| `tailscale_ip` | string | Optional |
| `atlas` | 0 \| 1 | | ---
| `argus` | 0 \| 1 | |
| `semaphore` | 0 \| 1 | | ## Quick start
| `patchmon` | 0 \| 1 | |
| `tailscale` | 0 \| 1 | | ```bash
| `andromeda` | 0 \| 1 | | docker run -d \
| `hardware_acceleration` | 0 \| 1 | | --name catalyst \
| `createdAt` | ISO string | Set on insert | -p 3000:3000 \
| `updatedAt` | ISO string | Updated on every write | -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).
---
## 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` or `vmid` |
| `state` | string | Exact match: `deployed`, `testing`, `degraded` |
| `stack` | string | Exact match: `production`, `development` |
```
GET /api/instances?search=plex&state=deployed
```
```json
[
{
"vmid": 117,
"name": "plex",
"state": "deployed",
"stack": "production",
"tailscale_ip": "100.64.0.1",
"atlas": 1, "argus": 0, "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"
}
]
```
---
#### `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 |
| `404` | No instance with that VMID |
| `400` | VMID is not a valid integer |
---
#### `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, 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` |
| `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 |
| `404` | No instance with that VMID |
| `422` | Instance is on the `production` stack |
| `400` | VMID is not a valid integer |
---
## Development
```bash
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 — all CRUD operations, constraints, filters |
| `tests/api.test.js` | HTTP API — all endpoints, status codes, error cases |
| `tests/helpers.test.js` | UI helper functions — `esc()` XSS contract, `fmtDate()` |
--- ---
## Versioning ## Versioning
Catalyst uses [semantic versioning](https://semver.org). The version in `package.json` is the source of truth and must match the release tag. Catalyst uses [semantic versioning](https://semver.org). `package.json` is the single source of truth for the version number.
| Change | Bump | Example | | Change | Bump | Example |
|---|---|---| |--------|------|---------|
| Bug fix | patch | `1.0.0``1.0.1` | | Bug fix | patch | `1.0.0``1.0.1` |
| New feature, backward compatible | minor | `1.0.0``1.1.0` | | New feature, backward compatible | minor | `1.0.0``1.1.0` |
| Breaking change | major | `1.0.0``2.0.0` | | Breaking change | major | `1.0.0``2.0.0` |
### Cutting a release ### Cutting a release
**1. Bump the version in `package.json`**
```json
"version": "1.1.0"
```
**2. Commit, tag, and push**
```bash ```bash
# 1. Bump version in package.json, then:
git add package.json git add package.json
git commit -m "chore: release v1.1.0" git commit -m "chore: release v1.1.0"
git tag v1.1.0 git tag v1.1.0
git push && git push --tags git push && git push --tags
``` ```
Pushing the tag triggers the full pipeline: tests → build → release. Pushing a tag triggers the full CI pipeline: **test → build → release**.
- The image is tagged `:1.1.0`, `:1.1`, and `:latest` in the Gitea registry - Docker image tagged `:1.1.0`, `:1.1`, and `:latest` in the Gitea registry
- A Gitea release is created at `v1.1.0` with the image reference in the release notes - A Gitea release is created at `v1.1.0`
Pushes to `main` without a tag still run tests and build a `:latest` image — no release is created.

View File

@@ -70,6 +70,8 @@ nav {
.nav-sep { flex: 1; } .nav-sep { flex: 1; }
.nav-divider { color: var(--border2); }
.nav-status { .nav-status {
font-size: 11px; font-size: 11px;
color: var(--text3); color: var(--text3);

0
data/.gitkeep Normal file
View File

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
catalyst:
image: ${REGISTRY:-gitea.thewrightserver.net/josh}/catalyst:${TAG:-latest}
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
volumes:
- catalyst-data:/app/data
environment:
- NODE_ENV=production
volumes:
catalyst-data:

View File

@@ -18,6 +18,8 @@
<div class="nav-status"> <div class="nav-status">
<div class="pulse"></div> <div class="pulse"></div>
<span id="nav-count">— instances</span> <span id="nav-count">— instances</span>
<span class="nav-divider">·</span>
<span id="nav-version"></span>
</div> </div>
</nav> </nav>
@@ -175,6 +177,7 @@
</div> </div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"></script>
<script src="js/version.js" onerror="window.VERSION=null"></script>
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/db.js"></script> <script src="js/db.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>

View File

@@ -21,6 +21,7 @@ function handleRoute() {
renderDetailPage(parseInt(m[1], 10)); renderDetailPage(parseInt(m[1], 10));
} else { } else {
document.getElementById('page-dashboard').classList.add('active'); document.getElementById('page-dashboard').classList.add('active');
renderDashboard();
} }
} }
@@ -37,7 +38,6 @@ window.addEventListener('popstate', e => {
// ── Bootstrap ───────────────────────────────────────────────────────────────── // ── Bootstrap ─────────────────────────────────────────────────────────────────
initDB().then(() => { if (VERSION) document.getElementById('nav-version').textContent = `v${VERSION}`;
renderDashboard();
handleRoute(); handleRoute();
});

View File

@@ -1,21 +1,6 @@
// Services shown as dots on instance cards (all tracked services) // Services shown as dots on instance cards
const CARD_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda']; const CARD_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda'];
// Services shown in the detail page service grid // Services shown in the detail page service grid
// (tailscale is shown separately under "network" alongside its IP) // (tailscale lives in the network section alongside its IP)
const DETAIL_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'andromeda']; const DETAIL_SERVICES = ['atlas', 'argus', 'semaphore', 'patchmon', 'andromeda'];
const SQL_JS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/';
const STORAGE_KEY = 'catalyst_db';
const SEED = [
{ name: 'plex', state: 'deployed', stack: 'production', vmid: 117, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.1', hardware_acceleration: true },
{ name: 'foldergram', state: 'testing', stack: 'development', vmid: 137, atlas: false, argus: false, semaphore: false, patchmon: false, tailscale: false, andromeda: false, tailscale_ip: '', hardware_acceleration: false },
{ name: 'homeassistant', state: 'deployed', stack: 'production', vmid: 102, atlas: true, argus: true, semaphore: true, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.5', hardware_acceleration: false },
{ name: 'gitea', state: 'deployed', stack: 'production', vmid: 110, atlas: true, argus: false, semaphore: true, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.8', hardware_acceleration: false },
{ name: 'postgres-primary', state: 'degraded', stack: 'production', vmid: 201, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: false, andromeda: true, tailscale_ip: '', hardware_acceleration: false },
{ name: 'nextcloud', state: 'testing', stack: 'development', vmid: 144, atlas: false, argus: false, semaphore: false, patchmon: false, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.12', hardware_acceleration: false },
{ name: 'traefik', state: 'deployed', stack: 'production', vmid: 100, atlas: true, argus: true, semaphore: false, patchmon: true, tailscale: true, andromeda: false, tailscale_ip: '100.64.0.2', hardware_acceleration: false },
{ name: 'monitoring-stack', state: 'testing', stack: 'development', vmid: 155, atlas: false, argus: false, semaphore: true, patchmon: false, tailscale: false, andromeda: false, tailscale_ip: '', hardware_acceleration: false },
];

178
js/db.js
View File

@@ -1,159 +1,57 @@
let db = null; // API client — replaces the sql.js database layer.
// Swap these fetch() calls for any other transport when needed.
// ── Persistence ────────────────────────────────────────────────────────────── const BASE = '/api';
function saveToStorage() { async function api(path, options = {}) {
try { const res = await fetch(BASE + path, options);
const data = db.export(); // Uint8Array if (res.status === 204) return null;
let binary = ''; return res.json().then(data => ({ ok: res.ok, status: res.status, data }));
const chunk = 8192;
for (let i = 0; i < data.length; i += chunk) {
binary += String.fromCharCode(...data.subarray(i, i + chunk));
}
localStorage.setItem(STORAGE_KEY, btoa(binary));
} catch (e) {
console.warn('catalyst: failed to persist database', e);
}
} }
function loadFromStorage() { // ── Queries ───────────────────────────────────────────────────────────────────
try {
const stored = localStorage.getItem(STORAGE_KEY); async function getInstances(filters = {}) {
if (!stored) return null; const params = new URLSearchParams(
const binary = atob(stored); Object.entries(filters).filter(([, v]) => v)
const buf = new Uint8Array(binary.length); );
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i); const res = await fetch(`${BASE}/instances?${params}`);
return buf; return res.json();
} catch (e) {
console.warn('catalyst: failed to load database from storage', e);
return null;
}
} }
// ── Init ───────────────────────────────────────────────────────────────────── async function getInstance(vmid) {
const res = await fetch(`${BASE}/instances/${vmid}`);
async function initDB() { if (res.status === 404) return null;
const SQL = await initSqlJs({ locateFile: f => SQL_JS_CDN + f }); return res.json();
const saved = loadFromStorage();
if (saved) {
db = new SQL.Database(saved);
return;
}
db = new SQL.Database();
db.run(`
CREATE TABLE instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
state TEXT DEFAULT 'deployed',
stack TEXT DEFAULT '',
vmid INTEGER UNIQUE NOT NULL,
atlas INTEGER DEFAULT 0,
argus INTEGER DEFAULT 0,
semaphore INTEGER DEFAULT 0,
patchmon INTEGER DEFAULT 0,
tailscale INTEGER DEFAULT 0,
andromeda INTEGER DEFAULT 0,
tailscale_ip TEXT DEFAULT '',
hardware_acceleration INTEGER DEFAULT 0,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now'))
)
`);
const stmt = db.prepare(`
INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
SEED.forEach(s => stmt.run([
s.name, s.state, s.stack, s.vmid,
+s.atlas, +s.argus, +s.semaphore, +s.patchmon,
+s.tailscale, +s.andromeda, s.tailscale_ip, +s.hardware_acceleration,
]));
stmt.free();
saveToStorage();
} }
// ── Queries ────────────────────────────────────────────────────────────────── async function getDistinctStacks() {
const res = await fetch(`${BASE}/instances/stacks`);
function getInstances(filters = {}) { return res.json();
let sql = 'SELECT * FROM instances WHERE 1=1';
const params = [];
if (filters.search) {
sql += ' AND (name LIKE ? OR CAST(vmid AS TEXT) LIKE ? OR stack LIKE ?)';
const s = `%${filters.search}%`;
params.push(s, s, s);
}
if (filters.state) { sql += ' AND state = ?'; params.push(filters.state); }
if (filters.stack) { sql += ' AND stack = ?'; params.push(filters.stack); }
sql += ' ORDER BY name ASC';
const res = db.exec(sql, params);
if (!res.length) return [];
const cols = res[0].columns;
return res[0].values.map(row => Object.fromEntries(cols.map((c, i) => [c, row[i]])));
}
function getInstance(vmid) {
const res = db.exec('SELECT * FROM instances WHERE vmid = ?', [vmid]);
if (!res.length) return null;
const cols = res[0].columns;
return Object.fromEntries(cols.map((c, i) => [c, res[0].values[0][i]]));
}
function getDistinctStacks() {
const res = db.exec(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`);
if (!res.length) return [];
return res[0].values.map(row => row[0]);
} }
// ── Mutations ───────────────────────────────────────────────────────────────── // ── Mutations ─────────────────────────────────────────────────────────────────
function createInstance(data) { async function createInstance(data) {
try { const { ok, data: body } = await api('/instances', {
db.run( method: 'POST',
`INSERT INTO instances headers: { 'Content-Type': 'application/json' },
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration) body: JSON.stringify(data),
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, });
[data.name, data.state, data.stack, data.vmid, if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error creating instance' };
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration]
);
saveToStorage();
return { ok: true }; return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
} }
function updateInstance(id, data) { async function updateInstance(vmid, data) {
try { const { ok, data: body } = await api(`/instances/${vmid}`, {
db.run( method: 'PUT',
`UPDATE instances SET headers: { 'Content-Type': 'application/json' },
name=?, state=?, stack=?, vmid=?, body: JSON.stringify(data),
atlas=?, argus=?, semaphore=?, patchmon=?, });
tailscale=?, andromeda=?, tailscale_ip=?, hardware_acceleration=?, if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error updating instance' };
updatedAt=datetime('now')
WHERE id=?`,
[data.name, data.state, data.stack, data.vmid,
data.atlas, data.argus, data.semaphore, data.patchmon,
data.tailscale, data.andromeda, data.tailscale_ip, data.hardware_acceleration,
id]
);
saveToStorage();
return { ok: true }; return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
} }
function deleteInstance(id) { async function deleteInstance(vmid) {
db.run('DELETE FROM instances WHERE id = ?', [id]); await api(`/instances/${vmid}`, { method: 'DELETE' });
saveToStorage();
} }

View File

@@ -1,5 +1,5 @@
// Module-level UI state // Module-level UI state
let editingId = null; let editingVmid = null;
let currentVmid = null; let currentVmid = null;
let toastTimer = null; let toastTimer = null;
@@ -27,8 +27,8 @@ function fmtDateFull(d) {
// ── Dashboard ───────────────────────────────────────────────────────────────── // ── Dashboard ─────────────────────────────────────────────────────────────────
function renderDashboard() { async function renderDashboard() {
const all = getInstances(); const all = await getInstances();
document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`; document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`;
const states = {}; const states = {};
@@ -39,18 +39,19 @@ function renderDashboard() {
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div> <div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div> <div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div> <div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${getDistinctStacks().length}</div></div> <div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${(await getDistinctStacks()).length}</div></div>
`; `;
populateStackFilter(); await populateStackFilter();
filterInstances(); await filterInstances();
} }
function populateStackFilter() { async function populateStackFilter() {
const select = document.getElementById('filter-stack'); const select = document.getElementById('filter-stack');
const current = select.value; const current = select.value;
select.innerHTML = '<option value="">all stacks</option>'; select.innerHTML = '<option value="">all stacks</option>';
getDistinctStacks().forEach(s => { const stacks = await getDistinctStacks();
stacks.forEach(s => {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = s; opt.value = s;
opt.textContent = s; opt.textContent = s;
@@ -59,11 +60,11 @@ function populateStackFilter() {
}); });
} }
function filterInstances() { async function filterInstances() {
const search = document.getElementById('search-input').value; const search = document.getElementById('search-input').value;
const state = document.getElementById('filter-state').value; const state = document.getElementById('filter-state').value;
const stack = document.getElementById('filter-stack').value; const stack = document.getElementById('filter-stack').value;
const instances = getInstances({ search, state, stack }); const instances = await getInstances({ search, state, stack });
const grid = document.getElementById('instance-grid'); const grid = document.getElementById('instance-grid');
if (!instances.length) { if (!instances.length) {
@@ -76,7 +77,6 @@ function filterInstances() {
`<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>` `<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>`
).join(''); ).join('');
const activeCount = CARD_SERVICES.filter(s => inst[s]).length; const activeCount = CARD_SERVICES.filter(s => inst[s]).length;
return ` return `
<div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})"> <div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})">
<div class="card-top"> <div class="card-top">
@@ -100,8 +100,8 @@ function filterInstances() {
// ── Detail Page ─────────────────────────────────────────────────────────────── // ── Detail Page ───────────────────────────────────────────────────────────────
function renderDetailPage(vmid) { async function renderDetailPage(vmid) {
const inst = getInstance(vmid); const inst = await getInstance(vmid);
if (!inst) { navigate('dashboard'); return; } if (!inst) { navigate('dashboard'); return; }
currentVmid = vmid; currentVmid = vmid;
@@ -109,7 +109,7 @@ function renderDetailPage(vmid) {
document.getElementById('detail-name').textContent = inst.name; document.getElementById('detail-name').textContent = inst.name;
document.getElementById('detail-vmid-sub').textContent = inst.vmid; document.getElementById('detail-vmid-sub').textContent = inst.vmid;
document.getElementById('detail-id-sub').textContent = inst.id; document.getElementById('detail-id-sub').textContent = inst.id;
document.getElementById('detail-created-sub').textContent = fmtDate(inst.createdAt); document.getElementById('detail-created-sub').textContent = fmtDate(inst.created_at);
document.getElementById('detail-identity').innerHTML = ` document.getElementById('detail-identity').innerHTML = `
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div> <div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div>
@@ -135,8 +135,8 @@ function renderDetailPage(vmid) {
`).join(''); `).join('');
document.getElementById('detail-timestamps').innerHTML = ` document.getElementById('detail-timestamps').innerHTML = `
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.createdAt)}</span></div> <div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.created_at)}</span></div>
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updatedAt)}</span></div> <div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updated_at)}</span></div>
`; `;
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid); document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
@@ -146,16 +146,16 @@ function renderDetailPage(vmid) {
// ── Modal ───────────────────────────────────────────────────────────────────── // ── Modal ─────────────────────────────────────────────────────────────────────
function openNewModal() { function openNewModal() {
editingId = null; editingVmid = null;
document.getElementById('modal-title').textContent = 'new instance'; document.getElementById('modal-title').textContent = 'new instance';
clearForm(); clearForm();
document.getElementById('instance-modal').classList.add('open'); document.getElementById('instance-modal').classList.add('open');
} }
function openEditModal(vmid) { async function openEditModal(vmid) {
const inst = getInstance(vmid); const inst = await getInstance(vmid);
if (!inst) return; if (!inst) return;
editingId = inst.id; editingVmid = inst.vmid;
document.getElementById('modal-title').textContent = `edit / ${inst.name}`; document.getElementById('modal-title').textContent = `edit / ${inst.name}`;
document.getElementById('f-name').value = inst.name; document.getElementById('f-name').value = inst.name;
document.getElementById('f-vmid').value = inst.vmid; document.getElementById('f-vmid').value = inst.vmid;
@@ -186,19 +186,18 @@ function clearForm() {
.forEach(id => { document.getElementById(id).checked = false; }); .forEach(id => { document.getElementById(id).checked = false; });
} }
function saveInstance() { async function saveInstance() {
const name = document.getElementById('f-name').value.trim(); const name = document.getElementById('f-name').value.trim();
const vmid = parseInt(document.getElementById('f-vmid').value, 10); const vmid = parseInt(document.getElementById('f-vmid').value, 10);
const state = document.getElementById('f-state').value; const state = document.getElementById('f-state').value;
const stack = document.getElementById('f-stack').value; const stack = document.getElementById('f-stack').value;
const tip = document.getElementById('f-tailscale-ip').value.trim();
if (!name) { showToast('name is required', 'error'); return; } if (!name) { showToast('name is required', 'error'); return; }
if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; } if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; }
const data = { const data = {
name, state, stack, vmid, name, state, stack, vmid,
tailscale_ip: tip, tailscale_ip: document.getElementById('f-tailscale-ip').value.trim(),
atlas: +document.getElementById('f-atlas').checked, atlas: +document.getElementById('f-atlas').checked,
argus: +document.getElementById('f-argus').checked, argus: +document.getElementById('f-argus').checked,
semaphore: +document.getElementById('f-semaphore').checked, semaphore: +document.getElementById('f-semaphore').checked,
@@ -208,20 +207,19 @@ function saveInstance() {
hardware_acceleration: +document.getElementById('f-hardware-accel').checked, hardware_acceleration: +document.getElementById('f-hardware-accel').checked,
}; };
const result = editingId ? updateInstance(editingId, data) : createInstance(data); const result = editingVmid
? await updateInstance(editingVmid, data)
: await createInstance(data);
if (!result.ok) { if (!result.ok) { showToast(result.error, 'error'); return; }
showToast(result.error.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
return;
}
showToast(`${name} ${editingId ? 'updated' : 'created'}`, 'success'); showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success');
closeModal(); closeModal();
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
renderDetailPage(vmid); await renderDetailPage(vmid);
} else { } else {
renderDashboard(); await renderDashboard();
} }
} }
@@ -232,11 +230,10 @@ function confirmDeleteDialog(inst) {
showToast(`demote ${inst.name} to development before deleting`, 'error'); showToast(`demote ${inst.name} to development before deleting`, 'error');
return; return;
} }
document.getElementById('confirm-title').textContent = `delete ${inst.name}?`; document.getElementById('confirm-title').textContent = `delete ${inst.name}?`;
document.getElementById('confirm-msg').textContent = document.getElementById('confirm-msg').textContent =
`This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`; `This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`;
document.getElementById('confirm-ok').onclick = () => doDelete(inst.id, inst.name); document.getElementById('confirm-ok').onclick = () => doDelete(inst.vmid, inst.name);
document.getElementById('confirm-overlay').classList.add('open'); document.getElementById('confirm-overlay').classList.add('open');
} }
@@ -244,9 +241,9 @@ function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open'); document.getElementById('confirm-overlay').classList.remove('open');
} }
function doDelete(id, name) { async function doDelete(vmid, name) {
deleteInstance(id);
closeConfirm(); closeConfirm();
await deleteInstance(vmid);
showToast(`${name} deleted`, 'success'); showToast(`${name} deleted`, 'success');
navigate('dashboard'); navigate('dashboard');
} }
@@ -261,7 +258,7 @@ function showToast(msg, type = 'success') {
toastTimer = setTimeout(() => t.classList.remove('show'), 3000); toastTimer = setTimeout(() => t.classList.remove('show'), 3000);
} }
// ── Global keyboard handler ─────────────────────────────────────────────────── // ── Keyboard / backdrop ───────────────────────────────────────────────────────
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
@@ -269,7 +266,6 @@ document.addEventListener('keydown', e => {
if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; }
}); });
// Close modals on backdrop click
document.getElementById('instance-modal').addEventListener('click', e => { document.getElementById('instance-modal').addEventListener('click', e => {
if (e.target === document.getElementById('instance-modal')) closeModal(); if (e.target === document.getElementById('instance-modal')) closeModal();
}); });

View File

@@ -1,19 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
add_header Cache-Control "no-store";
}
}

1472
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,19 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"version:write": "node -e \"const {version}=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('js/version.js','const VERSION = \\\"'+version+'\\\";\\n');\""
},
"dependencies": {
"express": "^4.18.0"
}, },
"devDependencies": { "devDependencies": {
"vitest": "^2.0.0", "jsdom": "^25.0.0",
"sql.js": "^1.10.2", "supertest": "^7.0.0",
"jsdom": "^25.0.0" "vitest": "^3.2.4"
} }
} }

137
server/db.js Normal file
View File

@@ -0,0 +1,137 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_PATH = join(__dirname, '../data/catalyst.db');
let db;
function init(path) {
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
db = new DatabaseSync(path);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA synchronous = NORMAL');
createSchema();
if (path !== ':memory:') seed();
}
function createSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 100),
state TEXT NOT NULL DEFAULT 'deployed'
CHECK(state IN ('deployed','testing','degraded')),
stack TEXT NOT NULL DEFAULT 'development'
CHECK(stack IN ('production','development')),
vmid INTEGER NOT NULL UNIQUE CHECK(vmid > 0),
atlas INTEGER NOT NULL DEFAULT 0 CHECK(atlas IN (0,1)),
argus INTEGER NOT NULL DEFAULT 0 CHECK(argus IN (0,1)),
semaphore INTEGER NOT NULL DEFAULT 0 CHECK(semaphore IN (0,1)),
patchmon INTEGER NOT NULL DEFAULT 0 CHECK(patchmon IN (0,1)),
tailscale INTEGER NOT NULL DEFAULT 0 CHECK(tailscale IN (0,1)),
andromeda INTEGER NOT NULL DEFAULT 0 CHECK(andromeda IN (0,1)),
tailscale_ip TEXT NOT NULL DEFAULT '',
hardware_acceleration INTEGER NOT NULL DEFAULT 0 CHECK(hardware_acceleration IN (0,1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_instances_state ON instances(state);
CREATE INDEX IF NOT EXISTS idx_instances_stack ON instances(stack);
`);
}
const SEED = [
{ name: 'plex', state: 'deployed', stack: 'production', vmid: 117, atlas: 1, argus: 1, semaphore: 0, patchmon: 1, tailscale: 1, andromeda: 0, tailscale_ip: '100.64.0.1', hardware_acceleration: 1 },
{ name: 'foldergram', state: 'testing', stack: 'development', vmid: 137, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 },
{ name: 'homeassistant', state: 'deployed', stack: 'production', vmid: 102, atlas: 1, argus: 1, semaphore: 1, patchmon: 1, tailscale: 1, andromeda: 0, tailscale_ip: '100.64.0.5', hardware_acceleration: 0 },
{ name: 'gitea', state: 'deployed', stack: 'production', vmid: 110, atlas: 1, argus: 0, semaphore: 1, patchmon: 1, tailscale: 1, andromeda: 0, tailscale_ip: '100.64.0.8', hardware_acceleration: 0 },
{ name: 'postgres-primary', state: 'degraded', stack: 'production', vmid: 201, atlas: 1, argus: 1, semaphore: 0, patchmon: 1, tailscale: 0, andromeda: 1, tailscale_ip: '', hardware_acceleration: 0 },
{ name: 'nextcloud', state: 'testing', stack: 'development', vmid: 144, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 1, andromeda: 0, tailscale_ip: '100.64.0.12', hardware_acceleration: 0 },
{ name: 'traefik', state: 'deployed', stack: 'production', vmid: 100, atlas: 1, argus: 1, semaphore: 0, patchmon: 1, tailscale: 1, andromeda: 0, tailscale_ip: '100.64.0.2', hardware_acceleration: 0 },
{ name: 'monitoring-stack', state: 'testing', stack: 'development', vmid: 155, atlas: 0, argus: 0, semaphore: 1, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 },
];
function seed() {
const count = db.prepare('SELECT COUNT(*) as n FROM instances').get().n;
if (count > 0) return;
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)
`);
db.exec('BEGIN');
for (const s of SEED) insert.run(s);
db.exec('COMMIT');
}
// ── Queries ───────────────────────────────────────────────────────────────────
export function getInstances(filters = {}) {
const parts = ['SELECT * FROM instances WHERE 1=1'];
const params = {};
if (filters.search) {
parts.push('AND (name LIKE @search OR CAST(vmid AS TEXT) LIKE @search OR stack LIKE @search)');
params.search = `%${filters.search}%`;
}
if (filters.state) { parts.push('AND state = @state'); params.state = filters.state; }
if (filters.stack) { parts.push('AND stack = @stack'); params.stack = filters.stack; }
parts.push('ORDER BY name ASC');
return db.prepare(parts.join(' ')).all(params);
}
export function getInstance(vmid) {
return db.prepare('SELECT * FROM instances WHERE vmid = ?').get(vmid) ?? null;
}
export function getDistinctStacks() {
return db.prepare(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`)
.all().map(r => r.stack);
}
// ── Mutations ─────────────────────────────────────────────────────────────────
export function createInstance(data) {
return 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)
`).run(data);
}
export function updateInstance(vmid, data) {
return db.prepare(`
UPDATE instances SET
name=@name, state=@state, stack=@stack, vmid=@newVmid,
atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon,
tailscale=@tailscale, andromeda=@andromeda, tailscale_ip=@tailscale_ip,
hardware_acceleration=@hardware_acceleration, updated_at=datetime('now')
WHERE vmid=@vmid
`).run({ ...data, newVmid: data.vmid, vmid });
}
export function deleteInstance(vmid) {
return db.prepare('DELETE FROM instances WHERE vmid = ?').run(vmid);
}
// ── Test helpers ──────────────────────────────────────────────────────────────
export function _resetForTest() {
if (db) db.close();
init(':memory:');
}
// ── Boot ──────────────────────────────────────────────────────────────────────
init(process.env.DB_PATH ?? DEFAULT_PATH);

114
server/routes.js Normal file
View File

@@ -0,0 +1,114 @@
import { Router } from 'express';
import {
getInstances, getInstance, getDistinctStacks,
createInstance, updateInstance, deleteInstance,
} from './db.js';
export const router = Router();
// ── Validation ────────────────────────────────────────────────────────────────
const VALID_STATES = ['deployed', 'testing', 'degraded'];
const VALID_STACKS = ['production', 'development'];
const SERVICE_KEYS = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda'];
function validate(body) {
const errors = [];
if (!body.name || typeof body.name !== 'string' || !body.name.trim())
errors.push('name is required');
if (!Number.isInteger(body.vmid) || body.vmid < 1)
errors.push('vmid must be a positive integer');
if (!VALID_STATES.includes(body.state))
errors.push(`state must be one of: ${VALID_STATES.join(', ')}`);
if (!VALID_STACKS.includes(body.stack))
errors.push(`stack must be one of: ${VALID_STACKS.join(', ')}`);
return errors;
}
function normalise(body) {
const row = {
name: body.name.trim(),
state: body.state,
stack: body.stack,
vmid: body.vmid,
tailscale_ip: (body.tailscale_ip ?? '').trim(),
hardware_acceleration: body.hardware_acceleration ? 1 : 0,
};
for (const svc of SERVICE_KEYS) row[svc] = body[svc] ? 1 : 0;
return row;
}
// ── Routes ────────────────────────────────────────────────────────────────────
// GET /api/instances/stacks — must be declared before /:vmid
router.get('/instances/stacks', (_req, res) => {
res.json(getDistinctStacks());
});
// GET /api/instances
router.get('/instances', (req, res) => {
const { search, state, stack } = req.query;
res.json(getInstances({ search, state, stack }));
});
// GET /api/instances/:vmid
router.get('/instances/:vmid', (req, res) => {
const vmid = parseInt(req.params.vmid, 10);
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
const instance = getInstance(vmid);
if (!instance) return res.status(404).json({ error: 'instance not found' });
res.json(instance);
});
// POST /api/instances
router.post('/instances', (req, res) => {
const errors = validate(req.body);
if (errors.length) return res.status(400).json({ errors });
try {
const data = normalise(req.body);
createInstance(data);
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' });
throw e;
}
});
// PUT /api/instances/:vmid
router.put('/instances/:vmid', (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' });
const errors = validate(req.body);
if (errors.length) return res.status(400).json({ errors });
try {
const data = normalise(req.body);
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' });
throw e;
}
});
// DELETE /api/instances/:vmid
router.delete('/instances/:vmid', (req, res) => {
const vmid = parseInt(req.params.vmid, 10);
if (!vmid) return res.status(400).json({ error: 'invalid vmid' });
const instance = getInstance(vmid);
if (!instance) return res.status(404).json({ error: 'instance not found' });
if (instance.stack !== 'development')
return res.status(422).json({ error: 'only development instances can be deleted' });
deleteInstance(vmid);
res.status(204).end();
});

33
server/server.js Normal file
View File

@@ -0,0 +1,33 @@
import express from 'express';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { router } from './routes.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT ?? 3000;
export const app = express();
app.use(express.json());
// API
app.use('/api', router);
// Static files
app.use(express.static(join(__dirname, '..')));
// SPA fallback — all non-API, non-asset routes serve index.html
app.get('*', (req, res) => {
res.sendFile(join(__dirname, '../index.html'));
});
// Error handler
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: 'internal server error' });
});
// Boot — only when run directly, not when imported by tests
if (process.argv[1] === fileURLToPath(import.meta.url)) {
app.listen(PORT, () => console.log(`catalyst on :${PORT}`));
}

239
tests/api.test.js Normal file
View File

@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import { app } from '../server/server.js'
import { _resetForTest } from '../server/db.js'
beforeEach(() => _resetForTest())
const base = {
name: 'traefik',
vmid: 100,
state: 'deployed',
stack: 'production',
atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0,
tailscale_ip: '',
hardware_acceleration: 0,
}
// ── GET /api/instances ────────────────────────────────────────────────────────
describe('GET /api/instances', () => {
it('returns empty array when no instances exist', async () => {
const res = await request(app).get('/api/instances')
expect(res.status).toBe(200)
expect(res.body).toEqual([])
})
it('returns all instances sorted by name', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'zebra' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'alpha' })
const res = await request(app).get('/api/instances')
expect(res.status).toBe(200)
expect(res.body).toHaveLength(2)
expect(res.body[0].name).toBe('alpha')
expect(res.body[1].name).toBe('zebra')
})
it('filters by state', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', state: 'deployed' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', state: 'degraded' })
const res = await request(app).get('/api/instances?state=deployed')
expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('a')
})
it('filters by stack', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' })
const res = await request(app).get('/api/instances?stack=development')
expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('b')
})
it('searches by name substring', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'gitea' })
const res = await request(app).get('/api/instances?search=ple')
expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('plex')
})
it('searches by vmid', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 137, name: 'a' })
await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' })
const res = await request(app).get('/api/instances?search=137')
expect(res.body).toHaveLength(1)
expect(res.body[0].vmid).toBe(137)
})
it('combines search and state filters', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'plex', state: 'deployed' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'plex2', state: 'degraded' })
const res = await request(app).get('/api/instances?search=plex&state=deployed')
expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('plex')
})
})
// ── GET /api/instances/stacks ─────────────────────────────────────────────────
describe('GET /api/instances/stacks', () => {
it('returns empty array when no instances exist', async () => {
const res = await request(app).get('/api/instances/stacks')
expect(res.status).toBe(200)
expect(res.body).toEqual([])
})
it('returns unique stacks sorted alphabetically', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a', stack: 'production' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b', stack: 'development', state: 'testing' })
await request(app).post('/api/instances').send({ ...base, vmid: 3, name: 'c', stack: 'production' })
const res = await request(app).get('/api/instances/stacks')
expect(res.body).toEqual(['development', 'production'])
})
})
// ── GET /api/instances/:vmid ──────────────────────────────────────────────────
describe('GET /api/instances/:vmid', () => {
it('returns the instance for a known vmid', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 117, name: 'plex' })
const res = await request(app).get('/api/instances/117')
expect(res.status).toBe(200)
expect(res.body.name).toBe('plex')
expect(res.body.vmid).toBe(117)
})
it('returns 404 for unknown vmid', async () => {
const res = await request(app).get('/api/instances/999')
expect(res.status).toBe(404)
expect(res.body.error).toBeDefined()
})
it('returns 400 for non-numeric vmid', async () => {
const res = await request(app).get('/api/instances/abc')
expect(res.status).toBe(400)
})
})
// ── POST /api/instances ───────────────────────────────────────────────────────
describe('POST /api/instances', () => {
it('creates an instance and returns 201 with the created record', async () => {
const res = await request(app).post('/api/instances').send(base)
expect(res.status).toBe(201)
expect(res.body.name).toBe('traefik')
expect(res.body.vmid).toBe(100)
expect(res.body.created_at).not.toBeNull()
expect(res.body.updated_at).not.toBeNull()
})
it('stores service flags correctly', async () => {
const res = await request(app).post('/api/instances').send({ ...base, atlas: 1, tailscale: 1, hardware_acceleration: 1 })
expect(res.body.atlas).toBe(1)
expect(res.body.tailscale).toBe(1)
expect(res.body.hardware_acceleration).toBe(1)
expect(res.body.argus).toBe(0)
})
it('returns 409 for duplicate vmid', async () => {
await request(app).post('/api/instances').send(base)
const res = await request(app).post('/api/instances').send({ ...base, name: 'other' })
expect(res.status).toBe(409)
expect(res.body.error).toMatch(/vmid/)
})
it('returns 400 when name is missing', async () => {
const res = await request(app).post('/api/instances').send({ ...base, name: '' })
expect(res.status).toBe(400)
expect(res.body.errors).toBeInstanceOf(Array)
})
it('returns 400 for vmid less than 1', async () => {
const res = await request(app).post('/api/instances').send({ ...base, vmid: 0 })
expect(res.status).toBe(400)
expect(res.body.errors).toBeInstanceOf(Array)
})
it('returns 400 for invalid state', async () => {
const res = await request(app).post('/api/instances').send({ ...base, state: 'invalid' })
expect(res.status).toBe(400)
})
it('returns 400 for invalid stack', async () => {
const res = await request(app).post('/api/instances').send({ ...base, stack: 'invalid' })
expect(res.status).toBe(400)
})
it('trims whitespace from name', async () => {
const res = await request(app).post('/api/instances').send({ ...base, name: ' plex ' })
expect(res.status).toBe(201)
expect(res.body.name).toBe('plex')
})
})
// ── PUT /api/instances/:vmid ──────────────────────────────────────────────────
describe('PUT /api/instances/:vmid', () => {
it('updates fields and returns the updated record', async () => {
await request(app).post('/api/instances').send(base)
const res = await request(app).put('/api/instances/100').send({ ...base, name: 'updated', state: 'degraded' })
expect(res.status).toBe(200)
expect(res.body.name).toBe('updated')
expect(res.body.state).toBe('degraded')
})
it('can change the vmid', async () => {
await request(app).post('/api/instances').send(base)
await request(app).put('/api/instances/100').send({ ...base, vmid: 200 })
expect((await request(app).get('/api/instances/100')).status).toBe(404)
expect((await request(app).get('/api/instances/200')).status).toBe(200)
})
it('returns 404 for unknown vmid', async () => {
const res = await request(app).put('/api/instances/999').send(base)
expect(res.status).toBe(404)
})
it('returns 400 for validation errors', async () => {
await request(app).post('/api/instances').send(base)
const res = await request(app).put('/api/instances/100').send({ ...base, name: '' })
expect(res.status).toBe(400)
expect(res.body.errors).toBeInstanceOf(Array)
})
it('returns 409 when new vmid conflicts with an existing instance', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' })
await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' })
const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 })
expect(res.status).toBe(409)
})
})
// ── DELETE /api/instances/:vmid ───────────────────────────────────────────────
describe('DELETE /api/instances/:vmid', () => {
it('deletes a development instance and returns 204', async () => {
await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' })
const res = await request(app).delete('/api/instances/100')
expect(res.status).toBe(204)
expect((await request(app).get('/api/instances/100')).status).toBe(404)
})
it('returns 422 when attempting to delete a production instance', async () => {
await request(app).post('/api/instances').send({ ...base, stack: 'production' })
const res = await request(app).delete('/api/instances/100')
expect(res.status).toBe(422)
expect(res.body.error).toMatch(/development/)
})
it('returns 404 for unknown vmid', async () => {
const res = await request(app).delete('/api/instances/999')
expect(res.status).toBe(404)
})
it('returns 400 for non-numeric vmid', async () => {
const res = await request(app).delete('/api/instances/abc')
expect(res.status).toBe(400)
})
})

View File

@@ -1,250 +1,167 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import initSqlJs from 'sql.js' import {
_resetForTest,
getInstances, getInstance, getDistinctStacks,
createInstance, updateInstance, deleteInstance,
} from '../server/db.js'
// ── Schema (mirrors db.js) ──────────────────────────────────────────────────── beforeEach(() => _resetForTest());
const SCHEMA = ` // ── getInstances ──────────────────────────────────────────────────────────────
CREATE TABLE instances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
state TEXT DEFAULT 'deployed',
stack TEXT DEFAULT '',
vmid INTEGER UNIQUE NOT NULL,
atlas INTEGER DEFAULT 0,
argus INTEGER DEFAULT 0,
semaphore INTEGER DEFAULT 0,
patchmon INTEGER DEFAULT 0,
tailscale INTEGER DEFAULT 0,
andromeda INTEGER DEFAULT 0,
tailscale_ip TEXT DEFAULT '',
hardware_acceleration INTEGER DEFAULT 0,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now'))
)
`
// ── Helpers ───────────────────────────────────────────────────────────────────
let db
beforeEach(async () => {
const SQL = await initSqlJs()
db = new SQL.Database()
db.run(SCHEMA)
})
function rows(res) {
if (!res.length) return []
const cols = res[0].columns
return res[0].values.map(row => Object.fromEntries(cols.map((c, i) => [c, row[i]])))
}
function insert(overrides = {}) {
const defaults = {
name: 'test-instance', state: 'deployed', stack: 'production', vmid: 100,
atlas: 0, argus: 0, semaphore: 0, patchmon: 0,
tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0,
}
const d = { ...defaults, ...overrides }
db.run(
`INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[d.name, d.state, d.stack, d.vmid, d.atlas, d.argus, d.semaphore,
d.patchmon, d.tailscale, d.andromeda, d.tailscale_ip, d.hardware_acceleration]
)
return d
}
function getInstances(filters = {}) {
let sql = 'SELECT * FROM instances WHERE 1=1'
const params = []
if (filters.search) {
sql += ' AND (name LIKE ? OR CAST(vmid AS TEXT) LIKE ? OR stack LIKE ?)'
const s = `%${filters.search}%`
params.push(s, s, s)
}
if (filters.state) { sql += ' AND state = ?'; params.push(filters.state) }
if (filters.stack) { sql += ' AND stack = ?'; params.push(filters.stack) }
sql += ' ORDER BY name ASC'
return rows(db.exec(sql, params))
}
function getInstance(vmid) {
const res = rows(db.exec('SELECT * FROM instances WHERE vmid = ?', [vmid]))
return res[0] ?? null
}
function getDistinctStacks() {
const res = db.exec(`SELECT DISTINCT stack FROM instances WHERE stack != '' ORDER BY stack`)
if (!res.length) return []
return res[0].values.map(r => r[0])
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('getInstances', () => { describe('getInstances', () => {
it('returns empty array when no instances exist', () => { it('returns empty array when table is empty', () => {
expect(getInstances()).toEqual([]) expect(getInstances()).toEqual([]);
}) });
it('returns all instances sorted by name', () => { it('returns all instances sorted by name', () => {
insert({ name: 'zebra', vmid: 1 }) createInstance({ name: 'zebra', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'alpha', vmid: 2 }) createInstance({ name: 'alpha', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances() const result = getInstances();
expect(result).toHaveLength(2) expect(result[0].name).toBe('alpha');
expect(result[0].name).toBe('alpha') expect(result[1].name).toBe('zebra');
expect(result[1].name).toBe('zebra') });
})
it('filters by state', () => { it('filters by state', () => {
insert({ name: 'a', vmid: 1, state: 'deployed' }) createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'b', vmid: 2, state: 'degraded' }) createInstance({ name: 'b', state: 'degraded', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'c', vmid: 3, state: 'testing' }) createInstance({ name: 'c', state: 'testing', stack: 'development', vmid: 3, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ state: 'deployed' })).toHaveLength(1) expect(getInstances({ state: 'deployed' })).toHaveLength(1);
expect(getInstances({ state: 'degraded' })).toHaveLength(1) expect(getInstances({ state: 'degraded' })).toHaveLength(1);
expect(getInstances({ state: 'testing' })).toHaveLength(1) expect(getInstances({ state: 'testing' })).toHaveLength(1);
}) });
it('filters by stack', () => { it('filters by stack', () => {
insert({ name: 'a', vmid: 1, stack: 'production' }) createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'b', vmid: 2, stack: 'development' }) createInstance({ name: 'b', state: 'testing', stack: 'development', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ stack: 'production' })).toHaveLength(1) expect(getInstances({ stack: 'production' })).toHaveLength(1);
expect(getInstances({ stack: 'development' })).toHaveLength(1) expect(getInstances({ stack: 'development' })).toHaveLength(1);
}) });
it('searches by name', () => { it('searches by name', () => {
insert({ name: 'plex', vmid: 1 }) createInstance({ name: 'plex', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'gitea', vmid: 2 }) createInstance({ name: 'gitea', state: 'deployed', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ search: 'ple' })).toHaveLength(1) expect(getInstances({ search: 'ple' })).toHaveLength(1);
expect(getInstances({ search: 'ple' })[0].name).toBe('plex') expect(getInstances({ search: 'ple' })[0].name).toBe('plex');
}) });
it('searches by vmid', () => { it('searches by vmid', () => {
insert({ name: 'a', vmid: 137 }) createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 137, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'b', vmid: 200 }) createInstance({ name: 'b', state: 'deployed', stack: 'production', vmid: 200, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ search: '137' })).toHaveLength(1) expect(getInstances({ search: '137' })).toHaveLength(1);
}) });
it('searches by stack', () => { it('combines filters', () => {
insert({ name: 'a', vmid: 1, stack: 'production' }) createInstance({ name: 'plex', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ name: 'b', vmid: 2, stack: 'development' }) createInstance({ name: 'plex2', state: 'degraded', stack: 'production', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getInstances({ search: 'prod' })).toHaveLength(1) expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1);
}) });
});
it('combines search and state filters', () => { // ── getInstance ───────────────────────────────────────────────────────────────
insert({ name: 'plex', vmid: 1, state: 'deployed' })
insert({ name: 'plex2', vmid: 2, state: 'degraded' })
expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1)
})
it('returns empty array when no results match', () => {
insert({ name: 'plex', vmid: 1 })
expect(getInstances({ search: 'zzz' })).toEqual([])
})
})
describe('getInstance', () => { describe('getInstance', () => {
it('returns the instance with the given vmid', () => { it('returns the instance with the given vmid', () => {
insert({ name: 'plex', vmid: 117 }) createInstance({ name: 'plex', state: 'deployed', stack: 'production', vmid: 117, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const inst = getInstance(117) const inst = getInstance(117);
expect(inst).not.toBeNull() expect(inst).not.toBeNull();
expect(inst.name).toBe('plex') expect(inst.name).toBe('plex');
expect(inst.vmid).toBe(117) expect(inst.vmid).toBe(117);
}) });
it('returns null for an unknown vmid', () => { it('returns null for unknown vmid', () => {
expect(getInstance(999)).toBeNull() expect(getInstance(999)).toBeNull();
}) });
}) });
// ── getDistinctStacks ─────────────────────────────────────────────────────────
describe('getDistinctStacks', () => { describe('getDistinctStacks', () => {
it('returns empty array when no instances exist', () => { it('returns empty array when table is empty', () => {
expect(getDistinctStacks()).toEqual([]) expect(getDistinctStacks()).toEqual([]);
}) });
it('returns unique stacks sorted alphabetically', () => { it('returns unique stacks sorted alphabetically', () => {
insert({ vmid: 1, stack: 'production' }) createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 1, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ vmid: 2, stack: 'development' }) createInstance({ name: 'b', state: 'testing', stack: 'development', vmid: 2, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
insert({ vmid: 3, stack: 'production' }) createInstance({ name: 'c', state: 'deployed', stack: 'production', vmid: 3, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
expect(getDistinctStacks()).toEqual(['development', 'production']) expect(getDistinctStacks()).toEqual(['development', 'production']);
}) });
});
it('excludes blank stack values', () => { // ── createInstance ────────────────────────────────────────────────────────────
insert({ vmid: 1, stack: '' })
insert({ vmid: 2, stack: 'production' })
expect(getDistinctStacks()).toEqual(['production'])
})
})
describe('createInstance', () => { describe('createInstance', () => {
it('inserts a new instance', () => { const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 };
insert({ name: 'traefik', vmid: 100, stack: 'production', state: 'deployed' })
const inst = getInstance(100) it('inserts a new instance and sets timestamps', () => {
expect(inst.name).toBe('traefik') createInstance({ ...base, name: 'traefik', vmid: 100 });
expect(inst.stack).toBe('production') const inst = getInstance(100);
expect(inst.state).toBe('deployed') expect(inst.name).toBe('traefik');
}) expect(inst.created_at).not.toBeNull();
expect(inst.updated_at).not.toBeNull();
});
it('stores service flags correctly', () => { it('stores service flags correctly', () => {
insert({ vmid: 1, atlas: 1, argus: 0, tailscale: 1, hardware_acceleration: 1 }) createInstance({ ...base, name: 'plex', vmid: 1, atlas: 1, tailscale: 1, hardware_acceleration: 1 });
const inst = getInstance(1) const inst = getInstance(1);
expect(inst.atlas).toBe(1) expect(inst.atlas).toBe(1);
expect(inst.argus).toBe(0) expect(inst.argus).toBe(0);
expect(inst.tailscale).toBe(1) expect(inst.tailscale).toBe(1);
expect(inst.hardware_acceleration).toBe(1) expect(inst.hardware_acceleration).toBe(1);
}) });
it('rejects duplicate vmid', () => { it('rejects duplicate vmid', () => {
insert({ vmid: 100 }) createInstance({ ...base, name: 'a', vmid: 100 });
expect(() => insert({ name: 'other', vmid: 100 })).toThrow() expect(() => createInstance({ ...base, name: 'b', vmid: 100 })).toThrow();
}) });
it('sets createdAt and updatedAt on insert', () => { it('rejects invalid state', () => {
insert({ vmid: 1 }) expect(() => createInstance({ ...base, name: 'a', vmid: 1, state: 'invalid' })).toThrow();
const inst = getInstance(1) });
expect(inst.createdAt).not.toBeNull()
expect(inst.updatedAt).not.toBeNull() it('rejects invalid stack', () => {
}) expect(() => createInstance({ ...base, name: 'a', vmid: 1, stack: 'invalid' })).toThrow();
}) });
});
// ── updateInstance ────────────────────────────────────────────────────────────
describe('updateInstance', () => { describe('updateInstance', () => {
it('updates fields on an existing instance', () => { const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 };
insert({ name: 'old-name', vmid: 100, state: 'testing', stack: 'development' })
const before = getInstance(100)
db.run(
`UPDATE instances SET name=?, state=?, stack=?, updatedAt=datetime('now') WHERE id=?`,
['new-name', 'deployed', 'production', before.id]
)
const after = getInstance(100)
expect(after.name).toBe('new-name')
expect(after.state).toBe('deployed')
expect(after.stack).toBe('production')
})
it('updates updatedAt on write', () => { it('updates fields and refreshes updated_at', () => {
insert({ vmid: 1 }) createInstance({ ...base, name: 'old', vmid: 100 });
const before = getInstance(1) updateInstance(100, { ...base, name: 'new', vmid: 100, state: 'degraded' });
db.run(`UPDATE instances SET name=?, updatedAt=datetime('now') WHERE id=?`, ['updated', before.id]) const inst = getInstance(100);
const after = getInstance(1) expect(inst.name).toBe('new');
expect(after.updatedAt).not.toBeNull() expect(inst.state).toBe('degraded');
}) });
})
it('can change vmid', () => {
createInstance({ ...base, name: 'a', vmid: 100 });
updateInstance(100, { ...base, name: 'a', vmid: 200 });
expect(getInstance(100)).toBeNull();
expect(getInstance(200)).not.toBeNull();
});
});
// ── deleteInstance ────────────────────────────────────────────────────────────
describe('deleteInstance', () => { describe('deleteInstance', () => {
const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 };
it('removes the instance', () => { it('removes the instance', () => {
insert({ vmid: 1 }) createInstance({ ...base, name: 'a', vmid: 1 });
const inst = getInstance(1) deleteInstance(1);
db.run('DELETE FROM instances WHERE id = ?', [inst.id]) expect(getInstance(1)).toBeNull();
expect(getInstance(1)).toBeNull() });
})
it('only removes the targeted instance', () => { it('only removes the targeted instance', () => {
insert({ name: 'a', vmid: 1 }) createInstance({ ...base, name: 'a', vmid: 1 });
insert({ name: 'b', vmid: 2 }) createInstance({ ...base, name: 'b', vmid: 2 });
const inst = getInstance(1) deleteInstance(1);
db.run('DELETE FROM instances WHERE id = ?', [inst.id]) expect(getInstance(1)).toBeNull();
expect(getInstance(1)).toBeNull() expect(getInstance(2)).not.toBeNull();
expect(getInstance(2)).not.toBeNull() });
}) });
})

View File

@@ -1,3 +1,4 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
// ── esc() ───────────────────────────────────────────────────────────────────── // ── esc() ─────────────────────────────────────────────────────────────────────

View File

@@ -2,6 +2,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'node',
}, },
}) })