Clarify server actions and group maintenance failures

This commit is contained in:
2026-10-07 19:21:53 -04:00
parent bcecc42dd1
commit c39563daef
15 changed files with 559 additions and 111 deletions
+69 -15
View File
@@ -15,9 +15,13 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { groupMaintenanceScanErrors, groupMaintenanceVersions } from '$ui/maintenance-presentation.js';
let data = $state<MaintenanceOverview | null>(null);
let error = $state('');
@@ -34,6 +38,7 @@
let scheduleLoaded = false;
let displayedZone = $state('');
let request = 0;
let checkedAt = $state<number | null>(null);
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'policies', label: 'Policies & schedules' }, { id: 'activity', label: 'Activity' }];
const policies: { id: MaintenancePolicy; title: string; description: string }[] = [
{ id: 'health', title: 'Health recovery', description: 'Restart unhealthy servers, then rebuild once if recovery fails. Rebuilding deletes instance files and installs the latest promoted setup.' },
@@ -44,6 +49,9 @@
const outdated = $derived(versions.filter(v => v.update_status === 'outdated'));
const unknown = $derived(versions.filter(v => v.update_status === 'unknown'));
const activeJobs = $derived(data?.runtime.jobs.filter(j => !j.server_deleted_at && ['queued', 'restarting'].includes(j.state)) ?? []);
const scanErrors = $derived(groupMaintenanceScanErrors(data?.recovery.scanError, data?.runtime.servers.map(version => version.server) ?? []));
const versionGroups = $derived(groupMaintenanceVersions(versions, activeJobs));
const updatableServers = $derived(versionGroups.flatMap(group => group.updatable));
const activities = $derived([
...(data?.recovery.incidents ?? []).map(i => ({
key: i.id, kind: i.kind, server: i.server, server_deleted_at: i.server_deleted_at, at: i.first_seen_at,
@@ -92,6 +100,7 @@
}
function receive(body: MaintenanceOverview) {
data = body;
checkedAt = Date.now();
if (!scheduleLoaded) { scheduleEnabled = body.runtime.schedule.enabled; scheduleLoaded = true; }
}
async function load() {
@@ -129,8 +138,9 @@
{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if}
{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 5 seconds" />{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual Provisioning actions continue.</div>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual server operations continue.</div>{/if}
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/provisioning" />{/if}
<Tabs {tabs} bind:value={view} onchange={() => saveContext()} ariaLabel="Maintenance views" idPrefix="maintenance-tabs" panelId="maintenance-panel" />
<div id="maintenance-panel" role="tabpanel" aria-labelledby={`maintenance-tabs-${view}`} class="panels">
@@ -159,10 +169,21 @@
</section>
{:else}
{#if view === 'overview'}
<div class="summary"><span><strong>{attention}</strong> need attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> versions unverified</span></div>
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
{#if data.runtime.schedule.nextAt}<p class="muted">Next scheduled restart: {$timeFormat.dateTime(data.runtime.schedule.nextAt)} · <a href="/maintenance?view=policies#schedule">Manage schedule</a></p>{/if}
{#if data.recovery.lastScanAt}<p class="muted">Health and drift checked {$timeFormat.dateTime(data.recovery.lastScanAt)}</p>{/if}
{#if data.recovery.scanError}<p class="ui-error" role="alert">{data.recovery.scanError}</p>{/if}
{#if scanErrors.length}
<section class="scan-errors" aria-labelledby="scan-errors-heading">
<div class="row-heading"><h2 id="scan-errors-heading">Checks need attention</h2><Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry checks</Button></div>
<ul class="error-groups">{#each scanErrors as group (group.key)}
<li><h3><StatusPip tone="warning" />{group.title}{group.servers.length ? ` · ${group.servers.length} ${group.servers.length === 1 ? 'server' : 'servers'}` : ''}</h3>
<p>{group.description}</p>
{#if group.servers.length}<ul class="affected-servers">{#each group.servers as name}<li><a href={contextHref(`/servers/${encodeURIComponent(name)}?tab=diagnostics`, $page.url)}>{serverTitle(name)}</a></li>{/each}</ul>{/if}
<details><summary>Technical details</summary><ul class="technical-details">{#each group.details as detail}<li>{#if detail.server}<strong>{serverTitle(detail.server)}</strong>{/if}<pre>{detail.message}</pre></li>{/each}</ul></details>
</li>
{/each}</ul>
</section>
{/if}
{/if}
<div class="filters">
<label>Capability<select bind:value={kind} onchange={() => saveContext()}><option value="all">All capabilities</option><option value="health">Health recovery</option><option value="drift">Configuration drift</option><option value="updates">Plutonium updates</option><option value="schedule">Scheduled restarts</option></select></label>
@@ -171,19 +192,34 @@
</div>
{#if view === 'overview' && ['all', 'updates'].includes(kind)}
<section class="card" id="updates">
<div class="row-heading"><h2>Plutonium updates</h2><Button disabled={busy || data.policies.paused || !outdated.some(v => !activeJobs.some(j => j.server === v.server))} onclick={() => act({ action: 'update', servers: outdated.map(v => v.server) })}>Update outdated servers on restart</Button></div>
<div class="row-heading"><h2>Plutonium updates</h2>{#if updatableServers.length}<Button disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: updatableServers })}>Update outdated servers on restart</Button>{/if}</div>
<p>Published revision: <strong>{data.runtime.release.revision ? `r${data.runtime.release.revision}` : 'Unknown'}</strong> · <a href={PLUTONIUM_MANIFEST_URL} target="_blank" rel="noreferrer">Production manifest</a> · <a href="https://plutonium.pw/docs/changelog/" target="_blank" rel="noreferrer">Changelog</a></p>
{#if data.runtime.release.checkedAt}<p class="muted">Checked {$timeFormat.dateTime(data.runtime.release.checkedAt)}</p>{/if}
{#if data.runtime.release.error}<p class="ui-error" role="alert">{data.runtime.release.error} Displayed release information is cached.</p>{/if}
<p class="muted">Plutainer applies the available release at startup when auto-update is enabled. Maintenance verifies the running revision after recovery. Unknown versions require a successful check before automatic updates.</p>
<div class="table-wrap"><table><thead><tr><th>Server</th><th>Running revision</th><th>Status</th><th>Action</th></tr></thead><tbody>
{#each versions as version (version.server)}
{@const pending = activeJobs.find(j => j.server === version.server)}
<tr><td><a href={`/servers/${encodeURIComponent(version.server)}`}><ServerIdentity name={version.server} /></a></td><td>{version.revision ? `r${version.revision}` : 'Unknown'}{#if version.checked_at}<small>{$timeFormat.dateTime(version.checked_at)}</small>{/if}{#if version.error}<small>{version.error}</small>{/if}</td>
<td>{version.update_status === 'outdated' ? 'Update available' : version.update_status === 'current' ? 'Up to date' : version.update_status === 'ahead' ? 'Ahead of published release' : version.update_status === 'stopped' ? 'Intentionally stopped' : 'Unverified'}</td>
<td>{#if pending}<a href={pending.provisioning_id ? `/provisioning/${encodeURIComponent(pending.provisioning_id)}` : '#activity'}>Restart pending</a>{:else if version.update_status === 'outdated'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: [version.server] })}>Update on restart</Button>{/if}</td></tr>
{:else}<tr><td colspan="4">No matching fleet servers.</td></tr>{/each}
</tbody></table></div>
<div class="version-groups">{#each versionGroups as group (group.key)}
<article class="version-group">
<div class="row-heading">
<div class="version-summary"><strong>{group.versions.length} {group.versions.length === 1 ? 'server' : 'servers'} · {group.revision ? `reported r${group.revision}` : 'revision unknown'}</strong><Badge variant={group.tone}><StatusPip tone={group.tone} size="sm" />{group.label}</Badge></div>
<div class="version-actions">
{#if group.pending.length}<span>{group.pending.length} {group.pending.length === 1 ? 'restart' : 'restarts'} pending</span>{/if}
{#if group.status === 'unknown'}<Button size="sm" disabled={busy} onclick={() => act({ action: 'scan' })}>Retry version check</Button>
{:else if group.updatable.length}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'update', servers: group.updatable })}>Update {group.updatable.length === 1 ? 'server' : `${group.updatable.length} servers`} on restart</Button>{/if}
</div>
</div>
<details><summary>{group.versions.length === 1 ? 'Server and check details' : `Show ${group.versions.length} servers and check details`}</summary>
<ul class="version-members">{#each group.versions as version (version.server)}
{@const pending = group.pending.find(job => job.server === version.server)}
<li><div class="row-heading"><a href={contextHref(`/servers/${encodeURIComponent(version.server)}`, $page.url)}><ServerIdentity name={version.server} /></a>
{#if pending}<span class="pending-job"><StatusBadge state={pending.state} />{#if pending.provisioning_id}<a href={contextHref(`/provisioning/${encodeURIComponent(pending.provisioning_id)}`, $page.url)}>View restart</a>{:else}<a href="#activity">View pending restart</a>{/if}</span>{/if}
</div>
{#if version.checked_at}<small>Last checked <time datetime={version.checked_at}>{$timeFormat.dateTime(version.checked_at)}</time></small>{/if}
{#if version.error}<pre class="version-error">{version.error}</pre>{/if}
</li>
{/each}</ul>
</details>
</article>
{:else}<EmptyState title="No matching servers" description="Choose another server name to see its reported Plutonium revision." />{/each}</div>
</section>
{/if}
<section id="activity">
@@ -215,11 +251,29 @@
.summary, .paused { padding: 20px; background: var(--surface-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); }
.paused { line-height: 1.7; }
h2 { font-size: var(--text-md); } h3 { font-size: var(--text-sm); } h2, h3, p { margin: 0; }
p, label, td, th { font-size: var(--text-sm); line-height: 1.6; }
p, label { font-size: var(--text-sm); line-height: 1.6; }
.muted, small { color: var(--text-secondary); } small { display: block; }
a { color: var(--accent-primary-hover); } label { display: grid; gap: 6px; } .toggle { display: flex; align-items: center; }
input:not([type='checkbox']), select { color: inherit; background: var(--surface); padding: 9px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); max-width: 100%; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; text-align: left; } td, th { padding: 12px 8px; border-bottom: 1px solid var(--border-subtle); }
input:not([type='checkbox']), select { color: inherit; background: var(--surface-sunken); padding: 9px; border: 1px solid var(--border-control); border-radius: var(--radius-sm); max-width: 100%; }
.scan-errors { padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--state-warning) 35%, var(--border-subtle)); border-radius: var(--radius-md); background: var(--surface); }
.error-groups, .technical-details, .version-members { list-style: none; padding: 0; margin: 0; }
.error-groups { display: grid; gap: var(--space-4); }
.error-groups > li + li { border-top: 1px solid var(--border-subtle); padding-top: var(--space-4); }
.error-groups h3 { display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.error-groups p { color: var(--text-secondary); }
.affected-servers { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); padding: 0; margin: var(--space-3) 0; list-style: none; font-size: var(--text-sm); }
details { margin-top: var(--space-2); min-width: 0; }
summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-xs); }
.technical-details > li { margin-top: var(--space-3); font-size: var(--text-xs); }
pre { max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: var(--text-xs); line-height: var(--leading-mono); color: var(--text-secondary); margin: var(--space-2) 0 0; }
.version-groups { display: grid; gap: var(--space-2); }
.version-group { min-width: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
.version-summary, .version-actions, .pending-job { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: var(--text-sm); }
.version-summary strong { font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }
.version-actions { font-size: var(--text-xs); color: var(--text-secondary); }
.version-members > li { padding: var(--space-3) 0; }
.version-members > li + li { border-top: 1px solid var(--border-subtle); }
.version-members small { margin-top: var(--space-1); font-size: var(--text-xs); }
.activity article, section { scroll-margin-top: 100px; }
@media (max-width: 640px) { .card { padding: 18px; } }
</style>
+1 -1
View File
@@ -158,7 +158,7 @@
<Button onclick={() => lifecycleOpen = true}>Server actions</Button><Button variant="primary" onclick={() => open = true}>Create server</Button>
<details class="fleet-actions" bind:open={fleetActionsOpen}><summary>Fleet actions</summary><div class="fleet-menu">
{#if active.some(row => row.canCancel)}<Button disabled={cancelling} onclick={() => cancelRequests(active.filter(row => row.canCancel && !row.server_deleted_at), true)}>Cancel all active operations</Button>{/if}
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Destroy fleet…</Button>
<Button variant="danger" onclick={() => { fleetActionsOpen = false; housePartyOpen = true; }}>Remove fleet servers…</Button>
</div></details>
{/snippet}
</SectionHeader>
+37 -16
View File
@@ -23,9 +23,11 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
import { runtimeActions, actionLabels, type RuntimeAction } from '$lib/provisioning.js';
import { actionLabels, type RuntimeAction } from '$lib/provisioning.js';
let lifecycleOpen = $state(false);
let lifecycleAction = $state<RuntimeAction>('start');
import { applicableRuntimeActions } from '$ui/lifecycle-actions.js';
import { serverHealth } from '$ui/server-health.js';
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
import { telemetryByServer, uiNow } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
@@ -39,7 +41,6 @@
import Badge from '$ui/components/primitives/Badge.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusPip from '$ui/components/primitives/StatusPip.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -91,7 +92,24 @@
});
const snapshot = $derived($fleet.receivedAt ? $fleet : data.serverSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null);
const serverContext = $derived(visibleFleet.find(s => s.name === name) ?? null);
let server = $derived((serverContext as Server | null));
const health = $derived(serverContext ? serverHealth(serverContext, $uiNow) : null);
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const availableActions = $derived(serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []);
const primaryAction = $derived(availableActions.includes('restart') ? 'restart' : availableActions.includes('start') ? 'start' : null);
function openAction(action: RuntimeAction) { if (canOperate && availableActions.includes(action)) { lifecycleAction = action; lifecycleOpen = true; } }
let consumedAction = '';
$effect(() => {
const requested = $page.url.searchParams.get('action');
const key = `${name}:${requested}`;
if (requested !== 'start') { consumedAction = ''; return; }
if (requested === 'start' && canOperate && availableActions.includes('start') && consumedAction !== key) {
consumedAction = key; openAction('start');
const url = new URL($page.url); url.searchParams.delete('action');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
});
const native = $derived(!!server?.native_instance_id);
let telemetryOpen = $state(false);
let history = $state<DeploymentRow[]>([]);
@@ -159,7 +177,7 @@
$effect(() => { name; rconProbe = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
let effectivePlayers = $derived(fresh && health?.state === 'online' ? server?.last_players ?? null : null);
const scoreboard = $derived(server?.telemetry ?? $telemetryByServer.get(name));
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveSeen = $derived(server?.last_seen_at ?? null);
@@ -182,7 +200,8 @@
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning');
const currentRound = $derived(effectivePlayers !== null && scoreboard && 'round' in scoreboard && freshTelemetry(scoreboard, $uiNow, server?.telemetry_after) ? scoreboard.round : null);
let hasDiagnosticIssue = $derived(!!health && !['online', 'starting', 'provisioning', 'stopped'].includes(health.state));
function ago(iso: string | null, now: number): string {
if (!iso) return 'never';
@@ -218,30 +237,33 @@
</script>
<svelte:head><title>{serverTitle(name)} · {activeTab.charAt(0).toUpperCase() + activeTab.slice(1)} · Electric Cherry</title></svelte:head>
<LifecycleDialog bind:open={lifecycleOpen} initialServer={name} initialAction={lifecycleAction} />
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} />
<LifecycleDialog bind:open={lifecycleOpen} initialServer={name} initialAction={lifecycleAction} {serverContext} players={effectivePlayers} round={currentRound} />
<DestroyServerModal bind:open={destroyOpen} {name} players={effectivePlayers} round={currentRound} />
{#if server}
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<header class="instance-header">
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
<Breadcrumbs items={[{ label: 'Servers', href: '/' }, { label: serverTitle(name) }]} />
<SectionHeader title={serverTitle(server.name)}>
{#snippet actions()}
<details class="server-actions"><summary>Instance actions</summary><div class="action-menu">{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue instance removal</Button></div></details>
{#if canOperate}
{#if primaryAction}<Button onclick={() => openAction(primaryAction)}>{actionLabels[primaryAction]} server</Button>{/if}
<details class="server-actions"><summary>More actions</summary><div class="action-menu">{#each availableActions.filter(action => action !== primaryAction) as action}<Button onclick={() => openAction(action)}>{actionLabels[action]} server</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Remove server</Button></div></details>
{/if}
{/snippet}
</SectionHeader>
<div class="instance-meta">
<span class="health"><StatusPip health={effectiveHealth} size="md" />{effectiveHealth === 'online' ? 'Online' : effectiveHealth === 'starting' ? 'Starting' : effectiveHealth === 'provisioning' ? 'Provisioning' : effectiveHealth === 'stale' ? 'Stale' : 'Offline'}</span>
<StatusBadge state={health?.state ?? 'unknown'} />
{#if server.host_id}<a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a>{/if}
<details><summary>Details</summary><div class="instance-details"><CopyValue value={name} label="Instance ID" /><RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} /></div></details>
</div>
</header>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry</Button></div>{/if}
{#if hasDiagnosticIssue}
{#if hasDiagnosticIssue && health}
<section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
<div><strong>{health.label}</strong><p>{health.reason} {health.since ? `Last evidence ${ago(health.since, $uiNow)}.` : 'No current report is available.'}</p></div>
<div class="issue-actions">{#if health.state === 'host-offline' && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" onclick={() => changeTab('diagnostics')}>Open diagnostics</Button>{/if}<Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button></div>
</section>
{/if}
<div class="tabs-row">
@@ -259,7 +281,7 @@
</section>
<section id="chat" aria-label="Chat">
<Card>
{#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}<ChatPanel server={name} />{/key}
{#key `${name}:${scoreboard?.timing?.matchId ?? ''}:${effectiveGameStartedAt ?? ''}`}<ChatPanel server={name} hostName={server.host?.name} hostOffline={health?.state === 'host-offline'} />{/key}
</Card>
</section>
{:else if activeSection === 'configuration'}
@@ -529,8 +551,7 @@
.live-context { margin: 0; font-size: var(--text-sm); color: var(--text-secondary); }
.instance-header { display: grid; gap: 12px; }
.instance-meta, .health { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-sm); }
.health { gap: 8px; }
.instance-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: var(--text-sm); }
.instance-details { display: grid; gap: 8px; padding: 12px 0; }
details > summary { cursor: pointer; color: var(--text-secondary); font-size: var(--text-sm); }
.server-actions { position: relative; }