Files
Catalyst/js/ui.js
josh 12125e8942
Some checks failed
Deploy / deploy (push) Has been cancelled
oops
2026-03-27 22:57:13 -04:00

279 lines
13 KiB
JavaScript

// Module-level UI state
let editingId = 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 ─────────────────────────────────────────────────────────────────
function renderDashboard() {
const all = 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 = `
<div class="stat-cell"><div class="stat-label">total</div><div class="stat-value accent">${all.length}</div></div>
<div class="stat-cell"><div class="stat-label">deployed</div><div class="stat-value">${states['deployed'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">testing</div><div class="stat-value amber">${states['testing'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">degraded</div><div class="stat-value red">${states['degraded'] || 0}</div></div>
<div class="stat-cell"><div class="stat-label">stacks</div><div class="stat-value">${getDistinctStacks().length}</div></div>
`;
populateStackFilter();
filterInstances();
}
function populateStackFilter() {
const select = document.getElementById('filter-stack');
const current = select.value;
select.innerHTML = '<option value="">all stacks</option>';
getDistinctStacks().forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (s === current) opt.selected = true;
select.appendChild(opt);
});
}
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 = getInstances({ search, state, stack });
const grid = document.getElementById('instance-grid');
if (!instances.length) {
grid.innerHTML = `<div class="empty-state"><div class="empty-icon">⊘</div><p>no instances match the current filters</p></div>`;
return;
}
grid.innerHTML = instances.map(inst => {
const dots = CARD_SERVICES.map(s =>
`<div class="svc-dot ${inst[s] ? 'on' : ''}" title="${s}"></div>`
).join('');
const activeCount = CARD_SERVICES.filter(s => inst[s]).length;
return `
<div class="instance-card state-${esc(inst.state)}" onclick="navigate('instance', ${inst.vmid})">
<div class="card-top">
<div>
<div class="card-name">${esc(inst.name)}</div>
<div class="card-vmid">vmid: ${inst.vmid}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<div class="badge ${esc(inst.state)}">${esc(inst.state)}</div>
<div class="badge ${esc(inst.stack)}">${esc(inst.stack)}</div>
</div>
</div>
<div class="card-services">
${dots}
<span class="svc-label">${activeCount} service${activeCount !== 1 ? 's' : ''} active</span>
</div>
${inst.tailscale_ip ? `<div class="card-ip">ts: <span>${esc(inst.tailscale_ip)}</span></div>` : ''}
</div>`;
}).join('');
}
// ── Detail Page ───────────────────────────────────────────────────────────────
function renderDetailPage(vmid) {
const inst = 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.createdAt);
document.getElementById('detail-identity').innerHTML = `
<div class="kv-row"><span class="kv-key">name</span><span class="kv-val highlight">${esc(inst.name)}</span></div>
<div class="kv-row"><span class="kv-key">state</span><span class="kv-val"><span class="badge ${esc(inst.state)}">${esc(inst.state)}</span></span></div>
<div class="kv-row"><span class="kv-key">stack</span><span class="kv-val highlight">${esc(inst.stack) || '—'}</span></div>
<div class="kv-row"><span class="kv-key">vmid</span><span class="kv-val highlight">${inst.vmid}</span></div>
<div class="kv-row"><span class="kv-key">internal id</span><span class="kv-val">${inst.id}</span></div>
`;
document.getElementById('detail-network').innerHTML = `
<div class="kv-row"><span class="kv-key">tailscale ip</span><span class="kv-val highlight">${esc(inst.tailscale_ip) || '—'}</span></div>
<div class="kv-row"><span class="kv-key">tailscale</span><span class="kv-val ${inst.tailscale ? 'on' : 'off'}">${inst.tailscale ? 'enabled' : 'disabled'}</span></div>
`;
document.getElementById('detail-services').innerHTML = [
...DETAIL_SERVICES.map(s => ({ key: s, label: s })),
{ key: 'hardware_acceleration', label: 'hw acceleration' },
].map(({ key, label }) => `
<div class="svc-item">
<span class="svc-name">${esc(label)}</span>
<span class="svc-status ${inst[key] ? 'on' : 'off'}">${inst[key] ? 'enabled' : 'disabled'}</span>
</div>
`).join('');
document.getElementById('detail-timestamps').innerHTML = `
<div class="kv-row"><span class="kv-key">created</span><span class="kv-val">${fmtDateFull(inst.createdAt)}</span></div>
<div class="kv-row"><span class="kv-key">updated</span><span class="kv-val">${fmtDateFull(inst.updatedAt)}</span></div>
`;
document.getElementById('detail-edit-btn').onclick = () => openEditModal(inst.vmid);
document.getElementById('detail-delete-btn').onclick = () => confirmDeleteDialog(inst);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
function openNewModal() {
editingId = null;
document.getElementById('modal-title').textContent = 'new instance';
clearForm();
document.getElementById('instance-modal').classList.add('open');
}
function openEditModal(vmid) {
const inst = getInstance(vmid);
if (!inst) return;
editingId = inst.id;
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; });
}
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;
const tip = document.getElementById('f-tailscale-ip').value.trim();
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: tip,
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 = editingId ? updateInstance(editingId, data) : createInstance(data);
if (!result.ok) {
showToast(result.error.includes('UNIQUE') ? 'vmid already exists' : 'error saving instance', 'error');
return;
}
showToast(`${name} ${editingId ? 'updated' : 'created'}`, 'success');
closeModal();
if (currentVmid && document.getElementById('page-detail').classList.contains('active')) {
renderDetailPage(vmid);
} else {
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.id, inst.name);
document.getElementById('confirm-overlay').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirm-overlay').classList.remove('open');
}
function doDelete(id, name) {
deleteInstance(id);
closeConfirm();
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);
}
// ── Global keyboard handler ───────────────────────────────────────────────────
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; }
});
// Close modals on backdrop click
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();
});