/* ─── UVM Dashboard ─── */ (function () { 'use strict'; // ─ State ─ const state = { token: sessionStorage.getItem('uvm_token') || '', baseUrl: location.origin, vms: [], health: null, diagnostics: null, selectedVmIds: new Set(), currentPage: 'overview', currentVmId: null, refreshTimer: null, }; // ─ Helpers ─ const $ = (sel, el=document) => el.querySelector(sel); const $$ = (sel, el=document) => [...el.querySelectorAll(sel)]; function formatRam(mib) { const value = Number(mib); if (!Number.isFinite(value)) return '—'; return value % 1024 === 0 ? `${value / 1024} GiB` : `${value} MiB`; } function statusClass(status) { if (status === 'running') return 'running'; if (status === 'stopped') return 'stopped'; return 'unknown'; } function showScreen(id) { $$('.screen').forEach(s => s.classList.remove('active')); $(id).classList.add('active'); } function showPage(name) { state.currentPage = name; $$('.page').forEach(p => p.classList.remove('active')); const page = $('#page-' + name); if (page) page.classList.add('active'); $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === name)); const titles = { overview: 'Overview', vms: 'Virtual Machines', 'vm-detail': 'VM Details', create: 'Create VM', system: 'System', }; $('#page-title').textContent = titles[name] || name; } function toast(message, type='success') { const t = document.createElement('div'); t.className = 'toast ' + type; t.textContent = message; $('#toast-container').appendChild(t); setTimeout(() => t.remove(), 3500); } async function api(path, opts={}) { const url = (state.baseUrl || '').replace(/\/$/, '') + path; const res = await fetch(url, { ...opts, headers: { 'Content-Type': 'application/json', 'X-UVM-Token': state.token, ...(opts.headers || {}), }, }); const text = await res.text(); let data = null; try { data = JSON.parse(text); } catch {} if (!res.ok) { const msg = data?.error?.message || `HTTP ${res.status}`; throw new Error(msg); } return data; } // ─ Auth ─ function initAuth() { if (state.token) { showScreen('#main-app'); showPage('overview'); startRefresh(); return; } showScreen('#auth-screen'); } $('#auth-btn').addEventListener('click', async () => { const token = $('#token-input').value.trim(); if (!token) { $('#auth-error').textContent = 'Token is required.'; return; } state.token = token; try { // Health is intentionally public, so verify the supplied token on a protected route. state.vms = await api('/vms') || []; sessionStorage.setItem('uvm_token', token); $('#token-input').value = ''; $('#auth-error').textContent = ''; showScreen('#main-app'); showPage('overview'); startRefresh(); toast('Connected successfully'); } catch (e) { $('#auth-error').textContent = e.message || 'Connection failed.'; state.token = ''; } }); $('#logout-btn').addEventListener('click', () => { state.token = ''; $('#token-input').value = ''; sessionStorage.removeItem('uvm_token'); localStorage.removeItem('uvm_token'); localStorage.removeItem('uvm_baseUrl'); stopRefresh(); showScreen('#auth-screen'); }); // ─ Data Loading ─ async function loadVms() { try { state.vms = await api('/vms') || []; const availableIds = new Set(state.vms.map(vm => vm.id)); state.selectedVmIds = new Set( [...state.selectedVmIds].filter(id => availableIds.has(id)), ); renderOverviewStats(); renderOverviewVMs(); renderVMsTable(); if (state.currentPage === 'vm-detail' && state.currentVmId) { renderVMDetail(state.currentVmId); } updateConnStatus(true); } catch (e) { updateConnStatus(false); console.error('loadVms failed', e); } } async function loadHealth() { try { state.health = await api('/health'); const el = $('#stat-health'); el.textContent = state.health?.status || 'ok'; el.className = 'stat-value text-green'; $('#health-ago').textContent = 'just now'; renderSystemHealth(); } catch (e) { $('#stat-health').textContent = 'unreachable'; $('#stat-health').className = 'stat-value text-red'; $('#health-ago').textContent = '—'; renderSystemHealth(); } } async function loadDiagnostics() { const list = $('#diagnostics-list'); list.innerHTML = '
Running host readiness checks…
'; try { state.diagnostics = await api('/diagnostics'); renderDiagnostics(); } catch (e) { state.diagnostics = null; list.innerHTML = `Diagnostics unavailable: ${esc(e.message)}
`; } } function renderDiagnostics() { const list = $('#diagnostics-list'); if (!state.diagnostics?.length) { list.innerHTML = 'No diagnostic checks returned.
'; return; } list.innerHTML = state.diagnostics.map((check) => `No virtual machines yet.
This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}
` : ports.length ? ports.map(p => `${p.port} / ${p.protocol}`).join(' ') : 'No port rules configured.
'; // Actions visibility $('#detail-start-btn').style.display = vm.status !== 'running' ? 'inline-flex' : 'none'; $('#detail-start-btn').onclick = () => startVM(vm.id); $('#detail-restart-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; $('#detail-restart-btn').onclick = () => restartVM(vm.id); $('#detail-stop-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; $('#detail-stop-btn').onclick = () => stopVM(vm.id); $('#detail-delete-btn').onclick = () => deleteVM(vm.id); // Port editing const portsInput = $('#detail-ports-input'); portsInput.value = ports.map(p => `${p.port}:${p.protocol}`).join(', '); $('#detail-ports-edit').style.display = 'none'; $('#detail-ports').style.display = 'block'; const canSetLegacyPorts = vm.ports === null && vm.status === 'stopped'; $('#detail-ports-edit-btn').style.display = vm.ports !== null || canSetLegacyPorts ? 'inline-flex' : 'none'; $('#detail-ports-edit-btn').onclick = () => { $('#detail-ports-edit').style.display = 'block'; $('#detail-ports').style.display = 'none'; }; $('#detail-ports-cancel').onclick = () => { $('#detail-ports-edit').style.display = 'none'; $('#detail-ports').style.display = 'block'; }; $('#detail-ports-set').onclick = () => updatePorts(vm.id, 'set'); $('#detail-ports-set').style.display = 'inline-flex'; $('#detail-ports-open').onclick = () => updatePorts(vm.id, 'open'); $('#detail-ports-open').style.display = vm.ports !== null ? 'inline-flex' : 'none'; $('#detail-ports-close').onclick = () => updatePorts(vm.id, 'close'); $('#detail-ports-close').style.display = vm.ports !== null ? 'inline-flex' : 'none'; $('#detail-ports-sync-btn').style.display = vm.ports !== null ? 'inline-flex' : 'none'; $('#detail-ports-sync-btn').onclick = () => syncPorts(vm.id); $('#detail-log-refresh-btn').onclick = () => loadVmLogs(vm.id); loadVmLogs(vm.id); } async function loadVmLogs(id) { const output = $('#detail-log-output'); const meta = $('#detail-log-meta'); output.textContent = 'Loading recent log entries…'; meta.textContent = 'Showing up to 100 recent lines.'; try { const log = await api(`/vms/${encodeURIComponent(id)}/logs?tail=100`); if (state.currentVmId !== id) return; output.textContent = log.content || 'No log entries yet.'; meta.textContent = log.truncated ? 'Showing the available tail; an oversized log line was truncated.' : 'Showing up to 100 recent lines.'; } catch (e) { if (state.currentVmId !== id) return; output.textContent = 'Log unavailable.'; meta.textContent = e.message; } } // ─ Actions ─ async function startVM(id) { try { await api(`/vms/${id}/start`, { method: 'POST' }); toast('VM started'); await loadVms(); if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); } catch (e) { toast('Start failed: ' + e.message, 'error'); } } async function restartVM(id) { showConfirm(`Restart ${esc(id)}?`, 'The VM will be stopped and then started again.', async () => { try { await api(`/vms/${id}/restart`, { method: 'POST' }); toast('VM restarted'); await loadVms(); if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); } catch (e) { toast('Restart failed: ' + e.message, 'error'); } }); } async function stopVM(id) { showConfirm(`Stop ${esc(id)}?`, 'The VM will be sent SIGTERM and SIGKILL if necessary. Guest data is retained.', async () => { try { await api(`/vms/${id}/stop`, { method: 'POST' }); toast('VM stopped'); await loadVms(); if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); } catch (e) { toast('Stop failed: ' + e.message, 'error'); } }); } async function deleteVM(id) { showConfirm(`Delete ${esc(id)}?`, 'This permanently removes the VM disk and releases its name and IP.', async () => { try { await api(`/vms/${id}`, { method: 'DELETE' }); toast('VM deleted'); if (state.currentPage === 'vm-detail') location.hash = '#vms'; await loadVms(); } catch (e) { toast('Delete failed: ' + e.message, 'error'); } }); } async function updatePorts(id, mode) { let ports; try { ports = parsePorts($('#detail-ports-input').value); } catch (e) { toast(e.message, 'error'); return; } const payload = { mode, ports }; try { await api(`/vms/${id}/ports`, { method: 'POST', body: JSON.stringify(payload) }); toast({ set: 'Ports updated', open: 'Ports opened', close: 'Ports closed' }[mode]); await loadVms(); if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); } catch (e) { toast('Port update failed: ' + e.message, 'error'); } } async function syncPorts(id) { try { await api(`/vms/${id}/ports/sync`, { method: 'POST' }); toast('Ports synced'); await loadVms(); if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); } catch (e) { toast('Port sync failed: ' + e.message, 'error'); } } // ─ Create VM ─ $('#create-submit').addEventListener('click', async () => { let ports; try { ports = parsePorts($('#create-ports').value); } catch (e) { showCreateError(e.message); return; } const body = { name: $('#create-name').value.trim() || undefined, cpu: parseInt($('#create-cpu').value, 10) || 1, ram: $('#create-ram').value.trim() || '512M', guest_ip: $('#create-guest-ip').value.trim() || undefined, username: $('#create-username').value.trim() || undefined, password: $('#create-password').value, ports, no_default_ports: $('#create-no-default-ports').checked, }; if (!body.password) { showCreateError('Password is required.'); return; } const errEl = $('#create-error'); errEl.classList.add('hidden'); try { await api('/vms', { method: 'POST', body: JSON.stringify(body) }); toast('VM created successfully'); resetCreateForm(); location.hash = '#vms'; await loadVms(); } catch (e) { showCreateError(e.message); } }); $('#create-cancel').addEventListener('click', () => { history.back(); }); function parsePorts(val) { if (!val.trim()) return []; return val.split(',').map((entry) => { const parts = entry.trim().split(':'); const port = Number(parts[0]); const protocol = (parts[1] || 'TCP').trim().toUpperCase(); if ( parts.length > 2 || !Number.isInteger(port) || port < 1 || port > 65535 || !['TCP', 'HTTP', 'HTTPS'].includes(protocol) ) { throw new Error(`Invalid port rule: ${entry.trim()}`); } return { port, protocol }; }); } async function copyText(value) { try { await navigator.clipboard.writeText(value); toast('Command copied'); } catch { const field = document.createElement('textarea'); field.value = value; field.style.position = 'fixed'; field.style.opacity = '0'; document.body.appendChild(field); field.select(); document.execCommand('copy'); field.remove(); toast('Command copied'); } } function showCreateError(msg) { const el = $('#create-error'); el.textContent = msg; el.classList.remove('hidden'); } function resetCreateForm() { $('#create-name').value = ''; $('#create-cpu').value = '1'; $('#create-ram').value = '512M'; $('#create-guest-ip').value = ''; $('#create-username').value = 'root'; $('#create-password').value = 'root'; $('#create-ports').value = '22:TCP'; $('#create-no-default-ports').checked = false; $('#create-error').classList.add('hidden'); } // ─ System ─ function renderSystemHealth() { const wrap = $('#system-health'); if (!state.health) { wrap.innerHTML = 'Checking…Send a test request to the management API.
'; return; } wrap.innerHTML = `HealthyAPI responding normally.
`; } $('#install-btn').addEventListener('click', async () => { const force = $('#install-force').checked; const result = $('#install-result'); result.classList.remove('hidden'); result.textContent = 'Installing…'; try { const data = await api('/install', { method: 'POST', body: JSON.stringify({ force }) }); result.textContent = JSON.stringify(data, null, 2); toast('Installation completed'); } catch (e) { result.textContent = 'Error: ' + e.message; toast('Installation failed: ' + e.message, 'error'); } }); $('#diagnostics-refresh-btn').addEventListener('click', loadDiagnostics); // ─ Router ─ function route() { const hash = location.hash.slice(1) || 'overview'; if (hash.startsWith('vm/')) { showPage('vm-detail'); renderVMDetail(hash.slice(3)); } else if (hash === 'vms') { showPage('vms'); renderVMsTable(); } else if (hash === 'create') { showPage('create'); } else if (hash === 'system') { showPage('system'); $('#info-api-url').textContent = state.baseUrl || '—'; $('#info-observed').textContent = new Date().toLocaleString(); loadDiagnostics(); } else { showPage('overview'); } } // ─ Modal ─ function showConfirm(title, body, onOk) { $('#confirm-title').innerHTML = title; $('#confirm-body').textContent = body; $('#confirm-modal').classList.add('open'); const okHandler = async () => { $('#confirm-modal').classList.remove('open'); cleanup(); await onOk(); }; const cancelHandler = () => { $('#confirm-modal').classList.remove('open'); cleanup(); }; function cleanup() { $('#confirm-ok').removeEventListener('click', okHandler); $('#confirm-cancel').removeEventListener('click', cancelHandler); $('#confirm-modal .modal-overlay').removeEventListener('click', cancelHandler); } $('#confirm-ok').addEventListener('click', okHandler); $('#confirm-cancel').addEventListener('click', cancelHandler); $('#confirm-modal .modal-overlay').addEventListener('click', cancelHandler); } // ─ Refresh ─ function startRefresh() { loadVms(); loadHealth(); state.refreshTimer = setInterval(() => { loadVms(); loadHealth(); }, 5000); } function stopRefresh() { if (state.refreshTimer) clearInterval(state.refreshTimer); state.refreshTimer = null; } $('#refresh-btn').addEventListener('click', () => { loadVms(); loadHealth(); if (state.currentPage === 'system') loadDiagnostics(); toast('Refreshed'); }); $('#create-vm-btn').addEventListener('click', () => { location.hash = '#create'; }); // Search / Filter $('#vm-search').addEventListener('input', renderVMsTable); $('#vm-filter-status').addEventListener('change', renderVMsTable); $('#vm-select-all').addEventListener('change', (event) => { $$('input[data-vm-select]', $('#vms-table tbody')).forEach((checkbox) => { if (event.target.checked) state.selectedVmIds.add(checkbox.dataset.vmSelect); else state.selectedVmIds.delete(checkbox.dataset.vmSelect); }); renderVMsTable(); }); $('#vms-table tbody').addEventListener('click', (event) => { const selection = event.target.closest('input[data-vm-select]'); if (selection) { if (selection.checked) state.selectedVmIds.add(selection.dataset.vmSelect); else state.selectedVmIds.delete(selection.dataset.vmSelect); renderVMsTable(); return; } const button = event.target.closest('button[data-vm-action]'); if (!button) return; const { vmAction, vmId } = button.dataset; if (vmAction === 'view') location.hash = `#vm/${vmId}`; if (vmAction === 'start') startVM(vmId); if (vmAction === 'restart') restartVM(vmId); if (vmAction === 'stop') stopVM(vmId); if (vmAction === 'delete') deleteVM(vmId); }); $('#bulk-run-btn').addEventListener('click', () => { const action = $('#bulk-action').value; const ids = [...state.selectedVmIds]; const labels = { start: 'Start', restart: 'Restart', stop: 'Stop', 'ports-sync': 'Sync ports for', delete: 'Delete', }; const detail = action === 'delete' ? 'This permanently removes every selected VM disk and releases its resources.' : `${labels[action]} ${ids.length} selected VM${ids.length === 1 ? '' : 's'}.`; showConfirm( `${labels[action]} ${ids.length} VM${ids.length === 1 ? '' : 's'}?`, detail, async () => { try { const results = await api('/vms/actions', { method: 'POST', body: JSON.stringify({ action, identifiers: ids }), }); const failed = results.filter(result => result.outcome !== 'succeeded'); state.selectedVmIds.clear(); await loadVms(); toast( failed.length ? `${results.length - failed.length} succeeded, ${failed.length} failed` : `${results.length} VM${results.length === 1 ? '' : 's'} updated`, failed.length ? 'error' : 'success', ); } catch (e) { toast(`Bulk ${action} failed: ${e.message}`, 'error'); } }, ); }); // ─ Utils ─ function esc(s) { if (s == null) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ─ Boot ─ window.addEventListener('hashchange', route); initAuth(); route(); })();