Dev #6
@@ -26,6 +26,8 @@
|
||||
page: 'overview',
|
||||
detailId: null,
|
||||
detail: null,
|
||||
detailTab: 'overview',
|
||||
terminalId: null,
|
||||
portEditing: false,
|
||||
resizeEditing: false,
|
||||
selected: new Set(),
|
||||
@@ -45,6 +47,13 @@
|
||||
menuTrigger: null,
|
||||
};
|
||||
|
||||
const sessions = new window.NestSessions({
|
||||
api, getToken: () => state.token,
|
||||
isSelectedVm: (id) => state.page === 'vm-detail' && state.detailId === id,
|
||||
onExpired: () => logout('Your token was not accepted. Connect again to continue.'),
|
||||
esc, formatDate, toast, confirmAction,
|
||||
});
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
@@ -598,9 +607,22 @@
|
||||
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
|
||||
html('#detail-actions', (running
|
||||
? actionButton('restart', 'Restart', 'refresh') + actionButton('stop', 'Stop machine', 'stop')
|
||||
: actionButton('start', 'Start machine', 'play', true));
|
||||
: actionButton('start', 'Start machine', 'play', true))
|
||||
+ `<button class="button button-primary" type="button" data-action="launch-session">${icon('terminal')}<span>Launch session</span></button>`);
|
||||
const showingSessions = state.detailTab === 'sessions';
|
||||
$('#vm-overview-panel').hidden = showingSessions;
|
||||
$('#vm-sessions-panel').hidden = !showingSessions;
|
||||
$('#vm-overview-tab').href = vmHref(vm.id);
|
||||
$('#vm-sessions-tab').href = `${vmHref(vm.id)}/sessions`;
|
||||
[['#vm-overview-tab', !showingSessions], ['#vm-sessions-tab', showingSessions]].forEach(([selector, active]) => {
|
||||
$(selector).classList.toggle('active', active);
|
||||
if (active) $(selector).setAttribute('aria-current', 'page');
|
||||
else $(selector).removeAttribute('aria-current');
|
||||
});
|
||||
text('#breadcrumb', `Machines / ${machineName(vm)}${showingSessions ? ' / Sessions' : ''}`);
|
||||
sessions.sync(vm, state.terminalId, showingSessions);
|
||||
html('#detail-config', detailRows([
|
||||
['Recorded state', label(vm.status)], ['Observed state', label(runtimeStatus(vm))],
|
||||
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
|
||||
@@ -655,13 +677,14 @@
|
||||
state.detail = vm;
|
||||
state.detailId = vm.id;
|
||||
renderDetail();
|
||||
await loadLogs();
|
||||
if (state.detailTab === 'overview') 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 {
|
||||
sessions.leave();
|
||||
state.detail = null;
|
||||
$('#detail-content').hidden = true;
|
||||
$('#detail-loading').hidden = false;
|
||||
@@ -1119,8 +1142,17 @@
|
||||
if (!state.authenticated) return;
|
||||
let value = location.hash.slice(1) || 'overview';
|
||||
let identifier = null;
|
||||
let detailTab = 'overview';
|
||||
let terminalId = null;
|
||||
if (value.startsWith('vm/')) {
|
||||
try { identifier = decodeURIComponent(value.slice(3)); } catch { identifier = null; }
|
||||
const parts = value.split('/');
|
||||
try {
|
||||
identifier = decodeURIComponent(parts[1]);
|
||||
if (parts[2] === 'sessions' && parts.length <= 4) {
|
||||
detailTab = 'sessions';
|
||||
terminalId = parts[3] ? decodeURIComponent(parts[3]) : null;
|
||||
} else if (parts.length !== 2) identifier = null;
|
||||
} catch { identifier = null; }
|
||||
value = identifier ? 'vm-detail' : 'overview';
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(pages, value)) value = 'overview';
|
||||
@@ -1129,6 +1161,9 @@
|
||||
state.requests.detail += 1;
|
||||
state.requests.logs += 1;
|
||||
state.detailId = identifier;
|
||||
state.detailTab = detailTab;
|
||||
state.terminalId = terminalId;
|
||||
sessions.leave();
|
||||
state.detail = null;
|
||||
state.portEditing = false;
|
||||
state.resizeEditing = false;
|
||||
@@ -1217,6 +1252,7 @@
|
||||
}
|
||||
|
||||
function logout(message = '') {
|
||||
sessions.leave();
|
||||
state.session += 1;
|
||||
state.controllers.forEach((controller) => controller.abort());
|
||||
state.controllers.clear();
|
||||
@@ -1285,6 +1321,7 @@
|
||||
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 === 'launch-session' && state.detail) sessions.launch(state.detail);
|
||||
if (action === 'refresh-logs') loadLogs();
|
||||
if (action === 'copy-ssh') copyText($('#ssh-command').textContent);
|
||||
if (action === 'delete-current' && state.detail) machineAction('delete', state.detail.id);
|
||||
@@ -1388,6 +1425,7 @@
|
||||
if (target && !target.disabled) handleAction(target);
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.target.closest('.xterm')) return;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const href = (vmId, sessionId = '') => `#vm/${encodeURIComponent(vmId)}/sessions${sessionId ? `/${encodeURIComponent(sessionId)}` : ''}`;
|
||||
const path = (vmId, suffix = '') => `/vms/${encodeURIComponent(vmId)}/sessions${suffix}`;
|
||||
const bytes = (encoded) => Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
|
||||
const encoded = (data) => btoa(String.fromCharCode(...data));
|
||||
|
||||
window.NestSessions = class {
|
||||
constructor({ api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction }) {
|
||||
Object.assign(this, { api, getToken, isSelectedVm, onExpired, esc, formatDate, toast, confirmAction });
|
||||
this.vm = null;
|
||||
this.records = [];
|
||||
this.current = null;
|
||||
this.selectedId = null;
|
||||
this.visible = false;
|
||||
this.sequence = 0;
|
||||
this.generation = 0;
|
||||
this.launching = new Set();
|
||||
this.upgrading = new Set();
|
||||
this.offset = 0;
|
||||
this.renderPending = Promise.resolve();
|
||||
$('#sessions-refresh').addEventListener('click', () => this.refresh());
|
||||
$('#session-end').addEventListener('click', () => this.end());
|
||||
$('#session-reconnect').addEventListener('click', () => this.connectSocket());
|
||||
$('#session-install-agent').addEventListener('click', () => this.installAgent());
|
||||
}
|
||||
|
||||
sync(vm, selectedId, visible) {
|
||||
if (this.vm?.id !== vm.id) {
|
||||
this.leave();
|
||||
this.vm = vm;
|
||||
$('#vm-session-count').textContent = '—';
|
||||
}
|
||||
this.vm = vm;
|
||||
if (this.selectedId !== selectedId || this.visible !== visible) {
|
||||
this.detachTerminal();
|
||||
this.showError('');
|
||||
}
|
||||
this.selectedId = selectedId;
|
||||
this.visible = visible;
|
||||
this.updateControls();
|
||||
if (this.pendingError?.vmId === vm.id && visible) {
|
||||
this.showError(this.pendingError.message);
|
||||
$('#terminal-support').open = this.pendingError.setup;
|
||||
this.pendingError = null;
|
||||
}
|
||||
this.renderList();
|
||||
this.openSelected();
|
||||
if (!this.loading && (!this.lastRefresh || Date.now() - this.lastRefresh > 3000)) this.refresh();
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
$('#sessions-error').textContent = message;
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const running = this.vm && (this.vm.observed_status || this.vm.status) === 'running'
|
||||
&& !this.vm.operation && !this.vm.pending_resize;
|
||||
document.querySelectorAll('[data-action="launch-session"]').forEach((button) => {
|
||||
button.disabled = !running || this.launching.has(this.vm?.id);
|
||||
button.querySelector('span').textContent = this.launching.has(this.vm?.id) ? 'Launching…' : 'Launch session';
|
||||
button.title = running ? 'Open an interactive shell inside this microVM' : 'Start this machine before launching a session';
|
||||
});
|
||||
const stopped = this.vm?.status === 'stopped' && (this.vm.observed_status || this.vm.status) === 'stopped'
|
||||
&& !this.vm.operation && !this.vm.pending_resize;
|
||||
$('#session-install-agent').disabled = !stopped || this.upgrading.has(this.vm?.id);
|
||||
$('#session-install-agent').textContent = this.upgrading.has(this.vm?.id) ? 'Updating terminal support…' : 'Install / update terminal support';
|
||||
$('#session-install-hint').textContent = stopped ? 'Ready to update on this stopped machine.' : 'Stop the machine before updating its guest agent.';
|
||||
this.renderStatus();
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const markup = this.records.length ? this.records.map((record) => `
|
||||
<a class="session-item${record.id === this.selectedId ? ' active' : ''}" href="${href(this.vm.id, record.id)}" ${record.id === this.selectedId ? 'aria-current="page"' : ''}>
|
||||
<strong>${this.esc(record.name)}</strong><i class="session-state ${this.esc(record.status)}" aria-hidden="true"></i>
|
||||
<small>${this.esc(this.formatDate(record.created_at))}<br />${this.esc(record.status)}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}</small>
|
||||
</a>`).join('') : `<p class="session-list-empty">${this.loading ? 'Loading sessions…' : 'No sessions yet.<br />Launch a session to get started.'}</p>`;
|
||||
if ($('#session-list').innerHTML !== markup) $('#session-list').innerHTML = markup;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.vm) return;
|
||||
const vmId = this.vm.id;
|
||||
const sequence = ++this.sequence;
|
||||
this.loading = true;
|
||||
$('#sessions-refresh').disabled = true;
|
||||
try {
|
||||
const records = await this.api(path(vmId));
|
||||
if (sequence !== this.sequence || this.vm?.id !== vmId) return;
|
||||
if (!Array.isArray(records)) throw new Error('The session history response was not recognized.');
|
||||
this.records = records;
|
||||
if (this.current) {
|
||||
const latest = records.find((record) => record.id === this.current.id);
|
||||
if (latest && latest.updated_at >= this.current.updated_at) this.current = latest;
|
||||
else if (latest) this.records[this.records.indexOf(latest)] = this.current;
|
||||
}
|
||||
this.lastRefresh = Date.now();
|
||||
$('#vm-session-count').textContent = records.length;
|
||||
this.openSelected();
|
||||
this.renderStatus();
|
||||
} catch (error) {
|
||||
if (sequence === this.sequence && error.name !== 'AbortError') this.showError(error.message);
|
||||
} finally {
|
||||
if (sequence === this.sequence) {
|
||||
this.loading = false;
|
||||
$('#sessions-refresh').disabled = false;
|
||||
this.renderList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async launch(vm) {
|
||||
if (!vm || this.launching.has(vm.id)) return;
|
||||
const token = this.getToken();
|
||||
this.launching.add(vm.id);
|
||||
this.showError('');
|
||||
this.updateControls();
|
||||
try {
|
||||
const record = await this.api(path(vm.id), { method: 'POST', body: { rows: 24, cols: 80 } });
|
||||
if (token === this.getToken() && this.isSelectedVm(vm.id)) location.hash = href(vm.id, record.id);
|
||||
} catch (error) {
|
||||
if (token === this.getToken() && error.name !== 'AbortError') {
|
||||
this.toast(error.message, true);
|
||||
if (this.isSelectedVm(vm.id)) {
|
||||
this.pendingError = { vmId: vm.id, message: error.message, setup: error.status === 503 };
|
||||
location.hash = href(vm.id);
|
||||
if (this.visible) this.sync(vm, null, true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.launching.delete(vm.id);
|
||||
this.lastRefresh = 0;
|
||||
this.updateControls();
|
||||
}
|
||||
}
|
||||
|
||||
async installAgent() {
|
||||
const vm = this.vm;
|
||||
if (!vm || this.upgrading.has(vm.id)) return;
|
||||
this.upgrading.add(vm.id);
|
||||
this.showError('');
|
||||
this.updateControls();
|
||||
try {
|
||||
await this.api(`/vms/${encodeURIComponent(vm.id)}/agent/install`, { method: 'POST' });
|
||||
if (this.vm?.id === vm.id) {
|
||||
this.toast('Terminal support updated. Start this machine, then launch a session.');
|
||||
$('#session-install-hint').textContent = 'Updated. Start the machine to use the new agent.';
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.vm?.id === vm.id && error.name !== 'AbortError') this.showError(error.message);
|
||||
} finally {
|
||||
this.upgrading.delete(vm.id);
|
||||
this.updateControls();
|
||||
}
|
||||
}
|
||||
|
||||
openSelected() {
|
||||
if (!this.visible || !this.selectedId || this.current?.id === this.selectedId) return;
|
||||
const record = this.records.find((item) => item.id === this.selectedId);
|
||||
if (!record) {
|
||||
if (this.lastRefresh) this.showError('This session was not found for the selected machine.');
|
||||
return;
|
||||
}
|
||||
this.showError('');
|
||||
this.detachTerminal();
|
||||
this.current = record;
|
||||
$('#session-blank').hidden = true;
|
||||
$('#session-terminal').hidden = false;
|
||||
this.term = new window.Terminal({
|
||||
cols: record.cols, rows: record.rows, cursorBlink: true, fontSize: 13,
|
||||
fontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
||||
scrollback: 10000, disableStdin: true,
|
||||
theme: { background: '#25222d', foreground: '#e8e3ef', cursor: '#ede39f', selectionBackground: '#74628780' },
|
||||
});
|
||||
this.fit = new window.FitAddon.FitAddon();
|
||||
this.term.loadAddon(this.fit);
|
||||
this.term.open($('#session-terminal'));
|
||||
this.term.onData((text) => this.input(new TextEncoder().encode(text)));
|
||||
this.term.onBinary((text) => this.input(Uint8Array.from(text, (character) => character.charCodeAt(0))));
|
||||
this.term.onResize(({ rows, cols }) => this.sendSize(rows, cols));
|
||||
this.observer = new ResizeObserver(() => this.fitTerminal());
|
||||
this.observer.observe($('#session-terminal'));
|
||||
this.fitTerminal();
|
||||
this.connectSocket();
|
||||
}
|
||||
|
||||
fitTerminal() {
|
||||
if (this.term && this.visible && $('#session-terminal').clientWidth > 0) {
|
||||
this.fit.fit();
|
||||
}
|
||||
}
|
||||
|
||||
input(data) {
|
||||
if (!this.canType()) return;
|
||||
if (data.length > 65536 || this.socket.bufferedAmount > 65536) {
|
||||
this.showError('Terminal input is busy or the paste exceeds 64 KiB. Please send a smaller chunk.');
|
||||
return;
|
||||
}
|
||||
for (let offset = 0; offset < data.length; offset += 8192) {
|
||||
this.socket.send(JSON.stringify({ type: 'input', data: encoded(data.subarray(offset, offset + 8192)) }));
|
||||
}
|
||||
}
|
||||
|
||||
canType() {
|
||||
return this.socket?.readyState === WebSocket.OPEN && this.current?.status === 'active' && this.caughtUp;
|
||||
}
|
||||
|
||||
sendSize(rows, cols) {
|
||||
if (this.canType()) this.socket.send(JSON.stringify({ type: 'resize', rows: Math.max(2, Math.min(200, rows)), cols: Math.max(2, Math.min(400, cols)) }));
|
||||
}
|
||||
|
||||
async connectSocket() {
|
||||
if (!this.current || !this.visible || !this.getToken()) return;
|
||||
const generation = this.generation;
|
||||
await this.renderPending;
|
||||
if (generation !== this.generation || !this.current || !this.visible) return;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
const previous = this.socket;
|
||||
this.socket = null;
|
||||
previous?.close();
|
||||
this.caughtUp = false;
|
||||
this.replayEnd = null;
|
||||
this.connection = 'Connecting…';
|
||||
const url = new URL(path(this.vm.id, `/${encodeURIComponent(this.current.id)}/terminal`), location.origin);
|
||||
url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(url);
|
||||
this.socket = socket;
|
||||
let rejected = false;
|
||||
const current = () => generation === this.generation && this.socket === socket;
|
||||
this.renderStatus();
|
||||
socket.onopen = () => {
|
||||
if (current()) socket.send(JSON.stringify({ type: 'auth', token: this.getToken(), offset: this.offset }));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
if (!current()) return;
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'session') {
|
||||
const wasReady = this.caughtUp;
|
||||
this.current = message.session;
|
||||
if (message.offset > this.offset) {
|
||||
this.term.reset();
|
||||
this.offset = message.offset;
|
||||
}
|
||||
if (this.replayEnd == null) this.replayEnd = message.session.output_end;
|
||||
this.caughtUp = this.offset >= this.replayEnd;
|
||||
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
||||
const index = this.records.findIndex((record) => record.id === message.session.id);
|
||||
if (index >= 0) this.records[index] = message.session;
|
||||
this.renderList();
|
||||
this.renderStatus();
|
||||
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
||||
} else if (message.type === 'output') {
|
||||
const data = bytes(message.data);
|
||||
if (message.offset !== this.offset) throw new Error('Terminal output is out of sequence. Reconnect to load history.');
|
||||
this.renderPending = new Promise((resolve) => {
|
||||
this.finishRender = resolve;
|
||||
this.term.write(data, () => {
|
||||
if (generation === this.generation) {
|
||||
this.offset += data.length;
|
||||
const wasReady = this.caughtUp;
|
||||
this.caughtUp = this.offset >= this.replayEnd;
|
||||
this.connection = this.caughtUp ? 'Connected' : 'Loading history…';
|
||||
if (current()) socket.send(JSON.stringify({ type: 'ack', offset: this.offset }));
|
||||
this.renderStatus();
|
||||
if (!wasReady && this.canType()) { this.sendSize(this.term.rows, this.term.cols); this.term.focus(); }
|
||||
this.finishRender = null;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else if (message.type === 'error') {
|
||||
rejected = true;
|
||||
this.showError(message.message);
|
||||
}
|
||||
} catch (error) {
|
||||
rejected = true;
|
||||
this.showError(error.message);
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
socket.onclose = (event) => {
|
||||
if (!current()) return;
|
||||
this.connection = 'Disconnected';
|
||||
this.caughtUp = false;
|
||||
this.renderStatus();
|
||||
if (event.code === 4401) { this.onExpired(); return; }
|
||||
if (!rejected && ['active', 'starting'].includes(this.current?.status)) {
|
||||
this.reconnectTimer = window.setTimeout(() => this.connectSocket(), 2000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderStatus() {
|
||||
const record = this.current;
|
||||
const active = record?.status === 'active';
|
||||
$('#session-title').textContent = record ? `${record.name} · ${this.vm?.name || this.vm?.id || ''}` : 'Terminal';
|
||||
$('#session-status').textContent = record ? active ? this.connection || 'Connecting…' : `${record.status}${record.exit_code != null ? ` · exit ${record.exit_code}` : ''}` : 'Select a session';
|
||||
$('#session-end').disabled = !active || this.ending === record?.id;
|
||||
$('#session-reconnect').hidden = !active || this.socket?.readyState === WebSocket.OPEN;
|
||||
if (this.term) this.term.options.disableStdin = !this.canType();
|
||||
$('#session-meta').textContent = record
|
||||
? `${record.username || 'Guest'} · Started ${this.formatDate(record.created_at)}${record.ended_at ? ` · Ended ${this.formatDate(record.ended_at)}` : ' · Reopen this session to continue'}${record.truncated ? ' · Showing the latest 8 MiB of output' : ''}${record.error ? ` · ${record.error}` : ''}`
|
||||
: 'Your sessions and terminal output are saved on the host.';
|
||||
}
|
||||
|
||||
async end() {
|
||||
const record = this.current;
|
||||
if (!record || this.ending) return;
|
||||
const confirmed = await this.confirmAction(`End ${record.name}?`, 'Close this shell and its running processes. Its saved terminal output will be available in session history.', 'End session');
|
||||
if (!confirmed) return;
|
||||
this.ending = record.id;
|
||||
this.renderStatus();
|
||||
try {
|
||||
const ended = await this.api(path(record.vm_id, `/${encodeURIComponent(record.id)}/close`), { method: 'POST' });
|
||||
if (this.current?.id === record.id) this.current = ended;
|
||||
if (this.vm?.id === record.vm_id) await this.refresh();
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError' && this.vm?.id === record.vm_id) this.showError(error.message);
|
||||
} finally {
|
||||
this.ending = null;
|
||||
this.renderStatus();
|
||||
}
|
||||
}
|
||||
|
||||
detachTerminal() {
|
||||
this.generation += 1;
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
socket?.close();
|
||||
this.finishRender?.();
|
||||
this.finishRender = null;
|
||||
this.renderPending = Promise.resolve();
|
||||
this.term?.dispose();
|
||||
this.term = null;
|
||||
this.current = null;
|
||||
this.offset = 0;
|
||||
this.caughtUp = false;
|
||||
$('#session-terminal').replaceChildren();
|
||||
$('#session-terminal').hidden = true;
|
||||
$('#session-blank').hidden = false;
|
||||
this.renderStatus();
|
||||
}
|
||||
|
||||
leave() {
|
||||
this.sequence += 1;
|
||||
this.loading = false;
|
||||
this.lastRefresh = 0;
|
||||
this.detachTerminal();
|
||||
this.visible = false;
|
||||
this.vm = null;
|
||||
this.records = [];
|
||||
this.selectedId = null;
|
||||
this.showError('');
|
||||
$('#terminal-support').open = false;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -122,7 +122,7 @@ async def terminal(websocket: WebSocket, vm_id: str, session_id: str):
|
||||
while True:
|
||||
record, start, data = await sessions.read(vm_id, session_id, cursor)
|
||||
if previous != record or start != cursor:
|
||||
await websocket.send_json({"type": "session", "session": record, "offset": start})
|
||||
await websocket.send_json({"type": "session", "session": SessionResponse(**record).model_dump(), "offset": start})
|
||||
previous = record
|
||||
cursor = start
|
||||
if data:
|
||||
|
||||
@@ -63,12 +63,15 @@ class SessionStore:
|
||||
@staticmethod
|
||||
def _private_file(path: Path) -> int:
|
||||
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
|
||||
os.close(descriptor)
|
||||
raise StateError("session history must use private regular files")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
return descriptor
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
@contextmanager
|
||||
def _db(self):
|
||||
|
||||
+13
-2
@@ -13,6 +13,17 @@ from uvm.guest_terminal import GuestTerminal, validate_size
|
||||
from .session_store import SessionStore
|
||||
|
||||
|
||||
async def _persist(function, *arguments):
|
||||
# Cancellation cannot release the history-owner lock while a worker still
|
||||
# has an outstanding write to that database.
|
||||
task = asyncio.create_task(asyncio.to_thread(function, *arguments))
|
||||
try:
|
||||
return await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
await task
|
||||
raise
|
||||
|
||||
|
||||
@dataclass
|
||||
class _LiveSession:
|
||||
vm_id: str
|
||||
@@ -98,7 +109,7 @@ class SessionManager:
|
||||
if message["type"] == "exit":
|
||||
status, exit_code = "closed", message["exit_code"]
|
||||
break
|
||||
await asyncio.to_thread(history.append, session_id, message["data"])
|
||||
await _persist(history.append, session_id, message["data"])
|
||||
live.changed.set()
|
||||
except asyncio.CancelledError:
|
||||
error = "Nest stopped; this shell is no longer connected." if self._closing else "The guest terminal connection was interrupted."
|
||||
@@ -107,7 +118,7 @@ class SessionManager:
|
||||
finally:
|
||||
live.terminal.close()
|
||||
try:
|
||||
await asyncio.to_thread(history.finish, session_id, status, exit_code, error)
|
||||
await _persist(history.finish, session_id, status, exit_code, error)
|
||||
finally:
|
||||
live.changed.set()
|
||||
self._live.pop(session_id, None)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Isolated browser-test server: all VM and terminal operations are fakes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import uvicorn
|
||||
|
||||
from nest.server import create_app
|
||||
from tests.test_server import FakeLifecycle, fake_application
|
||||
from tests.test_sessions import FakeTerminal
|
||||
from uvm.errors import ConflictError, NotFoundError
|
||||
from uvm.lifecycle import ListedVm
|
||||
|
||||
|
||||
class BrowserTerminal(FakeTerminal):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.command = bytearray()
|
||||
|
||||
async def write(self, data):
|
||||
await self.events.put({"type": "output", "data": data.replace(b"\r", b"\r\n")})
|
||||
for byte in data:
|
||||
if byte == 13:
|
||||
await self.events.put({"type": "output", "data": b"result:" + bytes(self.command) + b"\r\nguest$ "})
|
||||
self.command.clear()
|
||||
elif byte == 3:
|
||||
self.command.clear()
|
||||
else:
|
||||
self.command.append(byte)
|
||||
|
||||
|
||||
class BrowserLifecycle(FakeLifecycle):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.vm.username = "worker"
|
||||
self.records = {self.vm.id: self.vm, "vm-other": replace(self.vm, id="vm-other", name="second-vm", status="stopped")}
|
||||
|
||||
def get(self, vm_id):
|
||||
try:
|
||||
return self.records[vm_id]
|
||||
except KeyError as error:
|
||||
raise NotFoundError("VM not found") from error
|
||||
|
||||
def inspect(self, identifier):
|
||||
for vm in self.records.values():
|
||||
if identifier in {vm.id, vm.name, vm.guest_ip}:
|
||||
return ListedVm(vm, vm.status, "enforced" if vm.status == "running" else "inactive")
|
||||
raise NotFoundError("VM not found")
|
||||
|
||||
def list_vms(self):
|
||||
return [self.inspect(vm_id) for vm_id in self.records]
|
||||
|
||||
def open_terminal(self, vm_id, **_size):
|
||||
if self.get(vm_id).status != "running":
|
||||
raise ConflictError("Start the machine first")
|
||||
return BrowserTerminal(), "worker"
|
||||
|
||||
def install_guest_agent(self, vm_id):
|
||||
return self.get(vm_id)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--port", type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
application = fake_application(api_token="browser-test-token")
|
||||
application.settings.base = args.base
|
||||
application.lifecycle = BrowserLifecycle()
|
||||
application.state_store = SimpleNamespace(get=application.lifecycle.get)
|
||||
api = create_app(application, host="127.0.0.1")
|
||||
server = uvicorn.Server(uvicorn.Config(api, host="127.0.0.1", port=args.port, log_level="warning"))
|
||||
api.state.uvicorn_server = server
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
// Optional integration test: requires Playwright + Chromium and the project's test extra.
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { mkdtemp, rm } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), 'nest-browser-'));
|
||||
const port = process.env.NEST_BROWSER_TEST_PORT || '18765';
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const server = spawn(process.env.NEST_TEST_PYTHON || 'python3',
|
||||
['-B', '-m', 'tests.browser_session_server', '--base', temporary, '--port', port],
|
||||
{ cwd: path.resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let logs = '';
|
||||
server.stdout.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||
server.stderr.on('data', (data) => { logs = (logs + data).slice(-16000); });
|
||||
let browser;
|
||||
let page;
|
||||
try {
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (server.exitCode !== null) throw new Error(`Fixture server exited: ${logs}`);
|
||||
try {
|
||||
const response = await fetch(`${origin}/health`);
|
||||
if (response.ok) { ready = true; break; }
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.ok(ready, `Fixture server did not start: ${logs}`);
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const connected = () => page.waitForFunction(() => document.querySelector('#session-status').textContent === 'Connected');
|
||||
const output = (value) => page.waitForFunction((text) => document.querySelector('#session-terminal').textContent.includes(text), value);
|
||||
await page.goto(`${origin}/dashboard#vm/vm-test`);
|
||||
await page.locator('#token-input').fill('browser-test-token');
|
||||
await page.locator('#auth-btn').click();
|
||||
await page.locator('#detail-actions [data-action="launch-session"]').click();
|
||||
await connected();
|
||||
const firstUrl = page.url();
|
||||
assert.match(firstUrl, /#vm\/vm-test\/sessions\/session-/);
|
||||
await page.keyboard.type('echo alpha');
|
||||
await page.keyboard.press('Enter');
|
||||
await output('result:echo alpha');
|
||||
|
||||
await page.reload();
|
||||
await connected();
|
||||
await output('result:echo alpha');
|
||||
assert.equal(await page.locator('.session-item').count(), 1);
|
||||
|
||||
await page.locator('#detail-actions [data-action="launch-session"]').click();
|
||||
await page.waitForURL((url) => url.href !== firstUrl);
|
||||
await connected();
|
||||
assert.equal(await page.locator('.session-item').count(), 2);
|
||||
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo alpha'));
|
||||
await page.keyboard.type('echo beta');
|
||||
await page.keyboard.press('Enter');
|
||||
await output('result:echo beta');
|
||||
await page.locator('.session-item').filter({ hasText: 'Session 1' }).click();
|
||||
await connected();
|
||||
await output('result:echo alpha');
|
||||
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('result:echo beta'));
|
||||
|
||||
await page.goto(`${origin}/dashboard#vm/vm-other/sessions`);
|
||||
await page.locator('#vm-sessions-panel').waitFor({ state: 'visible' });
|
||||
await page.waitForFunction(() => document.querySelector('#vm-session-count').textContent === '0');
|
||||
assert.equal(await page.locator('.session-item').count(), 0);
|
||||
assert.ok(await page.locator('#detail-actions [data-action="launch-session"]').isDisabled());
|
||||
assert.ok(!(await page.locator('#session-terminal').textContent()).includes('alpha'));
|
||||
|
||||
await page.goto(firstUrl);
|
||||
await connected();
|
||||
await output('result:echo alpha');
|
||||
await page.locator('#session-end').click();
|
||||
await page.locator('#confirm-ok').click();
|
||||
await page.waitForFunction(() => document.querySelector('#session-status').textContent.startsWith('closed'));
|
||||
await page.reload();
|
||||
await output('result:echo alpha');
|
||||
assert.ok(await page.locator('#session-end').isDisabled());
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(300);
|
||||
const fits = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
|
||||
assert.ok(fits, 'mobile layout overflows the viewport');
|
||||
assert.deepEqual(errors, []);
|
||||
if (process.env.NEST_BROWSER_SCREENSHOT) await page.screenshot({ path: process.env.NEST_BROWSER_SCREENSHOT, fullPage: true });
|
||||
console.log('Browser sessions passed: launch, terminal input, reload/reconnect, multiple sessions, VM isolation, closed history, and mobile layout.');
|
||||
} catch (error) {
|
||||
if (page) console.error('Browser state:', await page.locator('#sessions-error').textContent(), await page.locator('#session-status').textContent());
|
||||
console.error(logs);
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
server.kill('SIGTERM');
|
||||
if (server.exitCode === null) await new Promise((resolve) => server.once('exit', resolve));
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -139,9 +139,24 @@ class GuestAgentImageTests(unittest.TestCase):
|
||||
before = self.snapshot()
|
||||
self.assertFalse(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
self.replace_guest_file("/etc/uvm-image", "uvm-ubuntu-jammy-v4\n")
|
||||
for version in (4, 5):
|
||||
self.replace_guest_file("/etc/uvm-image", f"uvm-ubuntu-jammy-v{version}\n")
|
||||
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker"))
|
||||
|
||||
def test_cached_template_without_a_manifest_only_adopts_the_known_v4_payload(self) -> None:
|
||||
self.make_disk()
|
||||
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||
self.runner.run(("debugfs", "-w", "-R", "rm /etc/uvm-agent-files.json", self.disk), capture=True)
|
||||
old_payload = "previous packaged agent\n"
|
||||
self.replace_guest_file("/usr/lib/uvm/agent.py", old_payload)
|
||||
before = self.snapshot()
|
||||
with self.assertRaisesRegex(UvmError, "customized guest agent"):
|
||||
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
with patch("uvm.images._V4_TEMPLATE_AGENT_SHA256", hashlib.sha256(old_payload.encode()).hexdigest()):
|
||||
self.assertTrue(self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True))
|
||||
self.assertEqual(self.guest("cat /usr/lib/uvm/agent.py"), guest_agent_files()["/usr/lib/uvm/agent.py"].decode())
|
||||
|
||||
def test_agent_updates_use_recorded_hashes_but_preserve_guest_customizations(self) -> None:
|
||||
self.make_disk()
|
||||
self.images.provision_guest_agent(self.disk, "vm-test", "worker", install=True)
|
||||
|
||||
@@ -108,7 +108,9 @@ class OfflineGuestNetworkTests(unittest.TestCase):
|
||||
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
|
||||
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
|
||||
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
|
||||
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)):
|
||||
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE),
|
||||
("uvm-ubuntu-jammy-v4\n", GUEST_NETWORK_TEMPLATE),
|
||||
("uvm-ubuntu-jammy-v5\n", GUEST_NETWORK_TEMPLATE)):
|
||||
with self.subTest(marker=marker):
|
||||
self.make_disk(marker=marker, profile=profile)
|
||||
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
|
||||
|
||||
+10
-2
@@ -89,6 +89,11 @@ check_python
|
||||
"""
|
||||
|
||||
|
||||
# The original v4 template shipped this exact agent without an ownership
|
||||
# manifest. Recognize that payload when creating from a cached v4 template.
|
||||
_V4_TEMPLATE_AGENT_SHA256 = "dfdd4bd6ee3dc06fbdbda6402cb2c54921cff22190d8da889209559800f8b948"
|
||||
|
||||
|
||||
def guest_agent_files() -> dict[str, bytes]:
|
||||
"""The same standalone payload is used by the builder and offline upgrades."""
|
||||
return {
|
||||
@@ -283,7 +288,7 @@ class ImageStore:
|
||||
)
|
||||
if marker is None or marker.path.read_text(encoding="utf-8") not in {
|
||||
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
|
||||
"uvm-ubuntu-jammy-v4\n",
|
||||
"uvm-ubuntu-jammy-v4\n", "uvm-ubuntu-jammy-v5\n",
|
||||
}:
|
||||
return None
|
||||
profile = self._read_guest_file(
|
||||
@@ -374,7 +379,10 @@ class ImageStore:
|
||||
existing = self._agent_file(disk, path, files / f"existing-{index}")
|
||||
if existing is not None:
|
||||
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
|
||||
if digest not in {hashes[path], old_hashes.get(path)}:
|
||||
allowed = {hashes[path], old_hashes.get(path)}
|
||||
if not old_hashes and path == "/usr/lib/uvm/agent.py":
|
||||
allowed.add(_V4_TEMPLATE_AGENT_SHA256)
|
||||
if digest not in allowed:
|
||||
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
|
||||
source = files / f"payload-{index}"
|
||||
source.write_bytes(content)
|
||||
|
||||
+8
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -397,12 +399,17 @@ class Installer:
|
||||
machine_id.symlink_to("../../../etc/machine-id")
|
||||
for key in (root / "etc/ssh").glob("ssh_host_*_key*"):
|
||||
key.unlink()
|
||||
for guest_path, contents in guest_agent_files().items():
|
||||
agent_payloads = guest_agent_files()
|
||||
for guest_path, contents in agent_payloads.items():
|
||||
path = root / guest_path.lstrip("/")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.parent.chmod(0o755)
|
||||
path.write_bytes(contents)
|
||||
path.chmod(0o644)
|
||||
manifest = root / "etc/uvm-agent-files.json"
|
||||
manifest.write_text(json.dumps({name: hashlib.sha256(data).hexdigest() for name, data in agent_payloads.items()},
|
||||
sort_keys=True) + "\n", encoding="utf-8")
|
||||
manifest.chmod(0o600)
|
||||
self._runner.run(("systemctl", "--root", root, "enable", "ssh.service", "systemd-networkd.service",
|
||||
"uvm-agent.service"), capture=True)
|
||||
self._runner.run(("systemctl", "--root", root, "set-default", "multi-user.target"), capture=True)
|
||||
|
||||
Reference in New Issue
Block a user