feat: Implement UVM Dashboard with frontend and backend integration

- Added dashboard.js for frontend functionality, including VM management and system health monitoring.
- Created dashboard route to serve the dashboard HTML.
- Introduced diagnostics route for token-protected host diagnostics.
- Enhanced VMs router with bulk actions, VM log retrieval, and port management.
- Updated lifecycle service to read VM log tails with truncation handling.
- Integrated static file serving for dashboard assets in the FastAPI server.
- Redirect root URL to the dashboard for improved user experience.
This commit is contained in:
2026-09-19 10:50:29 +05:30
parent 3d7af4adb8
commit b3b1b527e1
17 changed files with 2285 additions and 69 deletions
+739
View File
@@ -0,0 +1,739 @@
/* ─── 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 = '<p class="muted">Running host readiness checks…</p>';
try {
state.diagnostics = await api('/diagnostics');
renderDiagnostics();
} catch (e) {
state.diagnostics = null;
list.innerHTML = `<p class="muted">Diagnostics unavailable: ${esc(e.message)}</p>`;
}
}
function renderDiagnostics() {
const list = $('#diagnostics-list');
if (!state.diagnostics?.length) {
list.innerHTML = '<p class="muted">No diagnostic checks returned.</p>';
return;
}
list.innerHTML = state.diagnostics.map((check) => `
<div class="diagnostic-item">
<span class="diagnostic-mark ${check.ok ? 'ok' : 'error'}"></span>
<div>
<div class="diagnostic-name">${esc(check.name)}</div>
<div class="diagnostic-message">${esc(check.message)}</div>
</div>
</div>
`).join('');
}
function updateConnStatus(ok) {
const dot = $('#conn-dot');
const label = $('#conn-label');
dot.classList.toggle('offline', !ok);
label.textContent = ok ? 'Connected' : 'Disconnected';
}
function renderOverviewStats() {
const total = state.vms.length;
const running = state.vms.filter(v => v.status === 'running').length;
const stopped = state.vms.filter(v => v.status !== 'running').length;
$('#stat-total').textContent = total;
$('#stat-running').textContent = running;
$('#stat-stopped').textContent = stopped;
}
function renderOverviewVMs() {
const container = $('#overview-vm-list');
if (!state.vms.length) {
container.innerHTML = `
<div class="empty-state">
<p>No virtual machines yet.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create your first VM</button>
</div>`;
return;
}
const recent = state.vms.slice(0, 6);
container.innerHTML = recent.map(vm => `
<div class="vm-card" onclick="location.hash='#vm/${vm.id}'">
<div class="vm-card-header">
<div>
<div class="vm-card-name">${esc(vm.name || vm.id)}</div>
<div class="vm-card-ip">${esc(vm.guest_ip || '—')}</div>
</div>
<span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span>
</div>
<div class="vm-card-meta">
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/></svg> ${vm.cpu || 1} vCPU</span>
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/></svg> ${formatRam(vm.ram_mib)}</span>
</div>
</div>
`).join('');
}
function renderVMsTable() {
const tbody = $('#vms-table tbody');
const empty = $('#vms-empty');
const selectAll = $('#vm-select-all');
const filterStatus = $('#vm-filter-status').value;
const search = $('#vm-search').value.trim().toLowerCase();
let list = state.vms.slice();
if (filterStatus !== 'all') {
list = list.filter(v => v.status === filterStatus);
}
if (search) {
list = list.filter(v =>
(v.name || '').toLowerCase().includes(search) ||
(v.id || '').toLowerCase().includes(search) ||
(v.guest_ip || '').includes(search)
);
}
if (!list.length) {
tbody.innerHTML = '';
empty.classList.add('visible');
selectAll.checked = false;
selectAll.indeterminate = false;
renderBulkControls();
return;
}
empty.classList.remove('visible');
tbody.innerHTML = list.map(vm => {
const ports = (vm.ports || []).map(p => `<span class="port-chip">${p.port}:${p.protocol}</span>`).join(' ') || '—';
const actionBtns = [];
if (vm.status === 'running') {
actionBtns.push(`<button class="btn btn-ghost btn-sm" data-vm-action="restart" data-vm-id="${esc(vm.id)}">Restart</button>`);
actionBtns.push(`<button class="btn btn-warning btn-sm" data-vm-action="stop" data-vm-id="${esc(vm.id)}">Stop</button>`);
} else {
actionBtns.push(`<button class="btn btn-primary btn-sm" data-vm-action="start" data-vm-id="${esc(vm.id)}">Start</button>`);
}
return `
<tr>
<td class="selection"><input type="checkbox" data-vm-select="${esc(vm.id)}" aria-label="Select ${esc(vm.name || vm.id)}" ${state.selectedVmIds.has(vm.id) ? 'checked' : ''} /></td>
<td>
<div style="font-weight:600;font-size:13px;">${esc(vm.name || '—')}</div>
<div style="font-size:11px;color:var(--text-muted);font-family:monospace;">${esc(vm.id)}</div>
</td>
<td><span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span></td>
<td style="font-family:monospace;font-size:12px;">${esc(vm.guest_ip || '—')}</td>
<td>${vm.cpu || 1} vCPU · ${formatRam(vm.ram_mib)}</td>
<td>${ports}</td>
<td><span class="badge badge-${vm.policy_status === 'enforced' ? 'running' : vm.policy_status === 'inactive' ? 'stopped' : 'unknown'}">${esc(vm.policy_status || '—')}</span></td>
<td class="actions">
<button class="btn btn-ghost btn-sm" data-vm-action="view" data-vm-id="${esc(vm.id)}">View</button>
${actionBtns.join('')}
<button class="btn btn-danger btn-sm" data-vm-action="delete" data-vm-id="${esc(vm.id)}">Delete</button>
</td>
</tr>
`;
}).join('');
const selectedVisible = list.filter(vm => state.selectedVmIds.has(vm.id)).length;
selectAll.checked = selectedVisible === list.length;
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < list.length;
renderBulkControls();
}
function renderBulkControls() {
const count = state.selectedVmIds.size;
$('#bulk-controls').classList.toggle('visible', count > 0);
$('#bulk-selection-count').textContent = `${count} selected`;
}
// ─ VM Details ─
async function renderVMDetail(id) {
let vm = state.vms.find(v => v.id === id || v.name === id || v.guest_ip === id);
if (!vm) {
try { vm = await api(`/vms/${id}`); } catch (e) {
toast('Failed to load VM details: ' + e.message, 'error');
location.hash = '#vms';
return;
}
}
state.currentVmId = vm.id;
$('#detail-name').textContent = vm.name || vm.id;
$('#detail-status').textContent = vm.status || 'unknown';
$('#detail-status').className = 'badge badge-' + statusClass(vm.status);
$('#detail-config').innerHTML = `
<div class="detail-row"><span class="detail-key">ID</span><span class="detail-value">${esc(vm.id)}</span></div>
<div class="detail-row"><span class="detail-key">Name</span><span class="detail-value">${esc(vm.name || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Status</span><span class="detail-value">${esc(vm.status)}</span></div>
<div class="detail-row"><span class="detail-key">Operation</span><span class="detail-value">${esc(vm.operation || '—')}</span></div>
<div class="detail-row"><span class="detail-key">vCPUs</span><span class="detail-value">${vm.cpu || 1}</span></div>
<div class="detail-row"><span class="detail-key">RAM</span><span class="detail-value">${formatRam(vm.ram_mib)}</span></div>
<div class="detail-row"><span class="detail-key">Guest User</span><span class="detail-value">${esc(vm.username || '—')}</span></div>
`;
$('#detail-network').innerHTML = `
<div class="detail-row"><span class="detail-key">Guest IP</span><span class="detail-value">${esc(vm.guest_ip || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Observed Status</span><span class="detail-value">${esc(vm.observed_status || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Policy Status</span><span class="detail-value">${esc(vm.policy_status || '—')}</span></div>
`;
const sshCommand = `ssh ${vm.username || 'root'}@${vm.guest_ip}`;
const sshAllowed = (vm.ports || []).some(port => port.port === 22);
$('#detail-ssh-command').textContent = sshCommand;
$('#detail-ssh-copy-btn').onclick = () => copyText(sshCommand);
$('#detail-ssh-hint').textContent = sshAllowed
? 'Run this from a trusted local terminal; UVM does not expose guest passwords.'
: 'TCP/22 is not currently allowed by this VM policy.';
const ports = vm.ports || [];
$('#detail-ports').innerHTML = vm.ports === null
? `<p class="muted">This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}</p>`
: ports.length
? ports.map(p => `<span class="port-chip">${p.port} / ${p.protocol}</span>`).join(' ')
: '<p class="muted">No port rules configured.</p>';
// 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 <b>${esc(id)}</b>?`, '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 <b>${esc(id)}</b>?`, '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 <b>${esc(id)}</b>?`, '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 = '<span class="status-badge badge-loading">Checking…</span><p class="muted">Send a test request to the management API.</p>';
return;
}
wrap.innerHTML = `<span class="status-badge badge-ok">Healthy</span><p class="muted">API responding normally.</p>`;
}
$('#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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ─ Boot ─
window.addEventListener('hashchange', route);
initAuth();
route();
})();