Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
386 lines
20 KiB
Svelte
386 lines
20 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { startTimeZonePreference } from '$ui/stores/time-zone.js';
|
||
import { afterNavigate, onNavigate, goto } from '$app/navigation';
|
||
import { startPipelineStream } from '$ui/stores/pipeline.js';
|
||
import { startServerStream } from '$ui/stores/servers.js';
|
||
import { startFleetMonitor } from '$ui/stores/fleet.js';
|
||
import { page } from '$app/stores';
|
||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||
import Toaster from '$ui/components/Toaster.svelte';
|
||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||
import NotificationHistory from '$ui/components/NotificationHistory.svelte';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import { notificationHistory, startNotificationHistory } from '$ui/toast.js';
|
||
import { resolveConfirmation } from '$ui/confirm.js';
|
||
import '$ui/styles/components.css';
|
||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||
import type { AuthUser } from '$lib/auth.js';
|
||
// Side-effect import — puts @fontsource CSS on Vite's module graph so
|
||
// the referenced woff/woff2 files get emitted into the build.
|
||
import '$ui/styles/fonts.js';
|
||
|
||
interface Props { children: import('svelte').Snippet; data: { user: AuthUser | null; setupRequired: boolean } }
|
||
let { children, data }: Props = $props();
|
||
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
|
||
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
|
||
let menuOpen = $state(false);
|
||
let accountOpen = $state(false);
|
||
let signingOut = $state(false);
|
||
let signOutError = $state('');
|
||
let notificationsOpen = $state(false);
|
||
let helpOpen = $state(false);
|
||
const unread = $derived($notificationHistory.filter(item => !item.read).length);
|
||
let menuButton = $state<HTMLButtonElement>();
|
||
let accountButton = $state<HTMLButtonElement>();
|
||
|
||
function openUtility(panel: 'notifications' | 'help') {
|
||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||
if (panel === 'notifications') notificationsOpen = true;
|
||
else helpOpen = true;
|
||
}
|
||
|
||
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
|
||
|
||
function closeMenu(event: KeyboardEvent) {
|
||
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
|
||
if (event.key === 'Escape' && menuOpen) {
|
||
menuOpen = false;
|
||
menuButton?.focus();
|
||
}
|
||
}
|
||
|
||
async function signOut() {
|
||
if (signingOut) return;
|
||
signingOut = true; signOutError = '';
|
||
try {
|
||
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out. Try again.'); }
|
||
try { sessionStorage.removeItem('electric-cherry:notifications'); } catch { /* Optional local history. */ }
|
||
notificationHistory.set([]);
|
||
window.location.assign('/login');
|
||
} catch (cause) { signOutError = (cause as Error).message; signingOut = false; }
|
||
}
|
||
|
||
$effect(() => {
|
||
if (!sessionReady) return;
|
||
const stopNotifications = startNotificationHistory();
|
||
const stopPipeline = startPipelineStream();
|
||
const stopServers = startServerStream();
|
||
const stopFleet = startFleetMonitor();
|
||
return () => { stopNotifications(); stopFleet(); stopPipeline?.(); stopServers?.(); };
|
||
});
|
||
|
||
onMount(() => {
|
||
const stopTimeZone = startTimeZonePreference();
|
||
|
||
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` provisioning,
|
||
// `g b` betas, `g s` scripts. Ignored while typing in inputs.
|
||
const shortcuts: Record<string, string> = {
|
||
f: '/', d: '/provisioning', a: '/campaigns', w: '/maintenance', b: '/betas',
|
||
k: '/server-keys', s: '/scripts', c: '/configs', p: '/plugins', l: '/leaderboard', h: '/settings', m: '/metrics', o: '/hosts'
|
||
};
|
||
let armed = false;
|
||
let armTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!sessionReady) 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;
|
||
if (e.key === '?') { e.preventDefault(); openUtility('help'); return; }
|
||
|
||
if (!armed && e.key === 'g') {
|
||
armed = true;
|
||
if (armTimer) clearTimeout(armTimer);
|
||
armTimer = setTimeout(() => { armed = false; }, 1000);
|
||
return;
|
||
}
|
||
if (armed) {
|
||
armed = false;
|
||
if (armTimer) { clearTimeout(armTimer); armTimer = null; }
|
||
const dest = shortcuts[e.key];
|
||
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
|
||
if (dest) {
|
||
e.preventDefault();
|
||
goto(dest);
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => {
|
||
stopTimeZone();
|
||
window.removeEventListener('keydown', onKey);
|
||
if (armTimer) clearTimeout(armTimer);
|
||
};
|
||
});
|
||
|
||
// Route changes cross-fade via the browser's View Transitions API.
|
||
// Non-Chromium browsers fall through to plain navigation.
|
||
onNavigate(enableViewTransitions);
|
||
|
||
const tabs = [
|
||
{ href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
|
||
{ href: '/hosts', label: 'Hosts', match: (p: string) => /^\/hosts(\/|$)/.test(p) },
|
||
{ href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins|game-files)(\/|$)/.test(p) },
|
||
{ href: '/provisioning', label: 'Operations', match: (p: string) => /^\/(provisioning|deployments|collections|campaigns|maintenance|watchdog|server-keys)(\/|$)/.test(p) },
|
||
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
|
||
{ href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/settings') },
|
||
{ href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') },
|
||
{ href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') },
|
||
{ href: '/metrics', label: 'Metrics', match: (p: string) => p === '/metrics' }
|
||
];
|
||
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
|
||
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
|
||
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
|
||
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
|
||
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
|
||
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
|
||
</script>
|
||
|
||
<svelte:window onkeydown={closeMenu} />
|
||
|
||
<div class="shell">
|
||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||
{#if publicPage}
|
||
<header class="auth-brand"><BrandMark size="md" /></header>
|
||
{:else}
|
||
<nav aria-label="Primary">
|
||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||
<BrandMark size="sm" />
|
||
</a>
|
||
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
|
||
aria-expanded={menuOpen} aria-controls="primary-links"
|
||
onclick={() => { menuOpen = !menuOpen; }}>
|
||
{menuOpen ? 'Close' : 'Menu'}
|
||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||
</button>
|
||
<div class="tabs" id="primary-links" class:expanded={menuOpen}>
|
||
{#each visibleTabs as t (t.href)}
|
||
{@const on = t.match($page.url.pathname)}
|
||
<a
|
||
href={t.href}
|
||
class:on
|
||
aria-current={on ? 'page' : undefined}
|
||
>
|
||
<span class="lbl">{t.label}</span>
|
||
</a>
|
||
{/each}
|
||
</div>{/if}
|
||
<div class="nav-tools" class:expanded={menuOpen} class:account-only={!sessionReady}>
|
||
{#if sessionReady}<button class="utility" aria-label={`Recent notifications${unread ? `, ${unread} unread` : ''}`} onclick={() => openUtility('notifications')}>Activity{unread ? ` · ${unread}` : ''}</button><button class="utility" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onclick={() => openUtility('help')}>?</button>{/if}
|
||
{#if data.user}<div class="account-menu"><button bind:this={accountButton} class="utility account-button" aria-expanded={accountOpen} aria-controls="account-links" onclick={() => accountOpen = !accountOpen}>{data.user.displayName || data.user.username}<span class="role">{data.user.role}</span></button>
|
||
{#if accountOpen}<div class="account-links" id="account-links"><a href="/account">My account</a>{#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}</div>{/if}
|
||
</div>{/if}
|
||
</div>
|
||
</nav>
|
||
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
|
||
{/if}
|
||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||
{#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>
|
||
<Toaster />
|
||
<Confirmation />
|
||
<NotificationHistory bind:open={notificationsOpen} />
|
||
<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 [['g f','Fleet'],['g o','Hosts'],['g s','Scripts'],['g c','Configs'],['g p','Plugins'],['g d','Provisioning'],['g a','Campaigns'],['g w','Maintenance'],['g b','Betas'],...(data.user?.role === 'admin' ? [['g h','Settings'],['g k','Keys']] : []),['g l','Leaderboard'],['g m','Metrics'],['?','This help']] as [keys,label]}<div><dt><kbd>{keys}</kbd></dt><dd>{label}</dd></div>{/each}</dl>
|
||
</Dialog>
|
||
</div>
|
||
|
||
<style>
|
||
@import '$ui/styles/tokens.css';
|
||
|
||
:global(html, body) {
|
||
margin: 0;
|
||
background: var(--surface-sunken);
|
||
color: var(--text-primary);
|
||
font-family: var(--font-body);
|
||
font-size: var(--text-base);
|
||
line-height: var(--leading-body);
|
||
min-height: 100vh;
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
:global(*) { box-sizing: border-box; }
|
||
:global(a) { color: var(--accent-primary-hover); }
|
||
:global(::selection) {
|
||
background: var(--accent-primary);
|
||
color: var(--text-on-accent);
|
||
}
|
||
:global(:focus-visible) {
|
||
outline: 2px solid var(--accent-primary-hover);
|
||
outline-offset: 3px;
|
||
}
|
||
|
||
/* Ambient background — a faint radial gradient hints at depth without
|
||
any lines. Fixed to the viewport so scrolling stays quiet. */
|
||
:global(body::before) {
|
||
content: '';
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
background:
|
||
radial-gradient(1200px 700px at 20% -10%, rgba(46, 134, 209, 0.10), transparent 60%),
|
||
radial-gradient(900px 600px at 90% 100%, rgba(124, 192, 245, 0.05), transparent 60%);
|
||
z-index: -1;
|
||
}
|
||
|
||
.shell { min-height: 100vh; }
|
||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
||
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); }
|
||
.account-menu { position:relative; }
|
||
.account-button { display:flex; align-items:center; gap:8px; max-width:220px; overflow:hidden; text-overflow:ellipsis; }
|
||
.role { text-transform:capitalize; color:var(--text-tertiary); font-size:var(--text-xs); }
|
||
.account-links { position:absolute; right:0; top:calc(100% + 8px); min-width:200px; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
|
||
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
|
||
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
|
||
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||
|
||
nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-6);
|
||
height: 60px;
|
||
padding: 0 var(--space-8);
|
||
background: color-mix(in oklab, var(--surface) 75%, transparent);
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
@supports (backdrop-filter: blur(4px)) {
|
||
nav {
|
||
backdrop-filter: blur(20px) saturate(1.4);
|
||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||
background: color-mix(in oklab, var(--surface) 62%, transparent);
|
||
}
|
||
}
|
||
|
||
.brand-link {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
text-decoration: none;
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.brand-link:focus-visible { box-shadow: var(--focus-ring); }
|
||
|
||
.tabs {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
margin-left: var(--space-4);
|
||
}
|
||
.tabs a {
|
||
white-space: nowrap;
|
||
position: relative;
|
||
color: var(--text-tertiary);
|
||
text-decoration: none;
|
||
padding: 8px 14px;
|
||
border-radius: var(--radius-pill);
|
||
font-size: var(--text-sm);
|
||
font-weight: var(--weight-medium);
|
||
letter-spacing: var(--tracking-normal);
|
||
transition:
|
||
color var(--dur-fast) var(--ease-standard),
|
||
background-color var(--dur-fast) var(--ease-standard);
|
||
}
|
||
.tabs a:hover { color: var(--text-primary); }
|
||
.tabs a:focus-visible { box-shadow: var(--focus-ring); }
|
||
.tabs a.on {
|
||
color: var(--text-primary);
|
||
background: var(--surface-raised);
|
||
/* Shared-element transition — pill slides smoothly between tabs
|
||
when navigating. Chromium only; falls back to instant swap. */
|
||
view-transition-name: nav-active;
|
||
}
|
||
|
||
.nav-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||
.utility { background: var(--surface-raised); color: var(--text-secondary); border: 1px solid var(--border-strong); padding: 8px 10px; border-radius: var(--radius-md); cursor: pointer; font: inherit; font-size: var(--text-xs); white-space: nowrap; }
|
||
.shortcuts { display: grid; gap: 12px; margin: 0; }
|
||
.shortcuts > div { display: flex; gap: 20px; align-items: center; }
|
||
.shortcuts dt { min-width: 55px; }
|
||
.shortcuts dd { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
|
||
kbd { font-family: var(--font-mono); padding: 3px 8px; border: 1px solid var(--border-strong); border-radius: 5px; }
|
||
.menu-toggle { display: none; }
|
||
|
||
main {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
padding: var(--space-6) var(--space-8) var(--space-16);
|
||
max-width: 1440px;
|
||
margin: 0 auto;
|
||
view-transition-name: main;
|
||
}
|
||
|
||
/* The full navigation needs more room than a phone or portrait tablet. */
|
||
@media (max-width: 1500px) {
|
||
nav {
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
gap: 0;
|
||
height: auto;
|
||
min-height: 60px;
|
||
padding: calc(8px + env(safe-area-inset-top)) max(var(--space-4), env(safe-area-inset-right)) 8px max(var(--space-4), env(safe-area-inset-left));
|
||
max-height: 100dvh;
|
||
overflow-y: auto;
|
||
}
|
||
.menu-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-2);
|
||
min-height: 44px;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface-raised);
|
||
color: var(--text-primary);
|
||
font: inherit;
|
||
font-size: var(--text-sm);
|
||
cursor: pointer;
|
||
}
|
||
.menu-toggle:focus-visible { box-shadow: var(--focus-ring); }
|
||
.tabs { display: none; width: 100%; margin: 0; }
|
||
.tabs.expanded { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3) 0 4px; }
|
||
.tabs a { display: flex; align-items: center; min-height: 44px; border-radius: var(--radius-md); }
|
||
.nav-tools { display: none; }
|
||
.nav-tools.expanded { display: flex; width: 100%; padding-top: 8px; }
|
||
.nav-tools.account-only { display:flex; }
|
||
.account-menu { margin-left:auto; }
|
||
.account-links { position:static; margin-top:8px; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
main { padding: var(--space-6) 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)); }
|
||
/* Keep native controls readable and prevent focus zoom on phones. */
|
||
:global(input:not([type='checkbox']):not([type='radio'])),
|
||
:global(select), :global(textarea) { font-size: 16px !important; min-height: 44px; }
|
||
:global(button), :global(.btn) { min-height: 44px; }
|
||
}
|
||
|
||
:global(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; }
|
||
.skip-link { position: fixed; left: 16px; top: -80px; z-index: 300; padding: 12px 16px; background: var(--surface-raised); border-radius: var(--radius-md); }
|
||
.skip-link:focus { top: 12px; }
|
||
.subnav { border-bottom: 1px solid var(--border-subtle); background: var(--surface-sunken); }
|
||
.subnav > div { display: flex; gap: var(--space-6); max-width: 1440px; margin: 0 auto; padding: 0 var(--space-8); overflow-x: auto; }
|
||
.subnav a { text-decoration: none; white-space: nowrap; font-size: var(--text-sm); color: var(--text-secondary); padding: var(--space-3) 0; border-bottom: 2px solid transparent; }
|
||
.subnav a[aria-current] { color: var(--text-primary); border-bottom-color: var(--accent-primary); }
|
||
@media (max-width: 720px) { .subnav > div { padding-inline: var(--space-4); } }
|
||
|
||
/* View-transition tuning — cross-fade routes, ease the shared nav pill. */
|
||
:global(::view-transition-old(root)),
|
||
:global(::view-transition-new(root)) {
|
||
animation-duration: var(--dur-base);
|
||
animation-timing-function: var(--ease-standard);
|
||
}
|
||
:global(::view-transition-old(main)),
|
||
:global(::view-transition-new(main)) {
|
||
animation-duration: var(--dur-base);
|
||
animation-timing-function: var(--ease-standard);
|
||
}
|
||
</style>
|