Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
+90 -85
View File
@@ -1,9 +1,17 @@
<script lang="ts">
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
import { onMount, onDestroy } from 'svelte';
import { pollVisible } from '$ui/poll.js';
import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
import { serverTitle } from '$ui/server-labels.js';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ConfigInspector from '$ui/components/ConfigInspector.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
import { page } from '$app/stores';
import DestroyServerModal from '$ui/components/DestroyServerModal.svelte';
import { statusByServer, uiNow } from '$ui/stores/servers.js';
import { uiNow } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import { elapsedSince } from '$ui/duration.js';
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
@@ -68,7 +76,7 @@
game_started_at: string | null; game_ended_at: string | null;
}
interface InventoryEntry {
relPath: string; scriptVersionId: number | null;
relPath: string; kind?: string; scriptVersionId: number | null;
sha256: string; size: number; updatedAt: string; source: string | null;
}
interface Integrations {
@@ -79,11 +87,11 @@
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
}
let server = $state<Server | null>(null);
let integrations = $state<Integrations | null>(null);
let name = $derived($page.params.name ?? '');
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
let integrations = $derived($fleetIntegrations);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
@@ -91,54 +99,53 @@
let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false);
let destroyOpen = $state(false);
let activeTab = $state<'roster' | 'logs' | 'inventory' | 'diagnostics' | 'history'>('roster');
let activeTab = $state('roster');
let tabLoading = $state(false);
let tabError = $state('');
let historyLimit = $state(25);
let inventoryLimit = $state(25);
const operations = $derived($fleet.operations.filter(op => op.server === name));
async function loadServer(): Promise<void> {
const [srvRes, depRes, invRes] = await Promise.all([
fetch('/api/servers'),
fetch('/api/campaigns'),
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
]);
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
server = srvBody.servers.find((s) => s.name === name) ?? null;
integrations = srvBody.integrations;
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
if (invRes.ok) {
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
inventory = invBody.scripts;
}
async function loadTab(currentName: string, tab: string) {
if (tab !== 'history' && tab !== 'inventory') return;
try {
const response = await fetch(tab === 'history' ? '/api/campaigns?server=' + encodeURIComponent(currentName) : '/api/servers/' + encodeURIComponent(currentName) + '/scripts');
if (!response.ok) throw new Error('Could not refresh ' + tab + '.');
const body = await response.json();
if (name !== currentName || activeTab !== tab) return;
if (tab === 'history') history = body.deployments; else inventory = body.scripts;
tabError = '';
} catch (error) { if (name === currentName && activeTab === tab) tabError = (error as Error).message; }
finally { if (name === currentName && activeTab === tab) tabLoading = false; }
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadServer();
poll = setInterval(loadServer, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
$effect(() => {
const currentName = name;
const tab = activeTab;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'inventory';
return pollVisible(() => loadTab(currentName, tab), 10000);
});
$effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let live = $derived($statusByServer.get(name));
let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null);
let effectiveMap = $derived(live?.map ?? server?.last_map ?? null);
let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null);
let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null);
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
let effectiveErr = $derived(live ? live.lastError : server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
let effectiveMap = $derived(server?.last_map ?? null);
let effectiveGametype = $derived(server?.last_gametype ?? null);
let effectiveRoster = $derived(server?.roster ?? null);
let effectiveHealth = $derived<Health>(server?.health === 'online' && !fresh ? 'stale' : server?.health ?? 'offline');
let effectiveSeen = $derived(server?.last_seen_at ?? null);
let effectiveErr = $derived(server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(server?.endpoint ?? null);
let decoded = $derived(decodeRconError(effectiveErr));
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
rconProbe?.rawReply ?? server?.replySnippet ?? null
);
let effectiveActiveSince = $derived<string | null>(live?.activeSince ?? server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(live?.inactiveSince ?? server?.inactive_since ?? null);
let effectiveGameStartedAt = $derived<string | null>(live?.gameStartedAt ?? server?.game_started_at ?? null);
let effectiveGameEndedAt = $derived<string | null>(live?.gameEndedAt ?? server?.game_ended_at ?? null);
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
let effectiveGameStartedAt = $derived<string | null>(server?.game_started_at ?? null);
let effectiveGameEndedAt = $derived<string | null>(server?.game_ended_at ?? null);
let activeElapsed = $derived(effectiveHealth !== 'starting' && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
let inactiveElapsed = $derived(effectiveHealth !== 'starting' && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
@@ -146,13 +153,6 @@
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting');
// Auto-open the Diagnostics tab if there's a problem the operator should see.
$effect(() => {
if (hasDiagnosticIssue && activeTab === 'roster' && effectiveRoster === null) {
activeTab = 'diagnostics';
}
});
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.now() - new Date(iso).getTime();
@@ -173,10 +173,7 @@
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' });
komodoProbe = (await res.json()) as KomodoProbeResult;
if (komodoProbe.ok && server) {
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
integrations = srvBody.integrations;
await refreshFleet();
}
} catch (err) {
komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message };
@@ -198,9 +195,7 @@
try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' });
rconProbe = (await res.json()) as RconProbeResult;
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
await refreshFleet();
} catch (err) {
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
} finally { probingRcon = false; }
@@ -209,6 +204,7 @@
let tabs = $derived([
{ id: 'roster', label: 'Roster', badge: effectiveRoster?.length ?? null },
{ id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Config' },
{ id: 'inventory', label: 'Inventory', badge: inventory.length || null },
{ id: 'diagnostics',label: 'Diagnostics', badge: hasDiagnosticIssue ? '!' : null },
{ id: 'history', label: 'History', badge: history.length || null }
@@ -221,9 +217,9 @@
<div class="server-page" style="view-transition-name: server-{name.replace(/[^a-z0-9_-]/gi, '-')};">
<!-- Hero: name + status + player count + primary actions -->
<Card padding="lg" elevation="flat">
<SectionHeader title={server.name} size="lg">
<SectionHeader title={serverTitle(server.name)}>
{#snippet crumbs()}
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: name }]} />
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
{/snippet}
{#snippet actions()}
<Button variant="danger" onclick={() => destroyOpen = true}>
@@ -232,6 +228,7 @@
{/snippet}
</SectionHeader>
<CopyValue value={name} label="Server ID" />
<div class="metrics">
<div class="metric-cell name-cell">
<StatusPip health={effectiveHealth} size="md" />
@@ -244,14 +241,14 @@
<div class="metric-cell">
<Stat
label="Players"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? 0}
size="xl"
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
size="md"
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
tween
/>
</div>
<div class="metric-cell">
<Stat label="Map" value={effectiveMap ?? '—'} size="md" tween={false} />
<Stat label="Map" value={fleetIdentity(server).map} size="md" tween={false} />
{#if gametypeLabel(effectiveGametype)}
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
{/if}
@@ -281,11 +278,14 @@
</Card>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
{#if operations.length}<section class="ui-panel"><h2>Current operations</h2><div class="server-operations">{#each operations as operation (operation.key)}<a href={operation.href}><strong>{operation.label}</strong><span>{operation.detail}</span><StatusBadge state={operation.state} /></a>{/each}</div></section>{/if}
<div class="tabs-row">
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" />
<Tabs {tabs} bind:value={activeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
</div>
<div class="tab-panel">
<div class="tab-panel" role="tabpanel" id="server-panel" aria-labelledby={"server-tabs-" + activeTab}>
{#if tabError}<div class="ui-error" role="alert">{tabError}<Button size="sm" onclick={() => loadTab(name, activeTab)}>Retry</Button></div>{/if}
{#if activeTab === 'roster'}
<Card padding="lg">
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
@@ -300,22 +300,24 @@
]}
/>
</Card>
{:else if activeTab === 'config'}
{#key name}<ConfigInspector fixedServer={name} />{/key}
{:else if activeTab === 'inventory'}
<Card padding="lg">
<div class="panel-head">
<h2>Scripts on this server</h2>
<h2>Installed files</h2>
<span class="panel-count">{inventory.length}</span>
</div>
{#if inventory.length === 0}
{#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0}
<EmptyState
title="No scripts tracked yet"
description="Upload a script from the Scripts page and open a beta to deploy it here."
title="No files tracked yet"
description="Files appear here after scripts, configs, or plugins are deployed to this server."
/>
{:else}
<div class="inv-list">
{#each inventory as f (f.relPath)}
{#each inventory.slice(0,inventoryLimit) as f (f.relPath)}
<div class="inv-row">
<span class="rel">{f.relPath}</span>
<span class="rel">{f.relPath}<span class="file-kind">{f.kind ?? (f.relPath.endsWith(".dll") ? "Plugin" : f.relPath.endsWith(".cfg") ? "Config" : "Script")}</span></span>
<span class="size">{f.size} B</span>
<span class="src">{f.source ?? '—'}</span>
<span class="sha">{f.sha256.slice(0, 12)}</span>
@@ -324,6 +326,7 @@
</div>
{/if}
</Card>
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
{:else if activeTab === 'diagnostics'}
<Card padding="lg">
<div class="panel-head">
@@ -489,29 +492,22 @@
{:else if activeTab === 'history'}
<Card padding="lg">
<div class="panel-head">
<h2>Campaign history</h2>
<h2>Rollout history</h2>
<span class="panel-count">{history.length}</span>
</div>
{#if history.length === 0}
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
<EmptyState
title="No campaigns yet"
title="No rollouts yet"
description="Nothing has been pushed to this server. Open a beta or push scripts to see history here."
/>
{:else}
<div class="history">
{#each history as d (d.id)}
{#each history.slice(0,historyLimit) as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
<span class="hist-action">{d.action}</span>
<span class="hist-state">
<Badge
variant={mine.restart_state === 'done' ? 'online' :
mine.restart_state === 'failed' ? 'danger' :
mine.restart_state === 'pending' ? 'warning' :
'neutral'}
size="sm"
uppercase
>{mine.restart_state}</Badge>
<StatusBadge state={mine.restart_state} />
{#if mine.players !== null && mine.players > 0}
<span class="hist-players">{mine.players}p</span>
{/if}
@@ -524,9 +520,14 @@
</div>
{/if}
</Card>
<ListFooter total={history.length} bind:limit={historyLimit} label="rollouts" />
{/if}
</div>
</div>
{:else if $fleetError}
<div class="ui-error" role="alert">{$fleetError}<Button onclick={refreshFleet}>Retry</Button></div>
{:else if $fleet.receivedAt}
<EmptyState title="Server not found" description="This server may have been removed from the fleet." />
{:else}
<div class="loading">Loading…</div>
{/if}
@@ -823,4 +824,8 @@
gap: var(--space-1);
}
}
.server-operations { display:grid; gap:8px; }
.server-operations a { display:flex; gap:12px; flex-wrap:wrap; align-items:center; text-decoration:none; color:var(--text-primary); padding:10px 0; }
.server-operations a span { color:var(--text-secondary); font-size:13px; }
.file-kind { display:block; color:var(--text-secondary); font:12px var(--font-body); margin-top:5px; }
</style>