Clarify server actions and group maintenance failures

This commit is contained in:
2026-10-07 19:21:53 -04:00
parent bcecc42dd1
commit c39563daef
15 changed files with 559 additions and 111 deletions
+3 -3
View File
@@ -2,16 +2,16 @@ export const provisioningActions = ['create', 'start', 'restart', 'rebuild', 'st
export type ProvisioningAction = typeof provisioningActions[number];
export type RuntimeAction = Exclude<ProvisioningAction, 'create' | 'destroy'>;
export const actionLabels: Record<ProvisioningAction, string> = {
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Destroy'
create: 'Create', start: 'Start', restart: 'Restart', rebuild: 'Rebuild', stop: 'Stop', destroy: 'Remove'
};
export const actionCompleted: Record<ProvisioningAction, string> = {
create: 'Server creation completed', start: 'Server started', restart: 'Server restarted',
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server destroyed'
rebuild: 'Server rebuilt', stop: 'Server stopped', destroy: 'Server removed'
};
export const runtimeActions: RuntimeAction[] = ['start', 'restart', 'rebuild', 'stop'];
export const runtimeDescriptions: Record<RuntimeAction, string> = {
start: 'Start the existing container and verify its health.',
restart: 'Restart the container after the server is empty, then verify its health.',
rebuild: 'Destroy the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
rebuild: 'Remove the instance and delete all of its server files, then create a fresh instance in the same server slot and port with the latest promoted setup. No instance data is retained. Uses the key saved separately in Keys. Waits until empty, then verifies health and map.',
stop: 'Stop the container after the server is empty. Its files, key, and registration are retained so you can start it again.'
};
+13 -8
View File
@@ -6,8 +6,10 @@
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Freshness from './Freshness.svelte';
let { server, history }: { server?: string; history?: ContextualChatEntry[] } = $props();
let { server, history, hostOffline = false, hostName }: { server?: string; history?: ContextualChatEntry[]; hostOffline?: boolean; hostName?: string } = $props();
let updatedAt = $state<number | null>(null);
let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries));
@@ -24,7 +26,7 @@
let controller: AbortController | null = null;
async function refresh(manual = false) {
if (!server) return;
if (!server || hostOffline) return;
if (loading) {
if (manual) manualRefreshing = true;
return;
@@ -37,11 +39,11 @@
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
const data = await response.json().catch(() => null);
if (!response.ok || !data?.ok) throw new Error('Could not refresh chat. Try again shortly.');
if (request.signal.aborted) return;
if (request.signal.aborted || hostOffline) return;
if (paused && !manual) return;
liveEntries = data.entries ?? [];
missing = !!data.missing;
liveLoaded = true;
liveLoaded = true; updatedAt = Date.now();
error = '';
await tick();
if (follow && body) body.scrollTop = body.scrollHeight;
@@ -50,6 +52,8 @@
} finally { loading = false; manualRefreshing = false; }
}
$effect(() => { if (hostOffline) controller?.abort(); });
function onScroll() {
if (body) follow = body.scrollHeight - body.scrollTop - body.clientHeight < 40;
}
@@ -93,17 +97,17 @@
<section aria-label="Player chat">
<div class="chat-head">
<div class="chat-title"><h2>Chat</h2><span class="update-cadence">{server ? paused ? 'Paused' : 'Live · 5s' : 'Match history'}</span></div>
<div class="chat-title"><h2>Chat</h2>{#if !server}<span class="update-cadence">Match history</span>{:else if hostOffline}<span class="update-cadence">Host offline</span>{:else if paused}<span class="update-cadence">Paused</span>{:else if !error}<Freshness {updatedAt} intervalLabel="every 5 s" />{/if}</div>
{#if server}
<div class="actions">
{#if hasMessages}<Button size="sm" variant="ghost" onclick={() => paused = !paused}>{paused ? 'Resume' : 'Pause'}</Button>{/if}
{#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
<Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
</div>
{/if}
</div>
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
{#if !loaded && !error}
{#if hostOffline}<p class="outage">Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}</p>
{:else if error}<p class="ui-error" role="alert">Chat updates paused. {loaded ? 'Showing the last loaded messages.' : 'Could not load messages.'} <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing}>Retry</Button></p>{/if}
{#if !loaded && !error && !hostOffline}
<p class="empty" role="status">Loading chat…</p>
{:else if loaded && !hasMessages}
<p class="empty">{history !== undefined ? 'No chat messages were saved for this match.' : missing ? 'Waiting for the game log. Player messages will appear here.' : 'No player messages in this match yet.'}</p>
@@ -152,6 +156,7 @@
.chat-title { gap: var(--space-2) var(--space-3); }
h2 { margin: 0; font-size: var(--text-lg); }
.update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
.outage { color: var(--state-warning); font-size: var(--text-sm); line-height: var(--leading-body); }
.empty { color: var(--text-secondary); font-size: var(--text-sm); padding: 8px 0; margin-bottom: 0; }
.messages { max-height: 360px; overflow: auto; margin-top: var(--space-4); }
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
+20 -29
View File
@@ -1,15 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { lifecycleImpact } from '$ui/lifecycle-actions.js';
import { serverTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
let { open = $bindable(false), name, players }: { open?: boolean; name: string; players: number | null } = $props();
let dialog = $state<HTMLDialogElement>();
let { open = $bindable(false), name, players, round = null }: { open?: boolean; name: string; players: number | null; round?: number | null } = $props();
let submitting = $state(false);
let error = $state('');
$effect(() => {
if (!dialog) return;
if (open && !dialog.open) { error = ''; dialog.showModal(); }
else if (!open && dialog.open) dialog.close();
if (open) error = '';
});
async function destroy() {
@@ -19,7 +19,7 @@
try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
const body = await res.json();
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue destruction. Please retry.');
if (!res.ok || !body.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue server removal. Please retry.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false;
} catch (err) { error = (err as Error).message; }
@@ -27,33 +27,24 @@
}
</script>
<dialog bind:this={dialog} aria-labelledby="destroy-title" onclose={() => open = false}
oncancel={(event) => { if (submitting) event.preventDefault(); }}>
<form onsubmit={(event) => { event.preventDefault(); void destroy(); }}>
<h2 id="destroy-title">Queue server removal?</h2>
<p class="name">{name}</p>
<p>This stops and removes the container, its writable server files, and campaign details. Shared game libraries, provisioning history, and the saved server key are retained.</p>
<div class="notice">
{#if players !== null && players > 0}<strong>{players} player{players === 1 ? '' : 's'} currently online.</strong>{/if}
Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the provisioning page.
</div>
<Dialog bind:open title={`Remove ${serverTitle(name)}?`} busy={submitting}>
<div class="fields">
<p class="notice">{lifecycleImpact('destroy', players, round)}</p>
<p>This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.</p>
<p>Follow progress or cancel the waiting request on the provisioning page.</p>
<p class="irreversible">You can cancel while waiting. Once removal begins, it cannot be undone.</p>
{#if error}<p role="alert" class="error">{error}</p>{/if}
<footer>
<Button variant="ghost" disabled={submitting} onclick={() => open = false}>Cancel</Button>
<Button variant="danger" type="submit" loading={submitting}>Queue server removal</Button>
</footer>
</form>
</dialog>
</div>
{#snippet actions()}
<Button disabled={submitting} onclick={() => open = false}>Cancel</Button>
<Button variant="danger" disabled={submitting} loading={submitting} onclick={destroy}>Remove server when empty</Button>
{/snippet}
</Dialog>
<style>
dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); color: var(--text-primary); }
dialog::backdrop { background: rgb(0 0 0 / .65); backdrop-filter: blur(4px); }
form { padding: 28px; } h2 { margin: 0 0 12px; font-size: 22px; }
p { line-height: 1.6; color: var(--text-secondary); }
.name { font-family: var(--font-mono); color: var(--text-primary); overflow-wrap: anywhere; }
.fields { display: grid; gap: 16px; }
p { margin: 0; line-height: 1.6; color: var(--text-secondary); }
.notice { padding: 16px; background: var(--surface-raised); border-radius: var(--radius-md); line-height: 1.6; }
strong { display: block; color: var(--state-warning); margin-bottom: 4px; }
.notice { color: var(--text-primary); }
.irreversible { font-size: 13px; } .error { color: var(--state-danger); }
footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 24px; }
</style>
+10 -9
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { serverTitle } from '$ui/server-labels.js';
import { isDialogBackdrop } from '$ui/dialog.js';
import Button from './primitives/Button.svelte';
@@ -36,7 +37,7 @@
const body = await res.json();
if (!res.ok) {
if (res.status === 409) servers = null;
throw new Error(body.error ?? 'Could not queue destruction.');
throw new Error(body.error ?? 'Could not queue server removal.');
}
open = false;
onqueued(body.deployments.length);
@@ -45,21 +46,21 @@
}
</script>
<dialog bind:this={dialog} aria-label="Destroy fleet" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<dialog bind:this={dialog} aria-label="Remove fleet servers" onclick={event => { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
<div class="content">
<header><h2>Queue fleet removal</h2></header>
<p>Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Destruction removes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
<header><h2>Remove fleet servers?</h2></header>
<p>Queue removal of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.</p>
<p>Removal deletes containers, server files, and fleet registrations. Saved keys and collections are kept.</p>
<p>In Provisioning’s Active tab, cancel individual waiting requests or all waiting removals. Once a server begins removal, that removal cannot be cancelled or undone.</p>
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if servers}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'}</strong>
{#if servers.length}<ul>{#each servers as server}<li>{server}</li>{/each}</ul><p class="caption">Existing destruction requests are reused. Servers added after this request are not included.</p>{/if}
<strong>{servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to remove` : 'No servers to remove.'}</strong>
{#if servers.length}<ul>{#each servers as server}<li>{serverTitle(server)}</li>{/each}</ul><p class="caption">Existing removal requests are reused. Servers added after this request are not included.</p>{/if}
{:else}<p>{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}</p>{/if}
<div class="actions">
<Button variant="ghost" disabled={busy} onclick={() => open = false}>Cancel</Button>
{#if !servers}<Button disabled={busy} onclick={review}>Refresh preview</Button>
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Queue destruction of {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
{:else}<Button variant="danger" loading={busy} disabled={busy || !servers.length} onclick={launch}>Remove {servers.length} server{servers.length === 1 ? '' : 's'}</Button>{/if}
</div>
</div>
</dialog>
@@ -70,6 +71,6 @@
.content { display: grid; gap: 20px; padding: 28px; } h2, p { margin: 0; } h2 { font-size: 25px; }
p { color: var(--text-secondary); font-size: 13px; line-height: 1.6; } .error { color: var(--state-danger); } .caption { font-size: 12px; }
ul { margin: 0; padding: 12px 12px 12px 30px; max-height: 220px; overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-md); }
li { padding: 5px 0; font-family: var(--font-mono); font-size: 12px; overflow-wrap: anywhere; }
li { padding: 5px 0; font-family: var(--font-body); font-size: 12px; overflow-wrap: anywhere; }
.actions { display: flex; gap: 12px; justify-content: flex-end; flex-wrap: wrap; }
</style>
+73 -22
View File
@@ -1,41 +1,79 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { runtimeActions, actionLabels, runtimeDescriptions, type RuntimeAction } from '$lib/provisioning.js';
import { currentRound, freshServer, type FleetServer } from '$lib/fleet-overview.js';
import { actionLabels, runtimeDescriptions, type RuntimeAction } from '$lib/provisioning.js';
import { applicableRuntimeActions, lifecycleImpact } from '$ui/lifecycle-actions.js';
import { serverHealth } from '$ui/server-health.js';
import { serverTitle } from '$ui/server-labels.js';
import { serverTimeNow, syncServerTime, uiNow } from '$ui/stores/servers.js';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
let { open = $bindable(false), initialServer = '', initialAction = 'start' }:
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction } = $props();
let servers = $state<{ name: string }[]>([]);
let server = $state('');
let action = $state<RuntimeAction | 'destroy'>('start');
let { open = $bindable(false), initialServer = '', initialAction = 'start', serverContext = null, players = null, round = null }:
{ open?: boolean; initialServer?: string; initialAction?: RuntimeAction; serverContext?: FleetServer | null; players?: number | null; round?: number | null } = $props();
let servers = $state<FleetServer[]>([]);
let server = $state(untrack(() => initialServer));
let action = $state<RuntimeAction | 'destroy'>(untrack(() => initialAction));
let busy = $state(false);
let loading = $state(false);
let error = $state('');
let confirmed = $state(false);
const selected = $derived(serverContext?.name === server ? serverContext : servers.find(item => item.name === server));
const available = $derived(selected ? applicableRuntimeActions(selected, $uiNow) : []);
const applicable = $derived(action === 'destroy' || available.includes(action));
const destructive = $derived(action === 'rebuild' || action === 'destroy');
const health = $derived(selected ? serverHealth(selected, $uiNow) : null);
const impactPlayers = $derived(initialServer ? players : selected && health?.state === 'online' && freshServer(selected, $uiNow) ? selected.last_players : null);
const impactRound = $derived(initialServer ? round : selected ? currentRound(selected, selected.telemetry, $uiNow) : null);
const title = $derived(initialServer ? `${actionLabels[action]} ${serverTitle(initialServer)}?` : 'Manage server');
const submitLabel = $derived(action === 'start' ? 'Start server' : action === 'destroy' ? 'Remove server when empty' : `${actionLabels[action]} when empty`);
$effect(() => {
if (open) { server = initialServer; action = initialAction; confirmed = false; error = ''; void load(); }
if (!open) return;
const fixedServer = initialServer;
const requestedAction = initialAction;
const controller = new AbortController();
// Live context and match reports update the copy and eligibility without
// reopening the dialog or clearing a deliberate confirmation.
untrack(() => {
server = fixedServer; action = requestedAction; confirmed = false; error = ''; servers = [];
loading = false;
if (!fixedServer || serverContext?.name !== fixedServer) void load(fixedServer, controller.signal);
});
return () => controller.abort();
});
async function load() {
async function load(fixedServer: string, signal: AbortSignal) {
loading = true;
try {
const response = await fetch('/api/servers');
const response = await fetch(fixedServer ? `/api/servers/${encodeURIComponent(fixedServer)}` : '/api/servers', { signal });
if (!response.ok) throw new Error('Could not load servers.');
servers = (await response.json()).servers;
} catch (err) { error = (err as Error).message; }
finally { loading = false; }
const body = await response.json();
if (signal.aborted) return;
servers = body.servers;
syncServerTime(body.serverNow);
} catch (err) { if (!signal.aborted) error = (err as Error).message; }
finally { if (!signal.aborted) loading = false; }
}
function chooseServer(event: Event & { currentTarget: EventTarget & HTMLSelectElement }) {
server = event.currentTarget.value;
confirmed = false; error = '';
const item = servers.find(item => item.name === server);
const actions = item ? applicableRuntimeActions(item, serverTimeNow()) : [];
action = actions[0] ?? 'destroy';
}
async function submit() {
if (busy || !server || (destructive && !confirmed)) return;
if (action !== 'destroy' && (!selected || !applicableRuntimeActions(selected, serverTimeNow()).includes(action))) {
error = 'The server status has changed. Close this dialog and choose an action from its current status.';
return;
}
busy = true; error = '';
try {
const response = action === 'destroy'
? await fetch(`/api/servers/${encodeURIComponent(server)}`, { method: 'DELETE' })
: await fetch('/api/provisioning', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server, action }) });
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not queue this operation.');
if (!response.ok || !body.deploymentId) throw new Error(body.error ?? 'Could not queue this operation.');
await goto(`/provisioning/${encodeURIComponent(body.deploymentId)}`);
open = false;
} catch (err) { error = (err as Error).message; }
@@ -43,23 +81,34 @@
}
</script>
<Dialog bind:open title="Manage server" {busy}>
<Dialog bind:open {title} {busy}>
<div class="fields">
<label>Server <select bind:value={server} disabled={busy || loading} onchange={() => confirmed = false}>
{#if !initialServer}
<label>Server <select bind:value={server} disabled={busy || loading} onchange={chooseServer}>
<option value="">{loading ? 'Loading servers…' : 'Choose a server'}</option>
{#each servers as item}<option value={item.name}>{serverTitle(item.name)} · {item.name}</option>{/each}
{#each servers as item (item.name)}<option value={item.name}>{serverTitle(item.name)}</option>{/each}
</select></label>
<label>Action <select bind:value={action} disabled={busy} onchange={() => confirmed = false}>
{#each runtimeActions as value}<option value={value}>{actionLabels[value]}</option>{/each}
<option value="destroy">Destroy</option>
<label>Action <select bind:value={action} disabled={busy || !server || loading} onchange={() => { confirmed = false; error = ''; }}>
{#if !server}<option value="start">Choose a server first</option>{/if}
{#if server && !applicable}<option value={action} disabled>{actionLabels[action]} unavailable</option>{/if}
{#each available as value}<option value={value}>{actionLabels[value]}</option>{/each}
{#if server}<option value="destroy">Remove server</option>{/if}
</select></label>
<p>{action === 'destroy' ? 'Destroy the container and remove its writable server files and registration after the server is empty. Shared game libraries, provisioning history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
{#if destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that all files and data in this instance will be permanently deleted.</label>{/if}
{/if}
{#if loading}<p role="status">Checking the latest server status…</p>
{:else if server}
{#if applicable}
<p class="impact">{lifecycleImpact(action, impactPlayers, impactRound)}</p>
<p>{action === 'destroy' ? 'Remove the container, its writable server files, and registration after the server is empty. Shared game libraries, recorded history, and the key saved separately in Keys are retained.' : runtimeDescriptions[action]}</p>
{#if action !== 'start'}<p class="help">Follow progress or cancel the waiting request on the provisioning page.</p>{/if}
{:else}<p role="status">{actionLabels[action]} is unavailable. {health?.reason ?? 'The current server status could not be verified.'} Close this dialog and choose an action from its current status.</p>{/if}
{#if applicable && destructive}<label class="confirmation"><input type="checkbox" bind:checked={confirmed} disabled={busy} /> I understand that this instance’s writable files and data will be permanently deleted.</label>{/if}
{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
</div>
{#snippet actions()}
<Button disabled={busy} onclick={() => open = false}>Cancel</Button>
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || (destructive && !confirmed)} loading={busy} onclick={submit}>Queue {actionLabels[action].toLowerCase()}</Button>
<Button variant={destructive ? 'danger' : 'primary'} disabled={busy || loading || !server || !applicable || (destructive && !confirmed)} loading={busy} onclick={submit}>{submitLabel}</Button>
{/snippet}
</Dialog>
@@ -67,4 +116,6 @@
.fields { display: grid; gap: 16px; } label { display: grid; gap: 8px; }
select { padding: 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); }
p { margin: 0; line-height: 1.6; color: var(--text-secondary); } .confirmation { display: flex; align-items: flex-start; gap: 12px; line-height: 1.5; }
.impact { padding: var(--space-4); border-radius: var(--radius-md); background: var(--surface-raised); color: var(--text-primary); }
.help { font-size: var(--text-sm); }
</style>
+28
View File
@@ -0,0 +1,28 @@
import type { FleetServer } from '../fleet-overview.js';
import { actionLabels, type RuntimeAction } from '../provisioning.js';
import { freshStatusTimestamp } from '../server-freshness.js';
import { serverHealth } from './server-health.js';
/** UI affordances only: the queue still checks ownership, conflicts and occupancy. */
export function applicableRuntimeActions(server: FleetServer, now: number): RuntimeAction[] {
const health = serverHealth(server, now).state;
if (health === 'stopped') return ['start', 'rebuild'];
const currentRunningContainer = server.container_state === 'running'
&& freshStatusTimestamp(server.container_checked_at ?? null, now)
&& (!server.telemetry_after || Date.parse(server.container_checked_at!) > Date.parse(server.telemetry_after));
if (health === 'online' || health === 'not-responding' && currentRunningContainer) return ['restart', 'stop', 'rebuild'];
return [];
}
/** Counts must come from fresh observations; null must never read as an empty lobby. */
export function lifecycleImpact(action: RuntimeAction | 'destroy', players: number | null, round: number | null): string {
if (action === 'start') return 'Start the existing container, then verify that the game is responding.';
const label = action === 'destroy' ? 'Removal' : actionLabels[action];
if (players === null || !Number.isSafeInteger(players) || players < 0) {
return `Player count is unknown. ${label} waits for a live check confirming the lobby is empty.`;
}
if (players === 0) return `The lobby is currently empty. A live check will confirm this before ${label.toLowerCase()} begins.`;
const subject = `${players.toLocaleString('en-US')} ${players === 1 ? 'player is' : 'players are'}`;
const activity = round !== null && Number.isSafeInteger(round) && round >= 0 ? `in round ${round.toLocaleString('en-US')}` : 'currently online';
return `${subject} ${activity}. ${label} waits until the lobby is empty.`;
}
+86
View File
@@ -0,0 +1,86 @@
import type { MaintenanceJob, PlutoniumVersion } from '../plutonium.js';
import type { StatusTone } from './status.js';
export interface ScanErrorGroup {
key: string;
title: string;
description: string;
servers: string[];
details: { server: string | null; message: string }[];
}
const scanCauses = [
{ key: 'host', pattern: /(?:host|agent).*(?:offline|unavailable|disconnect|not connected)/i,
title: 'Host agent unavailable', description: 'The host agent could not complete the checks. Check its connection, then retry.' },
{ key: 'timeout', pattern: /timed?\s*out|timeout|no reply/i,
title: 'Checks timed out', description: 'The check did not receive a response in time. Check the server connection, then retry.' },
{ key: 'permissions', pattern: /permission denied|access denied|EACCES|EPERM/i,
title: 'File access denied', description: 'The host agent could not read files needed for the check. Review file permissions on the affected host.' },
{ key: 'files', pattern: /no such file|file.*not found|ENOENT|missing (?:file|directory)|cannot find.*file/i,
title: 'Files unavailable', description: 'Files needed for the check could not be found. Review the affected server’s files and retry.' },
{ key: 'response', pattern: /bad.reply|invalid (?:response|reply)|malformed|could not parse/i,
title: 'Unexpected response', description: 'The response could not be read. Inspect the connection and host logs before retrying.' },
{ key: 'setup', pattern: /register a native host instance/i,
title: 'No managed servers', description: 'Register a host and create a server before running automatic maintenance checks.' }
];
const otherCause = { key: 'other', title: 'Other check failures', description: 'Some checks could not finish. Review the technical details before retrying.' };
/** Worker errors use "server: detail" lines. Only known names become links. */
export function groupMaintenanceScanErrors(raw: string | null | undefined, knownServers: readonly string[]): ScanErrorGroup[] {
if (!raw?.trim()) return [];
const names = [...new Set(knownServers)].sort((a, b) => b.length - a.length);
const groups = new Map<string, ScanErrorGroup>();
for (const line of raw.split(/\r?\n/).map(value => value.trim()).filter(Boolean)) {
const server = names.find(name => line.startsWith(`${name}:`)) ?? null;
const message = server ? line.slice(server.length + 1).trim() : line;
const cause = scanCauses.find(cause => cause.pattern.test(message)) ?? otherCause;
let group = groups.get(cause.key);
if (!group) {
group = { key: cause.key, title: cause.title, description: cause.description, servers: [], details: [] };
groups.set(cause.key, group);
}
if (server && !group.servers.includes(server)) group.servers.push(server);
if (!group.details.some(detail => detail.server === server && detail.message === message)) group.details.push({ server, message });
}
return [...groups.values()];
}
type VersionStatus = NonNullable<PlutoniumVersion['update_status']>;
const versionStatuses: Record<VersionStatus, { label: string; tone: StatusTone }> = {
current: { label: 'Up to date', tone: 'online' },
outdated: { label: 'Update available', tone: 'warning' },
ahead: { label: 'Ahead of published release', tone: 'neutral' },
stopped: { label: 'Intentionally stopped', tone: 'neutral' },
unknown: { label: 'Unverified', tone: 'warning' }
};
export interface MaintenanceVersionGroup {
key: string;
revision: number | null;
status: VersionStatus;
label: string;
tone: StatusTone;
versions: PlutoniumVersion[];
pending: MaintenanceJob[];
updatable: string[];
}
/** Keep verification and pending work distinct even when revision numbers match. */
export function groupMaintenanceVersions(versions: readonly PlutoniumVersion[], jobs: readonly MaintenanceJob[]): MaintenanceVersionGroup[] {
const groups = new Map<string, MaintenanceVersionGroup>();
const pending = jobs.filter(job => !job.server_deleted_at && ['queued', 'restarting'].includes(job.state));
for (const version of versions) {
const status = version.update_status ?? (version.stopped ? 'stopped' : 'unknown');
const key = `${version.revision ?? 'unknown'}:${status}`;
let group = groups.get(key);
if (!group) {
group = { key, revision: version.revision, status, ...versionStatuses[status], versions: [], pending: [], updatable: [] };
groups.set(key, group);
}
group.versions.push(version);
const work = pending.filter(job => job.server === version.server);
group.pending.push(...work);
if (status === 'outdated' && !work.length) group.updatable.push(version.server);
}
const order: Record<VersionStatus, number> = { unknown: 0, outdated: 1, current: 2, ahead: 3, stopped: 4 };
return [...groups.values()].sort((a, b) => order[a.status] - order[b.status] || (b.revision ?? 0) - (a.revision ?? 0));
}
+69 -15
View File
@@ -15,9 +15,13 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
let data = $state<MaintenanceOverview | null>(null);
let error = $state('');
@@ -34,6 +38,7 @@
let scheduleLoaded = false;
let displayedZone = $state('');
let request = 0;
let checkedAt = $state<number | null>(null);
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'policies', label: 'Policies & schedules' }, { id: 'activity', label: 'Activity' }];
const policies: { id: MaintenancePolicy; title: string; description: string }[] = [
{ id: 'health', title: 'Health recovery', description: 'Restart unhealthy servers, then rebuild once if recovery fails. Rebuilding deletes instance files and installs the latest promoted setup.' },
@@ -44,6 +49,9 @@
const outdated = $derived(versions.filter(v => v.update_status === 'outdated'));
const unknown = $derived(versions.filter(v => v.update_status === 'unknown'));
const activeJobs = $derived(data?.runtime.jobs.filter(j => !j.server_deleted_at && ['queued', 'restarting'].includes(j.state)) ?? []);
const scanErrors = $derived(groupMaintenanceScanErrors(data?.recovery.scanError, data?.runtime.servers.map(version => version.server) ?? []));
const versionGroups = $derived(groupMaintenanceVersions(versions, activeJobs));
const updatableServers = $derived(versionGroups.flatMap(group => group.updatable));
const activities = $derived([
...(data?.recovery.incidents ?? []).map(i => ({
key: i.id, kind: i.kind, server: i.server, server_deleted_at: i.server_deleted_at, at: i.first_seen_at,
@@ -92,6 +100,7 @@
}
function receive(body: MaintenanceOverview) {
data = body;
checkedAt = Date.now();
if (!scheduleLoaded) { scheduleEnabled = body.runtime.schedule.enabled; scheduleLoaded = true; }
}
async function load() {
@@ -129,8 +138,9 @@
{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if}
{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 5 seconds" />{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.</div>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual server operations continue.</div>{/if}
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/provisioning" />{/if}
<Tabs {tabs} bind:value={view} onchange={() => saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" />
<div id="maintenance-panel" role="tabpanel" aria-labelledby={`maintenance-tabs-${view}`} class="panels">
@@ -159,10 +169,21 @@
</section>
{:else}
{#if view === 'overview'}
<div class="summary"><span><strong>{attention}</strong> need attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> versions unverified</span></div>
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
{#if data.runtime.schedule.nextAt}<p class="muted">Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · <a href="/maintenance?view=policies#schedule">Manage schedule</a></p>{/if}
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}</p>{/if}
{#if data.recovery.scanError}<p class="ui-error" role="alert">{data.recovery.scanError}</p>{/if}
{#if scanErrors.length}
<section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading"><h2 id="scan-errors-heading">Checks need attention</h2><Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry checks</Button></div>
<ul class="error-groups">{#each scanErrors as group (group.key)}
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}</h3>
<p>{group.description}</p>
{#if group.servers.length}<ul class="affected-servers">{#each group.servers as name}<li><a href={contextHref(`/servers/${encodeURIComponent(name)}?tab=diagnostics`, $page.url)}>{serverTitle(name)}</a></li>{/each}</ul>{/if}
<details><summary>Technical details</summary><ul class="technical-details">{#each group.details as detail}<li>{#if detail.server}<strong>{serverTitle(detail.server)}</strong>{/if}<pre>{detail.message}</pre></li>{/each}</ul></details>
</li>
{/each}</ul>
</section>
{/if}
{/if}
<div class="filters">
<label>Capability<select bind:value={kind} onchange={() => saveContext()}><option value="all">All capabilities</option><option value="health">Health recovery</option><option value="drift">Configuration drift</option><option value="updates">Plutonium updates</option><option value="schedule">Scheduled restarts</option></select></label>
@@ -171,19 +192,34 @@
</div>
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
<section class="card" id="updates">
<div class="row-heading"><h2>Plutonium updates</h2><Button disabled={busy || data.policies.paused || !outdated.some(v => !activeJobs.some(j => j.server === v.server))} onclick={() => act({ action: 'update', servers: outdated.map(v => v.server) })}>Update outdated servers on restart</Button></div>
<div class="row-heading"><h2>Plutonium updates</h2>{#if updatableServers.length}<Button disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: updatableServers })}>Update outdated servers on restart</Button>{/if}</div>
<p>Published revision: <strong>{data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'}</strong> · <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a></p>
{#if data.runtime.release.checkedAt}<p class="muted">Checked {$timeFormat.dateTime(data.runtime.release.checkedAt)}</p>{/if}
{#if data.runtime.release.error}<p class="ui-error" role="alert">{data.runtime.release.error} Displayed release information is cached.</p>{/if}
<p class="muted">Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. Unknown versions require a successful check before automatic updates.</p>
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Running revision</th><th>Status</th><th>Action</th></tr></thead><tbody>
{#each versions as version (version.server)}
{@const pending = activeJobs.find(j => j.server === version.server)}
<tr><td><a href={`/servers/${encodeURIComponent(version.server)}`}><ServerIdentity name={version.server} /></a></td><td>{version.revision ? `r${version.revision}` : 'Unknown'}{#if version.checked_at}<small>{$timeFormat.dateTime(version.checked_at)}</small>{/if}{#if version.error}<small>{version.error}</small>{/if}</td>
<td>{version.update_status === 'outdated' ? 'Update available' : version.update_status === 'current' ? 'Up to date' : version.update_status === 'ahead' ? 'Ahead of published release' : version.update_status === 'stopped' ? 'Intentionally stopped' : 'Unverified'}</td>
<td>{#if pending}<a href={pending.provisioning_id ? `/provisioning/${encodeURIComponent(pending.provisioning_id)}` : '#activity'}>Restart pending</a>{:else if version.update_status === 'outdated'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: [version.server] })}>Update on restart</Button>{/if}</td></tr>
{:else}<tr><td colspan="4">No matching fleet servers.</td></tr>{/each}
</tbody></table></div>
<div class="version-groups">{#each versionGroups as group (group.key)}
<article class="version-group">
<div class="row-heading">
<div class="version-summary"><strong>{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
<div class="version-actions">
{#if group.pending.length}<span>{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending</span>{/if}
{#if group.status === 'unknown'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry version check</Button>
{:else if group.updatable.length}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: group.updatable })}>Update {group.updatable.length === 1 ? 'server' : `${group.updatable.length} servers`} on restart</Button>{/if}
</div>
</div>
<details><summary>{group.versions.length === 1 ? 'Server and check details' : `Show ${group.versions.length} servers and check details`}</summary>
<ul class="version-members">{#each group.versions as version (version.server)}
{@const pending = group.pending.find(job => job.server === version.server)}
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a>
{#if pending}<span class="pending-job"><StatusBadge state={pending.state} />{#if pending.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(pending.provisioning_id)}`, $page.url)}>View restart</a>{:else}<a href="#activity">View pending restart</a>{/if}</span>{/if}
</div>
{#if version.checked_at}<small>Last checked <time datetime={version.checked_at}>{$timeFormat.dateTime(version.checked_at)}</time></small>{/if}
{#if version.error}<pre class="version-error">{version.error}</pre>{/if}
</li>
{/each}</ul>
</details>
</article>
{:else}<EmptyState title="No matching servers" description="Choose another server name to see its reported Plutonium revision." />{/each}</div>
</section>
{/if}
<section id="activity">
@@ -215,11 +251,29 @@
.summary, .paused { padding: 20px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
.paused { line-height: 1.7; }
h2 { font-size: var(--text-md); } h3 { font-size: var(--text-sm); } h2, h3, p { margin: 0; }
p, label, td, th { font-size: var(--text-sm); line-height: 1.6; }
p, label { font-size: var(--text-sm); line-height: 1.6; }
.muted, small { color: var(--text-secondary); } small { display: block; }
a { color: var(--accent-primary-hover); } label { display: grid; gap: 6px; } .toggle { display: flex; align-items: center; }
input:not([type='checkbox']), select { color: inherit; background: var(--surface); padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); max-width: 100%; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } td, th { padding: 12px 8px; border-bottom: 1px solid var(--border-subtle); }
input:not([type='checkbox']), select { color: inherit; background: var(--surface-sunken); padding: 9px; border: 1px solid var(--border-control); border-radius: var(--radius-sm); max-width: 100%; }
.scan-errors { padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--state-warning) 35%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--surface); }
.error-groups, .technical-details, .version-members { list-style: none; padding: 0; margin: 0; }
.error-groups { display: grid; gap: var(--space-4); }
.error-groups > li + li { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
.error-groups h3 { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.error-groups p { color: var(--text-secondary); }
.affected-servers { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: 0; margin: var(--space-3) 0; list-style: none; font-size: var(--text-sm); }
details { margin-top: var(--space-2); min-width: 0; }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
.technical-details > li { margin-top: var(--space-3); font-size: var(--text-xs); }
pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); color: var(--text-secondary); margin: var(--space-2) 0 0; }
.version-groups { display: grid; gap: var(--space-2); }
.version-group { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.version-summary, .version-actions, .pending-job { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-sm); }
.version-summary strong { font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }
.version-actions { font-size: var(--text-xs); color: var(--text-secondary); }
.version-members > li { padding: var(--space-3) 0; }
.version-members > li + li { border-top: 1px solid var(--border-subtle); }
.version-members small { margin-top: var(--space-1); font-size: var(--text-xs); }
.activity article, section { scroll-margin-top: 100px; }
@media (max-width: 640px) { .card { padding: 18px; } }
</style>
+1 -1
View File
@@ -158,7 +158,7 @@
<Button onclick={() => lifecycleOpen = true}>Server actions</Button><Button variant="primary" onclick={() => open = true}>Create server</Button>
<details class="fleet-actions" bind:open={fleetActionsOpen}><summary>Fleet actions</summary><div class="fleet-menu">
{#if active.some(row => row.canCancel)}<Button disabled={cancelling} onclick={() => cancelRequests(active.filter(row => row.canCancel && !row.server_deleted_at), true)}>Cancel all active operations</Button>{/if}
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Destroy fleet…</Button>
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Remove fleet servers…</Button>
</div></details>
{/snippet}
</SectionHeader>
+37 -16
View File
@@ -23,9 +23,11 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { runtimeActions, actionLabels, type RuntimeAction } from '$lib/provisioning.js';
import { actionLabels, type RuntimeAction } from '$lib/provisioning.js';
let lifecycleOpen = $state(false);
let lifecycleAction = $state<RuntimeAction>('start');
import { applicableRuntimeActions } from '$ui/lifecycle-actions.js';
import { serverHealth } from '$ui/server-health.js';
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
@@ -39,7 +41,6 @@
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -91,7 +92,24 @@
});
const snapshot = $derived($fleet.receivedAt ? $fleet : data.serverSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null);
const serverContext = $derived(visibleFleet.find(s => s.name === name) ?? null);
let server = $derived((serverContext as Server | null));
const health = $derived(serverContext ? serverHealth(serverContext, $uiNow) : null);
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const availableActions = $derived(serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []);
const primaryAction = $derived(availableActions.includes('restart') ? 'restart' : availableActions.includes('start') ? 'start' : null);
function openAction(action: RuntimeAction) { if (canOperate && availableActions.includes(action)) { lifecycleAction = action; lifecycleOpen = true; } }
let consumedAction = '';
$effect(() => {
const requested = $page.url.searchParams.get('action');
const key = `${name}:${requested}`;
if (requested !== 'start') { consumedAction = ''; return; }
if (requested === 'start' && canOperate && availableActions.includes('start') && consumedAction !== key) {
consumedAction = key; openAction('start');
const url = new URL($page.url); url.searchParams.delete('action');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
});
const native = $derived(!!server?.native_instance_id);
let telemetryOpen = $state(false);
let history = $state<DeploymentRow[]>([]);
@@ -159,7 +177,7 @@
$effect(() => { name; rconProbe = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
let effectivePlayers = $derived(fresh && health?.state === 'online' ? server?.last_players ?? null : null);
const scoreboard = $derived(server?.telemetry ?? $telemetryByServer.get(name));
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveSeen = $derived(server?.last_seen_at ?? null);
@@ -182,7 +200,8 @@
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning');
const currentRound = $derived(effectivePlayers !== null && scoreboard && 'round' in scoreboard && freshTelemetry(scoreboard, $uiNow, server?.telemetry_after) ? scoreboard.round : null);
let hasDiagnosticIssue = $derived(!!health && !['online', 'starting', 'provisioning', 'stopped'].includes(health.state));
function ago(iso: string | null, now: number): string {
if (!iso) return 'never';
@@ -218,30 +237,33 @@
</script>
<svelte:head><title>{serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry</title></svelte:head>
<LifecycleDialog bind:open={lifecycleOpen} initialServer={name} initialAction={lifecycleAction} />
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
<LifecycleDialog bind:open={lifecycleOpen} initialServer={name} initialAction={lifecycleAction} {serverContext} players={effectivePlayers} round={currentRound} />
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} round={currentRound} />
{#if server}
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<header class="instance-header">
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
<Breadcrumbs items={[{ label: 'Servers', href: '/' }, { label: serverTitle(name) }]} />
<SectionHeader title={serverTitle(server.name)}>
{#snippet actions()}
<details class="server-actions"><summary>Instance actions</summary><div class="action-menu">{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue instance removal</Button></div></details>
{#if canOperate}
{#if primaryAction}<Button onclick={() => openAction(primaryAction)}>{actionLabels[primaryAction]} server</Button>{/if}
<details class="server-actions"><summary>More actions</summary><div class="action-menu">{#each availableActions.filter(action => action !== primaryAction) as action}<Button onclick={() => openAction(action)}>{actionLabels[action]} server</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Remove server</Button></div></details>
{/if}
{/snippet}
</SectionHeader>
<div class="instance-meta">
<span class="health"><StatusPip health={effectiveHealth} size="md" />{effectiveHealth === 'online' ? 'Online' : effectiveHealth === 'starting' ? 'Starting' : effectiveHealth === 'provisioning' ? 'Provisioning' : effectiveHealth === 'stale' ? 'Stale' : 'Offline'}</span>
<StatusBadge state={health?.state ?? 'unknown'} />
{#if server.host_id}<a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a>{/if}
<details><summary>Details</summary><div class="instance-details"><CopyValue value={name} label="Instance ID" /><RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} /></div></details>
</div>
</header>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry</Button></div>{/if}
{#if hasDiagnosticIssue}
{#if hasDiagnosticIssue && health}
<section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
<div><strong>{health.label}</strong><p>{health.reason} {health.since ? `Last evidence ${ago(health.since, $uiNow)}.` : 'No current report is available.'}</p></div>
<div class="issue-actions">{#if health.state === 'host-offline' && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" onclick={() => changeTab('diagnostics')}>Open diagnostics</Button>{/if}<Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button></div>
</section>
{/if}
<div class="tabs-row">
@@ -259,7 +281,7 @@
</section>
<section id="chat" aria-label="Chat">
<Card>
{#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}<ChatPanel server={name} />{/key}
{#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}<ChatPanel server={name} hostName={server.host?.name} hostOffline={health?.state === 'host-offline'} />{/key}
</Card>
</section>
{:else if activeSection === 'configuration'}
@@ -529,8 +551,7 @@
.live-context { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
.instance-header { display: grid; gap: 12px; }
.instance-meta, .health { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-sm); }
.health { gap: 8px; }
.instance-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-sm); }
.instance-details { display: grid; gap: 8px; padding: 12px 0; }
details > summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-sm); }
.server-actions { position: relative; }