From ecdac6fe2396949bff8c62ab76b95c9b4a0f5f48 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 16:47:20 -0400 Subject: [PATCH 1/8] fix: remove internal database ID from frontend Removed from the instance subtitle and the overview kv grid. The auto- increment ID is an implementation detail with no user-facing meaning. Co-Authored-By: Claude Sonnet 4.6 --- index.html | 1 - js/ui.js | 2 -- 2 files changed, 3 deletions(-) diff --git a/index.html b/index.html index 246f199..33b3e4c 100644 --- a/index.html +++ b/index.html @@ -69,7 +69,6 @@
vmid - id created
diff --git a/js/ui.js b/js/ui.js index 09b8807..4916263 100644 --- a/js/ui.js +++ b/js/ui.js @@ -172,7 +172,6 @@ async function renderDetailPage(vmid) { document.getElementById('detail-vmid-crumb').textContent = vmid; document.getElementById('detail-name').textContent = inst.name; document.getElementById('detail-vmid-sub').textContent = inst.vmid; - document.getElementById('detail-id-sub').textContent = inst.id; document.getElementById('detail-created-sub').textContent = fmtDate(inst.created_at); document.getElementById('detail-identity').innerHTML = ` @@ -180,7 +179,6 @@ async function renderDetailPage(vmid) {
state${esc(inst.state)}
stack${esc(inst.stack) || '—'}
vmid${inst.vmid}
-
internal id${inst.id}
`; document.getElementById('detail-network').innerHTML = ` -- 2.39.5 From 47e9c4faf7f73bff9988bf64763d1e25ad34425a Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 17:11:40 -0400 Subject: [PATCH 2/8] feat: Tailscale sync jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a background job system that polls the Tailscale API on a configurable interval and syncs tailscale status and IPs to instances by hostname match. - New config table (key/value) in SQLite for persistent server-side settings - New server/jobs.js: runTailscaleSync + restartJobs scheduler - GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET - POST /api/jobs/tailscale/run — immediate manual sync - Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status Co-Authored-By: Claude Sonnet 4.6 --- index.html | 25 +++++++++++++++++ js/ui.js | 58 +++++++++++++++++++++++++++++++++++++++ server/db.js | 17 ++++++++++++ server/jobs.js | 63 ++++++++++++++++++++++++++++++++++++++++++ server/routes.js | 43 +++++++++++++++++++++++++++++ server/server.js | 2 ++ tests/api.test.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++ tests/db.test.js | 30 ++++++++++++++++++++ 8 files changed, 308 insertions(+) create mode 100644 server/jobs.js diff --git a/index.html b/index.html index 33b3e4c..11ce947 100644 --- a/index.html +++ b/index.html @@ -199,6 +199,31 @@ +
+
Tailscale Sync
+

Sync Tailscale status and IPs by matching device hostnames to instance names.

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/js/ui.js b/js/ui.js index 4916263..2ed3e0d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -353,6 +353,7 @@ function openSettingsModal() { } } sel.value = getTimezone(); + loadTailscaleSettings(); document.getElementById('settings-modal').classList.add('open'); } @@ -424,3 +425,60 @@ document.getElementById('tz-select').addEventListener('change', e => { if (m) renderDetailPage(parseInt(m[1], 10)); else renderDashboard(); }); + +// ── Tailscale Settings ──────────────────────────────────────────────────────── + +async function loadTailscaleSettings() { + try { + const res = await fetch('/api/config'); + if (!res.ok) return; + const cfg = await res.json(); + document.getElementById('ts-enabled').checked = cfg.tailscale_enabled === '1'; + document.getElementById('ts-tailnet').value = cfg.tailscale_tailnet ?? ''; + document.getElementById('ts-api-key').value = cfg.tailscale_api_key ?? ''; + document.getElementById('ts-poll').value = cfg.tailscale_poll_minutes || '15'; + _updateTsStatus(cfg.tailscale_last_run_at, cfg.tailscale_last_result); + } catch { /* silent */ } +} + +function _updateTsStatus(lastRun, lastResult) { + const el = document.getElementById('ts-status'); + if (!lastRun) { el.textContent = 'Never run'; return; } + el.textContent = `Last run: ${fmtDateFull(lastRun)} — ${lastResult || '—'}`; +} + +async function saveTailscaleSettings() { + const body = { + tailscale_enabled: document.getElementById('ts-enabled').checked ? '1' : '0', + tailscale_tailnet: document.getElementById('ts-tailnet').value.trim(), + tailscale_api_key: document.getElementById('ts-api-key').value, + tailscale_poll_minutes: document.getElementById('ts-poll').value || '15', + }; + const res = await fetch('/api/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + showToast(res.ok ? 'Tailscale settings saved' : 'Failed to save settings', res.ok ? 'success' : 'error'); +} + +async function runTailscaleNow() { + const btn = document.getElementById('ts-run-btn'); + btn.disabled = true; + btn.textContent = 'Running…'; + try { + const res = await fetch('/api/jobs/tailscale/run', { method: 'POST' }); + const data = await res.json(); + if (res.ok) { + showToast(`Sync complete — ${data.updated} updated`, 'success'); + _updateTsStatus(new Date().toISOString(), `ok: ${data.updated} updated of ${data.total}`); + } else { + showToast(data.error ?? 'Sync failed', 'error'); + } + } catch { + showToast('Sync failed', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Now'; + } +} diff --git a/server/db.js b/server/db.js index 645f852..77e01b6 100644 --- a/server/db.js +++ b/server/db.js @@ -53,6 +53,11 @@ function createSchema() { changed_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_history_vmid ON instance_history(vmid); + + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ); `); } @@ -187,6 +192,18 @@ export function getAllHistory() { return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); } +export function getConfig(key, defaultVal = '') { + const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key); + return row ? row.value : defaultVal; +} + +export function setConfig(key, value) { + db.prepare( + `INSERT INTO config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + ).run(key, String(value)); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/jobs.js b/server/jobs.js new file mode 100644 index 0000000..7737812 --- /dev/null +++ b/server/jobs.js @@ -0,0 +1,63 @@ +import { getInstances, updateInstance, getConfig, setConfig } from './db.js'; + +const TAILSCALE_API = 'https://api.tailscale.com/api/v2'; + +let _interval = null; + +export async function runTailscaleSync() { + const apiKey = getConfig('tailscale_api_key'); + const tailnet = getConfig('tailscale_tailnet'); + if (!apiKey || !tailnet) throw new Error('Tailscale not configured'); + + const res = await fetch( + `${TAILSCALE_API}/tailnet/${encodeURIComponent(tailnet)}/devices`, + { headers: { Authorization: `Bearer ${apiKey}` } } + ); + if (!res.ok) throw new Error(`Tailscale API ${res.status}`); + + const { devices } = await res.json(); + + // hostname -> first 100.x.x.x address + const tsMap = new Map( + devices.map(d => [d.hostname, (d.addresses ?? []).find(a => a.startsWith('100.')) ?? '']) + ); + + const instances = getInstances(); + let updated = 0; + + for (const inst of instances) { + const tsIp = tsMap.get(inst.name); // undefined = not in Tailscale + const matched = tsIp !== undefined; + + const newTailscale = matched ? 1 : (inst.tailscale === 1 ? 0 : inst.tailscale); + const newIp = matched ? tsIp : (inst.tailscale === 1 ? '' : inst.tailscale_ip); + + if (newTailscale !== inst.tailscale || newIp !== inst.tailscale_ip) { + // Strip db-generated columns — node:sqlite rejects unknown named parameters + const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst; + updateInstance(inst.vmid, { ...instData, tailscale: newTailscale, tailscale_ip: newIp }); + updated++; + } + } + + return { updated, total: instances.length }; +} + +export function restartJobs() { + if (_interval) { clearInterval(_interval); _interval = null; } + if (getConfig('tailscale_enabled') !== '1') return; + + const mins = parseInt(getConfig('tailscale_poll_minutes', '15'), 10); + const ms = Math.max(1, Number.isFinite(mins) ? mins : 15) * 60_000; + + _interval = setInterval(async () => { + try { + const r = await runTailscaleSync(); + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `ok: ${r.updated} updated of ${r.total}`); + } catch (e) { + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `error: ${e.message}`); + } + }, ms); +} diff --git a/server/routes.js b/server/routes.js index eb77abf..319092d 100644 --- a/server/routes.js +++ b/server/routes.js @@ -2,7 +2,9 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, + getConfig, setConfig, } from './db.js'; +import { runTailscaleSync, restartJobs } from './jobs.js'; export const router = Router(); @@ -12,6 +14,12 @@ const VALID_STATES = ['deployed', 'testing', 'degraded']; const VALID_STACKS = ['production', 'development']; const SERVICE_KEYS = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda']; +const CONFIG_KEYS = [ + 'tailscale_api_key', 'tailscale_tailnet', 'tailscale_poll_minutes', + 'tailscale_enabled', 'tailscale_last_run_at', 'tailscale_last_result', +]; +const REDACTED = '**REDACTED**'; + function validate(body) { const errors = []; if (!body.name || typeof body.name !== 'string' || !body.name.trim()) @@ -160,3 +168,38 @@ router.delete('/instances/:vmid', (req, res) => { handleDbError('DELETE /api/instances/:vmid', e, res); } }); + +// GET /api/config +router.get('/config', (_req, res) => { + const cfg = {}; + for (const key of CONFIG_KEYS) { + const val = getConfig(key); + cfg[key] = (key === 'tailscale_api_key' && val) ? REDACTED : val; + } + res.json(cfg); +}); + +// PUT /api/config +router.put('/config', (req, res) => { + for (const key of CONFIG_KEYS) { + if (!(key in (req.body ?? {}))) continue; + if (key === 'tailscale_api_key' && req.body[key] === REDACTED) continue; + setConfig(key, req.body[key]); + } + try { restartJobs(); } catch (e) { console.error('PUT /api/config restartJobs', e); } + res.json({ ok: true }); +}); + +// POST /api/jobs/tailscale/run +router.post('/jobs/tailscale/run', async (req, res) => { + if (!getConfig('tailscale_api_key') || !getConfig('tailscale_tailnet')) + return res.status(400).json({ error: 'Tailscale not configured' }); + try { + const result = await runTailscaleSync(); + setConfig('tailscale_last_run_at', new Date().toISOString()); + setConfig('tailscale_last_result', `ok: ${result.updated} updated of ${result.total}`); + res.json(result); + } catch (e) { + handleDbError('POST /api/jobs/tailscale/run', e, res); + } +}); diff --git a/server/server.js b/server/server.js index 5f83e90..b1a05e4 100644 --- a/server/server.js +++ b/server/server.js @@ -3,6 +3,7 @@ import helmet from 'helmet'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { router } from './routes.js'; +import { restartJobs } from './jobs.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT ?? 3000; @@ -47,5 +48,6 @@ app.use((err, _req, res, _next) => { // Boot — only when run directly, not when imported by tests if (process.argv[1] === fileURLToPath(import.meta.url)) { + restartJobs(); app.listen(PORT, () => console.log(`catalyst on :${PORT}`)); } diff --git a/tests/api.test.js b/tests/api.test.js index 2e9d8f5..45951f4 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -453,3 +453,73 @@ describe('error handling — unexpected DB failures', () => { ) }) }) + +// ── GET /api/config ─────────────────────────────────────────────────────────── + +describe('GET /api/config', () => { + it('returns 200 with all config keys', async () => { + const res = await request(app).get('/api/config') + expect(res.status).toBe(200) + expect(res.body).toHaveProperty('tailscale_enabled') + expect(res.body).toHaveProperty('tailscale_api_key') + expect(res.body).toHaveProperty('tailscale_poll_minutes') + }) + + it('returns empty string for api key when not set', async () => { + expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('') + }) + + it('masks api key as **REDACTED** when set', async () => { + await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' }) + expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**') + }) +}) + +// ── PUT /api/config ─────────────────────────────────────────────────────────── + +describe('PUT /api/config', () => { + it('saves config and returns ok', async () => { + const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' }) + expect(res.status).toBe(200) + expect(res.body.ok).toBe(true) + }) + + it('does not overwrite api key when **REDACTED** is sent', async () => { + await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' }) + await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' }) + expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key') + }) +}) + +// ── POST /api/jobs/tailscale/run ────────────────────────────────────────────── + +describe('POST /api/jobs/tailscale/run', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('returns 400 when not configured', async () => { + const res = await request(app).post('/api/jobs/tailscale/run') + expect(res.status).toBe(400) + expect(res.body.error).toMatch(/not configured/i) + }) + + it('updates matching instance and returns count', async () => { + await request(app).put('/api/config').send({ + tailscale_api_key: 'tskey-test', + tailscale_tailnet: 'example.com', + }) + await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 }) + + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }), + })) + + const res = await request(app).post('/api/jobs/tailscale/run') + expect(res.status).toBe(200) + expect(res.body.updated).toBe(1) + + const inst = await request(app).get('/api/instances/100') + expect(inst.body.tailscale).toBe(1) + expect(inst.body.tailscale_ip).toBe('100.64.0.2') + }) +}) diff --git a/tests/db.test.js b/tests/db.test.js index fee0400..e754633 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -3,6 +3,7 @@ import { _resetForTest, getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, + getConfig, setConfig, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -269,3 +270,32 @@ describe('test environment boot isolation', () => { expect(getInstances()).toEqual([]); }); }); + +// ── getConfig / setConfig ───────────────────────────────────────────────────── + +describe('getConfig / setConfig', () => { + it('returns defaultVal when key does not exist', () => { + expect(getConfig('missing', 'fallback')).toBe('fallback'); + }); + + it('returns empty string by default', () => { + expect(getConfig('missing')).toBe(''); + }); + + it('stores and retrieves a value', () => { + setConfig('tailscale_api_key', 'tskey-test'); + expect(getConfig('tailscale_api_key')).toBe('tskey-test'); + }); + + it('overwrites an existing key', () => { + setConfig('tailscale_enabled', '0'); + setConfig('tailscale_enabled', '1'); + expect(getConfig('tailscale_enabled')).toBe('1'); + }); + + it('config is cleared by _resetForTest', () => { + setConfig('tailscale_api_key', 'tskey-test'); + _resetForTest(); + expect(getConfig('tailscale_api_key')).toBe(''); + }); +}); -- 2.39.5 From d7727badb1590d4a80a49ca089a0d0a6b074c89f Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:09:42 -0400 Subject: [PATCH 3/8] feat: jobs system with dedicated nav page and run history Replaces ad-hoc Tailscale config tracking with a proper jobs system. Jobs get their own nav page (master/detail layout), a dedicated DB table, and full run history persisted forever. Tailscale connection settings move from the Settings modal into the Jobs page. Registry pattern makes adding future jobs straightforward. Co-Authored-By: Claude Sonnet 4.6 --- css/app.css | 87 +++++++++++++++++++++++++++++ index.html | 39 +++++-------- js/app.js | 12 +++- js/ui.js | 136 ++++++++++++++++++++++++++++++++-------------- server/db.js | 85 ++++++++++++++++++++++++++++- server/jobs.js | 77 +++++++++++++++----------- server/routes.js | 76 +++++++++++++++----------- tests/api.test.js | 120 +++++++++++++++++++++++++--------------- tests/db.test.js | 87 +++++++++++++++++++++++++++++ 9 files changed, 541 insertions(+), 178 deletions(-) diff --git a/css/app.css b/css/app.css index 096a54a..afda024 100644 --- a/css/app.css +++ b/css/app.css @@ -763,4 +763,91 @@ select:focus { border-color: var(--accent); } /* Toast — stretch across bottom */ .toast { right: 16px; left: 16px; bottom: 16px; } + + /* Jobs — stack sidebar above detail */ + .jobs-layout { grid-template-columns: 1fr; } + .jobs-sidebar { border-right: none; border-bottom: 1px solid var(--border); } } + +/* ── JOBS PAGE ───────────────────────────────────────────────────────────────── */ + +.jobs-layout { + display: grid; + grid-template-columns: 220px 1fr; + height: calc(100vh - 48px); +} +.jobs-sidebar { + border-right: 1px solid var(--border); + overflow-y: auto; +} +.jobs-sidebar-title { + padding: 16px 16px 8px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text3); +} +.job-item { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border); + user-select: none; +} +.job-item:hover, .job-item.active { background: var(--bg2); } +.job-item-name { font-size: 13px; color: var(--text); } +.jobs-detail { + padding: 28px 32px; + overflow-y: auto; + max-width: 600px; +} +.jobs-detail-hd { margin-bottom: 20px; } +.jobs-detail-title { font-size: 17px; font-weight: 600; color: var(--text); } +.jobs-detail-desc { font-size: 12px; color: var(--text2); margin-top: 4px; line-height: 1.6; } +.job-actions { display: flex; gap: 8px; margin: 16px 0 0; } +.jobs-placeholder { padding: 48px 32px; color: var(--text3); font-size: 13px; } + +/* Shared job status dot */ +.job-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} +.job-dot--success { background: var(--accent); } +.job-dot--error { background: var(--red); } +.job-dot--running { background: var(--amber); animation: pulse 2s ease-in-out infinite; } +.job-dot--none { background: var(--border2); } + +/* Run history list */ +.run-item { + display: grid; + grid-template-columns: 10px 1fr 60px 1fr; + gap: 0 12px; + padding: 7px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + align-items: baseline; +} +.run-item:last-child { border-bottom: none; } +.run-time { color: var(--text3); } +.run-status { color: var(--text2); } +.run-result { color: var(--text); } +.run-empty { color: var(--text3); font-size: 12px; padding: 8px 0; } + +/* Nav dot */ +.nav-job-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + margin-left: 5px; + vertical-align: middle; +} +.nav-job-dot--success { background: var(--accent); } +.nav-job-dot--error { background: var(--red); } +.nav-job-dot--none { display: none; } diff --git a/index.html b/index.html index 11ce947..80c3f7f 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,7 @@ · + @@ -171,6 +172,19 @@ + +
+
+
+
Jobs
+
+
+
+
Select a job
+
+
+
+ -
-
Tailscale Sync
-

Sync Tailscale status and IPs by matching device hostnames to instance names.

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
diff --git a/js/app.js b/js/app.js index 1380b38..e389242 100644 --- a/js/app.js +++ b/js/app.js @@ -11,12 +11,19 @@ function navigate(page, vmid) { document.getElementById('page-detail').classList.add('active'); history.pushState({ page: 'instance', vmid }, '', `/instance/${vmid}`); renderDetailPage(vmid); + } else if (page === 'jobs') { + document.getElementById('page-jobs').classList.add('active'); + history.pushState({ page: 'jobs' }, '', '/jobs'); + renderJobsPage(); } } function handleRoute() { const m = window.location.pathname.match(/^\/instance\/(\d+)/); - if (m) { + if (window.location.pathname === '/jobs') { + document.getElementById('page-jobs').classList.add('active'); + renderJobsPage(); + } else if (m) { document.getElementById('page-detail').classList.add('active'); renderDetailPage(parseInt(m[1], 10)); } else { @@ -30,6 +37,9 @@ window.addEventListener('popstate', e => { if (e.state?.page === 'instance') { document.getElementById('page-detail').classList.add('active'); renderDetailPage(e.state.vmid); + } else if (e.state?.page === 'jobs') { + document.getElementById('page-jobs').classList.add('active'); + renderJobsPage(); } else { document.getElementById('page-dashboard').classList.add('active'); renderDashboard(); diff --git a/js/ui.js b/js/ui.js index 2ed3e0d..d31f385 100644 --- a/js/ui.js +++ b/js/ui.js @@ -353,7 +353,6 @@ function openSettingsModal() { } } sel.value = getTimezone(); - loadTailscaleSettings(); document.getElementById('settings-modal').classList.add('open'); } @@ -426,59 +425,112 @@ document.getElementById('tz-select').addEventListener('change', e => { else renderDashboard(); }); -// ── Tailscale Settings ──────────────────────────────────────────────────────── +// ── Jobs Page ───────────────────────────────────────────────────────────────── -async function loadTailscaleSettings() { - try { - const res = await fetch('/api/config'); - if (!res.ok) return; - const cfg = await res.json(); - document.getElementById('ts-enabled').checked = cfg.tailscale_enabled === '1'; - document.getElementById('ts-tailnet').value = cfg.tailscale_tailnet ?? ''; - document.getElementById('ts-api-key').value = cfg.tailscale_api_key ?? ''; - document.getElementById('ts-poll').value = cfg.tailscale_poll_minutes || '15'; - _updateTsStatus(cfg.tailscale_last_run_at, cfg.tailscale_last_result); - } catch { /* silent */ } +async function renderJobsPage() { + const jobs = await fetch('/api/jobs').then(r => r.json()); + _updateJobsNavDot(jobs); + document.getElementById('jobs-list').innerHTML = jobs.length + ? jobs.map(j => ` +
+ + ${esc(j.name)} +
`).join('') + : '
No jobs
'; + if (jobs.length) loadJobDetail(jobs[0].id); } -function _updateTsStatus(lastRun, lastResult) { - const el = document.getElementById('ts-status'); - if (!lastRun) { el.textContent = 'Never run'; return; } - el.textContent = `Last run: ${fmtDateFull(lastRun)} — ${lastResult || '—'}`; +async function loadJobDetail(jobId) { + document.querySelectorAll('.job-item').forEach(el => el.classList.remove('active')); + document.getElementById(`job-item-${jobId}`)?.classList.add('active'); + const job = await fetch(`/api/jobs/${jobId}`).then(r => r.json()); + const cfg = job.config ?? {}; + document.getElementById('jobs-detail').innerHTML = ` +
+
${esc(job.name)}
+
${esc(job.description)}
+
+
+ +
+
+ + +
+ ${_renderJobConfigFields(job.key, cfg)} +
+ + +
+
Run History
+ ${_renderRunList(job.runs)} + `; } -async function saveTailscaleSettings() { - const body = { - tailscale_enabled: document.getElementById('ts-enabled').checked ? '1' : '0', - tailscale_tailnet: document.getElementById('ts-tailnet').value.trim(), - tailscale_api_key: document.getElementById('ts-api-key').value, - tailscale_poll_minutes: document.getElementById('ts-poll').value || '15', - }; - const res = await fetch('/api/config', { +function _renderJobConfigFields(key, cfg) { + if (key === 'tailscale_sync') return ` +
+ + +
+
+ + +
`; + return ''; +} + +function _renderRunList(runs) { + if (!runs?.length) return '
No runs yet
'; + return `
${runs.map(r => ` +
+ + ${fmtDateFull(r.started_at)} + ${esc(r.status)} + ${esc(r.result)} +
`).join('')}
`; +} + +async function saveJobDetail(jobId) { + const enabled = document.getElementById('job-enabled').checked; + const schedule = document.getElementById('job-schedule').value; + const cfg = {}; + const tailnet = document.getElementById('job-cfg-tailnet'); + const apiKey = document.getElementById('job-cfg-api-key'); + if (tailnet) cfg.tailnet = tailnet.value.trim(); + if (apiKey) cfg.api_key = apiKey.value; + const res = await fetch(`/api/jobs/${jobId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify({ enabled, schedule: parseInt(schedule, 10), config: cfg }), }); - showToast(res.ok ? 'Tailscale settings saved' : 'Failed to save settings', res.ok ? 'success' : 'error'); + if (res.ok) { showToast('Job saved', 'success'); loadJobDetail(jobId); } + else { showToast('Failed to save', 'error'); } } -async function runTailscaleNow() { - const btn = document.getElementById('ts-run-btn'); +async function runJobNow(jobId) { + const btn = document.getElementById('job-run-btn'); btn.disabled = true; btn.textContent = 'Running…'; try { - const res = await fetch('/api/jobs/tailscale/run', { method: 'POST' }); + const res = await fetch(`/api/jobs/${jobId}/run`, { method: 'POST' }); const data = await res.json(); - if (res.ok) { - showToast(`Sync complete — ${data.updated} updated`, 'success'); - _updateTsStatus(new Date().toISOString(), `ok: ${data.updated} updated of ${data.total}`); - } else { - showToast(data.error ?? 'Sync failed', 'error'); - } - } catch { - showToast('Sync failed', 'error'); - } finally { - btn.disabled = false; - btn.textContent = 'Run Now'; - } + if (res.ok) { showToast(`Done — ${data.summary}`, 'success'); loadJobDetail(jobId); } + else { showToast(data.error ?? 'Run failed', 'error'); } + } catch { showToast('Run failed', 'error'); } + finally { btn.disabled = false; btn.textContent = 'Run Now'; } +} + +function _updateJobsNavDot(jobs) { + const dot = document.getElementById('nav-jobs-dot'); + const cls = jobs.some(j => j.last_status === 'error') ? 'error' + : jobs.some(j => j.last_status === 'success') ? 'success' + : 'none'; + dot.className = `nav-job-dot nav-job-dot--${cls}`; } diff --git a/server/db.js b/server/db.js index 77e01b6..80c7e3c 100644 --- a/server/db.js +++ b/server/db.js @@ -17,7 +17,7 @@ function init(path) { db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA synchronous = NORMAL'); createSchema(); - if (path !== ':memory:') seed(); + if (path !== ':memory:') { seed(); seedJobs(); } } function createSchema() { @@ -58,6 +58,26 @@ function createSchema() { key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' ); + + CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0,1)), + schedule INTEGER NOT NULL DEFAULT 15, + config TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE IF NOT EXISTS job_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT, + status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','success','error')), + result TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS idx_job_runs_job_id ON job_runs(job_id); `); } @@ -88,6 +108,21 @@ function seed() { db.exec('COMMIT'); } +function seedJobs() { + const count = db.prepare('SELECT COUNT(*) as n FROM jobs').get().n; + if (count > 0) return; + const apiKey = getConfig('tailscale_api_key'); + const tailnet = getConfig('tailscale_tailnet'); + const schedule = parseInt(getConfig('tailscale_poll_minutes', '15'), 10) || 15; + const enabled = getConfig('tailscale_enabled') === '1' ? 1 : 0; + db.prepare(` + INSERT INTO jobs (key, name, description, enabled, schedule, config) + VALUES ('tailscale_sync', 'Tailscale Sync', + 'Syncs Tailscale device status and IPs to instances by matching hostnames.', + ?, ?, ?) + `).run(enabled, schedule, JSON.stringify({ api_key: apiKey, tailnet })); +} + // ── Queries ─────────────────────────────────────────────────────────────────── export function getInstances(filters = {}) { @@ -204,6 +239,54 @@ export function setConfig(key, value) { ).run(key, String(value)); } +// ── Jobs ────────────────────────────────────────────────────────────────────── + +const JOB_WITH_LAST_RUN = ` + SELECT j.*, + r.id AS last_run_id, + r.started_at AS last_run_at, + r.status AS last_status, + r.result AS last_result + FROM jobs j + LEFT JOIN job_runs r + ON r.id = (SELECT id FROM job_runs WHERE job_id = j.id ORDER BY id DESC LIMIT 1) +`; + +export function getJobs() { + return db.prepare(JOB_WITH_LAST_RUN + ' ORDER BY j.id').all(); +} + +export function getJob(id) { + return db.prepare(JOB_WITH_LAST_RUN + ' WHERE j.id = ?').get(id) ?? null; +} + +export function createJob(data) { + db.prepare(` + INSERT INTO jobs (key, name, description, enabled, schedule, config) + VALUES (@key, @name, @description, @enabled, @schedule, @config) + `).run(data); +} + +export function updateJob(id, { enabled, schedule, config }) { + db.prepare(` + UPDATE jobs SET enabled=@enabled, schedule=@schedule, config=@config WHERE id=@id + `).run({ id, enabled, schedule, config }); +} + +export function createJobRun(jobId) { + return Number(db.prepare('INSERT INTO job_runs (job_id) VALUES (?)').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 + `).run({ id: runId, status, result }); +} + +export function getJobRuns(jobId) { + return db.prepare('SELECT * FROM job_runs WHERE job_id = ? ORDER BY id DESC').all(jobId); +} + // ── Test helpers ────────────────────────────────────────────────────────────── export function _resetForTest() { diff --git a/server/jobs.js b/server/jobs.js index 7737812..4cc1a20 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -1,63 +1,74 @@ -import { getInstances, updateInstance, getConfig, setConfig } from './db.js'; +import { getJobs, getJob, getInstances, updateInstance, createJobRun, completeJobRun } from './db.js'; + +// ── Handlers ────────────────────────────────────────────────────────────────── const TAILSCALE_API = 'https://api.tailscale.com/api/v2'; -let _interval = null; - -export async function runTailscaleSync() { - const apiKey = getConfig('tailscale_api_key'); - const tailnet = getConfig('tailscale_tailnet'); - if (!apiKey || !tailnet) throw new Error('Tailscale not configured'); +async function tailscaleSyncHandler(cfg) { + const { api_key, tailnet } = cfg; + if (!api_key || !tailnet) throw new Error('Tailscale not configured — set API key and tailnet'); const res = await fetch( `${TAILSCALE_API}/tailnet/${encodeURIComponent(tailnet)}/devices`, - { headers: { Authorization: `Bearer ${apiKey}` } } + { headers: { Authorization: `Bearer ${api_key}` } } ); if (!res.ok) throw new Error(`Tailscale API ${res.status}`); const { devices } = await res.json(); - - // hostname -> first 100.x.x.x address const tsMap = new Map( devices.map(d => [d.hostname, (d.addresses ?? []).find(a => a.startsWith('100.')) ?? '']) ); const instances = getInstances(); let updated = 0; - for (const inst of instances) { - const tsIp = tsMap.get(inst.name); // undefined = not in Tailscale + const tsIp = tsMap.get(inst.name); const matched = tsIp !== undefined; - const newTailscale = matched ? 1 : (inst.tailscale === 1 ? 0 : inst.tailscale); const newIp = matched ? tsIp : (inst.tailscale === 1 ? '' : inst.tailscale_ip); - if (newTailscale !== inst.tailscale || newIp !== inst.tailscale_ip) { - // Strip db-generated columns — node:sqlite rejects unknown named parameters const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst; updateInstance(inst.vmid, { ...instData, tailscale: newTailscale, tailscale_ip: newIp }); updated++; } } - - return { updated, total: instances.length }; + return { summary: `${updated} updated of ${instances.length}` }; } +// ── Registry ────────────────────────────────────────────────────────────────── + +const HANDLERS = { + tailscale_sync: tailscaleSyncHandler, +}; + +// ── Public API ──────────────────────────────────────────────────────────────── + +export async function runJob(jobId) { + const job = getJob(jobId); + if (!job) throw new Error('Job not found'); + const handler = HANDLERS[job.key]; + if (!handler) throw new Error(`No handler for '${job.key}'`); + const cfg = JSON.parse(job.config || '{}'); + const runId = createJobRun(jobId); + try { + const result = await handler(cfg); + completeJobRun(runId, 'success', result.summary ?? ''); + return result; + } catch (e) { + completeJobRun(runId, 'error', e.message); + throw e; + } +} + +const _intervals = new Map(); + export function restartJobs() { - if (_interval) { clearInterval(_interval); _interval = null; } - if (getConfig('tailscale_enabled') !== '1') return; - - const mins = parseInt(getConfig('tailscale_poll_minutes', '15'), 10); - const ms = Math.max(1, Number.isFinite(mins) ? mins : 15) * 60_000; - - _interval = setInterval(async () => { - try { - const r = await runTailscaleSync(); - setConfig('tailscale_last_run_at', new Date().toISOString()); - setConfig('tailscale_last_result', `ok: ${r.updated} updated of ${r.total}`); - } catch (e) { - setConfig('tailscale_last_run_at', new Date().toISOString()); - setConfig('tailscale_last_result', `error: ${e.message}`); - } - }, ms); + for (const iv of _intervals.values()) clearInterval(iv); + _intervals.clear(); + for (const job of getJobs()) { + if (!job.enabled) continue; + const ms = Math.max(1, job.schedule || 15) * 60_000; + const id = job.id; + _intervals.set(id, setInterval(() => runJob(id).catch(() => {}), ms)); + } } diff --git a/server/routes.js b/server/routes.js index 319092d..59f0fe1 100644 --- a/server/routes.js +++ b/server/routes.js @@ -2,9 +2,9 @@ import { Router } from 'express'; import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, - getConfig, setConfig, + getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, } from './db.js'; -import { runTailscaleSync, restartJobs } from './jobs.js'; +import { runJob, restartJobs } from './jobs.js'; export const router = Router(); @@ -14,12 +14,14 @@ const VALID_STATES = ['deployed', 'testing', 'degraded']; const VALID_STACKS = ['production', 'development']; const SERVICE_KEYS = ['atlas', 'argus', 'semaphore', 'patchmon', 'tailscale', 'andromeda']; -const CONFIG_KEYS = [ - 'tailscale_api_key', 'tailscale_tailnet', 'tailscale_poll_minutes', - 'tailscale_enabled', 'tailscale_last_run_at', 'tailscale_last_result', -]; const REDACTED = '**REDACTED**'; +function maskJob(job) { + const cfg = JSON.parse(job.config || '{}'); + if (cfg.api_key) cfg.api_key = REDACTED; + return { ...job, config: cfg }; +} + function validate(body) { const errors = []; if (!body.name || typeof body.name !== 'string' || !body.name.trim()) @@ -169,37 +171,47 @@ router.delete('/instances/:vmid', (req, res) => { } }); -// GET /api/config -router.get('/config', (_req, res) => { - const cfg = {}; - for (const key of CONFIG_KEYS) { - const val = getConfig(key); - cfg[key] = (key === 'tailscale_api_key' && val) ? REDACTED : val; - } - res.json(cfg); +// GET /api/jobs +router.get('/jobs', (_req, res) => { + res.json(getJobs().map(maskJob)); }); -// PUT /api/config -router.put('/config', (req, res) => { - for (const key of CONFIG_KEYS) { - if (!(key in (req.body ?? {}))) continue; - if (key === 'tailscale_api_key' && req.body[key] === REDACTED) continue; - setConfig(key, req.body[key]); - } - try { restartJobs(); } catch (e) { console.error('PUT /api/config restartJobs', e); } - res.json({ ok: true }); +// GET /api/jobs/:id +router.get('/jobs/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'invalid id' }); + const job = getJob(id); + if (!job) return res.status(404).json({ error: 'job not found' }); + res.json({ ...maskJob(job), runs: getJobRuns(id) }); }); -// POST /api/jobs/tailscale/run -router.post('/jobs/tailscale/run', async (req, res) => { - if (!getConfig('tailscale_api_key') || !getConfig('tailscale_tailnet')) - return res.status(400).json({ error: 'Tailscale not configured' }); +// PUT /api/jobs/:id +router.put('/jobs/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'invalid id' }); + const job = getJob(id); + if (!job) return res.status(404).json({ error: 'job not found' }); + const { enabled, schedule, config: newCfg } = req.body ?? {}; + const existingCfg = JSON.parse(job.config || '{}'); + const mergedCfg = { ...existingCfg, ...(newCfg ?? {}) }; + if (newCfg?.api_key === REDACTED) mergedCfg.api_key = existingCfg.api_key; + updateJob(id, { + enabled: enabled != null ? (enabled ? 1 : 0) : job.enabled, + schedule: schedule != null ? (parseInt(schedule, 10) || 15) : job.schedule, + config: JSON.stringify(mergedCfg), + }); + try { restartJobs(); } catch (e) { console.error('PUT /api/jobs/:id restartJobs', e); } + res.json(maskJob(getJob(id))); +}); + +// POST /api/jobs/:id/run +router.post('/jobs/:id/run', async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'invalid id' }); + if (!getJob(id)) return res.status(404).json({ error: 'job not found' }); try { - const result = await runTailscaleSync(); - setConfig('tailscale_last_run_at', new Date().toISOString()); - setConfig('tailscale_last_result', `ok: ${result.updated} updated of ${result.total}`); - res.json(result); + res.json(await runJob(id)); } catch (e) { - handleDbError('POST /api/jobs/tailscale/run', e, res); + handleDbError('POST /api/jobs/:id/run', e, res); } }); diff --git a/tests/api.test.js b/tests/api.test.js index 45951f4..8dd1085 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import request from 'supertest' import { app } from '../server/server.js' -import { _resetForTest } from '../server/db.js' +import { _resetForTest, createJob } from '../server/db.js' import * as dbModule from '../server/db.js' beforeEach(() => _resetForTest()) @@ -454,72 +454,104 @@ describe('error handling — unexpected DB failures', () => { }) }) -// ── GET /api/config ─────────────────────────────────────────────────────────── +const testJob = { + key: 'tailscale_sync', name: 'Tailscale Sync', description: 'Test job', + enabled: 0, schedule: 15, + config: JSON.stringify({ api_key: 'tskey-test', tailnet: 'example.com' }), +} -describe('GET /api/config', () => { - it('returns 200 with all config keys', async () => { - const res = await request(app).get('/api/config') +// ── GET /api/jobs ───────────────────────────────────────────────────────────── + +describe('GET /api/jobs', () => { + it('returns empty array when no jobs', async () => { + const res = await request(app).get('/api/jobs') expect(res.status).toBe(200) - expect(res.body).toHaveProperty('tailscale_enabled') - expect(res.body).toHaveProperty('tailscale_api_key') - expect(res.body).toHaveProperty('tailscale_poll_minutes') + expect(res.body).toEqual([]) }) - it('returns empty string for api key when not set', async () => { - expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('') - }) - - it('masks api key as **REDACTED** when set', async () => { - await request(app).put('/api/config').send({ tailscale_api_key: 'tskey-secret' }) - expect((await request(app).get('/api/config')).body.tailscale_api_key).toBe('**REDACTED**') + it('returns jobs with masked api key', async () => { + createJob(testJob) + const res = await request(app).get('/api/jobs') + expect(res.body).toHaveLength(1) + expect(res.body[0].config.api_key).toBe('**REDACTED**') }) }) -// ── PUT /api/config ─────────────────────────────────────────────────────────── +// ── GET /api/jobs/:id ───────────────────────────────────────────────────────── -describe('PUT /api/config', () => { - it('saves config and returns ok', async () => { - const res = await request(app).put('/api/config').send({ tailscale_tailnet: 'example.com' }) +describe('GET /api/jobs/:id', () => { + it('returns job with runs array', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id + const res = await request(app).get(`/api/jobs/${id}`) expect(res.status).toBe(200) - expect(res.body.ok).toBe(true) + expect(res.body.runs).toBeInstanceOf(Array) }) - it('does not overwrite api key when **REDACTED** is sent', async () => { - await request(app).put('/api/config').send({ tailscale_api_key: 'real-key' }) - await request(app).put('/api/config').send({ tailscale_api_key: '**REDACTED**' }) - expect(dbModule.getConfig('tailscale_api_key')).toBe('real-key') + it('returns 404 for unknown id', async () => { + expect((await request(app).get('/api/jobs/999')).status).toBe(404) + }) + + it('returns 400 for non-numeric id', async () => { + expect((await request(app).get('/api/jobs/abc')).status).toBe(400) }) }) -// ── POST /api/jobs/tailscale/run ────────────────────────────────────────────── +// ── PUT /api/jobs/:id ───────────────────────────────────────────────────────── -describe('POST /api/jobs/tailscale/run', () => { +describe('PUT /api/jobs/:id', () => { + it('updates enabled and schedule', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id + const res = await request(app).put(`/api/jobs/${id}`).send({ enabled: true, schedule: 30 }) + expect(res.status).toBe(200) + expect(res.body.enabled).toBe(1) + expect(res.body.schedule).toBe(30) + }) + + it('does not overwrite api_key when **REDACTED** is sent', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id + await request(app).put(`/api/jobs/${id}`).send({ config: { api_key: '**REDACTED**' } }) + expect(dbModule.getJob(id).config).toContain('tskey-test') + }) + + it('returns 404 for unknown id', async () => { + expect((await request(app).put('/api/jobs/999').send({})).status).toBe(404) + }) +}) + +// ── POST /api/jobs/:id/run ──────────────────────────────────────────────────── + +describe('POST /api/jobs/:id/run', () => { afterEach(() => vi.unstubAllGlobals()) - it('returns 400 when not configured', async () => { - const res = await request(app).post('/api/jobs/tailscale/run') - expect(res.status).toBe(400) - expect(res.body.error).toMatch(/not configured/i) + it('returns 404 for unknown id', async () => { + expect((await request(app).post('/api/jobs/999/run')).status).toBe(404) }) - it('updates matching instance and returns count', async () => { - await request(app).put('/api/config').send({ - tailscale_api_key: 'tskey-test', - tailscale_tailnet: 'example.com', - }) - await request(app).post('/api/instances').send({ ...base, name: 'traefik', vmid: 100 }) - + it('runs job, returns summary, and logs the run', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: true, - json: async () => ({ devices: [{ hostname: 'traefik', addresses: ['100.64.0.2'] }] }), + json: async () => ({ devices: [] }), })) - - const res = await request(app).post('/api/jobs/tailscale/run') + const res = await request(app).post(`/api/jobs/${id}/run`) expect(res.status).toBe(200) - expect(res.body.updated).toBe(1) + expect(res.body.summary).toBeDefined() + const detail = await request(app).get(`/api/jobs/${id}`) + expect(detail.body.runs).toHaveLength(1) + expect(detail.body.runs[0].status).toBe('success') + }) - const inst = await request(app).get('/api/instances/100') - expect(inst.body.tailscale).toBe(1) - expect(inst.body.tailscale_ip).toBe('100.64.0.2') + it('logs error run on failure', async () => { + createJob(testJob) + const id = (await request(app).get('/api/jobs')).body[0].id + vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('network error'))) + const res = await request(app).post(`/api/jobs/${id}/run`) + expect(res.status).toBe(500) + const detail = await request(app).get(`/api/jobs/${id}`) + expect(detail.body.runs[0].status).toBe('error') }) }) diff --git a/tests/db.test.js b/tests/db.test.js index e754633..f216572 100644 --- a/tests/db.test.js +++ b/tests/db.test.js @@ -4,6 +4,7 @@ import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getConfig, setConfig, + getJobs, getJob, createJob, updateJob, createJobRun, completeJobRun, getJobRuns, } from '../server/db.js' beforeEach(() => _resetForTest()); @@ -299,3 +300,89 @@ describe('getConfig / setConfig', () => { expect(getConfig('tailscale_api_key')).toBe(''); }); }); + +// ── jobs ────────────────────────────────────────────────────────────────────── + +const baseJob = { + key: 'test_job', name: 'Test Job', description: 'desc', + enabled: 0, schedule: 15, config: '{}', +}; + +describe('jobs', () => { + it('returns empty array when no jobs', () => { + expect(getJobs()).toEqual([]); + }); + + it('createJob + getJobs returns the job', () => { + createJob(baseJob); + expect(getJobs()).toHaveLength(1); + expect(getJobs()[0].name).toBe('Test Job'); + }); + + it('getJob returns null for unknown id', () => { + expect(getJob(999)).toBeNull(); + }); + + it('updateJob changes enabled and schedule', () => { + createJob(baseJob); + const id = getJobs()[0].id; + updateJob(id, { enabled: 1, schedule: 30, config: '{}' }); + expect(getJob(id).enabled).toBe(1); + expect(getJob(id).schedule).toBe(30); + }); + + it('getJobs includes last_status null when no runs', () => { + createJob(baseJob); + expect(getJobs()[0].last_status).toBeNull(); + }); + + it('getJobs reflects last_status after a run', () => { + createJob(baseJob); + const id = getJobs()[0].id; + const runId = createJobRun(id); + completeJobRun(runId, 'success', 'ok'); + expect(getJobs()[0].last_status).toBe('success'); + }); +}); + +// ── job_runs ────────────────────────────────────────────────────────────────── + +describe('job_runs', () => { + it('createJobRun returns a positive id', () => { + createJob(baseJob); + const id = getJobs()[0].id; + expect(createJobRun(id)).toBeGreaterThan(0); + }); + + it('new run has status running and no ended_at', () => { + createJob(baseJob); + const id = getJobs()[0].id; + const runId = createJobRun(id); + const runs = getJobRuns(id); + expect(runs[0].status).toBe('running'); + expect(runs[0].ended_at).toBeNull(); + }); + + it('completeJobRun sets status, result, and ended_at', () => { + createJob(baseJob); + const id = getJobs()[0].id; + const runId = createJobRun(id); + completeJobRun(runId, 'success', '2 updated of 8'); + const run = getJobRuns(id)[0]; + expect(run.status).toBe('success'); + expect(run.result).toBe('2 updated of 8'); + expect(run.ended_at).not.toBeNull(); + }); + + it('getJobRuns returns newest first', () => { + createJob(baseJob); + const id = getJobs()[0].id; + const r1 = createJobRun(id); + const r2 = createJobRun(id); + completeJobRun(r1, 'success', 'first'); + completeJobRun(r2, 'error', 'second'); + const runs = getJobRuns(id); + expect(runs[0].id).toBe(r2); + expect(runs[1].id).toBe(r1); + }); +}); -- 2.39.5 From 14a4826bb668294e3a9b280472746a7594c55552 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:14:32 -0400 Subject: [PATCH 4/8] fix: move page-jobs inside main so it renders at the top Co-Authored-By: Claude Sonnet 4.6 --- index.html | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index 80c3f7f..a0dcc88 100644 --- a/index.html +++ b/index.html @@ -98,6 +98,19 @@ + + +
+
+
+
Jobs
+
+
+
+
Select a job
+
+
+
@@ -172,19 +185,6 @@ - -
-
-
-
Jobs
-
-
-
-
Select a job
-
-
-
- `; - if (key === 'patchmon_sync') return ` + if (key === 'patchmon_sync' || key === 'semaphore_sync') { + const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)'; + return `
+ value="${esc(cfg.api_url ?? '')}">
- + + value="${esc(cfg.api_token ?? '')}">
`; + } return ''; } diff --git a/server/db.js b/server/db.js index 660f654..c0d0fd5 100644 --- a/server/db.js +++ b/server/db.js @@ -125,6 +125,10 @@ function seedJobs() { upsert.run('patchmon_sync', 'Patchmon Sync', 'Syncs Patchmon host registration status to instances by matching hostnames.', 0, 60, JSON.stringify({ api_url: 'http://patchmon:3000/api/v1/api/hosts', api_token: '' })); + + upsert.run('semaphore_sync', 'Semaphore Sync', + 'Syncs Semaphore inventory membership to instances by matching hostnames.', + 0, 60, JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: '' })); } // ── Queries ─────────────────────────────────────────────────────────────────── diff --git a/server/jobs.js b/server/jobs.js index cd2070b..cfcb20e 100644 --- a/server/jobs.js +++ b/server/jobs.js @@ -66,11 +66,46 @@ async function patchmonSyncHandler(cfg) { return { summary: `${updated} updated of ${instances.length}` }; } +// ── Semaphore Sync ──────────────────────────────────────────────────────────── + +async function semaphoreSyncHandler(cfg) { + const { api_url, api_token } = cfg; + if (!api_url || !api_token) throw new Error('Semaphore not configured — set API URL and token'); + + const res = await fetch(api_url, { + headers: { Authorization: `Bearer ${api_token}` }, + }); + if (!res.ok) throw new Error(`Semaphore API ${res.status}`); + + const data = await res.json(); + // Inventory is an Ansible INI string; extract bare hostnames + const hostSet = new Set( + (data.inventory ?? '').split('\n') + .map(l => l.trim()) + .filter(l => l && !l.startsWith('[') && !l.startsWith('#') && !l.startsWith(';')) + .map(l => l.split(/[\s=]/)[0]) + .filter(Boolean) + ); + + const instances = getInstances(); + let updated = 0; + for (const inst of instances) { + const newSemaphore = hostSet.has(inst.name) ? 1 : 0; + if (newSemaphore !== inst.semaphore) { + const { id: _id, created_at: _ca, updated_at: _ua, ...instData } = inst; + updateInstance(inst.vmid, { ...instData, semaphore: newSemaphore }); + updated++; + } + } + return { summary: `${updated} updated of ${instances.length}` }; +} + // ── Registry ────────────────────────────────────────────────────────────────── const HANDLERS = { - tailscale_sync: tailscaleSyncHandler, - patchmon_sync: patchmonSyncHandler, + tailscale_sync: tailscaleSyncHandler, + patchmon_sync: patchmonSyncHandler, + semaphore_sync: semaphoreSyncHandler, }; // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/tests/api.test.js b/tests/api.test.js index 7654980..3cb7f1c 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -585,4 +585,21 @@ describe('POST /api/jobs/:id/run', () => { const res = await request(app).post(`/api/jobs/${id}/run`) expect(res.status).toBe(500) }) + + it('semaphore_sync: parses ansible inventory and updates instances', async () => { + const semaphoreJob = { + key: 'semaphore_sync', name: 'Semaphore Sync', description: 'test', + enabled: 0, schedule: 60, + config: JSON.stringify({ api_url: 'http://semaphore:3000/api/project/1/inventory/1', api_token: 'bearer-token' }), + } + createJob(semaphoreJob) + const id = (await request(app).get('/api/jobs')).body[0].id + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ inventory: '[production]\nplex\nhomeassistant\n' }), + })) + const res = await request(app).post(`/api/jobs/${id}/run`) + expect(res.status).toBe(200) + expect(res.body.summary).toMatch(/updated of/) + }) }) -- 2.39.5 From 954d85ca81ec9ac9c3f2c8638e38fe1a82abe8d3 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:43:34 -0400 Subject: [PATCH 7/8] feat: include job config and run history in export/import backup Export bumped to version 3, now includes jobs (with raw unmasked config) and job_runs arrays. Import restores them when present and restarts the scheduler. Payloads without a jobs key leave jobs untouched, keeping v1/v2 backups fully compatible. Co-Authored-By: Claude Sonnet 4.6 --- js/ui.js | 8 +++++--- server/db.js | 27 +++++++++++++++++++++++++++ server/routes.js | 18 ++++++++++++++---- tests/api.test.js | 41 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/js/ui.js b/js/ui.js index 4b7d51e..7156fc5 100644 --- a/js/ui.js +++ b/js/ui.js @@ -382,15 +382,17 @@ async function importDB() { document.getElementById('confirm-ok').onclick = async () => { closeConfirm(); try { - const { instances, history = [] } = JSON.parse(await file.text()); + const { instances, history = [], jobs, job_runs } = JSON.parse(await file.text()); const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ instances, history }), + body: JSON.stringify({ instances, history, jobs, job_runs }), }); const data = await res.json(); if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } - showToast(`Imported ${data.imported} instance${data.imported !== 1 ? 's' : ''}`, 'success'); + const parts = [`${data.imported} instance${data.imported !== 1 ? 's' : ''}`]; + if (data.imported_jobs != null) parts.push(`${data.imported_jobs} job${data.imported_jobs !== 1 ? 's' : ''}`); + showToast(`Imported ${parts.join(', ')}`, 'success'); closeSettingsModal(); renderDashboard(); } catch { diff --git a/server/db.js b/server/db.js index c0d0fd5..52ef555 100644 --- a/server/db.js +++ b/server/db.js @@ -235,6 +235,33 @@ export function getAllHistory() { return db.prepare('SELECT * FROM instance_history ORDER BY vmid, changed_at').all(); } +export function getAllJobs() { + return db.prepare('SELECT id, key, name, description, enabled, schedule, config FROM jobs ORDER BY id').all(); +} + +export function getAllJobRuns() { + return db.prepare('SELECT * FROM job_runs ORDER BY job_id, id').all(); +} + +export function importJobs(jobRows, jobRunRows = []) { + db.exec('BEGIN'); + db.exec('DELETE FROM job_runs'); + db.exec('DELETE FROM jobs'); + const insertJob = db.prepare(` + INSERT INTO jobs (id, key, name, description, enabled, schedule, config) + VALUES (@id, @key, @name, @description, @enabled, @schedule, @config) + `); + for (const j of jobRows) insertJob.run(j); + if (jobRunRows.length) { + const insertRun = db.prepare(` + INSERT INTO job_runs (id, job_id, started_at, ended_at, status, result) + VALUES (@id, @job_id, @started_at, @ended_at, @status, @result) + `); + for (const r of jobRunRows) insertRun.run(r); + } + db.exec('COMMIT'); +} + export function getConfig(key, defaultVal = '') { const row = db.prepare('SELECT value FROM config WHERE key = ?').get(key); return row ? row.value : defaultVal; diff --git a/server/routes.js b/server/routes.js index 7140b84..eed881c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -3,6 +3,7 @@ import { getInstances, getInstance, getDistinctStacks, createInstance, updateInstance, deleteInstance, importInstances, getInstanceHistory, getAllHistory, getConfig, setConfig, getJobs, getJob, updateJob, getJobRuns, + getAllJobs, getAllJobRuns, importJobs, } from './db.js'; import { runJob, restartJobs } from './jobs.js'; @@ -127,15 +128,17 @@ router.put('/instances/:vmid', (req, res) => { // GET /api/export router.get('/export', (_req, res) => { const instances = getInstances(); - const history = getAllHistory(); + const history = getAllHistory(); + const jobs = getAllJobs(); + const job_runs = getAllJobRuns(); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Disposition', `attachment; filename="catalyst-backup-${date}.json"`); - res.json({ version: 2, exported_at: new Date().toISOString(), instances, history }); + res.json({ version: 3, exported_at: new Date().toISOString(), instances, history, jobs, job_runs }); }); // POST /api/import router.post('/import', (req, res) => { - const { instances, history = [] } = req.body ?? {}; + const { instances, history = [], jobs, job_runs } = req.body ?? {}; if (!Array.isArray(instances)) { return res.status(400).json({ error: 'body must contain an instances array' }); } @@ -147,7 +150,14 @@ router.post('/import', (req, res) => { if (errors.length) return res.status(400).json({ errors }); try { importInstances(instances.map(normalise), Array.isArray(history) ? history : []); - res.json({ imported: instances.length }); + if (Array.isArray(jobs)) { + importJobs(jobs, Array.isArray(job_runs) ? job_runs : []); + try { restartJobs(); } catch (e) { console.error('POST /api/import restartJobs', e); } + } + res.json({ + imported: instances.length, + imported_jobs: Array.isArray(jobs) ? jobs.length : undefined, + }); } catch (e) { console.error('POST /api/import', e); res.status(500).json({ error: 'internal server error' }); diff --git a/tests/api.test.js b/tests/api.test.js index 3cb7f1c..6b7e652 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -276,9 +276,9 @@ describe('GET /api/export', () => { expect(res.body.instances).toEqual([]) }) - it('returns version 2', async () => { + it('returns version 3', async () => { const res = await request(app).get('/api/export') - expect(res.body.version).toBe(2) + expect(res.body.version).toBe(3) }) it('includes a history array', async () => { @@ -287,6 +287,21 @@ describe('GET /api/export', () => { expect(res.body.history).toBeInstanceOf(Array) expect(res.body.history.some(e => e.field === 'created')).toBe(true) }) + + it('includes jobs and job_runs arrays', async () => { + createJob(testJob) + const res = await request(app).get('/api/export') + expect(res.body.jobs).toBeInstanceOf(Array) + expect(res.body.jobs).toHaveLength(1) + expect(res.body.jobs[0].key).toBe('tailscale_sync') + expect(res.body.job_runs).toBeInstanceOf(Array) + }) + + it('exports raw job config without masking', async () => { + createJob(testJob) + const res = await request(app).get('/api/export') + expect(res.body.jobs[0].config).toContain('tskey-test') + }) }) // ── POST /api/import ────────────────────────────────────────────────────────── @@ -341,6 +356,28 @@ describe('POST /api/import', () => { expect(res.status).toBe(200) expect(res.body.imported).toBe(1) }) + + it('imports jobs and job_runs and returns imported_jobs count', async () => { + const exp = await request(app).get('/api/export') + createJob(testJob) + const fullExport = await request(app).get('/api/export') + const res = await request(app).post('/api/import').send({ + instances: fullExport.body.instances, + history: fullExport.body.history, + jobs: fullExport.body.jobs, + job_runs: fullExport.body.job_runs, + }) + expect(res.status).toBe(200) + expect(res.body.imported_jobs).toBe(1) + expect((await request(app).get('/api/jobs')).body).toHaveLength(1) + }) + + it('leaves jobs untouched when no jobs key in payload', async () => { + createJob(testJob) + await request(app).post('/api/import') + .send({ instances: [{ ...base, vmid: 1, name: 'x' }] }) + expect((await request(app).get('/api/jobs')).body).toHaveLength(1) + }) }) // ── Static assets & SPA routing ─────────────────────────────────────────────── -- 2.39.5 From c4ebb76deb93bb47d988e912a0636aaf7452558f Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 28 Mar 2026 19:48:16 -0400 Subject: [PATCH 8/8] chore: bump version to 1.5.0 Co-Authored-By: Claude Sonnet 4.6 --- js/version.js | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/js/version.js b/js/version.js index 7f8fb21..2b45f39 100644 --- a/js/version.js +++ b/js/version.js @@ -1 +1 @@ -const VERSION = "1.4.0"; +const VERSION = "1.5.0"; diff --git a/package.json b/package.json index 252f229..9a08c3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "catalyst", - "version": "1.4.0", + "version": "1.5.0", "type": "module", "scripts": { "start": "node server/server.js", -- 2.39.5