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/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(); diff --git a/js/ui.js b/js/ui.js index 7156fc5..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; @@ -289,6 +294,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,6 +307,8 @@ async function saveInstance() { showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); closeModal(); + if (jobBaseline) await _waitForOnCreateJobs(jobBaseline); + if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { await renderDetailPage(vmid); } else { @@ -305,6 +316,30 @@ async function saveInstance() { } } +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 => (j.config ?? {}).run_on_create); + if (!relevant.length) return; + + 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) { @@ -463,6 +498,13 @@ async function loadJobDetail(jobId) { +
+ +
${_renderJobConfigFields(job.key, cfg)}
@@ -521,10 +563,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/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", diff --git a/server/db.js b/server/db.js index 52ef555..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 DESC' + '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 }); } diff --git a/server/jobs.js b/server/jobs.js index cfcb20e..dc61d1f 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -129,6 +129,15 @@ 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) { + try { await 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',