// Module-level UI state let editingVmid = null; let currentVmid = null; let toastTimer = null; // ── Helpers ─────────────────────────────────────────────────────────────────── function esc(str) { const d = document.createElement('div'); d.textContent = (str == null) ? '' : String(str); return d.innerHTML; } function fmtDate(d) { if (!d) return '—'; try { return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return d; } } function fmtDateFull(d) { if (!d) return '—'; try { return new Date(d).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } 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}
stacks
${(await getDistinctStacks()).length}
`; 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); }); } 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 ─────────────────────────────────────────────────────────────── async function renderDetailPage(vmid) { const inst = await getInstance(vmid); if (!inst) { navigate('dashboard'); return; } currentVmid = vmid; 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-id-sub').textContent = inst.id; 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}
internal id${inst.id}
`; 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 = `
created${fmtDateFull(inst.created_at)}
updated${fmtDateFull(inst.updated_at)}
`; 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, }; 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 (currentVmid && document.getElementById('page-detail').classList.contains('active')) { await renderDetailPage(vmid); } else { await renderDashboard(); } } // ── 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() { 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 } = JSON.parse(await file.text()); const res = await fetch('/api/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instances }), }); const data = await res.json(); if (!res.ok) { showToast(data.error ?? 'Import failed', 'error'); return; } showToast(`Imported ${data.imported} instance${data.imported !== 1 ? 's' : ''}`, '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(); });