a73x

cdf4b8bc

fix(web): a VM's controls answer for the moment it is in

a73x   2026-08-09 19:47

Commit message
fix(web): a VM's controls answer for the moment it is in

Only a VM the plane has settled offers a power flip. `ready` and `stopped` get
the one that changes something — read off DESIRED power, since that is what the
control sets, so a VM already on its way somewhere has no button pointing the
way it is going. Every other lifecycle gets the state itself where the control
would be: `creating` and `deleting` are mid-flight and the plane is already
driving them; `failed` cannot be started, because an ephemeral guest that died
is never restarted and a create the host's budget refused reconsiders only on a
spec change, which power is not; a state this console does not recognise is one
it should not invent an action for.

One derivation, vmPowerAction, decides this for the detail page and the fleet
table alike, and deleteConfirm beside it words the question both of them ask.
Deleting a VM whose create is still in flight is the one worth spelling out:
the dialog names a machine that does not exist yet, and pressing through
cancels the work.

The teardown callout says what is at stake, and says only what it knows. Once
the host has taken the tombstone there is a destroy deadline to count down to,
and the guest is stopped: what remains is the disk, which still holds
everything that was on it until that deadline, and restoring boots the VM from
it. Before the host has reported taking it there is no deadline and no
stopped guest to promise — eitri has asked, the guest may still be running, and
the clock starts when the host answers. Either way the offer is a restore, not
an undo, and the button says so.

web/src/lib/fleet.svelte.ts
Old New
@@ -386,9 +386,33 @@ export function vmStatus(vm: VM): string {
386 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown'); 386 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown');
387 } 387 }
388 388
389 /** vmIsRunning reports whether the VM is actually running (gates Start/Stop). */ 389 /** vmPowerAction is the power flip a VM's controls may offer, or null when
390 export function vmIsRunning(vm: VM): boolean { 390 * offering one would be a lie.
391 return vm.actual_power === 'running'; 391 *
392 * Only a VM the plane has settled — `ready` or `stopped` — has a power flip
393 * that means anything. `creating` and `deleting` are mid-flight, and the plane
394 * is already driving them somewhere. `failed` cannot be started: an ephemeral
395 * guest that died is never restarted, and a create the host's budget refused
396 * reconsiders only on a spec change, which power is not. An unrecognized state
397 * is one this console does not understand well enough to act on.
398 *
399 * For the two that do get a control, it reads the DESIRED power (power_state),
400 * not the observed one: the control sets desired state, so a VM already on its
401 * way to running has no Start to press. */
402 export function vmPowerAction(vm: VM): 'start' | 'stop' | null {
403 const status = vmStatus(vm);
404 if (status !== 'ready' && status !== 'stopped') return null;
405 return vm.power_state === 'running' ? 'stop' : 'start';
406 }
407
408 /** deleteConfirm is the question a delete asks before it runs. A create in
409 * flight is the one delete worth spelling out: the VM the dialog names does
410 * not exist yet, and pressing through cancels the work rather than removing a
411 * machine. Shared, so the fleet table and the VM page ask the same thing. */
412 export function deleteConfirm(vm: VM): string {
413 return vmStatus(vm) === 'creating'
414 ? `Delete VM ${vm.name}? It is still being created—deleting cancels the create.`
415 : `Delete VM ${vm.name}?`;
392 } 416 }
393 417
394 /** teardownApprox renders a calm, coarse estimate of the time left to undo a 418 /** teardownApprox renders a calm, coarse estimate of the time left to undo a
web/src/routes/+page.svelte
Old New
@@ -11,9 +11,11 @@
11 restoreVM, 11 restoreVM,
12 vmPower, 12 vmPower,
13 vmIP, 13 vmIP,
14 vmIsRunning, 14 vmPowerAction,
15 deleteConfirm,
15 upgradeAgent, 16 upgradeAgent,
16 type CreateVMRequest 17 type CreateVMRequest,
18 type VM
17 } from '$lib/fleet.svelte'; 19 } from '$lib/fleet.svelte';
18 20
19 let showCreate = $state(false); 21 let showCreate = $state(false);
@@ -112,9 +114,9 @@
112 await action(() => setPower(id, p)); 114 await action(() => setPower(id, p));
113 } 115 }
114 116
115 async function remove(id: string) { 117 async function remove(v: VM) {
116 if (!confirm(`Delete VM ${id}?`)) return; 118 if (!confirm(deleteConfirm(v))) return;
117 await action(() => deleteVM(id)); 119 await action(() => deleteVM(v.id));
118 } 120 }
119 121
120 async function restore(id: string) { 122 async function restore(id: string) {
@@ -300,21 +302,22 @@
300 <td>{hostName(v.host_id)}</td> 302 <td>{hostName(v.host_id)}</td>
301 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td> 303 <td class="num">{v.vcpus}c · {v.mem_mb}MB · {v.disk_gb}GB</td>
302 <td> 304 <td>
303 {#if v.deleted}<span class="teardown">deleting—undo available</span>{:else}{vmStatus(v)}{/if}{v.last_error 305 {#if v.deleted}<span class="teardown">deleting—restorable</span>{:else}{vmStatus(v)}{/if}{v.last_error
304 ? ` · ${v.last_error}` 306 ? ` · ${v.last_error}`
305 : ''} 307 : ''}
306 </td> 308 </td>
307 <td>{vmPower(v)}</td> 309 <td>{vmPower(v)}</td>
308 <td class="actions"> 310 <td class="actions">
309 {#if v.deleted} 311 {#if v.deleted}
310 <button class="restore" onclick={() => restore(v.id)}>Undo delete</button> 312 <button class="restore" onclick={() => restore(v.id)}>Restore</button>
311 {:else} 313 {:else}
312 {#if vmIsRunning(v)} 314 {@const act = vmPowerAction(v)}
315 {#if act === 'stop'}
313 <button class="danger" onclick={() => power(v.id, 'stopped')}>Stop</button> 316 <button class="danger" onclick={() => power(v.id, 'stopped')}>Stop</button>
314 {:else} 317 {:else if act === 'start'}
315 <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button> 318 <button class="ghost" onclick={() => power(v.id, 'running')}>Start</button>
316 {/if} 319 {/if}
317 <button class="danger" onclick={() => remove(v.id)}>Delete</button> 320 <button class="danger" onclick={() => remove(v)}>Delete</button>
318 {/if} 321 {/if}
319 </td> 322 </td>
320 </tr> 323 </tr>
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -11,7 +11,8 @@
11 teardownApprox, 11 teardownApprox,
12 vmPower, 12 vmPower,
13 vmIP, 13 vmIP,
14 vmIsRunning, 14 vmPowerAction,
15 deleteConfirm,
15 vmEvents, 16 vmEvents,
16 eventLabel, 17 eventLabel,
17 listExposures, 18 listExposures,
@@ -31,9 +32,19 @@
31 // the derived would refetch on every push. The string only changes on a real 32 // the derived would refetch on every push. The string only changes on a real
32 // lifecycle transition. 33 // lifecycle transition.
33 const lifecycle = $derived(vm?.lifecycle ?? ''); 34 const lifecycle = $derived(vm?.lifecycle ?? '');
34 // tearingDown gates the cancel/undo affordance: the VM is quarantined for 35 // status is the VM's lifecycle as the page speaks it (see vmStatus).
36 const status = $derived(vm ? vmStatus(vm) : '');
37 // tearingDown gates the restore affordance: the VM is quarantined for
35 // teardown (deleted). 38 // teardown (deleted).
36 const tearingDown = $derived(!!vm && vmStatus(vm) === 'deleting'); 39 const tearingDown = $derived(status === 'deleting');
40 // powerAction is the flip the button offers, or null when the VM's moment
41 // admits none (see vmPowerAction).
42 const powerAction = $derived(vm ? vmPowerAction(vm) : null);
43 // powerNote stands where the power control would be when there is none: the
44 // state itself, which for a create in flight is worth a few more words.
45 const powerNote = $derived(
46 status === 'creating' ? 'creating—eitri is bringing this VM up' : status
47 );
37 // approx is the coarse "time left to undo" shown in the teardown callout. 48 // approx is the coarse "time left to undo" shown in the teardown callout.
38 const approx = $derived(vm ? teardownApprox(vm, clock.now) : ''); 49 const approx = $derived(vm ? teardownApprox(vm, clock.now) : '');
39 50
@@ -143,12 +154,12 @@
143 154
144 async function remove() { 155 async function remove() {
145 if (!vm) return; 156 if (!vm) return;
146 if (!confirm(`Delete VM ${vm.name}?`)) return; 157 if (!confirm(deleteConfirm(vm))) return;
147 const vmId = vm.id; 158 const vmId = vm.id;
148 await action(() => deleteVM(vmId)); 159 await action(() => deleteVM(vmId));
149 } 160 }
150 161
151 async function cancelDeletion() { 162 async function restore() {
152 if (!vm) return; 163 if (!vm) return;
153 const vmId = vm.id; 164 const vmId = vm.id;
154 // The SSE stream flips the VM back to live on its own; no local poke. 165 // The SSE stream flips the VM back to live on its own; no local poke.
@@ -222,19 +233,32 @@
222 {#if tearingDown} 233 {#if tearingDown}
223 <div class="teardown-callout"> 234 <div class="teardown-callout">
224 <h3>This VM is being deleted</h3> 235 <h3>This VM is being deleted</h3>
225 <p> 236 {#if approx}
226 The guest is already stopped. You can still undo this{#if approx}—otherwise it's 237 <p>
227 destroyed automatically in {approx}{:else}—it's destroyed automatically once the 238 The guest is stopped. Its disk still holds everything that was on it, and is destroyed in
228 guest finishes shutting down{/if}. 239 {approx}. Restoring brings the VM back and boots it from that same disk.
229 </p> 240 </p>
230 <button class="restore" onclick={cancelDeletion}>Undo delete</button> 241 {:else}
242 <!-- No destroy_at: the host has not reported taking the tombstone, so
243 the countdown has not started and neither has the shutdown, as
244 far as anything here knows. -->
245 <p>
246 eitri has asked this VM's host to stop the guest and destroy it. Until the host takes
247 the tombstone the guest may still be running, and the clock on the disk starts then.
248 Restoring brings the VM back and boots it from that same disk, with everything that was
249 on it.
250 </p>
251 {/if}
252 <button class="restore" onclick={restore}>Restore VM</button>
231 </div> 253 </div>
232 {:else} 254 {:else}
233 <div class="actions"> 255 <div class="actions">
234 {#if vmIsRunning(vm)} 256 {#if powerAction === 'stop'}
235 <button class="ghost" onclick={() => power('stopped')}>Stop</button> 257 <button class="ghost" onclick={() => power('stopped')}>Stop</button>
236 {:else} 258 {:else if powerAction === 'start'}
237 <button class="ghost" onclick={() => power('running')}>Start</button> 259 <button class="ghost" onclick={() => power('running')}>Start</button>
260 {:else}
261 <span class="hint">{powerNote}</span>
238 {/if} 262 {/if}
239 <button class="danger" onclick={remove}>Delete</button> 263 <button class="danger" onclick={remove}>Delete</button>
240 </div> 264 </div>
@@ -327,6 +351,7 @@
327 } 351 }
328 .actions { 352 .actions {
329 display: flex; 353 display: flex;
354 align-items: center;
330 gap: 0.5em; 355 gap: 0.5em;
331 margin-top: 0.8em; 356 margin-top: 0.8em;
332 } 357 }