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
+2 -2
View File
@@ -39,9 +39,9 @@ Open the dashboard and create your first administrator with the setup token.
Remove the token from `.env` afterward and recreate the controller with
`docker compose up -d`.
1. Upload your T5 or T6 folder, including DLC, under **Library → Game Files**.
1. Upload your T5 or T6 folder, including DLC, under **Releases → Game files**.
2. Add a machine under **Fleet → Hosts** and run its generated enrollment command.
3. Save your Plutonium keys under **Administration → Keys**.
3. Save your Plutonium keys under **Admin → Keys**.
4. Wait for host setup to finish, then launch a server or collection.
See the [setup guide](docs/guides/getting-started.md) for telemetry, storage,
+15
View File
@@ -38,3 +38,18 @@ Preserve visible keyboard focus, associated field labels and separate navigation
and action targets. Avoid nested buttons or placing a button inside a link.
Allow long server identities to wrap and tab/filter rows to scroll on narrow
screens.
## Navigation
Use the shared navigation metadata for Fleet, Releases, Activity, Players, and
Admin. Campaigns stays under Releases beside Scripts, Configs, Plugins, Game
files, and Betas. The Servers page is the Fleet landing page. Desktop navigation
uses a collapsible sidebar; mobile navigation uses a modal drawer.
Use Cmd/Ctrl K to search pages, servers, hosts, players, and recorded matches.
Common actions open their existing forms and retain role checks and confirmations.
Keyboard shortcut help is available from the account menu and with `?`.
Detail pages use `Breadcrumbs`. Its parent link restores the validated `returnTo`
URL, including filters, sorting, pagination, and anchors. Supply `fallback` when
the default parent also needs filters. Do not add a duplicate Back link.
+1 -1
View File
@@ -20,7 +20,7 @@ their access checks. HTTPS requirements still apply. See the
password. Only the holder of the token can create the first administrator.
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller.
Setup remains closed once any user exists, even if the token is still set.
5. Use **Administration → Users** to create additional accounts. Share temporary
5. Use **Admin → Users** to create additional accounts. Share temporary
passwords privately; each user must change theirs on first login.
Existing installations become protected on upgrade. Existing game servers and
+3 -3
View File
@@ -37,7 +37,7 @@ It fills deficits without changing existing servers' files.
plan. Review the change summary and the map table grouped by game.
4. Check the required key coverage for each game. Available keys count once
across the full lineup, and every new server needs a key. If a game is short,
open **Administration → Keys** to add keys or wait for assigned keys to be
open **Admin → Keys** to add keys or wait for assigned keys to be
released. Returning to the dialog rechecks the pools and fleet.
5. Review the capacity summary. Expand its details for CPU, memory, ports, and
startup slots. Capacity estimates are advisory: queued launches can wait for
@@ -142,7 +142,7 @@ All collections and individual launches share one startup queue, ordered by requ
time. By default, one server starts at a time. Its startup slot remains occupied
until the host agent confirms container health, game status and the selected map
are verified, and RCON reports a current Plutonium revision.
Only then can the next queued server start. Set **Concurrent server startups** in **Administration → Settings** to
Only then can the next queued server start. Set **Concurrent server startups** in **Admin → Settings** to
a positive integer to allow more simultaneous startups. Each worker pass admits
at most the available slots, even if an attempt fails immediately.
@@ -190,7 +190,7 @@ against the remaining fleet.
2. Choose a game and map. The dialog automatically checks the current promoted
setup, latest game files, available lobby, and game key pool as selections change.
3. Review the readiness summary and resolve any displayed blockers. When no game
key is available, open **Administration → Keys** and add one or wait for a key
key is available, open **Admin → Keys** and add one or wait for a key
to be released. Returning to the launch dialog rechecks availability; **Retry checks**
also runs the checks again.
4. If needed, expand **Advanced settings** for runtime and network overrides.
+3 -3
View File
@@ -46,13 +46,13 @@ Proxy the HTTPS address to controller port 3000. Open the dashboard and follow
`.env` and run `docker compose up -d` to recreate the controller. Setup stays
closed once an account exists.
Add other users under **Administration → Users**. The
Add other users under **Admin → Users**. The
[access guide](authentication.md) explains roles, proxy configuration, and
password recovery.
## Prepare a game host
1. Open **Library → Game Files** and upload the complete local folder for T5 or
1. Open **Releases → Game files** and upload the complete local folder for T5 or
T6, including DLC. Uploads can run before any host is enrolled. The latest
verified upload becomes that game's default files.
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and
@@ -67,7 +67,7 @@ base game files; each instance has its own writable data. See
## Launch your first server
1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**.
1. Save Plutonium server keys in the T5 or T6 pool under **Admin → Keys**.
2. Open **Create server** and choose a game and map. Readiness checks run
automatically. For a reusable lineup, use **Fleet → Collections** instead.
3. Resolve any launch blockers and choose **Launch server**. Provisioning queues
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintenance
Open **Operations → Maintenance** to keep existing servers healthy and current.
Open **Activity → Maintenance** to keep existing servers healthy and current.
Maintenance owns observations, policies, schedules, requests, and verification of
maintenance outcomes. Provisioning owns lifecycle execution, player checks,
conflicts, rolling capacity, container recovery, and persistent operation history.
@@ -98,7 +98,7 @@ Schedules survive application restarts. Missed days, including while globally
paused, become one run on resume. Pending work is deduplicated per server. The
worker starts on the first application request, so keep the app running and
reachable. Finished scheduled and legacy maintenance activity is retained for
30 days by default (configurable in Administration → Settings); update attempt records remain durable.
30 days by default (configurable in Admin → Settings); update attempt records remain durable.
## Health recovery and configuration drift
+1 -1
View File
@@ -90,7 +90,7 @@ Observations are recorded by the existing status worker. Each remains valid unti
Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Administration → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup.
## API
+1 -1
View File
@@ -555,7 +555,7 @@ on heartbeats, including vanished caches. The latest published version of each g
is always retained. With agent 1.4.7 or newer, an older host copy is automatically
eligible for removal once no live instance reservation on that host references it.
The game-file minimum count, supersession grace period and explicit protections in
Administration → Settings also apply. Stopped,
Admin → Settings also apply. Stopped,
provisioning, and rebuilding instances still pin their version. The agent also
refuses deletion if any Docker container (including stopped containers) mounts
those files. Cleanup includes partial downloads and retries after interruption.
+3 -3
View File
@@ -1,6 +1,6 @@
# Provisioning
Open **Operations → Provisioning** for instance lifecycle work and persistent
Open **Activity → Operations** for instance lifecycle work and persistent
activity. **Fleet → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and
@@ -83,7 +83,7 @@ same key. Confirmed removal releases it for another launch. Failed or ambiguous
creation keeps the assignment while cleanup may still be required.
An unstarted creation cancelled before dispatch releases its key immediately.
Administrators can add and remove available keys in **Administration → Keys**;
Administrators can add and remove available keys in **Admin → Keys**;
assigned keys cannot be removed. Existing keys are migrated into their game
pool, retaining assignments for existing servers and pending work. If a legacy
rebuild has an uncertain result and used a different key, both possible keys
@@ -248,7 +248,7 @@ See [native host management](native-hosts.md) for command delivery and recovery.
## Concurrency limits
Administration → Settings separates Concurrency and Retention, with independent
Admin → Settings separates Concurrency and Retention, with independent
save actions. Existing fleet restart, startup and destruction limits continue to
apply. Additional per-host restart, startup and destruction limits, a combined
lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default
+1 -1
View File
@@ -1,6 +1,6 @@
# Fleet settings
Administration → Settings has independent **Concurrency** and **Retention**
Admin → Settings has independent **Concurrency** and **Retention**
sections. Settings persist in SQLite, apply without restarting the controller,
and are writable only by administrators. Saving one section preserves the other
section's saved values. Upgrades preserve existing values and add new defaults.
+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>
+35 -48
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}>
<nav aria-label="Main 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>
<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>
{: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>
<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>
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { loadPaletteDirectory, paletteDirectoryResults, paletteMatchJump, palettePageResults, searchPaletteRecords } from '../../src/lib/ui/command-palette.js';
describe('command palette destinations', () => {
it('keeps administrator destinations and operations out of inappropriate roles', () => {
const viewer = palettePageResults('viewer', '');
expect(viewer.some(item => item.group === 'Actions' || item.href.startsWith('/settings') || item.href === '/server-keys')).toBe(false);
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create']);
expect(palettePageResults('admin', 'enroll')).toContainEqual(expect.objectContaining({ href: '/hosts?action=add' }));
expect(palettePageResults('admin', 'campaigns')).toContainEqual(expect.objectContaining({ label: 'Campaigns', href: '/campaigns' }));
});
it('searches friendly server names and hosts while encoding identifiers and bounding groups', () => {
const directory = { servers: Array.from({ length: 10 }, (_, i) => ({ name: `t5_zombie_theater_${i + 1}`, host: { name: 'Dallas 1' } })),
hosts: [{ id: 'host / 1', name: 'Dallas 1', hostname: 'dallas.example', connectionState: 'offline' }], unavailable: [] };
expect(paletteDirectoryResults('', directory)).toEqual([]);
expect(paletteDirectoryResults('kino lobby 2', directory)).toEqual([{ key: 'server:t5_zombie_theater_2', group: 'Servers', label: 'Kino Der Toten · Lobby 2', detail: 'Dallas 1', href: '/servers/t5_zombie_theater_2' }]);
const found = paletteDirectoryResults('Dallas', directory);
expect(found.filter(item => item.group === 'Servers')).toHaveLength(6);
expect(found).toContainEqual(expect.objectContaining({ href: '/hosts/host%20%2F%201', detail: 'Host agent offline' }));
expect(new Set(found.map(item => item.key)).size).toBe(found.length);
});
it.each(['42', '#42', 'match 42', 'Match #42'])('opens a positive match ID directly: %s', query => {
expect(paletteMatchJump(query)).toEqual([expect.objectContaining({ key: 'match:42', href: '/matches/42' })]);
});
it.each(['0', '-1', '1.5', 'match one', '9007199254740993', 'https://example.com'])('does not invent an invalid match destination: %s', query => {
expect(paletteMatchJump(query)).toEqual([]);
});
});
describe('bounded palette requests', () => {
it('does not fetch histories for an empty or one-character query', async () => {
const fetcher = vi.fn();
expect(await searchPaletteRecords('x', new AbortController().signal, fetcher)).toEqual({ items: [], unavailable: [] });
expect(fetcher).not.toHaveBeenCalled();
});
it('queries at most five players and completed matches, and propagates cancellation', async () => {
const controller = new AbortController();
const fetcher = vi.fn(async (url: string) => Response.json(url.startsWith('/api/leaderboard') ? { entries: [{ guid: 'id / 1', name: 'Cherry' }] }
: { matches: [{ id: 2, map: 'Kino Der Toten', server: 't5_zombie_theater_1', status: 'completed' }, { id: 3, map: 'Town', server: 'server', status: 'active' }] }));
const result = await searchPaletteRecords('Cherry & Co', controller.signal, fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
for (const call of fetcher.mock.calls) {
const url = new URL(call[0], 'http://localhost');
expect(url.searchParams.get('limit')).toBe('5'); expect(url.searchParams.get('q')).toBe('Cherry & Co');
}
expect(fetcher).toHaveBeenCalledWith(expect.stringContaining('/api/matches?'), { signal: controller.signal, cache: 'no-store' });
expect(result.items.map(item => item.href)).toEqual(['/leaderboard/players/id%20%2F%201', '/matches/2']);
expect(result.unavailable).toEqual([]);
});
it('preserves results from a successful source when another source fails', async () => {
const fetcher = vi.fn(async (url: string) => url === '/api/hosts' ? new Response('', { status: 503 }) : Response.json({ servers: [{ name: 'server' }] }));
expect(await loadPaletteDirectory(new AbortController().signal, fetcher)).toEqual({ servers: [{ name: 'server' }], hosts: [], unavailable: ['Host'] });
const records = await searchPaletteRecords('Cherry', new AbortController().signal, async url => url.startsWith('/api/leaderboard')
? new Response('', { status: 500 }) : Response.json({ matches: [{ id: 5, status: 'completed', map: 'Town', server: 'server' }] }));
expect(records.items.map(item => item.href)).toEqual(['/matches/5']); expect(records.unavailable).toEqual(['Player']);
});
});
+17 -11
View File
@@ -6,30 +6,36 @@ describe('application navigation', () => {
['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'operations', '/hosts/operations'],
['/provisioning', 'operations', '/provisioning'],
['/betas/new', 'operations', '/betas'],
['/campaigns/42', 'operations', '/campaigns'],
['/configs/42', 'library', '/configs'],
['/leaderboard/players/42', 'analytics', '/leaderboard'],
['/settings/users', 'administration', '/settings/users'],
['/server-keys', 'administration', '/server-keys']
['/hosts/operations', 'activity', '/hosts/operations'],
['/provisioning', 'activity', '/provisioning'],
['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'],
['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => {
expect(navigationLocation(path, 'admin')?.group.id).toBe(group);
expect(navigationGroups.flatMap(group => group.items).filter(item => navigationItemActive(item, path)).map(item => item.href)).toEqual([href]);
});
it('opens Operations at Provisioning and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'operations')?.items[0].href).toBe('/provisioning');
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/provisioning');
const { load } = await import('../../src/routes/operations/+page.server.js');
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' }));
});
it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
for (const role of ['operator', 'viewer'] as const) {
expect(visibleNavigation(role).map(group => group.id)).not.toContain('administration');
expect(visibleNavigation(role).map(group => group.id)).not.toContain('admin');
expect(navigationLocation('/server-keys', role)).toBeNull();
expect(visibleNavigation(role).flatMap(group => group.items).filter(item => ['h', 'k'].includes(item.shortcut ?? ''))).toEqual([]);
}
});
it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map(group => group.label)).toEqual(['Fleet', 'Releases', 'Activity', 'Players', 'Admin']);
expect(navigationGroups.find(group => group.id === 'releases')?.items.map(item => item.label)).toEqual(['Scripts', 'Configs', 'Plugins', 'Game files', 'Betas', 'Campaigns']);
expect(navigationGroups.flatMap(group => group.items).some(item => /rollout/i.test(item.label))).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
const { page } = await vi.hoisted(async () => {
const { writable } = await import('svelte/store');
return { page: writable({ url: new URL('http://localhost/servers/t5_zombie_theater_1'), data: { user: { role: 'admin' } } }) };
});
vi.mock('$app/stores', () => ({ page }));
import Breadcrumbs from '../../src/lib/ui/components/primitives/Breadcrumbs.svelte';
const items = [{ label: 'Fleet', href: '/' }, { label: 'Kino Der Toten' }];
describe('contextual breadcrumbs', () => {
it('restores the parent list filters in its only parent link', () => {
page.set({ url: new URL('http://localhost/servers/t5_zombie_theater_1?returnTo=%2F%3Ffilter%3Doccupied%26sort%3Dround'), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items } }).body;
expect(html).toContain('href="/?filter=occupied&amp;sort=round"');
expect(html.match(/<a\b/g)).toHaveLength(1);
expect(html).toContain('aria-current="page"');
});
it('names a contextual parent and retains its saved section', () => {
page.set({ url: new URL('http://localhost/servers/t5_zombie_theater_1?returnTo=%2Fmaintenance%3Fview%3Dactivity'), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items } }).body;
expect(html).toContain('href="/maintenance?view=activity"');
expect(html).toContain('Maintenance');
});
it('rejects external and API destinations while preserving the filtered fallback', () => {
for (const returnTo of ['https://evil.invalid/', '/api/auth/logout']) {
page.set({ url: new URL(`http://localhost/servers/t5_zombie_theater_1?returnTo=${encodeURIComponent(returnTo)}`), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items, fallback: '/?filter=pinned' } }).body;
expect(html).toContain('href="/?filter=pinned"');
expect(html).not.toContain(returnTo);
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import CommandPalette from '../../src/lib/ui/components/CommandPalette.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
it('connects the search field to a labelled option list and its selected result', () => {
const body = render(CommandPalette, { props: { open: true, role: 'operator' } }).body;
expect(body).toContain('role="combobox"');
expect(body).toContain('aria-autocomplete="list"');
expect(body).toContain('aria-expanded="true"');
const list = /aria-controls="([^"]+)"/.exec(body)?.[1];
const active = /aria-activedescendant="([^"]+)"/.exec(body)?.[1];
expect(list).toBeTruthy(); expect(active).toBeTruthy();
expect(body).toContain(`id="${list}" role="listbox"`);
expect(body).toContain(`id="${active}" role="option" tabindex="-1" aria-selected="true"`);
expect(body).toContain('Create server'); expect(body).not.toContain('Add host');
});
it('keeps the navigation palette useful for viewers without offering write actions', () => {
const body = render(CommandPalette, { props: { open: true, role: 'viewer' } }).body;
expect(body).toContain('Campaigns'); expect(body).toContain('Leaderboard');
expect(body).not.toContain('Create server'); expect(body).not.toContain('Add host');
});
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { createRawSnippet } from 'svelte';
import Navigation from '../../src/lib/ui/components/Navigation.svelte';
import Layout from '../../src/routes/+layout.svelte';
import { fleet } from '../../src/lib/ui/stores/fleet.js';
import type { AuthUser } from '../../src/lib/auth.js';
const currentPage = writable({ url: new URL('http://localhost/campaigns/42'), data: {} });
vi.mock('$app/stores', () => ({ page: { subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run) } }));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn(), afterNavigate: vi.fn(), onNavigate: vi.fn() }));
afterEach(() => { fleet.set({ servers: [], operations: [], receivedAt: 0 }); currentPage.set({ url: new URL('http://localhost/campaigns/42'), data: {} }); });
describe('sidebar navigation', () => {
it('exposes every allowed page in one navigation and marks the current page', () => {
const body = render(Navigation, { props: { role: 'operator' } }).body;
expect(body.match(/<nav\b/g)).toHaveLength(1);
expect(body).toContain('Main navigation');
expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/);
expect(body).toContain('href="/betas"');
expect(body).toContain('href="/hosts/operations"');
expect(body).toContain('href="/metrics"');
expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"');
});
it('does not show invented attention counts before the fleet snapshot loads', () => {
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(/needs? attention/);
fleet.set({ receivedAt: Date.now(), operations: [], servers: [{
name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_players: null,
last_map: null, last_gametype: null, last_seen_at: null, last_error: null, roster: [],
health: 'offline', held: [], active_since: null, inactive_since: null, game_started_at: null, game_ended_at: null
}] });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Servers, 1 needs attention"');
});
});
const user: AuthUser = { id: '1', username: 'operator', displayName: 'Operator', role: 'operator', disabled: false,
mustChangePassword: false, createdAt: '', updatedAt: '', lastLoginAt: null };
const children = createRawSnippet(() => ({ render: () => '<h1>Page content</h1>' }));
const shell = (overrides: Partial<AuthUser> = {}) => render(Layout, { props: { children, data: { user: { ...user, ...overrides }, setupRequired: false } } }).body;
describe('application shell', () => {
it('offers collapsible desktop navigation, a labelled mobile drawer, search and help', () => {
const body = shell();
expect(body).toContain('id="desktop-navigation"');
expect(body).toContain('aria-label="Collapse navigation"');
expect(body).toContain('aria-label="Open navigation"');
expect(body).toContain('id="mobile-navigation"');
expect(body).toContain('aria-keyshortcuts="Meta+k Control+k"');
expect(body).toContain('aria-label="Keyboard shortcuts"');
expect(body).not.toContain('class="primary-navigation"');
expect(body).not.toContain('variant="secondary"');
});
it('preserves the viewer notice and hides navigation while a password change is required', () => {
const viewer = shell({ role: 'viewer' });
expect(viewer).toContain('Viewer access');
expect(viewer).not.toContain('href="/server-keys"');
const password = shell({ mustChangePassword: true });
expect(password).not.toContain('id="desktop-navigation"');
expect(password).not.toContain('id="mobile-navigation"');
expect(password).not.toContain('aria-keyshortcuts=');
});
});
+2 -2
View File
@@ -26,7 +26,7 @@ describe('server health presentation', () => {
{ ...server.host!, lastHeartbeat: iso(-91_000) }]) {
expect(serverHealth({ ...server, host, health: 'offline', container_state: 'stopped' }, now)).toEqual({
state: 'host-offline', label: 'Host offline', reason: "Dallas 1's host agent is unavailable. The game may still be running.",
actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat
shortReason: 'Dallas 1 unavailable; game may still run.', actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat
});
}
});
@@ -57,7 +57,7 @@ describe('server health presentation', () => {
it('separates a running container with failed game checks from a stopped or missing container', () => {
expect(serverHealth({ ...server, health: 'offline', last_seen_at: iso(-300_000), last_error: 'timeout:Internal endpoint 10.0.0.1 timed out' }, now)).toEqual({
state: 'not-responding', label: 'Not responding', reason: 'The game did not answer the latest status check.',
actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000)
shortReason: 'No reply to the status check.', actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000)
});
expect(serverHealth({ ...server, health: 'offline', container_state: 'missing', last_seen_at: null }, now).state).toBe('unknown');
});