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:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─ Boot ─
|
||||
window.addEventListener('hashchange', route);
|
||||
initAuth();
|
||||
route();
|
||||
})();
|
||||
Reference in New Issue
Block a user