Clarify server actions and group maintenance failures
This commit is contained in:
@@ -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.'
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user