- 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.
740 lines
27 KiB
JavaScript
740 lines
27 KiB
JavaScript
/* ─── 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();
|
|
})();
|