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 = `
-
- deployed
${states['deployed'] || 0}
- testing
${states['testing'] || 0}
- degraded
${states['degraded'] || 0}
+
+ 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',