11 Commits

Author SHA1 Message Date
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 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 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
8 changed files with 66 additions and 8 deletions
+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');
});
}); });