Files
electric-cherry/src/routes/+page.svelte
T

346 lines
28 KiB
Svelte

<script module lang="ts">
const serverTitleCollator = new Intl.Collator(undefined, { numeric: true });
</script>
<script lang="ts">
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 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;
});
$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 || 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())));
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.by(() => {
const now = observationNow();
const pinnedNames = new Set(pinned);
return servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, now))
.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) => b.pinned - a.pinned || b.rank - a.rank || serverTitleCollator.compare(a.title, b.title))
.map(({ server }) => server);
});
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), 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>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 && !data.fleetSnapshot}
{#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 integrations?.native && !integrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {integrations.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 && !integrations.native && (!integrations.komodo || !integrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!integrations.komodo ? 'Komodo' : ''}{!integrations.komodo && !integrations.rcon ? ' and ' : ''}{!integrations.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>