27 Commits

Author SHA1 Message Date
f1e192c5d4 Merge pull request 'v1.2.0' (#10) from dev into main
Some checks failed
CI / test (push) Successful in 13s
Release / release (push) Failing after 5m14s
CI / build-dev (push) Has been skipped
Reviewed-on: #10
2026-03-28 13:24:34 -04:00
3037381084 Merge pull request 'chore: bump version to 1.2.0' (#9) from chore/bump-v1.2.0 into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 26s
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #9
2026-03-28 13:22:15 -04:00
e54c1d4848 chore: bump version to 1.2.0
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:21:18 -04:00
3ae3f98df5 Merge pull request 'fix: use git rev-parse for short SHA in build-dev' (#8) from fix/ci-short-sha into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 20s
Reviewed-on: #8
2026-03-28 13:19:23 -04:00
65d6514603 fix: use git rev-parse for short SHA in build-dev
All checks were successful
CI / test (pull_request) Successful in 12s
CI / build-dev (pull_request) Has been skipped
$GITEA_SHA is unset on Gitea runners — the nav showed "dev-" with an
empty SHA. git rev-parse --short HEAD works regardless of runner env vars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:18:47 -04:00
bc44bcbde9 Merge pull request 'fix: remove npm cache from setup-node' (#7) from fix/ci-remove-npm-cache into dev
All checks were successful
CI / test (push) Successful in 12s
CI / build-dev (push) Successful in 13s
Reviewed-on: #7
2026-03-28 13:16:33 -04:00
cae0f2222a fix: remove npm cache from setup-node
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
The Gitea runner's cache service is unreachable, causing a ~4 minute
ETIMEDOUT on every run before falling back to a cold install anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:11:05 -04:00
28833a7ec6 Merge pull request 'feat: show dev-<sha> version string in nav for dev builds' (#6) from feat/dev-version-string into dev
All checks were successful
CI / test (push) Successful in 9m33s
CI / build-dev (push) Successful in 22s
Reviewed-on: #6
2026-03-28 13:03:23 -04:00
6ba02bf17d feat: show dev-<sha> version string in nav for dev builds
All checks were successful
CI / test (pull_request) Successful in 9m31s
CI / build-dev (pull_request) Has been skipped
Production images continue to display the semver (v1.x.x). Dev images
built by CI now receive BUILD_VERSION=dev-<7-char-sha> via a Docker ARG,
and app.js skips the v prefix for non-semver strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:52:15 -04:00
bfe71b2511 Merge pull request 'fix: centre badge text on instance cards' (#5) from fix/badge-alignment into dev
All checks were successful
CI / test (push) Successful in 9m32s
CI / build-dev (push) Successful in 20s
Reviewed-on: #5
2026-03-28 12:38:53 -04:00
0f2a37cb39 fix: centre badge text on instance cards
All checks were successful
CI / test (pull_request) Successful in 9m31s
CI / build-dev (pull_request) Has been skipped
.badge lacked text-align: center. Inside the card's flex-end right
column, badge text was left-justified within each pill, making state
labels (deployed / testing / degraded) appear skewed to the left.

TDD: CSS regression test added to tests/helpers.test.js — reads
css/app.css directly and asserts the rule is present, so this
cannot regress silently in future.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 12:28:44 -04:00
73f4eabbc7 Merge pull request 'fix: db volume ownership and explicit error handling for write failures' (#3) from fix/db-permissions-and-error-handling into dev
All checks were successful
CI / test (push) Successful in 9m33s
CI / build-dev (push) Successful in 21s
Reviewed-on: #3
2026-03-28 12:10:32 -04:00
515ff8ddb3 Merge branch 'dev' into fix/db-permissions-and-error-handling
All checks were successful
CI / test (pull_request) Successful in 9m28s
CI / build-dev (pull_request) Has been skipped
2026-03-28 11:48:36 -04:00
08c12c9394 fix: skip db boot init in test env to prevent parallel worker lock
All checks were successful
CI / test (pull_request) Successful in 9m33s
CI / build-dev (pull_request) Has been skipped
Vitest runs test files in parallel workers. Each worker imports server/db.js,
which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers
racing to open the same SQLite file caused "database is locked", followed
by process.exit(1) killing the worker — surfacing as:

  Error: process.exit unexpectedly called with "1"

Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets
NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest())
initialises its own :memory: database, so no file coordination is needed.

process.exit(1) is also guarded by the same condition — it must never
fire inside a test runner process.

TDD: two regression tests added to tests/db.test.js documenting the
expected boot behaviour and proving the module loads cleanly in parallel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:48:07 -04:00
4ce7df4649 Merge pull request 'fix: skip db boot init in test env to prevent parallel worker lock' (#4) from fix/db-boot-test-isolation into dev
Some checks failed
CI / test (push) Successful in 9m29s
CI / build-dev (push) Has been cancelled
Reviewed-on: #4
2026-03-28 11:41:55 -04:00
6c04a30c3a fix: skip db boot init in test env to prevent parallel worker lock
All checks were successful
CI / test (pull_request) Successful in 9m29s
CI / build-dev (pull_request) Has been skipped
Vitest runs test files in parallel workers. Each worker imports server/db.js,
which triggered module-level init(DEFAULT_PATH) unconditionally. Two workers
racing to open the same SQLite file caused "database is locked", followed
by process.exit(1) killing the worker — surfacing as:

  Error: process.exit unexpectedly called with "1"

Fix: guard the boot init block behind NODE_ENV !== 'test'. Vitest sets
NODE_ENV=test automatically. Each worker's beforeEach(() => _resetForTest())
initialises its own :memory: database, so no file coordination is needed.

process.exit(1) is also guarded by the same condition — it must never
fire inside a test runner process.

TDD: two regression tests added to tests/db.test.js documenting the
expected boot behaviour and proving the module loads cleanly in parallel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:31:55 -04:00
c6cd8098fd Merge branch 'dev' into fix/db-permissions-and-error-handling
Some checks failed
CI / test (pull_request) Failing after 4m52s
CI / build-dev (pull_request) Has been skipped
2026-03-28 11:16:31 -04:00
15ed329743 fix: db volume ownership and explicit error handling for write failures
All checks were successful
CI / test (pull_request) Successful in 9m32s
Root cause of the 500 on create/update/delete: the non-root app user in
the Docker container lacked write permission to the volume mount point.
Docker volume mounts are owned by root by default; the app user (added
in a previous commit) could read the database but not write to it.

Fixes:

1. Dockerfile — RUN mkdir -p /app/data before chown so the directory
   exists in the image with correct ownership. Docker uses this as a
   seed when initialising a new named volume, ensuring the app user
   owns the mount point from the start.

   NOTE: existing volumes from before the non-root user was introduced
   will still be root-owned. Fix with:
     docker run --rm -v catalyst-data:/data alpine chown -R 1000:1000 /data

2. server/routes.js — replace bare `throw e` in POST/PUT catch blocks
   with console.error (route context + error) + explicit 500 response.
   Add try-catch to DELETE handler which previously had none. Unexpected
   DB errors now log the route they came from and return a clean JSON
   body instead of relying on the generic Express error handler.

3. server/db.js — wrap the boot init() call in try-catch. Fatal startup
   errors (e.g. data directory not writable) now print a clear message
   pointing to the cause before exiting, instead of a raw stack trace.

TDD: tests written first (RED), then fixed (GREEN). Six new tests in
tests/api.test.js verify that unexpected DB errors on POST, PUT, and
DELETE return 500 with { error: 'internal server error' } and call
console.error with the route context string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:11:00 -04:00
1412b2e0b7 Merge pull request 'feat: build :dev Docker image on push to dev' (#1) from chore/dev-staging-build into dev
All checks were successful
CI / test (push) Successful in 9m29s
CI / build-dev (push) Successful in 13s
Reviewed-on: #1
2026-03-28 10:38:48 -04:00
30b037ff9c feat: build :dev Docker image on push to dev
All checks were successful
CI / test (pull_request) Successful in 9m30s
CI / build-dev (pull_request) Has been skipped
Adds a build-dev job to ci.yml that fires after tests pass on direct
pushes to dev (not PRs). Pushes two tags to the registry:

  :dev          — mutable, always the latest integrated dev state
  :dev-<sha>    — immutable, for tracing exactly which commit is running

Staging servers can pull :dev to test before a release PR is opened.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:27:23 -04:00
7a5b5d7afc chore: establish dev branch and branching workflow
All checks were successful
CI / test (push) Successful in 9m26s
Merges the initial ci.yml + release.yml workflow changes onto dev.
This is the first merge under the new feature-branch → dev → main model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:16:00 -04:00
3383bee968 chore: replace build.yml with ci.yml + release.yml
Splits the single workflow into two with distinct responsibilities:

ci.yml    — runs tests on push/PR to dev and main. Powers the required
            status check for branch protection on both branches.

release.yml — triggers on push to main (merged PR). Reads version from
              package.json, asserts the tag doesn't already exist, creates
              the git tag, generates patch notes from commits since the
              previous tag, builds and pushes the Docker image, and creates
              the Gitea release. No more manual git tag or git push --tags.

build.yml deleted — all three of its jobs are covered by the new files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 10:15:52 -04:00
0c30e4bd29 chore: release v1.1.2
All checks were successful
Build / test (push) Successful in 9m27s
Build / build (push) Successful in 27s
Build / release (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:57 -04:00
01f83d25f6 fix: SPA deep-link assets and broken home screen CSS
Three root causes addressed:

1. Added <base href="/"> to index.html so all relative asset paths
   (css/app.css, js/*.js) resolve from the root regardless of the
   current SPA route. Without this, /instance/117 requested
   /instance/css/app.css, which hit the SPA fallback and returned
   HTML; helmet's nosniff then refused it as a stylesheet.

2. Removed upgrade-insecure-requests from the CSP (useDefaults: false).
   This directive told browsers to upgrade HTTP→HTTPS for every asset
   request, breaking all resource loading on HTTP-only deployments.

3. Changed script-src-attr from 'none' to 'unsafe-inline' to allow
   the inline onclick handlers used throughout the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:52:48 -04:00
79adc365d8 server/server.js — added helmet with CSP configured to allow Google Fonts
All checks were successful
Build / test (push) Successful in 9m29s
Build / release (push) Successful in 1s
Build / build (push) Successful in 32s
Dockerfile — creates a non-root app user and runs the process under it
server/routes.js — tailscale_ip validated against IPv4 regex (empty string still allowed)
index.html — sql.js CDN script tag already removed earlier in this session
2026-03-28 09:20:24 -04:00
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
26 changed files with 2575 additions and 874 deletions

View File

@@ -1,7 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npm test:*)"
"Bash(npm test:*)",
"Bash(npm install:*)",
"Bash(find /c/Users/josh1/Documents/Code/Catalyst -type f \\\\\\(-name *.test.js -o -name *.spec.js -o -name .env* -o -name *.config.js \\\\\\))"
]
}
}

View File

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

View File

@@ -1,84 +0,0 @@
name: Build
on:
push:
branches: [main]
tags:
- 'v*'
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
build:
runs-on: ubuntu-latest
needs: test
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
release:
runs-on: ubuntu-latest
needs: build
if: startsWith(gitea.ref, 'refs/tags/v')
steps:
- name: Create release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{
\"tag_name\": \"${{ gitea.ref_name }}\",
\"name\": \"Catalyst ${{ gitea.ref_name }}\",
\"body\": \"### Image\n\n\`${{ env.IMAGE }}:${{ gitea.ref_name }}\`\",
\"draft\": false,
\"prerelease\": false
}"

53
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,53 @@
name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- run: npm ci
- run: npm test
build-dev:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Compute short SHA
run: echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITEA_ENV
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
build-args: BUILD_VERSION=dev-${{ env.SHORT_SHA }}
tags: |
${{ env.IMAGE }}:dev
${{ env.IMAGE }}:dev-${{ gitea.sha }}

View File

@@ -0,0 +1,95 @@
name: Release
on:
push:
branches: [main]
env:
IMAGE: ${{ vars.REGISTRY_HOST }}/${{ gitea.repository_owner }}/catalyst
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: npm
- run: npm ci
- run: npm test
- name: Read version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=${VERSION}" >> $GITEA_ENV
- name: Assert tag does not exist
run: |
if git ls-remote --tags origin "refs/tags/v${{ env.VERSION }}" | grep -q .; then
echo "ERROR: tag v${{ env.VERSION }} already exists — bump version in package.json before merging to main."
exit 1
fi
- name: Create and push tag
run: |
git config user.name "gitea-actions"
git config user.email "actions@gitea"
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
- name: Generate release notes
run: |
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$LAST_TAG" ]; then
NOTES=$(git log "${LAST_TAG}..HEAD" --pretty=format:"- %s" --no-merges)
else
NOTES=$(git log --pretty=format:"- %s" --no-merges)
fi
NOTES_JSON=$(printf '%s' "$NOTES" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
echo "NOTES=${NOTES_JSON}" >> $GITEA_ENV
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}},value=v${{ env.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=v${{ env.VERSION }}
type=sha,prefix=,format=short
type=raw,value=latest
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Create Gitea release
run: |
curl -sf -X POST \
-H "Authorization: token ${{ secrets.TOKEN }}" \
-H "Content-Type: application/json" \
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases" \
-d "{
\"tag_name\": \"v${{ env.VERSION }}\",
\"name\": \"Catalyst v${{ env.VERSION }}\",
\"body\": \"### Changes\n\n${{ env.NOTES }}\n\n### Image\n\n\`${{ env.IMAGE }}:${{ env.VERSION }}\`\",
\"draft\": false,
\"prerelease\": false
}"

4
.gitignore vendored
View File

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

View File

@@ -1,3 +1,22 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY . /usr/share/nginx/html
FROM node:lts-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ARG BUILD_VERSION=""
RUN if [ -n "$BUILD_VERSION" ]; then \
printf 'const VERSION = "%s";\n' "$BUILD_VERSION" > js/version.js; \
else \
awk -F'"' '/"version"/{printf "const VERSION = \"%s\";\n", $4; exit}' \
package.json > js/version.js; \
fi
RUN mkdir -p /app/data && chown -R app:app /app
USER app
EXPOSE 3000
CMD ["node", "server/server.js"]

278
README.md
View File

@@ -1,134 +1,198 @@
# 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. :)
## 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.
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.
---
## Instance shape
## Features
| Field | Type | Notes |
|---|---|---|
| `id` | integer | Internal autoincrement ID |
| `vmid` | integer | Unique. Used as the public identifier and in URLs (`/instance/137`) |
| `name` | string | Display name |
| `state` | string | `deployed`, `testing`, or `degraded` |
| `stack` | string | `production` or `development` |
| `tailscale_ip` | string | Optional |
| `atlas` | 0 \| 1 | |
| `argus` | 0 \| 1 | |
| `semaphore` | 0 \| 1 | |
| `patchmon` | 0 \| 1 | |
| `tailscale` | 0 \| 1 | |
| `andromeda` | 0 \| 1 | |
| `hardware_acceleration` | 0 \| 1 | |
| `createdAt` | ISO string | Set on insert |
| `updatedAt` | ISO string | Updated on every write |
- **Dashboard** — filterable, searchable instance list with state and stack badges
- **Detail pages** — per-instance view with service flags, Tailscale IP, and timestamps
- **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.
---
## 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).
---
## 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
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 |
|---|---|---|
|--------|------|---------|
| 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` |
### Cutting a release
**1. Bump the version in `package.json`**
```json
"version": "1.1.0"
```
**2. Commit, tag, and push**
```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 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
- A Gitea release is created at `v1.1.0` with the image reference in the release notes
Pushes to `main` without a tag still run tests and build a `:latest` image — no release is created.
- Docker image tagged `:1.1.0`, `:1.1`, and `:latest` in the Gitea registry
- A Gitea release is created at `v1.1.0`

View File

@@ -289,6 +289,7 @@ select:focus { border-color: var(--accent); }
border-radius: 3px;
letter-spacing: 0.08em;
text-transform: uppercase;
text-align: center;
}
.badge.deployed { background: var(--accent2); color: var(--accent); }

0
data/.gitkeep Normal file
View File

View File

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

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Catalyst</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -176,7 +177,7 @@
<span id="toast-msg"></span>
</div>
<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/db.js"></script>
<script src="js/ui.js"></script>

View File

@@ -21,6 +21,7 @@ function handleRoute() {
renderDetailPage(parseInt(m[1], 10));
} else {
document.getElementById('page-dashboard').classList.add('active');
renderDashboard();
}
}
@@ -37,12 +38,9 @@ window.addEventListener('popstate', e => {
// ── Bootstrap ─────────────────────────────────────────────────────────────────
fetch('/package.json')
.then(r => r.json())
.then(pkg => { document.getElementById('nav-version').textContent = `v${pkg.version}`; })
.catch(() => {});
if (VERSION) {
const label = /^\d/.test(VERSION) ? `v${VERSION}` : VERSION;
document.getElementById('nav-version').textContent = label;
}
initDB().then(() => {
renderDashboard();
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'];
// 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 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() {
try {
const data = db.export(); // Uint8Array
let binary = '';
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);
}
async function api(path, options = {}) {
const res = await fetch(BASE + path, options);
if (res.status === 204) return null;
return res.json().then(data => ({ ok: res.ok, status: res.status, data }));
}
function loadFromStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
const binary = atob(stored);
const buf = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
return buf;
} catch (e) {
console.warn('catalyst: failed to load database from storage', e);
return null;
}
// ── Queries ───────────────────────────────────────────────────────────────────
async function getInstances(filters = {}) {
const params = new URLSearchParams(
Object.entries(filters).filter(([, v]) => v)
);
const res = await fetch(`${BASE}/instances?${params}`);
return res.json();
}
// ── Init ─────────────────────────────────────────────────────────────────────
async function initDB() {
const SQL = await initSqlJs({ locateFile: f => SQL_JS_CDN + f });
const saved = loadFromStorage();
if (saved) {
db = new SQL.Database(saved);
return;
async function getInstance(vmid) {
const res = await fetch(`${BASE}/instances/${vmid}`);
if (res.status === 404) return null;
return res.json();
}
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 ──────────────────────────────────────────────────────────────────
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';
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]);
async function getDistinctStacks() {
const res = await fetch(`${BASE}/instances/stacks`);
return res.json();
}
// ── Mutations ─────────────────────────────────────────────────────────────────
function createInstance(data) {
try {
db.run(
`INSERT INTO instances
(name, state, stack, vmid, atlas, argus, semaphore, patchmon, tailscale, andromeda, tailscale_ip, hardware_acceleration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[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]
);
saveToStorage();
async function createInstance(data) {
const { ok, data: body } = await api('/instances', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error creating instance' };
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function updateInstance(id, data) {
try {
db.run(
`UPDATE instances SET
name=?, state=?, stack=?, vmid=?,
atlas=?, argus=?, semaphore=?, patchmon=?,
tailscale=?, andromeda=?, tailscale_ip=?, hardware_acceleration=?,
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();
async function updateInstance(vmid, data) {
const { ok, data: body } = await api(`/instances/${vmid}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!ok) return { ok: false, error: body.error ?? body.errors?.[0] ?? 'error updating instance' };
return { ok: true };
} catch (e) {
return { ok: false, error: e.message };
}
}
function deleteInstance(id) {
db.run('DELETE FROM instances WHERE id = ?', [id]);
saveToStorage();
async function deleteInstance(vmid) {
await api(`/instances/${vmid}`, { method: 'DELETE' });
}

View File

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

1482
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,20 @@
{
"name": "catalyst",
"version": "1.0.2",
"version": "1.2.0",
"type": "module",
"scripts": {
"start": "node server/server.js",
"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",
"helmet": "^8.1.0"
},
"devDependencies": {
"vitest": "^2.0.0",
"sql.js": "^1.10.2",
"jsdom": "^25.0.0"
"jsdom": "^25.0.0",
"supertest": "^7.0.0",
"vitest": "^3.2.4"
}
}

150
server/db.js Normal file
View File

@@ -0,0 +1,150 @@
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 ──────────────────────────────────────────────────────────────────────
// Skipped in test environment — parallel Vitest workers would race to open
// the same file, causing "database is locked". _resetForTest() in beforeEach
// handles initialisation for every test worker using :memory: instead.
if (process.env.NODE_ENV !== 'test') {
const DB_PATH = process.env.DB_PATH ?? DEFAULT_PATH;
try {
init(DB_PATH);
} catch (e) {
console.error('[catalyst] fatal: could not open database at', DB_PATH);
console.error('[catalyst] ensure the data directory exists and is writable by the server process.');
console.error(e);
process.exit(1);
}
}

124
server/routes.js Normal file
View File

@@ -0,0 +1,124 @@
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(', ')}`);
const ip = (body.tailscale_ip ?? '').trim();
if (ip && !/^(\d{1,3}\.){3}\d{1,3}$/.test(ip))
errors.push('tailscale_ip must be a valid IPv4 address or empty');
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' });
console.error('POST /api/instances', e);
res.status(500).json({ error: 'internal server error' });
}
});
// 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' });
console.error('PUT /api/instances/:vmid', e);
res.status(500).json({ error: 'internal server error' });
}
});
// 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' });
try {
deleteInstance(vmid);
res.status(204).end();
} catch (e) {
console.error('DELETE /api/instances/:vmid', e);
res.status(500).json({ error: 'internal server error' });
}
});

51
server/server.js Normal file
View File

@@ -0,0 +1,51 @@
import express from 'express';
import helmet from 'helmet';
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(helmet({
contentSecurityPolicy: {
useDefaults: false, // explicit — upgrade-insecure-requests breaks HTTP deployments
directives: {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'"],
'script-src-attr': ["'unsafe-inline'"], // allow onclick handlers
'style-src': ["'self'", 'https://fonts.googleapis.com'],
},
},
}));
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}`));
}

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

@@ -0,0 +1,351 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import request from 'supertest'
import { app } from '../server/server.js'
import { _resetForTest } from '../server/db.js'
import * as dbModule 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)
})
})
// ── Static assets & SPA routing ───────────────────────────────────────────────
describe('static assets and SPA routing', () => {
it('serves index.html at root', async () => {
const res = await request(app).get('/')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves index.html for deep SPA routes (e.g. /instance/117)', async () => {
const res = await request(app).get('/instance/117')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/html/)
})
it('serves CSS with correct content-type (not sniffed as HTML)', async () => {
const res = await request(app).get('/css/app.css')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toMatch(/text\/css/)
})
it('does not set upgrade-insecure-requests in CSP (HTTP deployments must work)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
expect(csp).not.toContain('upgrade-insecure-requests')
})
it('allows inline event handlers in CSP (onclick attributes)', async () => {
const res = await request(app).get('/')
const csp = res.headers['content-security-policy'] ?? ''
// script-src-attr must not be 'none' — that blocks onclick handlers
expect(csp).not.toContain("script-src-attr 'none'")
})
it('index.html contains base href / for correct asset resolution on deep routes', async () => {
const res = await request(app).get('/')
expect(res.text).toContain('<base href="/">')
})
})
// ── Error handling — unexpected DB failures ───────────────────────────────────
const dbError = () => Object.assign(
new Error('attempt to write a readonly database'),
{ code: 'ERR_SQLITE_ERROR', errcode: 8 }
)
describe('error handling — unexpected DB failures', () => {
let consoleSpy
beforeEach(() => {
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('POST returns 500 with friendly message when DB throws unexpectedly', async () => {
vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() })
const res = await request(app).post('/api/instances').send(base)
expect(res.status).toBe(500)
expect(res.body).toEqual({ error: 'internal server error' })
})
it('POST logs the error with route context when DB throws unexpectedly', async () => {
vi.spyOn(dbModule, 'createInstance').mockImplementationOnce(() => { throw dbError() })
await request(app).post('/api/instances').send(base)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('POST /api/instances'),
expect.any(Error)
)
})
it('PUT returns 500 with friendly message when DB throws unexpectedly', async () => {
await request(app).post('/api/instances').send(base)
vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() })
const res = await request(app).put('/api/instances/100').send(base)
expect(res.status).toBe(500)
expect(res.body).toEqual({ error: 'internal server error' })
})
it('PUT logs the error with route context when DB throws unexpectedly', async () => {
await request(app).post('/api/instances').send(base)
vi.spyOn(dbModule, 'updateInstance').mockImplementationOnce(() => { throw dbError() })
await request(app).put('/api/instances/100').send(base)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('PUT /api/instances/:vmid'),
expect.any(Error)
)
})
it('DELETE returns 500 with friendly message when DB throws unexpectedly', async () => {
await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' })
vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() })
const res = await request(app).delete('/api/instances/100')
expect(res.status).toBe(500)
expect(res.body).toEqual({ error: 'internal server error' })
})
it('DELETE logs the error with route context when DB throws unexpectedly', async () => {
await request(app).post('/api/instances').send({ ...base, stack: 'development', state: 'testing' })
vi.spyOn(dbModule, 'deleteInstance').mockImplementationOnce(() => { throw dbError() })
await request(app).delete('/api/instances/100')
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('DELETE /api/instances/:vmid'),
expect.any(Error)
)
})
})

View File

@@ -1,250 +1,187 @@
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 = `
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 ─────────────────────────────────────────────────────────────────────
// ── getInstances ──────────────────────────────────────────────────────────────
describe('getInstances', () => {
it('returns empty array when no instances exist', () => {
expect(getInstances()).toEqual([])
})
it('returns empty array when table is empty', () => {
expect(getInstances()).toEqual([]);
});
it('returns all instances sorted by name', () => {
insert({ name: 'zebra', vmid: 1 })
insert({ name: 'alpha', vmid: 2 })
const result = getInstances()
expect(result).toHaveLength(2)
expect(result[0].name).toBe('alpha')
expect(result[1].name).toBe('zebra')
})
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 });
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();
expect(result[0].name).toBe('alpha');
expect(result[1].name).toBe('zebra');
});
it('filters by state', () => {
insert({ name: 'a', vmid: 1, state: 'deployed' })
insert({ name: 'b', vmid: 2, state: 'degraded' })
insert({ name: 'c', vmid: 3, state: 'testing' })
expect(getInstances({ state: 'deployed' })).toHaveLength(1)
expect(getInstances({ state: 'degraded' })).toHaveLength(1)
expect(getInstances({ state: 'testing' })).toHaveLength(1)
})
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 });
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 });
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: 'degraded' })).toHaveLength(1);
expect(getInstances({ state: 'testing' })).toHaveLength(1);
});
it('filters by stack', () => {
insert({ name: 'a', vmid: 1, stack: 'production' })
insert({ name: 'b', vmid: 2, stack: 'development' })
expect(getInstances({ stack: 'production' })).toHaveLength(1)
expect(getInstances({ stack: 'development' })).toHaveLength(1)
})
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 });
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: 'development' })).toHaveLength(1);
});
it('searches by name', () => {
insert({ name: 'plex', vmid: 1 })
insert({ name: 'gitea', vmid: 2 })
expect(getInstances({ search: 'ple' })).toHaveLength(1)
expect(getInstances({ search: 'ple' })[0].name).toBe('plex')
})
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 });
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' })[0].name).toBe('plex');
});
it('searches by vmid', () => {
insert({ name: 'a', vmid: 137 })
insert({ name: 'b', vmid: 200 })
expect(getInstances({ search: '137' })).toHaveLength(1)
})
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 });
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);
});
it('searches by stack', () => {
insert({ name: 'a', vmid: 1, stack: 'production' })
insert({ name: 'b', vmid: 2, stack: 'development' })
expect(getInstances({ search: 'prod' })).toHaveLength(1)
})
it('combines filters', () => {
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 });
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: 'plex', state: 'deployed' })).toHaveLength(1);
});
});
it('combines search and state filters', () => {
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([])
})
})
// ── getInstance ───────────────────────────────────────────────────────────────
describe('getInstance', () => {
it('returns the instance with the given vmid', () => {
insert({ name: 'plex', vmid: 117 })
const inst = getInstance(117)
expect(inst).not.toBeNull()
expect(inst.name).toBe('plex')
expect(inst.vmid).toBe(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);
expect(inst).not.toBeNull();
expect(inst.name).toBe('plex');
expect(inst.vmid).toBe(117);
});
it('returns null for an unknown vmid', () => {
expect(getInstance(999)).toBeNull()
})
})
it('returns null for unknown vmid', () => {
expect(getInstance(999)).toBeNull();
});
});
// ── getDistinctStacks ─────────────────────────────────────────────────────────
describe('getDistinctStacks', () => {
it('returns empty array when no instances exist', () => {
expect(getDistinctStacks()).toEqual([])
})
it('returns empty array when table is empty', () => {
expect(getDistinctStacks()).toEqual([]);
});
it('returns unique stacks sorted alphabetically', () => {
insert({ vmid: 1, stack: 'production' })
insert({ vmid: 2, stack: 'development' })
insert({ vmid: 3, stack: 'production' })
expect(getDistinctStacks()).toEqual(['development', '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 });
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 });
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']);
});
});
it('excludes blank stack values', () => {
insert({ vmid: 1, stack: '' })
insert({ vmid: 2, stack: 'production' })
expect(getDistinctStacks()).toEqual(['production'])
})
})
// ── createInstance ────────────────────────────────────────────────────────────
describe('createInstance', () => {
it('inserts a new instance', () => {
insert({ name: 'traefik', vmid: 100, stack: 'production', state: 'deployed' })
const inst = getInstance(100)
expect(inst.name).toBe('traefik')
expect(inst.stack).toBe('production')
expect(inst.state).toBe('deployed')
})
const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 };
it('inserts a new instance and sets timestamps', () => {
createInstance({ ...base, name: 'traefik', vmid: 100 });
const inst = getInstance(100);
expect(inst.name).toBe('traefik');
expect(inst.created_at).not.toBeNull();
expect(inst.updated_at).not.toBeNull();
});
it('stores service flags correctly', () => {
insert({ vmid: 1, atlas: 1, argus: 0, tailscale: 1, hardware_acceleration: 1 })
const inst = getInstance(1)
expect(inst.atlas).toBe(1)
expect(inst.argus).toBe(0)
expect(inst.tailscale).toBe(1)
expect(inst.hardware_acceleration).toBe(1)
})
createInstance({ ...base, name: 'plex', vmid: 1, atlas: 1, tailscale: 1, hardware_acceleration: 1 });
const inst = getInstance(1);
expect(inst.atlas).toBe(1);
expect(inst.argus).toBe(0);
expect(inst.tailscale).toBe(1);
expect(inst.hardware_acceleration).toBe(1);
});
it('rejects duplicate vmid', () => {
insert({ vmid: 100 })
expect(() => insert({ name: 'other', vmid: 100 })).toThrow()
})
createInstance({ ...base, name: 'a', vmid: 100 });
expect(() => createInstance({ ...base, name: 'b', vmid: 100 })).toThrow();
});
it('sets createdAt and updatedAt on insert', () => {
insert({ vmid: 1 })
const inst = getInstance(1)
expect(inst.createdAt).not.toBeNull()
expect(inst.updatedAt).not.toBeNull()
})
})
it('rejects invalid state', () => {
expect(() => createInstance({ ...base, name: 'a', vmid: 1, state: 'invalid' })).toThrow();
});
it('rejects invalid stack', () => {
expect(() => createInstance({ ...base, name: 'a', vmid: 1, stack: 'invalid' })).toThrow();
});
});
// ── updateInstance ────────────────────────────────────────────────────────────
describe('updateInstance', () => {
it('updates fields on an existing instance', () => {
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')
})
const base = { state: 'deployed', stack: 'production', atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 };
it('updates updatedAt on write', () => {
insert({ vmid: 1 })
const before = getInstance(1)
db.run(`UPDATE instances SET name=?, updatedAt=datetime('now') WHERE id=?`, ['updated', before.id])
const after = getInstance(1)
expect(after.updatedAt).not.toBeNull()
})
})
it('updates fields and refreshes updated_at', () => {
createInstance({ ...base, name: 'old', vmid: 100 });
updateInstance(100, { ...base, name: 'new', vmid: 100, state: 'degraded' });
const inst = getInstance(100);
expect(inst.name).toBe('new');
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', () => {
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', () => {
insert({ vmid: 1 })
const inst = getInstance(1)
db.run('DELETE FROM instances WHERE id = ?', [inst.id])
expect(getInstance(1)).toBeNull()
})
createInstance({ ...base, name: 'a', vmid: 1 });
deleteInstance(1);
expect(getInstance(1)).toBeNull();
});
it('only removes the targeted instance', () => {
insert({ name: 'a', vmid: 1 })
insert({ name: 'b', vmid: 2 })
const inst = getInstance(1)
db.run('DELETE FROM instances WHERE id = ?', [inst.id])
expect(getInstance(1)).toBeNull()
expect(getInstance(2)).not.toBeNull()
})
})
createInstance({ ...base, name: 'a', vmid: 1 });
createInstance({ ...base, name: 'b', vmid: 2 });
deleteInstance(1);
expect(getInstance(1)).toBeNull();
expect(getInstance(2)).not.toBeNull();
});
});
// ── Test environment boot isolation ───────────────────────────────────────────
describe('test environment boot isolation', () => {
it('vitest runs with NODE_ENV=test', () => {
// Vitest sets NODE_ENV=test automatically. This is the guard condition
// that prevents the boot init() from opening the real database file.
expect(process.env.NODE_ENV).toBe('test');
});
it('db module loads cleanly in parallel workers without locking the real db file', () => {
// Regression: the module-level init(DEFAULT_PATH) used to run unconditionally,
// causing "database is locked" when multiple test workers imported db.js at
// the same time. process.exit(1) then killed the worker mid-suite.
// Fix: boot init is skipped when NODE_ENV=test. _resetForTest() handles setup.
// Reaching this line proves the module loaded without calling process.exit.
expect(() => _resetForTest()).not.toThrow();
expect(getInstances()).toEqual([]);
});
});

View File

@@ -1,4 +1,7 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { join } from 'path'
// ── esc() ─────────────────────────────────────────────────────────────────────
// Mirrors the implementation in ui.js exactly (DOM-based).
@@ -111,3 +114,53 @@ describe('fmtDateFull', () => {
expect(fmtDateFull('')).toBe('—')
})
})
// ── versionLabel() ───────────────────────────────────────────────────────────
// Mirrors the logic in app.js — semver strings get a v prefix, dev strings don't.
function versionLabel(v) {
return /^\d/.test(v) ? `v${v}` : v
}
describe('version label formatting', () => {
it('prepends v for semver strings', () => {
expect(versionLabel('1.1.2')).toBe('v1.1.2')
expect(versionLabel('2.0.0')).toBe('v2.0.0')
})
it('does not prepend v for dev build strings', () => {
expect(versionLabel('dev-abc1234')).toBe('dev-abc1234')
})
})
// ── CSS regressions ───────────────────────────────────────────────────────────
const css = readFileSync(join(__dirname, '../css/app.css'), 'utf8')
describe('CSS regressions', () => {
it('.badge has text-align: center so state labels are not left-skewed on cards', () => {
// Regression: badges rendered left-aligned inside the card's flex-end column.
// Without text-align: center, short labels (e.g. "deployed") appear
// left-justified inside their pill rather than centred.
expect(css).toMatch(/\.badge\s*\{[^}]*text-align\s*:\s*center/s)
})
})
// ── CI workflow regressions ───────────────────────────────────────────────────
const ciYml = readFileSync(join(__dirname, '../.gitea/workflows/ci.yml'), 'utf8')
describe('CI workflow regressions', () => {
it('build-dev job passes BUILD_VERSION build arg', () => {
// Regression: dev image showed semver instead of dev-<sha> because
// BUILD_VERSION was never passed to docker build.
expect(ciYml).toContain('BUILD_VERSION')
})
it('short SHA is computed with git rev-parse, not $GITEA_SHA (which is empty)', () => {
// Regression: ${GITEA_SHA::7} expands to "" on Gitea runners — nav showed "dev-".
// git rev-parse --short HEAD works regardless of which env vars the runner sets.
expect(ciYml).toContain('git rev-parse --short HEAD')
expect(ciYml).not.toContain('GITEA_SHA')
})
})

View File

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