Add sidebar navigation and global command search
This commit is contained in:
+18
-18
@@ -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' }
|
||||
] }
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user