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

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

136
js/ui.js
View File

@@ -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 => `
<div class="job-item" id="job-item-${j.id}" onclick="loadJobDetail(${j.id})">
<span class="job-dot job-dot--${j.last_status ?? 'none'}"></span>
<span class="job-item-name">${esc(j.name)}</span>
</div>`).join('')
: '<div class="jobs-placeholder">No jobs</div>';
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 = `
<div class="jobs-detail-hd">
<div class="jobs-detail-title">${esc(job.name)}</div>
<div class="jobs-detail-desc">${esc(job.description)}</div>
</div>
<div class="form-group">
<label class="form-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="job-enabled" ${job.enabled ? 'checked' : ''}
style="accent-color:var(--accent);width:13px;height:13px">
Enable scheduled runs
</label>
</div>
<div class="form-group">
<label class="form-label" for="job-schedule">Poll interval (minutes)</label>
<input class="form-input" id="job-schedule" type="number" min="1" value="${job.schedule}" style="max-width:100px">
</div>
${_renderJobConfigFields(job.key, cfg)}
<div class="job-actions">
<button class="btn btn-secondary" onclick="saveJobDetail(${job.id})">Save</button>
<button class="btn btn-secondary" id="job-run-btn" onclick="runJobNow(${job.id})">Run Now</button>
</div>
<div class="detail-section-title" style="margin:28px 0 10px">Run History</div>
${_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 `
<div class="form-group">
<label class="form-label" for="job-cfg-tailnet">Tailnet</label>
<input class="form-input" id="job-cfg-tailnet" type="text"
placeholder="e.g. Tt3Btpm6D921CNTRL" value="${esc(cfg.tailnet ?? '')}">
</div>
<div class="form-group">
<label class="form-label" for="job-cfg-api-key">API Key</label>
<input class="form-input" id="job-cfg-api-key" type="password"
placeholder="tskey-api-…" value="${esc(cfg.api_key ?? '')}">
</div>`;
return '';
}
function _renderRunList(runs) {
if (!runs?.length) return '<div class="run-empty">No runs yet</div>';
return `<div class="run-list">${runs.map(r => `
<div class="run-item">
<span class="job-dot job-dot--${r.status}"></span>
<span class="run-time">${fmtDateFull(r.started_at)}</span>
<span class="run-status">${esc(r.status)}</span>
<span class="run-result">${esc(r.result)}</span>
</div>`).join('')}</div>`;
}
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}`;
}