Standardize UI typography, charts and empty states

This commit is contained in:
2026-10-07 19:40:02 -04:00
parent 05bdeaf624
commit 0ed3cd6b47
57 changed files with 417 additions and 382 deletions
+11 -8
View File
@@ -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<CapacityData | null>(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(); }; });
</script>
@@ -76,11 +78,11 @@
<div class="page" aria-busy={navigating}>
<SectionHeader title="Capacity" />
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span></div>{/if}
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span>{#if refreshError}<Button size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>Retry</Button>{/if}</div>{/if}
<div class="toolbar">
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
<Field label="Launch game"><Select value={report.query.game} options={gameOptions} disabled={navigating} onchange={event => void changeScope({ game: (event.currentTarget as HTMLSelectElement).value as CapacityGame })} /></Field></div>
<div class="freshness"><span>{report.current.updatedAt ? `Updated ${$timeFormat.minuteTime(report.current.updatedAt)}` : 'Waiting for host reports'}</span><Button variant="ghost" size="sm" disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button></div>
<div class="freshness">{#if navigating}<span role="status">Updating capacity…</span>{:else if !refreshError}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 30 seconds" />{/if}</div>
</div>
<section class="overview" aria-labelledby="capacity-now-title">
<div class="overview-heading"><div>
@@ -94,7 +96,7 @@
<div><dt>CPU available</dt><dd>{count(summary.cpuCoresAvailable)} cores</dd></div>
<div><dt>Memory available</dt><dd>{memory(summary.memoryBytesAvailable)} GiB</dd></div>
<div><dt>Free ports</dt><dd>{count(summary.portsAvailable)}</dd></div>
<div><dt>Startup slots · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
<div><dt>Concurrent starts · Fleet-wide</dt><dd>{summary.startupSlots === null ? 'Unavailable' : `${count(summary.startupSlots)}${summary.startupLimit === null ? '' : ` of ${summary.startupLimit}`} available`}</dd></div>
</dl>
</section>
{#if report.current.warnings.length}<aside class="warnings" aria-label="Capacity notices">{#each report.current.warnings as warning}<p>{warning}</p>{/each}</aside>{/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); } }
</style>