// 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 = `
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);
});
}
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-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 = 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,
};
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() {
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 } = 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();
});
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();
});