d49a156e
feat(web): the VM page uses the width it is given
a73x 2026-08-10 18:02
Commit message
web/src/routes/vms/[id]/+page.svelte
| Old | New | ||
|---|---|---|---|
| @@ -201,40 +201,11 @@ | |||
| 201 | {@render timelineSection()} | 201 | {@render timelineSection()} |
| 202 | {:else} | 202 | {:else} |
| 203 | <h2>{vm.name}</h2> | 203 | <h2>{vm.name}</h2> |
| 204 | <table class="kv"> | ||
| 205 | <tbody> | ||
| 206 | <tr><th>ID</th><td>{vm.id}</td></tr> | ||
| 207 | <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> | ||
| 208 | <tr><th>Status</th><td>{vmStatus(vm)}</td></tr> | ||
| 209 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> | ||
| 210 | <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr> | ||
| 211 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> | ||
| 212 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> | ||
| 213 | <tr> | ||
| 214 | <th>Injected key</th> | ||
| 215 | <td> | ||
| 216 | {#if !vm.injected_key} | ||
| 217 | <span class="hint">none—eitri installed no key in this guest</span> | ||
| 218 | {:else if vm.injected_key.fingerprint} | ||
| 219 | <code class="wrap">{vm.injected_key.fingerprint}</code> | ||
| 220 | <span class="hint" | ||
| 221 | >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key | ||
| 222 | .comment}{/if}</span | ||
| 223 | > | ||
| 224 | {:else} | ||
| 225 | <span class="hint" | ||
| 226 | >{vm.injected_key.type}—unreadable, so no fingerprint. The guest's sshd decides | ||
| 227 | whether it works.</span | ||
| 228 | > | ||
| 229 | {/if} | ||
| 230 | </td> | ||
| 231 | </tr> | ||
| 232 | {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if} | ||
| 233 | <tr><th>Created</th><td>{vm.created_at}</td></tr> | ||
| 234 | </tbody> | ||
| 235 | </table> | ||
| 236 | 204 | ||
| 237 | {#if tearingDown} | 205 | {#if tearingDown} |
| 206 | <!-- A VM on its way out qualifies the whole page—there is no console to | ||
| 207 | watch and no port left to publish—so its box sits above both | ||
| 208 | columns rather than in either one. --> | ||
| 238 | <div class="teardown-callout"> | 209 | <div class="teardown-callout"> |
| 239 | <h3>This VM is being deleted</h3> | 210 | <h3>This VM is being deleted</h3> |
| 240 | {#if approx} | 211 | {#if approx} |
| @@ -255,89 +226,136 @@ | |||
| 255 | {/if} | 226 | {/if} |
| 256 | <button class="restore" onclick={restore}>Restore VM</button> | 227 | <button class="restore" onclick={restore}>Restore VM</button> |
| 257 | </div> | 228 | </div> |
| 258 | {:else} | ||
| 259 | <div class="actions"> | ||
| 260 | {#if powerAction === 'stop'} | ||
| 261 | <button class="ghost" onclick={() => power('stopped')}>Stop</button> | ||
| 262 | {:else if powerAction === 'start'} | ||
| 263 | <button class="ghost" onclick={() => power('running')}>Start</button> | ||
| 264 | {:else} | ||
| 265 | <span class="hint">{powerNote}</span> | ||
| 266 | {/if} | ||
| 267 | <button class="danger" onclick={remove}>Delete</button> | ||
| 268 | </div> | ||
| 269 | {/if} | 229 | {/if} |
| 270 | 230 | ||
| 271 | <div class="exposures"> | 231 | <div class="columns"> |
| 272 | <h3>Exposures</h3> | 232 | <div class="facts"> |
| 273 | {#if exposuresFailed} | 233 | <div class="scroll"> |
| 274 | <p class="hint">couldn't load exposures</p> | 234 | <table class="kv"> |
| 275 | {:else if exposures.length === 0} | 235 | <tbody> |
| 276 | <p class="hint"> | 236 | <tr><th>ID</th><td>{vm.id}</td></tr> |
| 277 | No published ports. The SSH gate reaches this guest; anything else it serves needs a port | 237 | <tr><th>Host</th><td>{#if host}<a href="/hosts/{host.id}">{host.name}</a>{:else}{vm.host_id}{/if}</td></tr> |
| 278 | published here. | 238 | <tr><th>Status</th><td>{vmStatus(vm)}</td></tr> |
| 279 | </p> | 239 | <tr><th>Power</th><td>{vmPower(vm)} (desired: {vm.power_state})</td></tr> |
| 280 | {:else} | 240 | <tr><th>IP</th><td>{vmIP(vm)} <span class="hint">(host bridge, NAT—not reachable off-host)</span></td></tr> |
| 281 | <table class="kv"> | 241 | <tr><th>Resources</th><td>{vm.vcpus}c / {vm.mem_mb}MB / {vm.disk_gb}GB</td></tr> |
| 282 | <tbody> | 242 | <tr><th>Image</th><td class="wrap">{vm.image_url}</td></tr> |
| 283 | {#each exposures as e (e.id)} | ||
| 284 | <tr> | 243 | <tr> |
| 285 | <th>guest :{e.guest_port}/{e.protocol}</th> | 244 | <th>Injected key</th> |
| 286 | <td> | 245 | <td> |
| 287 | {#if exposureTarget(e)} | 246 | {#if !vm.injected_key} |
| 288 | <code>{exposureTarget(e)}</code> | 247 | <span class="hint">none—eitri installed no key in this guest</span> |
| 248 | {:else if vm.injected_key.fingerprint} | ||
| 249 | <code class="wrap">{vm.injected_key.fingerprint}</code> | ||
| 250 | <span class="hint" | ||
| 251 | >{vm.injected_key.type}{#if vm.injected_key.comment}, {vm.injected_key | ||
| 252 | .comment}{/if}</span | ||
| 253 | > | ||
| 289 | {:else} | 254 | {:else} |
| 290 | <span class="hint">host address not yet known · :{e.host_port}</span> | 255 | <span class="hint" |
| 291 | {/if} | 256 | >{vm.injected_key.type}—unreadable, so no fingerprint. The guest's sshd decides |
| 292 | <span class="state {exposureState(e)}">●</span> {exposureState(e)} | 257 | whether it works.</span |
| 293 | {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if} | 258 | > |
| 294 | </td> | ||
| 295 | <td> | ||
| 296 | {#if !tearingDown} | ||
| 297 | <button class="ghost" onclick={() => removeExposure(e.id)}>Remove</button> | ||
| 298 | {/if} | 259 | {/if} |
| 299 | </td> | 260 | </td> |
| 300 | </tr> | 261 | </tr> |
| 301 | {/each} | 262 | {#if vm.last_error}<tr><th>Last error</th><td class="err">{vm.last_error}</td></tr>{/if} |
| 302 | </tbody> | 263 | <tr><th>Created</th><td>{vm.created_at}</td></tr> |
| 303 | </table> | 264 | </tbody> |
| 304 | {/if} | 265 | </table> |
| 305 | {#if !tearingDown} | 266 | </div> |
| 306 | <form class="expose-form" onsubmit={addExposure}> | 267 | </div> |
| 307 | <input | ||
| 308 | type="number" | ||
| 309 | min="1" | ||
| 310 | max="65535" | ||
| 311 | required | ||
| 312 | bind:value={guestPortInput} | ||
| 313 | placeholder="guest port" | ||
| 314 | /> | ||
| 315 | <input | ||
| 316 | type="number" | ||
| 317 | min="1024" | ||
| 318 | max="65535" | ||
| 319 | bind:value={hostPortInput} | ||
| 320 | placeholder="host port (optional)" | ||
| 321 | /> | ||
| 322 | <select bind:value={protocolInput} aria-label="protocol"> | ||
| 323 | <option value="tcp">tcp</option> | ||
| 324 | <option value="udp">udp</option> | ||
| 325 | </select> | ||
| 326 | <button type="submit">Expose</button> | ||
| 327 | </form> | ||
| 328 | {/if} | ||
| 329 | <p class="hint"> | ||
| 330 | Anything that can reach the host can reach a published port—there is no authentication in | ||
| 331 | front of one. | ||
| 332 | </p> | ||
| 333 | </div> | ||
| 334 | 268 | ||
| 335 | <!-- A plane with no gate has no hop to name, so it prints no recipe rather | 269 | <div class="doing"> |
| 336 | than commands that cannot work. --> | 270 | {#if !tearingDown} |
| 337 | {#if connectable && fleet.me?.ssh_gate} | 271 | <div class="actions"> |
| 338 | <SshConnect vmName={vm.name} tenant={fleet.me.tenant} gate={fleet.me.ssh_gate} /> | 272 | {#if powerAction === 'stop'} |
| 339 | {/if} | 273 | <button class="ghost" onclick={() => power('stopped')}>Stop</button> |
| 274 | {:else if powerAction === 'start'} | ||
| 275 | <button class="ghost" onclick={() => power('running')}>Start</button> | ||
| 276 | {:else} | ||
| 277 | <span class="hint">{powerNote}</span> | ||
| 278 | {/if} | ||
| 279 | <button class="danger" onclick={remove}>Delete</button> | ||
| 280 | </div> | ||
| 281 | {/if} | ||
| 282 | |||
| 283 | <!-- A plane with no gate has no hop to name, so it prints no recipe rather | ||
| 284 | than commands that cannot work. --> | ||
| 285 | {#if connectable && fleet.me?.ssh_gate} | ||
| 286 | <SshConnect vmName={vm.name} tenant={fleet.me.tenant} gate={fleet.me.ssh_gate} /> | ||
| 287 | {/if} | ||
| 288 | |||
| 289 | <div class="exposures"> | ||
| 290 | <h3>Exposures</h3> | ||
| 291 | {#if exposuresFailed} | ||
| 292 | <p class="hint">couldn't load exposures</p> | ||
| 293 | {:else if exposures.length === 0} | ||
| 294 | <p class="hint"> | ||
| 295 | No published ports. The SSH gate reaches this guest; anything else it serves needs a | ||
| 296 | port published here. | ||
| 297 | </p> | ||
| 298 | {:else} | ||
| 299 | <div class="scroll"> | ||
| 300 | <table class="kv"> | ||
| 301 | <tbody> | ||
| 302 | {#each exposures as e (e.id)} | ||
| 303 | <tr> | ||
| 304 | <th>guest :{e.guest_port}/{e.protocol}</th> | ||
| 305 | <td> | ||
| 306 | {#if exposureTarget(e)} | ||
| 307 | <code>{exposureTarget(e)}</code> | ||
| 308 | {:else} | ||
| 309 | <span class="hint">host address not yet known · :{e.host_port}</span> | ||
| 310 | {/if} | ||
| 311 | <span class="state {exposureState(e)}">●</span> {exposureState(e)} | ||
| 312 | {#if e.reason && !tearingDown}<span class="err">{e.reason}</span>{/if} | ||
| 313 | </td> | ||
| 314 | <td> | ||
| 315 | {#if !tearingDown} | ||
| 316 | <button class="ghost" onclick={() => removeExposure(e.id)}>Remove</button> | ||
| 317 | {/if} | ||
| 318 | </td> | ||
| 319 | </tr> | ||
| 320 | {/each} | ||
| 321 | </tbody> | ||
| 322 | </table> | ||
| 323 | </div> | ||
| 324 | {/if} | ||
| 325 | {#if !tearingDown} | ||
| 326 | <form class="expose-form" onsubmit={addExposure}> | ||
| 327 | <input | ||
| 328 | type="number" | ||
| 329 | min="1" | ||
| 330 | max="65535" | ||
| 331 | required | ||
| 332 | bind:value={guestPortInput} | ||
| 333 | placeholder="guest port" | ||
| 334 | /> | ||
| 335 | <input | ||
| 336 | type="number" | ||
| 337 | min="1024" | ||
| 338 | max="65535" | ||
| 339 | bind:value={hostPortInput} | ||
| 340 | placeholder="host port (optional)" | ||
| 341 | /> | ||
| 342 | <select bind:value={protocolInput} aria-label="protocol"> | ||
| 343 | <option value="tcp">tcp</option> | ||
| 344 | <option value="udp">udp</option> | ||
| 345 | </select> | ||
| 346 | <button type="submit">Expose</button> | ||
| 347 | </form> | ||
| 348 | {/if} | ||
| 349 | <p class="hint"> | ||
| 350 | Anything that can reach the host can reach a published port—there is no authentication in | ||
| 351 | front of one. | ||
| 352 | </p> | ||
| 353 | </div> | ||
| 354 | </div> | ||
| 355 | </div> | ||
| 340 | 356 | ||
| 357 | <!-- The console is a terminal: it takes the full measure, under both | ||
| 358 | columns, and the VM's history follows it as before. --> | ||
| 341 | {#if !tearingDown} | 359 | {#if !tearingDown} |
| 342 | <Console vmId={vm.id} /> | 360 | <Console vmId={vm.id} /> |
| 343 | {/if} | 361 | {/if} |
| @@ -359,6 +377,38 @@ | |||
| 359 | .err { | 377 | .err { |
| 360 | color: var(--bad); | 378 | color: var(--bad); |
| 361 | } | 379 | } |
| 380 | /* Given the width, the page stops being one column of everything: what this | ||
| 381 | VM IS reads down the left, what you can DO with it stands to the right. | ||
| 382 | Space alone divides them—a vertical rule beside a ruled table would read | ||
| 383 | as a box, and this vocabulary rules rows rather than drawing cells. | ||
| 384 | Narrower than the breakpoint there is no grid at all: the blocks stack in | ||
| 385 | source order, which is why the facts come first in the markup. */ | ||
| 386 | @media (min-width: 1100px) { | ||
| 387 | .columns { | ||
| 388 | display: grid; | ||
| 389 | /* The facts take the wider share. Their rows are fixed key/value | ||
| 390 | pairs that cannot reflow, while everything on the right is built | ||
| 391 | to give: commands scroll in their own box and the form wraps. */ | ||
| 392 | grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); | ||
| 393 | column-gap: 3em; | ||
| 394 | align-items: start; | ||
| 395 | } | ||
| 396 | /* Both columns start on the same line. */ | ||
| 397 | .doing .actions { | ||
| 398 | margin-top: 0.5em; | ||
| 399 | } | ||
| 400 | /* A column is not a page: a table keeps its natural width and scrolls | ||
| 401 | sideways inside its own box, so a fingerprint or a published address | ||
| 402 | is scrolled to rather than squeezed, and the page itself never | ||
| 403 | scrolls. The box belongs to the columns—stacked, each table already | ||
| 404 | has the full measure. */ | ||
| 405 | .scroll { | ||
| 406 | overflow-x: auto; | ||
| 407 | } | ||
| 408 | .scroll table { | ||
| 409 | min-width: 100%; | ||
| 410 | } | ||
| 411 | } | ||
| 362 | .actions { | 412 | .actions { |
| 363 | display: flex; | 413 | display: flex; |
| 364 | align-items: center; | 414 | align-items: center; |
| @@ -405,8 +455,11 @@ | |||
| 405 | .state.deleting { | 455 | .state.deleting { |
| 406 | color: var(--faint); | 456 | color: var(--faint); |
| 407 | } | 457 | } |
| 458 | /* The fields wrap rather than overflow: in a column they no longer have a | ||
| 459 | whole page to sit across. */ | ||
| 408 | .expose-form { | 460 | .expose-form { |
| 409 | display: flex; | 461 | display: flex; |
| 462 | flex-wrap: wrap; | ||
| 410 | gap: 0.5em; | 463 | gap: 0.5em; |
| 411 | margin-top: 0.5em; | 464 | margin-top: 0.5em; |
| 412 | } | 465 | } |