Add sidebar navigation and global command search

This commit is contained in:
2026-10-07 18:59:39 -04:00
parent 9999e80226
commit e4f37f6964
33 changed files with 660 additions and 151 deletions
+18 -18
View File
@@ -5,34 +5,34 @@ export interface NavigationGroup { id: string; label: string; admin?: boolean; i
export const navigationGroups: NavigationGroup[] = [
{ id: 'fleet', label: 'Fleet', items: [
{ label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
{ label: 'Collections', href: '/collections' },
{ label: 'Autoscaling', href: '/autoscaling' },
{ label: 'Servers', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) },
{ label: 'Capacity', href: '/capacity' }
{ label: 'Capacity', href: '/capacity' },
{ label: 'Autoscaling', href: '/autoscaling' },
{ label: 'Collections', href: '/collections' }
] },
{ id: 'library', label: 'Library', items: [
{ id: 'releases', label: 'Releases', items: [
{ label: 'Scripts', href: '/scripts', shortcut: 's' },
{ label: 'Configs', href: '/configs', shortcut: 'c' },
{ label: 'Plugins', href: '/plugins', shortcut: 'p' },
{ label: 'Game Files', href: '/game-files' }
] },
{ id: 'operations', label: 'Operations', items: [
{ label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Game files', href: '/game-files' },
{ label: 'Betas', href: '/betas', shortcut: 'b' },
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) },
{ label: 'Host operation history', href: '/hosts/operations' }
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
] },
{ id: 'analytics', label: 'Analytics', items: [
{ label: 'Metrics', href: '/metrics', shortcut: 'm' },
{ id: 'activity', label: 'Activity', items: [
{ label: 'Operations', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Host operations', href: '/hosts/operations' },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
] },
{ id: 'players', label: 'Players', items: [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Match History', href: '/matches' }
{ label: 'Matches', href: '/matches' },
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
] },
{ id: 'administration', label: 'Administration', admin: true, items: [
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' },
{ id: 'admin', label: 'Admin', admin: true, items: [
{ label: 'Users', href: '/settings/users' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
{ label: 'Users', href: '/settings/users' }
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }
] }
];
+88
View File
@@ -0,0 +1,88 @@
import type { UserRole } from '../auth.js';
import { visibleNavigation } from '../navigation.js';
import { playerProfileHref } from '../leaderboard.js';
import { serverTitle } from './server-labels.js';
export type PaletteGroup = 'Pages' | 'Actions' | 'Servers' | 'Hosts' | 'Players' | 'Matches';
export interface PaletteItem { key: string; group: PaletteGroup; label: string; detail: string; href: string }
export interface PaletteServer { name: string; map?: string | null; last_map?: string | null; host?: { name: string } | null }
export interface PaletteHost { id: string; name: string; hostname?: string; connectionState?: string }
export interface PaletteDirectory { servers: PaletteServer[]; hosts: PaletteHost[]; unavailable: string[] }
type PaletteFetch = (input: string, init: RequestInit) => Promise<Response>;
export const paletteGroups: PaletteGroup[] = ['Actions', 'Pages', 'Servers', 'Hosts', 'Players', 'Matches'];
function matches(query: string, ...values: Array<string | null | undefined>) {
const text = values.filter(Boolean).join(' ').toLocaleLowerCase();
return query.trim().toLocaleLowerCase().split(/\s+/).every(word => text.includes(word));
}
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }];
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
return [...actions.filter(item => matches(query, item.label, item.detail)),
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
key: `page:${item.href}`, group: 'Pages' as const, label: item.label, detail: group.label, href: item.href
})))];
}
export function paletteDirectoryResults(query: string, directory: PaletteDirectory | null): PaletteItem[] {
if (!query.trim() || !directory) return [];
const servers: PaletteItem[] = directory.servers.filter(server => matches(query, serverTitle(server.name), server.name, server.map, server.last_map, server.host?.name))
.slice(0, 6).map(server => ({ key: `server:${server.name}`, group: 'Servers', label: serverTitle(server.name),
detail: server.host?.name ?? 'Game server', href: `/servers/${encodeURIComponent(server.name)}` }));
const hosts: PaletteItem[] = directory.hosts.filter(host => matches(query, host.name, host.hostname, host.id))
.slice(0, 6).map(host => ({ key: `host:${host.id}`, group: 'Hosts', label: host.name,
detail: host.connectionState === 'offline' ? 'Host agent offline' : 'Host', href: `/hosts/${encodeURIComponent(host.id)}` }));
return [...servers, ...hosts];
}
/** Match IDs can be opened directly without downloading match history. */
export function paletteMatchJump(query: string): PaletteItem[] {
const id = /^(?:match\s*#?\s*|#)?([1-9]\d*)$/i.exec(query.trim())?.[1];
if (!id || !Number.isSafeInteger(Number(id))) return [];
return [{ key: `match:${id}`, group: 'Matches', label: `Open match #${id}`, detail: 'Go to a recorded match', href: `/matches/${id}` }];
}
async function read(url: string, signal: AbortSignal, fetcher: PaletteFetch) {
const response = await fetcher(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Search request failed.');
const body: unknown = await response.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Search response is unavailable.');
return body as Record<string, unknown>;
}
/** Load only the current directory; historical entities use bounded queries below. */
export async function loadPaletteDirectory(signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<PaletteDirectory> {
const [servers, hosts] = await Promise.allSettled([
read('/api/servers', signal, fetcher), read('/api/hosts', signal, fetcher)
]);
return {
servers: servers.status === 'fulfilled' && Array.isArray(servers.value.servers) ? servers.value.servers.filter(server => server && typeof server.name === 'string') : [],
hosts: hosts.status === 'fulfilled' && Array.isArray(hosts.value.hosts) ? hosts.value.hosts.filter(host => host && typeof host.id === 'string' && typeof host.name === 'string') : [],
unavailable: [servers.status === 'rejected' ? 'Server' : '', hosts.status === 'rejected' ? 'Host' : ''].filter(Boolean)
};
}
export async function searchPaletteRecords(query: string, signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise<{ items: PaletteItem[]; unavailable: string[] }> {
const term = query.trim().slice(0, 100);
if (term.length < 2) return { items: [], unavailable: [] };
const playersParams = new URLSearchParams({ q: term, limit: '5', metric: 'highestRound' });
const matchesParams = new URLSearchParams({ q: term, limit: '5', status: 'completed' });
const [players, recordedMatches] = await Promise.allSettled([
read(`/api/leaderboard?${playersParams}`, signal, fetcher), read(`/api/matches?${matchesParams}`, signal, fetcher)
]);
const items: PaletteItem[] = [];
if (players.status === 'fulfilled' && Array.isArray(players.value.entries)) {
for (const player of players.value.entries.slice(0, 5)) {
if (!player || typeof player.guid !== 'string' || typeof player.name !== 'string') continue;
items.push({ key: `player:${player.guid}`, group: 'Players', label: player.name, detail: 'Player profile', href: playerProfileHref(player.guid) });
}
}
if (recordedMatches.status === 'fulfilled' && Array.isArray(recordedMatches.value.matches)) {
for (const match of recordedMatches.value.matches.slice(0, 5)) {
if (!match || !Number.isSafeInteger(match.id) || match.id < 1 || match.status !== 'completed' || typeof match.map !== 'string' || typeof match.server !== 'string') continue;
items.push({ key: `match:${match.id}`, group: 'Matches', label: `${match.map} · Match #${match.id}`, detail: serverTitle(match.server), href: `/matches/${match.id}` });
}
}
return { items, unavailable: [players.status === 'rejected' ? 'Player' : '', recordedMatches.status === 'rejected' ? 'Match' : ''].filter(Boolean) };
}
@@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -288,7 +287,6 @@
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet}
</SectionHeader>
<BackLink fallback={basePath} />
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={contextHref(betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), $page.url)}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import type { UserRole } from '$lib/auth.js';
import Dialog from './primitives/Dialog.svelte';
import { loadPaletteDirectory, paletteDirectoryResults, paletteGroups, paletteMatchJump, palettePageResults, searchPaletteRecords,
type PaletteDirectory, type PaletteItem } from '$ui/command-palette.js';
let { open = $bindable(false), role }: { open?: boolean; role: UserRole } = $props();
const id = $props.id();
let input = $state<HTMLInputElement>();
let query = $state('');
let directory = $state<PaletteDirectory | null>(null);
let records = $state<PaletteItem[]>([]);
let directoryLoading = $state(false);
let recordsLoading = $state(false);
let recordErrors = $state<string[]>([]);
let selected = $state('');
let navigationError = $state('');
let navigating = $state(false);
const items = $derived.by(() => {
const unique = new Map<string, PaletteItem>();
for (const item of [...palettePageResults(role, query), ...paletteDirectoryResults(query, directory), ...paletteMatchJump(query), ...records]) unique.set(item.key, item);
return paletteGroups.flatMap(group => [...unique.values()].filter(item => item.group === group));
});
const activeIndex = $derived(Math.max(0, items.findIndex(item => item.key === selected)));
const unavailable = $derived([...(directory?.unavailable ?? []), ...recordErrors]);
const searching = $derived(directoryLoading || recordsLoading);
$effect(() => {
if (!open) { query = ''; records = []; directory = null; navigationError = ''; return; }
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
directoryLoading = true;
void loadPaletteDirectory(controller.signal).then(result => { if (!cancelled) directory = result; })
.finally(() => { clearTimeout(timeout); if (!cancelled) directoryLoading = false; });
void tick().then(() => { if (!cancelled) input?.focus(); });
return () => { cancelled = true; controller.abort(); clearTimeout(timeout); };
});
$effect(() => {
const term = query.trim();
selected = ''; records = []; recordErrors = [];
if (!open || term.length < 2) { recordsLoading = false; return; }
let cancelled = false;
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
recordsLoading = true;
const debounce = setTimeout(() => {
timeout = setTimeout(() => controller.abort(), 15_000);
void searchPaletteRecords(term, controller.signal).then(result => {
if (!cancelled) { records = result.items; recordErrors = result.unavailable; }
}).finally(() => { clearTimeout(timeout); if (!cancelled) recordsLoading = false; });
}, 220);
return () => { cancelled = true; clearTimeout(debounce); clearTimeout(timeout); controller.abort(); };
});
$effect(() => {
const option = activeIndex;
if (!open || !items.length) return;
void tick().then(() => document.getElementById(`${id}-option-${option}`)?.scrollIntoView({ block: 'nearest' }));
});
async function choose(item: PaletteItem) {
if (navigating) return;
navigating = true; navigationError = '';
try { await goto(item.href); open = false; }
catch { navigationError = 'Could not open that result. Try again.'; }
finally { navigating = false; }
}
function keydown(event: KeyboardEvent) {
if (!items.length || navigating) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const movement = event.key === 'ArrowDown' ? 1 : -1;
selected = items[(activeIndex + movement + items.length) % items.length].key;
} else if (event.key === 'Enter') {
event.preventDefault(); void choose(items[activeIndex]);
}
}
</script>
<Dialog bind:open title="Search and commands" description="Jump to a page, server, host, player, or match.">
<div class="palette">
<label class="sr-only" for={`${id}-search`}>Search Electric Cherry</label>
<input bind:this={input} bind:value={query} id={`${id}-search`} type="search" maxlength="100" placeholder="Search Electric Cherry…"
role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-results`}
aria-activedescendant={items.length ? `${id}-option-${activeIndex}` : undefined} aria-describedby={`${id}-hint`}
autocomplete="off" onkeydown={keydown} />
<p class="hint" id={`${id}-hint`}>Try a lobby, player name, or #match number. <span>↑ ↓ to choose · Enter to open</span></p>
<div class="results" id={`${id}-results`} role="listbox" aria-label="Search results" aria-busy={searching || navigating}>
{#each paletteGroups as group}
{@const groupItems = items.filter(item => item.group === group)}
{#if groupItems.length}
<div role="group" aria-labelledby={`${id}-${group}`}>
<div class="group-label" id={`${id}-${group}`}>{group}</div>
{#each groupItems as item (item.key)}
{@const index = items.indexOf(item)}
<button type="button" id={`${id}-option-${index}`} role="option" tabindex="-1" aria-selected={index === activeIndex}
class:active={index === activeIndex} disabled={navigating} onclick={() => void choose(item)} onpointermove={() => selected = item.key}>
<span class="result-label">{item.label}</span><span class="result-detail">{item.detail}</span>
</button>
{/each}
</div>
{/if}
{/each}
</div>
<p class="status" role="status">{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}</p>
{#if unavailable.length}<p class="error">{unavailable.join(', ')} search is unavailable. Other results are still available.</p>{/if}
{#if navigationError}<p class="error" role="alert">{navigationError}</p>{/if}
</div>
</Dialog>
<style>
.palette { display:grid; gap:var(--space-3); min-width:0; }
input { width:100%; min-height:44px; padding:var(--space-3); font:inherit; font-size:var(--text-base); color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); }
.hint,.status,.error { margin:0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.5; }
.hint span { display:block; }
.results { min-height:40px; max-height:min(55dvh,480px); overflow:auto; overscroll-behavior:contain; position:relative; }
.group-label { padding:var(--space-3) var(--space-2) var(--space-1); font-size:var(--text-xs); color:var(--text-tertiary); font-weight:var(--weight-semibold); }
button { display:grid; gap:var(--space-1); width:100%; min-height:48px; text-align:left; border:1px solid transparent; border-radius:var(--radius-sm); padding:var(--space-2) var(--space-3); background:transparent; color:var(--text-primary); font:inherit; cursor:pointer; }
button.active { background:var(--surface-raised); border-color:var(--accent-primary); }
button:disabled { cursor:wait; }
.result-label { font-size:var(--text-sm); overflow-wrap:anywhere; }
.result-detail { color:var(--text-secondary); font-size:var(--text-xs); overflow-wrap:anywhere; }
.error { color:var(--state-warning); }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
</style>
+37 -50
View File
@@ -1,61 +1,48 @@
<script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/stores';
import type { UserRole } from '$lib/auth.js';
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
import { navigationItemActive, visibleNavigation } from '$lib/navigation.js';
import { serverHealth } from '$ui/server-health.js';
import { fleet, fleetServers } from '$ui/stores/fleet.js';
import { uiNow } from '$ui/stores/servers.js';
let { role, variant = 'primary' }: { role: UserRole; variant?: 'primary' | 'secondary' } = $props();
let navigation = $state<HTMLElement>();
let { role, onNavigate }: { role: UserRole; onNavigate?: () => void } = $props();
const id = $props.id();
const groups = $derived(visibleNavigation(role));
const location = $derived(navigationLocation($page.url.pathname, role));
// Keep the current section or page visible in horizontally scrolling rows.
$effect(() => {
const current = location;
const node = navigation;
if (!current || !node) return;
let cancelled = false;
let observer: ResizeObserver | undefined;
void tick().then(() => {
if (cancelled) return;
const selected = node.querySelector<HTMLElement>('[aria-current]');
if (!selected) return;
const reveal = () => {
const bounds = node.getBoundingClientRect();
const target = selected.getBoundingClientRect();
if (target.left < bounds.left) node.scrollLeft -= bounds.left - target.left;
else if (target.right > bounds.right) node.scrollLeft += target.right - bounds.right;
};
reveal();
observer = new ResizeObserver(reveal);
observer.observe(node);
});
return () => { cancelled = true; observer?.disconnect(); };
const attention = $derived.by(() => {
if (!$fleet.receivedAt) return null;
const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
|| $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
const hosts = new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
});
</script>
{#if variant === 'primary'}
<nav class="primary" aria-label="Primary" bind:this={navigation}>
{#each groups as group (group.id)}
<a href={group.items[0].href} aria-current={location?.group.id === group.id ? 'location' : undefined}>{group.label}</a>
{/each}
</nav>
{:else if location}
<nav class="secondary" aria-label={`${location.group.label} pages`} bind:this={navigation}>
{#each location.group.items as item (item.href)}
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
{/each}
</nav>
{/if}
<nav aria-label="Main navigation">
{#each groups as group (group.id)}
<section aria-labelledby={`${id}-${group.id}`}>
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
<ul>{#each group.items as item (item.href)}
<li><a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}
aria-label={attention && attention[item.href] > 0 ? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention` : undefined} onclick={() => onNavigate?.()}>
<span>{item.label}</span>
{#if attention && attention[item.href] > 0}<span class="attention" aria-hidden="true">{attention[item.href]}</span>{/if}
</a></li>
{/each}</ul>
</section>
{/each}
</nav>
<style>
nav { display:flex; align-items:stretch; gap:4px; min-width:0; max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; }
a { display:flex; align-items:center; flex-shrink:0; min-height:44px; padding:10px 16px; border-bottom:2px solid transparent; color:var(--text-secondary); text-decoration:none; white-space:nowrap; font-size:var(--text-sm); font-weight:500; }
a:hover { color:var(--text-primary); background:var(--surface-raised); }
a:focus-visible { outline-offset:-3px; }
a[aria-current] { color:var(--accent-primary-hover); border-bottom-color:var(--accent-primary); }
.primary a { min-height:59px; }
.secondary { margin-bottom:var(--space-6); border-bottom:1px solid var(--border-subtle); }
@media (max-width:999px) { .primary a { min-height:48px; } }
@media (max-width:720px) { a { padding-inline:12px; } }
nav { display: grid; gap: var(--space-4); min-width: 0; }
h2 { margin: 0 0 var(--space-1); padding: 0 var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-label); text-transform: uppercase; }
ul { list-style: none; padding: 0; margin: 0; }
a { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 32px; padding: 5px var(--space-3); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; }
a:hover { color: var(--text-primary); background: var(--surface-raised); }
a[aria-current] { color: var(--text-primary); background: var(--surface-raised); box-shadow: inset 2px 0 var(--accent-primary); font-weight: var(--weight-semibold); }
a:focus-visible { outline-offset: -2px; }
.attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
@media (max-width: 960px) { a { min-height: 44px; } }
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { navigationLocation } from '$lib/navigation.js';
import { returnLabel, safeReturnHref } from '$lib/navigation-context.js';
interface Crumb {
label: string;
href?: string | null;
@@ -8,11 +9,24 @@
interface Props {
items: Crumb[];
fallback?: string;
}
let { items }: Props = $props();
let { items, fallback }: Props = $props();
const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group);
const trail = $derived(group && group.label !== items[0]?.label ? [{ label: group.label }, ...items] : items);
const trail = $derived.by(() => {
const parent = items.findIndex(item => item.href);
const context = $page.url.searchParams.get('returnTo');
const result = [...items];
if (parent >= 0) {
const href = safeReturnHref(context, fallback ?? items[parent].href!);
const original = items[parent];
const label = href.split('?')[0].split('#')[0] === original.href?.split('?')[0]
? original.label : returnLabel(href).replace(/^Back to /, '').replace(/^./, char => char.toUpperCase());
result[parent] = { label, href };
}
return !context && group && group.label !== result[0]?.label ? [{ label: group.label }, ...result] : result;
});
</script>
<nav class="crumbs" aria-label="Breadcrumb">
+14 -2
View File
@@ -8,6 +8,7 @@ export interface ServerHealthPresentation {
state: ServerHealthState;
label: string;
reason: string;
shortReason: string;
actionLabel: string;
actionHref: string;
/** Last relevant evidence, not an inferred time when the state began. */
@@ -22,10 +23,21 @@ function observedAt(value: string | null | undefined, now: number): string | nul
/** Explain the available evidence without treating a missing report as a stopped game. */
export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation {
const evidence = healthEvidence(server, now);
return { ...evidence, label: statusPresentation(evidence.state).label };
const error = decodeRconError(server.last_error);
const connectionReason = error ? {
'no-config': 'Connection not configured.', 'no-port': 'Connection port not configured.',
'send-error': 'Game connection failed.', timeout: 'No reply to the status check.',
'bad-reply': 'Unreadable game response.', 'no-players': 'Player count not reported.'
}[error.reason] : null;
const shortReason = evidence.state === 'host-offline' ? `${server.host?.name ?? 'Host'} unavailable; game may still run.`
: evidence.state === 'stopped' ? 'Container confirmed stopped.'
: evidence.state === 'not-responding' ? connectionReason ?? 'Game report out of date; players unknown.'
: evidence.state === 'unknown' ? connectionReason ?? (freshStatusTimestamp(server.last_seen_at, now) ? 'Game replied; container unverified.' : 'Game and container unverified.')
: evidence.reason;
return { ...evidence, shortReason, label: statusPresentation(evidence.state).label };
}
function healthEvidence(server: FleetServer, now: number): Omit<ServerHealthPresentation, 'label'> {
function healthEvidence(server: FleetServer, now: number): Omit<ServerHealthPresentation, 'label' | 'shortReason'> {
const href = `/servers/${encodeURIComponent(server.name)}`;
const diagnostics = `${href}?tab=diagnostics`;
const host = server.host;
+84 -17
View File
@@ -7,6 +7,7 @@
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
import { page } from '$app/stores';
import Navigation from '$ui/components/Navigation.svelte';
import CommandPalette from '$ui/components/CommandPalette.svelte';
import { visibleNavigation } from '$lib/navigation.js';
import BrandMark from '$ui/components/BrandMark.svelte';
import Confirmation from '$ui/components/Confirmation.svelte';
@@ -27,6 +28,9 @@
let signingOut = $state(false);
let signOutError = $state('');
let helpOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let sidebarCollapsed = $state(false);
let accountButton = $state<HTMLButtonElement>();
const accountName = $derived(data.user?.displayName || data.user?.username || 'Account');
const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?');
@@ -36,12 +40,14 @@
}
function openHelp() {
if (accountOpen) accountButton?.focus();
accountOpen = false;
helpOpen = true;
}
beforeNavigate(() => performance.mark('ec:navigation-start'));
afterNavigate(() => { if (performance.getEntriesByName('ec:navigation-start').length) { performance.measure('ec:navigation', 'ec:navigation-start'); performance.clearMarks('ec:navigation-start'); } });
afterNavigate(() => { accountOpen = false; resolveConfirmation(false); });
afterNavigate(() => { accountOpen = false; drawerOpen = false; paletteOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
@@ -66,14 +72,20 @@
$effect(() => {
if (!sessionReady) return;
const path = $page.url.pathname;
if (!(path === '/' || /^\/(scripts|configs|plugins)\//.test(path) || path === '/betas/new')) return;
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot) hydrateFleet($page.data.fleetSnapshot);
});
$effect(() => {
if (!sessionReady) return;
// Navigation badges need fresh host evidence on every signed-in page.
return startFleetMonitor();
});
onMount(() => {
const stopTimeZone = startTimeZonePreference();
const desktop = window.matchMedia('(min-width: 961px)');
const closeDrawer = () => { if (desktop.matches) drawerOpen = false; };
desktop.addEventListener('change', closeDrawer);
const shortcuts = Object.fromEntries(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut).map(item => [item.shortcut!, item.href]));
let armed = false;
@@ -81,6 +93,13 @@
const onKey = (e: KeyboardEvent) => {
if (!sessionReady) return;
// Global search also works while focus is in a page's search field.
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
accountOpen = false; drawerOpen = false; helpOpen = false;
paletteOpen = !paletteOpen;
return;
}
const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -106,6 +125,7 @@
window.addEventListener('keydown', onKey);
return () => {
stopTimeZone();
desktop.removeEventListener('change', closeDrawer);
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
@@ -120,17 +140,40 @@
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
<div class="shell">
<div class="shell" class:with-sidebar={sessionReady} class:sidebar-collapsed={sidebarCollapsed}>
<a class="skip-link" href="#main-content">Skip to content</a>
{#if sessionReady && data.user}
<aside class="sidebar" id="desktop-navigation" hidden={sidebarCollapsed}>
<a class="sidebar-brand" href="/" aria-label="Electric Cherry home"><BrandMark size="sm" /></a>
<div class="sidebar-scroll"><Navigation role={data.user.role} /></div>
</aside>
<div class="navigation-drawer">
<Dialog bind:open={drawerOpen} title="Navigation" drawer>
<div id="mobile-navigation"><Navigation role={data.user.role} onNavigate={() => drawerOpen = false} /></div>
</Dialog>
</div>
{/if}
{#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header>
{:else}
<header class="app-header">
{#if sessionReady}
<button class="icon-button desktop-toggle" type="button" aria-label={sidebarCollapsed ? 'Expand navigation' : 'Collapse navigation'} aria-controls="desktop-navigation" aria-expanded={!sidebarCollapsed} onclick={() => sidebarCollapsed = !sidebarCollapsed}>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="2" /><path d="M7 3v14" /></svg>
</button>
<button class="icon-button mobile-toggle" type="button" aria-label="Open navigation" aria-controls="mobile-navigation" aria-expanded={drawerOpen} onclick={() => drawerOpen = true}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14" /></svg>
</button>
{/if}
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" />
</a>
{#if sessionReady && data.user}<div class="primary-navigation"><Navigation role={data.user.role} /></div>{/if}
{#if sessionReady}<button class="search-button" type="button" aria-label="Search pages, servers, hosts, and players" aria-keyshortcuts="Meta+k Control+k" onclick={() => paletteOpen = true}>
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8" cy="8" r="5" /><path d="m12 12 5 5" /></svg>
<span class="search-label">Search pages, servers, players…</span><kbd>⌘K / Ctrl K</kbd>
</button>{/if}
<div class="header-actions">
{#if sessionReady}<button class="icon-button help-button" type="button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={openHelp}>?</button>{/if}
{#if data.user}
<div class="account-menu">
<button bind:this={accountButton} class="account-button" type="button"
@@ -143,6 +186,7 @@
<div class="account-links" id="account-links">
<div class="account-name">{accountName}</div>
<a href="/account">My account</a>
{#if sessionReady}<button onclick={openHelp}>Keyboard shortcuts <span aria-hidden="true">?</span></button>{/if}
{#if data.user.role === 'admin' && sessionReady}<a href="/settings/users">Manage users</a>{/if}
<button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>
{#if signOutError}<p role="alert">{signOutError}</p>{/if}
@@ -154,13 +198,13 @@
</header>
{/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
{#if sessionReady && data.user}<Navigation role={data.user.role} variant="secondary" />{/if}
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
{@render children()}
</main>
<Confirmation />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog.">
<dl class="shortcuts">{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
{#if sessionReady && data.user}<CommandPalette bind:open={paletteOpen} role={data.user.role} />{/if}
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then a destination key. Page shortcuts are inactive while typing or using a dialog; search works from any field.">
<dl class="shortcuts"><div><dt><kbd>⌘K / Ctrl K</kbd></dt><dd>Search pages, servers, hosts, and players</dd></div>{#each shortcuts as item}<div><dt><kbd>g {item.shortcut}</kbd></dt><dd>{item.href === '/' ? 'Fleet' : item.label}</dd></div>{/each}<div><dt><kbd>?</kbd></dt><dd>This help</dd></div></dl>
</Dialog>
</div>
@@ -204,14 +248,29 @@
}
.shell { min-height: 100vh; }
.shell.with-sidebar { padding-left: 224px; }
.shell.sidebar-collapsed { padding-left: 0; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 25; display: flex; flex-direction: column; width: 224px; border-right: 1px solid var(--border-subtle); background: var(--surface); }
.sidebar[hidden] { display: none; }
.sidebar-brand { display: flex; align-items: center; min-height: 60px; padding: 0 var(--space-4); text-decoration: none; }
.sidebar-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: var(--space-2) var(--space-2) var(--space-6); scrollbar-width: thin; }
.navigation-drawer :global(dialog.drawer) { inset: 0 auto 0 0; width: min(320px, 100vw); border-left: 0; border-right: 1px solid var(--border-subtle); }
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
main { min-width:0; overflow-wrap:anywhere; padding:var(--space-6) var(--space-8) var(--space-16); max-width:1600px; margin:0 auto; view-transition-name:main; }
main.auth-main { max-width:520px; padding-top:16px; }
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); }
.viewer-note strong { color:var(--text-primary); }
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:24px; min-height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:var(--space-3); min-height:60px; padding:0 var(--space-5); border-bottom:1px solid var(--border-subtle); background:var(--surface-sunken); }
.brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; }
.primary-navigation { flex:1; min-width:0; }
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: none; }
.icon-button { display: inline-grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-secondary); font-size: var(--text-lg); cursor: pointer; }
.icon-button:hover { color: var(--text-primary); background: var(--surface-raised); }
.icon-button svg, .search-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; flex-shrink: 0; }
.mobile-toggle { display: none; }
.search-button { min-width: 0; display: flex; align-items: center; gap: var(--space-2); flex: 1; max-width: 440px; height: 36px; padding: 0 var(--space-3); border: 1px solid var(--border-control); border-radius: var(--radius-sm); background: var(--surface-sunken); color: var(--text-secondary); cursor: pointer; text-align: left; font-size: var(--text-xs); }
.search-button:hover { border-color: var(--border-control-hover); color: var(--text-primary); }
.search-label { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.search-button kbd { padding: 1px 4px; white-space: nowrap; }
.header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
.account-menu { position:relative; }
.account-button { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
@@ -223,7 +282,7 @@
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
.shortcuts { display:grid; gap:12px; margin:0; }
.shortcuts > div { display:flex; gap:20px; align-items:center; }
.shortcuts dt { min-width:55px; }
.shortcuts dt { min-width:120px; }
.shortcuts dd { margin:0; font-size:var(--text-sm); }
kbd { font-family:var(--font-mono); padding:3px 8px; border:1px solid var(--border-strong); border-radius:5px; }
.skip-link { position:fixed; left:16px; top:-80px; z-index:300; padding:12px 16px; background:var(--surface-raised); border-radius:var(--radius-md); }
@@ -231,17 +290,25 @@
:global(input),:global(select),:global(textarea) { min-width:0; max-width:100%; }
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
:global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
@media (max-width:999px) {
.app-header { flex-wrap:wrap; gap:0 16px; padding-top:8px; }
.primary-navigation { order:3; flex-basis:100%; }
@media (max-width:960px) {
.shell.with-sidebar { padding-left: 0; }
.sidebar, .desktop-toggle { display: none; }
.mobile-toggle { display: inline-grid; }
.with-sidebar:not(.sidebar-collapsed) .app-header .brand-link { display: flex; }
.app-header { min-height: 56px; gap: var(--space-2); padding: 0 var(--space-3); }
.search-button { margin-left: auto; max-width: 300px; }
.search-button kbd { display: none; }
.header-actions { margin-left: 0; }
}
@media (max-width:720px) {
.app-header { padding-inline:12px; gap:8px; }
.app-header { padding-inline: var(--space-2); gap: var(--space-1); }
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
main { padding:var(--space-5) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
.search-button { flex: 0 0 44px; width: 44px; height: 44px; padding: 0; justify-content: center; border: 0; }
.search-label, .help-button { display: none; }
main { padding:var(--space-4) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
:global(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size:16px !important; min-height:44px; }
:global(button),:global(.btn) { min-height:44px; }
}
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { font-size:10px; letter-spacing:0; } .header-actions { gap:4px; } }
@media (max-width:360px) { .brand-link :global(.mark-sm .word) { display: none; } .brand-link::after { content: 'EC'; margin-left: var(--space-2); color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); } .header-actions { gap:4px; } }
:global(::view-transition-old(root)),:global(::view-transition-new(root)),:global(::view-transition-old(main)),:global(::view-transition-new(main)) { animation-duration:var(--dur-base); animation-timing-function:var(--ease-standard); }
</style>
+31 -12
View File
@@ -45,6 +45,8 @@
{ 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') ?? '';
@@ -160,11 +162,11 @@
function resetFilters() { query = ''; filter = 'all'; game = ''; }
</script>
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head>
<svelte:head><title>Servers · Electric Cherry</title></svelte:head>
<section class="page">
<div class="page-heading">
<SectionHeader title="Fleet" />
<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>
@@ -196,11 +198,16 @@
{#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><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></div>
<StatusBadge state={operation && status.state === 'online' ? 'attention' : status.state} />
<p>{operation && status.state === 'online' ? operation.label : status.reason}{#if status.since}<span title={status.since}> · Evidence {elapsedSince(status.since, $uiNow)} ago</span>{/if}</p>
<Button size="sm" href={contextHref(operation && status.state === 'online' ? operation.href : status.actionHref, $page.url)}>{operation && status.state === 'online' ? 'Review operation' : status.actionLabel}</Button>
<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>
@@ -316,9 +323,15 @@
.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-pin { color: var(--text-secondary); background: none; border: 0; padding: var(--space-2); font-size: var(--text-md); }
.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 p span { white-space: nowrap; }
.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; }
@@ -339,7 +352,7 @@
.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: 1fr auto; } .attention-row p { grid-column: 1; } }
@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); }
@@ -348,9 +361,15 @@
.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-2); padding: var(--space-3); }
.attention-row p { grid-column: 1; }
.attention-row :global(.btn) { grid-column: 2; grid-row: 2; justify-self: end; padding-inline: var(--space-2); font-size: var(--text-xs); }
.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; }
}
-2
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte';
@@ -202,7 +201,6 @@
<svelte:head><title>New beta | Electric Cherry</title></svelte:head>
<div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<BackLink fallback="/betas" />
<SectionHeader title="New beta" subtitle="Test files across games in one beta. Choose one instance count for every game." />
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
@@ -1,6 +1,5 @@
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
@@ -98,9 +97,8 @@
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
{#snippet crumbs()}<Breadcrumbs fallback="/collections?view=active" items={[{ label: 'Collections', href: '/collections' }, { label: 'Collection launch' }]} />{/snippet}
</SectionHeader>
<BackLink fallback="/collections?view=active" />
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail}
<section class="summary" aria-label="Collection progress">
+1
View File
@@ -16,6 +16,7 @@
let search = $state(''); let status = $state('');
let loadSequence = 0;
const canManage = $derived($page.data.user?.role === 'admin');
$effect(() => { if (canManage && $page.url.searchParams.get('action') === 'add') addOpen = true; });
const attention = $derived(hosts.filter(host => ['attention', 'offline'].includes(hostListStatus(host, now).key)).length);
const ready = $derived(hosts.filter(host => hostListStatus(host, now).key === 'ready').length);
const filtersVisible = $derived(hosts.length > 5 || !!search || !!status);
+1 -4
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import { contextHref } from '$lib/navigation-context.js';
@@ -73,7 +72,6 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Hosts', href: '/hosts' }, { label: host?.name ?? 'Host' }]} />{/snippet}
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<details class="host-menu"><summary>More actions</summary><div><Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button></div></details>{/if}{/snippet}
</SectionHeader>
<BackLink fallback="/hosts" />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}<p role="status">Loading host…</p>{:else if host}
{#if host.decommission}<section class="panel" aria-live="polite"><h2>Host cleanup</h2><p>{hostDeletionMessage(host)}</p>{#if host.decommission.error}<p class="ui-error">{host.decommission.error}</p>{/if}<p>Requested {new Date(host.decommission.requestedAt).toLocaleString()}. This page closes only after the cleanup worker confirms removal.</p>{#if host.decommission.stage === 'cleaned'}<p>The agent is expected to go offline during uninstall. If final confirmation never arrives, inspect the machine; loss of contact alone does not prove completion.</p>{/if}</section>{/if}
@@ -84,7 +82,6 @@
<Tabs {tabs} value={tab} onchange={changeTab} ariaLabel="Host sections" idPrefix="host-tabs" panelId="host-panel" />
<div class="host-panel" id="host-panel" role="tabpanel" aria-labelledby={`host-tabs-${tab}`}>
{#if tab === 'overview'}
<div class="overview-actions"><Button onclick={() => changeTab('servers')}>View {instances.length} servers</Button></div>
<section class="panel"><h2>Agent workers</h2>
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)}
<dt>{hostAgentLaneLabel(lane.name)}</dt><dd>{hostAgentLaneState(lane)}{#if !host.connected} (last reported){/if}<br />Last successful poll: {lane.lastPollAt ? new Date(lane.lastPollAt).toLocaleString() : 'Not yet reported'}</dd>
@@ -161,6 +158,6 @@
.host-menu>div { position:absolute; right:0; top:100%; z-index:5; padding:12px; min-width:160px; background:var(--surface-raised); border:1px solid var(--border-strong); border-radius:var(--radius-md); }
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
.host-panel { display:grid; gap:24px; min-width:0; } .overview-actions { display:flex; flex-wrap:wrap; gap:12px; }
.host-panel { display:grid; gap:24px; min-width:0; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); }
</style>
@@ -1,6 +1,5 @@
<script lang="ts">
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { goto, invalidateAll } from '$app/navigation';
@@ -64,10 +63,9 @@
<section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
{#snippet crumbs()}<Breadcrumbs fallback={data.returnTo} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader>
<BackLink fallback={data.returnTo} />
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
-2
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
@@ -103,7 +102,6 @@
{#if canDismiss}<Button variant="ghost" disabled={!!busy || activeOperation} loading={busy === 'dismiss'} onclick={() => update('dismiss')}>Dismiss incident</Button>{/if}
{/snippet}
</SectionHeader>
<BackLink fallback="/maintenance" />
{#if loadError}<div class="ui-error" role="alert">{loadError} {detail ? 'Showing the last successful refresh.' : ''}<Button size="sm" disabled={!!busy} onclick={load}>Retry refresh</Button></div>{/if}
{#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
-2
View File
@@ -2,7 +2,6 @@
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
@@ -37,7 +36,6 @@
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Match History', href: '/matches' }, { label: `Match #${match.id}` }]} />{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
</SectionHeader>
<BackLink fallback="/matches" />
<section class="summary" aria-label="Match summary">
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
+2 -2
View File
@@ -6,7 +6,7 @@
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
let { data }: { data: import('./$types').PageData } = $props();
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
import MatchHistory from '$ui/components/MatchHistory.svelte';
@@ -224,7 +224,7 @@
{#if server}
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<header class="instance-header">
<BackLink fallback="/" />
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
<SectionHeader title={serverTitle(server.name)}>
{#snippet actions()}
<details class="server-actions"><summary>Instance actions</summary><div class="action-menu">{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue instance removal</Button></div></details>