// Module-level UI state let editingVmid = null; let currentVmid = null; let toastTimer = null; // ── Timezone ────────────────────────────────────────────────────────────────── const TIMEZONES = [ { label: 'UTC', tz: 'UTC' }, { label: 'Hawaii (HST)', tz: 'Pacific/Honolulu' }, { label: 'Alaska (AKT)', tz: 'America/Anchorage' }, { label: 'Pacific (PT)', tz: 'America/Los_Angeles' }, { label: 'Mountain (MT)', tz: 'America/Denver' }, { label: 'Central (CT)', tz: 'America/Chicago' }, { label: 'Eastern (ET)', tz: 'America/New_York' }, { label: 'Atlantic (AT)', tz: 'America/Halifax' }, { label: 'London (GMT/BST)', tz: 'Europe/London' }, { label: 'Paris / Berlin (CET)', tz: 'Europe/Paris' }, { label: 'Helsinki (EET)', tz: 'Europe/Helsinki' }, { label: 'Istanbul (TRT)', tz: 'Europe/Istanbul' }, { label: 'Dubai (GST)', tz: 'Asia/Dubai' }, { label: 'India (IST)', tz: 'Asia/Kolkata' }, { label: 'Singapore (SGT)', tz: 'Asia/Singapore' }, { label: 'China (CST)', tz: 'Asia/Shanghai' }, { label: 'Japan / Korea (JST/KST)', tz: 'Asia/Tokyo' }, { label: 'Sydney (AEST)', tz: 'Australia/Sydney' }, { label: 'Auckland (NZST)', tz: 'Pacific/Auckland' }, ]; function getTimezone() { return localStorage.getItem('catalyst_tz') || 'UTC'; } // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(str) { const d = document.createElement('div'); d.textContent = (str == null) ? '' : String(str); return d.innerHTML; } // SQLite datetime('now') → 'YYYY-MM-DD HH:MM:SS' (UTC, no timezone marker). // Appending 'Z' tells JS to parse it as UTC rather than local time. function parseUtc(d) { if (typeof d !== 'string') return new Date(d); const hasZone = d.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(d); return new Date(hasZone ? d : d.replace(' ', 'T') + 'Z'); } function fmtDate(d) { if (!d) return '—'; try { return parseUtc(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: getTimezone() }); } catch (e) { return d; } } function fmtDateFull(d) { if (!d) return '—'; try { return parseUtc(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: getTimezone(), timeZoneName: 'short' }); } catch (e) { return d; } } // ── Dashboard ───────────────────────────────────────────────────────────────── async function renderDashboard() { const all = await getInstances(); document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`; const states = {}; all.forEach(i => { states[i.state] = (states[i.state] || 0) + 1; }); document.getElementById('stats-bar').innerHTML = `
total
${all.length}
deployed
${states['deployed'] || 0}
testing
${states['testing'] || 0}
degraded
${states['degraded'] || 0}
`; await populateStackFilter(); await filterInstances(); } async function populateStackFilter() { const select = document.getElementById('filter-stack'); const current = select.value; select.innerHTML = ''; const stacks = await getDistinctStacks(); stacks.forEach(s => { const opt = document.createElement('option'); opt.value = s; opt.textContent = s; if (s === current) opt.selected = true; select.appendChild(opt); }); } function setStateFilter(state) { document.getElementById('filter-state').value = state; filterInstances(); } async function filterInstances() { const search = document.getElementById('search-input').value; const state = document.getElementById('filter-state').value; const stack = document.getElementById('filter-stack').value; const instances = await getInstances({ search, state, stack }); const grid = document.getElementById('instance-grid'); if (!instances.length) { grid.innerHTML = `

no instances match the current filters

`; return; } grid.innerHTML = instances.map(inst => { const dots = CARD_SERVICES.map(s => `
` ).join(''); const activeCount = CARD_SERVICES.filter(s => inst[s]).length; return `
${esc(inst.name)}
vmid: ${inst.vmid}
${esc(inst.state)}
${esc(inst.stack)}
${dots} ${activeCount} service${activeCount !== 1 ? 's' : ''} active
${inst.tailscale_ip ? `
ts: ${esc(inst.tailscale_ip)}
` : ''}
`; }).join(''); } // ── Detail Page ─────────────────────────────────────────────────────────────── const BOOL_FIELDS = ['atlas','argus','semaphore','patchmon','tailscale','andromeda','hardware_acceleration']; const FIELD_LABELS = { name: 'name', state: 'state', stack: 'stack', vmid: 'vmid', tailscale_ip: 'tailscale ip', atlas: 'atlas', argus: 'argus', semaphore: 'semaphore', patchmon: 'patchmon', tailscale: 'tailscale', andromeda: 'andromeda', hardware_acceleration: 'hw acceleration', }; function stateClass(field, val) { if (field !== 'state') return ''; return { deployed: 'tl-deployed', testing: 'tl-testing', degraded: 'tl-degraded' }[val] ?? ''; } function fmtHistVal(field, val) { if (val == null || val === '') return '—'; if (BOOL_FIELDS.includes(field)) return val === '1' ? 'on' : 'off'; return esc(val); } async function renderDetailPage(vmid) { const [inst, history, all] = await Promise.all([getInstance(vmid), getInstanceHistory(vmid), getInstances()]); if (!inst) { navigate('dashboard'); return; } currentVmid = vmid; document.getElementById('nav-count').textContent = `${all.length} instance${all.length !== 1 ? 's' : ''}`; document.getElementById('detail-vmid-crumb').textContent = vmid; document.getElementById('detail-name').textContent = inst.name; document.getElementById('detail-vmid-sub').textContent = inst.vmid; document.getElementById('detail-created-sub').textContent = fmtDate(inst.created_at); document.getElementById('detail-identity').innerHTML = `
name${esc(inst.name)}
state${esc(inst.state)}
stack${esc(inst.stack) || '—'}
vmid${inst.vmid}
`; document.getElementById('detail-network').innerHTML = `
tailscale ip${esc(inst.tailscale_ip) || '—'}
tailscale${inst.tailscale ? 'enabled' : 'disabled'}
`; document.getElementById('detail-services').innerHTML = [ ...DETAIL_SERVICES.map(s => ({ key: s, label: s })), { key: 'hardware_acceleration', label: 'hw acceleration' }, ].map(({ key, label }) => `
${esc(label)} ${inst[key] ? 'enabled' : 'disabled'}
`).join(''); document.getElementById('detail-timestamps').innerHTML = history.length ? history.map(e => { if (e.field === 'created') return `
instance created ${fmtDateFull(e.changed_at)}
`; const label = FIELD_LABELS[e.field] ?? esc(e.field); const newCls = (e.field === 'state' || e.field === 'stack') ? `badge ${esc(e.new_value)}` : `tl-new ${stateClass(e.field, e.new_value)}`; return `
${label} · ${fmtHistVal(e.field, e.old_value)} ${fmtHistVal(e.field, e.new_value)}
${fmtDateFull(e.changed_at)}
`; }).join('') : '
no history yet
'; document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid); document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst); } // ── Modal ───────────────────────────────────────────────────────────────────── function openNewModal() { editingVmid = null; document.getElementById('modal-title').textContent = 'new instance'; clearForm(); document.getElementById('instance-modal').classList.add('open'); } async function openEditModal(vmid) { const inst = await getInstance(vmid); if (!inst) return; editingVmid = inst.vmid; document.getElementById('modal-title').textContent = `edit / ${inst.name}`; document.getElementById('f-name').value = inst.name; document.getElementById('f-vmid').value = inst.vmid; document.getElementById('f-state').value = inst.state; document.getElementById('f-stack').value = inst.stack; document.getElementById('f-tailscale-ip').value = inst.tailscale_ip; document.getElementById('f-atlas').checked = !!inst.atlas; document.getElementById('f-argus').checked = !!inst.argus; document.getElementById('f-semaphore').checked = !!inst.semaphore; document.getElementById('f-patchmon').checked = !!inst.patchmon; document.getElementById('f-tailscale').checked = !!inst.tailscale; document.getElementById('f-andromeda').checked = !!inst.andromeda; document.getElementById('f-hardware-accel').checked = !!inst.hardware_acceleration; document.getElementById('instance-modal').classList.add('open'); } function closeModal() { document.getElementById('instance-modal').classList.remove('open'); } function clearForm() { document.getElementById('f-name').value = ''; document.getElementById('f-vmid').value = ''; document.getElementById('f-tailscale-ip').value = ''; document.getElementById('f-state').value = 'deployed'; document.getElementById('f-stack').value = 'production'; ['f-atlas', 'f-argus', 'f-semaphore', 'f-patchmon', 'f-tailscale', 'f-andromeda', 'f-hardware-accel'] .forEach(id => { document.getElementById(id).checked = false; }); } async function saveInstance() { const name = document.getElementById('f-name').value.trim(); const vmid = parseInt(document.getElementById('f-vmid').value, 10); const state = document.getElementById('f-state').value; const stack = document.getElementById('f-stack').value; if (!name) { showToast('name is required', 'error'); return; } if (!vmid || vmid < 1) { showToast('a valid vmid is required', 'error'); return; } const data = { name, state, stack, vmid, tailscale_ip: document.getElementById('f-tailscale-ip').value.trim(), atlas: +document.getElementById('f-atlas').checked, argus: +document.getElementById('f-argus').checked, semaphore: +document.getElementById('f-semaphore').checked, patchmon: +document.getElementById('f-patchmon').checked, tailscale: +document.getElementById('f-tailscale').checked, andromeda: +document.getElementById('f-andromeda').checked, hardware_acceleration: +document.getElementById('f-hardware-accel').checked, }; // Snapshot job state before creation — jobs fire immediately after the 201 // so the baseline must be captured before the POST, not after. const jobBaseline = !editingVmid ? await _snapshotJobBaseline() : null; const result = editingVmid ? await updateInstance(editingVmid, data) : await createInstance(data); if (!result.ok) { showToast(result.error, 'error'); return; } showToast(`${name} ${editingVmid ? 'updated' : 'created'}`, 'success'); closeModal(); if (jobBaseline) await _waitForOnCreateJobs(jobBaseline); if (currentVmid && document.getElementById('page-detail').classList.contains('active')) { await renderDetailPage(vmid); } else { await renderDashboard(); } } async function _snapshotJobBaseline() { const jobs = await fetch('/api/jobs').then(r => r.json()); return new Map(jobs.map(j => [j.id, j.last_run_id ?? null])); } async function _waitForOnCreateJobs(baseline) { const jobs = await fetch('/api/jobs').then(r => r.json()); const relevant = jobs.filter(j => (j.config ?? {}).run_on_create); if (!relevant.length) return; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise(r => setTimeout(r, 500)); const current = await fetch('/api/jobs').then(r => r.json()); const allDone = relevant.every(j => { const cur = current.find(c => c.id === j.id); if (!cur) return true; if (cur.last_run_id === baseline.get(j.id)) return false; // new run not started yet return cur.last_status !== 'running'; // new run complete }); if (allDone) return; } } // ── Confirm Dialog ──────────────────────────────────────────────────────────── function confirmDeleteDialog(inst) { if (inst.stack !== 'development') { showToast(`demote ${inst.name} to development before deleting`, 'error'); return; } document.getElementById('confirm-title').textContent = `delete ${inst.name}?`; document.getElementById('confirm-msg').textContent = `This will permanently remove instance "${inst.name}" (vmid: ${inst.vmid}) from Catalyst. This action cannot be undone.`; document.getElementById('confirm-ok').onclick = () => doDelete(inst.vmid, inst.name); document.getElementById('confirm-overlay').classList.add('open'); } function closeConfirm() { document.getElementById('confirm-overlay').classList.remove('open'); } async function doDelete(vmid, name) { closeConfirm(); await deleteInstance(vmid); showToast(`${name} deleted`, 'success'); navigate('dashboard'); } // ── Toast ───────────────────────────────────────────────────────────────────── function showToast(msg, type = 'success') { const t = document.getElementById('toast'); document.getElementById('toast-msg').textContent = msg; t.className = `toast ${type} show`; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 3000); } // ── Settings Modal ──────────────────────────────────────────────────────────── function openSettingsModal() { const sel = document.getElementById('tz-select'); if (!sel.options.length) { for (const { label, tz } of TIMEZONES) { const opt = document.createElement('option'); opt.value = tz; opt.textContent = label; sel.appendChild(opt); } } sel.value = getTimezone(); document.getElementById('settings-modal').classList.add('open'); } function closeSettingsModal() { document.getElementById('settings-modal').classList.remove('open'); document.getElementById('import-file').value = ''; } async function exportDB() { const res = await fetch('/api/export'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `catalyst-backup-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); } async function importDB() { const file = document.getElementById('import-file').files[0]; if (!file) { showToast('Select a backup file first', 'error'); return; } document.getElementById('confirm-title').textContent = 'Replace all instances?'; document.getElementById('confirm-msg').textContent = `This will delete all current instances and replace them with the contents of "${file.name}". This cannot be undone.`; document.getElementById('confirm-overlay').classList.add('open'); document.getElementById('confirm-ok').onclick = async () => { closeConfirm(); try { const { instances, history = [], jobs, job_runs } = JSON.parse(await file.text()); const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instances, history, jobs, job_runs }), }); const data = await res.json(); if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } const parts = [`${data.imported} instance${data.imported !== 1 ? 's' : ''}`]; if (data.imported_jobs != null) parts.push(`${data.imported_jobs} job${data.imported_jobs !== 1 ? 's' : ''}`); showToast(`Imported ${parts.join(', ')}`, 'success'); closeSettingsModal(); renderDashboard(); } catch { showToast('Invalid backup file', 'error'); } }; } // ── Keyboard / backdrop ─────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.key !== 'Escape') return; if (document.getElementById('instance-modal').classList.contains('open')) { closeModal(); return; } if (document.getElementById('confirm-overlay').classList.contains('open')) { closeConfirm(); return; } if (document.getElementById('settings-modal').classList.contains('open')) { closeSettingsModal(); return; } }); document.getElementById('instance-modal').addEventListener('click', e => { if (e.target === document.getElementById('instance-modal')) closeModal(); }); document.getElementById('confirm-overlay').addEventListener('click', e => { if (e.target === document.getElementById('confirm-overlay')) closeConfirm(); }); document.getElementById('settings-modal').addEventListener('click', e => { if (e.target === document.getElementById('settings-modal')) closeSettingsModal(); }); document.getElementById('tz-select').addEventListener('change', e => { localStorage.setItem('catalyst_tz', e.target.value); const m = window.location.pathname.match(/^\/instance\/(\d+)/); if (m) renderDetailPage(parseInt(m[1], 10)); else renderDashboard(); }); // ── Jobs Page ───────────────────────────────────────────────────────────────── 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 => `
${esc(j.name)}
`).join('') : '
No jobs
'; if (jobs.length) loadJobDetail(jobs[0].id); } 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 = `
${esc(job.name)}
${esc(job.description)}
${_renderJobConfigFields(job.key, cfg)}
Run History
${_renderRunList(job.runs)} `; } function _renderJobConfigFields(key, cfg) { if (key === 'tailscale_sync') return `
`; if (key === 'patchmon_sync' || key === 'semaphore_sync') { const label = key === 'semaphore_sync' ? 'API Token (Bearer)' : 'API Token (Basic)'; return `
`; } return ''; } function _renderRunList(runs) { if (!runs?.length) return '
No runs yet
'; return `
${runs.map(r => `
${fmtDateFull(r.started_at)} ${esc(r.status)} ${esc(r.result)}
`).join('')}
`; } 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'); const apiUrl = document.getElementById('job-cfg-api-url'); const apiToken = document.getElementById('job-cfg-api-token'); if (tailnet) cfg.tailnet = tailnet.value.trim(); if (apiKey) cfg.api_key = apiKey.value; if (apiUrl) cfg.api_url = apiUrl.value.trim(); if (apiToken) cfg.api_token = apiToken.value; const runOnCreate = document.getElementById('job-run-on-create'); if (runOnCreate) cfg.run_on_create = runOnCreate.checked; const res = await fetch(`/api/jobs/${jobId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, schedule: parseInt(schedule, 10), config: cfg }), }); if (res.ok) { showToast('Job saved', 'success'); loadJobDetail(jobId); } else { showToast('Failed to save', 'error'); } } async function runJobNow(jobId) { const btn = document.getElementById('job-run-btn'); btn.disabled = true; btn.textContent = 'Running…'; try { const res = await fetch(`/api/jobs/${jobId}/run`, { method: 'POST' }); const data = await res.json(); 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}`; }