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); + }); +});