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:
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user