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:
12
js/app.js
12
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();
|
||||
|
||||
136
js/ui.js
136
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 => `
|
||||
<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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user