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