21 Commits
Author SHA1 Message Date
joshandClaude Sonnet 4.6 9a81098b9d docs: move API reference to docs/api.md and add Jobs endpoints
CI / test (pull_request) Successful in 21s
CI / build-dev (pull_request) Has been skipped
Extracts the REST API section from README.md into a dedicated
docs/api.md. Adds documentation for the four Jobs endpoints
(GET/PUT /api/jobs, GET /api/jobs/:id, POST /api/jobs/:id/run)
which existed in code but were not yet documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 20:11:41 -04:00
josh 5db2e5fb0b Merge pull request '1.7.2' (#80) from dev into main
CI / test (push) Successful in 10s
Release / release (push) Successful in 26s
CI / build-dev (push) Has been skipped
Reviewed-on: #80
2026-06-06 09:50:15 -04:00
josh 5b3edbdfe5 Merge pull request 'chore: bump to version 1.7.2' (#79) from feat/cap-job-runs-history into dev
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 20s
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #79
2026-06-06 09:32:21 -04:00
josh 8edbeba2ec Merge branch 'dev' into feat/cap-job-runs-history
CI / test (pull_request) Successful in 8s
CI / build-dev (pull_request) Has been skipped
2026-06-06 09:31:18 -04:00
joshandClaude Opus 4.7 ca914b915b chore: bump to version 1.7.2
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 09:30:23 -04:00
josh 53fbcbe22c Merge pull request 'feat: cap job_runs history at last 10 per job' (#78) from feat/cap-job-runs-history into dev
CI / test (push) Successful in 19s
CI / build-dev (push) Successful in 21s
Reviewed-on: #78
2026-06-06 09:27:05 -04:00
joshandClaude Opus 4.7 e330119753 feat: cap job_runs history at last 10 per job
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
Tailscale, Patchmon, and Semaphore sync jobs all wrote into a shared
job_runs table with no retention. With default poll intervals of 15-60
minutes, history grew unbounded.

- Add pruneJobRuns(jobId) and pruneAllJobRuns() helpers.
- Prune after every completeJobRun() so new runs trim old ones.
- Prune once on init() to clean up existing over-cap rows.
- Prune in importJobs() so re-imported runs are also capped.
- Defensive LIMIT 10 in getJobRuns() for the read path.

No UI changes needed — _renderRunList already renders whatever the
server returns. No schema migration — only row deletions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:38:43 -04:00
josh 194cd3c175 Merge pull request 'fix: base64-encode Patchmon Basic auth credentials server-side' (#77) from dev into main
CI / test (push) Successful in 10s
Release / release (push) Successful in 23s
CI / build-dev (push) Has been skipped
Reviewed-on: #77
2026-05-30 19:04:27 -04:00
josh 72b8d60985 Merge pull request 'Merge pull request 'test: add failing tests for sort/order on GET /api/instances' (#71) from dev into main' (#76) from merge/main-into-dev-v1.7.1 into dev
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 15s
CI / build-dev (push) Successful in 15s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #76
2026-05-30 19:03:24 -04:00
josh 917fb26e05 Merge branch 'main' into dev — resolve 1.7.0/1.7.1 version overlap, keep 1.7.1
CI / test (pull_request) Successful in 11s
CI / build-dev (pull_request) Has been skipped
# Conflicts:
#	package.json
2026-05-30 19:00:49 -04:00
josh a28867b398 Merge pull request 'fix: base64-encode Patchmon Basic auth credentials server-side' (#74) from fix/patchmon-sync-basic-auth into dev
CI / test (push) Successful in 9s
CI / build-dev (push) Successful in 23s
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #74
2026-05-30 18:54:53 -04:00
joshandClaude Opus 4.7 ac71ea2c49 chore: bump to version 1.7.1
CI / test (pull_request) Successful in 19s
CI / build-dev (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:51:27 -04:00
joshandClaude Opus 4.7 efa1750cac fix: base64-encode Patchmon Basic auth credentials server-side
Patchmon's API uses standard RFC 7617 Basic auth — `Basic base64(token_key:token_secret)`. The handler was sending the api_token field verbatim, so it only worked if the user had manually base64-encoded the credential. After a Patchmon upgrade, the sync started returning HTML (the SPA, served when auth is rejected) and failing with "Unexpected token '<'" on JSON.parse.

Now: if the token contains ':' (raw key:secret), encode it server-side; otherwise pass through unchanged for backward compatibility. UI gets a placeholder hint showing the expected format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:33:41 -04:00
josh aa6e28d818 Merge pull request 'chore: bump to version 1.7.0' (#72) from chore/bump-v1.7.0 into main
CI / test (push) Successful in 13s
Release / release (push) Successful in 30s
CI / build-dev (push) Has been skipped
Reviewed-on: #72
2026-05-29 16:10:56 -04:00
josh 2cf797545c chore: bump to version 1.7.0
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-05-29 16:08:18 -04:00
josh a0381b12cc Merge pull request 'test: add failing tests for sort/order on GET /api/instances' (#71) from dev into main
CI / test (push) Successful in 16s
Release / release (push) Failing after 11s
CI / build-dev (push) Has been skipped
Reviewed-on: #71
2026-05-29 16:05:54 -04:00
josh 64eacb28d2 Merge pull request 'feat: accept partial bodies on PUT /api/instances/:vmid' (#70) from feat/partial-instance-updates into dev
CI / test (push) Successful in 11s
CI / build-dev (push) Successful in 38s
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #70
2026-05-29 16:03:44 -04:00
josh ea671535fc feat: accept partial bodies on PUT /api/instances/:vmid
CI / test (pull_request) Successful in 10s
CI / build-dev (pull_request) Has been skipped
Merge the request body onto the existing instance row before validating,
so external callers (n8n, scripts) can send only the fields they want to
change instead of GET-then-splat-then-PUT the full record.

Mirrors the partial-update pattern already used by PUT /api/jobs/:id.
Full-body PUTs (today's frontend) are unaffected.
2026-05-29 15:57:58 -04:00
josh fb3c6405c9 Merge pull request 'feat/sort-instances' (#69) from feat/sort-instances into dev
CI / test (push) Successful in 9s
CI / build-dev (push) Successful in 25s
Reviewed-on: #69
2026-03-29 08:46:15 -04:00
Josh WrightandClaude Sonnet 4.6 b6ca460ac6 feat: add sort by vmid, name, last created, last updated on dashboard
CI / test (pull_request) Successful in 9s
CI / build-dev (pull_request) Has been skipped
- GET /api/instances now accepts ?sort= (name|vmid|created_at|updated_at)
  and ?order= (asc|desc); invalid sort fields fall back to name asc
- Timestamp sorts use id as a tiebreaker (datetime() precision is 1 s)
- Toolbar gains a sort-field <select> and a ↑/↓ direction toggle button
- toggleSortDir() flips direction and re-fetches; state held in data-dir attr

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:26:46 -04:00
Josh WrightandClaude Sonnet 4.6 8312701147 test: add failing tests for sort/order on GET /api/instances
Tests cover:
- sort by vmid asc/desc
- sort by name desc
- sort by created_at asc/desc (id tiebreaker for same-second inserts)
- sort by updated_at asc/desc (id tiebreaker for same-second inserts)
- invalid sort field falls back to name asc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 08:25:53 -04:00
11 changed files with 490 additions and 182 deletions
+1 -170
View File
@@ -37,176 +37,7 @@ Open [http://localhost:3000](http://localhost:3000).
## REST API ## REST API
All endpoints are under `/api`. Request and response bodies are JSON. See **[docs/api.md](docs/api.md)** for the full API reference.
### Instances
#### `GET /api/instances`
Returns all instances sorted by name. All query parameters are optional.
| Parameter | Type | Description |
|---|---|---|
| `search` | string | Partial match on `name`, `vmid`, or `stack` |
| `state` | string | Exact match: `deployed`, `testing`, `degraded` |
| `stack` | string | Exact match: `production`, `development` |
```
GET /api/instances?search=plex&state=deployed
```
```json
[
{
"vmid": 117,
"name": "plex",
"state": "deployed",
"stack": "production",
"tailscale_ip": "100.64.0.1",
"atlas": 1, "argus": 1, "semaphore": 0,
"patchmon": 1, "tailscale": 1, "andromeda": 0,
"hardware_acceleration": 1,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-03-10T14:22:00"
}
]
```
---
#### `GET /api/instances/stacks`
Returns a sorted array of distinct stack names present in the registry.
```
GET /api/instances/stacks
→ ["development", "production"]
```
---
#### `GET /api/instances/:vmid`
Returns a single instance by VMID.
| Status | Condition |
|---|---|
| `200` | Instance found |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
---
#### `GET /api/instances/:vmid/history`
Returns the audit log for an instance — newest events first.
| Status | Condition |
|---|---|
| `200` | History returned (may be empty array) |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
```json
[
{
"id": 3,
"vmid": 117,
"field": "state",
"old_value": "testing",
"new_value": "deployed",
"changed_at": "2024-03-10T14:22:00"
},
{
"id": 1,
"vmid": 117,
"field": "created",
"old_value": null,
"new_value": null,
"changed_at": "2024-01-15T10:30:00"
}
]
```
---
#### `POST /api/instances`
Creates a new instance. Returns the created record.
| Status | Condition |
|---|---|
| `201` | Created successfully |
| `400` | Validation error — see `errors` array in response |
| `409` | VMID already exists |
**Request body:**
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | |
| `vmid` | integer | yes | Must be > 0 and unique |
| `state` | string | yes | `deployed`, `testing`, or `degraded` |
| `stack` | string | yes | `production` or `development` |
| `tailscale_ip` | string | no | Valid IPv4 or empty string |
| `atlas` | 0\|1 | no | |
| `argus` | 0\|1 | no | |
| `semaphore` | 0\|1 | no | |
| `patchmon` | 0\|1 | no | |
| `tailscale` | 0\|1 | no | |
| `andromeda` | 0\|1 | no | |
| `hardware_acceleration` | 0\|1 | no | |
---
#### `PUT /api/instances/:vmid`
Replaces all fields on an existing instance. Accepts the same body shape as `POST`. The `vmid` in the body may differ from the URL — this is how you change a VMID.
| Status | Condition |
|---|---|
| `200` | Updated successfully |
| `400` | Validation error |
| `404` | No instance with that VMID |
| `409` | New VMID conflicts with an existing instance |
---
#### `DELETE /api/instances/:vmid`
Deletes an instance. Only instances on the `development` stack may be deleted.
| Status | Condition |
|---|---|
| `204` | Deleted successfully |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
| `422` | Instance is on the `production` stack |
---
### Backup
#### `GET /api/export`
Downloads a JSON backup of all instances as a file attachment.
```json
{
"version": 1,
"exported_at": "2024-03-10T14:22:00.000Z",
"instances": [ ... ]
}
```
#### `POST /api/import`
Replaces all instances from a JSON backup. Validates every row before committing — if any row is invalid the entire import is rejected.
| Status | Condition |
|---|---|
| `200` | Import successful — returns `{ "imported": N }` |
| `400` | Body missing `instances` array, or validation errors |
--- ---
+283
View File
@@ -0,0 +1,283 @@
# Catalyst API Reference
All endpoints are under `/api`. Request and response bodies are JSON.
---
## Instances
#### `GET /api/instances`
Returns all instances sorted by name. All query parameters are optional.
| Parameter | Type | Description |
|---|---|---|
| `search` | string | Partial match on `name`, `vmid`, or `stack` |
| `state` | string | Exact match: `deployed`, `testing`, `degraded` |
| `stack` | string | Exact match: `production`, `development` |
```
GET /api/instances?search=plex&state=deployed
```
```json
[
{
"vmid": 117,
"name": "plex",
"state": "deployed",
"stack": "production",
"tailscale_ip": "100.64.0.1",
"atlas": 1, "argus": 1, "semaphore": 0,
"patchmon": 1, "tailscale": 1, "andromeda": 0,
"hardware_acceleration": 1,
"created_at": "2024-01-15T10:30:00",
"updated_at": "2024-03-10T14:22:00"
}
]
```
---
#### `GET /api/instances/stacks`
Returns a sorted array of distinct stack names present in the registry.
```
GET /api/instances/stacks
→ ["development", "production"]
```
---
#### `GET /api/instances/:vmid`
Returns a single instance by VMID.
| Status | Condition |
|---|---|
| `200` | Instance found |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
---
#### `GET /api/instances/:vmid/history`
Returns the audit log for an instance — newest events first.
| Status | Condition |
|---|---|
| `200` | History returned (may be empty array) |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
```json
[
{
"id": 3,
"vmid": 117,
"field": "state",
"old_value": "testing",
"new_value": "deployed",
"changed_at": "2024-03-10T14:22:00"
},
{
"id": 1,
"vmid": 117,
"field": "created",
"old_value": null,
"new_value": null,
"changed_at": "2024-01-15T10:30:00"
}
]
```
---
#### `POST /api/instances`
Creates a new instance. Returns the created record.
| Status | Condition |
|---|---|
| `201` | Created successfully |
| `400` | Validation error — see `errors` array in response |
| `409` | VMID already exists |
**Request body:**
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | |
| `vmid` | integer | yes | Must be > 0 and unique |
| `state` | string | yes | `deployed`, `testing`, or `degraded` |
| `stack` | string | yes | `production` or `development` |
| `tailscale_ip` | string | no | Valid IPv4 or empty string |
| `atlas` | 0\|1 | no | |
| `argus` | 0\|1 | no | |
| `semaphore` | 0\|1 | no | |
| `patchmon` | 0\|1 | no | |
| `tailscale` | 0\|1 | no | |
| `andromeda` | 0\|1 | no | |
| `hardware_acceleration` | 0\|1 | no | |
---
#### `PUT /api/instances/:vmid`
Replaces all fields on an existing instance. Accepts the same body shape as `POST`. The `vmid` in the body may differ from the URL — this is how you change a VMID.
| Status | Condition |
|---|---|
| `200` | Updated successfully |
| `400` | Validation error |
| `404` | No instance with that VMID |
| `409` | New VMID conflicts with an existing instance |
---
#### `DELETE /api/instances/:vmid`
Deletes an instance. Only instances on the `development` stack may be deleted.
| Status | Condition |
|---|---|
| `204` | Deleted successfully |
| `400` | VMID is not a valid integer |
| `404` | No instance with that VMID |
| `422` | Instance is on the `production` stack |
---
## Jobs
Jobs are background sync tasks that keep instance data up to date by pulling from external services (Tailscale, Patchmon, Semaphore). Each job runs on a configurable interval and can also be triggered on demand.
Sensitive config fields (`api_key`, `api_token`) are always returned as `**REDACTED**`. Send `**REDACTED**` back in a `PUT` to preserve the stored value unchanged.
#### `GET /api/jobs`
Returns all jobs.
```json
[
{
"id": 1,
"key": "tailscale_sync",
"name": "Tailscale Sync",
"description": "Syncs Tailscale device status and IPs",
"enabled": 1,
"schedule": 15,
"config": { "api_key": "**REDACTED**", "tailnet": "example.com" },
"last_run_at": "2024-03-10T14:22:00",
"last_status": "success"
}
]
```
---
#### `GET /api/jobs/:id`
Returns a single job and its last 10 run records, newest first.
| Status | Condition |
|---|---|
| `200` | Job found |
| `400` | ID is not a valid integer |
| `404` | No job with that ID |
```json
{
"id": 1,
"key": "tailscale_sync",
"name": "Tailscale Sync",
"enabled": 1,
"schedule": 15,
"config": { "api_key": "**REDACTED**", "tailnet": "example.com" },
"runs": [
{
"id": 42,
"started_at": "2024-03-10T14:22:00",
"ended_at": "2024-03-10T14:22:01",
"status": "success",
"result": "3 updated of 12"
}
]
}
```
---
#### `PUT /api/jobs/:id`
Updates a job's enabled state, schedule interval, or configuration. All fields are optional — omitted fields are left unchanged. Config is merged with the existing config (partial updates are safe).
| Status | Condition |
|---|---|
| `200` | Updated successfully — returns the updated job |
| `400` | ID is not a valid integer |
| `404` | No job with that ID |
**Request body:**
| Field | Type | Notes |
|---|---|---|
| `enabled` | boolean | `true` to enable, `false` to disable |
| `schedule` | integer | Interval in minutes (default: 15) |
| `config` | object | Merged with existing config — see per-job fields below |
**Config fields by job key:**
| Key | Field | Notes |
|---|---|---|
| `tailscale_sync` | `api_key` | Tailscale API key |
| `tailscale_sync` | `tailnet` | Tailnet name (e.g. `example.com`) |
| `tailscale_sync` | `run_on_create` | `true` to also run when a new instance is created |
| `patchmon_sync` | `api_url` | Full URL to the Patchmon hosts endpoint |
| `patchmon_sync` | `api_token` | `key:secret` credential or pre-encoded base64 |
| `semaphore_sync` | `api_url` | Full URL to the Semaphore inventory endpoint |
| `semaphore_sync` | `api_token` | Semaphore API token |
---
#### `POST /api/jobs/:id/run`
Triggers a job to run immediately. Blocks until the job completes and returns the result. The run is recorded in the job's history regardless of outcome.
| Status | Condition |
|---|---|
| `200` | Job completed successfully |
| `400` | ID is not a valid integer |
| `404` | No job with that ID |
| `500` | Job handler threw an error (run is logged with `status: "error"`) |
```json
{ "summary": "3 updated of 12" }
```
---
## Backup
#### `GET /api/export`
Downloads a JSON backup of all instances as a file attachment.
```json
{
"version": 1,
"exported_at": "2024-03-10T14:22:00.000Z",
"instances": [ ... ]
}
```
#### `POST /api/import`
Replaces all instances from a JSON backup. Validates every row before committing — if any row is invalid the entire import is rejected.
| Status | Condition |
|---|---|
| `200` | Import successful — returns `{ "imported": N }` |
| `400` | Body missing `instances` array, or validation errors |
+7
View File
@@ -48,6 +48,13 @@
<select id="filter-stack" onchange="filterInstances()"> <select id="filter-stack" onchange="filterInstances()">
<option value="">all stacks</option> <option value="">all stacks</option>
</select> </select>
<select id="sort-field" onchange="filterInstances()">
<option value="name">name</option>
<option value="vmid">vmid</option>
<option value="updated_at">last updated</option>
<option value="created_at">last created</option>
</select>
<button id="sort-dir" class="btn" data-dir="asc" onclick="toggleSortDir()" title="reverse sort"></button>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="btn primary" onclick="openNewModal()">+ new instance</button> <button class="btn primary" onclick="openNewModal()">+ new instance</button>
</div> </div>
+13 -1
View File
@@ -100,11 +100,21 @@ function setStateFilter(state) {
filterInstances(); filterInstances();
} }
function toggleSortDir() {
const btn = document.getElementById('sort-dir');
const next = btn.dataset.dir === 'asc' ? 'desc' : 'asc';
btn.dataset.dir = next;
btn.textContent = next === 'asc' ? '↑' : '↓';
filterInstances();
}
async 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 = await getInstances({ search, state, stack }); const sort = document.getElementById('sort-field').value;
const order = document.getElementById('sort-dir').dataset.dir || 'asc';
const instances = await getInstances({ search, state, stack, sort, order });
const grid = document.getElementById('instance-grid'); const grid = document.getElementById('instance-grid');
if (!instances.length) { if (!instances.length) {
@@ -529,6 +539,7 @@ function _renderJobConfigFields(key, cfg) {
</div>`; </div>`;
if (key === 'patchmon_sync' || key === 'semaphore_sync') { if (key === 'patchmon_sync' || key === 'semaphore_sync') {
const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)'; const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)';
const tokenPlaceholder = key === 'patchmon_sync' ? 'token_key:token_secret' : '';
return ` return `
<div class="form-group"> <div class="form-group">
<label class="form-label" for="job-cfg-api-url">API URL</label> <label class="form-label" for="job-cfg-api-url">API URL</label>
@@ -538,6 +549,7 @@ function _renderJobConfigFields(key, cfg) {
<div class="form-group"> <div class="form-group">
<label class="form-label" for="job-cfg-api-token">${label}</label> <label class="form-label" for="job-cfg-api-token">${label}</label>
<input class="form-input" id="job-cfg-api-token" type="password" <input class="form-input" id="job-cfg-api-token" type="password"
placeholder="${tokenPlaceholder}"
value="${esc(cfg.api_token ?? '')}"> value="${esc(cfg.api_token ?? '')}">
</div>`; </div>`;
} }
+1 -1
View File
@@ -1 +1 @@
const VERSION = "1.5.0"; const VERSION = "1.7.2";
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.6.0", "version": "1.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
+30 -3
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const DEFAULT_PATH = join(__dirname, '../data/catalyst.db'); const DEFAULT_PATH = join(__dirname, '../data/catalyst.db');
const JOB_RUN_LIMIT = 10;
let db; let db;
function init(path) { function init(path) {
@@ -17,7 +19,7 @@ function init(path) {
db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA synchronous = NORMAL'); db.exec('PRAGMA synchronous = NORMAL');
createSchema(); createSchema();
if (path !== ':memory:') { seed(); seedJobs(); } if (path !== ':memory:') { seed(); seedJobs(); pruneAllJobRuns(); }
} }
function createSchema() { function createSchema() {
@@ -133,6 +135,8 @@ function seedJobs() {
// ── Queries ─────────────────────────────────────────────────────────────────── // ── Queries ───────────────────────────────────────────────────────────────────
const VALID_SORTS = ['name', 'vmid', 'updated_at', 'created_at'];
export function getInstances(filters = {}) { export function getInstances(filters = {}) {
const parts = ['SELECT * FROM instances WHERE 1=1']; const parts = ['SELECT * FROM instances WHERE 1=1'];
const params = {}; const params = {};
@@ -142,7 +146,11 @@ export function getInstances(filters = {}) {
} }
if (filters.state) { parts.push('AND state = @state'); params.state = filters.state; } if (filters.state) { parts.push('AND state = @state'); params.state = filters.state; }
if (filters.stack) { parts.push('AND stack = @stack'); params.stack = filters.stack; } if (filters.stack) { parts.push('AND stack = @stack'); params.stack = filters.stack; }
parts.push('ORDER BY name ASC'); const sortField = VALID_SORTS.includes(filters.sort) ? filters.sort : 'name';
const sortOrder = filters.order === 'desc' ? 'DESC' : 'ASC';
// id is a stable tiebreaker for timestamp fields (datetime precision is 1 s)
const tiebreaker = (sortField === 'created_at' || sortField === 'updated_at') ? `, id ${sortOrder}` : '';
parts.push(`ORDER BY ${sortField} ${sortOrder}${tiebreaker}`);
return db.prepare(parts.join(' ')).all(params); return db.prepare(parts.join(' ')).all(params);
} }
@@ -261,6 +269,7 @@ export function importJobs(jobRows, jobRunRows = []) {
`); `);
for (const r of jobRunRows) insertRun.run(r); for (const r of jobRunRows) insertRun.run(r);
} }
pruneAllJobRuns();
db.exec('COMMIT'); db.exec('COMMIT');
} }
@@ -320,10 +329,28 @@ export function completeJobRun(runId, status, result) {
db.prepare(` db.prepare(`
UPDATE job_runs SET ended_at=strftime('%Y-%m-%dT%H:%M:%f', 'now'), status=@status, result=@result WHERE id=@id UPDATE job_runs SET ended_at=strftime('%Y-%m-%dT%H:%M:%f', 'now'), status=@status, result=@result WHERE id=@id
`).run({ id: runId, status, result }); `).run({ id: runId, status, result });
const row = db.prepare('SELECT job_id FROM job_runs WHERE id = ?').get(runId);
if (row) pruneJobRuns(row.job_id);
} }
export function getJobRuns(jobId) { export function getJobRuns(jobId) {
return db.prepare('SELECT * FROM job_runs WHERE job_id = ? ORDER BY id DESC').all(jobId); return db.prepare(`SELECT * FROM job_runs WHERE job_id = ? ORDER BY id DESC LIMIT ${JOB_RUN_LIMIT}`).all(jobId);
}
function pruneJobRuns(jobId) {
db.prepare(`
DELETE FROM job_runs
WHERE job_id = ?
AND id NOT IN (
SELECT id FROM job_runs WHERE job_id = ? ORDER BY id DESC LIMIT ?
)
`).run(jobId, jobId, JOB_RUN_LIMIT);
}
function pruneAllJobRuns() {
for (const j of db.prepare('SELECT id FROM jobs').all()) {
pruneJobRuns(j.id);
}
} }
// ── Test helpers ────────────────────────────────────────────────────────────── // ── Test helpers ──────────────────────────────────────────────────────────────
+7 -1
View File
@@ -41,8 +41,14 @@ async function patchmonSyncHandler(cfg) {
const { api_url, api_token } = cfg; const { api_url, api_token } = cfg;
if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token'); if (!api_url || !api_token) throw new Error('Patchmon not configured — set API URL and token');
// Accept raw "key:secret" (recommended) or a pre-encoded base64 string.
// ":" cannot appear in a valid base64 string, so it's a reliable discriminator.
const credential = api_token.includes(':')
? Buffer.from(api_token).toString('base64')
: api_token;
const res = await fetch(api_url, { const res = await fetch(api_url, {
headers: { Authorization: `Basic ${api_token}` }, headers: { Authorization: `Basic ${credential}` },
}); });
if (!res.ok) throw new Error(`Patchmon API ${res.status}`); if (!res.ok) throw new Error(`Patchmon API ${res.status}`);
+9 -5
View File
@@ -69,8 +69,8 @@ router.get('/instances/stacks', (_req, res) => {
// GET /api/instances // GET /api/instances
router.get('/instances', (req, res) => { router.get('/instances', (req, res) => {
const { search, state, stack } = req.query; const { search, state, stack, sort, order } = req.query;
res.json(getInstances({ search, state, stack })); res.json(getInstances({ search, state, stack, sort, order }));
}); });
// GET /api/instances/:vmid/history // GET /api/instances/:vmid/history
@@ -112,13 +112,17 @@ router.post('/instances', (req, res) => {
router.put('/instances/:vmid', (req, res) => { router.put('/instances/:vmid', (req, res) => {
const vmid = parseInt(req.params.vmid, 10); const vmid = parseInt(req.params.vmid, 10);
if (!vmid) return res.status(400).json({ error: 'invalid vmid' }); 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); const existing = getInstance(vmid);
if (!existing) return res.status(404).json({ error: 'instance not found' });
const merged = { ...existing, ...(req.body ?? {}) };
const errors = validate(merged);
if (errors.length) return res.status(400).json({ errors }); if (errors.length) return res.status(400).json({ errors });
try { try {
const data = normalise(req.body); const data = normalise(merged);
updateInstance(vmid, data); updateInstance(vmid, data);
res.json(getInstance(data.vmid)); res.json(getInstance(data.vmid));
} catch (e) { } catch (e) {
+60
View File
@@ -74,6 +74,54 @@ describe('GET /api/instances', () => {
expect(res.body).toHaveLength(1) expect(res.body).toHaveLength(1)
expect(res.body[0].name).toBe('plex') expect(res.body[0].name).toBe('plex')
}) })
it('sorts by vmid ascending', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 200, name: 'b' })
await request(app).post('/api/instances').send({ ...base, vmid: 100, name: 'a' })
const res = await request(app).get('/api/instances?sort=vmid&order=asc')
expect(res.body[0].vmid).toBe(100)
expect(res.body[1].vmid).toBe(200)
})
it('sorts by vmid descending', 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).get('/api/instances?sort=vmid&order=desc')
expect(res.body[0].vmid).toBe(200)
expect(res.body[1].vmid).toBe(100)
})
it('sorts by name descending', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'alpha' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'zebra' })
const res = await request(app).get('/api/instances?sort=name&order=desc')
expect(res.body[0].name).toBe('zebra')
expect(res.body[1].name).toBe('alpha')
})
it('sorts by created_at desc — id tiebreaker preserves insertion order', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'first' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'second' })
const res = await request(app).get('/api/instances?sort=created_at&order=desc')
expect(res.body[0].name).toBe('second') // id=2 before id=1
expect(res.body[1].name).toBe('first')
})
it('sorts by updated_at desc — id tiebreaker preserves insertion order', async () => {
await request(app).post('/api/instances').send({ ...base, vmid: 1, name: 'a' })
await request(app).post('/api/instances').send({ ...base, vmid: 2, name: 'b' })
const res = await request(app).get('/api/instances?sort=updated_at&order=desc')
expect(res.body[0].name).toBe('b') // id=2 before id=1
expect(res.body[1].name).toBe('a')
})
it('ignores invalid sort field and falls back to name asc', 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?sort=bad_field')
expect(res.status).toBe(200)
expect(res.body[0].name).toBe('alpha')
})
}) })
// ── GET /api/instances/stacks ───────────────────────────────────────────────── // ── GET /api/instances/stacks ─────────────────────────────────────────────────
@@ -209,6 +257,18 @@ describe('PUT /api/instances/:vmid', () => {
const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 }) const res = await request(app).put('/api/instances/100').send({ ...base, vmid: 200 })
expect(res.status).toBe(409) expect(res.status).toBe(409)
}) })
it('accepts a partial body and preserves unspecified fields', async () => {
await request(app).post('/api/instances').send({ ...base, atlas: 1, tailscale_ip: '100.64.0.1' })
const res = await request(app).put('/api/instances/100').send({ state: 'degraded' })
expect(res.status).toBe(200)
expect(res.body.state).toBe('degraded')
expect(res.body.name).toBe(base.name)
expect(res.body.vmid).toBe(100)
expect(res.body.stack).toBe(base.stack)
expect(res.body.atlas).toBe(1)
expect(res.body.tailscale_ip).toBe('100.64.0.1')
})
}) })
// ── DELETE /api/instances/:vmid ─────────────────────────────────────────────── // ── DELETE /api/instances/:vmid ───────────────────────────────────────────────
+78
View File
@@ -58,6 +58,71 @@ describe('getInstances', () => {
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 }); 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); expect(getInstances({ search: 'plex', state: 'deployed' })).toHaveLength(1);
}); });
it('sorts by vmid ascending', () => {
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 });
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 100, atlas: 0, argus: 0, semaphore: 0, patchmon: 0, tailscale: 0, andromeda: 0, tailscale_ip: '', hardware_acceleration: 0 });
const result = getInstances({ sort: 'vmid' });
expect(result[0].vmid).toBe(100);
expect(result[1].vmid).toBe(200);
});
it('sorts by vmid descending', () => {
createInstance({ name: 'a', state: 'deployed', stack: 'production', vmid: 100, 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 });
const result = getInstances({ sort: 'vmid', order: 'desc' });
expect(result[0].vmid).toBe(200);
expect(result[1].vmid).toBe(100);
});
it('sorts by name descending', () => {
createInstance({ name: 'alpha', 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: 'zebra', 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({ sort: 'name', order: 'desc' });
expect(result[0].name).toBe('zebra');
expect(result[1].name).toBe('alpha');
});
it('sorts by created_at asc — id is tiebreaker when timestamps are equal (same second)', () => {
// datetime('now') has second precision; rapid inserts share the same timestamp.
// The implementation uses id ASC as secondary sort so insertion order is preserved.
createInstance({ name: 'first', 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: 'second', 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({ sort: 'created_at', order: 'asc' });
expect(result[0].name).toBe('first'); // id=1 before id=2
expect(result[1].name).toBe('second');
});
it('sorts by created_at desc — id is tiebreaker when timestamps are equal (same second)', () => {
createInstance({ name: 'first', 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: 'second', 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({ sort: 'created_at', order: 'desc' });
expect(result[0].name).toBe('second'); // id=2 before id=1
expect(result[1].name).toBe('first');
});
it('sorts by updated_at asc — id is tiebreaker when timestamps are equal (same second)', () => {
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: '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({ sort: 'updated_at', order: 'asc' });
expect(result[0].name).toBe('a'); // id=1 before id=2
expect(result[1].name).toBe('b');
});
it('sorts by updated_at desc — id is tiebreaker when timestamps are equal (same second)', () => {
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: '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({ sort: 'updated_at', order: 'desc' });
expect(result[0].name).toBe('b'); // id=2 before id=1
expect(result[1].name).toBe('a');
});
it('falls back to name asc for an invalid sort field', () => {
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({ sort: 'injected; DROP TABLE instances--' });
expect(result[0].name).toBe('alpha');
});
}); });
// ── getInstance ─────────────────────────────────────────────────────────────── // ── getInstance ───────────────────────────────────────────────────────────────
@@ -385,4 +450,17 @@ describe('job_runs', () => {
expect(runs[0].id).toBe(r2); expect(runs[0].id).toBe(r2);
expect(runs[1].id).toBe(r1); expect(runs[1].id).toBe(r1);
}); });
it('caps history at the last 10 runs per job', () => {
createJob(baseJob);
const id = getJobs()[0].id;
for (let i = 0; i < 15; i++) {
const runId = createJobRun(id);
completeJobRun(runId, 'success', `run ${i}`);
}
const runs = getJobRuns(id);
expect(runs).toHaveLength(10);
expect(runs[0].result).toBe('run 14');
expect(runs[9].result).toBe('run 5');
});
}); });