59416337
refactor(web): the console states each shared rule once
a73x 2026-08-12 19:09
Commit message
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; |