Files
electric-cherry/src/routes/+page.svelte
T
josh e5c0fda1ed Add native host management and role-based operator authentication
Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
2026-10-03 11:42:36 -04:00

332 lines
28 KiB
Svelte

<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, replaceState } from '$app/navigation';
import { page } from '$app/stores';
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
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 StatusPip from '$ui/components/primitives/StatusPip.svelte';
import { fleet, fleetServers, fleetError, fleetRefreshing, 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 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';
let query = $state('');
let filter = $state<FleetFilter>('all');
let game = $state('');
let sort = $state<FleetSort>('name');
let view = $state<'cards' | 'table'>('cards');
let grouping = $state<'map' | 'game' | 'none'>('none');
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: 'Occupied' }, { id: 'empty', label: 'Empty' },
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' }
];
function readViewUrl() {
const params = new URL(window.location.href).searchParams;
query = params.get('q') ?? '';
if (params.get('view') === 'cards' || params.get('view') === 'table') view = params.get('view') as typeof view;
if (['map', 'game', 'none'].includes(params.get('group') ?? '')) grouping = params.get('group') as typeof grouping;
if (['name', 'players', 'round', 'duration'].includes(params.get('sort') ?? '')) sort = params.get('sort') as FleetSort;
if (filters.some(item => item.id === params.get('filter'))) filter = params.get('filter') as FleetFilter;
if (params.has('game') && ['', 't5', 't6'].includes(params.get('game')!)) game = params.get('game')!;
}
afterNavigate(() => { routerReady = true; if (preferencesReady) readViewUrl(); });
onMount(() => {
try {
const saved = JSON.parse(localStorage.getItem('electric-cherry:fleet') ?? '{}');
if (saved.view === 'cards' || saved.view === 'table') view = saved.view;
if (['map', 'game', 'none'].includes(saved.grouping)) grouping = saved.grouping;
if (['name', 'players', 'round', 'duration'].includes(saved.sort)) sort = saved.sort;
if (filters.some(item => item.id === saved.filter)) filter = saved.filter;
if (['', 't5', 't6'].includes(saved.game)) game = saved.game;
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;
void refreshFleet();
});
$effect(() => {
if (!preferencesReady) return;
try { localStorage.setItem('electric-cherry:fleet', JSON.stringify({ view, grouping, sort, filter, game, 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);
for (const [key, value] of Object.entries(values)) url.searchParams.set(key, value);
if (url.href !== window.location.href) replaceState(url, $page.state);
});
});
const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - $fleet.receivedAt) / 1000)); });
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow)
? { ...server, health: 'stale' as const } : server));
let activeOperations = $derived($fleet.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, $uiNow)));
let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0));
let empty = $derived(healthy.filter(server => server.last_players === 0).length);
let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale'));
let starting = $derived(servers.filter(server => server.health === 'starting').length);
let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length);
let filtered = $derived(servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, $uiNow))
.sort((a, b) => {
const pinOrder = Number(pinned.includes(b.name)) - Number(pinned.includes(a.name));
if (pinOrder) return pinOrder;
const rank = (server: FleetServer) => sort === 'players' ? (freshServer(server, $uiNow) ? server.last_players ?? -1 : -1)
: sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), $uiNow) ?? -1
: sort === 'duration' ? matchDuration(server) : 0;
return rank(b) - rank(a) || serverTitle(a.name).localeCompare(serverTitle(b.name), undefined, { numeric: true });
}));
let groups = $derived.by(() => {
const result = new Map<string, { title: string; game: string; servers: FleetServer[]; launch: ReturnType<typeof fleetIdentity>['launch'] }>();
for (const server of filtered) {
const identity = fleetIdentity(server);
const key = grouping === 'map' ? identity.group : grouping === 'game' ? identity.game : 'all';
if (!result.has(key)) result.set(key, { title: grouping === 'map' ? identity.map : grouping === 'game'
? identity.game.toUpperCase() || 'Other servers' : 'All servers', game: identity.game,
servers: [], launch: grouping === 'map' ? identity.launch : null });
result.get(key)!.servers.push(server);
}
return [...result.entries()].sort(([, a], [, b]) => a.title.localeCompare(b.title));
});
let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), $uiNow) !== null)
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]);
function matchDuration(server: FleetServer): number {
return freshServer(server, $uiNow) && server.active_since && server.game_started_at && !server.game_ended_at
&& (server.last_players ?? 0) > 0 ? Math.max(0, $uiNow - 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>Fleet · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader title="Fleet" subtitle="Live health, players, and server operations.">
{#snippet actions()}
<Button variant="ghost" size="sm" loading={$fleetRefreshing} onclick={() => void refreshFleet()}>Refresh</Button>
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
{/snippet}
</SectionHeader>
{#if canOperate}<DeployServerDialog bind:open={deployOpen} {initialGame} {initialMapSlug} />{/if}
{#if $fleetError}
<div class="error" role="alert"><strong>{$fleet.receivedAt ? 'Showing the last available snapshot.' : 'Fleet is unavailable.'}</strong> {$fleetError}
<button onclick={() => void refreshFleet()}>Retry now</button></div>
{/if}
{#if !$fleet.receivedAt}
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
{:else}
{#if servers.length}<div class="overview" aria-label="Fleet summary">
<span><strong>{healthy.length ? players : '—'}</strong> players online</span>
<span><strong>{healthy.length}/{servers.length}</strong> healthy</span>
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}><strong>{empty}</strong> empty lobbies</button>
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
</div>{/if}
{#if $fleetIntegrations?.native && !$fleetIntegrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {$fleetIntegrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !$fleetIntegrations.native && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
{/if}
{#if problems.length || attentionOperations.length || starting || provisioning}
<div class="attention" class:has-issues={problems.length > 0 || attentionOperations.length > 0}>
<span class="attention-mark" aria-hidden="true">{problems.length || attentionOperations.length ? '!' : '✓'}</span>
<div class="attention-copy">
{#if problems.length}
<button onclick={() => { resetFilters(); filter = 'attention'; }}>{problems.length} {problems.length === 1 ? 'server needs' : 'servers need'} attention →</button>
{:else}<strong>{provisioning ? `${provisioning} ${provisioning === 1 ? 'server is' : 'servers are'} provisioning` : servers.length ? starting ? `${starting} ${starting === 1 ? 'server is' : 'servers are'} starting` : 'All servers healthy' : 'Preparing your fleet'}</strong>{/if}
</div>
{#if attentionOperations.length}<button onclick={() => { resetFilters(); filter = 'attention'; }}>{attentionOperations.length} operation {attentionOperations.length === 1 ? 'needs' : 'need'} attention →</button>{/if}
</div>
{/if}
{#if spotlight}
<a class="spotlight" href="/servers/{encodeURIComponent(spotlight.name)}">
<div><span class="eyebrow">Longest running match</span><strong>{serverTitle(spotlight.name)}</strong></div>
<span class="round">Round {currentRound(spotlight, $telemetryByServer.get(spotlight.name), $uiNow)}</span>
<span>{spotlight.last_players} {spotlight.last_players === 1 ? 'survivor' : 'survivors'} · {elapsedSince(spotlight.game_started_at, $uiNow)} <span aria-hidden="true">→</span></span>
</a>
{/if}
{#if servers.length === 0}
<EmptyState title="No servers deployed">
{#snippet actions()}
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create a server</Button>{/if}
<Button href="/collections" variant="ghost">Browse collections</Button>
{/snippet}
</EmptyState>
{:else}
<div class="toolbar">
<div class="search-row">
<label class="search"><span class="sr-only">Find a lobby or player</span><input type="search" bind:value={query} placeholder="Search lobbies, maps, or players…" /></label>
<button class="view-options-toggle" 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">
<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></label>
<label>Group by<select bind:value={grouping}><option value="map">Map</option><option value="game">Game</option><option value="none">None</option></select></label>
<label>Sort by<select bind:value={sort}><option value="name">Lobby name</option><option value="players">Most players</option><option value="round">Highest round</option><option value="duration">Longest match</option></select></label>
<div class="view-toggle" aria-label="Display mode"><button class:selected={view === 'cards'} aria-pressed={view === 'cards'} onclick={() => view = 'cards'}>Cards</button><button class:selected={view === 'table'} aria-pressed={view === 'table'} onclick={() => view = 'table'}>Table</button></div>
</div>{/if}
<div class="filter-row">
<div class="chips" aria-label="Filter servers">{#each filters as item}
<button class:selected={filter === item.id} aria-pressed={filter === item.id} onclick={() => filter = item.id}>{item.label}<span>{servers.filter(server => matchesFleet(server, '', item.id, game, pinned, activeOperations, $uiNow)).length}</span></button>
{/each}</div>
</div>
<div class="result-count"><span role="status">Showing {filtered.length} of {servers.length} servers</span>{#if query || filter !== 'all' || game}<button onclick={resetFilters}>Clear filters</button>{/if}</div>
</div>
{#if filtered.length === 0}<EmptyState title="No matching lobbies" description="Try a different player or map, or clear your filters." />{/if}
{#each groups as [key, group] (key)}
<section class="map-group" aria-label={group.title}>
{#if grouping !== 'none'}<div class="group-heading"><div>{#if grouping === 'map'}<span class="eyebrow">{group.game.toUpperCase()}</span>{/if}<h2>{group.title}</h2><p>{group.servers.length} {group.servers.length === 1 ? 'lobby' : 'lobbies'} · {group.servers.some(s => freshServer(s, $uiNow)) ? group.servers.reduce((sum, s) => sum + (freshServer(s, $uiNow) ? s.last_players ?? 0 : 0), 0) : '—'} players · {group.servers.filter(s => freshServer(s, $uiNow) && s.last_players === 0).length} empty</p></div>
{#if canOperate && group.launch}<Button size="sm" onclick={() => launch(group.launch)}>Add another lobby</Button>{/if}
</div>{/if}
{#if view === 'cards'}
<div class="cards">{#each group.servers as server (server.name)}
<article class="server-tile" class:pinned={pinned.includes(server.name)}>
<ServerCard name={server.name} telemetry={server.telemetry} players={server.last_players} map={server.last_map ?? server.map}
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
mapLabelOverride={fleetIdentity(server).map} />
{@render serverOperations(server)}
<div class="tile-actions">{@render serverActions(server)}</div>
</article>
{/each}</div>
{:else}
<div class="table-scroll"><table><caption class="sr-only">{group.title} servers</caption><thead><tr><th>Lobby</th><th>Status</th><th>Players</th><th>Round / match</th><th>Operation</th><th>Follow</th></tr></thead><tbody>
{#each group.servers as server (server.name)}
{@const round = currentRound(server, $telemetryByServer.get(server.name), $uiNow)}
<tr><td><a class="lobby-link" href="/servers/{encodeURIComponent(server.name)}"><ServerIdentity name={server.name} mapLabel={fleetIdentity(server).map} /></a></td>
<td><span class="health"><StatusPip health={server.health} />{server.health}</span></td><td>{freshServer(server, $uiNow) ? server.last_players ?? '—' : '—'}</td>
<td>{round !== null ? `Round ${round}` : '—'}{#if matchDuration(server) >= 0}<small>{elapsedSince(server.game_started_at, $uiNow)}</small>{/if}</td>
<td>{@render serverOperations(server, true)}</td><td><div class="table-actions">{@render serverActions(server)}</div></td></tr>
{/each}
</tbody></table></div>
{/if}
</section>
{/each}
{/if}
{/if}
</section>
{#snippet serverOperations(server: FleetServer, showEmpty = false)}
{@const operations = activeOperations.filter(op => op.server === server.name)}
{#if operations.length}<div class="server-operations">{#each operations as operation (operation.key)}
<a href={operation.href} class:issue={operation.state === 'attention'} title={operation.detail}><span>{operation.label} →</span><small>{operation.detail}</small></a>
{/each}</div>{:else if showEmpty}<span class="hint">No pending work</span>{/if}
{/snippet}
{#snippet serverActions(server: FleetServer)}
<RuntimeVersion name={server.name} version={server.plutonium} after={server.telemetry_after} ready={server.health === 'online'} />
<button class="pin" class:selected={pinned.includes(server.name)} aria-pressed={pinned.includes(server.name)} aria-label="Pin {serverTitle(server.name)}" onclick={() => togglePin(server.name)}>{pinned.includes(server.name) ? '★ Pinned' : '☆ Pin'}</button>
{/snippet}
<style>
.freshness { margin-left: auto; font-size: var(--text-xs); color: var(--text-tertiary); }
.setup-note { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); }
.setup-note strong { color: var(--text-primary); }
@media (max-width: 600px) { .freshness { flex-basis: 100%; margin-left: 0; } .no-campaigns { display: none; } }
.page { display: flex; flex-direction: column; gap: var(--space-4); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:focus-visible, a:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
.overview { display: flex; align-items: center; flex-wrap: wrap; padding: 12px 16px; gap: 8px 24px; background: var(--surface); border-radius: var(--radius-md); border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: var(--text-sm); }
.overview strong { color: var(--text-primary); font-variant-numeric: tabular-nums; margin-right: 4px; }
.view-options { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; padding: 16px; background: var(--surface); border-radius: var(--radius-md); }
.view-options label { flex: 1 1 140px; }
.view-options-toggle { background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); padding: 8px 12px; font-size: var(--text-sm); }
.overview > * { min-width: 0; }
.metric-link { text-align: left; color: inherit; text-decoration: none; background: none; border: 0; padding: 0; }
.hint { display: block; color: var(--text-secondary); font-size: 12px; margin-top: 6px; }
.attention { display: flex; gap: 14px; align-items: center; padding: 16px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.attention-mark { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 50%; background: var(--surface-raised); color: var(--state-online); }
.has-issues { border-color: color-mix(in srgb, var(--state-warning) 35%, transparent); }
.has-issues .attention-mark { color: var(--state-warning); }
.attention-copy { flex: 1; display: flex; flex-direction: column; gap: 5px; }
.attention button, .result-count button, .error button { background: none; color: var(--accent-primary-hover); border: 0; padding: 0; text-align: left; }
.spotlight { display: flex; align-items: center; flex-wrap: wrap; gap: 20px; padding: 12px 16px; background: linear-gradient(110deg, color-mix(in srgb, var(--accent-primary) 15%, var(--surface)), var(--surface)); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); color: var(--text-primary); text-decoration: none; }
.spotlight > div { flex: 1; min-width: 190px; }
.spotlight strong { display: block; margin-top: 6px; }
.spotlight > span:last-child { color: var(--text-secondary); font-size: 13px; }
.round { font-family: var(--font-mono); font-size: 23px; color: var(--state-online); }
.eyebrow { color: var(--text-secondary); text-transform: uppercase; font-size: var(--text-xs); letter-spacing: .12em; font-weight: 600; }
.toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
.search-row { display: flex; gap: 12px; flex-wrap: wrap; }
label { display: flex; flex-direction: column; gap: 8px; color: var(--text-secondary); font-size: 12px; }
.search { flex: 1; min-width: min(280px, 100%); }
input, select { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); border-radius: var(--radius-md); min-height: 42px; padding: 10px 12px; max-width: 100%; }
input::placeholder { color: var(--text-secondary); }
.filter-row, .result-count { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.chips, .view-toggle { display: flex; gap: 6px; flex-wrap: wrap; }
.chips button, .view-toggle button, .pin { background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 8px 12px; color: var(--text-secondary); font-size: 12px; }
.chips button span { margin-left: 8px; opacity: .75; font-variant-numeric: tabular-nums; }
button.selected { background: color-mix(in srgb, var(--accent-primary) 15%, var(--surface)); color: var(--accent-primary-hover); border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.result-count { justify-content: flex-start; font-size: 12px; color: var(--text-secondary); }
.map-group { display: flex; flex-direction: column; gap: 16px; }
.group-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { margin: 4px 0 6px; font-size: 20px; letter-spacing: -.03em; }
.group-heading p { margin: 0; color: var(--text-secondary); font-size: 12px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(310px, 100%), 1fr)); gap: 16px; align-items: start; }
.server-tile { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface); }
.server-tile.pinned { border-color: color-mix(in srgb, var(--accent-primary) 50%, transparent); }
.server-tile :global(.card) { border-radius: 0; }
.tile-actions { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 16px; border-top: 1px solid var(--border-subtle); }
.pin { white-space: nowrap; }
.server-operations { display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; font-size: 12px; }
.server-operations a { text-decoration: none; color: var(--accent-primary-hover); overflow-wrap: anywhere; }
.server-operations small { display: block; margin-top: 5px; color: var(--text-secondary); }
.server-operations .issue { color: var(--state-warning); }
.error { padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); font-size: 13px; line-height: 1.7; }
.error button { margin-left: 8px; }
.loading { padding: 70px 20px; text-align: center; color: var(--text-secondary); background: var(--surface); border-radius: var(--radius-lg); }
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: 13px; text-align: left; }
th { color: var(--text-secondary); font-weight: 500; white-space: nowrap; font-size: 12px; }
th, td { padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); }
tr:last-child td { border-bottom: 0; }
td small { display: block; color: var(--text-secondary); margin-top: 6px; }
td .server-operations { padding: 0; min-width: 180px; }
.lobby-link { color: var(--text-primary); text-decoration: none; font-weight: 600; }
.health { display: flex; align-items: center; gap: 8px; text-transform: capitalize; }
.table-actions { display: flex; align-items: center; gap: 8px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@media (max-width: 900px) { .overview { grid-template-columns: repeat(2, 1fr); } .attention { flex-wrap: wrap; } }
@media (max-width: 600px) { .page { gap: 20px; } .overview { padding: 18px; gap: 22px 16px; } .search-row > label:not(.search) { flex: 1; min-width: 100px; } .search { flex-basis: 100%; } .group-heading { align-items: flex-start; } .spotlight { padding: 18px; gap: 12px; } .spotlight > div { flex-basis: 100%; } }
@media (max-width: 600px) { .overview { padding: 10px 12px; gap: 8px 16px; } .search-row { flex-wrap: nowrap; } .search { flex-basis: auto; min-width: 0; } .view-options-toggle { flex-shrink: 0; } .chips { flex-wrap: nowrap; overflow-x: auto; padding: 3px; width: 100%; } .chips button { white-space: nowrap; flex-shrink: 0; } .attention { padding: 10px 12px; font-size: 13px; } .spotlight { padding:12px; gap:8px; } .spotlight > div { min-width:0; } .page { gap: 16px; } .group-heading p { display: none; } }
</style>