Unify UI workflows and streamline fleet management
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user