From e4f37f69645ccf068c72132484b4b1f03f9a1ba7 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 18:59:39 -0400 Subject: [PATCH] Add sidebar navigation and global command search --- README.md | 4 +- docs/development/ui-patterns.md | 15 ++ docs/guides/authentication.md | 2 +- docs/guides/collections.md | 6 +- docs/guides/getting-started.md | 6 +- docs/guides/maintenance.md | 4 +- docs/guides/metrics.md | 2 +- docs/guides/native-hosts.md | 2 +- docs/guides/provisioning.md | 6 +- docs/guides/settings.md | 2 +- src/lib/navigation.ts | 36 ++--- src/lib/ui/command-palette.ts | 88 ++++++++++++ src/lib/ui/components/ArtifactDetail.svelte | 2 - src/lib/ui/components/CommandPalette.svelte | 130 ++++++++++++++++++ src/lib/ui/components/Navigation.svelte | 87 +++++------- .../components/primitives/Breadcrumbs.svelte | 18 ++- src/lib/ui/server-health.ts | 16 ++- src/routes/+layout.svelte | 101 +++++++++++--- src/routes/+page.svelte | 43 ++++-- src/routes/betas/new/+page.svelte | 2 - .../collections/launches/[id]/+page.svelte | 4 +- src/routes/hosts/+page.svelte | 1 + src/routes/hosts/[id]/+page.svelte | 5 +- .../leaderboard/players/[guid]/+page.svelte | 4 +- src/routes/maintenance/[id]/+page.svelte | 2 - src/routes/matches/[id]/+page.svelte | 2 - src/routes/servers/[name]/+page.svelte | 4 +- tests/unit/command-palette.test.ts | 61 ++++++++ tests/unit/navigation.test.ts | 28 ++-- tests/unit/ui-breadcrumbs.test.ts | 34 +++++ tests/unit/ui-command-palette.test.ts | 24 ++++ tests/unit/ui-navigation.test.ts | 66 +++++++++ tests/unit/ui-server-health.test.ts | 4 +- 33 files changed, 660 insertions(+), 151 deletions(-) create mode 100644 src/lib/ui/command-palette.ts create mode 100644 src/lib/ui/components/CommandPalette.svelte create mode 100644 tests/unit/command-palette.test.ts create mode 100644 tests/unit/ui-breadcrumbs.test.ts create mode 100644 tests/unit/ui-command-palette.test.ts create mode 100644 tests/unit/ui-navigation.test.ts diff --git a/README.md b/README.md index dc1ff37..2249345 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ Open the dashboard and create your first administrator with the setup token. Remove the token from `.env` afterward and recreate the controller with `docker compose up -d`. -1. Upload your T5 or T6 folder, including DLC, under **Library → Game Files**. +1. Upload your T5 or T6 folder, including DLC, under **Releases → Game files**. 2. Add a machine under **Fleet → Hosts** and run its generated enrollment command. -3. Save your Plutonium keys under **Administration → Keys**. +3. Save your Plutonium keys under **Admin → Keys**. 4. Wait for host setup to finish, then launch a server or collection. See the [setup guide](docs/guides/getting-started.md) for telemetry, storage, diff --git a/docs/development/ui-patterns.md b/docs/development/ui-patterns.md index 2a7bd34..e0565e2 100644 --- a/docs/development/ui-patterns.md +++ b/docs/development/ui-patterns.md @@ -38,3 +38,18 @@ Preserve visible keyboard focus, associated field labels and separate navigation and action targets. Avoid nested buttons or placing a button inside a link. Allow long server identities to wrap and tab/filter rows to scroll on narrow screens. + +## Navigation + +Use the shared navigation metadata for Fleet, Releases, Activity, Players, and +Admin. Campaigns stays under Releases beside Scripts, Configs, Plugins, Game +files, and Betas. The Servers page is the Fleet landing page. Desktop navigation +uses a collapsible sidebar; mobile navigation uses a modal drawer. + +Use Cmd/Ctrl K to search pages, servers, hosts, players, and recorded matches. +Common actions open their existing forms and retain role checks and confirmations. +Keyboard shortcut help is available from the account menu and with `?`. + +Detail pages use `Breadcrumbs`. Its parent link restores the validated `returnTo` +URL, including filters, sorting, pagination, and anchors. Supply `fallback` when +the default parent also needs filters. Do not add a duplicate Back link. diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index e49a6ae..e533d86 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -20,7 +20,7 @@ their access checks. HTTPS requirements still apply. See the password. Only the holder of the token can create the first administrator. 4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller. Setup remains closed once any user exists, even if the token is still set. -5. Use **Administration → Users** to create additional accounts. Share temporary +5. Use **Admin → Users** to create additional accounts. Share temporary passwords privately; each user must change theirs on first login. Existing installations become protected on upgrade. Existing game servers and diff --git a/docs/guides/collections.md b/docs/guides/collections.md index f7228ad..be0d197 100644 --- a/docs/guides/collections.md +++ b/docs/guides/collections.md @@ -37,7 +37,7 @@ It fills deficits without changing existing servers' files. plan. Review the change summary and the map table grouped by game. 4. Check the required key coverage for each game. Available keys count once across the full lineup, and every new server needs a key. If a game is short, - open **Administration → Keys** to add keys or wait for assigned keys to be + open **Admin → Keys** to add keys or wait for assigned keys to be released. Returning to the dialog rechecks the pools and fleet. 5. Review the capacity summary. Expand its details for CPU, memory, ports, and startup slots. Capacity estimates are advisory: queued launches can wait for @@ -142,7 +142,7 @@ All collections and individual launches share one startup queue, ordered by requ time. By default, one server starts at a time. Its startup slot remains occupied until the host agent confirms container health, game status and the selected map are verified, and RCON reports a current Plutonium revision. -Only then can the next queued server start. Set **Concurrent server startups** in **Administration → Settings** to +Only then can the next queued server start. Set **Concurrent server startups** in **Admin → Settings** to a positive integer to allow more simultaneous startups. Each worker pass admits at most the available slots, even if an attempt fails immediately. @@ -190,7 +190,7 @@ against the remaining fleet. 2. Choose a game and map. The dialog automatically checks the current promoted setup, latest game files, available lobby, and game key pool as selections change. 3. Review the readiness summary and resolve any displayed blockers. When no game - key is available, open **Administration → Keys** and add one or wait for a key + key is available, open **Admin → Keys** and add one or wait for a key to be released. Returning to the launch dialog rechecks availability; **Retry checks** also runs the checks again. 4. If needed, expand **Advanced settings** for runtime and network overrides. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index bda6bb0..5eb828b 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -46,13 +46,13 @@ Proxy the HTTPS address to controller port 3000. Open the dashboard and follow `.env` and run `docker compose up -d` to recreate the controller. Setup stays closed once an account exists. -Add other users under **Administration → Users**. The +Add other users under **Admin → Users**. The [access guide](authentication.md) explains roles, proxy configuration, and password recovery. ## Prepare a game host -1. Open **Library → Game Files** and upload the complete local folder for T5 or +1. Open **Releases → Game files** and upload the complete local folder for T5 or T6, including DLC. Uploads can run before any host is enrolled. The latest verified upload becomes that game's default files. 2. Open **Fleet → Hosts → Add Host**, configure the host's game-port range and @@ -67,7 +67,7 @@ base game files; each instance has its own writable data. See ## Launch your first server -1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**. +1. Save Plutonium server keys in the T5 or T6 pool under **Admin → Keys**. 2. Open **Create server** and choose a game and map. Readiness checks run automatically. For a reusable lineup, use **Fleet → Collections** instead. 3. Resolve any launch blockers and choose **Launch server**. Provisioning queues diff --git a/docs/guides/maintenance.md b/docs/guides/maintenance.md index dbb40bb..0a5f915 100644 --- a/docs/guides/maintenance.md +++ b/docs/guides/maintenance.md @@ -1,6 +1,6 @@ # Maintenance -Open **Operations → Maintenance** to keep existing servers healthy and current. +Open **Activity → Maintenance** to keep existing servers healthy and current. Maintenance owns observations, policies, schedules, requests, and verification of maintenance outcomes. Provisioning owns lifecycle execution, player checks, conflicts, rolling capacity, container recovery, and persistent operation history. @@ -98,7 +98,7 @@ Schedules survive application restarts. Missed days, including while globally paused, become one run on resume. Pending work is deduplicated per server. The worker starts on the first application request, so keep the app running and reachable. Finished scheduled and legacy maintenance activity is retained for -30 days by default (configurable in Administration → Settings); update attempt records remain durable. +30 days by default (configurable in Admin → Settings); update attempt records remain durable. ## Health recovery and configuration drift diff --git a/docs/guides/metrics.md b/docs/guides/metrics.md index a2b7cf0..f5e02d2 100644 --- a/docs/guides/metrics.md +++ b/docs/guides/metrics.md @@ -90,7 +90,7 @@ Observations are recorded by the existing status worker. Each remains valid unti Capacity comes from `sv_maxclients` / `sv_maxClients` in the status response. When unavailable, the UI displays unknown rather than assuming a four-player limit. Capacity coverage is the map time for which every tracked lobby reported both occupancy and capacity. -Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Administration → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. +Collection begins after this feature is installed; previous occupancy cannot be reconstructed. Samples default to 31 days of retention; Admin → Settings can change this period. Cleanup runs during collection and in the background. They survive removal of a server and contain no player identities. The additive database table is created automatically on startup. ## API diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index 9ae2241..d941fa5 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -555,7 +555,7 @@ on heartbeats, including vanished caches. The latest published version of each g is always retained. With agent 1.4.7 or newer, an older host copy is automatically eligible for removal once no live instance reservation on that host references it. The game-file minimum count, supersession grace period and explicit protections in -Administration → Settings also apply. Stopped, +Admin → Settings also apply. Stopped, provisioning, and rebuilding instances still pin their version. The agent also refuses deletion if any Docker container (including stopped containers) mounts those files. Cleanup includes partial downloads and retries after interruption. diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index 0de2ae0..f333ca5 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -1,6 +1,6 @@ # Provisioning -Open **Operations → Provisioning** for instance lifecycle work and persistent +Open **Activity → Operations** for instance lifecycle work and persistent activity. **Fleet → Collections** owns reusable lineups and collection launch progress; their individual operations remain visible in Provisioning. Provisioning owns the queues, safety checks, execution, and @@ -83,7 +83,7 @@ same key. Confirmed removal releases it for another launch. Failed or ambiguous creation keeps the assignment while cleanup may still be required. An unstarted creation cancelled before dispatch releases its key immediately. -Administrators can add and remove available keys in **Administration → Keys**; +Administrators can add and remove available keys in **Admin → Keys**; assigned keys cannot be removed. Existing keys are migrated into their game pool, retaining assignments for existing servers and pending work. If a legacy rebuild has an uncertain result and used a different key, both possible keys @@ -248,7 +248,7 @@ See [native host management](native-hosts.md) for command delivery and recovery. ## Concurrency limits -Administration → Settings separates Concurrency and Retention, with independent +Admin → Settings separates Concurrency and Retention, with independent save actions. Existing fleet restart, startup and destruction limits continue to apply. Additional per-host restart, startup and destruction limits, a combined lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default diff --git a/docs/guides/settings.md b/docs/guides/settings.md index d3049c0..6ce83a8 100644 --- a/docs/guides/settings.md +++ b/docs/guides/settings.md @@ -1,6 +1,6 @@ # Fleet settings -Administration → Settings has independent **Concurrency** and **Retention** +Admin → Settings has independent **Concurrency** and **Retention** sections. Settings persist in SQLite, apply without restarting the controller, and are writable only by administrators. Saving one section preserves the other section's saved values. Upgrades preserve existing values and add new defaults. diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 38d2821..a8e6573 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -5,34 +5,34 @@ export interface NavigationGroup { id: string; label: string; admin?: boolean; i export const navigationGroups: NavigationGroup[] = [ { id: 'fleet', label: 'Fleet', items: [ - { label: 'Overview', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') }, - { label: 'Collections', href: '/collections' }, - { label: 'Autoscaling', href: '/autoscaling' }, + { label: 'Servers', href: '/', shortcut: 'f', matches: path => path === '/' || path.startsWith('/servers/') }, { label: 'Hosts', href: '/hosts', shortcut: 'o', matches: path => path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path) }, - { label: 'Capacity', href: '/capacity' } + { label: 'Capacity', href: '/capacity' }, + { label: 'Autoscaling', href: '/autoscaling' }, + { label: 'Collections', href: '/collections' } ] }, - { id: 'library', label: 'Library', items: [ + { id: 'releases', label: 'Releases', items: [ { label: 'Scripts', href: '/scripts', shortcut: 's' }, { label: 'Configs', href: '/configs', shortcut: 'c' }, { label: 'Plugins', href: '/plugins', shortcut: 'p' }, - { label: 'Game Files', href: '/game-files' } - ] }, - { id: 'operations', label: 'Operations', items: [ - { label: 'Provisioning', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) }, + { label: 'Game files', href: '/game-files' }, { label: 'Betas', href: '/betas', shortcut: 'b' }, - { label: 'Campaigns', href: '/campaigns', shortcut: 'a' }, - { label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }, - { label: 'Host operation history', href: '/hosts/operations' } + { label: 'Campaigns', href: '/campaigns', shortcut: 'a' } ] }, - { id: 'analytics', label: 'Analytics', items: [ - { label: 'Metrics', href: '/metrics', shortcut: 'm' }, + { id: 'activity', label: 'Activity', items: [ + { label: 'Operations', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) }, + { label: 'Host operations', href: '/hosts/operations' }, + { label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) } + ] }, + { id: 'players', label: 'Players', items: [ { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, - { label: 'Match History', href: '/matches' } + { label: 'Matches', href: '/matches' }, + { label: 'Usage', href: '/metrics', shortcut: 'm' } ] }, - { id: 'administration', label: 'Administration', admin: true, items: [ - { label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' }, + { id: 'admin', label: 'Admin', admin: true, items: [ + { label: 'Users', href: '/settings/users' }, { label: 'Keys', href: '/server-keys', shortcut: 'k' }, - { label: 'Users', href: '/settings/users' } + { label: 'Settings', href: '/settings', shortcut: 'h', matches: path => path === '/settings' } ] } ]; diff --git a/src/lib/ui/command-palette.ts b/src/lib/ui/command-palette.ts new file mode 100644 index 0000000..7e6136a --- /dev/null +++ b/src/lib/ui/command-palette.ts @@ -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; +export const paletteGroups: PaletteGroup[] = ['Actions', 'Pages', 'Servers', 'Hosts', 'Players', 'Matches']; + +function matches(query: string, ...values: Array) { + 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; +} + +/** Load only the current directory; historical entities use bounded queries below. */ +export async function loadPaletteDirectory(signal: AbortSignal, fetcher: PaletteFetch = fetch): Promise { + 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) }; +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 3227bd3..61b6363 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,7 +1,6 @@ + + +
+ + +

Try a lobby, player name, or #match number. ↑ ↓ to choose · Enter to open

+
+ {#each paletteGroups as group} + {@const groupItems = items.filter(item => item.group === group)} + {#if groupItems.length} +
+
{group}
+ {#each groupItems as item (item.key)} + {@const index = items.indexOf(item)} + + {/each} +
+ {/if} + {/each} +
+

{searching ? 'Searching…' : items.length ? `${items.length} results` : 'No matches. Try a different name or a match number.'}

+ {#if unavailable.length}

{unavailable.join(', ')} search is unavailable. Other results are still available.

{/if} + {#if navigationError}{/if} +
+
+ + diff --git a/src/lib/ui/components/Navigation.svelte b/src/lib/ui/components/Navigation.svelte index d014077..2089b55 100644 --- a/src/lib/ui/components/Navigation.svelte +++ b/src/lib/ui/components/Navigation.svelte @@ -1,61 +1,48 @@ -{#if variant === 'primary'} - -{:else if location} - -{/if} + diff --git a/src/lib/ui/components/primitives/Breadcrumbs.svelte b/src/lib/ui/components/primitives/Breadcrumbs.svelte index 6199fe3..8cdf620 100644 --- a/src/lib/ui/components/primitives/Breadcrumbs.svelte +++ b/src/lib/ui/components/primitives/Breadcrumbs.svelte @@ -1,6 +1,7 @@