diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index 48f449c..b5d3302 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -63,3 +63,11 @@ operations represent their host receipts and maintenance verification so a singl user action does not appear multiple times. Routine successful host checks stay in the technical Host operation history. Keep detail links contextual so their parent breadcrumb restores filters and pagination. + +### Type and numbers + +Use the shared `--text-*` scale, with 12 px as the minimum. Mobile inputs use +`--text-input` (16 px) to avoid browser zoom. `npm run check:styles`, also included +in `npm run check`, rejects raw pixel font sizes. Use Inter with tabular figures +for counts, scores, durations and charts; reserve mono for code, IDs, paths and +logs. Static values use text or state colors; the accent marks links and controls. diff --git a/package.json b/package.json index 889dc27..50fc9d4 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev": "vite dev", "build": "vite build && npm run build:reports -- --outDir build/reports && npm run build:launch -- --outDir build/launch", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "npm run check:styles && svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "test": "vitest run", "test:watch": "vitest", "benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs", @@ -21,7 +21,8 @@ "build:launch": "vite build --config vite.launch.config.ts", "predev": "npm run build:reports && npm run build:launch", "pretest": "npm run build:reports && npm run build:launch", - "pretest:watch": "npm run build:reports && npm run build:launch" + "pretest:watch": "npm run build:reports && npm run build:launch", + "check:styles": "node scripts/check-ui-styles.mjs" }, "dependencies": { "@fontsource/alfa-slab-one": "^5.3.0", diff --git a/scripts/check-ui-styles.mjs b/scripts/check-ui-styles.mjs new file mode 100644 index 0000000..afafae0 --- /dev/null +++ b/scripts/check-ui-styles.mjs @@ -0,0 +1,26 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('../src/', import.meta.url)); +const failures = []; +async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(file); + else if (/\.(svelte|css)$/.test(entry.name) && entry.name !== 'tokens.css') { + const source = await readFile(file, 'utf8'); + for (const match of source.matchAll(/font-size\s*:\s*[\d.]+px\b/g)) { + const line = source.slice(0, match.index).split('\n').length; + failures.push(`${path.relative(process.cwd(), file)}:${line}: use a shared type token (12 px minimum): ${match[0]}`); + } + } + } +} +await visit(root); +const tokens = await readFile(path.join(root, 'lib/ui/styles/tokens.css'), 'utf8'); +for (const match of tokens.matchAll(/--text-(?:xs|sm|base|md|lg|xl|2xl|3xl|input):\s*([\d.]+)px/g)) { + if (Number(match[1]) < 12) failures.push(`Type tokens must be at least 12 px: ${match[0]}`); +} +if (failures.length) { console.error(failures.join('\n')); process.exitCode = 1; } +else console.log('UI styles: shared font sizes and 12 px minimum verified.'); diff --git a/src/lib/server/fleet/snapshot.ts b/src/lib/server/fleet/snapshot.ts index b3145a1..533304b 100644 --- a/src/lib/server/fleet/snapshot.ts +++ b/src/lib/server/fleet/snapshot.ts @@ -6,11 +6,13 @@ import { runningVersions } from '../integrations/plutonium.js'; import { listHosts } from '../hosts/index.js'; import type { NativeInstance } from '../backends/native.js'; import { serverOperations } from './overview.js'; +import { hostListStatus } from '../../ui/host-overview.js'; export function fleetSnapshot(serverName?: string) { const bindings = serverName === undefined ? [] : [serverName]; const rconHost = config.rcon.host; const hosts = listHosts(); + const serverNow = Date.now(); const hostsById = new Map(hosts.map(({ inventory: _inventory, ...host }) => [host.id, host])); const versions = new Map(runningVersions(serverName).map(version => [version.server, version])); const instances = new Map((db().prepare(`SELECT * FROM native_instances WHERE retired_at IS NULL ${serverName === undefined ? '' : 'AND server = ?'}`) @@ -41,7 +43,10 @@ export function fleetSnapshot(serverName?: string) { }); return { servers, - serverNow: Date.now(), + serverNow, + // Include hosts without assigned servers. Re-evaluate heartbeat age on + // every existing snapshot refresh rather than adding a navigation poll. + hostAttention: hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, serverNow).key)).length, reservedNames: serverName === undefined ? (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server) : [], integrations: { native: true, diff --git a/src/lib/ui/components/BetaPlanBuilder.svelte b/src/lib/ui/components/BetaPlanBuilder.svelte index 86b0597..a8325e9 100644 --- a/src/lib/ui/components/BetaPlanBuilder.svelte +++ b/src/lib/ui/components/BetaPlanBuilder.svelte @@ -218,9 +218,9 @@ diff --git a/src/lib/ui/components/BetaTestProgress.svelte b/src/lib/ui/components/BetaTestProgress.svelte index 452cc4a..3a4611d 100644 --- a/src/lib/ui/components/BetaTestProgress.svelte +++ b/src/lib/ui/components/BetaTestProgress.svelte @@ -157,16 +157,16 @@ .server-detail { background:var(--surface); } .deployment-details,.deployment-links { display:flex; flex-wrap:wrap; gap:8px 20px; margin:12px 0; font-size:var(--text-sm); } @media(max-width:720px) { table { min-width:620px; } } - section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size:18px; } - p,span,time { font-size:13px; line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); } + section { display:grid; gap:16px; } h2,p { margin:0; } h2 { font-size: var(--text-md); } + p,span,time { font-size: var(--text-sm); line-height:1.6; color:var(--text-secondary); } a { color:var(--accent-primary-hover); } .heading,.actions,.filters { display:flex; flex-wrap:wrap; align-items:center; gap:12px; } .heading { justify-content:space-between; } .server { border:1px solid var(--border-subtle); border-radius:var(--radius-md); padding:16px; } - .server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size:14px; } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; } + .server > summary { display:flex; flex-wrap:wrap; gap:8px 16px; } summary { cursor:pointer; font-size: var(--text-base); } .server > summary::before { content:'▸'; } .server[open] > summary::before { content:'▾'; } .requirement { display:grid; gap:8px; margin-top:20px; border-top:1px solid var(--border-subtle); padding-top:16px; } .result { font-weight:600; margin-right:6px; color:inherit; } progress { width:100%; height:8px; accent-color:var(--state-online); } .failed { color:var(--state-danger); } - .observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size:12px; } + .observation,.guidance { margin-top:12px; } .evidence-link { justify-self:start; padding:6px 0; color:var(--accent-primary-hover); border:0; background:transparent; cursor:pointer; font-size: var(--text-xs); } .signoff { display:flex; gap:12px; align-items:end; flex-wrap:wrap; } .signoff label { flex:1 1 240px; } - label { display:grid; gap:6px; font-size:13px; color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; } + label { display:grid; gap:6px; font-size: var(--text-sm); color:var(--text-secondary); } input,select,textarea { min-height:40px; padding:8px 10px; border:1px solid var(--border-control); border-radius:6px; background:var(--surface-sunken); color:var(--text-primary); font:inherit; max-width:100%; } textarea { resize:vertical; width:100%; box-sizing:border-box; } .filters { margin:16px 0; align-items:end; } .filters label { flex:1 1 180px; min-width:0; } - .evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; } .evidence-section > p { margin-top:12px; } + .evidence { display:grid; gap:8px; padding:16px 0; border-bottom:1px solid var(--border-subtle); } .evidence-section { scroll-margin-top:80px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; font-size: var(--text-xs); } .evidence-section > p { margin-top:12px; } diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte index ce119e2..71e3b89 100644 --- a/src/lib/ui/components/BlueprintBuilder.svelte +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -98,20 +98,20 @@ diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte index c766307..ebba741 100644 --- a/src/lib/ui/components/DeployCollectionDialog.svelte +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -286,7 +286,7 @@ fieldset label.selected { background: var(--surface-raised); border-color: var(--accent-primary); } fieldset input { margin: 0; } .change-summary { display: grid; gap: 8px; padding: 14px 16px; background: var(--surface-sunken); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); } - .change-summary > strong { font-size: 17px; line-height: 1.4; } + .change-summary > strong { font-size: var(--text-md); line-height: 1.4; } .readiness { display: flex; flex-wrap: wrap; gap: 6px 16px; color: var(--text-secondary); font-size: var(--text-xs); } .readiness:empty { display: none; } p { margin: 0; font-size: var(--text-sm); line-height: 1.5; } diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 77c349c..9cbf5b5 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -220,7 +220,7 @@ label { display: grid; gap: 8px; color: var(--text-secondary); font-size: var(--text-sm); } select { width: 100%; } .summary { display: grid; gap: 8px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); } - .summary > strong { font-size: 17px; line-height: 1.4; } + .summary > strong { font-size: var(--text-md); line-height: 1.4; } p { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .muted { color: var(--text-secondary); font-size: var(--text-xs); } .readiness { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: var(--text-xs); color: var(--text-secondary); } diff --git a/src/lib/ui/components/DestroyServerModal.svelte b/src/lib/ui/components/DestroyServerModal.svelte index f2d4450..d058b5f 100644 --- a/src/lib/ui/components/DestroyServerModal.svelte +++ b/src/lib/ui/components/DestroyServerModal.svelte @@ -46,5 +46,5 @@ p { margin: 0; line-height: 1.6; color: var(--text-secondary); } .notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; } .notice { color: var(--text-primary); } - .irreversible { font-size: 13px; } .error { color: var(--state-danger); } + .irreversible { font-size: var(--text-sm); } .error { color: var(--state-danger); } diff --git a/src/lib/ui/components/DiffViewer.svelte b/src/lib/ui/components/DiffViewer.svelte index 31dcf3f..a927ee2 100644 --- a/src/lib/ui/components/DiffViewer.svelte +++ b/src/lib/ui/components/DiffViewer.svelte @@ -51,9 +51,9 @@ diff --git a/src/lib/ui/components/HousePartyDialog.svelte b/src/lib/ui/components/HousePartyDialog.svelte index db4300f..c8fc711 100644 --- a/src/lib/ui/components/HousePartyDialog.svelte +++ b/src/lib/ui/components/HousePartyDialog.svelte @@ -68,9 +68,9 @@ diff --git a/src/lib/ui/components/LaunchRecipe.svelte b/src/lib/ui/components/LaunchRecipe.svelte index 4d6e012..254be06 100644 --- a/src/lib/ui/components/LaunchRecipe.svelte +++ b/src/lib/ui/components/LaunchRecipe.svelte @@ -23,12 +23,12 @@ .recipe { display: grid; gap: 16px; min-width: 0; } .recipe-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .eyebrow { display: block; font-size: var(--text-xs); text-transform: uppercase; letter-spacing: .12em; color: var(--text-secondary); margin-bottom: 6px; } - h3 { margin: 0; font-size: 22px; letter-spacing: -.03em; } - .game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: 12px; white-space: nowrap; } - dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: 12px; } dd { margin: 4px 0 0; font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; } + h3 { margin: 0; font-size: var(--text-xl); letter-spacing: -.03em; } + .game { border: 1px solid var(--border-strong); border-radius: 99px; padding: 5px 10px; font-size: var(--text-xs); white-space: nowrap; } + dl { display: grid; gap: 12px; margin: 0; } dt { color: var(--text-secondary); font-size: var(--text-xs); } dd { margin: 4px 0 0; font-size: var(--text-sm); overflow-wrap: anywhere; line-height: 1.5; } .mono, code, .fingerprint { font-family: var(--font-mono); } - details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: 13px; } + details { border-top: 1px solid var(--border-subtle); padding-top: 14px; } summary { cursor: pointer; font-size: var(--text-sm); } ul { list-style: none; padding: 0; margin: 12px 0; } li { display: grid; gap: 5px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); overflow-wrap: anywhere; } - li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: 13px; font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); } - p { color: var(--text-secondary); font-size: 12px; line-height: 1.6; } + li > div { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; } strong { font-size: var(--text-sm); font-weight: 550; } span, code, small { font-size: var(--text-xs); } li span, small { color: var(--text-secondary); } + p { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.6; } diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte index 1d50fa3..c59f2b6 100644 --- a/src/lib/ui/components/LogPanel.svelte +++ b/src/lib/ui/components/LogPanel.svelte @@ -185,7 +185,7 @@ flex-wrap: wrap; gap: 6px; align-items: center; - border-bottom: 1px solid var(--border); + border-bottom: 1px solid var(--border-subtle); padding-bottom: 8px; } @@ -196,46 +196,46 @@ gap: 14px; } .meta { - color: var(--ink-500); - font-family: var(--font-mono); - font-size: var(--text-mono-sm); + color: var(--text-tertiary); + font-family: var(--font-body); + font-size: var(--text-xs); } .auto { display: flex; align-items: center; gap: 6px; - color: var(--ink-500); - font-family: var(--font-mono); - font-size: var(--text-mono-sm); + color: var(--text-tertiary); + font-family: var(--font-body); + font-size: var(--text-xs); cursor: pointer; user-select: none; } - .auto input { accent-color: var(--accent); } + .auto input { accent-color: var(--accent-primary); } .refresh { - background: var(--bg-card); - color: var(--ink-100); - border: 1px solid var(--border); - border-radius: var(--r-sm); + background: var(--surface); + color: var(--text-primary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); padding: 6px 12px; font-family: var(--font-body); font-size: var(--text-sm); cursor: pointer; } .refresh:hover:not(:disabled) { - border-color: var(--accent); - box-shadow: var(--accent-glow); + border-color: var(--accent-primary); + box-shadow: var(--focus-ring); } .refresh:disabled { opacity: 0.4; cursor: default; } .body { margin: 0; padding: 16px 18px; - background: var(--bg-page); - border: 1px solid var(--border); - border-radius: var(--r-sm); - color: var(--ink-300); + background: var(--surface-sunken); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + color: var(--text-secondary); font-family: var(--font-mono); - font-size: var(--text-mono); + font-size: var(--text-sm); line-height: var(--leading-mono); max-height: 560px; overflow: auto; @@ -245,28 +245,28 @@ .empty, .error { padding: 28px 16px; text-align: center; - font-family: var(--font-mono); - font-size: var(--text-mono); - border-radius: var(--r-sm); + font-family: var(--font-body); + font-size: var(--text-sm); + border-radius: var(--radius-sm); } .empty { - color: var(--ink-500); - background: var(--bg-nav); - border: 1px dashed var(--border); + color: var(--text-tertiary); + background: var(--surface); + border: 1px dashed var(--border-subtle); } .empty code { - color: var(--cream-dim); - font-size: var(--text-mono-sm); + color: var(--text-secondary); + font-size: var(--text-xs); } .error { - color: var(--danger-500); + color: var(--state-danger); background: rgba(255, 48, 80, 0.08); border: 1px solid rgba(255, 48, 80, 0.25); } .foot { - color: var(--ink-500); - font-family: var(--font-mono); - font-size: var(--text-mono-sm); + color: var(--text-tertiary); + font-family: var(--font-body); + font-size: var(--text-xs); text-align: right; } @media (max-width: 640px) { diff --git a/src/lib/ui/components/NativeHostSelection.svelte b/src/lib/ui/components/NativeHostSelection.svelte index dc56f2f..35fc76a 100644 --- a/src/lib/ui/components/NativeHostSelection.svelte +++ b/src/lib/ui/components/NativeHostSelection.svelte @@ -122,11 +122,11 @@ {/if} diff --git a/src/lib/ui/components/Navigation.svelte b/src/lib/ui/components/Navigation.svelte index 2089b55..a37d9c1 100644 --- a/src/lib/ui/components/Navigation.svelte +++ b/src/lib/ui/components/Navigation.svelte @@ -14,7 +14,7 @@ const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state })); const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state) || $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length; - const hosts = new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size; + const hosts = $fleet.hostAttention ?? new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size; const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length; return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record; }); diff --git a/src/lib/ui/components/OperationHistory.svelte b/src/lib/ui/components/OperationHistory.svelte index 3599bea..31c2239 100644 --- a/src/lib/ui/components/OperationHistory.svelte +++ b/src/lib/ui/components/OperationHistory.svelte @@ -47,7 +47,7 @@ diff --git a/src/lib/ui/components/PlayerScoreboard.svelte b/src/lib/ui/components/PlayerScoreboard.svelte index ed8c1e5..37c8ee2 100644 --- a/src/lib/ui/components/PlayerScoreboard.svelte +++ b/src/lib/ui/components/PlayerScoreboard.svelte @@ -51,7 +51,7 @@ {report.event === 'game_end' ? 'Final scoreboard' : fresh ? 'Live scoreboard' : 'Last reported scoreboard'}

{mapLabel ?? mapTitle(report.map)}

- {#if report.event !== 'game_start'}Round {report.round}{/if} + {#if report.event !== 'game_start'}Round {report.round}{/if} {#if displayElapsed}Match {displayElapsed}{fresh ? '' : ' at last report'}{/if} {#if report.event === 'game_end'}Game ended {:else if report.event === 'game_start'}Game started{/if} @@ -140,8 +140,8 @@ .timing-note { margin: var(--space-2) 0 0; font-size: var(--text-xs); color: var(--text-tertiary); } .guid { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); } .stat { text-align: right; white-space: nowrap; } - td.stat { font: var(--text-sm) var(--font-mono); font-variant-numeric: tabular-nums; } - .score { color: var(--accent-primary-hover); font-weight: var(--weight-semibold); } + td.stat { font: var(--text-sm) var(--font-numeric); font-variant-numeric: tabular-nums; } + .score { color: var(--text-primary); font-weight: var(--weight-semibold); } .unreported { color: var(--text-tertiary); } tbody tr:last-child > * { border-bottom: none; } tbody tr:hover > * { background: var(--surface-raised); } diff --git a/src/lib/ui/components/ProvisioningProgress.svelte b/src/lib/ui/components/ProvisioningProgress.svelte index 5751498..c383d15 100644 --- a/src/lib/ui/components/ProvisioningProgress.svelte +++ b/src/lib/ui/components/ProvisioningProgress.svelte @@ -93,7 +93,7 @@ summary { cursor: pointer; } .phases summary { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-5); list-style: none; } .phases summary::-webkit-details-marker { display: none; } - .phases summary::after { content: '+'; color: var(--text-tertiary); font-size: 18px; } + .phases summary::after { content: '+'; color: var(--text-tertiary); font-size: var(--text-md); } .phases details[open] > summary::after { content: '−'; } summary:focus-visible { outline: 2px solid var(--border-focus); outline-offset: -3px; border-radius: var(--radius-sm); } .phase-icon { flex: 0 0 26px; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); font-size: var(--text-sm); } diff --git a/src/lib/ui/components/RoundTelemetry.svelte b/src/lib/ui/components/RoundTelemetry.svelte index 8114c9b..b95d642 100644 --- a/src/lib/ui/components/RoundTelemetry.svelte +++ b/src/lib/ui/components/RoundTelemetry.svelte @@ -19,6 +19,6 @@ diff --git a/src/lib/ui/components/ServerIdentity.svelte b/src/lib/ui/components/ServerIdentity.svelte index 4011f2c..5764c83 100644 --- a/src/lib/ui/components/ServerIdentity.svelte +++ b/src/lib/ui/components/ServerIdentity.svelte @@ -7,6 +7,6 @@ diff --git a/src/lib/ui/components/StatusDot.svelte b/src/lib/ui/components/StatusDot.svelte deleted file mode 100644 index 1a1c10d..0000000 --- a/src/lib/ui/components/StatusDot.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - - - - diff --git a/src/lib/ui/components/capacity/CapacityChart.svelte b/src/lib/ui/components/capacity/CapacityChart.svelte index fd5291b..3187b5c 100644 --- a/src/lib/ui/components/capacity/CapacityChart.svelte +++ b/src/lib/ui/components/capacity/CapacityChart.svelte @@ -123,16 +123,17 @@ diff --git a/src/lib/ui/components/capacity/CapacityHosts.svelte b/src/lib/ui/components/capacity/CapacityHosts.svelte index fa50d38..d163d73 100644 --- a/src/lib/ui/components/capacity/CapacityHosts.svelte +++ b/src/lib/ui/components/capacity/CapacityHosts.svelte @@ -14,7 +14,8 @@ } -
+ +
{#each hosts as host (host.id)} @@ -43,7 +44,8 @@
HostAdditional instancesLimited byCPU availableMemory availableFree portsDetails
diff --git a/src/lib/ui/components/metrics/MetricsHeatmap.svelte b/src/lib/ui/components/metrics/MetricsHeatmap.svelte index b932bae..444604a 100644 --- a/src/lib/ui/components/metrics/MetricsHeatmap.svelte +++ b/src/lib/ui/components/metrics/MetricsHeatmap.svelte @@ -8,6 +8,7 @@ type Heatmap = MetricsData['heatmap']; type Cell = Heatmap['cells'][number]; let { heatmap, disabled = false, onTimeZoneChange }: { heatmap: Heatmap; disabled?: boolean; onTimeZoneChange: (value: string) => void } = $props(); + const id = $props.id(); const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const hours = Array.from({ length: 24 }, (_, hour) => hour); const zones = $derived([...new Set([...availableTimeZones(), heatmap.timeZone])].sort().map(zone => ({ value: zone, label: zone.replaceAll('_', ' ') }))); @@ -34,8 +35,8 @@ } -
-

Activity by weekday and hour

Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.

+
+

Activity by weekday and hour

Time with players as a share of known server time, grouped in {heatmap.timeZone.replaceAll('_', ' ')}.

- {#if search || status}{/if} + {#if betas.length && (search || status)}{/if}
{#if error}{/if}
{#if loading} - {:else if !betas.length && !error} + {:else if !betas.length && !error} + {#snippet actions()}{#if search || status}{:else}{/if}{/snippet} + {:else}
diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte index 80b2d1f..3ba3eed 100644 --- a/src/routes/betas/[id]/+page.svelte +++ b/src/routes/betas/[id]/+page.svelte @@ -256,14 +256,14 @@ diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte index d0458f1..8380c2a 100644 --- a/src/routes/betas/new/+page.svelte +++ b/src/routes/betas/new/+page.svelte @@ -266,13 +266,13 @@ diff --git a/src/routes/campaigns/[id]/+page.svelte b/src/routes/campaigns/[id]/+page.svelte index 8ac84fb..78e6db2 100644 --- a/src/routes/campaigns/[id]/+page.svelte +++ b/src/routes/campaigns/[id]/+page.svelte @@ -384,7 +384,7 @@ .file-row:hover:not(:disabled) { background: var(--surface-raised); } .file-row:disabled { cursor: default; } .path { font-family: var(--font-mono); font-size: var(--text-sm); overflow-wrap: anywhere; } - .sizes { margin-left: auto; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; } + .sizes { margin-left: auto; font-family: var(--font-numeric); font-size: var(--text-xs); color: var(--text-tertiary); white-space: nowrap; font-variant-numeric: tabular-nums; } .loading { text-align: center; padding: var(--space-16); color: var(--text-tertiary); } @media (max-width: 720px) { .outcome { padding: 16px; } diff --git a/src/routes/capacity/+page.svelte b/src/routes/capacity/+page.svelte index 1843b49..cb44534 100644 --- a/src/routes/capacity/+page.svelte +++ b/src/routes/capacity/+page.svelte @@ -12,6 +12,7 @@ import Select from '$ui/components/primitives/Select.svelte'; import CapacityHosts from '$ui/components/capacity/CapacityHosts.svelte'; import CapacityChart from '$ui/components/capacity/CapacityChart.svelte'; + import Freshness from '$ui/components/Freshness.svelte'; let { data }: { data: CapacityData } = $props(); let snapshot = $state(null); @@ -19,6 +20,7 @@ let navigating = $state(false); let refreshError = $state(''); let navigationError = $state(''); + let checkedAt = $state(untrack(() => Date.now())); let request: AbortController | null = null; let sequence = 0; const report = $derived(snapshot ?? data); @@ -26,7 +28,7 @@ const scopeName = $derived(report.query.hostId ? report.current.hostOptions.find(host => host.id === report.query.hostId)?.name ?? 'Selected host' : 'All hosts'); const hostOptions = $derived([{ value: '', label: 'All hosts' }, ...report.current.hostOptions.map(host => ({ value: host.id, label: host.name })), ...(report.query.hostId && !report.current.hostOptions.some(host => host.id === report.query.hostId) ? [{ value: report.query.hostId, label: 'Unavailable host' }] : [])]); - const gameOptions = [{ value: 'all', label: 'All games' }, { value: 't5', label: 'Black Ops I (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]; + const gameOptions = [{ value: 'all', label: 'All games' }, { value: 't5', label: 'Black Ops I' }, { value: 't6', label: 'Black Ops II' }]; const ranges: { value: CapacityRange; label: string }[] = [{ value: '1h', label: 'Last hour' }, { value: '24h', label: '24 hours' }, { value: '7d', label: '7 days' }]; const numbers = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); function count(value: number | null) { return value === null ? '—' : numbers.format(value); } @@ -54,7 +56,7 @@ }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not refresh capacity.'); - if (current === sequence) { snapshot = body; refreshError = ''; } + if (current === sequence) { snapshot = body; refreshError = ''; checkedAt = Date.now(); } } catch (cause) { if (current === sequence && !signal?.aborted && !request?.signal.aborted) refreshError = (cause as Error).message; return false; @@ -67,7 +69,7 @@ } beforeNavigate(() => { cancel(); navigating = true; }); afterNavigate(() => { navigating = false; }); - $effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); }); + $effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; checkedAt = Date.now(); }); }); onMount(() => { const stop = pollVisible(refresh, 30_000, false); return () => { stop(); cancel(); }; }); @@ -76,11 +78,11 @@
{#if page.url.searchParams.has('returnTo')}{/if} - {#if refreshError || navigationError}{/if} + {#if refreshError || navigationError}{/if}
void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} />
-
{report.current.updatedAt ? `Updated ${$timeFormat.minuteTime(report.current.updatedAt)}` : 'Waiting for host reports'}
+
{#if navigating}Updating capacity…{:else if !refreshError}{/if}
@@ -94,7 +96,7 @@
CPU available
{count(summary.cpuCoresAvailable)} cores
Memory available
{memory(summary.memoryBytesAvailable)} GiB
Free ports
{count(summary.portsAvailable)}
-
Startup slots · Fleet-wide
{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}
+
Concurrent starts · Fleet-wide
{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}
{#if report.current.warnings.length}{/if} @@ -135,9 +137,10 @@ .empty-hosts h3 { margin:0; font-size:var(--text-md); } .empty-hosts p { color:var(--text-secondary); font-size:var(--text-sm); } summary { cursor:pointer; color:var(--text-secondary); font-size:var(--text-xs); padding:4px 0; } .methodology p { color:var(--text-secondary); font-size:var(--text-xs); line-height:1.7; max-width:110ch; } - .ranges { display:flex; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } + .ranges { display:flex; flex-wrap:wrap; gap:3px; padding:3px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } .ranges a { padding:7px 12px; border-radius:var(--radius-sm); color:var(--text-secondary); text-decoration:none; font-size:var(--text-xs); white-space:nowrap; } .ranges a[aria-current] { color:var(--text-primary); background:var(--surface-raised); } a:focus-visible,summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; } - @media(max-width:600px) { .filters { width:100%; grid-template-columns:1fr 1fr; } .overview { padding:16px; } .resources { gap:16px 24px; } } + @media(max-width:600px) { .filters { width:100%; grid-template-columns:repeat(2,minmax(0,1fr)); } .overview { padding:16px; } .resources { gap:16px 24px; } } + @media(max-width:360px) { .filters { grid-template-columns:minmax(0,1fr); } } diff --git a/src/routes/collections/+page.svelte b/src/routes/collections/+page.svelte index 11a257e..bd640e9 100644 --- a/src/routes/collections/+page.svelte +++ b/src/routes/collections/+page.svelte @@ -107,7 +107,7 @@ Collections | Electric Cherry
- {#snippet actions()}{#if canOperate}{/if}{/snippet} + {#snippet actions()}{#if canOperate && (blueprintsLoading || blueprints.length || blueprintError)}{/if}{/snippet} {#if canOperate} {/if} @@ -118,7 +118,9 @@

Your collections {blueprints.length}

{#if blueprintError}{/if} {#if blueprintsLoading}

Loading collections…

- {:else if !blueprints.length}
No collections yet.

Collections define the maps and lobby counts for a fleet. Operators can create and launch them.

{#if canOperate}{/if}
+ {:else if !blueprints.length && !blueprintError} + {#snippet actions()}{#if canOperate}{/if}{/snippet} + {:else}
{#each blueprints.slice(0,limit) as blueprint (blueprint.id)} @@ -156,7 +158,9 @@ {/each}
{:else if !launchError} - + + {#snippet actions()}{/snippet} + {/if} saveContext()} step={10} label="launches" /> @@ -167,7 +171,7 @@ .page { display: flex; flex-direction: column; gap: 28px; } .panel { display: grid; gap: 16px; } section { display: grid; gap: 16px; } - h2 { font-size: 18px; margin: 0; } h2 span { opacity: .6; margin-left: 8px; } + h2 { font-size: var(--text-md); margin: 0; } h2 span { opacity: .6; margin-left: 8px; } p { margin: 8px 0; } .error { color: var(--state-danger); } .launches { display: grid; gap: 12px; } @@ -175,15 +179,15 @@ .collection-row:hover { border-color: var(--border-strong); background: var(--surface-raised); } .collection-row:focus-visible { outline: 2px solid var(--border-focus); outline-offset: 3px; } .row-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } - .collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: 13px; color: var(--text-secondary); } + .collection-counts { display: flex; flex-wrap: wrap; gap: 10px 20px; font-size: var(--text-sm); color: var(--text-secondary); } .collection-counts strong { color: var(--state-online); font-weight: 600; font-variant-numeric: tabular-nums; } .collection-meter { display: flex; height: 4px; background: var(--surface-raised); overflow: hidden; border-radius: var(--radius-pill); } .collection-meter .verified { background: var(--state-online); }.collection-meter .unsuccessful { background: var(--state-danger); }.collection-meter .cancelled { background: var(--text-tertiary); } - .collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: 12px; } + .collection-footer { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px 16px; font-size: var(--text-xs); } .meta { color: var(--text-secondary); }.collection-link { color: var(--accent-primary-hover); } - .blueprint p, .blueprint-empty p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } - .blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: 13px; }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: 12px; cursor: pointer; }.blueprint-footer { flex-wrap: wrap; } + .blueprint p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; } + .blueprint-lineup { list-style: none; padding: 0; margin: 8px 0; display: grid; gap: 9px; font-size: var(--text-sm); }.blueprint-lineup strong { color: var(--accent-primary-hover); margin-right: 5px; }.blueprint-lineup small { color: var(--text-secondary); margin-left: 6px; }.edit { background: none; border: 0; color: var(--accent-primary-hover); font: inherit; font-size: var(--text-xs); cursor: pointer; }.blueprint-footer { flex-wrap: wrap; } .eyebrow { display: block; text-transform: uppercase; letter-spacing: .1em; font-size: var(--text-xs); color: var(--text-secondary); margin-bottom: 8px; } - .blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: 19px; overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; } - .remove, .blueprint-empty button { background: none; color: var(--accent-primary-hover); border: 0; font: inherit; font-size: 12px; cursor: pointer; }.remove { color: var(--text-secondary); }.blueprint-empty { border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 24px; }.blueprint-empty strong { font-size: 15px; }.blueprint-empty button { padding: 8px 0 0; } + .blueprints { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(290px, 100%), 1fr)); gap: 16px; }.blueprint { padding: 22px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); display: flex; flex-direction: column; }.blueprint h3 { margin: 8px 0 0; font-size: var(--text-lg); overflow-wrap: anywhere; }.blueprint > p { flex: 1; overflow-wrap: anywhere; }.blueprint-top, .blueprint-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.blueprint-top .eyebrow { margin: 0; }.file-count { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }.blueprint-footer { border-top: 1px solid var(--border-subtle); padding-top: 16px; margin-top: 16px; } + .remove { background: none; color: var(--text-secondary); border: 0; font: inherit; font-size: var(--text-xs); cursor: pointer; } diff --git a/src/routes/collections/launches/[id]/+page.svelte b/src/routes/collections/launches/[id]/+page.svelte index bf29eb3..d4ea9ff 100644 --- a/src/routes/collections/launches/[id]/+page.svelte +++ b/src/routes/collections/launches/[id]/+page.svelte @@ -192,16 +192,16 @@ diff --git a/src/routes/metrics/+page.svelte b/src/routes/metrics/+page.svelte index edb91b1..e479098 100644 --- a/src/routes/metrics/+page.svelte +++ b/src/routes/metrics/+page.svelte @@ -20,6 +20,7 @@ import MetricsChart from '$ui/components/metrics/MetricsChart.svelte'; import MetricsExplore from '$ui/components/metrics/MetricsExplore.svelte'; import MetricsTables from '$ui/components/metrics/MetricsTables.svelte'; + import Freshness from '$ui/components/Freshness.svelte'; import type { MetricsData } from '$lib/metrics.js'; import type { PageData } from './$types'; @@ -31,6 +32,7 @@ let scopeNavigating = $state(false); let mounted = $state(false); let attemptedPreference = $state(null); + let checkedAt = $state(untrack(() => Date.now())); const report = $derived(snapshot ?? data); const format = $derived(createTimeFormatter(report.scope.timeZone)); const captured = $derived(report.summary.observed + report.summary.unknown); @@ -55,7 +57,7 @@ if (data.scope.map) params.set('map', data.scope.map); return params.toString(); }, - onData: value => { snapshot = value; }, + onData: value => { snapshot = value; checkedAt = Date.now(); }, onError: value => { refreshError = value; }, onLoading: value => { refreshing = value; } }); @@ -64,7 +66,7 @@ $effect(() => { // Navigation owns the new report; an older background request cannot replace it. void data; - untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; }); + untrack(() => { refresh.cancel(); snapshot = null; refreshError = null; checkedAt = Date.now(); }); }); $effect(() => { // Layout initializes the saved preference on mount; react when it arrives. @@ -118,7 +120,7 @@ changeMap((event.currentTarget as HTMLSelectElement).value)} /> -
{scopeNavigating ? 'Updating report…' : `Updated ${format.minuteTime(report.now)}`}
+
{#if scopeNavigating}Updating report…{:else if !refreshError}{/if}
{#if scopeError}{/if} {#if refreshError} @@ -153,7 +155,7 @@ {/if} {#if captured > 0} -
Activity by weekday and hour changeScope({ timeZone: value })} />
+ changeScope({ timeZone: value })} />
{#if view === 'insights'}{:else}{/if} @@ -168,7 +170,7 @@ Report refreshed {format.time(report.now)} · refreshes every minute while visible {#if report.scopeFirstSampleAt !== null}Scope retained samples from {format.dateTime(report.scopeFirstSampleAt)}{/if}
-

Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. pp means percentage points. Equal windows do not guarantee equal observation coverage.

+

Previous window: {format.period(report.previous.since)} – {format.period(report.previous.until)}. Known time: {metricDuration(report.previous.summary.observed)} previous / {metricDuration(report.summary.observed)} current. Percentage points show the difference between two percentages. Equal windows do not guarantee equal observation coverage.

Time with players is occupied server time divided by known server time. Known time includes occupied and idle observations; unknown status and collection gaps are excluded from that percentage.

Each periodic observation covers at most {ONLINE_MAX_MS / 1000} seconds. Longer collection gaps are excluded. History is retained for 31 days. Player-hours include all players reported by the server and are estimates, not individual session records.

Timeline buckets are aligned to UTC, with labels shown in {report.scope.timeZone.replaceAll('_', ' ')}. Daily buckets are 24-hour periods, not local calendar days. The first and latest buckets can be partial. Percentage bars divide occupied, idle, and unknown time by captured time; the headline occupancy percentage uses known time only.

@@ -177,8 +179,9 @@
diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte index 1263d46..3f12e3a 100644 --- a/src/routes/plugins/+page.svelte +++ b/src/routes/plugins/+page.svelte @@ -171,7 +171,7 @@
- {#snippet actions()}{#if plugins.length}{/if}{/snippet} + {#snippet actions()}{#if plugins.length}{/if}{#if loading || plugins.length || flash}{/if}{/snippet} {#if importComplete}{/if} {#if successBanner} @@ -191,11 +191,13 @@ {#if game}

Game results include unscoped plugins that can use explicitly selected beta servers.

{/if} {#if updatesOpen}
{ if (!event.currentTarget.open) updatesOpen = false; }}>Update check details{#key importCount} updates = results} />{/key}
{/if}
-
{visible.length} of {plugins.length} plugins{#if search || game || status}{/if}
+
{visible.length} of {plugins.length} plugins{#if visible.length && (search || game || status)}{/if}
{#if loading}
Loading…
{:else if visible.length === 0 && !flash} - + + {#snippet actions()}{#if plugins.length}{:else}{/if}{/snippet} + {:else}
BetaFilesStatusStarted
diff --git a/src/routes/scripts/+page.svelte b/src/routes/scripts/+page.svelte index 14b267d..fa5ebda 100644 --- a/src/routes/scripts/+page.svelte +++ b/src/routes/scripts/+page.svelte @@ -96,7 +96,7 @@
- {#snippet actions()}{/snippet} + {#snippet actions()}{#if loading || scripts.length || error}{/if}{/snippet}
{#if loading || scripts.length || error}
@@ -109,16 +109,19 @@
{visible.length} of {scripts.length} scripts {#if failedCount && status !== 'failed'}{/if} - {#if search || status !== 'all' || game || map}{/if} + {#if visible.length && (search || status !== 'all' || game || map)}{/if}
{/if} {#if game}

Game results include unscoped scripts that can use explicitly selected beta servers.

{/if} {#if error}{/if} {#if loading}

Loading your library…

{:else if !scripts.length && !error} - -
+ + {#snippet actions()}{/snippet} + {:else if !visible.length && !error} - + + {#snippet actions()}{/snippet} + {:else}
PluginGame / scopeVersion / statusEligible serversNext action
@@ -164,6 +167,6 @@ .selection-review li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 6px 0; } @media (max-width: 640px) { .selection-bar { padding: 12px; gap: 8px; bottom: 12px; } .selection-bar > strong { flex-basis: 100%; font-size: var(--text-sm); } } .drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); } - .drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); } + .drop-overlay strong { font-size: var(--text-2xl); } .loading { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } diff --git a/src/routes/server-keys/+page.svelte b/src/routes/server-keys/+page.svelte index e461c76..023648e 100644 --- a/src/routes/server-keys/+page.svelte +++ b/src/routes/server-keys/+page.svelte @@ -145,13 +145,13 @@ h2 { font-size: var(--text-md); } p { color: var(--text-secondary); } form, label { display: grid; gap: var(--space-4); } - label { gap: 8px; font-size: 14px; } + label { gap: 8px; font-size: var(--text-base); } select { padding: 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: inherit; } .actions { display: flex; gap: var(--space-2); } .table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); } th { color: var(--text-tertiary); font-size: var(--text-sm); } - code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); margin-top: 6px; } + code { display: block; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-secondary); margin-top: 6px; } .error { color: var(--state-danger); } diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 7f5e16f..ef1e5ea 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -431,7 +431,7 @@ letter-spacing: var(--tracking-tight); } .panel-count { - font-family: var(--font-mono); + font-family: var(--font-numeric); font-size: var(--text-sm); color: var(--text-tertiary); font-variant-numeric: tabular-nums; diff --git a/tests/unit/fleet-monitor.test.ts b/tests/unit/fleet-monitor.test.ts index 63bac0f..34726b3 100644 --- a/tests/unit/fleet-monitor.test.ts +++ b/tests/unit/fleet-monitor.test.ts @@ -35,6 +35,18 @@ async function setup() { } describe('fleet event refreshes', () => { + it('hydrates and refreshes the all-host attention count without an additional request', async () => { + const store = await import('../../src/lib/ui/stores/fleet.js'); + const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' }; + store.hydrateFleet({ servers: [], operations: [], integrations, serverNow: Date.now(), hostAttention: 2 }); + expect(get(store.fleet).hostAttention).toBe(2); + vi.mocked(fetch).mockImplementation(async url => Response.json(String(url) === '/api/servers' + ? { serverNow: Date.now(), servers: [], integrations, hostAttention: 0 } : { operations: [] })); + await store.refreshFleet(); + expect(get(store.fleet).hostAttention).toBe(0); + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls.map(([url]) => url).sort()).toEqual(['/api/fleet', '/api/servers']); + }); it('refreshes server history through one scoped request without replacing the fleet snapshot', async () => { const store = await import('../../src/lib/ui/stores/fleet.js'); const integrations = { rcon: true, rconTimeoutMs: 2000, defaultRconHost: '' }; diff --git a/tests/unit/fleet-snapshot-scope.test.ts b/tests/unit/fleet-snapshot-scope.test.ts index 2d44dba..a5db309 100644 --- a/tests/unit/fleet-snapshot-scope.test.ts +++ b/tests/unit/fleet-snapshot-scope.test.ts @@ -30,6 +30,27 @@ beforeEach(async () => { afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.restoreAllMocks(); }); describe('fleet and server response scope', () => { + it('counts unattached hosts needing attention and rechecks heartbeat age with each snapshot', async () => { + const hosts = await import('../../src/lib/server/hosts/index.js'); + const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js'); + const base: import('../../src/lib/hosts.js').Host = { id: 'empty-host', name: 'Empty host', hostname: 'empty-host', + portMin: 28960, portMax: 28980, bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 1024, createdAt: iso(), + connected: true, connectionState: 'online', lastHeartbeat: iso(), + enrolled: true, credentialActive: true, mode: 'active', readiness: 'ready', readinessReasons: [], + inventory: {}, decommission: null }; + vi.spyOn(hosts, 'listHosts').mockReturnValue([ + base, + { ...base, id: 'offline', connected: false }, + { ...base, id: 'degraded', readiness: 'degraded' }, + { ...base, id: 'disabled', connected: false, mode: 'disabled' }, + { ...base, id: 'unenrolled', connected: false, enrolled: false } + ]); + expect(fleetSnapshot().hostAttention).toBe(2); + vi.setSystemTime(now + 91_000); + expect(fleetSnapshot().hostAttention).toBe(3); + // None of these hosts has an assigned instance in this fixture. + expect(fleetSnapshot().servers.every(server => !server.host_id)).toBe(true); + }); it('omits completed history from fleet REST and SSR responses', async () => { const { GET } = await import('../../src/routes/api/fleet/+server.js'); const { load } = await import('../../src/routes/+page.server.js'); diff --git a/tests/unit/ui-capacity.test.ts b/tests/unit/ui-capacity.test.ts index 9d2fe86..01f377b 100644 --- a/tests/unit/ui-capacity.test.ts +++ b/tests/unit/ui-capacity.test.ts @@ -39,6 +39,8 @@ it('keeps current estimates and host comparison before history with scoped histo expect(body).toContain('No queued instances'); expect(body).not.toContain('href="/provisioning"'); expect(body).not.toContain('Manage hosts'); + expect(body).toContain('Updates every 30 seconds'); + expect(body).not.toContain('>Refresh<'); }); it('keeps fleet-wide queue scope explicit and preserves unavailable estimates', () => { @@ -61,6 +63,7 @@ it('keeps ineligible hosts and their reasons visible while collapsing detailed a expect(body).toMatch(/]*id="capacity-host-ec-1"/); expect(body).toContain('aria-expanded="false"'); expect(body).toContain('Running instance'); + expect(body).toContain('aria-label="Host capacity comparison" tabindex="0"'); }); it('preserves chart gaps, keyboard inspection and reporting coverage in the compact chart', () => { diff --git a/tests/unit/ui-metrics-page.test.ts b/tests/unit/ui-metrics-page.test.ts index b36b2ee..7265c83 100644 --- a/tests/unit/ui-metrics-page.test.ts +++ b/tests/unit/ui-metrics-page.test.ts @@ -41,12 +41,17 @@ it('shows a summary and one paginated breakdown, preserving scope on range links expect(body).toContain('Partial previous history'); expect(body).toContain('About this report'); expect(body).not.toContain('Explore this period'); + expect(body).toContain('Updates every minute'); + expect(body).not.toContain('>Refresh<'); + const heatmap = body.slice(body.indexOf('class="heatmap'), body.indexOf('aria-label="Metrics breakdown"')); + expect(heatmap).toContain(' { + fleet.set({ receivedAt: Date.now(), operations: [], servers: [], hostAttention: 2 }); + expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Hosts, 2 need attention"'); + fleet.update(value => ({ ...value, hostAttention: 0 })); + expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(/aria-label="Hosts, \d/); + }); }); const user: AuthUser = { id: '1', username: 'operator', displayName: 'Operator', role: 'operator', disabled: false,
ScriptGame / scopeVersion / statusEligible serversNext action