Files
electric-cherry/src/routes/+page.svelte
T
2026-10-12 19:55:26 -04:00

1108 lines
42 KiB
Svelte

<script module lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
const serverTitleCollator = new Intl.Collator(undefined, { numeric: true });
</script>
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
let { data }: { data: import('./$types').PageData } = $props();
const snapshot = $derived($fleet.receivedAt ? $fleet : data.fleetSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.fleetSnapshot.servers);
import { onMount, untrack } from 'svelte';
import { afterNavigate, replaceState } from '$app/navigation';
import { page } from '$app/stores';
import ServerCard from '$ui/components/ServerCard.svelte';
import DeployServerDialog from '$ui/components/DeployServerDialog.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { serverHealth } from '$ui/server-health.js';
import {
fleet,
fleetServers,
fleetError,
fleetIntegrations,
refreshFleet
} from '$ui/stores/fleet.js';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import { currentRound, fleetIdentity, freshServer, matchesFleet } from '$lib/fleet-overview.js';
import { currentCapacity } from '$lib/server-capacity.js';
import type { FleetServer, FleetFilter, FleetSort } from '$lib/fleet-overview.js';
import type { ZombieGame } from '$lib/map-aliases.js';
import { elapsedSince } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.js';
import { plural } from '$ui/plural.js';
let query = $state('');
let filter = $state<FleetFilter>('all');
let game = $state('');
let sort = $state<FleetSort>('duration');
let view = $state<'cards' | 'table'>('cards');
let grouping = $state<'map' | 'game' | 'none'>('game');
let viewOptionsOpen = $state(false);
let pinned = $state<string[]>([]);
let preferencesReady = $state(false);
let routerReady = $state(false);
let deployOpen = $state(false);
let initialGame = $state<ZombieGame>('t5');
let initialMapSlug = $state('');
const canOperate = $derived(
$page.data.user?.role === 'admin' || $page.data.user?.role === 'operator'
);
const filters: { id: FleetFilter; label: string }[] = [
{ id: 'all', label: 'All' },
{ id: 'occupied', label: 'Live' },
{ id: 'empty', label: 'Empty' },
{ id: 'attention', label: 'Needs attention' },
{ id: 'testing', label: 'Testing' },
{ id: 'pinned', label: 'Pinned' }
];
$effect(() => {
if (canOperate && $page.url.searchParams.get('action') === 'create') deployOpen = true;
});
function readViewUrl() {
const params = new URL(window.location.href).searchParams;
query = params.get('q') ?? '';
view = params.get('view') === 'table' ? 'table' : 'cards';
grouping = ['map', 'game', 'none'].includes(params.get('group') ?? '')
? (params.get('group') as typeof grouping)
: 'game';
sort = ['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')
? (params.get('sort') as FleetSort)
: 'duration';
filter = filters.some((item) => item.id === params.get('filter'))
? (params.get('filter') as FleetFilter)
: 'all';
game = ['t5', 't6'].includes(params.get('game') ?? '') ? params.get('game')! : '';
}
afterNavigate(() => {
routerReady = true;
if (preferencesReady) readViewUrl();
});
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
if (Array.isArray(saved.pinned))
pinned = saved.pinned.filter((name: unknown) => typeof name === 'string');
} catch {
/* Preferences also work when storage is disabled. */
}
readViewUrl();
preferencesReady = true;
});
$effect(() => {
if (!preferencesReady) return;
try {
localStorage.setItem('electric-cherry:fleet', JSON.stringify({ pinned }));
} catch {
/* The current view still works without persistent storage. */
}
const values = { q: query, view, group: grouping, sort, filter, game };
if (!routerReady) return;
untrack(() => {
const url = new URL(window.location.href);
const defaults: Record<string, string> = {
q: '',
view: 'cards',
group: 'game',
sort: 'duration',
filter: 'all',
game: ''
};
for (const [key, value] of Object.entries(values)) {
if (value === defaults[key]) url.searchParams.delete(key);
else url.searchParams.set(key, value);
}
if (url.href !== window.location.href) replaceState(url, $page.state);
});
});
const snapshotAge = $derived.by(() => {
void $uiNow;
return Math.max(
0,
Math.floor((Date.now() - ($fleet.receivedAt || data.fleetSnapshot.serverNow)) / 1000)
);
});
const integrations = $derived($fleetIntegrations ?? data.fleetSnapshot.integrations);
let servers = $derived(visibleFleet);
const observationNow = () =>
untrack(() => ($fleet.receivedAt ? $uiNow : data.fleetSnapshot.serverNow));
let activeOperations = $derived(snapshot.operations.filter((op) => op.state !== 'done'));
let attentionOperations = $derived(activeOperations.filter((op) => op.state === 'attention'));
let campaigns = $derived(
new Set(activeOperations.filter((op) => op.kind === 'campaign').map((op) => op.group)).size
);
let healthy = $derived(
servers.filter(
(server) =>
freshServer(server, observationNow()) &&
serverHealth(server, observationNow()).state === 'online'
)
);
let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0));
const needsAttention = (server: FleetServer) =>
!['online', 'starting', 'provisioning'].includes(
serverHealth(server, observationNow()).state
) || activeOperations.some((op) => op.server === server.name && op.state === 'attention');
function matchesView(server: FleetServer, search: string, selected: FleetFilter) {
return (
matchesFleet(
server,
search,
selected === 'attention' ? 'all' : selected,
game,
pinned,
activeOperations,
observationNow()
) &&
(selected !== 'attention' || needsAttention(server)) &&
(!['occupied', 'empty'].includes(selected) ||
serverHealth(server, observationNow()).state === 'online')
);
}
let filtered = $derived.by(() => {
const now = observationNow();
const pinnedNames = new Set(pinned);
return servers
.filter((server) => matchesView(server, query, filter))
.map((server) => ({
server,
title: serverTitle(server.name),
pinned: Number(pinnedNames.has(server.name)),
rank:
sort === 'players'
? freshServer(server, now)
? (server.last_players ?? -1)
: -1
: sort === 'round'
? (currentRound(server, $telemetryByServer.get(server.name), now) ?? -1)
: sort === 'duration'
? matchDuration(server, now)
: 0
}))
.sort(
(a, b) =>
Number(needsAttention(b.server)) - Number(needsAttention(a.server)) ||
b.pinned - a.pinned ||
b.rank - a.rank ||
serverTitleCollator.compare(a.title, b.title)
)
.map(({ server }) => server);
});
const problems = $derived(filtered.filter(needsAttention));
const live = $derived(
filtered.filter(
(server) =>
!needsAttention(server) &&
freshServer(server, observationNow()) &&
(server.last_players ?? 0) > 0
)
);
const idle = $derived(
filtered.filter(
(server) =>
!needsAttention(server) &&
freshServer(server, observationNow()) &&
server.last_players === 0
)
);
const preparing = $derived(
filtered.filter(
(server) => !needsAttention(server) && !live.includes(server) && !idle.includes(server)
)
);
const liveCount = $derived(healthy.filter((server) => (server.last_players ?? 0) > 0).length);
function grouped(items: FleetServer[], groupBy: typeof grouping = grouping) {
const result = new Map<
string,
{
title: string;
servers: FleetServer[];
launch: ReturnType<typeof fleetIdentity>['launch'];
}
>();
for (const server of items) {
const identity = fleetIdentity(server);
const key =
groupBy === 'map' ? identity.group : groupBy === 'game' ? identity.game : 'all';
if (!result.has(key))
result.set(key, {
title:
groupBy === 'map'
? identity.map
: groupBy === 'game'
? identity.game === 't5'
? 'Black Ops I'
: identity.game === 't6'
? 'Black Ops II'
: 'Other servers'
: '',
servers: [],
launch: groupBy === 'map' ? identity.launch : null
});
result.get(key)!.servers.push(server);
}
// Preserve the selected ranking between groups as well as within them.
return [...result.entries()];
}
const groups = $derived(grouped(filtered));
const sections = $derived(
[
{ title: 'Live matches', servers: live, compact: false },
{ title: 'Preparing', servers: preparing, compact: true },
{ title: 'Empty lobbies', servers: idle, compact: true }
].filter((section) => section.servers.length)
);
let announcement = $state('');
let previousStates = new Map<string, string>();
$effect(() => {
const current = new Map(
servers.map((server) => [server.name, serverHealth(server, observationNow()).state])
);
const changes = servers.filter(
(server) =>
previousStates.has(server.name) &&
previousStates.get(server.name) !== current.get(server.name)
);
if (changes.length)
announcement =
changes
.slice(0, 3)
.map(
(server) =>
`${serverTitle(server.name)}: ${serverHealth(server, observationNow()).label}.`
)
.join(' ') +
(changes.length > 3 ? ` ${changes.length - 3} other servers changed status.` : '');
previousStates = current;
});
let spotlight = $derived(
servers
.filter(
(server) =>
currentRound(server, $telemetryByServer.get(server.name), observationNow()) !==
null
)
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]
);
function matchDuration(server: FleetServer, now = observationNow()): number {
return freshServer(server, now) &&
server.active_since &&
server.game_started_at &&
!server.game_ended_at &&
(server.last_players ?? 0) > 0
? Math.max(0, now - Date.parse(server.game_started_at))
: -1;
}
function togglePin(name: string) {
pinned = pinned.includes(name) ? pinned.filter((item) => item !== name) : [...pinned, name];
}
function launch(selection: { game: ZombieGame; slug: string } | null = null) {
initialGame = selection?.game ?? 't5';
initialMapSlug = selection?.slug ?? '';
deployOpen = true;
}
function resetFilters() {
query = '';
filter = 'all';
game = '';
}
</script>
<svelte:head><title>Servers · Electric Cherry</title></svelte:head>
<section class="page">
<div class="page-heading">
<SectionHeader title="Servers" />
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button
>{/if}
</div>
<div class="sr-only" role="status" aria-atomic="true">{announcement}</div>
{#if canOperate}<DeployServerDialog
bind:open={deployOpen}
{initialGame}
{initialMapSlug}
/>{/if}
{#if $fleetError}
<div class="error" role="alert">
<strong>Reconnecting…</strong>
{$fleetError} Showing the last available snapshot.
<button onclick={() => void refreshFleet()}>Retry</button>
</div>
{/if}
{#if servers.length}
<div class="overview" aria-label="Fleet summary">
<span
><strong>{healthy.length ? players : '—'}</strong>
{plural(players, 'player')} · <strong>{liveCount}</strong> live {plural(
liveCount,
'match',
'matches'
)}</span
>
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
{#if campaigns}<a class="metric-link" href="/campaigns"
>{campaigns} active {campaigns === 1 ? 'campaign' : 'campaigns'}</a
>{/if}
<span class="freshness"
>{$fleetError
? 'Reconnecting…'
: `Live · updated ${snapshotAge < 60 ? `${snapshotAge} s` : `${Math.floor(snapshotAge / 60)} min`} ago`}</span
>
</div>
{/if}
{#if integrations?.native && !integrations.hostsReady}
<div class="setup-note">
<span
><strong>Prepare a game host</strong> · Connect a host, prepare its runtime, and download
game files.</span
><a href="/hosts">Review hosts →</a>
</div>
{/if}
{#if servers.length === 0}
<EmptyState
title="No servers yet"
description="Create a server to see live health, players, and matches here."
>
{#snippet actions()}{#if canOperate}<Button variant="primary" onclick={() => launch()}
>Create server</Button
>{/if}{/snippet}
</EmptyState>
{:else}
{#if view === 'cards' && problems.length}
<section class="attention" aria-labelledby="attention-heading">
<h2 id="attention-heading">
<span aria-hidden="true">▲</span> Needs attention
<span class="count">{problems.length}</span>
</h2>
{#each problems as server (server.name)}
{@const status = serverHealth(server, observationNow())}
{@const operation = attentionOperations.find(
(item) => item.server === server.name
)}
{@const operationIssue = operation && status.state === 'online'}
{@const actionLabel = operationIssue ? 'Review operation' : status.actionLabel}
<div class="attention-row">
<div class="attention-identity">
<a
class="server-link"
href={contextHref(
`/servers/${encodeURIComponent(server.name)}`,
$page.url
)}
><ServerIdentity
name={server.name}
mapLabel={fleetIdentity(server).map}
/></a
>
</div>
<StatusBadge state={operationIssue ? 'attention' : status.state} />
<p class="attention-evidence">
<span class="detailed-reason"
>{operationIssue ? operation.label : status.reason}</span
><span class="concise-reason"
>{operationIssue ? operation.label : status.shortReason}</span
>{#if status.since}<span class="evidence-time" title={status.since}
><span class="evidence-separator"> · </span>Evidence {elapsedSince(
status.since,
$uiNow
)} ago</span
>{/if}
</p>
<div class="attention-actions">
<button
class="attention-pin"
aria-pressed={pinned.includes(server.name)}
aria-label={`${pinned.includes(server.name) ? 'Unpin' : 'Pin'} ${serverTitle(server.name)}`}
onclick={() => togglePin(server.name)}
>{pinned.includes(server.name) ? '★' : '☆'}</button
>
<a
class="attention-action"
href={contextHref(
operationIssue ? operation.href : status.actionHref,
$page.url
)}
aria-label={`${actionLabel}: ${serverTitle(server.name)}`}
><span class="action-label">{actionLabel}</span><span
class="action-arrow"
aria-hidden="true">→</span
></a
>
</div>
</div>
{/each}
</section>
{/if}
<div class="toolbar">
<div class="search-row">
<Field class="search" hiddenLabel label="Search servers, maps, or players">
<Input type="search" bind:value={query} placeholder="Search fleet…" /></Field
>
<button
class="control"
aria-expanded={viewOptionsOpen}
aria-controls="fleet-view-options"
onclick={() => (viewOptionsOpen = !viewOptionsOpen)}
>View options{game ? ' · filtered' : ''}</button
>
</div>
{#if viewOptionsOpen}<div id="fleet-view-options" class="view-options">
<Field label="Game">
<Select bind:value={game}
><option value="">All games</option><option value="t5"
>Black Ops I</option
><option value="t6">Black Ops II</option></Select
></Field
>
<Field label="Group by">
<Select bind:value={grouping}
><option value="none">None</option><option value="map">Map</option
><option value="game">Game</option></Select
></Field
>
<Field label="Sort by">
<Select bind:value={sort}
><option value="duration">Longest match</option><option value="players"
>Most players</option
><option value="round">Highest round</option><option value="name"
>Lobby name</option
></Select
></Field
>
<div class="view-toggle" role="group" aria-label="Display mode">
<button
class="control"
class:selected={view === 'cards'}
aria-pressed={view === 'cards'}
onclick={() => (view = 'cards')}>Cards</button
><button
class="control"
class:selected={view === 'table'}
aria-pressed={view === 'table'}
onclick={() => (view = 'table')}>Table</button
>
</div>
</div>{/if}
<div class="filter-row">
<div class="chips" role="radiogroup" aria-label="Filter servers">
{#each filters as item}
{@const count = servers.filter((server) =>
matchesView(server, '', item.id)
).length}
<label class="filter-chip" class:selected={filter === item.id}>
<input
type="radio"
name="fleet-filter"
value={item.id}
bind:group={filter}
aria-label={`${item.label}, ${count} ${plural(count, 'server')}`}
/>
{item.label}<span> {count}</span>
</label>
{/each}
</div>
{#if query || filter !== 'all' || game}<button
class="text-button"
onclick={resetFilters}>Clear filters</button
>{/if}
</div>
</div>
{#if filtered.length === 0}<EmptyState
title="No matching servers"
description="Try another player or map, or clear your filters."
>
{#snippet actions()}<Button onclick={resetFilters}>Clear filters</Button>{/snippet}
</EmptyState>{/if}
{#if view === 'cards'}
{#each sections as section (section.title)}
<section class="fleet-section" aria-label={section.title}>
<div class="section-heading">
<h2>{section.title} <span class="count">{section.servers.length}</span></h2>
{#if section.compact && canOperate}<Button
size="sm"
variant="ghost"
onclick={() => launch()}>Add lobby</Button
>{/if}
</div>
{#each grouped(section.servers, section.compact ? 'none' : grouping) as [key, group] (key)}
{#if group.title}<div class="group-heading">
<h3>{group.title}</h3>
{#if canOperate && group.launch}<Button
size="sm"
variant="ghost"
onclick={() => launch(group.launch)}>Add lobby</Button
>{/if}
</div>{/if}
<div class="cards" class:compact={section.compact}>
{#each group.servers as server (server.name)}
<div class="server-tile">
<ServerCard
name={server.name}
telemetry={server.telemetry}
players={server.last_players}
map={server.last_map ?? server.map}
game={server.game}
mapSlug={server.map_slug}
gameMode={server.game_mode}
capacity={currentCapacity(server, observationNow())}
gametype={server.last_gametype}
roster={server.roster}
health={server.health}
lastSeenAt={server.last_seen_at}
lastError={server.last_error}
held={server.held}
activeSince={server.active_since}
inactiveSince={server.inactive_since}
gameStartedAt={server.game_started_at}
gameEndedAt={server.game_ended_at}
compactEmpty
round={currentRound(
server,
$telemetryByServer.get(server.name),
observationNow()
)}
pinned={pinned.includes(server.name)}
onPin={() => togglePin(server.name)}
longestMatch={spotlight?.name === server.name}
mapLabelOverride={fleetIdentity(server).map}
/>
{@render serverOperations(server)}
</div>
{/each}
</div>
{/each}
</section>
{/each}
{:else if filtered.length}
<div class="table-scroll">
<table>
<caption class="sr-only">Fleet servers</caption>
<thead
><tr
><th scope="col">Server</th><th scope="col">Status</th><th scope="col"
>Players</th
><th scope="col">Round</th><th scope="col">Match time</th><th
scope="col">Operation</th
><th scope="col">Pin</th></tr
></thead
>
{#each groups as [key, group] (key)}<tbody>
{#if group.title}<tr class="table-group"
><th colspan="7" scope="rowgroup"
>{group.title} · {group.servers.length}
{group.servers.length === 1 ? 'server' : 'servers'}</th
></tr
>{/if}
{#each group.servers as server (server.name)}
{@const round = currentRound(
server,
$telemetryByServer.get(server.name),
observationNow()
)}
<tr
><td
><a
class="server-link"
href={contextHref(
`/servers/${encodeURIComponent(server.name)}`,
$page.url
)}
><ServerIdentity
name={server.name}
mapLabel={fleetIdentity(server).map}
/></a
></td
>
<td
><StatusBadge
state={serverHealth(server, observationNow()).state}
/></td
><td
>{freshServer(server, observationNow())
? (server.last_players ?? '—')
: '—'}</td
>
<td>{round ?? '—'}</td><td
>{matchDuration(server) >= 0
? elapsedSince(server.game_started_at, $uiNow)
: '—'}</td
>
<td>{@render serverOperations(server)}</td><td
><button
class="control pin"
aria-pressed={pinned.includes(server.name)}
aria-label={`Pin ${serverTitle(server.name)}`}
onclick={() => togglePin(server.name)}
>{pinned.includes(server.name) ? '★' : '☆'}</button
></td
></tr
>
{/each}
</tbody>{/each}
</table>
</div>
{/if}
<footer class="fleet-footer">
<span>{filtered.length} of {servers.length} {plural(servers.length, 'server')}</span>
</footer>
{/if}
</section>
{#snippet serverOperations(server: FleetServer)}
{@const operations = activeOperations.filter((op) => op.server === server.name)}
{#if operations.length}<div class="server-operations">
{#each operations as operation (operation.key)}
<a
href={contextHref(operation.href, $page.url)}
class:issue={operation.state === 'attention'}>{operation.label} →</a
>
{/each}
</div>{/if}
{/snippet}
<style>
.page {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
min-width: 0;
}
.page-heading,
.overview,
.section-heading,
.group-heading,
.fleet-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.overview {
justify-content: flex-start;
gap: var(--space-2) var(--space-5);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.overview strong {
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.freshness {
margin-left: auto;
color: var(--text-tertiary);
font-size: var(--text-xs);
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.metric-link,
.text-button {
color: var(--accent-primary-hover);
background: none;
border: 0;
padding: 0;
text-decoration: underline;
text-underline-offset: 3px;
}
.setup-note,
.error {
display: flex;
gap: var(--space-3);
flex-wrap: wrap;
padding: var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.error {
border-color: var(--state-warning);
}
.error button {
color: var(--accent-primary-hover);
background: none;
border: 0;
text-decoration: underline;
}
.toolbar,
.search-row,
.filter-row,
.chips {
min-width: 0;
max-width: 100%;
}
.toolbar {
display: grid;
gap: var(--space-3);
}
.search-row,
.filter-row {
display: flex;
gap: var(--space-3);
align-items: center;
}
.search-row :global(.search) {
flex: 1;
min-width: 0;
}
input {
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
min-height: 40px;
padding: var(--space-2) var(--space-3);
max-width: 100%;
font-size: var(--text-sm);
}
.control,
.filter-chip {
color: var(--text-secondary);
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
white-space: nowrap;
}
.control {
min-height: 40px;
}
.view-options {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
background: var(--surface);
border-radius: var(--radius-md);
}
.view-options :global(.field) {
flex: 1 1 130px;
}
.chips,
.view-toggle {
display: flex;
gap: var(--space-2);
}
.chips {
flex-wrap: wrap;
}
.filter-chip {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
cursor: pointer;
}
.filter-chip input {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
padding: 0;
opacity: 0;
}
.filter-chip:focus-within {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 3px;
}
.selected {
color: var(--accent-primary-hover);
border-color: var(--accent-primary);
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface));
}
.filter-chip span,
.count {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
font-weight: var(--weight-regular);
}
h2,
h3 {
margin: 0;
font-size: var(--text-md);
font-weight: var(--weight-semibold);
}
h2 .count {
margin-left: var(--space-2);
font-size: var(--text-sm);
}
h3 {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.attention {
border: 1px solid color-mix(in srgb, var(--state-warning) 45%, var(--border-subtle));
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--surface);
}
.attention h2 {
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
color: var(--state-warning);
}
.attention-row {
display: grid;
grid-template-columns: minmax(150px, 1fr) auto minmax(180px, 1.5fr) auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border-subtle);
}
.attention-identity {
display: flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.attention-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-1);
}
.attention-pin {
color: var(--text-secondary);
background: none;
border: 0;
padding: var(--space-2);
font-size: var(--text-md);
width: 44px;
min-height: 44px;
flex-shrink: 0;
border-radius: var(--radius-sm);
}
.attention-pin:hover,
.attention-pin[aria-pressed='true'] {
color: var(--accent-primary-hover);
background: var(--surface-raised);
}
.attention-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-raised);
color: var(--text-primary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
text-decoration: none;
}
.attention-action:hover {
background: var(--surface-overlay);
border-color: var(--border-strong);
}
.attention-pin:focus-visible,
.attention-action:focus-visible {
outline: 2px solid var(--accent-primary-hover);
outline-offset: 2px;
}
.concise-reason,
.action-arrow {
display: none;
}
.attention-row p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: var(--leading-body);
}
.attention-row .evidence-time {
white-space: nowrap;
}
.server-link {
color: var(--text-primary);
text-decoration: none;
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
}
.server-link:hover {
text-decoration: underline;
}
.fleet-section {
display: grid;
gap: var(--space-3);
min-width: 0;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(285px, 100%), 1fr));
gap: var(--space-3);
align-items: start;
}
.cards.compact {
grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr));
}
.server-tile {
min-width: 0;
}
.server-operations {
display: grid;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
}
.server-operations a {
text-decoration: none;
}
.server-operations a:hover {
text-decoration: underline;
}
.server-operations .issue {
color: var(--state-warning);
}
.table-scroll {
position: relative;
overflow-x: auto;
max-width: 100%;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
background: var(--surface);
font-size: var(--text-sm);
font-variant-numeric: tabular-nums;
}
th,
td {
padding: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
}
thead th {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.table-group {
background: var(--surface-raised);
}
.table-group th {
font-weight: var(--weight-medium);
font-size: var(--text-sm);
}
td .server-operations {
padding: 0;
}
.pin {
font-size: var(--text-md);
}
.fleet-footer {
color: var(--text-tertiary);
font-size: var(--text-xs);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
@media (max-width: 1100px) {
.attention-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.attention-row p {
grid-column: 1;
}
}
@media (max-width: 640px) {
.page {
gap: var(--space-3);
}
.overview {
gap: var(--space-1) var(--space-3);
font-size: var(--text-xs);
}
.overview .metric-link {
display: none;
}
.freshness {
margin-left: 0;
}
.filter-row {
flex-wrap: wrap;
}
.chips {
flex-wrap: nowrap;
overflow-x: auto;
width: 100%;
padding: 3px;
}
.filter-chip {
flex-shrink: 0;
min-height: 44px;
}
.attention-row {
gap: var(--space-1) var(--space-2);
padding: var(--space-2) var(--space-3);
}
.attention-identity .server-link {
display: flex;
align-items: center;
min-height: 44px;
}
.attention-identity :global(small),
.detailed-reason,
.action-label,
.evidence-separator {
display: none;
}
.concise-reason {
display: inline;
}
.attention-row .attention-evidence {
grid-column: 1;
overflow-wrap: anywhere;
}
.attention-row .evidence-time {
display: block;
white-space: normal;
}
.attention-actions {
grid-column: 2;
grid-row: 2;
align-self: center;
}
.attention-action {
width: 44px;
padding: 0;
flex-shrink: 0;
}
.action-arrow {
display: inline;
font-size: var(--text-lg);
}
.attention h2 {
padding: var(--space-3);
}
.cards {
grid-template-columns: 1fr;
}
}
</style>