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
+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(); });