cdf4b8bc
fix(web): a VM's controls answer for the moment it is in
a73x 2026-08-09 19:47
Commit message
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 | } |