1341 lines
66 KiB
JavaScript
1341 lines
66 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const $ = (selector, scope = document) => scope.querySelector(selector);
|
||
const $$ = (selector, scope = document) => [...scope.querySelectorAll(selector)];
|
||
const renderCache = new WeakMap();
|
||
const pages = {
|
||
overview: { label: 'Overview', title: 'Management', subtitle: 'Overview', icon: 'grid' },
|
||
vms: { label: 'Machines', title: 'Machine', subtitle: 'Collection', icon: 'server' },
|
||
network: { label: 'Network & ports', title: 'Private', subtitle: 'Connections', icon: 'network' },
|
||
system: { label: 'Host health', title: 'Host', subtitle: 'Wellbeing', icon: 'heart' },
|
||
create: { label: 'New machine', title: 'Something', subtitle: 'New', icon: 'plus' },
|
||
'vm-detail': { label: 'Machine details', title: 'Machine', subtitle: 'Details', icon: 'server' },
|
||
};
|
||
const state = {
|
||
token: '',
|
||
authenticated: false,
|
||
session: 0,
|
||
vms: [],
|
||
loaded: false,
|
||
health: null,
|
||
checks: null,
|
||
vmError: '',
|
||
healthError: '',
|
||
checksError: '',
|
||
page: 'overview',
|
||
detailId: null,
|
||
detail: null,
|
||
portEditing: false,
|
||
selected: new Set(),
|
||
filter: 'all',
|
||
query: '',
|
||
sort: 'recent',
|
||
busy: new Set(),
|
||
controllers: new Set(),
|
||
requests: { vms: 0, health: 0, detail: 0, logs: 0 },
|
||
timer: null,
|
||
refreshPromise: null,
|
||
checksPromise: null,
|
||
lastUpdate: 0,
|
||
lastChecks: 0,
|
||
lastChecksAttempt: 0,
|
||
searchIndex: -1,
|
||
menuTrigger: null,
|
||
};
|
||
|
||
function esc(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
})[character]);
|
||
}
|
||
|
||
function icon(name, className = '') {
|
||
return `<svg class="icon ${className}" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
|
||
}
|
||
|
||
function text(selector, value) {
|
||
const element = $(selector);
|
||
const content = String(value ?? '—');
|
||
if (element.textContent !== content) {
|
||
element.textContent = content;
|
||
renderCache.delete(element);
|
||
}
|
||
}
|
||
|
||
// Unchanged polling responses must not replace focused controls or selections.
|
||
function html(selector, markup) {
|
||
const element = typeof selector === 'string' ? $(selector) : selector;
|
||
if (renderCache.get(element) === markup) return;
|
||
element.innerHTML = markup;
|
||
renderCache.set(element, markup);
|
||
}
|
||
|
||
function label(value) {
|
||
const valueText = String(value || 'unknown').replace(/[-_]/g, ' ');
|
||
return valueText.charAt(0).toUpperCase() + valueText.slice(1);
|
||
}
|
||
|
||
function number(value) {
|
||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
|
||
}
|
||
|
||
function formatRam(value) {
|
||
const mib = Number(value);
|
||
if (!Number.isFinite(mib)) return '—';
|
||
return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`;
|
||
}
|
||
|
||
function timestamp(value) {
|
||
if (value == null || value === '') return NaN;
|
||
if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) {
|
||
const numeric = Number(value);
|
||
return numeric < 1e12 ? numeric * 1000 : numeric;
|
||
}
|
||
return Date.parse(value);
|
||
}
|
||
|
||
function formatDate(value) {
|
||
const time = timestamp(value);
|
||
if (!Number.isFinite(time)) return '—';
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||
}).format(new Date(time));
|
||
}
|
||
|
||
function runtimeStatus(vm) {
|
||
return String(vm.observed_status || vm.status || 'unknown').toLowerCase();
|
||
}
|
||
|
||
function tone(status) {
|
||
if (status === 'running') return 'running';
|
||
if (status === 'stopped' || status === 'inactive') return 'stopped';
|
||
if (['ok', 'enforced', 'ready'].includes(status)) return 'good';
|
||
if (['failed', 'dead', 'error', 'unhealthy', 'missing', 'drift', 'quarantined'].includes(status)) return 'attention';
|
||
return 'pending';
|
||
}
|
||
|
||
function badge(status) {
|
||
return `<span class="badge badge-${tone(status)}">${esc(label(status))}</span>`;
|
||
}
|
||
|
||
function vcpu(vm) {
|
||
const cpu = Number(vm.cpu);
|
||
return Number.isFinite(cpu) && cpu > 0 ? Math.ceil(cpu) : 0;
|
||
}
|
||
|
||
function percent(part, total) {
|
||
return total > 0 ? Math.min(100, Math.max(0, part / total * 100)) : 0;
|
||
}
|
||
|
||
function vmPath(identifier, suffix = '') {
|
||
return `/vms/${encodeURIComponent(identifier)}${suffix}`;
|
||
}
|
||
|
||
function vmHref(identifier) {
|
||
return `#vm/${encodeURIComponent(identifier)}`;
|
||
}
|
||
|
||
function machineName(vm) {
|
||
return vm.name || vm.id;
|
||
}
|
||
|
||
function isAborted(error) {
|
||
return error.name === 'AbortError';
|
||
}
|
||
|
||
function apiError(data, status) {
|
||
if (data?.error?.message) return data.error.message;
|
||
if (typeof data?.detail === 'string') return data.detail;
|
||
if (Array.isArray(data?.detail)) {
|
||
return data.detail.map((item) => {
|
||
const field = (item.loc || []).filter((part) => part !== 'body').join('.');
|
||
return `${field ? `${field}: ` : ''}${item.msg || item.message || 'Invalid value'}`;
|
||
}).join('; ');
|
||
}
|
||
return `The server returned HTTP ${status}.`;
|
||
}
|
||
|
||
async function api(path, { method = 'GET', body } = {}) {
|
||
const session = state.session;
|
||
const controller = new AbortController();
|
||
state.controllers.add(controller);
|
||
let timedOut = false;
|
||
// Installation and lifecycle calls may legitimately take longer than reads.
|
||
const timer = method === 'GET' ? window.setTimeout(() => {
|
||
timedOut = true;
|
||
controller.abort();
|
||
}, 20000) : null;
|
||
try {
|
||
const response = await fetch(path, {
|
||
method,
|
||
headers: {
|
||
Accept: 'application/json',
|
||
'X-UVM-Token': state.token,
|
||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||
},
|
||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||
cache: 'no-store',
|
||
signal: controller.signal,
|
||
});
|
||
const raw = await response.text();
|
||
if (session !== state.session) throw new DOMException('Session changed', 'AbortError');
|
||
let data = null;
|
||
try { data = raw ? JSON.parse(raw) : null; } catch {
|
||
if (response.ok) throw new Error('The server returned an unreadable response.');
|
||
}
|
||
if (!response.ok) {
|
||
const error = new Error(apiError(data, response.status));
|
||
error.status = response.status;
|
||
if (response.status === 401 && state.authenticated) {
|
||
logout('Your token was not accepted. Connect again to continue.');
|
||
}
|
||
throw error;
|
||
}
|
||
return data;
|
||
} catch (error) {
|
||
if (timedOut) throw new Error('The request timed out. Try again in a moment.');
|
||
if (error instanceof TypeError) throw new Error('Unable to reach the control plane. Check your connection.');
|
||
throw error;
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
state.controllers.delete(controller);
|
||
}
|
||
}
|
||
|
||
function requireList(value) {
|
||
if (!Array.isArray(value)) throw new Error('The server returned an unexpected response.');
|
||
return value;
|
||
}
|
||
|
||
function toast(message, error = false) {
|
||
const item = document.createElement('div');
|
||
item.className = `toast${error ? ' toast-error' : ''}`;
|
||
item.innerHTML = icon(error ? 'info' : 'check');
|
||
const copy = document.createElement('span');
|
||
copy.textContent = message;
|
||
item.append(copy);
|
||
const container = $('#toast-container');
|
||
while (container.children.length >= 3) container.firstElementChild.remove();
|
||
container.append(item);
|
||
window.setTimeout(() => item.remove(), error ? 6500 : 3800);
|
||
}
|
||
|
||
function lockForm(form) {
|
||
const controls = $$('input, button, select', form).map((element) => [element, element.disabled]);
|
||
controls.forEach(([element]) => { element.disabled = true; });
|
||
return () => controls.forEach(([element, disabled]) => { element.disabled = disabled; });
|
||
}
|
||
|
||
async function withButton(button, pendingLabel, operation) {
|
||
if (button.disabled) return;
|
||
const previous = button.innerHTML;
|
||
button.disabled = true;
|
||
if (pendingLabel) button.textContent = pendingLabel;
|
||
try { return await operation(); } finally {
|
||
button.disabled = false;
|
||
button.innerHTML = previous;
|
||
}
|
||
}
|
||
|
||
function fleetStats() {
|
||
const running = state.vms.filter((vm) => runtimeStatus(vm) === 'running');
|
||
const stopped = state.vms.filter((vm) => runtimeStatus(vm) === 'stopped');
|
||
const sum = (vms, value) => vms.reduce((total, vm) => total + value(vm), 0);
|
||
return {
|
||
total: state.vms.length,
|
||
running: running.length,
|
||
stopped: stopped.length,
|
||
other: state.vms.length - running.length - stopped.length,
|
||
cpu: sum(state.vms, vcpu),
|
||
activeCpu: sum(running, vcpu),
|
||
ram: sum(state.vms, (vm) => Number(vm.ram_mib) || 0),
|
||
activeRam: sum(running, (vm) => Number(vm.ram_mib) || 0),
|
||
gateways: [...new Set(state.vms.map((vm) => vm.gateway).filter(Boolean))],
|
||
managed: state.vms.filter((vm) => Array.isArray(vm.ports)).length,
|
||
enforced: state.vms.filter((vm) => vm.policy_status === 'enforced').length,
|
||
ports: sum(state.vms, (vm) => (vm.ports || []).length),
|
||
};
|
||
}
|
||
|
||
function compactMetric(symbol, value, caption, word = false) {
|
||
return `<article class="compact-metric"><span class="round-icon">${icon(symbol)}</span><div><strong${word ? ' class="metric-word"' : ''}>${esc(value)}</strong><span>${esc(caption)}</span></div></article>`;
|
||
}
|
||
|
||
function emptyState(title, description, buttonLabel, target, isAction = false) {
|
||
const control = isAction
|
||
? `<button class="button button-small" type="button" data-action="${target}">${esc(buttonLabel)}${icon('arrow')}</button>`
|
||
: `<a class="button button-primary button-small" href="${esc(target)}">${icon('plus')}${esc(buttonLabel)}</a>`;
|
||
return `<div class="empty-state"><span class="empty-icon">${icon('server')}</span><h3>${esc(title)}</h3><p>${esc(description)}</p>${control}</div>`;
|
||
}
|
||
|
||
function portsMarkup(ports, limit = Infinity) {
|
||
if (ports === null) return '<span class="muted">Legacy policy</span>';
|
||
if (!ports?.length) return '<span class="muted">No open ports</span>';
|
||
return ports.slice(0, limit).map((rule) => `<span class="port-chip">${esc(rule.port)}<small>${esc(rule.protocol)}</small></span>`).join('')
|
||
+ (ports.length > limit ? `<span class="port-chip">+${ports.length - limit}</span>` : '');
|
||
}
|
||
|
||
function renderConnection() {
|
||
const connected = state.loaded && !state.vmError;
|
||
$('#connection-dot').className = `dot ${connected ? 'dot-sage' : state.vmError ? 'dot-error' : ''}`;
|
||
$('#footer-dot').className = `dot ${connected ? 'dot-sage' : 'dot-error'}`;
|
||
text('#connection-label', connected ? 'Connected' : state.vmError ? 'Reconnecting' : 'Connecting');
|
||
$('#connection-banner').hidden = !state.vmError;
|
||
text('#connection-message', state.vmError ? `${state.vmError}${state.loaded ? ' Showing the last available snapshot.' : ''}` : '');
|
||
text('#last-updated', state.lastUpdate ? `Updated ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(state.lastUpdate)}` : 'Waiting for an update');
|
||
}
|
||
|
||
function renderChrome() {
|
||
const stats = fleetStats();
|
||
text('#nav-vm-count', state.loaded ? stats.total : '—');
|
||
text('#rail-machines', state.loaded ? `${stats.total} ${stats.total === 1 ? 'machine' : 'machines'} in your fleet` : 'Your private fleet');
|
||
text('#rail-network', state.loaded ? `${stats.ports} private port ${stats.ports === 1 ? 'rule' : 'rules'}` : 'Connections, considered');
|
||
const failed = (state.checks || []).filter((check) => !check.ok).length;
|
||
const checksKnown = state.checks !== null;
|
||
text('#rail-health', checksKnown ? `${state.checks.length - failed} of ${state.checks.length} checks ready` : state.checksError ? 'Checks unavailable' : 'Checking the essentials');
|
||
$('#nav-health-dot').style.background = checksKnown ? (failed ? 'var(--peach)' : 'var(--sage)') : 'transparent';
|
||
if (state.checksError) {
|
||
text('#insight-copy', 'Host insights are taking a little longer. Open host health to try again.');
|
||
} else if (checksKnown && failed) {
|
||
html('#insight-copy', `A little care goes a long way. <strong>${failed} host ${failed === 1 ? 'check needs' : 'checks need'} your attention.</strong>`);
|
||
} else if (checksKnown && state.checks.length) {
|
||
html('#insight-copy', `The essentials are in place. <strong>All ${state.checks.length} host checks are ready.</strong>`);
|
||
} else if (checksKnown) {
|
||
text('#insight-copy', 'Your host returned no readiness checks. Open host health for the details.');
|
||
} else {
|
||
text('#insight-copy', 'A fresh perspective on your local compute. Checking the little details.');
|
||
}
|
||
text('#insight-footer', checksKnown ? 'From your latest host checks' : 'A little clarity, on its way');
|
||
renderConnection();
|
||
}
|
||
|
||
function renderOverview() {
|
||
const stats = fleetStats();
|
||
text('#metric-total', state.loaded ? number(stats.total).padStart(2, '0') : '—');
|
||
text('#metric-running', state.loaded ? `${stats.running} running now` : 'Waiting for the fleet');
|
||
text('#metric-stopped', state.loaded ? `${stats.stopped} at rest` : '—');
|
||
text('#metric-cpu', state.loaded ? `${number(stats.cpu)} vCPU` : '— vCPU');
|
||
text('#metric-ram', state.loaded ? formatRam(stats.ram) : '— MiB');
|
||
text('#cpu-active-label', state.loaded ? `${number(stats.activeCpu)} active / ${number(stats.cpu)}` : 'Allocated');
|
||
text('#ram-active-label', state.loaded ? `${Math.round(percent(stats.activeRam, stats.ram))}% active` : 'Allocated');
|
||
$('#cpu-allocation-bar').style.width = `${percent(stats.activeCpu, stats.cpu)}%`;
|
||
$('#ram-allocation-bar').style.width = `${percent(stats.activeRam, stats.ram)}%`;
|
||
|
||
if (!state.loaded) {
|
||
html('#overview-machines', state.vmError
|
||
? emptyState('A little trouble connecting.', 'Your machines will appear as soon as the registry is reachable.', 'Try again', 'refresh', true)
|
||
: '<div class="loading-state">Bringing your machines into view…</div>');
|
||
} else if (!stats.total) {
|
||
html('#overview-machines', emptyState('Your first machine starts here.', 'A private little space for whatever you have in mind.', 'Create a machine', '#create'));
|
||
} else {
|
||
const recent = [...state.vms].sort((a, b) => (timestamp(b.created_at) || 0) - (timestamp(a.created_at) || 0)).slice(0, 5);
|
||
html('#overview-machines', `<div class="table-scroll"><table class="portfolio-table"><thead><tr><th>Machine</th><th>Runtime state</th><th>Guest address</th><th>Allocation</th><th>Port policy</th><th><span class="sr-only">Details</span></th></tr></thead><tbody>${recent.map((vm) => `
|
||
<tr><td><div class="portfolio-name">${icon('server')}<a href="${esc(vmHref(vm.id))}">${esc(machineName(vm))}</a></div></td><td>${badge(runtimeStatus(vm))}</td><td>${esc(vm.guest_ip || '—')}</td><td class="resource-inline">${vcpu(vm)} vCPU<span>·</span>${esc(formatRam(vm.ram_mib))}</td><td>${badge(vm.policy_status || 'unknown')}</td><td><a class="mini-link" href="${esc(vmHref(vm.id))}" aria-label="Inspect ${esc(machineName(vm))}">${icon('chevron')}</a></td></tr>`).join('')}</tbody></table></div>`);
|
||
}
|
||
|
||
const runningPercent = Math.round(percent(stats.running, stats.total));
|
||
html('#ring-percent', `${state.loaded ? runningPercent : '—'}<small>%</small>`);
|
||
text('#ring-total', state.loaded ? `${stats.running} of ${stats.total} machines` : 'Your fleet, in balance');
|
||
text('#legend-running', state.loaded ? stats.running : '—');
|
||
text('#legend-stopped', state.loaded ? stats.stopped : '—');
|
||
text('#legend-other', state.loaded ? stats.other : '—');
|
||
$('#fleet-ring').setAttribute('aria-label', state.loaded
|
||
? `${stats.running} running, ${stats.stopped} stopped, and ${stats.other} other machines. ${runningPercent}% running.`
|
||
: 'Waiting for machine activity');
|
||
let offset = 0;
|
||
[['running', stats.running], ['stopped', stats.stopped], ['other', stats.other]].forEach(([name, count]) => {
|
||
const share = percent(count, stats.total);
|
||
const arc = share === 100 ? 100 : Math.max(0, share - 1.5);
|
||
const element = $(`#ring-${name}`);
|
||
element.style.strokeDasharray = `${arc} ${100 - arc}`;
|
||
element.style.strokeDashoffset = String(-offset);
|
||
element.style.opacity = count && arc ? '1' : '0';
|
||
offset += share;
|
||
});
|
||
renderNotes(stats);
|
||
}
|
||
|
||
function renderNotes(stats = fleetStats()) {
|
||
const checks = state.checks;
|
||
const failed = (checks || []).filter((check) => !check.ok).length;
|
||
const rows = [
|
||
['system', 'heart', 'Host essentials', checks ? failed ? `${failed} ${failed === 1 ? 'check could' : 'checks could'} use a look` : checks.length ? 'Everything is in its place' : 'No checks returned' : state.checksError ? 'Host checks unavailable' : 'Checking the foundations', checks ? `${checks.length - failed}/${checks.length}` : '—'],
|
||
['network', 'network', 'Your private network', stats.gateways.length ? stats.gateways.join(' · ') : 'A gateway appears with your first machine', stats.gateways.length ? `${stats.gateways.length} ${stats.gateways.length === 1 ? 'gateway' : 'gateways'}` : '—'],
|
||
['network', 'shield', 'Considered connections', stats.managed ? `${stats.managed} managed ${stats.managed === 1 ? 'policy' : 'policies'} in your fleet` : 'A private policy for each new machine', state.loaded ? `${stats.ports} ${stats.ports === 1 ? 'rule' : 'rules'}` : '—'],
|
||
];
|
||
html('#workspace-notes', rows.map(([route, symbol, title, detail, value]) => `<a class="note-row" href="#${route}">${icon(symbol)}<div><strong>${esc(title)}</strong><small>${esc(detail)}</small></div><span>${esc(value)}</span>${icon('chevron')}</a>`).join(''));
|
||
}
|
||
|
||
function visibleVms() {
|
||
const query = state.query.trim().toLowerCase();
|
||
return state.vms.filter((vm) => {
|
||
const status = runtimeStatus(vm);
|
||
const matchesStatus = state.filter === 'all' || (state.filter === 'attention'
|
||
? !['running', 'stopped'].includes(status) || tone(vm.policy_status) === 'attention'
|
||
: status === state.filter);
|
||
const matchesQuery = !query || [vm.name, vm.id, vm.guest_ip].some((value) => String(value || '').toLowerCase().includes(query));
|
||
return matchesStatus && matchesQuery;
|
||
}).sort((a, b) => {
|
||
if (state.sort === 'name') return machineName(a).localeCompare(machineName(b));
|
||
if (state.sort === 'status') return runtimeStatus(a).localeCompare(runtimeStatus(b));
|
||
return (timestamp(b.created_at) || 0) - (timestamp(a.created_at) || 0);
|
||
});
|
||
}
|
||
|
||
function renderMachines() {
|
||
const stats = fleetStats();
|
||
html('#fleet-summary', compactMetric('server', state.loaded ? stats.total : '—', 'Machines in your collection')
|
||
+ compactMetric('play', state.loaded ? stats.running : '—', 'Running, right now')
|
||
+ compactMetric('stop', state.loaded ? stats.stopped : '—', 'At rest, disks retained'));
|
||
const machines = visibleVms();
|
||
html('#machine-rows', machines.map((vm) => {
|
||
const id = esc(vm.id);
|
||
const running = runtimeStatus(vm) === 'running';
|
||
const busy = state.busy.has(vm.id) || ['starting', 'stopping', 'terminating'].includes(vm.status);
|
||
return `<tr class="${state.selected.has(vm.id) ? 'selected' : ''}">
|
||
<td class="check-cell"><input type="checkbox" data-select="${id}" aria-label="Select ${esc(machineName(vm))}" ${state.selected.has(vm.id) ? 'checked' : ''} /></td>
|
||
<td><div class="machine-name"><span class="round-icon">${icon('server')}</span><div><a href="${esc(vmHref(vm.id))}" title="${esc(machineName(vm))}">${esc(machineName(vm))}</a><small>${esc(vm.guest_ip || vm.id)}</small></div></div></td>
|
||
<td>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))}</small></td>
|
||
<td><div class="table-ports">${portsMarkup(vm.ports, 2)}</div></td><td>${badge(vm.policy_status || 'unknown')}</td>
|
||
<td><div class="row-controls"><button class="icon-button" type="button" data-action="vm-action" data-operation="${running ? 'stop' : 'start'}" data-id="${id}" aria-label="${running ? 'Stop' : 'Start'} ${esc(machineName(vm))}" title="${running ? 'Stop' : 'Start'} machine" ${busy ? 'disabled' : ''}>${icon(running ? 'stop' : 'play')}</button><button class="icon-button" type="button" data-action="machine-menu" data-id="${id}" aria-label="Actions for ${esc(machineName(vm))}" aria-haspopup="menu" aria-expanded="false">${icon('more')}</button></div></td>
|
||
</tr>`;
|
||
}).join(''));
|
||
$('#machines-empty').hidden = machines.length > 0;
|
||
if (!machines.length) {
|
||
html('#machines-empty', !state.loaded
|
||
? state.vmError ? emptyState('The registry is out of reach.', state.vmError, 'Try again', 'refresh', true) : '<div class="loading-state">Bringing your machines into view…</div>'
|
||
: stats.total ? emptyState('Nothing here by that name.', 'Try another search or give your filters a fresh start.', 'Clear filters', 'clear-filters', true)
|
||
: emptyState('A little quiet. A lot of potential.', 'Create your first private machine and give your next idea a home.', 'New machine', '#create'));
|
||
}
|
||
text('#visible-count', state.loaded ? `${machines.length} of ${stats.total}` : 'Loading');
|
||
text('#fleet-list-caption', state.loaded ? `${stats.total} ${stats.total === 1 ? 'machine' : 'machines'} · ${stats.running} running · ${stats.stopped} stopped` : 'Your private machine registry.');
|
||
const selectedVisible = machines.filter((vm) => state.selected.has(vm.id)).length;
|
||
const selectAll = $('#vm-select-all');
|
||
selectAll.disabled = !machines.length;
|
||
selectAll.checked = machines.length > 0 && selectedVisible === machines.length;
|
||
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < machines.length;
|
||
$('#bulk-controls').hidden = !state.selected.size;
|
||
text('#bulk-count', `${state.selected.size} selected`);
|
||
$$('.filter-tab').forEach((button) => {
|
||
const active = button.dataset.filter === state.filter;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
}
|
||
|
||
function renderNetwork() {
|
||
const stats = fleetStats();
|
||
html('#network-summary', compactMetric('network', state.loaded ? stats.gateways.length : '—', 'Private gateways')
|
||
+ compactMetric('shield', state.loaded ? stats.managed : '—', 'Managed port policies')
|
||
+ compactMetric('grid', state.loaded ? stats.ports : '—', 'Configured TCP rules'));
|
||
text('#network-gateway', stats.gateways.join(' · ') || (state.loaded ? 'No gateway yet' : 'Loading'));
|
||
text('#network-machine-count', state.loaded ? `${stats.total} private ${stats.total === 1 ? 'machine' : 'machines'}` : 'Waiting for your fleet');
|
||
html('#network-policies', state.vms.length ? state.vms.map((vm) => `<a href="${esc(vmHref(vm.id))}" class="policy-row"><div class="policy-row-heading"><strong>${esc(machineName(vm))}</strong>${icon('chevron')}</div><div class="policy-row-meta"><span>${esc(vm.guest_ip || 'Address pending')}</span>${badge(vm.policy_status || 'unknown')}</div><div class="table-ports">${portsMarkup(vm.ports)}</div></a>`).join('')
|
||
: !state.loaded ? state.vmError ? emptyState('Connections are out of view.', state.vmError, 'Try again', 'refresh', true) : '<div class="loading-state">Looking at your connections…</div>'
|
||
: emptyState('Good connections take a first step.', 'Each new machine comes with a private address and an explicit port policy.', 'Create a machine', '#create'));
|
||
}
|
||
|
||
function renderHost() {
|
||
const checks = state.checks;
|
||
const ready = (checks || []).filter((check) => check.ok).length;
|
||
const failed = (checks || []).length - ready;
|
||
text('#checks-ready', checks ? ready : '—');
|
||
text('#checks-attention', checks ? failed : '—');
|
||
text('#checks-total', checks ? checks.length : '—');
|
||
$('#checks-ready-bar').style.width = `${percent(ready, checks?.length)}%`;
|
||
$('#checks-attention-bar').style.width = `${percent(failed, checks?.length)}%`;
|
||
text('#checks-summary', checks ? `${ready} / ${checks.length} ready` : state.checksError ? 'Unavailable' : 'Taking a look');
|
||
const healthy = state.health?.status === 'ok';
|
||
html('#health-summary', compactMetric('heart', state.health ? healthy ? 'Available' : label(state.health.status) : state.healthError ? 'Unavailable' : 'Checking', 'Management API', true)
|
||
+ compactMetric('check', checks ? `${ready} / ${checks.length}` : '—', 'Host checks ready')
|
||
+ compactMetric('info', checks ? failed : '—', 'Checks needing attention'));
|
||
text('#diagnostics-count', checks ? `${ready} / ${checks.length} ready` : state.checksError ? 'Unavailable' : 'Checking');
|
||
text('#info-api-url', location.origin);
|
||
text('#info-observed', state.lastChecks ? formatDate(state.lastChecks) : 'Not checked yet');
|
||
const names = {
|
||
root: 'Host permissions', kvm: 'KVM virtualization', Firecracker: 'Firecracker runtime',
|
||
'guest assets': 'Guest image assets', 'private firewall': 'Private port filtering',
|
||
ip: 'IP networking', iptables: 'Egress routing', nft: 'nftables', conntrack: 'Connection tracking',
|
||
debugfs: 'Guest disk tools', openssl: 'OpenSSL', 'ssh-keygen': 'SSH key tools', ssh: 'SSH client',
|
||
};
|
||
html('#diagnostics-list', state.checksError
|
||
? emptyState('A check-in is taking a little longer.', state.checksError, 'Try again', 'diagnostics', true)
|
||
: checks ? checks.length ? checks.map((check) => `<article class="diagnostic-item${check.ok ? '' : ' attention'}"><span class="diagnostic-icon">${icon(check.ok ? 'check' : 'info')}</span><div><h3>${esc(names[check.name] || label(check.name))}<span>${check.ok ? 'Ready' : 'Attention'}</span></h3><p>${esc(check.message)}</p></div></article>`).join('')
|
||
: '<p class="panel-footnote">The host returned no diagnostic checks.</p>'
|
||
: '<div class="loading-state">Checking in with your host…</div>');
|
||
$('#diagnostics-refresh-btn').disabled = Boolean(state.checksPromise);
|
||
renderChrome();
|
||
renderNotes();
|
||
}
|
||
|
||
function renderFleet() {
|
||
renderChrome();
|
||
renderOverview();
|
||
renderMachines();
|
||
renderNetwork();
|
||
if (!$('#search-results').hidden) renderSearch();
|
||
}
|
||
|
||
async function loadVms() {
|
||
const request = ++state.requests.vms;
|
||
const session = state.session;
|
||
try {
|
||
const vms = requireList(await api('/vms'));
|
||
if (request !== state.requests.vms || session !== state.session) return false;
|
||
state.vms = vms;
|
||
state.loaded = true;
|
||
state.vmError = '';
|
||
state.lastUpdate = Date.now();
|
||
const existing = new Set(vms.map((vm) => vm.id));
|
||
state.selected = new Set([...state.selected].filter((id) => existing.has(id)));
|
||
renderFleet();
|
||
if (state.page === 'vm-detail' && state.detail) {
|
||
const vm = vms.find((item) => item.id === state.detail.id);
|
||
if (vm) {
|
||
state.detail = vm;
|
||
renderDetail();
|
||
} else {
|
||
await loadDetail(state.detail.id);
|
||
}
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
if (isAborted(error) || request !== state.requests.vms || session !== state.session) return false;
|
||
state.vmError = error.message;
|
||
renderConnection();
|
||
if (!state.loaded) renderFleet();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function loadHealth() {
|
||
const request = ++state.requests.health;
|
||
const session = state.session;
|
||
try {
|
||
const health = await api('/health');
|
||
if (request !== state.requests.health || session !== state.session) return false;
|
||
if (!health || typeof health.status !== 'string') throw new Error('The health response was not recognized.');
|
||
state.health = health;
|
||
state.healthError = '';
|
||
renderHost();
|
||
return true;
|
||
} catch (error) {
|
||
if (isAborted(error) || request !== state.requests.health || session !== state.session) return false;
|
||
state.health = null;
|
||
state.healthError = error.message;
|
||
renderHost();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function loadDiagnostics() {
|
||
if (state.checksPromise) return state.checksPromise;
|
||
const session = state.session;
|
||
state.lastChecksAttempt = Date.now();
|
||
const operation = (async () => {
|
||
try {
|
||
const checks = requireList(await api('/diagnostics'));
|
||
if (session !== state.session) return false;
|
||
state.checks = checks;
|
||
state.checksError = '';
|
||
state.lastChecks = Date.now();
|
||
return true;
|
||
} catch (error) {
|
||
if (isAborted(error) || session !== state.session) return false;
|
||
state.checks = null;
|
||
state.checksError = error.message;
|
||
return false;
|
||
} finally {
|
||
if (state.checksPromise === operation) state.checksPromise = null;
|
||
if (session === state.session) renderHost();
|
||
}
|
||
})();
|
||
state.checksPromise = operation;
|
||
$('#diagnostics-refresh-btn').disabled = true;
|
||
return operation;
|
||
}
|
||
|
||
function refreshData(forceChecks = false) {
|
||
if (!state.authenticated) return Promise.resolve(false);
|
||
if (state.refreshPromise) return state.refreshPromise;
|
||
const session = state.session;
|
||
$('#refresh-btn').classList.add('is-loading');
|
||
const loads = [loadVms(), loadHealth()];
|
||
if (forceChecks || Date.now() - state.lastChecksAttempt > 30000) loads.push(loadDiagnostics());
|
||
const operation = Promise.all(loads).then((results) => results.every(Boolean)).finally(() => {
|
||
if (state.refreshPromise === operation) state.refreshPromise = null;
|
||
if (session === state.session) $('#refresh-btn').classList.remove('is-loading');
|
||
});
|
||
state.refreshPromise = operation;
|
||
return operation;
|
||
}
|
||
|
||
async function manualRefresh() {
|
||
const session = state.session;
|
||
const success = await refreshData(true);
|
||
if (session === state.session && state.authenticated) toast(success ? 'A fresh look at your workspace.' : 'Some workspace data could not be refreshed.', !success);
|
||
}
|
||
|
||
function detailRows(rows) {
|
||
return rows.map(([key, value, mono]) => `<div><dt>${esc(key)}</dt><dd${mono ? ' class="mono"' : ''}>${esc(value ?? '—')}</dd></div>`).join('');
|
||
}
|
||
|
||
function renderDetail() {
|
||
const vm = state.detail;
|
||
if (!vm || state.page !== 'vm-detail') return;
|
||
$('#detail-loading').hidden = true;
|
||
$('#detail-content').hidden = false;
|
||
text('#detail-heading', machineName(vm));
|
||
text('#detail-id', vm.id);
|
||
html('#detail-status', badge(runtimeStatus(vm)));
|
||
document.title = `${machineName(vm)} · UVM Control`;
|
||
const busy = state.busy.has(vm.id);
|
||
const running = runtimeStatus(vm) === 'running';
|
||
const actionButton = (action, caption, symbol, primary = false) => `<button type="button" class="button${primary ? ' button-primary' : ''}" data-action="vm-action" data-operation="${action}" data-id="${esc(vm.id)}" ${busy ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`;
|
||
html('#detail-actions', running
|
||
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
|
||
: actionButton('start', 'Start machine', 'play', true));
|
||
html('#detail-config', detailRows([
|
||
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
||
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Guest user', vm.username],
|
||
['Created', formatDate(vm.created_at)], ['Last updated', formatDate(vm.updated_at)],
|
||
]));
|
||
html('#detail-network', detailRows([
|
||
['Guest address', vm.guest_ip, true], ['Gateway', vm.gateway, true], ['MAC address', vm.mac, true],
|
||
['Policy state', label(vm.policy_status)], ['Policy type', vm.ports === null ? 'Legacy / unmanaged' : 'Managed TCP'],
|
||
['Process ID', vm.pid ?? 'Not running'], ['Current operation', vm.operation ? label(vm.operation) : 'None'],
|
||
]));
|
||
text('#detail-error', vm.last_error || '');
|
||
$('#detail-error').hidden = !vm.last_error;
|
||
text('#ssh-command', `ssh ${vm.username || 'root'}@${vm.guest_ip || '—'}`);
|
||
text('#ssh-hint', vm.ports === null ? 'SSH reachability is not managed for this legacy policy.'
|
||
: !(vm.ports || []).some((rule) => Number(rule.port) === 22) ? 'TCP port 22 is not allowed by this policy.'
|
||
: !running ? 'Start this machine before connecting.' : 'Ready to copy into your local terminal.');
|
||
if (!state.portEditing) {
|
||
html('#detail-ports', vm.ports === null
|
||
? `<p>${vm.status === 'stopped' ? 'This legacy machine is ready for a managed policy. Set its first rules to continue.' : 'This machine has a legacy policy. Stop it before defining its first managed rules.'}</p>`
|
||
: portsMarkup(vm.ports));
|
||
$('#port-input').value = (vm.ports || []).map((rule) => `${rule.port}:${rule.protocol}`).join(', ');
|
||
}
|
||
$('#detail-ports').hidden = state.portEditing;
|
||
$('#port-editor').hidden = !state.portEditing;
|
||
$('#edit-ports-btn').hidden = state.portEditing || (vm.ports === null && vm.status !== 'stopped');
|
||
$('#sync-ports-btn').hidden = vm.ports === null;
|
||
$('#sync-ports-btn').disabled = busy;
|
||
$('#open-ports-btn').hidden = vm.ports === null;
|
||
$('#close-ports-btn').hidden = vm.ports === null;
|
||
}
|
||
|
||
async function loadDetail(identifier) {
|
||
const request = ++state.requests.detail;
|
||
const session = state.session;
|
||
try {
|
||
const vm = await api(vmPath(identifier));
|
||
if (request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
|
||
if (!vm?.id) throw new Error('The machine response was not recognized.');
|
||
state.detail = vm;
|
||
state.detailId = vm.id;
|
||
renderDetail();
|
||
await loadLogs();
|
||
} catch (error) {
|
||
if (isAborted(error) || request !== state.requests.detail || session !== state.session || state.page !== 'vm-detail') return;
|
||
if (state.detail && error.status !== 404) {
|
||
text('#detail-error', error.message);
|
||
$('#detail-error').hidden = false;
|
||
} else {
|
||
state.detail = null;
|
||
$('#detail-content').hidden = true;
|
||
$('#detail-loading').hidden = false;
|
||
html('#detail-loading', emptyState('This machine is out of view.', error.message, 'Back to machines', '#vms'));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadLogs() {
|
||
if (!state.detail) return;
|
||
const id = state.detail.id;
|
||
const request = ++state.requests.logs;
|
||
const session = state.session;
|
||
const tail = Number($('#log-tail').value);
|
||
$('#log-refresh-btn').disabled = true;
|
||
text('#log-meta', 'Bringing the latest entries into view…');
|
||
try {
|
||
const logs = await api(vmPath(id, `/logs?tail=${tail}`));
|
||
if (request !== state.requests.logs || session !== state.session || state.page !== 'vm-detail' || state.detailId !== id) return;
|
||
text('#log-output', logs?.content || 'A quiet log. No entries yet.');
|
||
text('#log-meta', logs?.truncated ? 'The available tail is shown. A size limit or oversized line truncated the log.' : `Up to ${number(tail)} recent lines. Refresh to see new entries.`);
|
||
} catch (error) {
|
||
if (isAborted(error) || request !== state.requests.logs || session !== state.session || state.page !== 'vm-detail' || state.detailId !== id) return;
|
||
text('#log-output', 'The runtime log is unavailable.');
|
||
text('#log-meta', error.message);
|
||
} finally {
|
||
if (request === state.requests.logs && session === state.session) $('#log-refresh-btn').disabled = false;
|
||
}
|
||
}
|
||
|
||
function confirmAction(title, description, actionLabel, destructive = false) {
|
||
const dialog = $('#confirm-dialog');
|
||
if (dialog.open) return Promise.resolve(false);
|
||
text('#confirm-title', title);
|
||
text('#confirm-body', description);
|
||
text('#confirm-ok', actionLabel);
|
||
$('#confirm-ok').className = `button ${destructive ? 'button-danger' : 'button-primary'}`;
|
||
dialog.returnValue = 'cancel';
|
||
return new Promise((resolve) => {
|
||
dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm'), { once: true });
|
||
dialog.showModal();
|
||
});
|
||
}
|
||
|
||
async function refreshAfterMutation(id) {
|
||
await loadVms();
|
||
if (state.page === 'vm-detail' && state.detailId === id) await loadDetail(id);
|
||
}
|
||
|
||
async function machineAction(action, id) {
|
||
if (!state.authenticated || state.busy.has(id)) return;
|
||
const session = state.session;
|
||
const vm = state.vms.find((item) => item.id === id) || (state.detail?.id === id ? state.detail : null);
|
||
const name = vm ? machineName(vm) : id;
|
||
const confirmations = {
|
||
stop: [`Let ${name} take a breather?`, 'The runtime will stop. Its private disk, guest credentials, and network identity are kept for the next start.', 'Stop machine'],
|
||
restart: [`A fresh start for ${name}?`, 'The runtime will stop and boot again from its existing private disk. Changes on the disk are retained.', 'Restart machine'],
|
||
delete: [`Say goodbye to ${name}?`, 'This permanently removes the machine and its private disk, and releases its network resources.', 'Delete machine'],
|
||
};
|
||
if (confirmations[action] && !await confirmAction(...confirmations[action], action === 'delete')) return;
|
||
if (session !== state.session || !state.authenticated) return;
|
||
state.busy.add(id);
|
||
renderMachines();
|
||
renderDetail();
|
||
try {
|
||
const suffix = action === 'ports-sync' ? '/ports/sync' : action === 'delete' ? '' : `/${action}`;
|
||
await api(vmPath(id, suffix), { method: action === 'delete' ? 'DELETE' : 'POST' });
|
||
if (session !== state.session) return;
|
||
if (action === 'delete' && state.detailId === id) {
|
||
state.detail = null;
|
||
state.detailId = null;
|
||
location.hash = '#vms';
|
||
}
|
||
const messages = { start: 'Machine started.', stop: 'Machine stopped. Its disk is tucked away.', restart: 'Machine restarted.', delete: 'Machine deleted.', 'ports-sync': 'Port policy synced.' };
|
||
toast(messages[action]);
|
||
await refreshAfterMutation(id);
|
||
} catch (error) {
|
||
if (!isAborted(error) && session === state.session) toast(error.message, true);
|
||
} finally {
|
||
if (session === state.session) {
|
||
state.busy.delete(id);
|
||
renderMachines();
|
||
renderDetail();
|
||
}
|
||
}
|
||
}
|
||
|
||
function parsePorts(value) {
|
||
if (!value.trim()) return [];
|
||
const rules = new Map();
|
||
value.split(',').forEach((part) => {
|
||
const match = part.trim().match(/^(\d+)(?:\s*:\s*(TCP|HTTP|HTTPS))?$/i);
|
||
const port = match ? Number(match[1]) : 0;
|
||
if (!match || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||
throw new Error(`“${part.trim()}” is not a valid port rule. Use a port from 1 to 65535, followed by TCP, HTTP, or HTTPS.`);
|
||
}
|
||
const protocol = (match[2] || 'TCP').toUpperCase();
|
||
if (rules.has(port) && rules.get(port).protocol !== protocol) throw new Error(`Port ${port} has conflicting protocol labels.`);
|
||
rules.set(port, { port, protocol });
|
||
});
|
||
return [...rules.values()].sort((a, b) => a.port - b.port);
|
||
}
|
||
|
||
function parseRam(value) {
|
||
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?$/i);
|
||
if (!match) throw new Error('Use a memory amount such as 512M, 1G, or 2G.');
|
||
const factors = { b: 1 / (1024 * 1024), k: 1 / 1024, kb: 1 / 1024, ki: 1 / 1024, kib: 1 / 1024, m: 1, mb: 1, mi: 1, mib: 1, g: 1024, gb: 1024, gi: 1024, gib: 1024 };
|
||
const mib = Math.floor(Number(match[1]) * factors[(match[2] || 'm').toLowerCase()]);
|
||
if (!Number.isFinite(mib) || mib < 128) throw new Error('Give this machine at least 128 MiB of memory.');
|
||
return mib;
|
||
}
|
||
|
||
async function submitPorts(event) {
|
||
event.preventDefault();
|
||
if (!state.detail || state.busy.has(state.detail.id)) return;
|
||
const id = state.detail.id;
|
||
const session = state.session;
|
||
const mode = event.submitter?.value || 'set';
|
||
let ports;
|
||
try { ports = parsePorts($('#port-input').value); } catch (error) {
|
||
text('#port-error', error.message);
|
||
return;
|
||
}
|
||
text('#port-error', '');
|
||
const unlock = lockForm($('#port-editor'));
|
||
state.busy.add(id);
|
||
try {
|
||
await api(vmPath(id, '/ports'), { method: 'POST', body: { mode, ports } });
|
||
if (session !== state.session) return;
|
||
state.portEditing = false;
|
||
toast({ set: 'Port policy updated.', open: 'Port rules opened.', close: 'Port rules closed.' }[mode]);
|
||
await refreshAfterMutation(id);
|
||
} catch (error) {
|
||
if (!isAborted(error) && session === state.session) text('#port-error', error.message);
|
||
} finally {
|
||
unlock();
|
||
if (session === state.session) {
|
||
state.busy.delete(id);
|
||
renderDetail();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function copyText(value) {
|
||
try {
|
||
try {
|
||
if (!navigator.clipboard) throw new Error('Clipboard unavailable');
|
||
await navigator.clipboard.writeText(value);
|
||
} catch {
|
||
const previous = document.activeElement;
|
||
const field = document.createElement('textarea');
|
||
field.value = value;
|
||
field.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
|
||
document.body.append(field);
|
||
field.select();
|
||
const copied = document.execCommand('copy');
|
||
field.remove();
|
||
previous?.focus({ preventScroll: true });
|
||
if (!copied) throw new Error('Copying is unavailable. Select the command and copy it manually.');
|
||
}
|
||
toast('SSH command copied.');
|
||
} catch (error) { toast(error.message, true); }
|
||
}
|
||
|
||
function updateCreateSummary() {
|
||
let memory = '—';
|
||
let ports = 'Check port rules';
|
||
try { memory = formatRam(parseRam($('#create-ram').value)); } catch { /* The form reports validation on submit. */ }
|
||
try {
|
||
const rules = parsePorts($('#create-ports').value);
|
||
if ($('#create-default-ssh').checked && !rules.some((rule) => rule.port === 22)) rules.unshift({ port: 22, protocol: 'TCP' });
|
||
ports = rules.length ? rules.map((rule) => `${rule.port} / ${rule.protocol}`).join(', ') : 'No inbound TCP ports';
|
||
} catch { /* Keep the summary useful while a rule is being typed. */ }
|
||
const cpu = Number($('#create-cpu').value);
|
||
text('#create-summary-name', $('#create-name').value.trim() || 'Room for an idea.');
|
||
html('#create-summary-details', detailRows([
|
||
['Compute', Number.isFinite(cpu) && cpu > 0 ? `${Math.ceil(cpu)} vCPU` : '—'],
|
||
['Memory', memory], ['Guest IP', $('#create-guest-ip').value.trim() || 'Automatic'],
|
||
['Guest user', $('#create-username').value.trim() || '—'], ['Allowed ports', ports],
|
||
]));
|
||
const presets = { small: [1, 512], balanced: [2, 1024], roomy: [4, 2048] };
|
||
let mib = null;
|
||
try { mib = parseRam($('#create-ram').value); } catch { /* No preset matches an incomplete value. */ }
|
||
$$('[data-preset]').forEach((button) => {
|
||
const [presetCpu, presetRam] = presets[button.dataset.preset];
|
||
const active = cpu === presetCpu && mib === presetRam;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-pressed', String(active));
|
||
});
|
||
}
|
||
|
||
function resetPasswordVisibility() {
|
||
$('#create-password').type = 'password';
|
||
const toggle = $('[data-action="toggle-password"]');
|
||
toggle.setAttribute('aria-pressed', 'false');
|
||
toggle.setAttribute('aria-label', 'Show password');
|
||
}
|
||
|
||
async function submitCreate(event) {
|
||
event.preventDefault();
|
||
if (state.busy.has('create')) return;
|
||
const session = state.session;
|
||
let payload;
|
||
try {
|
||
parseRam($('#create-ram').value);
|
||
const cpu = Number($('#create-cpu').value);
|
||
if (!Number.isFinite(cpu) || cpu <= 0) throw new Error('CPU capacity must be a positive number.');
|
||
payload = {
|
||
name: $('#create-name').value.trim() || null,
|
||
cpu,
|
||
ram: $('#create-ram').value.trim(),
|
||
guest_ip: $('#create-guest-ip').value.trim() || null,
|
||
username: $('#create-username').value.trim(),
|
||
password: $('#create-password').value,
|
||
ports: parsePorts($('#create-ports').value),
|
||
no_default_ports: !$('#create-default-ssh').checked,
|
||
};
|
||
if (!payload.password) throw new Error('Add a password for the guest account.');
|
||
} catch (error) {
|
||
text('#create-error', error.message);
|
||
return;
|
||
}
|
||
text('#create-error', '');
|
||
const button = $('#create-submit');
|
||
const previous = button.innerHTML;
|
||
const unlock = lockForm($('#create-form'));
|
||
state.busy.add('create');
|
||
button.textContent = 'Creating your machine…';
|
||
try {
|
||
const vm = await api('/vms', { method: 'POST', body: payload });
|
||
if (session !== state.session) return;
|
||
if (!vm?.id) throw new Error('The machine response was not recognized. Refresh the registry to check its state.');
|
||
$('#create-form').reset();
|
||
resetPasswordVisibility();
|
||
updateCreateSummary();
|
||
toast('A new little machine, ready for you.');
|
||
location.hash = vmHref(vm.id);
|
||
await loadVms();
|
||
} catch (error) {
|
||
if (!isAborted(error) && session === state.session) text('#create-error', error.message);
|
||
} finally {
|
||
unlock();
|
||
button.innerHTML = previous;
|
||
if (session === state.session) state.busy.delete('create');
|
||
}
|
||
}
|
||
|
||
async function runBulkAction() {
|
||
if (!state.selected.size || state.busy.has('bulk')) return;
|
||
const ids = [...state.selected];
|
||
const action = $('#bulk-action').value;
|
||
const session = state.session;
|
||
const labels = { start: 'Start', stop: 'Stop', restart: 'Restart', delete: 'Delete', 'ports-sync': 'Sync policies for' };
|
||
const title = `${labels[action]} ${ids.length} ${ids.length === 1 ? 'machine' : 'machines'}?`;
|
||
const description = action === 'delete'
|
||
? 'Every selected machine and its private disk will be permanently removed. Network resources will be released.'
|
||
: `This applies to the ${ids.length} selected ${ids.length === 1 ? 'machine' : 'machines'}. Each result will be reported separately.`;
|
||
if (!await confirmAction(title, description, action === 'ports-sync' ? 'Sync policies' : `${labels[action]} selected`, action === 'delete')) return;
|
||
if (session !== state.session) return;
|
||
state.busy.add('bulk');
|
||
ids.forEach((id) => state.busy.add(id));
|
||
await withButton($('#bulk-run-btn'), 'Applying…', async () => {
|
||
try {
|
||
const results = requireList(await api('/vms/actions', { method: 'POST', body: { action, identifiers: ids } }));
|
||
if (session !== state.session) return;
|
||
const failures = results.filter((result) => result.outcome !== 'succeeded');
|
||
state.selected = new Set(failures.map((result) => result.id));
|
||
$('#bulk-results').hidden = !failures.length;
|
||
if (failures.length) {
|
||
html('#bulk-results', `<header><h2>${results.length - failures.length} succeeded · ${failures.length} need attention</h2><button class="icon-button" type="button" data-action="dismiss-results" aria-label="Dismiss action results">${icon('close')}</button></header><ul>${failures.map((result) => `<li><strong>${esc(result.id)}</strong> · ${esc(label(result.outcome))}${result.error ? `: ${esc(result.error)}` : ''}</li>`).join('')}</ul>`);
|
||
}
|
||
toast(failures.length ? `${results.length - failures.length} succeeded. See the details for the remaining machines.` : `${results.length} ${results.length === 1 ? 'machine' : 'machines'} updated.`, Boolean(failures.length));
|
||
await loadVms();
|
||
} catch (error) {
|
||
if (!isAborted(error) && session === state.session) toast(error.message, true);
|
||
} finally {
|
||
if (session === state.session) {
|
||
state.busy.delete('bulk');
|
||
ids.forEach((id) => state.busy.delete(id));
|
||
renderMachines();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
async function installAssets() {
|
||
const session = state.session;
|
||
const force = $('#install-force').checked;
|
||
await withButton($('#install-btn'), 'Preparing your host…', async () => {
|
||
$('#install-force').disabled = true;
|
||
$('#install-result').hidden = false;
|
||
text('#install-result', 'Installing the host and guest assets. This can take a few minutes.');
|
||
try {
|
||
const result = await api('/install', { method: 'POST', body: { force } });
|
||
if (session !== state.session) return;
|
||
text('#install-result', `Installation complete.\n\nFirecracker\n${result.firecracker}\n\nGuest kernel\n${result.kernel}\n\nRoot filesystem\n${result.rootfs}`);
|
||
toast('A fresh foundation for your machines.');
|
||
await Promise.all([loadDiagnostics(), loadHealth()]);
|
||
} catch (error) {
|
||
if (!isAborted(error) && session === state.session) {
|
||
text('#install-result', error.message);
|
||
toast('Installation could not finish. See the host details.', true);
|
||
}
|
||
} finally { $('#install-force').disabled = false; }
|
||
});
|
||
}
|
||
|
||
function closeMenu(restoreFocus = false) {
|
||
$('#action-menu').hidden = true;
|
||
if (state.menuTrigger?.isConnected) {
|
||
state.menuTrigger.setAttribute('aria-expanded', 'false');
|
||
if (restoreFocus) state.menuTrigger.focus();
|
||
}
|
||
state.menuTrigger = null;
|
||
}
|
||
|
||
function openMachineMenu(trigger) {
|
||
if (state.menuTrigger === trigger && !$('#action-menu').hidden) return closeMenu(true);
|
||
closeMenu();
|
||
const id = trigger.dataset.id;
|
||
const vm = state.vms.find((item) => item.id === id);
|
||
if (!vm) return;
|
||
const running = runtimeStatus(vm) === 'running';
|
||
const items = [['inspect', 'eye', 'Inspect machine']];
|
||
if (running) items.push(['restart', 'refresh', 'Restart'], ['stop', 'stop', 'Stop machine']);
|
||
else items.push(['start', 'play', 'Start machine']);
|
||
if (vm.ports !== null) items.push(['ports-sync', 'shield', 'Sync port policy']);
|
||
items.push(['delete', 'trash', 'Delete machine']);
|
||
const menu = $('#action-menu');
|
||
html(menu, items.map(([action, symbol, caption]) => `<button type="button" role="menuitem" data-action="${action === 'inspect' ? 'inspect' : 'vm-action'}" data-operation="${action}" data-id="${esc(id)}" class="${action === 'delete' ? 'menu-danger' : ''}" ${state.busy.has(id) && action !== 'inspect' ? 'disabled' : ''}>${icon(symbol)}${caption}</button>`).join(''));
|
||
state.menuTrigger = trigger;
|
||
trigger.setAttribute('aria-expanded', 'true');
|
||
menu.hidden = false;
|
||
const rect = trigger.getBoundingClientRect();
|
||
const height = menu.getBoundingClientRect().height;
|
||
menu.style.left = `${Math.max(12, Math.min(rect.right - 181, window.innerWidth - 193))}px`;
|
||
menu.style.top = `${Math.max(12, rect.bottom + height + 8 > window.innerHeight ? rect.top - height - 6 : rect.bottom + 6)}px`;
|
||
$('button', menu)?.focus();
|
||
}
|
||
|
||
function closeSearch() {
|
||
$('#search-results').hidden = true;
|
||
$('#global-search').setAttribute('aria-expanded', 'false');
|
||
$('#global-search').removeAttribute('aria-activedescendant');
|
||
state.searchIndex = -1;
|
||
}
|
||
|
||
function renderSearch() {
|
||
if (!state.authenticated) return;
|
||
const query = $('#global-search').value.trim().toLowerCase();
|
||
const candidates = Object.entries(pages).filter(([key]) => key !== 'vm-detail').map(([key, page]) => ({
|
||
title: page.label, description: 'Workspace page', href: `#${key}`, icon: page.icon, search: page.label,
|
||
})).concat(state.vms.map((vm) => ({ title: machineName(vm), description: `${vm.guest_ip || vm.id} · ${label(runtimeStatus(vm))}`, href: vmHref(vm.id), icon: 'server', search: [vm.name, vm.id, vm.guest_ip].join(' ') })));
|
||
const results = candidates.filter((item) => !query || item.search.toLowerCase().includes(query)).slice(0, 8);
|
||
state.searchIndex = Math.min(state.searchIndex, results.length - 1);
|
||
html('#search-results', results.length ? results.map((item, index) => `<a id="search-option-${index}" class="search-result" href="${esc(item.href)}" role="option" aria-selected="${index === state.searchIndex}" tabindex="-1">${icon(item.icon)}<div><strong>${esc(item.title)}</strong><small>${esc(item.description)}</small></div>${icon('arrow')}</a>`).join('') : '<p class="search-empty">No machines or pages found. Try another name.</p>');
|
||
$('#search-results').hidden = false;
|
||
$('#global-search').setAttribute('aria-expanded', 'true');
|
||
if (state.searchIndex >= 0) $('#global-search').setAttribute('aria-activedescendant', `search-option-${state.searchIndex}`);
|
||
else $('#global-search').removeAttribute('aria-activedescendant');
|
||
}
|
||
|
||
function focusSearch() {
|
||
setMobileNav(false);
|
||
$('#global-search').focus();
|
||
$('#global-search').select();
|
||
renderSearch();
|
||
}
|
||
|
||
function setMobileNav(open, restoreFocus = false) {
|
||
const mobile = window.matchMedia('(max-width: 700px)').matches;
|
||
const shown = open && mobile;
|
||
$('#main-app').classList.toggle('nav-open', shown);
|
||
$('#nav-toggle').setAttribute('aria-expanded', String(shown));
|
||
$('#nav-toggle').setAttribute('aria-label', shown ? 'Close navigation' : 'Open navigation');
|
||
$('#nav-scrim').hidden = !shown;
|
||
$('.workspace').inert = shown;
|
||
if (shown) {
|
||
$('#workspace-nav').setAttribute('role', 'dialog');
|
||
$('#workspace-nav').setAttribute('aria-modal', 'true');
|
||
$('.brand', $('#workspace-nav')).focus();
|
||
} else {
|
||
$('#workspace-nav').removeAttribute('role');
|
||
$('#workspace-nav').removeAttribute('aria-modal');
|
||
if (restoreFocus && mobile) $('#nav-toggle').focus();
|
||
}
|
||
}
|
||
|
||
function route(initial = false) {
|
||
if (!state.authenticated) return;
|
||
let value = location.hash.slice(1) || 'overview';
|
||
let identifier = null;
|
||
if (value.startsWith('vm/')) {
|
||
try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; }
|
||
value = identifier ? 'vm-detail' : 'overview';
|
||
}
|
||
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
|
||
const previous = state.page;
|
||
state.page = value;
|
||
state.requests.detail += 1;
|
||
state.requests.logs += 1;
|
||
state.detailId = identifier;
|
||
state.detail = null;
|
||
state.portEditing = false;
|
||
closeMenu();
|
||
closeSearch();
|
||
setMobileNav(false);
|
||
$$('.page').forEach((page) => { page.hidden = page.id !== `page-${value}`; });
|
||
$$('.nav-item[data-nav]').forEach((item) => {
|
||
const active = item.dataset.nav === (value === 'vm-detail' ? 'vms' : value);
|
||
item.classList.toggle('active', active);
|
||
if (active) item.setAttribute('aria-current', 'page');
|
||
else item.removeAttribute('aria-current');
|
||
});
|
||
text('#sidebar-title', pages[value].title);
|
||
text('#sidebar-subtitle', pages[value].subtitle);
|
||
text('#breadcrumb', pages[value].label);
|
||
document.title = `${pages[value].label} · UVM Control`;
|
||
$('#main-content').scrollTop = 0;
|
||
if (!initial && previous !== value && window.matchMedia('(max-width: 700px)').matches) window.scrollTo({ top: 0, behavior: 'instant' });
|
||
if (value === 'vm-detail') {
|
||
$('#detail-content').hidden = true;
|
||
$('#detail-loading').hidden = false;
|
||
html('#detail-loading', 'Getting to know this machine…');
|
||
text('#log-output', 'Bringing the runtime log into view…');
|
||
loadDetail(identifier);
|
||
}
|
||
if (value === 'system') loadDiagnostics();
|
||
if (value === 'create') updateCreateSummary();
|
||
if (!initial && value !== 'vm-detail') $(`#page-${value} h1`)?.focus({ preventScroll: true });
|
||
}
|
||
|
||
function storeToken(token) {
|
||
try {
|
||
if (token) sessionStorage.setItem('uvm_token', token);
|
||
else sessionStorage.removeItem('uvm_token');
|
||
} catch { /* A restricted browser can still use an in-memory session. */ }
|
||
}
|
||
|
||
async function connect(token) {
|
||
if (!token || !/^[!-~]+$/.test(token)) {
|
||
text('#auth-error', 'Enter a management token without spaces or non-ASCII characters.');
|
||
return;
|
||
}
|
||
const session = ++state.session;
|
||
state.token = token;
|
||
text('#auth-error', '');
|
||
const button = $('#auth-btn');
|
||
const previous = button.innerHTML;
|
||
button.disabled = true;
|
||
button.textContent = 'Opening your workspace…';
|
||
$('#token-input').disabled = true;
|
||
try {
|
||
const vms = requireList(await api('/vms'));
|
||
if (session !== state.session) return;
|
||
state.authenticated = true;
|
||
state.vms = vms;
|
||
state.loaded = true;
|
||
state.lastUpdate = Date.now();
|
||
storeToken(token);
|
||
$('#token-input').value = '';
|
||
$('#auth-screen').hidden = true;
|
||
$('#main-app').hidden = false;
|
||
renderFleet();
|
||
renderHost();
|
||
route(true);
|
||
Promise.all([loadHealth(), loadDiagnostics()]);
|
||
window.clearInterval(state.timer);
|
||
state.timer = window.setInterval(() => {
|
||
if (!document.hidden) refreshData();
|
||
}, 5000);
|
||
} catch (error) {
|
||
if (session !== state.session || isAborted(error)) return;
|
||
state.token = '';
|
||
storeToken('');
|
||
text('#auth-error', error.status === 401 ? 'That token wasn’t accepted. Give it another look.' : error.message);
|
||
$('#token-input').focus();
|
||
} finally {
|
||
if (session === state.session) {
|
||
button.disabled = false;
|
||
button.innerHTML = previous;
|
||
$('#token-input').disabled = false;
|
||
if (!state.authenticated) $('#token-input').focus();
|
||
}
|
||
}
|
||
}
|
||
|
||
function logout(message = '') {
|
||
state.session += 1;
|
||
state.controllers.forEach((controller) => controller.abort());
|
||
state.controllers.clear();
|
||
window.clearInterval(state.timer);
|
||
state.timer = null;
|
||
state.token = '';
|
||
state.authenticated = false;
|
||
state.vms = [];
|
||
state.loaded = false;
|
||
state.health = null;
|
||
state.checks = null;
|
||
state.vmError = '';
|
||
state.healthError = '';
|
||
state.checksError = '';
|
||
state.lastUpdate = 0;
|
||
state.lastChecks = 0;
|
||
state.lastChecksAttempt = 0;
|
||
state.refreshPromise = null;
|
||
state.checksPromise = null;
|
||
state.selected.clear();
|
||
state.busy.clear();
|
||
state.detail = null;
|
||
state.detailId = null;
|
||
state.portEditing = false;
|
||
state.query = '';
|
||
state.filter = 'all';
|
||
state.sort = 'recent';
|
||
storeToken('');
|
||
closeMenu();
|
||
closeSearch();
|
||
setMobileNav(false);
|
||
if ($('#confirm-dialog').open) $('#confirm-dialog').close('cancel');
|
||
$('#create-form').reset();
|
||
resetPasswordVisibility();
|
||
$('#install-force').checked = false;
|
||
$('#install-result').hidden = true;
|
||
$('#bulk-results').hidden = true;
|
||
$('#vm-search').value = '';
|
||
$('#vm-sort').value = 'recent';
|
||
$('#global-search').value = '';
|
||
$('#token-input').value = '';
|
||
$('#token-input').disabled = false;
|
||
text('#create-error', '');
|
||
text('#port-error', '');
|
||
text('#auth-error', message);
|
||
$('#main-app').hidden = true;
|
||
$('#auth-screen').hidden = false;
|
||
$('#refresh-btn').classList.remove('is-loading');
|
||
$('#toast-container').replaceChildren();
|
||
document.title = 'UVM Control';
|
||
$('#token-input').focus();
|
||
}
|
||
|
||
function handleAction(target) {
|
||
const action = target.dataset.action;
|
||
const id = target.dataset.id;
|
||
if (action === 'search') focusSearch();
|
||
if (action === 'refresh') manualRefresh();
|
||
if (action === 'diagnostics') loadDiagnostics();
|
||
if (action === 'machine-menu') openMachineMenu(target);
|
||
if (action === 'inspect') { closeMenu(); location.hash = vmHref(id); }
|
||
if (action === 'vm-action') { closeMenu(true); machineAction(target.dataset.operation, id); }
|
||
if (action === 'clear-selection') { state.selected.clear(); renderMachines(); }
|
||
if (action === 'clear-filters') { state.query = ''; state.filter = 'all'; $('#vm-search').value = ''; renderMachines(); }
|
||
if (action === 'dismiss-results') $('#bulk-results').hidden = true;
|
||
if (action === 'install') installAssets();
|
||
if (action === 'refresh-logs') loadLogs();
|
||
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
|
||
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
|
||
if (action === 'sync-ports' && state.detail) machineAction('ports-sync', state.detail.id);
|
||
if (action === 'edit-ports') {
|
||
state.portEditing = true;
|
||
text('#port-error', '');
|
||
renderDetail();
|
||
$('#port-input').focus();
|
||
}
|
||
if (action === 'cancel-ports') { state.portEditing = false; text('#port-error', ''); renderDetail(); }
|
||
if (action === 'toggle-password') {
|
||
const visible = $('#create-password').type === 'password';
|
||
$('#create-password').type = visible ? 'text' : 'password';
|
||
target.setAttribute('aria-pressed', String(visible));
|
||
target.setAttribute('aria-label', visible ? 'Hide password' : 'Show password');
|
||
}
|
||
}
|
||
|
||
function bindEvents() {
|
||
$('#auth-form').addEventListener('submit', (event) => { event.preventDefault(); connect($('#token-input').value.trim()); });
|
||
$('#logout-btn').addEventListener('click', () => logout());
|
||
$('#refresh-btn').addEventListener('click', manualRefresh);
|
||
$('#create-form').addEventListener('submit', submitCreate);
|
||
$('#create-form').addEventListener('input', updateCreateSummary);
|
||
$('#port-editor').addEventListener('submit', submitPorts);
|
||
$('#bulk-run-btn').addEventListener('click', runBulkAction);
|
||
$('#log-tail').addEventListener('change', loadLogs);
|
||
$('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); });
|
||
$('#vm-sort').addEventListener('change', (event) => { state.sort = event.target.value; renderMachines(); });
|
||
$('#vm-select-all').addEventListener('change', (event) => {
|
||
visibleVms().forEach((vm) => {
|
||
if (event.target.checked) state.selected.add(vm.id);
|
||
else state.selected.delete(vm.id);
|
||
});
|
||
renderMachines();
|
||
});
|
||
$('#machine-rows').addEventListener('change', (event) => {
|
||
const checkbox = event.target.closest('[data-select]');
|
||
if (!checkbox) return;
|
||
if (checkbox.checked) state.selected.add(checkbox.dataset.select);
|
||
else state.selected.delete(checkbox.dataset.select);
|
||
const id = checkbox.dataset.select;
|
||
renderMachines();
|
||
$$('[data-select]').find((item) => item.dataset.select === id)?.focus({ preventScroll: true });
|
||
});
|
||
$('#global-search').addEventListener('input', () => { state.searchIndex = -1; renderSearch(); });
|
||
$('#global-search').addEventListener('focus', renderSearch);
|
||
$('#global-search').addEventListener('keydown', (event) => {
|
||
if (event.key === 'Escape') { closeSearch(); event.stopPropagation(); return; }
|
||
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return;
|
||
const results = $$('.search-result');
|
||
if (!results.length) return;
|
||
event.preventDefault();
|
||
if (event.key === 'Enter') {
|
||
const selected = results[Math.max(0, state.searchIndex)];
|
||
location.hash = selected.getAttribute('href');
|
||
$('#global-search').value = '';
|
||
closeSearch();
|
||
$('#global-search').blur();
|
||
} else {
|
||
state.searchIndex = (state.searchIndex + (event.key === 'ArrowDown' ? 1 : -1) + results.length) % results.length;
|
||
renderSearch();
|
||
$(`#search-option-${state.searchIndex}`)?.scrollIntoView({ block: 'nearest' });
|
||
}
|
||
});
|
||
$('#nav-toggle').addEventListener('click', () => setMobileNav(!$('#main-app').classList.contains('nav-open')));
|
||
$('#nav-scrim').addEventListener('click', () => setMobileNav(false, true));
|
||
$('#confirm-dialog').addEventListener('click', (event) => {
|
||
if (event.target !== $('#confirm-dialog')) return;
|
||
const rect = event.target.getBoundingClientRect();
|
||
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close('cancel');
|
||
});
|
||
document.addEventListener('click', (event) => {
|
||
const target = event.target.closest('[data-action]');
|
||
if (!event.target.closest('.global-search')) closeSearch();
|
||
if (!event.target.closest('#action-menu, [data-action="machine-menu"]')) closeMenu();
|
||
if (event.target.closest('.search-result')) {
|
||
$('#global-search').value = '';
|
||
closeSearch();
|
||
}
|
||
if (event.target.closest('a[href^="#"]')) setMobileNav(false);
|
||
const filter = event.target.closest('[data-filter]');
|
||
if (filter) { state.filter = filter.dataset.filter; renderMachines(); }
|
||
const preset = event.target.closest('[data-preset]');
|
||
if (preset) {
|
||
const sizes = { small: [1, '512M'], balanced: [2, '1G'], roomy: [4, '2G'] };
|
||
const [cpu, ram] = sizes[preset.dataset.preset];
|
||
$('#create-cpu').value = cpu;
|
||
$('#create-ram').value = ram;
|
||
updateCreateSummary();
|
||
}
|
||
if (target && !target.disabled) handleAction(target);
|
||
});
|
||
document.addEventListener('keydown', (event) => {
|
||
const editing = event.target.closest('input, textarea, select, [contenteditable="true"]');
|
||
if (state.authenticated && ((event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) || (event.key === '/' && !editing))) {
|
||
if ($('#confirm-dialog').open) return;
|
||
event.preventDefault();
|
||
focusSearch();
|
||
}
|
||
if (event.key === 'Escape') {
|
||
closeSearch();
|
||
closeMenu(true);
|
||
if ($('#main-app').classList.contains('nav-open')) setMobileNav(false, true);
|
||
}
|
||
if (!$('#action-menu').hidden && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
|
||
event.preventDefault();
|
||
const items = $$('button:not(:disabled)', $('#action-menu'));
|
||
let index = items.indexOf(document.activeElement);
|
||
index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
|
||
items[index]?.focus();
|
||
}
|
||
if (event.key === 'Tab' && $('#main-app').classList.contains('nav-open')) {
|
||
const items = $$('a, button:not(:disabled)', $('#workspace-nav'));
|
||
if (event.shiftKey && document.activeElement === items[0]) { event.preventDefault(); items.at(-1).focus(); }
|
||
if (!event.shiftKey && document.activeElement === items.at(-1)) { event.preventDefault(); items[0].focus(); }
|
||
}
|
||
});
|
||
document.addEventListener('focusin', (event) => {
|
||
if (!event.target.closest('.global-search')) closeSearch();
|
||
if (!$('#action-menu').hidden && !event.target.closest('#action-menu, [data-action="machine-menu"]')) closeMenu();
|
||
});
|
||
$('#main-content').addEventListener('scroll', () => { closeMenu(); closeSearch(); }, { passive: true });
|
||
window.addEventListener('resize', () => { closeMenu(); if (!window.matchMedia('(max-width: 700px)').matches) setMobileNav(false); });
|
||
window.addEventListener('hashchange', () => route());
|
||
document.addEventListener('visibilitychange', () => { if (!document.hidden && state.authenticated) refreshData(); });
|
||
}
|
||
|
||
function boot() {
|
||
bindEvents();
|
||
text('#today-label', new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' }).format(new Date()));
|
||
updateCreateSummary();
|
||
let stored = '';
|
||
try { stored = sessionStorage.getItem('uvm_token') || ''; } catch { /* Session storage is optional. */ }
|
||
if (stored) connect(stored);
|
||
}
|
||
|
||
boot();
|
||
})();
|