Complete workflow navigation and document page groups

This commit is contained in:
2026-10-05 19:25:55 -04:00
parent f20cfe1300
commit 9d4ff992a4
6 changed files with 33 additions and 10 deletions
+19 -3
View File
@@ -17,7 +17,7 @@ docker compose up -d --build
Open EC and create the first administrator using `EC_SETUP_TOKEN` (at least 32 Open EC and create the first administrator using `EC_SETUP_TOKEN` (at least 32
characters; generate it with `openssl rand -hex 32`). After setup, remove that characters; generate it with `openssl rand -hex 32`). After setup, remove that
token from the environment. Administrators can add administrators, operators and token from the environment. Administrators can add administrators, operators and
viewers from **Settings → Users**. See [Users and access](docs/authentication.md) viewers from **Administration → Users**. See [Users and access](docs/authentication.md)
for permissions, sessions, password recovery and upgrade instructions. for permissions, sessions, password recovery and upgrade instructions.
Operator pages and management APIs require login. Enrolled agents authenticate Operator pages and management APIs require login. Enrolled agents authenticate
@@ -32,11 +32,11 @@ including SQLite metadata and published game-file storage.
including its DLC files. There are no library names or version labels to enter. including its DLC files. There are no library names or version labels to enter.
Each upload becomes the latest files for that game after verification. Each upload becomes the latest files for that game after verification.
Uploads work before any host is enrolled. Uploads work before any host is enrolled.
2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its 2. **Fleet → Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its
generated enrollment command. The host automatically prepares its Plutainer generated enrollment command. The host automatically prepares its Plutainer
runtime and the latest T5 and T6 files, when available. The host page runtime and the latest T5 and T6 files, when available. The host page
shows setup progress. Automatic agent updates are enabled by default. shows setup progress. Automatic agent updates are enabled by default.
3. Add the exact Plutonium server keys in **Keys**, or supply missing keys when 3. Add the exact Plutonium server keys in **Administration → Keys**, or supply missing keys when
launching. launching.
4. Launch a server or collection. Collection previews show cached capacity 4. Launch a server or collection. Collection previews show cached capacity
estimates; Apply saves the queue and opens its progress page. Provisioning estimates; Apply saves the queue and opens its progress page. Provisioning
@@ -61,6 +61,22 @@ continue. The host page shows update status and the last check.
## Fleet and operations ## Fleet and operations
The sidebar groups pages into **Fleet**, **Library**, **Operations**, **Analytics**,
and **Administration** (administrators only). On small screens, open the navigation
menu in the header. The account menu contains **My account**, including your
browser's display timezone; keyboard shortcuts are available through **?**.
**Operations → Overview** combines work that needs attention, running workflows,
and recent results. Related provisioning attempts and host commands stay grouped
under their workflow. Normal waits for empty lobbies remain under **Running**;
successful routine checks remain in **Host operation history**. Full history is
available on each workflow page.
Server details use **Live**, **Matches**, **Configuration**, **Diagnostics**, and
**History** sections. Hosts use **Overview**, **Servers**, **Setup**, and **Settings**.
Contextual Back links preserve the originating filters and views, while breadcrumbs
show each page's place in the navigation. Existing URLs and server tab links work.
The fleet registry lives in SQLite, independently of host filesystems. Status The fleet registry lives in SQLite, independently of host filesystems. Status
combines agent container health, fresh game telemetry and agent-local RCON. combines agent container health, fresh game telemetry and agent-local RCON.
Unreachable hosts report unknown instance state; they do not trigger duplicate Unreachable hosts report unknown instance state; they do not trigger duplicate
+1 -1
View File
@@ -20,7 +20,7 @@ their access checks. HTTPS requirements still apply. See the
password. Only the holder of the token can create the first administrator. password. Only the holder of the token can create the first administrator.
4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller. 4. Remove `EC_SETUP_TOKEN` after setup and restart/recreate the controller.
Setup remains closed once any user exists, even if the token is still set. Setup remains closed once any user exists, even if the token is still set.
5. Use **Settings → Users** to create additional accounts. Share temporary 5. Use **Administration → Users** to create additional accounts. Share temporary
passwords privately; each user must change theirs on first login. passwords privately; each user must change theirs on first login.
Existing installations become protected on upgrade. Existing game servers and Existing installations become protected on upgrade. Existing game servers and
+1 -1
View File
@@ -1,6 +1,6 @@
# Metrics # Metrics
Metrics is a primary navigation tab at `/metrics` (keyboard shortcut: `g m`). Metrics is under **Analytics** at `/metrics` (keyboard shortcut: `g m`).
Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute. Choose the past 24 hours, 7 days, or 30 days. The page refreshes every minute.
The overview shows **Time with players** (occupied server-hours divided by known The overview shows **Time with players** (occupied server-hours divided by known
+2 -2
View File
@@ -291,7 +291,7 @@
<BackLink fallback={basePath} /> <BackLink fallback={basePath} />
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if} {#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if} {#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={contextHref(betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]), $page.url)}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId} {#if runningBetaId}
<div class="warn-banner"> <div class="warn-banner">
@@ -382,7 +382,7 @@
<Badge variant="online" size="sm">Promoted</Badge> <Badge variant="online" size="sm">Promoted</Badge>
{:else} {:else}
<StatusBadge state={v.state} size="sm" /> <StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button size="sm" href={betaSelectionHref([{scriptId:id,versionId:v.id}])}>Test version</Button>{/if} {#if runningBetaId === null}<Button size="sm" href={contextHref(betaSelectionHref([{scriptId:id,versionId:v.id}]), $page.url)}>Test version</Button>{/if}
{/if} {/if}
<details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details> <details class="version-technical"><summary>File details</summary><span>SHA-256: {v.sha256}</span><span>Size: {v.size.toLocaleString()} bytes</span></details>
</div> </div>
+8 -2
View File
@@ -1,4 +1,8 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation'; import { beforeNavigate, goto } from '$app/navigation';
@@ -167,14 +171,16 @@
const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json(); const response=await fetch('/api/betas',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scripts:picks.map(p=>({scriptId:p.scriptId,scriptVersionId:p.versionId})),servers:[...pickedServers],testPlan:parseBetaPlan(testPlan),notes:betaLabel.trim()||undefined,waitForHealth:true,waitForMatch:false})}), body=await response.json();
if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.'); if(!response.ok||!body.ok)throw new Error(body.error??'Could not start beta. Review the selected versions and servers.');
draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true; draftReady=false;try{localStorage.removeItem(betaDraftKey);}catch{}allowLeave=true;
await goto(`/betas/${body.result.betaId}`); await goto(contextHref(`/betas/${body.result.betaId}`, safeReturnHref($page.url.searchParams.get('returnTo'), '/betas')));
}catch(error){flash=(error as Error).message;}finally{openingBeta=false;} }catch(error){flash=(error as Error).message;}finally{openingBeta=false;}
} }
</script> </script>
<svelte:head><title>New beta | Electric Cherry</title></svelte:head> <svelte:head><title>New beta | Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting.">{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}</SectionHeader> <Breadcrumbs items={[{ label: 'Betas', href: '/betas' }, { label: 'New beta' }]} />
<BackLink fallback="/betas" />
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting." />
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if} {#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if} {#if notice}<p role="status" class="notice">{notice}</p>{/if}
+2 -1
View File
@@ -33,7 +33,7 @@
interface Detail { interface Detail {
deployment: { deployment: {
id: string; action: string; started_at: string; id: string; action: string; started_at: string;
finished_at: string | null; triggered_by: string | null; finished_at: string | null; triggered_by: string | null; beta_id?: number | null;
}; };
servers: ServerRow[]; servers: ServerRow[];
files: FileRow[]; files: FileRow[];
@@ -149,6 +149,7 @@
</section> </section>
{#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>View incident and recovery history</a></p>{/if} {#if detail.watchdogIncidentId}<p class="watchdog-source">Started by Maintenance · <a href={contextHref(`/maintenance/${encodeURIComponent(detail.watchdogIncidentId)}`, $page.url)}>View incident and recovery history</a></p>{/if}
{#if detail.deployment.beta_id}<p class="watchdog-source">Started from <a href={contextHref(`/betas/${detail.deployment.beta_id}`, $page.url)}>Beta #{detail.deployment.beta_id}</a> · Review its files, checks, and test servers.</p>{/if}
{#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))} {#if detail.servers.some(server => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
<div><Button onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button><p>Apply held changes and restart after the safety checks pass.</p></div> <div><Button onclick={resume} loading={resuming} disabled={resuming}>Resume when empty</Button><p>Apply held changes and restart after the safety checks pass.</p></div>