a73x

d49a156e

feat(web): the VM page uses the width it is given

a73x   2026-08-10 18:02

Commit message
feat(web): the VM page uses the width it is given

A VM's page was one narrow column of everything, so on a desktop the
facts you read and the controls you reach for were stacked a screen
apart with the rest of the display left blank.

Given the width, the page splits in two. What the VM IS reads down the
left—its identity, where it runs, what it was built from. What you can
DO with it stands on the right: power, delete, the way in, and the ports
it publishes. A VM on its way out still speaks for the whole page, so
its box sits above both columns.

The serial console keeps the full measure below them. It is a terminal
and wants every column it can get.

Narrower than the split, nothing changes: the blocks stack as they
always have, in the order they are written—facts first, then what to do
about them.

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 }