feat: Tailscale sync jobs
All checks were successful
CI / test (pull_request) Successful in 14s
CI / build-dev (pull_request) Has been skipped

Adds a background job system that polls the Tailscale API on a configurable
interval and syncs tailscale status and IPs to instances by hostname match.

- New config table (key/value) in SQLite for persistent server-side settings
- New server/jobs.js: runTailscaleSync + restartJobs scheduler
- GET/PUT /api/config — read and write Tailscale settings; API key masked as **REDACTED** on GET
- POST /api/jobs/tailscale/run — immediate manual sync
- Settings modal: new Tailscale Sync section with enable toggle, tailnet, API key, poll interval, Save + Run Now buttons, last-run status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:11:40 -04:00
parent 31a5090f4f
commit 47e9c4faf7
8 changed files with 308 additions and 0 deletions

View File

@@ -353,6 +353,7 @@ function openSettingsModal() {
}
}
sel.value = getTimezone();
loadTailscaleSettings();
document.getElementById('settings-modal').classList.add('open');
}
@@ -424,3 +425,60 @@ document.getElementById('tz-select').addEventListener('change', e => {
if (m) renderDetailPage(parseInt(m[1], 10));
else renderDashboard();
});
// ── Tailscale Settings ────────────────────────────────────────────────────────
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 */ }
}
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 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', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
showToast(res.ok ? 'Tailscale settings saved' : 'Failed to save settings', res.ok ? 'success' : 'error');
}
async function runTailscaleNow() {
const btn = document.getElementById('ts-run-btn');
btn.disabled = true;
btn.textContent = 'Running…';
try {
const res = await fetch('/api/jobs/tailscale/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';
}
}