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 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user