26 Commits

Author SHA1 Message Date
edf6f674b3 Merge pull request 'v1.6.0' (#65) from dev into main
All checks were successful
CI / test (push) Successful in 15s
Release / release (push) Successful in 52s
CI / build-dev (push) Has been skipped
Reviewed-on: #65
2026-03-28 21:01:27 -04:00
a8d367b4be Merge pull request 'chore: bump to version 1.6.0' (#64) from chore/bump-v1.6.0 into dev
All checks were successful
CI / test (push) Successful in 16s
CI / build-dev (push) Successful in 44s
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
Reviewed-on: #64
2026-03-28 20:59:21 -04:00
5ca0b648ca chore: bump to version 1.6.0
All checks were successful
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:58:41 -04:00
518ed42f60 Merge pull request 'feat: make stats bar cells clickable to filter by state' (#62) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 17s
CI / build-dev (push) Successful in 27s
Reviewed-on: #62
2026-03-28 20:53:14 -04:00
a9147b0198 Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:52:44 -04:00
2e3484b1d9 feat: make stats bar cells clickable to filter by state
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:51:31 -04:00
cb83d11261 Merge pull request 'fix: config is already a parsed object from the jobs API response' (#61) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 26s
Reviewed-on: #61
2026-03-28 20:47:46 -04:00
047fd0653e Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:47:18 -04:00
027ed52768 fix: config is already a parsed object from the jobs API response
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:46:49 -04:00
e2935c58c8 Merge pull request 'fix: capture job baseline before POST to avoid race condition' (#60) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 16s
CI / build-dev (push) Successful in 29s
Reviewed-on: #60
2026-03-28 20:43:26 -04:00
1bbe743dba fix: capture job baseline before POST to avoid race condition
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:42:46 -04:00
d88b79e9f0 Merge pull request 'feat: auto-refresh UI after on-create jobs complete' (#59) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 29s
Reviewed-on: #59
2026-03-28 20:26:26 -04:00
8a9de6d72a Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 15s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:25:55 -04:00
ddd528a682 feat: auto-refresh UI after on-create jobs complete
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:25:26 -04:00
03cf2aa9c6 Merge pull request 'fix: millisecond precision timestamps and correct history ordering' (#58) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 29s
Reviewed-on: #58
2026-03-28 20:20:42 -04:00
d84674b0c6 Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:20:03 -04:00
7999f46ca2 fix: millisecond precision timestamps and correct history ordering
All checks were successful
CI / test (pull_request) Successful in 21s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:19:42 -04:00
307c5cf9e8 Merge pull request 'fix: initialize jobs nav dot on every page load' (#57) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 14s
CI / build-dev (push) Successful in 28s
Reviewed-on: #57
2026-03-28 20:16:02 -04:00
34af8e5a8f Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:15:37 -04:00
76d2bffb4f fix: initialize jobs nav dot on every page load
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:14:53 -04:00
64de0e432c Merge pull request 'fix: queue on-create jobs sequentially and fix history ordering' (#56) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 13s
CI / build-dev (push) Successful in 28s
Reviewed-on: #56
2026-03-28 20:12:31 -04:00
a5b409a348 Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:09:59 -04:00
8f35724bde fix: queue on-create jobs sequentially and fix history ordering
All checks were successful
CI / test (pull_request) Successful in 13s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:09:32 -04:00
cec82a3347 Merge pull request 'feat: run jobs on instance creation when run_on_create is enabled' (#54) from feat/jobs-system into dev
All checks were successful
CI / test (push) Successful in 17s
CI / build-dev (push) Successful in 34s
Reviewed-on: #54
2026-03-28 20:01:53 -04:00
883e59789b Merge branch 'dev' into feat/jobs-system
All checks were successful
CI / test (pull_request) Successful in 17s
CI / build-dev (pull_request) Has been skipped
2026-03-28 20:01:20 -04:00
817fdaef13 feat: run jobs on instance creation when run_on_create is enabled
All checks were successful
CI / test (pull_request) Successful in 18s
CI / build-dev (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-03-28 20:00:45 -04:00
8 changed files with 97 additions and 16 deletions

View File

@@ -153,6 +153,8 @@ main { flex: 1; }
} }
.stat-cell:last-child { border-right: none; } .stat-cell:last-child { border-right: none; }
.stat-clickable { cursor: pointer; user-select: none; }
.stat-clickable:hover { background: var(--bg2); }
.stat-label { .stat-label {
font-size: 10px; font-size: 10px;

View File

@@ -53,4 +53,6 @@ if (VERSION) {
document.getElementById('nav-version').textContent = label; document.getElementById('nav-version').textContent = label;
} }
fetch('/api/jobs').then(r => r.json()).then(_updateJobsNavDot).catch(() => {});
handleRoute(); handleRoute();

View File

@@ -71,10 +71,10 @@ async function renderDashboard() {
all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; }); all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; });
document.getElementById('stats-bar').innerHTML = ` document.getElementById('stats-bar').innerHTML = `
<div class="stat-cell"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('')"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div>
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('deployed')"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('testing')"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div> <div class="stat-cell stat-clickable" onclick="setStateFilter('degraded')"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
`; `;
await populateStackFilter(); await populateStackFilter();
@@ -95,6 +95,11 @@ async function populateStackFilter() {
}); });
} }
function setStateFilter(state) {
document.getElementById('filter-state').value = state;
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;
@@ -289,6 +294,10 @@ async function saveInstance() {
hardware_acceleration: +document.getElementById('f-hardware-accel').checked, 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 const result = editingVmid
? await updateInstance(editingVmid, data) ? await updateInstance(editingVmid, data)
: await createInstance(data); : await createInstance(data);
@@ -298,6 +307,8 @@ async function saveInstance() {
showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success');
closeModal(); closeModal();
if (jobBaseline) await _waitForOnCreateJobs(jobBaseline);
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
await renderDetailPage(vmid); await renderDetailPage(vmid);
} else { } 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 ──────────────────────────────────────────────────────────── // ── Confirm Dialog ────────────────────────────────────────────────────────────
function confirmDeleteDialog(inst) { function confirmDeleteDialog(inst) {
@@ -463,6 +498,13 @@ async function loadJobDetail(jobId) {
<label class="form-label" for="job-schedule">Poll interval (minutes)</label> <label class="form-label" for="job-schedule">Poll interval (minutes)</label>
<input class="form-input" id="job-schedule" type="number" min="1" value="${job.schedule}" style="max-width:100px"> <input class="form-input" id="job-schedule" type="number" min="1" value="${job.schedule}" style="max-width:100px">
</div> </div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="job-run-on-create" ${cfg.run_on_create ? 'checked' : ''}
style="accent-color:var(--accent);width:13px;height:13px">
Run on instance creation
</label>
</div>
${_renderJobConfigFields(job.key, cfg)} ${_renderJobConfigFields(job.key, cfg)}
<div class="job-actions"> <div class="job-actions">
<button class="btn btn-secondary" onclick="saveJobDetail(${job.id})">Save</button> <button class="btn btn-secondary" onclick="saveJobDetail(${job.id})">Save</button>
@@ -525,6 +567,8 @@ async function saveJobDetail(jobId) {
if (apiKey) cfg.api_key = apiKey.value; if (apiKey) cfg.api_key = apiKey.value;
if (apiUrl) cfg.api_url = apiUrl.value.trim(); if (apiUrl) cfg.api_url = apiUrl.value.trim();
if (apiToken) cfg.api_token = apiToken.value; 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}`, { const res = await fetch(`/api/jobs/${jobId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,6 +1,6 @@
{ {
"name": "catalyst", "name": "catalyst",
"version": "1.5.0", "version": "1.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node server/server.js", "start": "node server/server.js",

View File

@@ -173,7 +173,8 @@ export function createInstance(data) {
@tailscale, @andromeda, @tailscale_ip, @hardware_acceleration) @tailscale, @andromeda, @tailscale_ip, @hardware_acceleration)
`).run(data); `).run(data);
db.prepare( 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); ).run(data.vmid);
} }
@@ -184,12 +185,13 @@ export function updateInstance(vmid, data) {
name=@name, state=@state, stack=@stack, vmid=@newVmid, name=@name, state=@state, stack=@stack, vmid=@newVmid,
atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon, atlas=@atlas, argus=@argus, semaphore=@semaphore, patchmon=@patchmon,
tailscale=@tailscale, andromeda=@andromeda, tailscale_ip=@tailscale_ip, 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 WHERE vmid=@vmid
`).run({ ...data, newVmid: data.vmid, vmid }); `).run({ ...data, newVmid: data.vmid, vmid });
const newVmid = data.vmid; const newVmid = data.vmid;
const insertEvt = db.prepare( 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) { for (const field of HISTORY_FIELDS) {
const oldVal = String(old[field] ?? ''); const oldVal = String(old[field] ?? '');
@@ -227,7 +229,7 @@ export function importInstances(rows, historyRows = []) {
export function getInstanceHistory(vmid) { export function getInstanceHistory(vmid) {
return db.prepare( 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); ).all(vmid);
} }
@@ -309,12 +311,14 @@ export function updateJob(id, { enabled, schedule, config }) {
} }
export function createJobRun(jobId) { 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) { export function completeJobRun(runId, status, result) {
db.prepare(` 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 }); `).run({ id: runId, status, result });
} }

View File

@@ -129,6 +129,15 @@ export async function runJob(jobId) {
const _intervals = new Map(); 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() { export function restartJobs() {
for (const iv of _intervals.values()) clearInterval(iv); for (const iv of _intervals.values()) clearInterval(iv);
_intervals.clear(); _intervals.clear();

View File

@@ -5,7 +5,7 @@ import {
getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns,
getAllJobs, getAllJobRuns, importJobs, getAllJobs, getAllJobRuns, importJobs,
} from './db.js'; } from './db.js';
import { runJob, restartJobs } from './jobs.js'; import { runJob, restartJobs, runJobsOnCreate } from './jobs.js';
export const router = Router(); export const router = Router();
@@ -102,6 +102,7 @@ router.post('/instances', (req, res) => {
createInstance(data); createInstance(data);
const created = getInstance(data.vmid); const created = getInstance(data.vmid);
res.status(201).json(created); res.status(201).json(created);
runJobsOnCreate().catch(() => {});
} catch (e) { } catch (e) {
handleDbError('POST /api/instances', e, res); handleDbError('POST /api/instances', e, res);
} }

View File

@@ -623,6 +623,25 @@ describe('POST /api/jobs/:id/run', () => {
expect(res.status).toBe(500) 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 () => { it('semaphore_sync: parses ansible inventory and updates instances', async () => {
const semaphoreJob = { const semaphoreJob = {
key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test', key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test',