diff --git a/docs/guides/maintenance.md b/docs/guides/maintenance.md index 0a5f915..2d4e3f2 100644 --- a/docs/guides/maintenance.md +++ b/docs/guides/maintenance.md @@ -10,7 +10,11 @@ Campaigns retain managed-file deployment evidence; Collections define lineups. - **Overview** shows active work, attention, upcoming restarts, and Plutonium version coverage. Filter by capability or server. **Check now** refreshes health, drift, - the production release, and running revisions. + the production release, and running revisions. Failed checks are grouped by + cause with links to affected servers; raw paths and responses stay in + **Technical details**. Identical revision states share one summary row. Expand + it for each server’s check and pending restart, or use **Retry version check** + when the running revision is unverified. - **Policies & schedules** controls health recovery, configuration drift, automatic Plutonium updates, and the optional daily restart schedule independently. - **Activity** combines recovery findings, update results, and scheduled restarts. diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index 8c1dea3..4a84295 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -5,7 +5,11 @@ activity. **Fleet → Collections** owns reusable lineups and collection launch progress; their individual operations remain visible in Provisioning. Provisioning owns the queues, safety checks, execution, and recovery for all six actions. Create a server using **Create server**. For an existing -server, choose **Manage server** or open its **Server actions** menu. +server, use its visible **Restart server** or **Start server** action, or open +**More actions**. Only actions supported by current host and container evidence +are shown. Confirmations name the server and its current players and round; +disruptive actions still wait for a fresh empty-lobby check. **Manage server** +on the operations page offers the same actions for a selected server. **Create server** opens one dialog with game and map choices followed by automatic readiness checks. Blockers explain what needs attention before launch. Runtime @@ -29,18 +33,18 @@ retries when a response is lost. | Restart | Wait until empty, restart the existing container, and verify container health and RCON. | | Rebuild | Wait until empty, replace the instance through the host agent, preserving its server identity and port while advancing its generation. Verify container health, game status, map, and Plutonium revision. | | Stop | Wait until empty, stop the container, and verify that the host agent reports it stopped. Keep the instance for a later Start. | -| Destroy | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. | +| Remove | Wait until empty, remove the container and its files through the host agent, then retire the instance and registration. | ## Safety and verification ### Beta ownership and rebuilds -Servers linked to a running or ready-to-promote beta cannot be destroyed or +Servers linked to a running or ready-to-promote beta cannot be removed or rebuilt. Promote or roll back the beta first, including paused or failed test plans. Fleet removal and exact collection launches queue nothing if any server selected for removal belongs to an active beta. Provisioning checks again before teardown; already queued removals wait while their server belongs to an active -beta. Opening a beta also requires cancelling or finishing any pending destruction +beta. Opening a beta also requires cancelling or finishing any pending removal or rebuild on its selected servers. Rebuild requires confirmation because **no instance data is preserved**. Its fresh @@ -87,7 +91,7 @@ Administrators can add and remove available keys in **Admin → Keys**; assigned keys cannot be removed. Existing keys are migrated into their game pool, retaining assignments for existing servers and pending work. If a legacy rebuild has an uncertain result and used a different key, both possible keys -remain reserved until that server is destroyed. Labels are for identification +remain reserved until that server is removed. Labels are for identification only and do not change the hostname registered with Plutonium. ### Player checks and administrator overrides @@ -210,7 +214,7 @@ heartbeat or completion-time promises. `DELETE /api/servers/:name`. - `/api/provisioning/:id`, `/:id/resume`, `/:id/cancel`, `/preview`, and `/house-party` preserve the existing operation, recovery, removal cancellation, - launch preview, and fleet destruction APIs. + launch preview, and fleet removal APIs. - `POST /api/provisioning/:id/cancel` cancels any active create, start, restart, rebuild, stop, or destroy job, including health verification. It releases that job's recovery capacity and prevents further steps; completed effects are not @@ -250,7 +254,7 @@ See [native host management](native-hosts.md) for command delivery and recovery. Admin → Settings groups Concurrency separately from history and storage settings, with one Save changes action for all pending edits. Existing fleet restart, startup -and removal limits continue to apply. Additional per-host restart, startup and destruction limits, a combined +and removal limits continue to apply. Additional per-host restart, startup and removal limits, a combined lifecycle limit, a rebuild limit, and a limit on hosts preparing game files default to 0 (no additional cap), preserving existing throughput on upgrade. diff --git a/src/lib/provisioning.ts b/src/lib/provisioning.ts index bf71dc6..21e8280 100644 --- a/src/lib/provisioning.ts +++ b/src/lib/provisioning.ts @@ -2,16 +2,16 @@ export const provisioningActions = ['create', 'start', 'restart', 'rebuild', 'st export type ProvisioningAction = typeof provisioningActions[number]; export type RuntimeAction = Exclude; export const actionLabels: Record = { - 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 = { 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 = { 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.' }; diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index 5d5c7fe..b949e71 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -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(null); let liveEntries = $state([]); 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 @@
-

Chat

{server ? paused ? 'Paused' : 'Live · 5s' : 'Match history'}
+

Chat

{#if !server}Match history{:else if hostOffline}Host offline{:else if paused}Paused{:else if !error}{/if}
{#if server}
{#if hasMessages}{/if} {#if !follow}{/if} -
{/if}
- {#if error}{/if} - {#if !loaded && !error} + {#if hostOffline}

Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded ? ' Showing the last loaded messages.' : ''}

+ {:else if error}{/if} + {#if !loaded && !error && !hostOffline}

Loading chat…

{:else if loaded && !hasMessages}

{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.'}

@@ -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); } diff --git a/src/lib/ui/components/DestroyServerModal.svelte b/src/lib/ui/components/DestroyServerModal.svelte index 11ad0f6..f2d4450 100644 --- a/src/lib/ui/components/DestroyServerModal.svelte +++ b/src/lib/ui/components/DestroyServerModal.svelte @@ -1,15 +1,15 @@ - open = false} - oncancel={(event) => { if (submitting) event.preventDefault(); }}> -
{ event.preventDefault(); void destroy(); }}> -

Queue server removal?

-

{name}

-

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.

-
- {#if players !== null && players > 0}{players} player{players === 1 ? '' : 's'} currently online.{/if} - Removal waits until a live check confirms the server is empty. Follow progress or cancel the waiting request on the provisioning page. -
+ +
+

{lifecycleImpact('destroy', players, round)}

+

This stops and removes the container, its writable server files, and registration. Shared game libraries, recorded history, and the saved server key are retained.

+

Follow progress or cancel the waiting request on the provisioning page.

You can cancel while waiting. Once removal begins, it cannot be undone.

{#if error}{/if} -
- - -
- -
+ + {#snippet actions()} + + + {/snippet} +
diff --git a/src/lib/ui/components/HousePartyDialog.svelte b/src/lib/ui/components/HousePartyDialog.svelte index da3d618..db4300f 100644 --- a/src/lib/ui/components/HousePartyDialog.svelte +++ b/src/lib/ui/components/HousePartyDialog.svelte @@ -1,4 +1,5 @@ - { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}> + { if (isDialogBackdrop(event, dialog) && !busy) open = false; }} oncancel={event => { if (busy) event.preventDefault(); }} onclose={() => open = false}>
-

Queue fleet removal

-

Queue destruction of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.

-

Destruction removes containers, server files, and fleet registrations. Saved keys and collections are kept.

+

Remove fleet servers?

+

Queue removal of every server, including servers being created. Each waits until a fresh player count confirms it is empty. Unreachable servers stay queued.

+

Removal deletes containers, server files, and fleet registrations. Saved keys and collections are kept.

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.

{#if error}{/if} {#if servers} - {servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to destroy` : 'The house is already empty.'} - {#if servers.length}
    {#each servers as server}
  • {server}
  • {/each}

Existing destruction requests are reused. Servers added after this request are not included.

{/if} + {servers.length ? `${servers.length} server${servers.length === 1 ? '' : 's'} to remove` : 'No servers to remove.'} + {#if servers.length}
    {#each servers as server}
  • {serverTitle(server)}
  • {/each}

Existing removal requests are reused. Servers added after this request are not included.

{/if} {:else}

{busy ? 'Reviewing the fleet…' : 'Refresh the preview to review the current fleet.'}

{/if}
{#if !servers} - {:else}{/if} + {:else}{/if}
@@ -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; } diff --git a/src/lib/ui/components/LifecycleDialog.svelte b/src/lib/ui/components/LifecycleDialog.svelte index 41d46b1..f324eb3 100644 --- a/src/lib/ui/components/LifecycleDialog.svelte +++ b/src/lib/ui/components/LifecycleDialog.svelte @@ -1,41 +1,79 @@ - +
- - -

{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]}

- {#if destructive}{/if} + {/if} + {#if loading}

Checking the latest server status…

+ {:else if server} + {#if applicable} +

{lifecycleImpact(action, impactPlayers, impactRound)}

+

{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]}

+ {#if action !== 'start'}

Follow progress or cancel the waiting request on the provisioning page.

{/if} + {:else}

{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.

{/if} + {#if applicable && destructive}{/if} + {/if} {#if error}{/if}
{#snippet actions()} - + {/snippet}
@@ -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); } diff --git a/src/lib/ui/lifecycle-actions.ts b/src/lib/ui/lifecycle-actions.ts new file mode 100644 index 0000000..151cd28 --- /dev/null +++ b/src/lib/ui/lifecycle-actions.ts @@ -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.`; +} diff --git a/src/lib/ui/maintenance-presentation.ts b/src/lib/ui/maintenance-presentation.ts new file mode 100644 index 0000000..321a4ba --- /dev/null +++ b/src/lib/ui/maintenance-presentation.ts @@ -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(); + 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; +const versionStatuses: Record = { + 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(); + 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 = { 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)); +} diff --git a/src/routes/maintenance/+page.svelte b/src/routes/maintenance/+page.svelte index 573bd0f..3b7418f 100644 --- a/src/routes/maintenance/+page.svelte +++ b/src/routes/maintenance/+page.svelte @@ -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(null); let error = $state(''); @@ -34,6 +38,7 @@ let scheduleLoaded = false; let displayedZone = $state(''); let request = 0; + let checkedAt = $state(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} {#if error}{/if} + {#if !error}{/if} {#if notice}

{notice}

{/if} - {#if data?.policies.paused}
All maintenance is paused. Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.
{/if} + {#if data?.policies.paused}
All maintenance is paused. Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual server operations continue.
{/if} {#if $page.url.searchParams.has('returnTo')}{/if} saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" />
@@ -159,10 +169,21 @@
{:else} {#if view === 'overview'} -
{attention} need attention{waiting} active{outdated.length} outdated{unknown.length} versions unverified
+
{attention} {attention === 1 ? 'needs' : 'need'} attention{waiting} active{outdated.length} outdated{unknown.length} {unknown.length === 1 ? 'version' : 'versions'} unverified
{#if data.runtime.schedule.nextAt}

Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · Manage schedule

{/if} {#if data.recovery.lastScanAt}

Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}

{/if} - {#if data.recovery.scanError}{/if} + {#if scanErrors.length} +
+

Checks need attention

+
    {#each scanErrors as group (group.key)} +
  • {group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}

    +

    {group.description}

    + {#if group.servers.length}{/if} +
    Technical details
      {#each group.details as detail}
    • {#if detail.server}{serverTitle(detail.server)}{/if}
      {detail.message}
    • {/each}
    +
  • + {/each}
+
+ {/if} {/if}
@@ -171,19 +192,34 @@
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
-

Plutonium updates

+

Plutonium updates

{#if updatableServers.length}{/if}

Published revision: {data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'} · Production manifest · Changelog

{#if data.runtime.release.checkedAt}

Checked {$timeFormat.dateTime(data.runtime.release.checkedAt)}

{/if} {#if data.runtime.release.error}{/if}

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.

-
- {#each versions as version (version.server)} - {@const pending = activeJobs.find(j => j.server === version.server)} - - - - {:else}{/each} -
ServerRunning revisionStatusAction
{version.revision ? `r${version.revision}` : 'Unknown'}{#if version.checked_at}{$timeFormat.dateTime(version.checked_at)}{/if}{#if version.error}{version.error}{/if}{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'}{#if pending}Restart pending{:else if version.update_status === 'outdated'}{/if}
No matching fleet servers.
+
{#each versionGroups as group (group.key)} +
+
+
{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}{group.label}
+
+ {#if group.pending.length}{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending{/if} + {#if group.status === 'unknown'} + {:else if group.updatable.length}{/if} +
+
+
{group.versions.length === 1 ? 'Server and check details' : `Show ${group.versions.length} servers and check details`} +
    {#each group.versions as version (version.server)} + {@const pending = group.pending.find(job => job.server === version.server)} +
  • + {#if pending}{#if pending.provisioning_id}View restart{:else}View pending restart{/if}{/if} +
    + {#if version.checked_at}Last checked {/if} + {#if version.error}
    {version.error}
    {/if} +
  • + {/each}
+
+
+ {:else}{/each}
{/if}
@@ -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; } } diff --git a/src/routes/provisioning/+page.svelte b/src/routes/provisioning/+page.svelte index 5bbaa32..7f0a245 100644 --- a/src/routes/provisioning/+page.svelte +++ b/src/routes/provisioning/+page.svelte @@ -158,7 +158,7 @@
Fleet actions
{#if active.some(row => row.canCancel)}{/if} - +
{/snippet} diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 88224e8..5dd2159 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -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('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([]); @@ -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(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 @@ {serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry - - + + {#if server}
- + {#snippet actions()} -
Instance actions
{#each runtimeActions as action}{/each}
+ {#if canOperate} + {#if primaryAction}{/if} +
More actions
{#each availableActions.filter(action => action !== primaryAction) as action}{/each}
+ {/if} {/snippet}
- {effectiveHealth === 'online' ? 'Online' : effectiveHealth === 'starting' ? 'Starting' : effectiveHealth === 'provisioning' ? 'Provisioning' : effectiveHealth === 'stale' ? 'Stale' : 'Offline'} + {#if server.host_id}{server.host?.name ?? server.host_id}{/if}
Details
{#if $fleetError}{/if} - {#if hasDiagnosticIssue} + {#if hasDiagnosticIssue && health}
-
{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}

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.' : ''}

-
{#if native && server.host_id}{:else}{/if}
+
{health.label}

{health.reason} {health.since ? `Last evidence ${ago(health.since, $uiNow)}.` : 'No current report is available.'}

+
{#if health.state === 'host-offline' && server.host_id}{:else}{/if}
{/if}
@@ -259,7 +281,7 @@
- {#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}{/key} + {#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}{/key}
{: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; } diff --git a/tests/unit/maintenance-presentation.test.ts b/tests/unit/maintenance-presentation.test.ts new file mode 100644 index 0000000..879c287 --- /dev/null +++ b/tests/unit/maintenance-presentation.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '../../src/lib/ui/maintenance-presentation.js'; +import type { MaintenanceJob, PlutoniumVersion } from '../../src/lib/plutonium.js'; + +const theater = 't5_zombie_theater_1'; +const moon = 't5_zombie_moon_1'; +const version = (server: string, changes: Partial = {}): PlutoniumVersion => ({ + server, revision: 4878, checked_at: '2026-10-07T12:00:00Z', error: null, update_status: 'unknown', ...changes +}); +const job = (server: string, changes: Partial = {}): MaintenanceJob => ({ + id: 1, server, state: 'queued', target_revision: 4878, requested_at: '2026-10-07T12:00:00Z', + restarted_at: null, provisioning_id: 'restart-1', finished_at: null, detail: null, + kind: 'update', origin: 'manual', ...changes +}); + +describe('maintenance scan summaries', () => { + it('groups shared failures into one cause while retaining each affected server and diagnostic message', () => { + const errors = `${theater}: Host agent is offline; /srv/private-one unavailable\n${moon}: Host agent is offline; /srv/private-two unavailable`; + const groups = groupMaintenanceScanErrors(errors, [theater, moon]); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ key: 'host', title: 'Host agent unavailable', servers: [theater, moon] }); + expect(groups[0].details).toHaveLength(2); + expect(groups[0].description).not.toContain('/srv/'); + expect(groups[0].description).not.toContain(theater); + expect(groups[0].details[0].message).toContain('/srv/private-one'); + }); + + it('separates file, permission, timeout and unknown causes without exposing raw messages as headings', () => { + const groups = groupMaintenanceScanErrors([ + `${theater}: ENOENT: no such file /srv/missing`, + `${moon}: EACCES: permission denied /srv/private`, + `${theater}: timeout: no reply`, + `${moon}: Unrecognized diagnostic code 123` + ].join('\n'), [theater, moon]); + expect(groups.map(group => group.key)).toEqual(['files', 'permissions', 'timeout', 'other']); + expect(groups.every(group => !/ENOENT|EACCES|123/.test(group.title))).toBe(true); + }); + + it('supports custom names containing colons, avoids linking unknown identifiers and deduplicates identical failures', () => { + const message = 'Weekend: west: timeout: No reply'; + const groups = groupMaintenanceScanErrors(`${message}\n${message}\nretired_server: Agent unavailable`, ['Weekend', 'Weekend: west']); + expect(groups[0].servers).toEqual(['Weekend: west']); + expect(groups[0].details).toEqual([{ server: 'Weekend: west', message: 'timeout: No reply' }]); + expect(groups[1].servers).toEqual([]); + expect(groups[1].details[0].server).toBeNull(); + }); + + it('handles setup messages and empty scans without inventing an affected server', () => { + expect(groupMaintenanceScanErrors(null, [])).toEqual([]); + expect(groupMaintenanceScanErrors(' \n ', [])).toEqual([]); + expect(groupMaintenanceScanErrors('Register a native host instance before automatic recovery.', [])) + .toEqual([expect.objectContaining({ key: 'setup', servers: [] })]); + }); +}); + +describe('maintenance revision groups', () => { + it('collapses identical revisions and verification states without claiming that unknown versions are current', () => { + const versions = Array.from({ length: 12 }, (_, index) => version(`t5_zombie_theater_${index + 1}`)); + const groups = groupMaintenanceVersions(versions, []); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ revision: 4878, label: 'Unverified', tone: 'warning', updatable: [] }); + expect(groups[0].versions).toHaveLength(12); + }); + + it('retains separate verification states and puts failures and available updates first', () => { + const groups = groupMaintenanceVersions([ + version('current', { update_status: 'current' }), version('unknown'), + version('outdated', { revision: 4800, update_status: 'outdated' }), + version('ahead', { revision: 5000, update_status: 'ahead' }) + ], []); + expect(groups.map(group => group.status)).toEqual(['unknown', 'outdated', 'current', 'ahead']); + expect(groups[0].revision).toBe(groups[2].revision); + }); + + it('excludes pending restarts from group update actions while preserving the pending operation links', () => { + const pending = job(theater, { state: 'restarting' }); + const [group] = groupMaintenanceVersions([ + version(theater, { update_status: 'outdated' }), version(moon, { update_status: 'outdated' }) + ], [pending, job(moon, { id: 2, state: 'done' })]); + expect(group.updatable).toEqual([moon]); + expect(group.pending).toEqual([pending]); + }); + + it('does not let deleted-server jobs suppress a current server update and preserves stopped legacy evidence', () => { + const groups = groupMaintenanceVersions([ + version(theater, { update_status: 'outdated' }), + version(moon, { update_status: undefined, stopped: true, revision: null }) + ], [job(theater, { server_deleted_at: '2026-10-06' })]); + expect(groups[0].updatable).toEqual([theater]); + expect(groups[0].pending).toEqual([]); + expect(groups[1]).toMatchObject({ status: 'stopped', label: 'Intentionally stopped', revision: null, updatable: [] }); + }); +}); diff --git a/tests/unit/ui-lifecycle-actions.test.ts b/tests/unit/ui-lifecycle-actions.test.ts new file mode 100644 index 0000000..691c987 --- /dev/null +++ b/tests/unit/ui-lifecycle-actions.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'svelte/server'; +import { get } from 'svelte/store'; +import type { FleetServer } from '../../src/lib/fleet-overview.js'; +import { applicableRuntimeActions, lifecycleImpact } from '../../src/lib/ui/lifecycle-actions.js'; +import { actionCompleted, actionLabels } from '../../src/lib/provisioning.js'; +import { uiNow } from '../../src/lib/ui/stores/servers.js'; +import LifecycleDialog from '../../src/lib/ui/components/LifecycleDialog.svelte'; +import DestroyServerModal from '../../src/lib/ui/components/DestroyServerModal.svelte'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +const now = get(uiNow); +const iso = (offset = 0) => new Date(now + offset).toISOString(); +const server: FleetServer = { + name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', + last_gametype: 'zclassic', last_players: 4, last_seen_at: iso(-10_000), last_error: null, + container_state: 'running', container_checked_at: iso(-5_000), last_checked_at: iso(-5_000), + roster: [], health: 'online', held: [], active_since: iso(-120_000), inactive_since: null, + game_started_at: iso(-120_000), game_ended_at: null, host_id: 'host-1', + host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: iso(-1_000) } +}; + +describe('lifecycle action applicability', () => { + it('offers Restart, Stop and Rebuild for a responding game, without Start', () => { + expect(applicableRuntimeActions(server, now)).toEqual(['restart', 'stop', 'rebuild']); + }); + + it('offers Start only for a confirmed stopped container with a reachable host', () => { + const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' }; + expect(applicableRuntimeActions(stopped, now)).toEqual(['start', 'rebuild']); + for (const change of [ + { container_checked_at: iso(-91_000) }, { container_checked_at: null }, + { telemetry_after: iso(-2_000) }, { last_seen_at: iso(-2_000) }, + { host: null }, { host: { ...server.host!, connected: false } } + ]) expect(applicableRuntimeActions({ ...stopped, ...change }, now)).not.toContain('start'); + }); + + it('permits recovery of an unresponsive game only with a current running-container observation', () => { + const unresponsive = { ...server, health: 'stale' as const, last_seen_at: iso(-300_000), last_error: 'timeout:no reply' }; + expect(applicableRuntimeActions(unresponsive, now)).toEqual(['restart', 'stop', 'rebuild']); + expect(applicableRuntimeActions({ ...unresponsive, container_checked_at: iso(-91_000) }, now)).toEqual([]); + expect(applicableRuntimeActions({ ...unresponsive, telemetry_after: iso(-2_000) }, now)).toEqual([]); + }); + + it('does not offer runtime mutations during startup or without evidence', () => { + for (const change of [ + { health: 'starting' as const }, { health: 'provisioning' as const }, + { host: { ...server.host!, connected: false } }, + { health: 'offline' as const, container_state: 'unknown', last_seen_at: null } + ]) expect(applicableRuntimeActions({ ...server, ...change }, now)).toEqual([]); + }); +}); + +describe('lifecycle impact copy', () => { + it('names current players and round with correct singular grammar', () => { + expect(lifecycleImpact('restart', 4, 30)).toBe('4 players are in round 30. Restart waits until the lobby is empty.'); + expect(lifecycleImpact('stop', 1, null)).toBe('1 player is currently online. Stop waits until the lobby is empty.'); + }); + + it('does not confuse unknown or invalid player counts with an empty lobby', () => { + for (const players of [null, -1, NaN, 1.5]) expect(lifecycleImpact('destroy', players, 30)) + .toBe('Player count is unknown. Removal waits for a live check confirming the lobby is empty.'); + expect(lifecycleImpact('rebuild', 0, 30)).toBe('The lobby is currently empty. A live check will confirm this before rebuild begins.'); + }); + + it('does not claim a round or occupancy wait when starting a stopped container', () => { + expect(lifecycleImpact('start', null, null)).toBe('Start the existing container, then verify that the game is responding.'); + expect(lifecycleImpact('restart', 4, NaN)).toContain('4 players are currently online.'); + }); +}); + +describe('contextual lifecycle confirmation', () => { + it('names the server and impact without re-asking the instance or action', () => { + const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name, + initialAction: 'restart', serverContext: server, players: 4, round: 30 } }).body; + expect(body).toContain('Restart Kino Der Toten · Lobby 1?'); + expect(body).toContain('4 players are in round 30.'); + expect(body).toContain('Restart when empty'); + expect(body).not.toContain('Manage server<'); + }); + + it('explains an unavailable action when the evidence no longer supports it', () => { + const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name, + initialAction: 'start', serverContext: server } }).body; + expect(body).toContain('Start is unavailable. The game is responding.'); + const startButton = [...body.matchAll(/]*>[\s\S]*?<\/button>/g)].find(match => match[0].includes('Start server'))?.[0]; + expect(startButton).toContain('disabled'); + expect(body).not.toContain('Start the existing container'); + }); + + it('uses Remove server consistently and preserves recorded history in the explanation', () => { + const body = render(DestroyServerModal, { props: { open: true, name: server.name, players: 4, round: 30 } }).body; + expect(body).toContain('Remove Kino Der Toten · Lobby 1?'); + expect(body).toContain('4 players are in round 30.'); + expect(body).toContain('Remove server when empty'); + expect(body).toContain('recorded history'); + expect(body).not.toContain('campaign details'); + expect(actionLabels.destroy).toBe('Remove'); + expect(actionCompleted.destroy).toBe('Server removed'); + }); +}); diff --git a/tests/unit/ui-match-chat.test.ts b/tests/unit/ui-match-chat.test.ts index 47d68c9..7ae1250 100644 --- a/tests/unit/ui-match-chat.test.ts +++ b/tests/unit/ui-match-chat.test.ts @@ -30,6 +30,14 @@ describe('historical match chat', () => { expect(body).not.toContain('Loading chat'); }); + it('explains an offline host without claiming live updates or offering a manual refresh', () => { + const body = render(ChatPanel, { props: { server: 't5_zombie_theater_1', hostOffline: true, hostName: 'Dallas 1' } }).body.replaceAll(//g, ''); + expect(body).toContain('Chat resumes when Dallas 1 reconnects.'); + expect(body).not.toContain('Live ·'); + expect(body).not.toContain('Loading chat'); + expect(body).not.toContain('Refresh chat'); + }); + it('explains when a historical match has no saved messages', () => { const body = render(ChatPanel, { props: { history: [] } }).body; expect(body).toContain('No chat messages were saved for this match.');