Clarify server actions and group maintenance failures
This commit is contained in:
@@ -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