18 Commits

Author SHA1 Message Date
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
josh 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
josh 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
josh 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
josh 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 Wright 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 Wright 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
9 changed files with 206 additions and 12 deletions
+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');
});
}); });