From f54cac8c50091a2f3d7bc3c73648465080ea3907 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 18 Sep 2026 14:00:28 -0400 Subject: [PATCH] Unify UI workflows and streamline fleet management --- docs/ui-refresh.md | 28 + src/lib/script-library.ts | 2 +- src/lib/ui/components/ArtifactDetail.svelte | 126 +++-- src/lib/ui/components/BetaPlanBuilder.svelte | 39 +- src/lib/ui/components/BetaTestProgress.svelte | 37 +- src/lib/ui/components/BlueprintBuilder.svelte | 11 +- src/lib/ui/components/ConfigEditor.svelte | 27 +- src/lib/ui/components/ConfigInspector.svelte | 13 +- src/lib/ui/components/ConfigLibrary.svelte | 44 +- src/lib/ui/components/ConfigTextEditor.svelte | 2 +- src/lib/ui/components/Confirmation.svelte | 14 + src/lib/ui/components/CopyValue.svelte | 11 + .../components/DeployCollectionDialog.svelte | 11 +- .../ui/components/DeployServerDialog.svelte | 17 +- .../ui/components/DestroyServerModal.svelte | 6 +- src/lib/ui/components/HousePartyDialog.svelte | 11 +- src/lib/ui/components/LaunchRecipe.svelte | 4 +- src/lib/ui/components/LogPanel.svelte | 105 ++-- src/lib/ui/components/OperationHistory.svelte | 7 +- src/lib/ui/components/ServerCard.svelte | 25 +- src/lib/ui/components/ServerIdentity.svelte | 12 + .../ui/components/primitives/Button.svelte | 11 +- src/lib/ui/components/primitives/Card.svelte | 18 +- .../ui/components/primitives/Dialog.svelte | 42 ++ .../components/primitives/EmptyState.svelte | 14 +- src/lib/ui/components/primitives/Field.svelte | 21 +- src/lib/ui/components/primitives/Input.svelte | 11 +- .../components/primitives/ListFooter.svelte | 8 + .../primitives/SectionHeader.svelte | 2 +- .../ui/components/primitives/Select.svelte | 11 +- .../components/primitives/StatusBadge.svelte | 7 + src/lib/ui/components/primitives/Tabs.svelte | 27 +- .../ui/components/primitives/Textarea.svelte | 11 +- src/lib/ui/confirm.ts | 25 + src/lib/ui/dialog.ts | 6 + src/lib/ui/field-context.ts | 6 + src/lib/ui/poll.ts | 25 + src/lib/ui/server-labels.ts | 10 + src/lib/ui/status.ts | 20 + src/lib/ui/stores/fleet.ts | 8 +- src/lib/ui/stores/pipeline.ts | 3 +- src/lib/ui/stores/servers.ts | 3 +- src/lib/ui/styles/components.css | 30 + src/lib/ui/styles/tokens.css | 10 +- src/routes/+layout.svelte | 50 +- src/routes/+page.svelte | 91 +-- src/routes/api/campaigns/+server.ts | 16 +- .../api/servers/[name]/scripts/+server.ts | 6 + src/routes/betas/+page.svelte | 532 ++---------------- src/routes/betas/[id]/+page.svelte | 42 +- src/routes/betas/new/+page.svelte | 422 ++++++++++++++ src/routes/campaigns/+page.svelte | 106 ++-- src/routes/campaigns/[id]/+page.svelte | 121 ++-- src/routes/deployments/+page.svelte | 104 ++-- src/routes/deployments/[id]/+page.svelte | 50 +- .../deployments/collections/[id]/+page.svelte | 9 +- src/routes/plugins/+page.svelte | 174 +++--- src/routes/scripts/+page.svelte | 16 +- src/routes/server-keys/+page.svelte | 88 +-- src/routes/servers/[name]/+page.svelte | 175 +++--- tests/unit/server-detail-api.test.ts | 44 ++ tests/unit/ui-identity-status.test.ts | 17 + tests/unit/ui-poll.test.ts | 48 ++ 63 files changed, 1706 insertions(+), 1286 deletions(-) create mode 100644 docs/ui-refresh.md create mode 100644 src/lib/ui/components/Confirmation.svelte create mode 100644 src/lib/ui/components/CopyValue.svelte create mode 100644 src/lib/ui/components/ServerIdentity.svelte create mode 100644 src/lib/ui/components/primitives/Dialog.svelte create mode 100644 src/lib/ui/components/primitives/ListFooter.svelte create mode 100644 src/lib/ui/components/primitives/StatusBadge.svelte create mode 100644 src/lib/ui/confirm.ts create mode 100644 src/lib/ui/dialog.ts create mode 100644 src/lib/ui/field-context.ts create mode 100644 src/lib/ui/poll.ts create mode 100644 src/lib/ui/status.ts create mode 100644 src/lib/ui/styles/components.css create mode 100644 src/routes/betas/new/+page.svelte create mode 100644 tests/unit/server-detail-api.test.ts create mode 100644 tests/unit/ui-identity-status.test.ts create mode 100644 tests/unit/ui-poll.test.ts diff --git a/docs/ui-refresh.md b/docs/ui-refresh.md new file mode 100644 index 0000000..54f4942 --- /dev/null +++ b/docs/ui-refresh.md @@ -0,0 +1,28 @@ +# UI refresh + +The interface keeps the existing dark palette and blue accent, with shared controls and task-focused pages. + +1. **Shared design system:** common headers, buttons, fields, dialogs, spacing, radii, and focus styles. Removed unused styles from retired layouts. +2. **Libraries:** Plugins opens on the searchable library. Add plugin opens Upload / GitHub tabs. Scripts uses compact status filters; Configs uses a compact summary and Library / Inspect tabs. +3. **Beta setup:** `/betas/new` guides Files → Servers → Test plan → Review. Exact versions, selected servers, and the plan survive Back / Continue. Existing beta selection links still redirect correctly. +4. **Fleet density:** compact totals and attention summary, with grouping, sorting, and display options behind View options. +5. **Server identity:** friendly map names and visible lobby numbers across cards, lists, beta setup, and operations. T6 variants retain their selected map name. Server details provides a copyable raw ID. +6. **Deployment organization:** Active / Blueprints / History views. Failures and warnings link to filtered history. Destroy fleet lives in Fleet actions and retains its explicit preview. +7. **Navigation:** Fleet / Library / Operations / Betas / Settings, with section navigation and a mobile menu. Existing route URLs remain usable. +8. **Status and progress:** shared status labels and tones distinguish ordinary waiting, work in progress, attention, passing tests, and promotion. Deployment, rollout, and beta details share operation history. +9. **Server detail hub:** live status, current operations, roster, logs, effective configuration, inventory, diagnostics, and scoped rollout history. Missing or stale player counts are unknown. Inventory identifies scripts, plugins, configs, and other files. +10. **Dialogs and feedback:** in-app confirmations for deletion, rollback, reconciliation, and unsaved edits. File comparisons use a native modal dialog with Escape and focus restoration. Refresh errors retain loaded data and provide retry controls. +11. **Keyboard and forms:** labeled fields, roving keyboard tabs, visible focus, a skip link, and separate navigation/action targets. Removed nested buttons and buttons inside rollout links. +12. **Readability:** brighter secondary metadata, readable small labels, contrasting primary-button text, and full-opacity offline cards. +13. **Page states:** compact loading and empty states, actionable failures, and retained snapshots during refresh errors. +14. **Mobile:** wrapping identities, scrollable tab/filter rows, compact summaries, responsive forms, and sticky form actions. +15. **Loading and rendering:** shared Fleet data, detail tabs loaded on demand, no overlapping scheduled page requests, and polling paused in hidden tabs. Watches can keep Fleet monitoring active. Long libraries, histories, and evidence lists progressively render more items. Server history is filtered before the API limit. + +## Maintaining the patterns + +- Use `SectionHeader` for page titles and primary actions. The containing page owns spacing between sections. +- Use `Field` with `Input`, `Select`, or `Textarea` for associated labels, help, and errors. Give controls distinct `ariaLabel` values when several share one field. +- Use `Dialog` for new modal workflows and `confirmAction` for ordinary confirmations. Browser unload protection remains native. +- Use `StatusBadge` for workflow states and `ServerIdentity` for server names. Preserve raw IDs where operators need to copy or troubleshoot them. +- Pair `Tabs` with a labeled panel. Use `ListFooter` for long lists and `pollVisible` for scheduled page refreshes. +- Read live server data from the Fleet store instead of starting another `/api/servers` polling loop. diff --git a/src/lib/script-library.ts b/src/lib/script-library.ts index b62999d..d5d7f79 100644 --- a/src/lib/script-library.ts +++ b/src/lib/script-library.ts @@ -37,7 +37,7 @@ export interface BetaSelection { scriptId: number; versionId: number; } export function betaSelectionHref(selections: BetaSelection[]): string { const query = new URLSearchParams(); for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`); - return `/betas?${query}`; + return `/betas/new?${query}`; } export function parseBetaSelection(query: URLSearchParams): BetaSelection[] { const values = query.getAll('artifact'); diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 36326ca..a9b37d4 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,5 +1,12 @@ +{#if loadError}{/if} {#if loading}
Loading…
-{:else if !artifact} +{:else if !artifact && !loadError} -{:else} +{:else if artifact} {@const a = artifact}
@@ -303,7 +302,7 @@ {:else if matchedServers.length === 0} No matching servers {:else} - {matchedServers.join(', ')} + {#each matchedServers as name}{/each} {/if}
@@ -323,7 +322,7 @@ Latest version {artifactVersionLabel(latestVersion)} · {latestVersion.sha256.slice(0, 8)} - {latestVersion.state} + {/if} @@ -344,7 +343,7 @@ {:else}
- {#each versions as v (v.id)} + {#each versions.slice(0,limit) as v (v.id)}
{artifactVersionLabel(v)} {v.sha256.slice(0, 12)} @@ -352,16 +351,18 @@ {v.notes ?? ''} {#if v.source}GitHub · {v.source.asset_name} ↗{/if} - {v.uploaded_at} + {#if a.current_version_id === v.id} Promoted {:else} - {v.state.replace('-', ' ')} + + {#if runningBetaId === null}{/if} {/if}
{/each}
{/if} + {#if family === 'plugin'} @@ -376,20 +377,20 @@ {#if flash}
{flash}
{/if} - onDialogClick(scopeDialog, e)}> + { if (updatingScope) event.preventDefault(); }} bind:this={scopeDialog} onclick={(e) => onDialogClick(scopeDialog, e)}>

Edit scope

- +
- {#if scopeType === 'game' || scopeType === 'map'} - {/if} {#if scopeType === 'map'} - {/if}
@@ -404,11 +405,11 @@
- onDialogClick(uploadDialog, e)}> + { if (uploadingVersion) event.preventDefault(); }} bind:this={uploadDialog} onclick={(e) => onDialogClick(uploadDialog, e)}>

Upload new version

- +
@@ -589,4 +590,5 @@ .panel-head, .head-actions, .dlg-actions { flex-wrap: wrap; } .dlg { padding: var(--space-4); } } + .matched-server { display:block; padding:5px 0; color:var(--text-primary); text-decoration:none; font-family:var(--font-body); } diff --git a/src/lib/ui/components/BetaPlanBuilder.svelte b/src/lib/ui/components/BetaPlanBuilder.svelte index 7f76b76..c8f499e 100644 --- a/src/lib/ui/components/BetaPlanBuilder.svelte +++ b/src/lib/ui/components/BetaPlanBuilder.svelte @@ -1,5 +1,6 @@
-

Test plan

{test.outcome}
+

Test plan

{#if test.reason}

{test.reason}

{/if} -

Coverage: {test.plan.scope === 'quorum' ? `${test.plan.quorum} servers` : test.plan.scope}. Window: {test.plan.timeoutHours} hours. - {test.plan.waveSize ? `${test.plan.waveSize} servers per wave.` : 'All servers deployed together.'} - Failure action: {test.plan.failureAction}. Rollback restart: {test.plan.rollbackRestart}.

+

{coverageSummary(test.plan)} · {test.plan.timeoutHours}-hour testing window · Manual promotion

+
Test policy

{test.plan.waveSize ? test.plan.waveSize + ' servers per wave.' : 'All servers deployed together.'} {test.plan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : test.plan.failureAction === 'pause' ? 'Failed checks pause the test for investigation.' : 'Failed checks hold the test for review.'} {test.plan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; servers must be restarted manually.'}

{#if !closed}
- {#if test.outcome === 'paused'}{/if} - {#if ['active', 'passed'].includes(test.outcome)}{/if} - {#if ['paused', 'failed'].includes(test.outcome)}{/if} + {#if test.outcome === 'paused'}{/if} + {#if ['active', 'passed'].includes(test.outcome)}{/if} + {#if ['paused', 'failed'].includes(test.outcome)}{/if}
{/if} {#if error}{/if} {#if test.servers.length === 0}

Waiting for the first server restart. Evidence collection begins with that run.

{/if} {#each test.servers as server (server.server)}
-

{server.server} Recovery cycles: {server.phase}

+

Recovery cycles: {server.phase}

{#each server.progress as progress (progress.id)} {@const requirement = test.plan.requirements.find(r => r.id === progress.id)!}
@@ -47,19 +53,22 @@ {#if requirement.kind === 'manual' && !progress.passed && !closed}
- +
{/if}
{/each}
{/each} -
+
evidenceOpen = event.currentTarget.open}> Evidence ({test.evidence.length}{test.evidence.length === 200 ? ' most recent' : ''}) - {#each test.evidence as event (event.id)} + {#if evidenceOpen} + {#each test.evidence.slice(0,evidenceLimit) as event (event.id)}
{event.server} · {event.kind}
{event.payload}
{/each} + {#if !test.evidence.length}

No evidence recorded yet.

{/if} + {/if}
@@ -70,12 +79,12 @@ p, small, span, time { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .heading, .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; } .heading { justify-content: space-between; } - article { border: 1px solid var(--border-subtle); border-radius: 8px; padding: 16px; } + article { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 16px; } .requirement { display: grid; gap: 8px; margin-top: 16px; } .requirement > div:first-child { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; } progress { width: 100%; height: 8px; accent-color: var(--state-online); } - input, button { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; } - input { flex: 1; min-width: 160px; } button, summary { cursor: pointer; } button:disabled { opacity: .5; } + input { padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--surface-sunken); color: var(--text-primary); font: inherit; } + input { flex: 1; min-width: 160px; } summary { cursor: pointer; } .failed { color: var(--state-danger); } .evidence { display: grid; gap: 4px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); } pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; margin: 0; } diff --git a/src/lib/ui/components/BlueprintBuilder.svelte b/src/lib/ui/components/BlueprintBuilder.svelte index 89b4a8a..b6643f5 100644 --- a/src/lib/ui/components/BlueprintBuilder.svelte +++ b/src/lib/ui/components/BlueprintBuilder.svelte @@ -1,4 +1,5 @@ - { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> + { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
One blueprint. Your whole lineup.

{blueprint ? 'Edit your blueprint' : 'Build a server collection'}

Choose the total lobbies you want for each map. Existing and queued servers count; only missing lobbies are created. Mix Black Ops I and II in the same blueprint.

@@ -93,12 +94,12 @@
diff --git a/src/lib/ui/components/ConfigTextEditor.svelte b/src/lib/ui/components/ConfigTextEditor.svelte index ea7cb4e..627cd1f 100644 --- a/src/lib/ui/components/ConfigTextEditor.svelte +++ b/src/lib/ui/components/ConfigTextEditor.svelte @@ -51,7 +51,7 @@ .setting { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(0, 1.4fr); align-items: center; gap: 16px; padding: 12px 0; border-top: 1px solid var(--border-subtle); } .setting span { min-width: 0; overflow-wrap: anywhere; } code { font: var(--text-sm) var(--font-mono); } - small { display: block; color: var(--text-secondary); font-size: 11px; margin-top: 4px; } + small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; } .hint { color: var(--text-secondary); font-size: var(--text-xs); } .warnings { color: var(--state-warning); font-size: var(--text-sm); padding: 12px 0 0; } summary { cursor: pointer; } diff --git a/src/lib/ui/components/Confirmation.svelte b/src/lib/ui/components/Confirmation.svelte new file mode 100644 index 0000000..0f31913 --- /dev/null +++ b/src/lib/ui/components/Confirmation.svelte @@ -0,0 +1,14 @@ + + + $confirmation !== null, (open) => { if (!open) resolveConfirmation(false); }} title={$confirmation?.title ?? 'Confirm action'}> +

{$confirmation?.message}

+ {#snippet actions()} + + + {/snippet} +
+ diff --git a/src/lib/ui/components/CopyValue.svelte b/src/lib/ui/components/CopyValue.svelte new file mode 100644 index 0000000..d7b52c7 --- /dev/null +++ b/src/lib/ui/components/CopyValue.svelte @@ -0,0 +1,11 @@ + +
{value}
+ diff --git a/src/lib/ui/components/DeployCollectionDialog.svelte b/src/lib/ui/components/DeployCollectionDialog.svelte index 2142ed5..ca4f744 100644 --- a/src/lib/ui/components/DeployCollectionDialog.svelte +++ b/src/lib/ui/components/DeployCollectionDialog.svelte @@ -1,4 +1,5 @@ - { if (event.target === dialog && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> + { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
Your lineup, ready to launch

{preview?.blueprintName ?? 'Preparing your collection'}

{#if error}{/if} @@ -80,9 +81,9 @@
diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 83812e7..0739643 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -1,4 +1,5 @@ - { if (event.target === dialog && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}> + { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}>
{sourceDeploymentId ? 'Launch from history' : 'A new place to play'}

{preview ? 'Review your next lobby' : 'Create your next server'}

  1. 01 Choose a starting point
  2. 02 Review & launch
@@ -144,19 +145,19 @@ diff --git a/src/lib/ui/components/DestroyServerModal.svelte b/src/lib/ui/components/DestroyServerModal.svelte index 3002489..2ba9dc9 100644 --- a/src/lib/ui/components/DestroyServerModal.svelte +++ b/src/lib/ui/components/DestroyServerModal.svelte @@ -32,7 +32,7 @@ { event.preventDefault(); void destroy(); }}>

Destroy container?

{name}

-

This stops the container, deletes its Komodo deployment, and removes its server files and campaign details. Deployment history and the saved server key are retained.

+

This stops the container, deletes its Komodo deployment, and removes its server files and rollout details. Deployment history and the saved server key are retained.

{#if players !== null && players > 0}{players} player{players === 1 ? '' : 's'} currently online.{/if} Destruction waits until a live check confirms the server is empty. You can follow its progress on the deployment page. @@ -47,12 +47,12 @@
diff --git a/src/lib/ui/components/LaunchRecipe.svelte b/src/lib/ui/components/LaunchRecipe.svelte index 992697b..b3b3693 100644 --- a/src/lib/ui/components/LaunchRecipe.svelte +++ b/src/lib/ui/components/LaunchRecipe.svelte @@ -21,13 +21,13 @@ diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte index e680c94..bca6391 100644 --- a/src/lib/ui/components/LogPanel.svelte +++ b/src/lib/ui/components/LogPanel.svelte @@ -1,5 +1,9 @@
@@ -17,8 +19,9 @@

Activity

{#if active}Updates automatically{/if}
{#if events.length} + {#if events.length > limit}{/if}
    - {#each events as event (event.id)} + {#each events.slice(-limit) as event (event.id)}
  1. {event.message}

  2. {/each}
@@ -34,7 +37,7 @@ time, .muted { color: var(--text-secondary); font-size: 13px; } p { margin: 0; line-height: 1.6; } .flow { display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0; list-style: none; } - .flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: 8px; padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); } + .flow li { display: flex; align-items: center; gap: 10px; background: var(--surface); border-radius: var(--radius-md); padding: 12px; font-size: 13px; border: 1px solid var(--border-subtle); color: var(--text-secondary); } .flow span { color: var(--accent-primary-hover); font-family: var(--font-mono); } .flow .done { color: var(--text-primary); } .flow .current { color: var(--text-primary); border-color: var(--accent-primary); } diff --git a/src/lib/ui/components/ServerCard.svelte b/src/lib/ui/components/ServerCard.svelte index b179a85..8210d02 100644 --- a/src/lib/ui/components/ServerCard.svelte +++ b/src/lib/ui/components/ServerCard.svelte @@ -1,10 +1,11 @@ +{mapLabel ?? identity.map}{#if identity.lobby !== null}Lobby {identity.lobby}{/if}{#if showGame && identity.game}{identity.game}{/if} + diff --git a/src/lib/ui/components/primitives/Button.svelte b/src/lib/ui/components/primitives/Button.svelte index 1bbe20a..748a40f 100644 --- a/src/lib/ui/components/primitives/Button.svelte +++ b/src/lib/ui/components/primitives/Button.svelte @@ -45,8 +45,9 @@ aria-label={ariaLabel} {title} href={disabled || loading ? undefined : href} - role="button" - tabindex="0" + tabindex={disabled || loading ? -1 : undefined} + aria-busy={loading} + onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }} > {#if leading}{@render leading()}{/if} {@render children?.()} @@ -60,6 +61,7 @@ {type} {title} aria-label={ariaLabel} + aria-busy={loading} disabled={disabled || loading} {onclick} > @@ -81,7 +83,8 @@ font-family: var(--font-body); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-normal); - line-height: 1; + line-height: 1.35; + min-height: 40px; cursor: pointer; text-decoration: none; border: 1px solid transparent; @@ -105,7 +108,7 @@ .btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); } /* Sizes */ - .s-sm { padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); } + .s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); } .s-md { padding: 9px 16px; font-size: var(--text-sm); } .s-lg { padding: 12px 22px; font-size: var(--text-base); } diff --git a/src/lib/ui/components/primitives/Card.svelte b/src/lib/ui/components/primitives/Card.svelte index cee1d8f..c798a98 100644 --- a/src/lib/ui/components/primitives/Card.svelte +++ b/src/lib/ui/components/primitives/Card.svelte @@ -45,6 +45,7 @@ {tabindex} {style} {onclick} + onkeydown={(event: KeyboardEvent) => { if (role === 'button' && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); (event.currentTarget as HTMLElement).click(); } }} > {@render children?.()} @@ -56,7 +57,8 @@ display: flex; flex-direction: column; border-radius: var(--radius-lg); - background: color-mix(in oklab, var(--surface) 90%, transparent); + background: var(--surface); + border: 1px solid var(--border-subtle); color: var(--text-primary); text-decoration: none; transition: @@ -77,12 +79,6 @@ /* Backdrop blur only where the browser supports it — some rendering backends downgrade poorly, and we don't want blurred noise. */ - @supports (backdrop-filter: blur(4px)) { - .card { - backdrop-filter: blur(10px) saturate(1.2); - -webkit-backdrop-filter: blur(10px) saturate(1.2); - } - } /* Padding scale */ .p-none { padding: 0; } @@ -91,23 +87,21 @@ .p-lg { padding: var(--space-6); } /* Elevation */ - .e-flat { box-shadow: var(--shadow-ambient); } + .e-flat { box-shadow: none; } .e-raised { box-shadow: var(--shadow-raised); } .e-interactive { - box-shadow: var(--shadow-ambient); + box-shadow: none; cursor: pointer; } .e-interactive:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-raised); + border-color: var(--border-strong); background: color-mix(in oklab, var(--surface-raised) 92%, transparent); } .e-interactive:focus-visible { outline: none; box-shadow: var(--focus-ring), var(--shadow-raised); } - .e-interactive:active { transform: translateY(-1px); } @media (max-width: 480px) { .p-md, .p-lg { padding: var(--space-4); } } diff --git a/src/lib/ui/components/primitives/Dialog.svelte b/src/lib/ui/components/primitives/Dialog.svelte new file mode 100644 index 0000000..599c959 --- /dev/null +++ b/src/lib/ui/components/primitives/Dialog.svelte @@ -0,0 +1,42 @@ + + + { if (busy) event.preventDefault(); }} onclose={closed} + onclick={event => { if (event.target === dialog) { const r = dialog!.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close(); } }}> +

{title}

{#if description}

{description}

{/if}
+
{@render children?.()}
+ {#if actions}
{@render actions()}
{/if} +
+ + diff --git a/src/lib/ui/components/primitives/EmptyState.svelte b/src/lib/ui/components/primitives/EmptyState.svelte index 58ea3ec..db53876 100644 --- a/src/lib/ui/components/primitives/EmptyState.svelte +++ b/src/lib/ui/components/primitives/EmptyState.svelte @@ -6,20 +6,15 @@ description?: string | null; icon?: Snippet; actions?: Snippet; + spacious?: boolean; } - let { title, description = null, icon, actions }: Props = $props(); + let { title, description = null, icon, actions, spacious = false }: Props = $props(); -
+
{#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} - + {/if}
{@render children?.()}
{#if 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}{/if} - - {#if loading} -
Loading…
- {:else} - -

Open a beta

- - {#if eligibleScripts.length === 0} - - {:else} - - -
- {#each picks as row, idx (row.key)} -
-
- - ▾ -
-
- - ▾ -
- -
- {/each} -
- -
- - -
- {#if candidateServers.length === 0} - No server satisfies all selected scopes — pick different scripts. - {:else} - {#each candidateServers as name (name)} - - {/each} - {/if} -
-
- - - -
- -
- - {/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()}{/snippet} + + +
+ {#if error}{/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()}{/snippet} + + {#if flash}{/if} + {#if $fleetError}{/if} + {#if loading} + {:else if eligibleScripts.length === 0 && !flash} + {:else if scripts.length} +
    + {#each steps as label, index}
  1. {index + 1}{label}
  2. {/each} +
+
+

{steps[step]}

+ {#if step === 0} +
+ {#each picks as row, idx (row.key)} +
+
+ + ▾ +
+
+ + ▾ +
+ +
+ {/each} +
+ +
+ + {:else if step === 1} +
+ {#if candidateServers.length === 0} + No server satisfies all selected scopes — pick different scripts. + {:else} + {#each candidateServers as name (name)} + + {/each} + {/if} +
+
+ + {:else if step === 2} + {:else} +
+

Files and versions

+
    {#each picks as pick}
  • {scripts.find(s => s.id === pick.scriptId)?.name} · {artifactVersionLabel(versionsForRow(pick).find(v => v.id === pick.versionId))}
  • {/each}
+

Test servers ({pickedServers.size})

+
    {#each [...pickedServers] as name}
  • {/each}
+

Pass criteria

{coverageSummary(testPlan)} · {testPlan.timeoutHours}-hour testing window

+
    {#each testPlan.requirements as requirement}
  • {requirementSummary(requirement)}
  • {/each}
+

Rollout and failure handling

+

{testPlan.waveSize ? `${testPlan.waveSize} servers per wave.` : 'Deploy to all selected servers.'} {testPlan.failureAction === 'rollback' ? 'Failed checks trigger an automatic rollback.' : testPlan.failureAction === 'pause' ? 'Failed checks pause testing for investigation.' : 'Failed checks hold the beta for review.'} {testPlan.rollbackRestart === 'when-empty' ? 'Rollback restarts wait for empty servers.' : 'Rollback restores files; restart the servers manually.'}

+

Starting installs these versions and restarts the selected servers. Promotion to the rest of the fleet is a separate action after testing.

+
+ {/if} +
+ + {#if step < 3} + {:else}{/if} +
+ + {/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}{/if} {#if loading}
Loading…
{:else} -
{/if} +
+
-
e.stopPropagation()} role="none"> + +
@@ -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}{/if} {#if detail} @@ -109,7 +110,7 @@ {#snippet crumbs()} {/snippet} @@ -139,13 +140,13 @@ {#each detail.servers as s (s.server)}
- {s.server} + {#if s.error} {s.error} {/if}
- {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)}
- {#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} - -
- -
-
- {/if} + + {#if 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}{/if} {#if detail?.collection}← Back to {detail.collection.name} collection{/if} {#if deployment}
-
{stateLabel(deployment.state)} +
{#if deployment.server_exists}View server{/if}

{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}
  1. {index < currentStep ? '✓' : index + 1}{step}
  2. {/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'}

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)}
  1. {event.message}

  2. {/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()}{/snippet} + + {#if successBanner} {/if} -
+ + {#if flash && !addOpen}{/if} +
+ + + + +
+
updatesOpen = event.currentTarget.open}>Check plugin updates{#if updatesOpen}{#key importCount}{/key}{/if}
+
+
+

All plugins

+ {plugins.length} +
+ {#if loading} +
Loading…
+ {:else if visible.length === 0 && !flash} + + {:else} + + + {/if} +
+
+ + + +
+ {#if method === 'github'} { importCount++; addOpen = false; await load(); }} /> + {:else} @@ -175,19 +230,19 @@
- {#if newScopeType === 'game' || newScopeType === 'map'} - {/if} {#if newScopeType === 'map'} - {/if}
- - +{/if} +
+
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.

-
-

{editing ? 'Update key' : 'Add key'}

+
+ + {#snippet actions()}{/snippet} + +
+ +{#if error && !open}{/if} +{#if notice}

{notice}

{/if} +

Saved keys

+{#if loading}

Loading keys…

+{:else if !filtered.length} +{:else} +
+ + {#each filtered.slice(0,limit) as key (key.server_id)} + + + + + + + {/each} +
Server IDStatusKeyActions
{key.server_id}{key.deployed ? 'Deployed' : 'Not deployed'}••••••••
+ + +
+{/if} + + +
+ + {#if error}{/if} + @@ -64,37 +106,16 @@
- {#if editing}{/if} +
-{#if error}{/if} -{#if notice}

{notice}

{/if} -

Saved keys

-{#if loading}

Loading keys…

-{:else if keys.length === 0}

No saved keys yet. Add one above or save one when deploying a server.

-{:else} -
- - {#each keys as key (key.server_id)} - - - - - - - {/each} -
Server IDStatusKeyActions
{key.server_id}{key.deployed ? 'Deployed' : 'Not deployed'}••••••••
- - -
-{/if} - +
diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 6096292..e61caca 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -1,9 +1,17 @@