380 lines
30 KiB
Svelte
380 lines
30 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(() => { $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));
|
|
let empty = $derived(healthy.filter(server => server.last_players === 0).length);
|
|
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>
|
|
<button class="metric-link" onclick={() => { resetFilters(); filter = 'empty'; }}>{empty} empty {empty === 1 ? 'lobby' : 'lobbies'}</button>
|
|
{#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><a href="/maintenance?view=versions">Plutonium versions →</a></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>
|