a73x

59416337

refactor(web): the console states each shared rule once

a73x   2026-08-12 19:09

Commit message
refactor(web): the console states each shared rule once

Collapse the console duplication the review found. No behavior change:
same rendering, same error surface, same layout at every width.

- joinCommands: hostBundle now returns the stem and suffix it already
  computes, and the recipe's SHA256SUMS regex reads them from there
  instead of re-deriving a parallel pair. A rename in hostBundle can no
  longer leave the regex matching nothing and $V empty. Test asserts the
  sed pattern keys off the same stem the bundle is named for.
- Duration formatting: one formatDuration over a caller-supplied unit
  ladder. upgradeAge keeps its s/m/"h m" ladder; the host page's fmtUptime
  becomes formatUptime with its d h/h m/m ladder. Both ladders covered by
  vitest; neither rendering changes.
- Two-column layout: the .columns grid and the .scroll pair it holds move
  to one :global block in +layout.svelte (inside the >=1100px query, so
  the settings margin-collapse note still holds). The per-page
  .doing .actions nudge stays page-local on the VM and host pages.
- Host status dot: hostStatusLabel() folds the "status, plus · offline"
  text both the fleet table and the host page hand-rolled, matching how
  vmStatus is centralized. Each site keeps its own dot span. Vitest added.
- Refresh boilerplate: refresh(), refreshUserCAs() and settings'
  refreshTokens() route their try/catch through action(); refresh() clears
  the banner from inside the work to keep its success-clears-error behavior.

web/src/lib/fleet.svelte.ts
Old New
@@ -254,9 +254,12 @@ export async function action(fn: () => Promise<unknown>): Promise<boolean> {
254 } 254 }
255 } 255 }
256 256
257 /** refresh does a one-shot fetch (used before SSE connects or as a fallback). */ 257 /** refresh does a one-shot fetch (used before SSE connects or as a fallback).
258 * It routes through action() for the shared catch, and clears the banner on
259 * success from inside the work — a clean load is the one refresh that also
260 * wipes a stale error, which the plain action() success path does not do. */
258 export async function refresh() { 261 export async function refresh() {
259 try { 262 await action(async () => {
260 const [h, v] = await Promise.all([ 263 const [h, v] = await Promise.all([
261 req('GET', '/api/v1/hosts').then((r) => r.json()), 264 req('GET', '/api/v1/hosts').then((r) => r.json()),
262 req('GET', '/api/v1/vms').then((r) => r.json()) 265 req('GET', '/api/v1/vms').then((r) => r.json())
@@ -264,9 +267,7 @@ export async function refresh() {
264 fleet.hosts = h; 267 fleet.hosts = h;
265 fleet.vms = v; 268 fleet.vms = v;
266 fleet.error = ''; 269 fleet.error = '';
267 } catch (err) { 270 });
268 fleet.error = String(err);
269 }
270 } 271 }
271 272
272 export async function createVM(body: CreateVMRequest): Promise<{ id: string; name: string }> { 273 export async function createVM(body: CreateVMRequest): Promise<{ id: string; name: string }> {
@@ -359,12 +360,10 @@ export async function revokeToken(id: string) {
359 /** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part 360 /** refreshUserCAs loads the tenant's CAs into fleet.userCAs. CAs are NOT part
360 * of the SSE snapshot, so callers invoke this on mount and after each mutation. */ 361 * of the SSE snapshot, so callers invoke this on mount and after each mutation. */
361 export async function refreshUserCAs() { 362 export async function refreshUserCAs() {
362 try { 363 await action(async () => {
363 fleet.userCAs = await listUserCAs(); 364 fleet.userCAs = await listUserCAs();
364 fleet.userCAsLoaded = true; 365 fleet.userCAsLoaded = true;
365 } catch (err) { 366 });
366 fleet.error = String(err);
367 }
368 } 367 }
369 368
370 export async function decommissionHost(id: string) { 369 export async function decommissionHost(id: string) {
@@ -403,8 +402,12 @@ export const HOST_PLATFORMS: { id: HostPlatform; label: string }[] = [
403 ]; 402 ];
404 403
405 /** HostBundle is the one release artifact a host of a given platform enrols 404 /** HostBundle is the one release artifact a host of a given platform enrols
406 * from: the tarball to fetch and the directory it unpacks into. */ 405 * from: the tarball to fetch, the directory it unpacks into, and the two parts
407 export type HostBundle = { file: string; dir: string }; 406 * of the name — the stem (`eitri-server` / `eitri-agent`) and the platform
407 * suffix (`linux_amd64` / `darwin_arm64`) — held apart so the join recipe's
408 * SHA256SUMS regex reads the version out of the same name this builds, rather
409 * than re-deriving a parallel one that a rename here could silently strand. */
410 export type HostBundle = { file: string; dir: string; stem: string; suffix: string };
408 411
409 /** hostBundle names that artifact. 412 /** hostBundle names that artifact.
410 * 413 *
@@ -421,13 +424,12 @@ export type HostBundle = { file: string; dir: string };
421 * version is written: `/dl/latest/` is a directory alias, and 424 * version is written: `/dl/latest/` is a directory alias, and
422 * `eitri-server_latest_linux_amd64.tar.gz` is a URL that 404s. */ 425 * `eitri-server_latest_linux_amd64.tar.gz` is a URL that 404s. */
423 export function hostBundle(platform: HostPlatform, version: string): HostBundle { 426 export function hostBundle(platform: HostPlatform, version: string): HostBundle {
424 const arch = platform.split('/')[1]; 427 const mac = platform === 'darwin/arm64';
428 const stem = mac ? 'eitri-agent' : 'eitri-server';
429 const suffix = mac ? 'darwin_arm64' : `linux_${platform.split('/')[1]}`;
425 const v = version || '${V}'; 430 const v = version || '${V}';
426 const dir = 431 const dir = `${stem}_${v}_${suffix}`;
427 platform === 'darwin/arm64' 432 return { file: `${dir}.tar.gz`, dir, stem, suffix };
428 ? `eitri-agent_${v}_darwin_arm64`
429 : `eitri-server_${v}_linux_${arch}`;
430 return { file: `${dir}.tar.gz`, dir };
431 } 433 }
432 434
433 /** joinCommands is the enrolment recipe for one join token on one platform: 435 /** joinCommands is the enrolment recipe for one join token on one platform:
@@ -443,11 +445,9 @@ export function hostBundle(platform: HostPlatform, version: string): HostBundle
443 * entitlement. macOS also ships no `sha256sum`, so verification there is 445 * entitlement. macOS also ships no `sha256sum`, so verification there is
444 * `shasum` over the one line of SHA256SUMS that names this bundle. */ 446 * `shasum` over the one line of SHA256SUMS that names this bundle. */
445 export function joinCommands(platform: HostPlatform, version: string, blob: string): string[] { 447 export function joinCommands(platform: HostPlatform, version: string, blob: string): string[] {
446 const { file, dir } = hostBundle(platform, version); 448 const { file, dir, stem, suffix } = hostBundle(platform, version);
447 const base = `https://eitri.sh/dl/${version || 'latest'}`; 449 const base = `https://eitri.sh/dl/${version || 'latest'}`;
448 const mac = platform === 'darwin/arm64'; 450 const mac = platform === 'darwin/arm64';
449 const stem = mac ? 'eitri-agent' : 'eitri-server';
450 const suffix = mac ? 'darwin_arm64' : `linux_${platform.split('/')[1]}`;
451 451
452 const cmds = mac ? ['brew install vfkit'] : []; 452 const cmds = mac ? ['brew install vfkit'] : [];
453 cmds.push(`curl -fsSLO ${base}/SHA256SUMS`); 453 cmds.push(`curl -fsSLO ${base}/SHA256SUMS`);
@@ -522,6 +522,15 @@ export function vmStatus(vm: VM): string {
522 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown'); 522 return vm.lifecycle || (vm.deleted ? 'deleting' : vm.phase || vm.status || 'unknown');
523 } 523 }
524 524
525 /** hostStatusLabel is the words beside a host's status dot: the host's status,
526 * with a '· offline' tail when it is dark. The dot's own colour is set from
527 * host.online at the callsite (the on/off class), the same split vmStatus
528 * leaves to its callers; this folds the one bit of text logic both the fleet
529 * table and the host page were spelling by hand. */
530 export function hostStatusLabel(h: Host): string {
531 return `${h.status}${h.online ? '' : ' · offline'}`;
532 }
533
525 /** shortFingerprint abbreviates an OpenSSH SHA256 fingerprint for a table cell, 534 /** shortFingerprint abbreviates an OpenSSH SHA256 fingerprint for a table cell,
526 * keeping the SHA256: prefix (so it still reads as a fingerprint and not a 535 * keeping the SHA256: prefix (so it still reads as a fingerprint and not a
527 * hash of some other kind) and enough of the digest to tell two CAs apart by 536 * hash of some other kind) and enough of the digest to tell two CAs apart by
@@ -590,13 +599,47 @@ export function upgradeStuck(h: Host): boolean {
590 return pending.age_s >= UPGRADE_STUCK_S; 599 return pending.age_s >= UPGRADE_STUCK_S;
591 } 600 }
592 601
602 /** A duration unit: how many seconds it spans and the letter it prints in. */
603 type DurationUnit = { secs: number; label: string };
604 const SEC: DurationUnit = { secs: 1, label: 's' };
605 const MIN: DurationUnit = { secs: 60, label: 'm' };
606 const HOUR: DurationUnit = { secs: 3600, label: 'h' };
607 const DAY: DurationUnit = { secs: 86400, label: 'd' };
608
609 /** formatDuration renders a coarse, round-down duration on a caller-supplied
610 * ladder. Each tier lists the units it prints, largest first: the first tier
611 * whose lead unit the value fills wins, and a one-unit tier prints just that
612 * unit ("4m") where a two-unit tier prints both ("1h 5m"). The last tier is
613 * the fallback for anything below them all. The lead count floors toward zero
614 * and never goes negative; each following count is the remainder within the
615 * unit above it. One formatter, two ladders: upgradeAge and formatUptime. */
616 function formatDuration(seconds: number, ladder: DurationUnit[][]): string {
617 const tier = ladder.find((t) => seconds >= t[0].secs) ?? ladder[ladder.length - 1];
618 return tier
619 .map((u, i) => {
620 const count =
621 i === 0
622 ? Math.max(0, Math.floor(seconds / u.secs))
623 : Math.floor((seconds % tier[i - 1].secs) / u.secs);
624 return `${count}${u.label}`;
625 })
626 .join(' ');
627 }
628
593 /** upgradeAge renders how long an offer has stood, coarsely — "40s", "4m", 629 /** upgradeAge renders how long an offer has stood, coarsely — "40s", "4m",
594 * "1h 5m". The number is evidence that something is wrong, not a stopwatch, 630 * "1h 5m". The number is evidence that something is wrong, not a stopwatch,
595 * so it rounds down to the unit that makes the point. */ 631 * so it rounds down to the unit that makes the point. Seconds under a minute,
632 * whole minutes under an hour, then hours and minutes. */
596 export function upgradeAge(seconds: number): string { 633 export function upgradeAge(seconds: number): string {
597 if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`; 634 return formatDuration(seconds, [[HOUR, MIN], [MIN], [SEC]]);
598 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; 635 }
599 return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; 636
637 /** formatUptime renders a host's uptime, coarsely — "3d 4h", "5h 12m", "40m".
638 * Days and hours once it has run a day, hours and minutes once an hour, whole
639 * minutes below that. Coarser than upgradeAge on purpose: an uptime is context,
640 * not a fault clock, so it never counts down to the second. */
641 export function formatUptime(seconds: number): string {
642 return formatDuration(seconds, [[DAY, HOUR], [HOUR, MIN], [MIN]]);
600 } 643 }
601 644
602 /** agentLogHint is where this host keeps the agent's log, in the exact form you 645 /** agentLogHint is where this host keeps the agent's log, in the exact form you
web/src/lib/fleet.test.ts
Old New
@@ -2,7 +2,9 @@ import { beforeEach, describe, expect, test } from 'vitest';
2 import { 2 import {
3 capacityReading, 3 capacityReading,
4 fleet, 4 fleet,
5 formatUptime,
5 hostBundle, 6 hostBundle,
7 hostStatusLabel,
6 joinCommands, 8 joinCommands,
7 sessionSummary, 9 sessionSummary,
8 upgradeAge, 10 upgradeAge,
@@ -250,6 +252,36 @@ describe('upgradeAge', () => {
250 }); 252 });
251 }); 253 });
252 254
255 describe('formatUptime', () => {
256 // The second ladder over the shared formatter: days/hours, hours/minutes,
257 // then whole minutes—coarser than upgradeAge, with no seconds tier, so a
258 // host up thirty seconds reads "0m" rather than counting up from a stopwatch.
259 test('under an hour is whole minutes, and never seconds', () => {
260 expect(formatUptime(30)).toBe('0m');
261 expect(formatUptime(3599)).toBe('59m');
262 });
263
264 test('past an hour says hours and minutes', () => {
265 expect(formatUptime(3600)).toBe('1h 0m');
266 expect(formatUptime(7199)).toBe('1h 59m');
267 });
268
269 test('past a day says days and hours', () => {
270 expect(formatUptime(86400)).toBe('1d 0h');
271 expect(formatUptime(90000)).toBe('1d 1h');
272 });
273 });
274
275 describe('hostStatusLabel', () => {
276 test('an online host reads as just its status', () => {
277 expect(hostStatusLabel(hostUpgrading('v0.0.5', null, true))).toBe('active');
278 });
279
280 test('an offline host carries the offline tail', () => {
281 expect(hostStatusLabel(hostUpgrading('v0.0.5', null, false))).toBe('active · offline');
282 });
283 });
284
253 describe('capacityReading', () => { 285 describe('capacityReading', () => {
254 test('a half-full host has room and a calm level', () => { 286 test('a half-full host has room and a calm level', () => {
255 expect(capacityReading(2, 4)).toEqual({ pct: 50, free: 2, over: 0, level: 'ok' }); 287 expect(capacityReading(2, 4)).toEqual({ pct: 50, free: 2, over: 0, level: 'ok' });
@@ -281,7 +313,9 @@ describe('hostBundle', () => {
281 test('a Linux host takes the tarball named for the server it also carries', () => { 313 test('a Linux host takes the tarball named for the server it also carries', () => {
282 expect(hostBundle('linux/amd64', 'v0.0.6')).toEqual({ 314 expect(hostBundle('linux/amd64', 'v0.0.6')).toEqual({
283 file: 'eitri-server_v0.0.6_linux_amd64.tar.gz', 315 file: 'eitri-server_v0.0.6_linux_amd64.tar.gz',
284 dir: 'eitri-server_v0.0.6_linux_amd64' 316 dir: 'eitri-server_v0.0.6_linux_amd64',
317 stem: 'eitri-server',
318 suffix: 'linux_amd64'
285 }); 319 });
286 expect(hostBundle('linux/arm64', 'v0.0.6').file).toBe( 320 expect(hostBundle('linux/arm64', 'v0.0.6').file).toBe(
287 'eitri-server_v0.0.6_linux_arm64.tar.gz' 321 'eitri-server_v0.0.6_linux_arm64.tar.gz'
@@ -291,7 +325,9 @@ describe('hostBundle', () => {
291 test('a Mac takes the agent bundle—no release carries a server for it', () => { 325 test('a Mac takes the agent bundle—no release carries a server for it', () => {
292 expect(hostBundle('darwin/arm64', 'v0.0.6')).toEqual({ 326 expect(hostBundle('darwin/arm64', 'v0.0.6')).toEqual({
293 file: 'eitri-agent_v0.0.6_darwin_arm64.tar.gz', 327 file: 'eitri-agent_v0.0.6_darwin_arm64.tar.gz',
294 dir: 'eitri-agent_v0.0.6_darwin_arm64' 328 dir: 'eitri-agent_v0.0.6_darwin_arm64',
329 stem: 'eitri-agent',
330 suffix: 'darwin_arm64'
295 }); 331 });
296 }); 332 });
297 333
@@ -363,6 +399,23 @@ describe('joinCommands', () => {
363 expect(cmds).not.toContain('/dl/latest/'); 399 expect(cmds).not.toContain('/dl/latest/');
364 expect(cmds).not.toContain('${V}'); 400 expect(cmds).not.toContain('${V}');
365 }); 401 });
402
403 // The regress this refactor closes: the sed that reads $V out of SHA256SUMS
404 // must key off the very stem hostBundle names the tarball for. Derived
405 // separately, a rename in hostBundle would leave the regex matching nothing,
406 // $V empty, and the recipe fetching a bundle that does not exist.
407 test('the SHA256SUMS regex keys off the same stem the bundle is named for', () => {
408 for (const platform of ['linux/amd64', 'linux/arm64', 'darwin/arm64'] as const) {
409 const { stem, file } = hostBundle(platform, '');
410 const cmds = joinCommands(platform, '', blob);
411 const sed = cmds.find((c) => c.startsWith('V='));
412 expect(sed).toBeDefined();
413 expect(sed).toContain(`${stem}_\\(v[^_]*\\)`);
414 // and the tarball the recipe then fetches carries that same stem.
415 expect(file.startsWith(`${stem}_`)).toBe(true);
416 expect(cmds.some((c) => c.includes(`"https://eitri.sh/dl/latest/${file}"`))).toBe(true);
417 }
418 });
366 }); 419 });
367 420
368 describe('vmDetail', () => { 421 describe('vmDetail', () => {
web/src/routes/+layout.svelte
Old New
@@ -178,6 +178,29 @@
178 :global(.hint) { 178 :global(.hint) {
179 color: var(--faint); 179 color: var(--faint);
180 } 180 }
181 /* The two-column split the VM, host and settings pages share: facts down the
182 wider left, what-you-can-do down the right, stated once here so the three
183 pages cannot drift apart. Below the breakpoint there is no grid at all and
184 the blocks stack in source order. A column is not a page—each table inside
185 it keeps its natural width and scrolls sideways in its own .scroll box, so
186 a fingerprint or a CPU model is scrolled to rather than squeezed and the
187 page itself never scrolls. The overflow-x stays inside the query on
188 purpose: an always-on one makes each box a formatting context, which stops
189 margins collapsing and changes the stacked spacing narrower than this. */
190 @media (min-width: 1100px) {
191 :global(.columns) {
192 display: grid;
193 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
194 column-gap: 3em;
195 align-items: start;
196 }
197 :global(.scroll) {
198 overflow-x: auto;
199 }
200 :global(.scroll table) {
201 min-width: 100%;
202 }
203 }
181 header { 204 header {
182 display: flex; 205 display: flex;
183 align-items: center; 206 align-items: center;
web/src/routes/+page.svelte
Old New
@@ -20,6 +20,7 @@
20 upgradeStuckNote, 20 upgradeStuckNote,
21 refreshUserCAs, 21 refreshUserCAs,
22 capacityReading, 22 capacityReading,
23 hostStatusLabel,
23 type CreateVMRequest, 24 type CreateVMRequest,
24 type VM 25 type VM
25 } from '$lib/fleet.svelte'; 26 } from '$lib/fleet.svelte';
@@ -235,7 +236,7 @@
235 <td><a href="/hosts/{h.id}">{h.name}</a></td> 236 <td><a href="/hosts/{h.id}">{h.name}</a></td>
236 <td> 237 <td>
237 <span class="dot {h.online ? 'on' : 'off'}"></span> 238 <span class="dot {h.online ? 'on' : 'off'}"></span>
238 {h.status}{h.online ? '' : ' · offline'} 239 {hostStatusLabel(h)}
239 </td> 240 </td>
240 <td title={osTitle(h)}>{osLabel(h)}</td> 241 <td title={osTitle(h)}>{osLabel(h)}</td>
241 <td> 242 <td>
web/src/routes/hosts/[id]/+page.svelte
Old New
@@ -10,7 +10,9 @@
10 vmsForHost, 10 vmsForHost,
11 vmPhase, 11 vmPhase,
12 vmPower, 12 vmPower,
13 vmIP 13 vmIP,
14 hostStatusLabel,
15 formatUptime
14 } from '$lib/fleet.svelte'; 16 } from '$lib/fleet.svelte';
15 import ResourceBar from '$lib/ResourceBar.svelte'; 17 import ResourceBar from '$lib/ResourceBar.svelte';
16 18
@@ -18,15 +20,6 @@
18 const host = $derived(fleet.hosts.find((h) => h.id === id)); 20 const host = $derived(fleet.hosts.find((h) => h.id === id));
19 const vms = $derived(id ? vmsForHost(id) : []); 21 const vms = $derived(id ? vmsForHost(id) : []);
20 22
21 function fmtUptime(s: number): string {
22 const d = Math.floor(s / 86400);
23 const h = Math.floor((s % 86400) / 3600);
24 const m = Math.floor((s % 3600) / 60);
25 if (d > 0) return `${d}d ${h}h`;
26 if (h > 0) return `${h}h ${m}m`;
27 return `${m}m`;
28 }
29
30 async function decommission() { 23 async function decommission() {
31 if (!host) return; 24 if (!host) return;
32 if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return; 25 if (!confirm(`Decommission host ${host.name}? Its VMs will be drained and removed.`)) return;
@@ -54,7 +47,7 @@
54 <table class="kv"> 47 <table class="kv">
55 <tbody> 48 <tbody>
56 <tr><th>ID</th><td>{host.id}</td></tr> 49 <tr><th>ID</th><td>{host.id}</td></tr>
57 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{host.status}{host.online ? '' : ' · offline'}</td></tr> 50 <tr><th>Status</th><td><span class="dot {host.online ? 'on' : 'off'}"></span>{hostStatusLabel(host)}</td></tr>
58 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr> 51 <tr><th>OS</th><td>{host.os_pretty || host.os} ({host.arch})</td></tr>
59 <tr> 52 <tr>
60 <th>Agent</th> 53 <th>Agent</th>
@@ -123,7 +116,7 @@
123 <div class="scroll"> 116 <div class="scroll">
124 <table class="kv"> 117 <table class="kv">
125 <tbody> 118 <tbody>
126 <tr><th>Uptime</th><td>{fmtUptime(host.metrics.uptime_s)}</td></tr> 119 <tr><th>Uptime</th><td>{formatUptime(host.metrics.uptime_s)}</td></tr>
127 <tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr> 120 <tr><th>Load avg</th><td>{host.metrics.load1.toFixed(2)} / {host.metrics.load5.toFixed(2)} / {host.metrics.load15.toFixed(2)}</td></tr>
128 <tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr> 121 <tr><th>Memory used</th><td>{host.metrics.mem_used_mb}MB used · {host.metrics.mem_available_mb}MB available</td></tr>
129 <tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr> 122 <tr><th>Disk used</th><td>{host.metrics.disk_used_gb}GB used · {host.metrics.disk_free_gb}GB free</td></tr>
@@ -170,34 +163,14 @@
170 reads down the left—the identity it enrolled with and the machine it 163 reads down the left—the identity it enrolled with and the machine it
171 turned out to be. What it is doing right now, and what you can do about 164 turned out to be. What it is doing right now, and what you can do about
172 it, stands on the right: decommission, then how full it is and how it is 165 it, stands on the right: decommission, then how full it is and how it is
173 coping. Space alone divides them—a vertical rule beside a ruled table 166 coping. The .columns grid and the .scroll boxes it holds are the shared
174 would read as a box. Narrower than the breakpoint there is no grid at 167 two-column vocabulary (:global in +layout.svelte); only this page-local
175 all: the blocks stack in source order. */ 168 nudge stays here. */
176 @media (min-width: 1100px) { 169 @media (min-width: 1100px) {
177 .columns {
178 display: grid;
179 /* The pilot's fractions. The identity table takes the wider share:
180 its rows are fixed key/value pairs that cannot reflow, while the
181 right column is built to give—the bars are a percentage and the
182 note wraps. */
183 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
184 column-gap: 3em;
185 align-items: start;
186 }
187 /* Both columns start on the same line as the table's first rule. */ 170 /* Both columns start on the same line as the table's first rule. */
188 .doing .actions { 171 .doing .actions {
189 margin-top: 0.5em; 172 margin-top: 0.5em;
190 } 173 }
191 /* A column is not a page: a table keeps its natural width and scrolls
192 sideways inside its own box, so a CPU model or an enrolment stamp is
193 scrolled to rather than squeezed. The box belongs to the columns—
194 stacked, each table already has the full measure. */
195 .scroll {
196 overflow-x: auto;
197 }
198 .scroll table {
199 min-width: 100%;
200 }
201 } 174 }
202 .actions { 175 .actions {
203 display: flex; 176 display: flex;
web/src/routes/settings/+page.svelte
Old New
@@ -34,11 +34,7 @@
34 let confirmRevoke = $state(''); 34 let confirmRevoke = $state('');
35 35
36 async function refreshTokens() { 36 async function refreshTokens() {
37 try { 37 await action(() => listTokens().then((t) => (tokens = t)));
38 tokens = await listTokens();
39 } catch (err) {
40 fleet.error = String(err);
41 }
42 } 38 }
43 39
44 onMount(() => { 40 onMount(() => {
@@ -248,27 +244,12 @@
248 apart—the same breakpoint, fractions and gap the VM and host pages use, so 244 apart—the same breakpoint, fractions and gap the VM and host pages use, so
249 the console reads as one layout rather than three. Tokens take the wider 245 the console reads as one layout rather than three. Tokens take the wider
250 share: their table carries three timestamps and a name. Who you are sits 246 share: their table carries three timestamps and a name. Who you are sits
251 above both, because it qualifies either one. Narrower than the split there 247 above both, because it qualifies either one. The .columns grid and the
252 is no grid at all: the sections stack in the order they are written. */ 248 .scroll boxes it holds are the shared two-column vocabulary (:global in
253 @media (min-width: 1100px) { 249 +layout.svelte); this page adds nothing page-local on top of it. The
254 .columns { 250 :global .scroll overflow-x deliberately lives inside the ≥1100px query—an
255 display: grid; 251 always-on one makes each box a formatting context, which stops the margins
256 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); 252 collapsing and changes the stacked spacing. */
257 column-gap: 3em;
258 align-items: start;
259 }
260 /* A column is not a page: a table keeps its natural width and scrolls
261 sideways inside its own box, so a fingerprint is scrolled to rather
262 than squeezed. The rule stays inside the query—an always-on
263 overflow-x makes each box a formatting context, which stops the
264 margins collapsing and changes the stacked spacing. */
265 .scroll {
266 overflow-x: auto;
267 }
268 .scroll table {
269 min-width: 100%;
270 }
271 }
272 .actions { 253 .actions {
273 display: flex; 254 display: flex;
274 gap: 0.5em; 255 gap: 0.5em;
web/src/routes/vms/[id]/+page.svelte
Old New
@@ -441,31 +441,14 @@
441 as a box, and this vocabulary rules rows rather than drawing cells. 441 as a box, and this vocabulary rules rows rather than drawing cells.
442 Narrower than the breakpoint there is no grid at all: the blocks stack in 442 Narrower than the breakpoint there is no grid at all: the blocks stack in
443 source order, which is why the facts come first in the markup. */ 443 source order, which is why the facts come first in the markup. */
444 /* The .columns grid and the .scroll boxes it holds are the shared
445 two-column vocabulary (:global in +layout.svelte); only this page-local
446 nudge stays here. */
444 @media (min-width: 1100px) { 447 @media (min-width: 1100px) {
445 .columns {
446 display: grid;
447 /* The facts take the wider share. Their rows are fixed key/value
448 pairs that cannot reflow, while everything on the right is built
449 to give: commands scroll in their own box and the form wraps. */
450 grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
451 column-gap: 3em;
452 align-items: start;
453 }
454 /* Both columns start on the same line. */ 448 /* Both columns start on the same line. */
455 .doing .actions { 449 .doing .actions {
456 margin-top: 0.5em; 450 margin-top: 0.5em;
457 } 451 }
458 /* A column is not a page: a table keeps its natural width and scrolls
459 sideways inside its own box, so a fingerprint or a published address
460 is scrolled to rather than squeezed, and the page itself never
461 scrolls. The box belongs to the columns—stacked, each table already
462 has the full measure. */
463 .scroll {
464 overflow-x: auto;
465 }
466 .scroll table {
467 min-width: 100%;
468 }
469 } 452 }
470 .actions { 453 .actions {
471 display: flex; 454 display: flex;