From 817fdaef135dfb8ad25f7f1e384e87fc0e773785 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:00:45 -0400 Subject: [PATCH 1/9] feat: run jobs on instance creation when run_on_create is enabled Jobs with run_on_create=true in their config fire automatically after a new instance is created. Runs fire-and-forget so they don't delay the 201 response. Option exposed as a checkbox in each job's detail panel. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 17 +++++++++++++---- server/jobs.js | 7 +++++++ server/routes.js | 3 ++- tests/api.test.js | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/js/ui.js b/js/ui.js index 7156fc5..a7ddd7f 100644 --- a/js/ui.js +++ b/js/ui.js @@ -463,6 +463,13 @@ async function loadJobDetail(jobId) { +
+ +
${_renderJobConfigFields(job.key, cfg)}
@@ -521,10 +528,12 @@ async function saveJobDetail(jobId) { const apiKey = document.getElementById('job-cfg-api-key'); const apiUrl = document.getElementById('job-cfg-api-url'); const apiToken = document.getElementById('job-cfg-api-token'); - if (tailnet) cfg.tailnet = tailnet.value.trim(); - if (apiKey) cfg.api_key = apiKey.value; - if (apiUrl) cfg.api_url = apiUrl.value.trim(); - if (apiToken) cfg.api_token = apiToken.value; + if (tailnet) cfg.tailnet = tailnet.value.trim(); + if (apiKey) cfg.api_key = apiKey.value; + if (apiUrl) cfg.api_url = apiUrl.value.trim(); + if (apiToken) cfg.api_token = apiToken.value; + const runOnCreate = document.getElementById('job-run-on-create'); + if (runOnCreate) cfg.run_on_create = runOnCreate.checked; const res = await fetch(`/api/jobs/${jobId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/server/jobs.js b/server/jobs.js index cfcb20e..f6b1a59 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -129,6 +129,13 @@ export async function runJob(jobId) { const _intervals = new Map(); +export async function runJobsOnCreate() { + for (const job of getJobs()) { + const cfg = JSON.parse(job.config || '{}'); + if (cfg.run_on_create) runJob(job.id).catch(e => console.error(`runJobsOnCreate job ${job.id}:`, e)); + } +} + export function restartJobs() { for (const iv of _intervals.values()) clearInterval(iv); _intervals.clear(); diff --git a/server/routes.js b/server/routes.js index eed881c..fba5552 100644 --- a/server/routes.js +++ b/server/routes.js @@ -5,7 +5,7 @@ import { getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, getAllJobs, getAllJobRuns, importJobs, } from './db.js'; -import { runJob, restartJobs } from './jobs.js'; +import { runJob, restartJobs, runJobsOnCreate } from './jobs.js'; export const router = Router(); @@ -102,6 +102,7 @@ router.post('/instances', (req, res) => { createInstance(data); const created = getInstance(data.vmid); res.status(201).json(created); + runJobsOnCreate().catch(() => {}); } catch (e) { handleDbError('POST /api/instances', e, res); } diff --git a/tests/api.test.js b/tests/api.test.js index 6b7e652..5373d62 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -623,6 +623,25 @@ describe('POST /api/jobs/:id/run', () => { expect(res.status).toBe(500) }) + it('run_on_create: triggers matching jobs when an instance is created', async () => { + createJob({ ...testJob, config: JSON.stringify({ api_key: 'k', tailnet: 't', run_on_create: true }) }) + const id = (await request(app).get('/api/jobs')).body[0].id + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ devices: [] }) })) + await request(app).post('/api/instances').send(base) + await new Promise(r => setImmediate(r)) + const detail = await request(app).get(`/api/jobs/${id}`) + expect(detail.body.runs).toHaveLength(1) + expect(detail.body.runs[0].status).toBe('success') + }) + + it('run_on_create: does not trigger jobs without the flag', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id + await request(app).post('/api/instances').send(base) + await new Promise(r => setImmediate(r)) + expect((await request(app).get(`/api/jobs/${id}`)).body.runs).toHaveLength(0) + }) + it('semaphore_sync: parses ansible inventory and updates instances', async () => { const semaphoreJob = { key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test', From 8f35724bde471f3b6401992669f2680b4df88a1b Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:09:32 -0400 Subject: [PATCH 2/9] fix: queue on-create jobs sequentially and fix history ordering runJobsOnCreate now awaits each job before starting the next, ensuring they don't stomp each other's DB writes in parallel. getInstanceHistory changed to ORDER BY changed_at ASC, id ASC so the creation event (lowest id) is always first regardless of same-second timestamps. Co-Authored-By: Claude Sonnet 4.6 --- server/db.js | 2 +- server/jobs.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/db.js b/server/db.js index 52ef555..2a4b6fa 100644 --- a/server/db.js +++ b/server/db.js @@ -227,7 +227,7 @@ export function importInstances(rows, historyRows = []) { export function getInstanceHistory(vmid) { return db.prepare( - 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at DESC' + 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at ASC, id ASC' ).all(vmid); } diff --git a/server/jobs.js b/server/jobs.js index f6b1a59..dc61d1f 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -132,7 +132,9 @@ const _intervals = new Map(); export async function runJobsOnCreate() { for (const job of getJobs()) { const cfg = JSON.parse(job.config || '{}'); - if (cfg.run_on_create) runJob(job.id).catch(e => console.error(`runJobsOnCreate job ${job.id}:`, e)); + if (cfg.run_on_create) { + try { await runJob(job.id); } catch (e) { console.error(`runJobsOnCreate job ${job.id}:`, e); } + } } } From 76d2bffb4f8bb78f1e50092f59fa017feb17b637 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:14:53 -0400 Subject: [PATCH 3/9] fix: initialize jobs nav dot on every page load Previously the dot only updated when visiting the Jobs page. Now a jobs fetch runs at bootstrap so the dot reflects status immediately on any page, including after a hard refresh. Co-Authored-By: Claude Sonnet 4.6 --- js/app.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/app.js b/js/app.js index e389242..23a9a3c 100644 --- a/js/app.js +++ b/js/app.js @@ -53,4 +53,6 @@ if (VERSION) { document.getElementById('nav-version').textContent = label; } +fetch('/api/jobs').then(r => r.json()).then(_updateJobsNavDot).catch(() => {}); + handleRoute(); From 7999f46ca26cab42d8eb27f1a5432452b95cf515 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:19:42 -0400 Subject: [PATCH 4/9] fix: millisecond precision timestamps and correct history ordering datetime('now') only stores to the second, making same-second events indistinguishable. Switched all instance_history and job_runs writes to strftime('%Y-%m-%dT%H:%M:%f', 'now') for millisecond precision. Reverted getInstanceHistory to ORDER BY changed_at DESC, id DESC so newest events appear at the top and instance creation (lowest id, earliest timestamp) is always at the bottom. Co-Authored-By: Claude Sonnet 4.6 --- server/db.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/db.js b/server/db.js index 2a4b6fa..fc7720e 100644 --- a/server/db.js +++ b/server/db.js @@ -173,7 +173,8 @@ export function createInstance(data) { @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) `).run(data); db.prepare( - `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, 'created', NULL, NULL)` + `INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at) + VALUES (?, 'created', NULL, NULL, strftime('%Y-%m-%dT%H:%M:%f', 'now'))` ).run(data.vmid); } @@ -184,12 +185,13 @@ export function updateInstance(vmid, data) { name=@name, state=@state, stack=@stack, vmid=@newVmid, atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon, tailscale=@tailscale, andromeda=@andromeda, tailscale_ip=@tailscale_ip, - hardware_acceleration=@hardware_acceleration, updated_at=datetime('now') + hardware_acceleration=@hardware_acceleration, updated_at=strftime('%Y-%m-%dT%H:%M:%f', 'now') WHERE vmid=@vmid `).run({ ...data, newVmid: data.vmid, vmid }); const newVmid = data.vmid; const insertEvt = db.prepare( - `INSERT INTO instance_history (vmid, field, old_value, new_value) VALUES (?, ?, ?, ?)` + `INSERT INTO instance_history (vmid, field, old_value, new_value, changed_at) + VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))` ); for (const field of HISTORY_FIELDS) { const oldVal = String(old[field] ?? ''); @@ -227,7 +229,7 @@ export function importInstances(rows, historyRows = []) { export function getInstanceHistory(vmid) { return db.prepare( - 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at ASC, id ASC' + 'SELECT * FROM instance_history WHERE vmid = ? ORDER BY changed_at DESC, id DESC' ).all(vmid); } @@ -309,12 +311,14 @@ export function updateJob(id, { enabled, schedule, config }) { } export function createJobRun(jobId) { - return Number(db.prepare('INSERT INTO job_runs (job_id) VALUES (?)').run(jobId).lastInsertRowid); + return Number(db.prepare( + `INSERT INTO job_runs (job_id, started_at) VALUES (?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))` + ).run(jobId).lastInsertRowid); } export function completeJobRun(runId, status, result) { db.prepare(` - UPDATE job_runs SET ended_at=datetime('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 }); } From ddd528a682e5cf476135cab8bb02db2cf36c4a2a Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:25:26 -0400 Subject: [PATCH 5/9] feat: auto-refresh UI after on-create jobs complete After creating an instance, if any jobs have run_on_create enabled, the client polls /api/jobs every 500ms until each relevant job has a new completed run (tracked via last_run_id baseline). The dashboard or detail page then refreshes automatically. 30s timeout as a safety net if a job hangs. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/js/ui.js b/js/ui.js index a7ddd7f..b8e42a0 100644 --- a/js/ui.js +++ b/js/ui.js @@ -298,6 +298,8 @@ async function saveInstance() { showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); closeModal(); + if (!editingVmid) await _waitForOnCreateJobs(); + if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { await renderDetailPage(vmid); } else { @@ -305,6 +307,30 @@ async function saveInstance() { } } +async function _waitForOnCreateJobs() { + const jobs = await fetch('/api/jobs').then(r => r.json()); + const relevant = jobs.filter(j => { + try { return JSON.parse(j.config || '{}').run_on_create; } catch { return false; } + }); + if (!relevant.length) return; + + // Snapshot run IDs before jobs fire so we can detect new completions + const baseline = new Map(relevant.map(j => [j.id, j.last_run_id ?? null])); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 500)); + const current = await fetch('/api/jobs').then(r => r.json()); + const allDone = relevant.every(j => { + const cur = current.find(c => c.id === j.id); + if (!cur) return true; + if (cur.last_run_id === baseline.get(j.id)) return false; // new run not started yet + return cur.last_status !== 'running'; // new run complete + }); + if (allDone) return; + } +} + // ── Confirm Dialog ──────────────────────────────────────────────────────────── function confirmDeleteDialog(inst) { From 1bbe743dba3282767c22f637afc355a348046a50 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:42:46 -0400 Subject: [PATCH 6/9] fix: capture job baseline before POST to avoid race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version snapshotted last_run_id after the 201 response, but jobs fire immediately server-side — by the time the client fetched /api/jobs the runs were already complete, so the baseline matched the new state and the poll loop never detected completion. Baseline is now captured before the creation POST so it always reflects pre-run state regardless of job speed. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/js/ui.js b/js/ui.js index b8e42a0..b691475 100644 --- a/js/ui.js +++ b/js/ui.js @@ -289,6 +289,10 @@ async function saveInstance() { hardware_acceleration: +document.getElementById('f-hardware-accel').checked, }; + // Snapshot job state before creation — jobs fire immediately after the 201 + // so the baseline must be captured before the POST, not after. + const jobBaseline = !editingVmid ? await _snapshotJobBaseline() : null; + const result = editingVmid ? await updateInstance(editingVmid, data) : await createInstance(data); @@ -298,7 +302,7 @@ async function saveInstance() { showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); closeModal(); - if (!editingVmid) await _waitForOnCreateJobs(); + if (jobBaseline) await _waitForOnCreateJobs(jobBaseline); if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { await renderDetailPage(vmid); @@ -307,16 +311,18 @@ async function saveInstance() { } } -async function _waitForOnCreateJobs() { +async function _snapshotJobBaseline() { + const jobs = await fetch('/api/jobs').then(r => r.json()); + return new Map(jobs.map(j => [j.id, j.last_run_id ?? null])); +} + +async function _waitForOnCreateJobs(baseline) { const jobs = await fetch('/api/jobs').then(r => r.json()); const relevant = jobs.filter(j => { try { return JSON.parse(j.config || '{}').run_on_create; } catch { return false; } }); if (!relevant.length) return; - // Snapshot run IDs before jobs fire so we can detect new completions - const baseline = new Map(relevant.map(j => [j.id, j.last_run_id ?? null])); - const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 500)); From 027ed5276822ad099ebce31efe5473ce2b1d802d Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:46:49 -0400 Subject: [PATCH 7/9] fix: config is already a parsed object from the jobs API response maskJob parses job.config before returning it, so calling JSON.parse on it again threw an exception. The catch returned false for every job, so relevant was always empty and _waitForOnCreateJobs returned immediately without polling. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/ui.js b/js/ui.js index b691475..64930fa 100644 --- a/js/ui.js +++ b/js/ui.js @@ -318,9 +318,7 @@ async function _snapshotJobBaseline() { async function _waitForOnCreateJobs(baseline) { const jobs = await fetch('/api/jobs').then(r => r.json()); - const relevant = jobs.filter(j => { - try { return JSON.parse(j.config || '{}').run_on_create; } catch { return false; } - }); + const relevant = jobs.filter(j => (j.config ?? {}).run_on_create); if (!relevant.length) return; const deadline = Date.now() + 30_000; From 2e3484b1d973e2c8469a87abbe53d51c47a22805 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:51:31 -0400 Subject: [PATCH 8/9] feat: make stats bar cells clickable to filter by state Clicking deployed/testing/degraded sets the state filter to that value. Clicking total clears all filters. Hover style added. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 2 ++ js/ui.js | 13 +++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/css/app.css b/css/app.css index afda024..829ce47 100644 --- a/css/app.css +++ b/css/app.css @@ -153,6 +153,8 @@ main { flex: 1; } } .stat-cell:last-child { border-right: none; } +.stat-clickable { cursor: pointer; user-select: none; } +.stat-clickable:hover { background: var(--bg2); } .stat-label { font-size: 10px; diff --git a/js/ui.js b/js/ui.js index 64930fa..7c3587d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -71,10 +71,10 @@ async function renderDashboard() { all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; }); document.getElementById('stats-bar').innerHTML = ` -
total
${all.length}
-
deployed
${states['deployed'] || 0}
-
testing
${states['testing'] || 0}
-
degraded
${states['degraded'] || 0}
+
total
${all.length}
+
deployed
${states['deployed'] || 0}
+
testing
${states['testing'] || 0}
+
degraded
${states['degraded'] || 0}
`; await populateStackFilter(); @@ -95,6 +95,11 @@ async function populateStackFilter() { }); } +function setStateFilter(state) { + document.getElementById('filter-state').value = state; + filterInstances(); +} + async function filterInstances() { const search = document.getElementById('search-input').value; const state = document.getElementById('filter-state').value; From 5ca0b648ca944995d4f128dc601a6dc222e60fa0 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 20:58:41 -0400 Subject: [PATCH 9/9] chore: bump to version 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a08c3e..d43a406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalyst", - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "start": "node server/server.js",