1109 lines
41 KiB
Svelte
1109 lines
41 KiB
Svelte
<script module lang="ts">
|
|
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[]) {
|
|
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 =
|
|
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 === 't5'
|
|
? 'Black Ops I'
|
|
: identity.game === 't6'
|
|
? 'Black Ops II'
|
|
: 'Other servers'
|
|
: '',
|
|
servers: [],
|
|
launch: grouping === '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">
|
|
<label class="search"
|
|
><span class="sr-only">Search servers, maps, or players</span><input
|
|
type="search"
|
|
bind:value={query}
|
|
placeholder="Search fleet…"
|
|
/></label
|
|
>
|
|
<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">
|
|
<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="none">None</option><option value="map">Map</option
|
|
><option value="game">Game</option></select
|
|
></label
|
|
>
|
|
<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
|
|
></label
|
|
>
|
|
<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>
|
|
</div>
|
|
{#each grouped(section.servers) 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}
|
|
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,
|
|
select {
|
|
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 {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.search input {
|
|
width: 100%;
|
|
}
|
|
input,
|
|
select {
|
|
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 label {
|
|
display: grid;
|
|
gap: var(--space-2);
|
|
flex: 1 1 130px;
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.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: 1000px) {
|
|
.attention-row {
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
}
|
|
.attention-row p {
|
|
grid-column: 1;
|
|
}
|
|
}
|
|
@media (max-width: 600px) {
|
|
.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>
|