Author SHA1 Message Date
josh 27c18aac26 Merge pull request 'Feat/api docs' (#81) from feat/api-docs into dev
CI / test (push) Successful in 16m25s
CI / build-dev (push) Successful in 50s
Reviewed-on: #81
2026-07-04 20:15:07 -04:00
joshandClaude Sonnet 4.6 7f6ce42d9a docs: move API reference to docs/api.md and add Jobs endpoints
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:15:07 -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 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 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
10 changed files with 350 additions and 178 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 |
+2
View File
@@ -539,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>
@@ -548,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.7.0", "version": "1.7.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",
+23 -2
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() {
@@ -267,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');
} }
@@ -326,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}`);
+7 -3
View File
@@ -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) {
+12
View File
@@ -257,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 ───────────────────────────────────────────────
+13
View File
@@ -450,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');
});
}); });