feat: implement resource resizing for VMs with storage management

- Added KnownHostsManager to handle cleanup of SSH known_hosts entries upon VM deletion.
- Introduced disk_size_bytes method in ImageStore to read logical disk capacity.
- Enhanced LifecycleService to manage VM resource resizing, including CPU, RAM, and storage.
- Updated application configuration to support known_hosts_files for SSH management.
- Modified dashboard to include resource resizing options in the UI.
- Improved error handling and validation for resource inputs in the dashboard.
- Removed obsolete .pyc files from the repository.
This commit is contained in:
2026-09-21 20:37:52 +05:30
parent fb80ce7c8a
commit 4a46685a4c
40 changed files with 1048 additions and 63 deletions
+4 -3
View File
@@ -469,7 +469,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.ssh-command { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px 5px 14px; border: 1px solid #fff9; border-radius: 12px; background: #fdfbfc9c; }
.ssh-command code { font-family: var(--mono); font-size: 10px; color: #918060; overflow-wrap: anywhere; }
.ssh-command .icon-button { width: 27px; height: 27px; }
.ports-panel, .log-panel { margin-top: 15px; }
.ports-panel, .log-panel, .resources-panel { margin-top: 15px; }
.storage-field { margin-top: 18px; }
.port-editor { padding-top: 5px; }
.log-output { max-height: 320px; min-height: 145px; margin: 0; padding: 17px; border: 1px solid #ffffff7a; border-radius: 13px; background: #e9e2ed8c; color: var(--ink-soft); font-family: var(--mono); font-size: 11px; line-height: 1.9; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.detail-delete-row { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding: 0 4px; color: #ad94b6; font-size: 9px; }
@@ -878,8 +879,8 @@ input[type='checkbox'] { width: 14px; height: 14px; accent-color: #9f9259; curso
.field input { font-size: 12px; }
.detail-delete-row { align-items: flex-start; }
.detail-delete-row > span { max-width: 170px; }
.ports-panel > .section-heading, .log-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child { flex: 1; }
.ports-panel > .section-heading, .log-panel > .section-heading, .resources-panel > .section-heading { align-items: flex-start; }
.ports-panel > .section-heading > div:first-child, .log-panel > .section-heading > div:first-child, .resources-panel > .section-heading > div:first-child { flex: 1; }
.auth-screen { padding: 20px; }
.auth-window { grid-template-columns: 1fr; min-height: 0; max-width: 440px; }
.auth-story { min-height: 240px; padding: 26px; border-right: 0; }
+11
View File
@@ -171,6 +171,16 @@
<div class="page-heading detail-heading"><div><span class="subtle-label">A closer look</span><h1 id="detail-heading" tabindex="-1"></h1><span id="detail-id" class="mono muted"></span></div><div id="detail-actions" class="button-row"></div></div>
<div id="detail-error" class="notice notice-error" hidden></div>
<div class="detail-grid"><article class="soft-panel detail-card"><header class="section-heading"><h2>The essentials</h2><span id="detail-status"></span></header><dl id="detail-config" class="detail-list"></dl></article><article class="soft-panel detail-card"><header class="section-heading"><h2>A place in the network</h2><svg class="icon caption-icon"><use href="#i-network"/></svg></header><dl id="detail-network" class="detail-list"></dl></article></div>
<article class="soft-panel resources-panel">
<header class="section-heading"><div><h2>Room to grow</h2><span>Adjust CPU, memory, and private storage.</span></div><button id="edit-resources-btn" class="button button-small" type="button" data-action="edit-resources">Resize resources</button></header>
<p id="resize-note" class="panel-footnote">Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.</p>
<form id="resize-form" hidden>
<div class="form-grid"><label class="field" for="resize-cpu"><span>CPU capacity</span><input id="resize-cpu" type="number" min="0.01" step="any" placeholder="Keep current CPU" /></label><label class="field" for="resize-ram"><span>Memory</span><input id="resize-ram" type="text" placeholder="Keep current memory" /><small>At least 128 MiB. For example, 4G.</small></label></div>
<label class="field storage-field" for="resize-storage"><span>Private storage</span><input id="resize-storage" type="text" placeholder="Keep current size (at least 30G)" /><small>Minimum 30 GiB. A smaller disk must still fit the existing filesystem data.</small></label>
<p id="resize-error" class="form-error" role="alert"></p>
<div class="button-row"><button id="resize-submit" class="button button-primary button-small" type="submit">Resize &amp; restart</button><button class="button button-quiet button-small" type="button" data-action="cancel-resize">Cancel</button></div>
</form>
</article>
<article class="soft-panel ssh-panel"><div><span class="round-icon"><svg class="icon"><use href="#i-terminal"/></svg></span><div><h2>A direct connection</h2><p id="ssh-hint" class="muted"></p></div></div><div class="ssh-command"><code id="ssh-command"></code><button class="icon-button" data-action="copy-ssh" type="button" aria-label="Copy SSH command"><svg class="icon"><use href="#i-copy"/></svg></button></div></article>
<article class="soft-panel ports-panel"><header class="section-heading"><div><h2>Private port policy</h2><span>Just the connections this machine needs.</span></div><div class="button-row"><button id="edit-ports-btn" class="button button-small" type="button" data-action="edit-ports">Edit rules</button><button id="sync-ports-btn" class="icon-button" type="button" data-action="sync-ports" aria-label="Sync port policy" title="Sync port policy"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><div id="detail-ports" class="port-list"></div><form id="port-editor" class="port-editor" hidden><label class="field" for="port-input"><span>Port rules <small>PORT:TCP, PORT:HTTP, or PORT:HTTPS</small></span><input id="port-input" type="text" placeholder="22:TCP, 80:HTTP, 443:HTTPS" spellcheck="false" /></label><p id="port-error" class="form-error" role="alert"></p><div class="button-row"><button class="button button-primary button-small" type="submit" value="set">Replace rules</button><button id="open-ports-btn" class="button button-small" type="submit" value="open">Open ports</button><button id="close-ports-btn" class="button button-small" type="submit" value="close">Close ports</button><button class="button button-quiet button-small" type="button" data-action="cancel-ports">Cancel</button></div><p class="panel-footnote">An empty replacement creates a managed policy with no inbound TCP ports.</p></form></article>
<article class="soft-panel log-panel"><header class="section-heading"><div><h2>A window into the runtime</h2><span>Recent Firecracker log entries.</span></div><div class="button-row"><label class="select-wrap"><span class="sr-only">Log tail length</span><select id="log-tail"><option value="100">100 lines</option><option value="250">250 lines</option><option value="500">500 lines</option><option value="1000">1,000 lines</option></select></label><button id="log-refresh-btn" class="icon-button" type="button" data-action="refresh-logs" aria-label="Refresh logs"><svg class="icon"><use href="#i-refresh"/></svg></button></div></header><pre id="log-output" class="log-output" tabindex="0"></pre><p id="log-meta" class="panel-footnote"></p></article>
@@ -182,6 +192,7 @@
<div class="page-heading"><div><span class="subtle-label">Every idea starts somewhere</span><h1 id="create-heading" tabindex="-1">Make something new<span class="heading-period">.</span></h1></div><a class="quiet-link" href="#vms">Back to machines <svg class="icon"><use href="#i-arrow"/></svg></a></div>
<form id="create-form" class="create-layout">
<div class="create-fields"><section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">01</span>A name and a little space</h2><span>Give your new machine a shape.</span></div></header><label class="field" for="create-name"><span>Machine name <small>Optional</small></span><input id="create-name" type="text" name="name" placeholder="e.g. my-little-server" pattern="[a-z][a-z0-9\-]{0,62}" maxlength="63" autocomplete="off" /></label><fieldset class="preset-fieldset"><legend>Start with a size</legend><div class="size-presets"><button class="size-preset active" type="button" data-preset="small" aria-pressed="true"><svg class="icon"><use href="#i-server"/></svg><strong>Small</strong><span>1 vCPU · 512 MiB</span></button><button class="size-preset" type="button" data-preset="balanced" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Balanced</strong><span>2 vCPU · 1 GiB</span></button><button class="size-preset" type="button" data-preset="roomy" aria-pressed="false"><svg class="icon"><use href="#i-server"/></svg><strong>Roomy</strong><span>4 vCPU · 2 GiB</span></button></div></fieldset><div class="form-grid"><label class="field" for="create-cpu"><span>CPU capacity</span><input id="create-cpu" name="cpu" type="number" value="1" min="0.01" step="any" required /><small>Fractional requests round up to whole vCPUs.</small></label><label class="field" for="create-ram"><span>Memory</span><input id="create-ram" name="ram" type="text" value="512M" required /><small>At least 128 MiB. Try 512M, 1G, or 2G.</small></label></div></section>
<section class="soft-panel form-section"><label class="field" for="create-storage"><span>Private storage</span><input id="create-storage" name="storage" type="text" value="30G" required /><small>Minimum 30 GiB. Try 30G, 50G, or 1T. You can resize this disk later.</small></label></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">02</span>A way to connect</h2><span>Private networking and guest access.</span></div></header><label class="field" for="create-guest-ip"><span>Guest IPv4 address <small>Optional</small></span><input id="create-guest-ip" name="guest_ip" type="text" placeholder="Automatically assigned" autocomplete="off" /></label><div class="form-grid"><label class="field" for="create-username"><span>Guest user</span><input id="create-username" name="username" type="text" value="root" autocomplete="username" required /></label><label class="field" for="create-password"><span>Guest password</span><span class="password-field"><input id="create-password" name="password" type="password" value="root" autocomplete="new-password" maxlength="128" required /><button class="icon-button" type="button" data-action="toggle-password" aria-label="Show password" aria-pressed="false"><svg class="icon"><use href="#i-eye"/></svg></button></span></label></div><p class="panel-footnote">Use an account that exists in your guest image. Credentials are applied to the machine’s private disk.</p></section>
<section class="soft-panel form-section"><header class="section-heading"><div><h2><span class="step-number">03</span>Open the right doors</h2><span>Choose which private TCP services are allowed.</span></div></header><label class="toggle-field"><span><strong>Include the default SSH rule</strong><small>Allow TCP port 22 for terminal access.</small></span><input id="create-default-ssh" name="default_ssh" type="checkbox" checked /><span class="toggle-track" aria-hidden="true"></span></label><label class="field" for="create-ports"><span>Additional port rules <small>Optional</small></span><input id="create-ports" name="ports" type="text" placeholder="80:HTTP, 443:HTTPS" spellcheck="false" /><small>Separate rules with commas. HTTP and HTTPS both use TCP.</small></label></section></div>
<aside class="create-summary soft-panel"><div class="summary-art" aria-hidden="true"><div></div><div></div><span><svg class="icon"><use href="#i-server"/></svg></span></div><span class="subtle-label">Your next little machine</span><h2 id="create-summary-name">Room for an idea.</h2><p>A private disk, a network identity,<br />and a fresh start.</p><dl id="create-summary-details" class="detail-list"></dl><p id="create-error" class="form-error" role="alert"></p><button id="create-submit" class="button button-primary button-wide" type="submit"><span>Create machine</span><svg class="icon"><use href="#i-arrow"/></svg></button><a href="#vms" class="quiet-link summary-cancel">Maybe later</a><span class="summary-note"><svg class="icon"><use href="#i-spark"/></svg>Built on Firecracker</span></aside>
+98 -3
View File
@@ -27,6 +27,7 @@
detailId: null,
detail: null,
portEditing: false,
resizeEditing: false,
selected: new Set(),
filter: 'all',
query: '',
@@ -86,6 +87,10 @@
return mib >= 1024 ? `${number(mib / 1024)} GiB` : `${number(mib)} MiB`;
}
function formatStorage(value) {
return value == null ? 'Not recorded' : formatRam(value);
}
function timestamp(value) {
if (value == null || value === '') return NaN;
if (typeof value === 'number' || /^\d+(\.\d+)?$/.test(value)) {
@@ -396,7 +401,7 @@
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>${badge(runtimeStatus(vm))}</td><td class="allocation-cell">${vcpu(vm)} vCPU<small>${esc(formatRam(vm.ram_mib))} RAM</small><small>${esc(formatStorage(vm.storage_mib))} storage</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>`;
@@ -598,7 +603,7 @@
: 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],
['vCPUs', vcpu(vm)], ['Memory', formatRam(vm.ram_mib)], ['Storage', formatStorage(vm.storage_mib)], ['Guest user', vm.username],
['Created', formatDate(vm.created_at)], ['Last updated', formatDate(vm.updated_at)],
]));
html('#detail-network', detailRows([
@@ -625,6 +630,19 @@
$('#sync-ports-btn').disabled = busy;
$('#open-ports-btn').hidden = vm.ports === null;
$('#close-ports-btn').hidden = vm.ports === null;
if (!state.resizeEditing) {
const resources = vm.pending_resize || vm;
$('#resize-cpu').value = resources.cpu;
$('#resize-ram').value = `${resources.ram_mib}M`;
$('#resize-storage').value = resources.storage_mib == null ? '' : `${resources.storage_mib / 1024}G`;
}
$('#resize-form').hidden = !state.resizeEditing;
$('#edit-resources-btn').hidden = state.resizeEditing;
$('#edit-resources-btn').disabled = busy || runtimeStatus(vm) === 'unknown' || vm.ports === null || vm.operation === 'delete' || vm.status === 'terminating';
$('#resize-submit').disabled = busy;
text('#resize-note', vm.pending_resize
? `Pending resize: ${number(vm.pending_resize.cpu)} CPU, ${formatRam(vm.pending_resize.ram_mib)} memory, ${formatStorage(vm.pending_resize.storage_mib)} storage. Retry the resize or start the stopped machine to finish it.`
: 'Resource changes stop the machine, apply the new size, and start it again with its existing IP and guest credentials.');
}
async function loadDetail(identifier) {
@@ -756,6 +774,67 @@
return mib;
}
function parseStorage(value) {
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?$/i);
if (!match) throw new Error('Use a storage amount such as 30G, 50G, or 1T.');
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, t: 1024 * 1024, tb: 1024 * 1024, ti: 1024 * 1024, tib: 1024 * 1024 };
const amount = Number(match[1]) * factors[(match[2] || 'g').toLowerCase()];
if (!Number.isFinite(amount) || amount < 30 * 1024) throw new Error('Give this machine at least 30 GiB of storage.');
if (amount > 8796093022207) throw new Error('That storage capacity exceeds the supported limit.');
return Math.ceil(amount);
}
async function submitResize(event) {
event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return;
const id = state.detail.id;
const session = state.session;
const payload = {};
try {
const cpu = $('#resize-cpu').value.trim();
const ram = $('#resize-ram').value.trim();
const storage = $('#resize-storage').value.trim();
if (cpu) {
payload.cpu = Number(cpu);
if (!Number.isFinite(payload.cpu) || payload.cpu <= 0) throw new Error('CPU capacity must be a positive number.');
}
if (ram) { parseRam(ram); payload.ram = ram; }
if (storage) { parseStorage(storage); payload.storage = storage; }
if (!Object.keys(payload).length) throw new Error('Enter at least one resource to resize.');
} catch (error) {
text('#resize-error', error.message);
return;
}
text('#resize-error', '');
const button = $('#resize-submit');
const previous = button.textContent;
const unlock = lockForm($('#resize-form'));
state.busy.add(id);
button.textContent = 'Resizing and restarting…';
renderDetail();
try {
await api(vmPath(id, '/resize'), { method: 'POST', body: payload });
if (session !== state.session) return;
if (state.detailId === id) state.resizeEditing = false;
toast('Machine resized and started.');
await refreshAfterMutation(id);
} catch (error) {
if (!isAborted(error) && session === state.session) {
if (state.detailId === id) text('#resize-error', error.message);
else toast(error.message, true);
await refreshAfterMutation(id);
}
} finally {
unlock();
button.textContent = previous;
if (session === state.session) {
state.busy.delete(id);
renderMachines();
renderDetail();
}
}
}
async function submitPorts(event) {
event.preventDefault();
if (!state.detail || state.busy.has(state.detail.id)) return;
@@ -810,8 +889,10 @@
function updateCreateSummary() {
let memory = '—';
let storage = '—';
let ports = 'Check port rules';
try { memory = formatRam(parseRam($('#create-ram').value)); } catch { /* The form reports validation on submit. */ }
try { storage = formatStorage(parseStorage($('#create-storage').value)); } catch { /* Report invalid capacity 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' });
@@ -821,7 +902,7 @@
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'],
['Memory', memory], ['Storage', storage], ['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] };
@@ -849,12 +930,14 @@
let payload;
try {
parseRam($('#create-ram').value);
parseStorage($('#create-storage').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(),
storage: $('#create-storage').value.trim(),
guest_ip: $('#create-guest-ip').value.trim() || null,
username: $('#create-username').value.trim(),
password: $('#create-password').value,
@@ -1048,6 +1131,8 @@
state.detailId = identifier;
state.detail = null;
state.portEditing = false;
state.resizeEditing = false;
text('#resize-error', '');
closeMenu();
closeSearch();
setMobileNav(false);
@@ -1156,6 +1241,7 @@
state.detail = null;
state.detailId = null;
state.portEditing = false;
state.resizeEditing = false;
state.query = '';
state.filter = 'all';
state.sort = 'recent';
@@ -1176,6 +1262,7 @@
$('#token-input').disabled = false;
text('#create-error', '');
text('#port-error', '');
text('#resize-error', '');
text('#auth-error', message);
$('#main-app').hidden = true;
$('#auth-screen').hidden = false;
@@ -1209,6 +1296,13 @@
$('#port-input').focus();
}
if (action === 'cancel-ports') { state.portEditing = false; text('#port-error', ''); renderDetail(); }
if (action === 'edit-resources') {
state.resizeEditing = true;
text('#resize-error', '');
renderDetail();
$('#resize-cpu').focus();
}
if (action === 'cancel-resize') { state.resizeEditing = false; text('#resize-error', ''); renderDetail(); }
if (action === 'toggle-password') {
const visible = $('#create-password').type === 'password';
$('#create-password').type = visible ? 'text' : 'password';
@@ -1224,6 +1318,7 @@
$('#create-form').addEventListener('submit', submitCreate);
$('#create-form').addEventListener('input', updateCreateSummary);
$('#port-editor').addEventListener('submit', submitPorts);
$('#resize-form').addEventListener('submit', submitResize);
$('#bulk-run-btn').addEventListener('click', runBulkAction);
$('#log-tail').addEventListener('change', loadLogs);
$('#vm-search').addEventListener('input', (event) => { state.query = event.target.value; renderMachines(); });