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 Remove the token from `.env` afterward and recreate the controller with
`docker compose up -d`. `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. 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. 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, 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. 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 Allow long server identities to wrap and tab/filter rows to scroll on narrow
screens. 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. password. Only the holder of the token can create the first administrator.
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller. 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. 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. passwords privately; each user must change theirs on first login.
Existing installations become protected on upgrade. Existing game servers and 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. 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 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, 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. released. Returning to the dialog rechecks the pools and fleet.
5. Review the capacity summary. Expand its details for CPU, memory, ports, and 5. Review the capacity summary. Expand its details for CPU, memory, ports, and
startup slots. Capacity estimates are advisory: queued launches can wait for 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 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 until the host agent confirms container health, game status and the selected map
are verified, and RCON reports a current Plutonium revision. 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 a positive integer to allow more simultaneous startups. Each worker pass admits
at most the available slots, even if an attempt fails immediately. 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 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. 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 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** to be released. Returning to the launch dialog rechecks availability; **Retry checks**
also runs the checks again. also runs the checks again.
4. If needed, expand **Advanced settings** for runtime and network overrides. 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 `.env` and run `docker compose up -d` to recreate the controller. Setup stays
closed once an account exists. 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 [access guide](authentication.md) explains roles, proxy configuration, and
password recovery. password recovery.
## Prepare a game host ## 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 T6, including DLC. Uploads can run before any host is enrolled. The latest
verified upload becomes that game's default files. verified upload becomes that game's default files.
2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and 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 ## 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 2. Open **Create server** and choose a game and map. Readiness checks run
automatically. For a reusable lineup, use **Fleet → Collections** instead. automatically. For a reusable lineup, use **Fleet → Collections** instead.
3. Resolve any launch blockers and choose **Launch server**. Provisioning queues 3. Resolve any launch blockers and choose **Launch server**. Provisioning queues
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintenance # 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 owns observations, policies, schedules, requests, and verification of
maintenance outcomes. Provisioning owns lifecycle execution, player checks, maintenance outcomes. Provisioning owns lifecycle execution, player checks,
conflicts, rolling capacity, container recovery, and persistent operation history. 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 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 worker starts on the first application request, so keep the app running and
reachable. Finished scheduled and legacy maintenance activity is retained for 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 ## 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. 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 ## 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 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. 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 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 provisioning, and rebuilding instances still pin their version. The agent also
refuses deletion if any Docker container (including stopped containers) mounts refuses deletion if any Docker container (including stopped containers) mounts
those files. Cleanup includes partial downloads and retries after interruption. those files. Cleanup includes partial downloads and retries after interruption.
+3 -3
View File
@@ -1,6 +1,6 @@
# Provisioning # 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 activity. **Fleet → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning. progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and 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. creation keeps the assignment while cleanup may still be required.
An unstarted creation cancelled before dispatch releases its key immediately. 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 assigned keys cannot be removed. Existing keys are migrated into their game
pool, retaining assignments for existing servers and pending work. If a legacy 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 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 ## 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 save actions. Existing fleet restart, startup and destruction limits continue to
apply. Additional per-host restart, startup and destruction limits, a combined 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 lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default
+1 -1
View File
@@ -1,6 +1,6 @@
# Fleet settings # 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, sections. Settings persist in SQLite, apply without restarting the controller,
and are writable only by administrators. Saving one section preserves the other and are writable only by administrators. Saving one section preserves the other
section's saved values. Upgrades preserve existing values and add new defaults. 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[] = [ export const navigationGroups: NavigationGroup[] = [
{ id: 'fleet', label: 'Fleet', items: [ { id: 'fleet', label: 'Fleet', items: [
{ label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') }, { label: 'Servers', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') },
{ label: 'Collections', href: '/collections' },
{ label: 'Autoscaling', href: '/autoscaling' },
{ label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) }, { 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: 'Scripts', href: '/scripts', shortcut: 's' },
{ label: 'Configs', href: '/configs', shortcut: 'c' }, { label: 'Configs', href: '/configs', shortcut: 'c' },
{ label: 'Plugins', href: '/plugins', shortcut: 'p' }, { label: 'Plugins', href: '/plugins', shortcut: 'p' },
{ label: 'Game Files', href: '/game-files' } { label: 'Game files', href: '/game-files' },
] },
{ id: 'operations', label: 'Operations', items: [
{ label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Betas', href: '/betas', shortcut: 'b' }, { label: 'Betas', href: '/betas', shortcut: 'b' },
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }, { 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' }
] }, ] },
{ id: 'analytics', label: 'Analytics', items: [ { id: 'activity', label: 'Activity', items: [
{ label: 'Metrics', href: '/metrics', shortcut: 'm' }, { 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: '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: [ { id: 'admin', label: 'Admin', admin: true, items: [
{ label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }, { label: 'Users', href: '/settings/users' },
{ label: 'Keys', href: '/server-keys', shortcut: 'k' }, { 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"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
@@ -288,7 +287,6 @@
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} /> <Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet} {/snippet}
</SectionHeader> </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 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} {#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>
+33 -46
View File
@@ -1,61 +1,48 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { UserRole } from '$lib/auth.js'; 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 { role, onNavigate }: { role: UserRole; onNavigate?: () => void } = $props();
let navigation = $state<HTMLElement>(); const id = $props.id();
const groups = $derived(visibleNavigation(role)); const groups = $derived(visibleNavigation(role));
const location = $derived(navigationLocation($page.url.pathname, role)); const attention = $derived.by(() => {
if (!$fleet.receivedAt) return null;
// Keep the current section or page visible in horizontally scrolling rows. const statuses = $fleetServers.map(server => ({ server, state: serverHealth(server, $uiNow).state }));
$effect(() => { const servers = statuses.filter(({ server, state }) => !['online', 'starting', 'provisioning'].includes(state)
const current = location; || $fleet.operations.some(operation => operation.server === server.name && operation.state === 'attention')).length;
const node = navigation; const hosts = new Set(statuses.filter(({ server, state }) => server.host && state === 'host-offline').map(({ server }) => server.host!.id)).size;
if (!current || !node) return; const maintenance = $fleet.operations.filter(operation => operation.kind === 'maintenance' && operation.state === 'attention').length;
let cancelled = false; return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<string, number>;
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(); };
}); });
</script> </script>
{#if variant === 'primary'} <nav aria-label="Main navigation">
<nav class="primary" aria-label="Primary" bind:this={navigation}>
{#each groups as group (group.id)} {#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} {/each}
</nav> </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}
<style> <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; } nav { display: grid; gap: var(--space-4); min-width: 0; }
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; } 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:hover { color: var(--text-primary); background: var(--surface-raised); }
a:focus-visible { outline-offset:-3px; } 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[aria-current] { color:var(--accent-primary-hover); border-bottom-color:var(--accent-primary); } a:focus-visible { outline-offset: -2px; }
.primary a { min-height:59px; } .attention { color: var(--state-warning); font-variant-numeric: tabular-nums; font-size: var(--text-xs); font-weight: var(--weight-semibold); }
.secondary { margin-bottom:var(--space-6); border-bottom:1px solid var(--border-subtle); } @media (max-width: 960px) { a { min-height: 44px; } }
@media (max-width:999px) { .primary a { min-height:48px; } }
@media (max-width:720px) { a { padding-inline:12px; } }
</style> </style>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { navigationLocation } from '$lib/navigation.js'; import { navigationLocation } from '$lib/navigation.js';
import { returnLabel, safeReturnHref } from '$lib/navigation-context.js';
interface Crumb { interface Crumb {
label: string; label: string;
href?: string | null; href?: string | null;
@@ -8,11 +9,24 @@
interface Props { interface Props {
items: Crumb[]; 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 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> </script>
<nav class="crumbs" aria-label="Breadcrumb"> <nav class="crumbs" aria-label="Breadcrumb">
+14 -2
View File
@@ -8,6 +8,7 @@ export interface ServerHealthPresentation {
state: ServerHealthState; state: ServerHealthState;
label: string; label: string;
reason: string; reason: string;
shortReason: string;
actionLabel: string; actionLabel: string;
actionHref: string; actionHref: string;
/** Last relevant evidence, not an inferred time when the state began. */ /** 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. */ /** Explain the available evidence without treating a missing report as a stopped game. */
export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation { export function serverHealth(server: FleetServer, now: number): ServerHealthPresentation {
const evidence = healthEvidence(server, now); 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 href = `/servers/${encodeURIComponent(server.name)}`;
const diagnostics = `${href}?tab=diagnostics`; const diagnostics = `${href}?tab=diagnostics`;
const host = server.host; const host = server.host;
+84 -17
View File
@@ -7,6 +7,7 @@
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js'; import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import Navigation from '$ui/components/Navigation.svelte'; import Navigation from '$ui/components/Navigation.svelte';
import CommandPalette from '$ui/components/CommandPalette.svelte';
import { visibleNavigation } from '$lib/navigation.js'; import { visibleNavigation } from '$lib/navigation.js';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Confirmation from '$ui/components/Confirmation.svelte'; import Confirmation from '$ui/components/Confirmation.svelte';
@@ -27,6 +28,9 @@
let signingOut = $state(false); let signingOut = $state(false);
let signOutError = $state(''); let signOutError = $state('');
let helpOpen = $state(false); let helpOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let sidebarCollapsed = $state(false);
let accountButton = $state<HTMLButtonElement>(); let accountButton = $state<HTMLButtonElement>();
const accountName = $derived(data.user?.displayName || data.user?.username || 'Account'); const accountName = $derived(data.user?.displayName || data.user?.username || 'Account');
const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?'); const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?');
@@ -36,12 +40,14 @@
} }
function openHelp() { function openHelp() {
if (accountOpen) accountButton?.focus();
accountOpen = false;
helpOpen = true; helpOpen = true;
} }
beforeNavigate(() => performance.mark('ec:navigation-start')); 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(() => { 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) { function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); } if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
@@ -66,14 +72,20 @@
$effect(() => { $effect(() => {
if (!sessionReady) return; 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); 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(); return startFleetMonitor();
}); });
onMount(() => { onMount(() => {
const stopTimeZone = startTimeZonePreference(); 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])); 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; let armed = false;
@@ -81,6 +93,13 @@
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (!sessionReady) return; 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; const target = e.target as HTMLElement;
if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return; if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.metaKey || e.ctrlKey || e.altKey) return;
@@ -106,6 +125,7 @@
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => { return () => {
stopTimeZone(); stopTimeZone();
desktop.removeEventListener('change', closeDrawer);
window.removeEventListener('keydown', onKey); window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer); if (armTimer) clearTimeout(armTimer);
}; };
@@ -120,17 +140,40 @@
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} /> <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> <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} {#if publicPage}
<header class="auth-brand"><BrandMark size="md" /></header> <header class="auth-brand"><BrandMark size="md" /></header>
{:else} {:else}
<header class="app-header"> <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"> <a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
<BrandMark size="sm" /> <BrandMark size="sm" />
</a> </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"> <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} {#if data.user}
<div class="account-menu"> <div class="account-menu">
<button bind:this={accountButton} class="account-button" type="button" <button bind:this={accountButton} class="account-button" type="button"
@@ -143,6 +186,7 @@
<div class="account-links" id="account-links"> <div class="account-links" id="account-links">
<div class="account-name">{accountName}</div> <div class="account-name">{accountName}</div>
<a href="/account">My account</a> <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} {#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> <button disabled={signingOut} onclick={signOut}>{signingOut ? 'Signing out…' : 'Sign out'}</button>
{#if signOutError}<p role="alert">{signOutError}</p>{/if} {#if signOutError}<p role="alert">{signOutError}</p>{/if}
@@ -154,13 +198,13 @@
</header> </header>
{/if} {/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}> <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} {#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()} {@render children()}
</main> </main>
<Confirmation /> <Confirmation />
<Dialog bind:open={helpOpen} title="Keyboard shortcuts" description="Press g, then the destination key. Shortcuts are inactive while typing or using a dialog."> {#if sessionReady && data.user}<CommandPalette bind:open={paletteOpen} role={data.user.role} />{/if}
<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> <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> </Dialog>
</div> </div>
@@ -204,14 +248,29 @@
} }
.shell { min-height: 100vh; } .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; } .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 { 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; } 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 { 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); } .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; } .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; } .header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
.account-menu { position:relative; } .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; } .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); } .account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
.shortcuts { display:grid; gap:12px; margin:0; } .shortcuts { display:grid; gap:12px; margin:0; }
.shortcuts > div { display:flex; gap:20px; align-items:center; } .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); } .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; } 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); } .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) { min-width:0; max-width:100%; }
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; } :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; } :global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
@media (max-width:999px) { @media (max-width:960px) {
.app-header { flex-wrap:wrap; gap:0 16px; padding-top:8px; } .shell.with-sidebar { padding-left: 0; }
.primary-navigation { order:3; flex-basis:100%; } .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) { @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; } .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(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; } :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); } :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> </style>
+31 -12
View File
@@ -45,6 +45,8 @@
{ id: 'attention', label: 'Needs attention' }, { id: 'testing', label: 'Testing' }, { id: 'pinned', label: 'Pinned' } { 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() { function readViewUrl() {
const params = new URL(window.location.href).searchParams; const params = new URL(window.location.href).searchParams;
query = params.get('q') ?? ''; query = params.get('q') ?? '';
@@ -160,11 +162,11 @@
function resetFilters() { query = ''; filter = 'all'; game = ''; } function resetFilters() { query = ''; filter = 'all'; game = ''; }
</script> </script>
<svelte:head><title>Fleet · Electric Cherry</title></svelte:head> <svelte:head><title>Servers · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<div class="page-heading"> <div class="page-heading">
<SectionHeader title="Fleet" /> <SectionHeader title="Servers" />
{#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if} {#if canOperate}<Button variant="primary" onclick={() => launch()}>Create server</Button>{/if}
</div> </div>
<div class="sr-only" role="status" aria-atomic="true">{announcement}</div> <div class="sr-only" role="status" aria-atomic="true">{announcement}</div>
@@ -196,11 +198,16 @@
{#each problems as server (server.name)} {#each problems as server (server.name)}
{@const status = serverHealth(server, observationNow())} {@const status = serverHealth(server, observationNow())}
{@const operation = attentionOperations.find(item => item.server === server.name)} {@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-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> <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={operation && status.state === 'online' ? 'attention' : status.state} /> <StatusBadge state={operationIssue ? '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> <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>
<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-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> </div>
{/each} {/each}
</section> </section>
@@ -316,9 +323,15 @@
.attention h2 { padding: var(--space-3) var(--space-4); font-size: var(--text-sm); color: var(--state-warning); } .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-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-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 { 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 { color: var(--text-primary); text-decoration: none; font-size: var(--text-sm); font-weight: var(--weight-semibold); }
.server-link:hover { text-decoration: underline; } .server-link:hover { text-decoration: underline; }
.fleet-section { display: grid; gap: var(--space-3); min-width: 0; } .fleet-section { display: grid; gap: var(--space-3); min-width: 0; }
@@ -339,7 +352,7 @@
.pin { font-size: var(--text-md); } .pin { font-size: var(--text-md); }
.fleet-footer { color: var(--text-tertiary); font-size: var(--text-xs); } .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%); } .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) { @media (max-width: 600px) {
.page { gap: var(--space-3); } .page { gap: var(--space-3); }
.overview { gap: var(--space-1) var(--space-3); font-size: var(--text-xs); } .overview { gap: var(--space-1) var(--space-3); font-size: var(--text-xs); }
@@ -348,9 +361,15 @@
.filter-row { flex-wrap: wrap; } .filter-row { flex-wrap: wrap; }
.chips { flex-wrap: nowrap; overflow-x: auto; width: 100%; padding: 3px; } .chips { flex-wrap: nowrap; overflow-x: auto; width: 100%; padding: 3px; }
.filter-chip { flex-shrink: 0; min-height: 44px; } .filter-chip { flex-shrink: 0; min-height: 44px; }
.attention-row { gap: var(--space-2); padding: var(--space-3); } .attention-row { gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); }
.attention-row p { grid-column: 1; } .attention-identity .server-link { display: flex; align-items: center; min-height: 44px; }
.attention-row :global(.btn) { grid-column: 2; grid-row: 2; justify-self: end; padding-inline: var(--space-2); font-size: var(--text-xs); } .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); } .attention h2 { padding: var(--space-3); }
.cards { grid-template-columns: 1fr; } .cards { grid-template-columns: 1fr; }
} }
-2
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js'; import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
@@ -202,7 +201,6 @@
<svelte:head><title>New beta | Electric Cherry</title></svelte:head> <svelte:head><title>New beta | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} /> <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." /> <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 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} {#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"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
@@ -98,9 +97,8 @@
<svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head> <svelte:head><title>{collection?.name ?? 'Collection launch'} | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title={collection?.name ?? 'Collection launch'}> <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> </SectionHeader>
<BackLink fallback="/collections?view=active" />
{#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if} {#if error}<p class="error" role="alert">{error} <button onclick={load}>Retry</button></p>{/if}
{#if collection && detail} {#if collection && detail}
<section class="summary" aria-label="Collection progress"> <section class="summary" aria-label="Collection progress">
+1
View File
@@ -16,6 +16,7 @@
let search = $state(''); let status = $state(''); let search = $state(''); let status = $state('');
let loadSequence = 0; let loadSequence = 0;
const canManage = $derived($page.data.user?.role === 'admin'); 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 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 ready = $derived(hosts.filter(host => hostListStatus(host, now).key === 'ready').length);
const filtersVisible = $derived(hosts.length > 5 || !!search || !!status); const filtersVisible = $derived(hosts.length > 5 || !!search || !!status);
+1 -4
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte'; import Tabs from '$ui/components/primitives/Tabs.svelte';
import { contextHref } from '$lib/navigation-context.js'; 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 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} {#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> </SectionHeader>
<BackLink fallback="/hosts" />
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#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 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} {#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" /> <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}`}> <div class="host-panel" id="host-panel" role="tabpanel" aria-labelledby={`host-tabs-${tab}`}>
{#if tab === 'overview'} {#if tab === 'overview'}
<div class="overview-actions"><Button onclick={() => changeTab('servers')}>View {instances.length} servers</Button></div>
<section class="panel"><h2>Agent workers</h2> <section class="panel"><h2>Agent workers</h2>
{#if host.inventory.agentLanes?.length}<dl>{#each host.inventory.agentLanes as lane (lane.name)} {#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> <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); } .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; } .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); } .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> </style>
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
@@ -64,10 +63,9 @@
<section class="profile"> <section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats"> <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} {#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader> </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> <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} {#if refreshError}<p role="alert">{refreshError}</p>{/if}
-2
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js'; 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} {#if canDismiss}<Button variant="ghost" disabled={!!busy || activeOperation} loading={busy === 'dismiss'} onclick={() => update('dismiss')}>Dismiss incident</Button>{/if}
{/snippet} {/snippet}
</SectionHeader> </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 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} {#if actionError}<p class="ui-error" role="alert">{actionError}</p>{/if}
-2
View File
@@ -2,7 +2,6 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import type { MatchDetail } from '$lib/match-history.js'; import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js'; import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.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 crumbs()}<Breadcrumbs items={[{ label: 'Match History', href: '/matches' }, { label: `Match #${match.id}` }]} />{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet} {#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
</SectionHeader> </SectionHeader>
<BackLink fallback="/matches" />
<section class="summary" aria-label="Match summary"> <section class="summary" aria-label="Match summary">
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card> <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 { freshStatusTimestamp } from '$lib/server-freshness.js';
import { serverSection, serverSectionAnchor } from '$lib/server-sections.js'; import { serverSection, serverSectionAnchor } from '$lib/server-sections.js';
import { contextHref } from '$lib/navigation-context.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(); let { data }: { data: import('./$types').PageData } = $props();
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte'; import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
@@ -224,7 +224,7 @@
{#if server} {#if server}
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};"> <div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<header class="instance-header"> <header class="instance-header">
<BackLink fallback="/" /> <Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
<SectionHeader title={serverTitle(server.name)}> <SectionHeader title={serverTitle(server.name)}>
{#snippet actions()} {#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> <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', '/'], ['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'], ['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'], ['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'operations', '/hosts/operations'], ['/hosts/operations', 'activity', '/hosts/operations'],
['/provisioning', 'operations', '/provisioning'], ['/provisioning', 'activity', '/provisioning'],
['/betas/new', 'operations', '/betas'], ['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'operations', '/campaigns'], ['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'library', '/configs'], ['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'analytics', '/leaderboard'], ['/leaderboard/players/42', 'players', '/leaderboard'],
['/settings/users', 'administration', '/settings/users'], ['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'administration', '/server-keys'] ['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => { ])('gives %s exactly one canonical home', (path, group, href) => {
expect(navigationLocation(path, 'admin')?.group.id).toBe(group); 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]); 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 () => { it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'operations')?.items[0].href).toBe('/provisioning'); expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/provisioning');
const { load } = await import('../../src/routes/operations/+page.server.js'); const { load } = await import('../../src/routes/operations/+page.server.js');
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' })); expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' }));
}); });
it('keeps administration and its shortcuts out of operator and viewer navigation', () => { it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
for (const role of ['operator', 'viewer'] as const) { 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(navigationLocation('/server-keys', role)).toBeNull();
expect(visibleNavigation(role).flatMap(group => group.items).filter(item => ['h', 'k'].includes(item.shortcut ?? ''))).toEqual([]); 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) }]) { { ...server.host!, lastHeartbeat: iso(-91_000) }]) {
expect(serverHealth({ ...server, host, health: 'offline', container_state: 'stopped' }, now)).toEqual({ 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.", 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', () => { 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({ 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.', 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'); expect(serverHealth({ ...server, health: 'offline', container_state: 'missing', last_seen_at: null }, now).state).toBe('unknown');
}); });