feat: jobs system with dedicated nav page and run history
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 19:09:42 -04:00
parent 537d78e71b
commit d7727badb1
9 changed files with 541 additions and 178 deletions

View File

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