+
{#if icon}
{@render icon()}
- {:else}
-
-
-
-
-
{/if}
{title}
{#if description}
{description}
{/if}
@@ -32,12 +27,13 @@
flex-direction: column;
align-items: center;
text-align: center;
- padding: var(--space-16) var(--space-6);
+ padding: var(--space-6);
gap: var(--space-3);
color: var(--text-secondary);
border-radius: var(--radius-lg);
background: color-mix(in oklab, var(--surface) 60%, transparent);
}
+ .spacious { padding: var(--space-12) var(--space-6); }
.icon {
color: var(--text-tertiary);
margin-bottom: var(--space-2);
diff --git a/src/lib/ui/components/primitives/Field.svelte b/src/lib/ui/components/primitives/Field.svelte
index 62b8354..efff030 100644
--- a/src/lib/ui/components/primitives/Field.svelte
+++ b/src/lib/ui/components/primitives/Field.svelte
@@ -1,5 +1,6 @@
{#if label}
-
+
{label}
{#if required}* {/if}
-
+
{/if}
{@render children?.()}
{#if error}
-
{error}
+
{error}
{:else if hint}
-
{hint}
+
{hint}
{/if}
@@ -40,9 +47,7 @@
.label {
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
- text-transform: uppercase;
- letter-spacing: var(--tracking-label);
- color: var(--text-tertiary);
+ color: var(--text-secondary);
}
.req { color: var(--state-danger); margin-left: 3px; }
.control { display: flex; flex-direction: column; }
diff --git a/src/lib/ui/components/primitives/Input.svelte b/src/lib/ui/components/primitives/Input.svelte
index d535a04..2e71d18 100644
--- a/src/lib/ui/components/primitives/Input.svelte
+++ b/src/lib/ui/components/primitives/Input.svelte
@@ -1,4 +1,7 @@
@@ -44,6 +49,10 @@
{readonly}
{autocomplete}
{accept}
+ aria-label={ariaLabel}
+ aria-labelledby={ariaLabel ? undefined : field?.labelId}
+ aria-describedby={field?.messageId}
+ aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
{oninput}
diff --git a/src/lib/ui/components/primitives/ListFooter.svelte b/src/lib/ui/components/primitives/ListFooter.svelte
new file mode 100644
index 0000000..d6ba752
--- /dev/null
+++ b/src/lib/ui/components/primitives/ListFooter.svelte
@@ -0,0 +1,8 @@
+
+{#if total > step}
+
+{/if}
+
diff --git a/src/lib/ui/components/primitives/SectionHeader.svelte b/src/lib/ui/components/primitives/SectionHeader.svelte
index c0296b9..3fb03d7 100644
--- a/src/lib/ui/components/primitives/SectionHeader.svelte
+++ b/src/lib/ui/components/primitives/SectionHeader.svelte
@@ -37,7 +37,7 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
- margin-bottom: var(--space-8);
+ margin-bottom: 0;
}
.crumbs {
font-size: var(--text-xs);
diff --git a/src/lib/ui/components/primitives/Select.svelte b/src/lib/ui/components/primitives/Select.svelte
index 4a3c27b..2dc4f4d 100644
--- a/src/lib/ui/components/primitives/Select.svelte
+++ b/src/lib/ui/components/primitives/Select.svelte
@@ -1,4 +1,7 @@
@@ -35,6 +40,10 @@
{name}
{required}
{disabled}
+ aria-label={ariaLabel}
+ aria-labelledby={ariaLabel ? undefined : field?.labelId}
+ aria-describedby={field?.messageId}
+ aria-invalid={field?.invalid || undefined}
bind:value
{onchange}
>
diff --git a/src/lib/ui/components/primitives/StatusBadge.svelte b/src/lib/ui/components/primitives/StatusBadge.svelte
new file mode 100644
index 0000000..deb6e21
--- /dev/null
+++ b/src/lib/ui/components/primitives/StatusBadge.svelte
@@ -0,0 +1,7 @@
+
+
{status.label}
diff --git a/src/lib/ui/components/primitives/Tabs.svelte b/src/lib/ui/components/primitives/Tabs.svelte
index 41a6cfd..167469d 100644
--- a/src/lib/ui/components/primitives/Tabs.svelte
+++ b/src/lib/ui/components/primitives/Tabs.svelte
@@ -12,9 +12,24 @@
onchange?: (id: string) => void;
ariaLabel?: string;
size?: 'sm' | 'md';
+ panelId?: string;
+ idPrefix?: string;
}
- let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md' }: Props = $props();
+ const uid = $props.id();
+ let { tabs, value = $bindable(), onchange, ariaLabel, size = 'md', panelId, idPrefix = uid }: Props = $props();
+
+ function onKey(event: KeyboardEvent) {
+ if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
+ event.preventDefault();
+ const enabled = tabs.filter(t => !t.disabled);
+ if (!enabled.length) return;
+ const index = enabled.findIndex(t => t.id === value);
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1
+ : (index + (event.key === 'ArrowRight' ? 1 : -1) + enabled.length) % enabled.length;
+ select(enabled[next].id, false);
+ document.getElementById(`${idPrefix}-${enabled[next].id}`)?.focus();
+ }
function select(id: string, disabled: boolean | undefined) {
if (disabled) return;
@@ -30,7 +45,11 @@
class="tab"
class:active={value === t.id}
role="tab"
+ id={`${idPrefix}-${t.id}`}
aria-selected={value === t.id}
+ aria-controls={panelId}
+ tabindex={value === t.id ? 0 : -1}
+ onkeydown={onKey}
disabled={t.disabled}
onclick={() => select(t.id, t.disabled)}
>
@@ -52,7 +71,7 @@
gap: 2px;
padding: 4px;
background: color-mix(in oklab, var(--surface) 80%, transparent);
- border-radius: var(--radius-pill);
+ border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.tab {
@@ -68,7 +87,7 @@
font-family: var(--font-body);
font-weight: var(--weight-medium);
cursor: pointer;
- border-radius: var(--radius-pill);
+ border-radius: var(--radius-sm);
transition:
color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard);
@@ -97,7 +116,7 @@
background: var(--surface-overlay);
color: var(--text-secondary);
font-family: var(--font-mono);
- font-size: 10px;
+ font-size: var(--text-xs);
}
.tab.active .badge {
background: var(--accent-primary);
diff --git a/src/lib/ui/components/primitives/Textarea.svelte b/src/lib/ui/components/primitives/Textarea.svelte
index 5e02b03..047af75 100644
--- a/src/lib/ui/components/primitives/Textarea.svelte
+++ b/src/lib/ui/components/primitives/Textarea.svelte
@@ -1,4 +1,7 @@
@@ -36,6 +41,10 @@
{required}
{disabled}
{readonly}
+ aria-label={ariaLabel}
+ aria-labelledby={ariaLabel ? undefined : field?.labelId}
+ aria-describedby={field?.messageId}
+ aria-invalid={field?.invalid || undefined}
bind:value
{oninput}
>
diff --git a/src/lib/ui/confirm.ts b/src/lib/ui/confirm.ts
new file mode 100644
index 0000000..80a7f8f
--- /dev/null
+++ b/src/lib/ui/confirm.ts
@@ -0,0 +1,25 @@
+import { writable } from 'svelte/store';
+
+export interface Confirmation {
+ title: string;
+ message: string;
+ confirmLabel?: string;
+ danger?: boolean;
+}
+export const confirmation = writable
(null);
+let resolvePending: ((accepted: boolean) => void) | null = null;
+
+/** Resolve only from a deliberate confirm/cancel action; never default to acceptance. */
+export function confirmAction(options: Confirmation): Promise {
+ resolvePending?.(false);
+ return new Promise(resolve => {
+ resolvePending = resolve;
+ confirmation.set(options);
+ });
+}
+export function resolveConfirmation(accepted: boolean): void {
+ const resolve = resolvePending;
+ resolvePending = null;
+ confirmation.set(null);
+ resolve?.(accepted);
+}
diff --git a/src/lib/ui/dialog.ts b/src/lib/ui/dialog.ts
new file mode 100644
index 0000000..4d29f37
--- /dev/null
+++ b/src/lib/ui/dialog.ts
@@ -0,0 +1,6 @@
+/** Native dialogs also receive clicks in their padding; only clicks beyond the bounds dismiss. */
+export function isDialogBackdrop(event: MouseEvent, dialog: HTMLDialogElement | null | undefined): boolean {
+ if (!dialog || event.target !== dialog) return false;
+ const rect = dialog.getBoundingClientRect();
+ return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom;
+}
diff --git a/src/lib/ui/field-context.ts b/src/lib/ui/field-context.ts
new file mode 100644
index 0000000..4267318
--- /dev/null
+++ b/src/lib/ui/field-context.ts
@@ -0,0 +1,6 @@
+export const fieldContext = Symbol('field');
+export interface FieldContext {
+ readonly labelId: string;
+ readonly messageId: string | undefined;
+ readonly invalid: boolean;
+}
diff --git a/src/lib/ui/poll.ts b/src/lib/ui/poll.ts
new file mode 100644
index 0000000..a1409c9
--- /dev/null
+++ b/src/lib/ui/poll.ts
@@ -0,0 +1,25 @@
+/** Poll visible page content without overlapping requests. Fleet watches use their own monitor. */
+export function pollVisible(task: () => void | Promise, interval: number, immediate = true): () => void {
+ let stopped = false;
+ let running = false;
+ let timer: ReturnType | undefined;
+ const visible = () => document.visibilityState !== 'hidden';
+ const run = async () => {
+ if (stopped || running || !visible()) return;
+ clearTimeout(timer);
+ running = true;
+ try { await task(); }
+ catch (error) { console.error('Page refresh failed', error); }
+ finally {
+ running = false;
+ if (!stopped && visible()) timer = setTimeout(run, interval);
+ }
+ };
+ const visibility = () => {
+ clearTimeout(timer);
+ if (document.visibilityState !== 'hidden') void run();
+ };
+ document.addEventListener('visibilitychange', visibility);
+ if (immediate) void run(); else timer = setTimeout(run, interval);
+ return () => { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); };
+}
diff --git a/src/lib/ui/server-labels.ts b/src/lib/ui/server-labels.ts
index a5c4c30..0b5e87e 100644
--- a/src/lib/ui/server-labels.ts
+++ b/src/lib/ui/server-labels.ts
@@ -13,3 +13,13 @@ export function serverTitle(name: string): string {
const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
}
+
+export function serverIdentity(name: string): { map: string; lobby: number | null; game: string } {
+ const parsed = parseServerName(name);
+ if (!parsed || !['t5', 't6'].includes(parsed.game.toLowerCase()) || !parsed.mapCanonical || (parsed.gameMode && parsed.gameMode !== 'zombie')) return { map: name, lobby: null, game: '' };
+ return {
+ map: zombieMaps(parsed.game).find(entry => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical),
+ lobby: parsed.lobby,
+ game: parsed.game === 't6' ? 'Black Ops II' : 'Black Ops I'
+ };
+}
diff --git a/src/lib/ui/status.ts b/src/lib/ui/status.ts
new file mode 100644
index 0000000..28cbf77
--- /dev/null
+++ b/src/lib/ui/status.ts
@@ -0,0 +1,20 @@
+export type StatusTone = 'neutral' | 'online' | 'warning' | 'danger' | 'info';
+const states: Record = {
+ online: ['Online', 'online'], offline: ['Offline', 'danger'], stale: ['Report outdated', 'warning'], starting: ['Starting', 'info'],
+ queued: ['Queued', 'neutral'], pending: ['Queued for restart', 'neutral'], running: ['In progress', 'info'], active: ['In progress', 'info'],
+ deploying: ['Installing files', 'info'], restarting: ['Restarting', 'info'],
+ observing: ['Collecting test evidence', 'info'], 'rolling-back': ['Rolling back', 'info'],
+ 'awaiting-empty': ['Waiting for empty lobby', 'neutral'], waiting: ['Waiting for empty lobby', 'neutral'],
+ 'awaiting-health': ['Verifying health', 'info'], 'awaiting-match': ['Waiting for a match', 'neutral'],
+ ready: ['Ready', 'online'], 'ready-promote': ['Ready to promote', 'online'],
+ done: ['Completed', 'online'], passed: ['Test passed', 'online'], tested: ['Test passed', 'online'],
+ promoted: ['Promoted', 'online'], 'rolled-back': ['Rolled back', 'neutral'],
+ failed: ['Failed', 'danger'], 'test-failed': ['Test failed', 'danger'], interrupted: ['Interrupted', 'danger'],
+ warning: ['Completed with warnings', 'warning'], held: ['Held by beta', 'neutral'],
+ manual: ['Manual action needed', 'warning'], paused: ['Paused', 'warning'], inconclusive: ['Missing evidence', 'warning'],
+ untested: ['Needs testing', 'neutral'], testing: ['Beta active', 'info'], empty: ['No versions', 'neutral'], attention: ['Needs attention', 'warning']
+};
+export function statusPresentation(state: string): { label: string; tone: StatusTone } {
+ const result = states[state];
+ return result ? { label: result[0], tone: result[1] } : { label: (state.charAt(0).toUpperCase() + state.slice(1)).replaceAll('-', ' '), tone: 'neutral' };
+}
diff --git a/src/lib/ui/stores/fleet.ts b/src/lib/ui/stores/fleet.ts
index f574b79..ab86092 100644
--- a/src/lib/ui/stores/fleet.ts
+++ b/src/lib/ui/stores/fleet.ts
@@ -10,6 +10,7 @@ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperati
});
export const fleetError = writable('');
export const fleetRefreshing = writable(false);
+export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const lobbyWatches = writable([]);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], ([snapshot, live, received]) =>
@@ -17,7 +18,7 @@ export const fleetServers = derived([fleet, statusByServer, statusReceivedAt], (
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined;
return status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
- roster: status.roster, health: status.health, active_since: status.activeSince,
+ roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
inactive_since: status.inactiveSince, game_started_at: status.gameStartedAt,
game_ended_at: status.gameEndedAt } : server;
}));
@@ -39,6 +40,7 @@ export function refreshFleet(): Promise {
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.');
const [servers, operations] = await Promise.all(responses.map(response => response.json()));
fleetError.set('');
+ fleetIntegrations.set(servers.integrations);
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
} catch (error) {
fleetError.set(error instanceof Error && error.name !== 'AbortError'
@@ -79,7 +81,9 @@ export function startFleetMonitor(): () => void {
try { sessionStorage.setItem('electric-cherry:lobby-watches', JSON.stringify(watches)); } catch { /* Optional storage. */ }
});
void refreshFleet();
- const interval = setInterval(() => void refreshFleet(), 10_000);
+ const interval = setInterval(() => {
+ if (document.visibilityState !== 'hidden' || get(lobbyWatches).length) void refreshFleet();
+ }, 10_000);
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); };
document.addEventListener('visibilitychange', onFocus);
const check = () => {
diff --git a/src/lib/ui/stores/pipeline.ts b/src/lib/ui/stores/pipeline.ts
index f30d253..0752505 100644
--- a/src/lib/ui/stores/pipeline.ts
+++ b/src/lib/ui/stores/pipeline.ts
@@ -23,7 +23,7 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
});
}
-export function startPipelineStream(): void {
+export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return;
source = new EventSource('/api/events/pipeline');
@@ -49,4 +49,5 @@ export function startPipelineStream(): void {
d.servers.set(data.server, { state: data.state, players: data.players, error: data.error });
});
});
+ return () => { source?.close(); source = null; };
}
diff --git a/src/lib/ui/stores/servers.ts b/src/lib/ui/stores/servers.ts
index 26298d7..9eab1b7 100644
--- a/src/lib/ui/stores/servers.ts
+++ b/src/lib/ui/stores/servers.ts
@@ -50,7 +50,7 @@ let tickInterval: ReturnType | null = null;
let source: EventSource | null = null;
-export function startServerStream(): void {
+export function startServerStream(): (() => void) | undefined {
if (!browser || source) return;
source = new EventSource('/api/events/servers');
source.addEventListener('server-telemetry', (evt) => {
@@ -73,4 +73,5 @@ export function startServerStream(): void {
if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(Date.now()), 1000);
}
+ return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; };
}
diff --git a/src/lib/ui/styles/components.css b/src/lib/ui/styles/components.css
new file mode 100644
index 0000000..bc92a2e
--- /dev/null
+++ b/src/lib/ui/styles/components.css
@@ -0,0 +1,30 @@
+/* Shared layout and interaction rules. Components own their contents; parents own spacing. */
+:root { color-scheme: dark; }
+:where(button, input, select, textarea) { font-family: var(--font-body); font-size: var(--text-sm); }
+:where(input:not([type='checkbox']):not([type='radio']), select, textarea) {
+ min-width: 0; max-width: 100%; min-height: 40px; padding: 10px 12px;
+ color: var(--text-primary); background: var(--surface-sunken);
+ border: 1px solid var(--border-strong); border-radius: var(--radius-md);
+}
+:where(input, textarea)::placeholder { color: var(--text-tertiary); opacity: 1; }
+:where(button, summary) { cursor: pointer; }
+:where(input[type='checkbox'], input[type='radio']) { accent-color: var(--accent-primary); }
+:where(dialog) { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-raised); }
+dialog::backdrop { background: rgb(0 0 0 / .7); backdrop-filter: blur(4px); }
+:focus-visible { outline: 2px solid var(--accent-primary-hover); outline-offset: 3px; }
+.ui-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); }
+.ui-toolbar .search, .ui-toolbar > input[type='search'] { flex: 1 1 240px; }
+.ui-toolbar select { flex: 0 1 auto; }
+.ui-summary { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3) var(--space-6); color: var(--text-secondary); font-size: var(--text-sm); }
+.ui-summary strong { color: var(--text-primary); font-variant-numeric: tabular-nums; }
+.ui-error { border: 1px solid color-mix(in srgb, var(--state-danger) 35%, transparent); padding: var(--space-3) var(--space-4); border-radius: var(--radius-md); color: var(--state-danger); font-size: var(--text-sm); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); }
+.ui-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); }
+.ui-sticky-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--space-3); position: sticky; bottom: 0; z-index: 5; background: var(--surface); padding: var(--space-4); border-top: 1px solid var(--border-strong); }
+.ui-panel { min-width: 0; }
+.ui-muted { color: var(--text-secondary); font-size: var(--text-sm); }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
+@media (max-width: 600px) {
+ .ui-toolbar .search, .ui-toolbar > input[type='search'] { flex-basis: 100%; width: 100%; }
+ .ui-toolbar select { flex: 1 1 130px; }
+ .ui-sticky-actions { padding: var(--space-3); bottom: env(safe-area-inset-bottom); }
+}
diff --git a/src/lib/ui/styles/tokens.css b/src/lib/ui/styles/tokens.css
index 08141e9..f47ec10 100644
--- a/src/lib/ui/styles/tokens.css
+++ b/src/lib/ui/styles/tokens.css
@@ -26,7 +26,7 @@
--blue-950: #060b16;
--blue-900: #0d1826;
--blue-700: #1c3d6b;
- --blue-500: #2e86d1;
+ --blue-500: #4da3e6;
--blue-300: #7cc0f5;
/* Neutral gray — cool-tinted, distinct from the blue ramp so surfaces
@@ -62,9 +62,9 @@
/* Text — four steps of hierarchy on dark. */
--text-primary: var(--gray-050);
--text-secondary: var(--gray-300);
- --text-tertiary: var(--gray-500);
- --text-quaternary: var(--gray-700);
- --text-on-accent: #ffffff;
+ --text-tertiary: #98a4b8;
+ --text-quaternary: var(--gray-400);
+ --text-on-accent: #08111f;
/* Border — hairlines, no more. */
--border-subtle: rgba(255, 255, 255, 0.06);
@@ -74,7 +74,7 @@
/* Accent — one color, one voice. */
--accent-primary: var(--blue-500);
--accent-primary-hover: var(--blue-300);
- --accent-primary-active: #256cae;
+ --accent-primary-active: #398fcf;
/* Typography — Inter everywhere, mono for data.
Display face lives inside BrandMark; no global --font-display token. */
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index a34c482..b66692c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -7,6 +7,9 @@
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 { resolveConfirmation } from '$ui/confirm.js';
+ import '$ui/styles/components.css';
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
// Side-effect import — puts @fontsource CSS on Vite's module graph so
// the referenced woff/woff2 files get emitted into the build.
@@ -17,7 +20,7 @@
let menuOpen = $state(false);
let menuButton: HTMLButtonElement;
- afterNavigate(() => { menuOpen = false; });
+ afterNavigate(() => { menuOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) {
if (event.key === 'Escape' && menuOpen) {
@@ -27,8 +30,8 @@
}
onMount(() => {
- startPipelineStream();
- startServerStream();
+ const stopPipeline = startPipelineStream();
+ const stopServers = startServerStream();
const stopFleetMonitor = startFleetMonitor();
// Two-key nav shortcuts (Gmail-style): `g f` fleet, `g d` deployments,
@@ -42,7 +45,7 @@
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
- if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
+ if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable || target.closest('dialog'))) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (!armed && e.key === 'g') {
@@ -64,6 +67,8 @@
window.addEventListener('keydown', onKey);
return () => {
stopFleetMonitor();
+ stopPipeline?.();
+ stopServers?.();
window.removeEventListener('keydown', onKey);
if (armTimer) clearTimeout(armTimer);
};
@@ -74,20 +79,23 @@
onNavigate(enableViewTransitions);
const tabs = [
- { href: '/', label: 'Fleet', match: (p: string) => p === '/' },
- { href: '/scripts', label: 'Scripts', match: (p: string) => p.startsWith('/scripts') },
- { href: '/configs', label: 'Configs', match: (p: string) => p.startsWith('/configs') },
- { href: '/plugins', label: 'Plugins', match: (p: string) => p.startsWith('/plugins') },
- { href: '/deployments', label: 'Deployments', match: (p: string) => p.startsWith('/deployments') },
- { href: '/campaigns', label: 'Campaigns', match: (p: string) => p.startsWith('/campaigns') },
- { href: '/server-keys', label: 'Keys', match: (p: string) => p.startsWith('/server-keys') },
- { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }
+ { href: '/', label: 'Fleet', match: (p: string) => p === '/' || p.startsWith('/servers/') },
+ { href: '/scripts', label: 'Library', match: (p: string) => /^\/(scripts|configs|plugins)(\/|$)/.test(p) },
+ { href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
+ { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') },
+ { href: '/server-keys', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') }
];
+ const secondary = $derived(tabs[1].match($page.url.pathname)
+ ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
+ : tabs[2].match($page.url.pathname)
+ ? [{ href: '/deployments', label: 'Server deployments' }, { href: '/campaigns', label: 'Rollouts' }]
+ : tabs[4].match($page.url.pathname) ? [{ href: '/server-keys', label: 'Server keys' }] : []);
-
+ {#if secondary.length}{/if}
+
{@render children()}
+
diff --git a/src/routes/api/campaigns/+server.ts b/src/routes/api/campaigns/+server.ts
index 956f2bd..0f0035f 100644
--- a/src/routes/api/campaigns/+server.ts
+++ b/src/routes/api/campaigns/+server.ts
@@ -3,6 +3,7 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ url }: { url: URL }) {
const activeOnly = url.searchParams.get('active') === 'true';
+ const server = url.searchParams.get('server');
const deployments = db()
.prepare(
@@ -14,10 +15,11 @@ export async function GET({ url }: { url: URL }) {
LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN script_versions sv ON sv.id = d.script_version_id
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
+ WHERE (? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id = d.id AND target.server = ?))
ORDER BY d.started_at DESC
LIMIT 500`
)
- .all() as Array<{
+ .all(server, server) as Array<{
id: string;
action: string;
started_at: string;
@@ -33,12 +35,15 @@ export async function GET({ url }: { url: URL }) {
prior_version: number | null;
}>;
+ const ids = deployments.map(deployment => deployment.id);
+ if (!ids.length) return json({ deployments: [] });
+ const placeholders = ids.map(() => '?').join(',');
const serversByDep = db()
.prepare(
`SELECT deployment_id, server, files_changed, restart_state, players, checked_at, restarted_at, attempts, error
- FROM deployment_servers`
+ FROM deployment_servers WHERE deployment_id IN (${placeholders})`
)
- .all() as Array<{
+ .all(...ids) as Array<{
deployment_id: string;
server: string;
files_changed: number;
@@ -64,9 +69,10 @@ export async function GET({ url }: { url: URL }) {
FROM deployment_scripts ds
LEFT JOIN scripts s ON s.id = ds.script_id
LEFT JOIN script_versions sv ON sv.id = ds.script_version_id
- LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id`
+ LEFT JOIN script_versions pv ON pv.id = ds.prior_version_id
+ WHERE ds.deployment_id IN (${placeholders})`
)
- .all() as Array<{
+ .all(...ids) as Array<{
deployment_id: string;
script_id: number;
script_name: string | null;
diff --git a/src/routes/api/servers/[name]/scripts/+server.ts b/src/routes/api/servers/[name]/scripts/+server.ts
index f585395..c94e471 100644
--- a/src/routes/api/servers/[name]/scripts/+server.ts
+++ b/src/routes/api/servers/[name]/scripts/+server.ts
@@ -1,12 +1,18 @@
import { json } from '@sveltejs/kit';
import { getServer } from '$lib/server/fleet/servers.js';
import { listServerScripts } from '$lib/server/fleet/inventory.js';
+import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
+ const metadata = db().prepare(`SELECT i.rel_path, s.kind FROM server_scripts i
+ JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
+ WHERE i.server = ?`).all(params.name) as Array<{ rel_path: string; kind: string }>;
+ const kinds = new Map(metadata.map(row => [row.rel_path, row.kind]));
const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path,
+ kind: kinds.get(r.rel_path) ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
scriptVersionId: r.script_version_id,
sha256: r.sha256,
size: r.size,
diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte
index 638c01c..3d43d79 100644
--- a/src/routes/betas/+page.svelte
+++ b/src/routes/betas/+page.svelte
@@ -1,17 +1,14 @@
-
-
-
- {#if flash}
{flash}
{/if}
-
- {#if loading}
-
Loading…
- {:else}
-
- Open a beta
-
- {#if eligibleScripts.length === 0}
-
- {:else}
-
- {/if}
-
-
- {#if betas.length === 0}
-
- {:else}
- {#each grouped as g (g.state)}
- {#if g.rows.length > 0}
-
-
-
{g.state.replace('-', ' ')}
- {g.rows.length}
-
-
-
- {/if}
- {/each}
- {/if}
- {/if}
+
+ {#snippet actions()}New beta {/snippet}
+
+
+
+ {#if error}
{error} {betas.length ? 'Showing the last loaded results.' : ''}Retry
{/if}
+
+ {#if loading}
+ {:else if !filtered.length && !error}
+ {:else}
{/if}
+
-
diff --git a/src/routes/betas/[id]/+page.svelte b/src/routes/betas/[id]/+page.svelte
index b127574..47552d9 100644
--- a/src/routes/betas/[id]/+page.svelte
+++ b/src/routes/betas/[id]/+page.svelte
@@ -2,7 +2,11 @@
import { artifactVersionLabel } from '$lib/artifact-version.js';
import BetaTestProgress from "$ui/components/BetaTestProgress.svelte";
import type { BetaTestView } from "$lib/beta-plan.js";
- import { onMount, onDestroy } from 'svelte';
+ import { onMount } from 'svelte';
+ import { pollVisible } from '$ui/poll.js';
+ import { confirmAction } from '$ui/confirm.js';
+ import ServerIdentity from '$ui/components/ServerIdentity.svelte';
+ import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import { page } from '$app/stores';
import Card from '$ui/components/primitives/Card.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
@@ -63,11 +67,7 @@
$effect(() => { id; beta = null; loading = true; loadError = ''; void load(); });
- let poll: ReturnType
| null = null;
- onMount(() => {
- poll = setInterval(load, 5000);
- });
- onDestroy(() => { if (poll) clearInterval(poll); });
+ onMount(() => pollVisible(load, 5000, false));
async function promote(): Promise {
acting = 'promote'; flash = null;
@@ -76,7 +76,7 @@
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Promote failed'; return; }
await load();
- } finally { acting = null; }
+ } catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
async function rollback(): Promise {
const isReject = beta?.state === 'ready-promote';
@@ -84,14 +84,14 @@
? `Reject beta #${id}? This restores original files on every beta server and marks the version as test-failed.`
: `Rollback beta #${id}? This restores original files on every beta server.`;
const restart = test ? test.plan.rollbackRestart === 'when-empty' ? ' Restarts will roll out when servers are empty.' : ' Restart servers manually to load the restored files.' : '';
- if (!confirm(prompt + restart)) return;
+ if (!await confirmAction({title:isReject ? 'Reject beta?' : 'Roll back beta?',message:prompt + restart,confirmLabel:isReject ? 'Reject beta' : 'Roll back',danger:true})) return;
acting = 'rollback'; flash = null;
try {
const res = await fetch(`/api/betas/${id}/rollback`, { method: 'POST' });
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Rollback failed'; return; }
await load();
- } finally { acting = null; }
+ } catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
async function retry(server: string): Promise {
acting = `retry:${server}`; flash = null;
@@ -104,21 +104,7 @@
const body = (await res.json()) as { ok: boolean; error?: string };
if (!body.ok) { flash = body.error ?? 'Retry failed'; return; }
await load();
- } finally { acting = null; }
- }
-
- function betaStateVariant(s: string): 'online' | 'warning' | 'info' | 'neutral' {
- return s === 'ready-promote' ? 'online' :
- s === 'running' ? 'warning' :
- s === 'promoted' ? 'info' :
- 'neutral';
- }
- function stageVariant(s: string): 'online' | 'warning' | 'danger' | 'neutral' {
- return s === 'ready' ? 'online' :
- s === 'failed' ? 'danger' :
- s === 'awaiting-empty' || s === 'restarting' || s === 'awaiting-health' || s === 'awaiting-match' || s === 'deploying'
- ? 'warning' :
- 'neutral';
+ } catch (error) { flash = (error as Error).message; } finally { acting = null; }
}
@@ -137,7 +123,7 @@
{/snippet}
{#snippet actions()}
- {b.state}
+
{/snippet}
@@ -163,7 +149,7 @@
{#each scripts as s (s.id)}
{s.kind}
-
{s.name}
+
{s.name}
{s.target_rel_path}
{artifactVersionLabel(s.version)}
{s.version?.sha256.slice(0, 8) ?? ''}
@@ -202,8 +188,8 @@
{#each servers as bs (bs.server)}
-
{bs.server}
-
{bs.stage}
+
+
Deploy
Empty
diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte
new file mode 100644
index 0000000..2c53c85
--- /dev/null
+++ b/src/routes/betas/new/+page.svelte
@@ -0,0 +1,422 @@
+
+
+
+
+ {#snippet actions()}Back to betas {/snippet}
+
+ {#if flash}
{flash}{#if loading === false && scripts.length === 0}Retry {/if}
{/if}
+ {#if $fleetError}
{$fleetError}Retry servers
{/if}
+ {#if loading}
+ {:else if eligibleScripts.length === 0 && !flash}
+ {:else if scripts.length}
+
+ {#each steps as label, index}{index + 1} {label} {/each}
+
+
+ {/if}
+
+
diff --git a/src/routes/campaigns/+page.svelte b/src/routes/campaigns/+page.svelte
index 9067b4e..2ddb466 100644
--- a/src/routes/campaigns/+page.svelte
+++ b/src/routes/campaigns/+page.svelte
@@ -1,5 +1,11 @@
+ {#if loadError}
{loadError}Retry
{/if}
{#if loading}
Loading…
{:else}
- (driftOpen = !driftOpen)}
- >
-
+ {#if driftError}
{driftError}Retry scan
{/if}
+
+ driftOpen = !driftOpen}>
+
›
Drift
{#if drift === null}
@@ -240,8 +241,8 @@
across {drift.servers.length} server{drift.servers.length === 1 ? '' : 's'}
{/if}
-
-
e.stopPropagation()} role="none">
+
+
Rescan
@@ -255,21 +256,21 @@
Reconcile
-
+
{#if driftOpen}
-
+
{#if drift && drift.totalDrift > 0}
- {#each filteredDriftServers as s (s.server)}
+ {#each filteredDriftServers.slice(0,driftLimit) as s (s.server)}
- {s.server}
+
{s.files.length} entr{s.files.length === 1 ? 'y' : 'ies'}
@@ -286,6 +287,7 @@
{/each}
+
{:else if drift?.totalDrift === 0}
{:else}
@@ -308,7 +310,7 @@
{#if active.length === 0}
{:else}
@@ -327,7 +329,7 @@
{#if history.length === 0}
{:else}
@@ -343,14 +345,15 @@
{/each}
{/if}
+
{/if}
{#snippet deployRow(d: Deployment)}
-
+
-
{d.action}
+
{d.action} rollout {d.id}
{#if d.action === 'promote' || d.action === 'rollback'}
{scriptSummary(d)}
{/if}
@@ -381,7 +384,7 @@
title="{s.server}: {s.restart_state}{s.error ? ' — ' + s.error : ''}"
>
-
{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
+
{serverTitle(s.server)}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
{/each}
@@ -391,12 +394,12 @@
{#each ['done','pending','failed','held','manual'] as state}
{@const n = d.servers.filter((s) => s.restart_state === state).length}
{#if n > 0}
-
{n} {state}
+
{n}
{/if}
{/each}
-
+
{/snippet}
diff --git a/src/routes/campaigns/[id]/+page.svelte b/src/routes/campaigns/[id]/+page.svelte
index 38e0227..247394b 100644
--- a/src/routes/campaigns/[id]/+page.svelte
+++ b/src/routes/campaigns/[id]/+page.svelte
@@ -1,5 +1,9 @@
-
Campaign | Electric Cherry
+
Rollout | Electric Cherry
{#if loadError}
{loadError} Retry
{/if}
{#if detail}
@@ -109,7 +110,7 @@
{#snippet crumbs()}
{/snippet}
@@ -139,13 +140,13 @@
{#each detail.servers as s (s.server)}
- {s.restart_state}
+
{#if s.players !== null && s.players > 0}
{s.players}p
{/if}
@@ -165,11 +166,11 @@
{detail.files.length}
{#if detail.files.length === 0}
-
+
{:else}
{#each [...byServer.entries()] as [srv, files] (srv)}
-
{srv}
+
{#each files as f (f.id)}
loadDiff(f)}>
@@ -188,29 +189,11 @@
{/if}
- {#if openFile}
- (openFile = null)}
- onkeydown={(e) => { if (e.key === 'Escape') openFile = null; }}
- role="button"
- tabindex="-1"
- aria-label="Close diff"
- >
-
e.stopPropagation()} role="none">
-
- {openFile.relPath}
- (openFile = null)}>Close
-
-
-
-
- {/if}
+
+ {#if diffError}{diffError}
+ {:else if diffLoading}
+ {:else if openFile} {/if}
+
{:else if !loadError}
Loading…
@@ -341,44 +324,10 @@
}
/* Diff modal */
- .diff-scrim {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.6);
- z-index: 100;
- display: grid;
- place-items: center;
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- }
- .diff-modal {
- width: min(1000px, 92vw);
- max-height: 92vh;
- overflow: auto;
- background: var(--surface);
- border: 1px solid var(--border-strong);
- border-radius: var(--radius-lg);
- padding: var(--space-6);
- box-shadow: var(--shadow-raised);
- }
- .diff-head {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: var(--space-3);
- margin-bottom: var(--space-4);
- padding-bottom: var(--space-3);
- border-bottom: 1px solid var(--border-subtle);
- }
- .diff-title {
- font-family: var(--font-mono);
- font-size: var(--text-sm);
- color: var(--text-primary);
- word-break: break-all;
- }
+
@media (max-width: 720px) {
.srv-row { align-items: flex-start; flex-direction: column; }
.srv-l, .file-row { flex-wrap: wrap; }
- .diff-modal { padding: var(--space-4); max-height: 92dvh; }
+
}
diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte
index 4731498..67593e8 100644
--- a/src/routes/deployments/+page.svelte
+++ b/src/routes/deployments/+page.svelte
@@ -1,5 +1,12 @@
{deployment ? (deployment.action === 'destroy' ? 'Destroy ' : 'Create ') + deployment.server : 'Deployment'} | Electric Cherry
-
- {#snippet crumbs()} {/snippet}
+
+ {#snippet crumbs()} {/snippet}
{#if error}{error} Retry
{/if}
{#if detail?.collection}← Back to {detail.collection.name} collection {/if}
{#if deployment}
- {stateLabel(deployment.state)}
+
{deployment.state === 'done' ? (deployment.action === 'destroy' ? 'Server destroyed' : 'Server creation completed') : deployment.stage}
@@ -120,38 +119,21 @@
{#if deployment.expected_map}
Expected map: {deployment.expected_map}
{/if}
Started {fmt(deployment.started_at)}
Finished {deployment.finished_at ? fmt(deployment.finished_at) : 'In progress'}
Requested by {deployment.triggered_by}
-
- Operation flow
- {#each steps as step, index}{index < currentStep ? '✓' : index + 1} {step} {/each}
-
+
({label,state:index < currentStep ? 'done' : index === currentStep ? (!active && ['failed','interrupted'].includes(deployment.state) ? 'failed' : 'current') : 'pending'}))} events={detail?.events ?? []} {active} />
{#if detail?.recipe}
{detail.blueprintName ? `Launched from ${detail.blueprintName}` : 'Recorded launch recipe'} open = true}>Launch again → Reuse the original managed files with a fresh lobby and port. Review the launch to save it as a blueprint.
{/if}
-
-
Activity {#if active}Updates automatically {/if}
- {#if detail && detail.events.length > 0}
- {#each detail.events as event (event.id)}{fmt(event.created_at)} {event.message}
{/each}
- {:else}This operation predates detailed activity tracking. Its recorded status and timestamps are shown above.
{/if}
-
+
{:else if !error}Loading deployment...
{/if}
diff --git a/src/routes/plugins/+page.svelte b/src/routes/plugins/+page.svelte
index 0f9aa6a..7f7f95c 100644
--- a/src/routes/plugins/+page.svelte
+++ b/src/routes/plugins/+page.svelte
@@ -1,5 +1,10 @@
-
-
-
{ importCount++; await load(); }} />
- {#key importCount} {/key}
-
-
- Upload a plugin
- {#if successBanner}
+
+ {#snippet actions()} addOpen = true}>Add plugin {/snippet}
+
+ {#if successBanner}
{/if}
-
+
+
+
+
+ {#if method === 'github'}
{ importCount++; addOpen = false; await load(); }} />
+ {:else}
+
diff --git a/src/routes/server-keys/+page.svelte b/src/routes/server-keys/+page.svelte
index cdec23a..8a2183f 100644
--- a/src/routes/server-keys/+page.svelte
+++ b/src/routes/server-keys/+page.svelte
@@ -1,10 +1,21 @@
Keys · Electric Cherry
-Keys
-Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.
-