Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
911 lines
47 KiB
Svelte
911 lines
47 KiB
Svelte
<script lang="ts">
|
|
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
|
|
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
|
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
|
|
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.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 { goto } from '$app/navigation';
|
|
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
|
import { runtimeActions, actionLabels, type RuntimeAction } from '$lib/provisioning.js';
|
|
let lifecycleOpen = $state(false);
|
|
let lifecycleAction = $state<RuntimeAction>('start');
|
|
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';
|
|
import { elapsedSince } from '$ui/duration.js';
|
|
import PlayerScoreboard from '$ui/components/PlayerScoreboard.svelte';
|
|
import LogPanel from '$ui/components/LogPanel.svelte';
|
|
import ChatPanel from '$ui/components/ChatPanel.svelte';
|
|
import { gametypeLabel } from '$ui/gametype.js';
|
|
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
|
|
import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js';
|
|
|
|
import Card from '$ui/components/primitives/Card.svelte';
|
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
|
import Button from '$ui/components/primitives/Button.svelte';
|
|
import Stat from '$ui/components/primitives/Stat.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 Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
|
|
|
interface KomodoProbeResult {
|
|
ok: boolean;
|
|
port?: number;
|
|
reason?: KomodoPortFailure;
|
|
detail?: string;
|
|
httpStatus?: number;
|
|
snippet?: string;
|
|
url?: string;
|
|
}
|
|
type NetOutcome = 'connected' | 'refused' | 'timeout' | 'dns-fail' | 'unreachable' | 'error';
|
|
interface NetTrial {
|
|
label: string; host: string; port: number;
|
|
resolvedIp: string | null; outcome: NetOutcome; elapsedMs: number; detail?: string;
|
|
}
|
|
interface NetDiagnosis {
|
|
ok: boolean;
|
|
config?: {
|
|
komodoAddress: string | null; komodoHost: string | null; komodoPort: number | null;
|
|
rconHost: string; probeTimeoutMs: number;
|
|
};
|
|
results?: NetTrial[];
|
|
error?: string;
|
|
}
|
|
interface RconProbeResult {
|
|
ok: boolean;
|
|
endpoint?: { host: string; port: number } | null;
|
|
reason?: RconFailure;
|
|
detail?: string;
|
|
rawReply?: string;
|
|
status?: { players: number; map: string | null; gametype: string | null; roster: RosterPlayer[] };
|
|
}
|
|
interface Server {
|
|
plutonium?: import('$lib/plutonium.js').PlutoniumVersion;
|
|
telemetry_after?: string | null;
|
|
status_source?: 'telemetry' | 'rcon' | null;
|
|
rcon_status?: import('$lib/server-observation.js').GameObservation;
|
|
telemetry?: import('$lib/telemetry.js').GameTelemetry;
|
|
name: string;
|
|
native_instance_id?: string | null; host_id?: string | null; instance_generation?: number | null; game_library_id?: string | null;
|
|
host?: import('$lib/hosts.js').Host | null;
|
|
network?: { bindAddress: string; localPort: number; publicAddress: string | null; publicPort: number | null } | null;
|
|
komodo_type: string; komodo_name: string; komodo_server: string | null;
|
|
game_port: number | null; rcon_host: string | null;
|
|
game: string | null; map: string | null;
|
|
last_players: number | null; last_checked_at: string | null;
|
|
last_map: string | null; last_gametype: string | null;
|
|
last_seen_at: string | null; last_error: string | null;
|
|
roster: RosterPlayer[] | null;
|
|
health: Health; endpoint: RconEndpoint | null; replySnippet: string | null;
|
|
held: Array<{ target: string; betaId: number }>;
|
|
active_since: string | null; inactive_since: string | null;
|
|
game_started_at: string | null; game_ended_at: string | null;
|
|
}
|
|
interface InventoryEntry {
|
|
relPath: string; kind?: string; scriptVersionId: number | null;
|
|
sha256: string; size: number; updatedAt: string; source: string | null;
|
|
}
|
|
interface Integrations {
|
|
rcon: boolean; komodo: boolean; rconTimeoutMs: number; defaultRconHost: string;
|
|
}
|
|
interface DeploymentRow {
|
|
id: string; action: string; started_at: string; finished_at: string | null;
|
|
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
|
|
}
|
|
|
|
let name = $derived($page.params.name ?? '');
|
|
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null);
|
|
const native = $derived(!!server?.native_instance_id);
|
|
let integrations = $derived($fleetIntegrations);
|
|
let history = $state<DeploymentRow[]>([]);
|
|
let inventory = $state<InventoryEntry[]>([]);
|
|
let komodoProbe = $state<KomodoProbeResult | null>(null);
|
|
let probing = $state(false);
|
|
let netDiagnosis = $state<NetDiagnosis | null>(null);
|
|
let diagnosing = $state(false);
|
|
let rconProbe = $state<RconProbeResult | null>(null);
|
|
let probingRcon = $state(false);
|
|
let destroyOpen = $state(false);
|
|
const sectionIds = ['scoreboard', 'matches', 'chat', 'logs', 'config', 'inventory', 'diagnostics', 'history'];
|
|
const activeTab = $derived(sectionIds.includes($page.url.searchParams.get('tab') ?? '') ? $page.url.searchParams.get('tab')! : 'scoreboard');
|
|
function changeTab(tab: string) {
|
|
const url = new URL($page.url);
|
|
url.searchParams.set('tab', tab);
|
|
void goto(url, { noScroll: true, keepFocus: true });
|
|
}
|
|
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 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; }
|
|
}
|
|
$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 effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
|
|
let effectiveMap = $derived(server?.last_map ?? null);
|
|
let effectiveGametype = $derived(server?.last_gametype ?? 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);
|
|
let effectiveErr = $derived(effectiveHealth === 'starting' || effectiveHealth === 'provisioning'
|
|
? null : server?.rcon_status?.last_error ?? server?.last_error ?? null);
|
|
let effectiveEndpoint = $derived<RconEndpoint | null>(server?.endpoint ?? null);
|
|
let decoded = $derived(decodeRconError(effectiveErr));
|
|
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
|
|
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
|
let effectiveSnippet = $derived<string | null>(
|
|
rconProbe?.rawReply ?? server?.replySnippet ?? 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 resetElapsed = $derived(fresh && effectiveGameEndedAt && server?.last_players !== 0 ? elapsedSince(effectiveGameEndedAt, $uiNow) : null);
|
|
let activeElapsed = $derived(fresh && effectiveActiveSince ? elapsedSince(effectiveActiveSince, $uiNow) : null);
|
|
let inactiveElapsed = $derived(fresh && effectiveInactiveSince ? elapsedSince(effectiveInactiveSince, $uiNow) : null);
|
|
let gameInProgress = $derived(!!effectiveGameStartedAt && !effectiveGameEndedAt && !!effectiveActiveSince);
|
|
let gameElapsed = $derived(fresh && gameInProgress ? elapsedSince(effectiveGameStartedAt, $uiNow) : null);
|
|
|
|
let hasDiagnosticIssue = $derived(effectiveHealth !== 'online' && effectiveHealth !== 'starting' && effectiveHealth !== 'provisioning');
|
|
|
|
function ago(iso: string | null, now: number): string {
|
|
if (!iso) return 'never';
|
|
const d = now - new Date(iso).getTime();
|
|
if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
|
|
if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
|
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
|
|
return `${Math.floor(d / 86_400_000)}d ago`;
|
|
}
|
|
function endpointLabel(ep: RconEndpoint | null): string {
|
|
if (!ep) return 'unresolved';
|
|
return `${ep.host}:${ep.port}`;
|
|
}
|
|
|
|
async function probeKomodo(): Promise<void> {
|
|
if (probing || !name) return;
|
|
probing = true; komodoProbe = null;
|
|
try {
|
|
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-komodo`, { method: 'POST' });
|
|
komodoProbe = (await res.json()) as KomodoProbeResult;
|
|
if (komodoProbe.ok && server) {
|
|
await refreshFleet();
|
|
}
|
|
} catch (err) {
|
|
komodoProbe = { ok: false, reason: 'unreachable', detail: (err as Error).message };
|
|
} finally { probing = false; }
|
|
}
|
|
async function diagnoseNetwork(): Promise<void> {
|
|
if (diagnosing || !name) return;
|
|
diagnosing = true; netDiagnosis = null;
|
|
try {
|
|
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/diagnose-network`, { method: 'POST' });
|
|
netDiagnosis = (await res.json()) as NetDiagnosis;
|
|
} catch (err) {
|
|
netDiagnosis = { ok: false, error: (err as Error).message };
|
|
} finally { diagnosing = false; }
|
|
}
|
|
async function probeRcon(): Promise<void> {
|
|
if (probingRcon || !name) return;
|
|
probingRcon = true; rconProbe = null;
|
|
try {
|
|
const res = await fetch(`/api/servers/${encodeURIComponent(name)}/probe-rcon`, { method: 'POST' });
|
|
rconProbe = (await res.json()) as RconProbeResult;
|
|
await refreshFleet();
|
|
} catch (err) {
|
|
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
|
|
} finally { probingRcon = false; }
|
|
}
|
|
// Tab config — Diagnostics gets a warning badge when there's an issue.
|
|
let tabs = $derived([
|
|
{ id: 'scoreboard', label: 'Scoreboard', badge: null },
|
|
{ id: 'chat', label: 'Chat' },
|
|
{ id: 'matches', label: 'Matches' },
|
|
{ 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 }
|
|
]);
|
|
</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} />
|
|
|
|
{#if server}
|
|
<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={serverTitle(server.name)}>
|
|
{#snippet crumbs()}
|
|
<Breadcrumbs items={[{ label: 'Fleet', href: '/' }, { label: serverTitle(name) }]} />
|
|
{/snippet}
|
|
{#snippet actions()}
|
|
<details class="server-actions"><summary>Server actions</summary>{#each runtimeActions as action}<Button onclick={() => { lifecycleAction = action; lifecycleOpen = true; }}>{actionLabels[action]}</Button>{/each}<Button variant="danger" onclick={() => destroyOpen = true}>Queue server removal</Button></details>
|
|
{/snippet}
|
|
</SectionHeader>
|
|
|
|
<CopyValue value={name} label="Server ID" />
|
|
<RuntimeVersion {name} version={server.plutonium} after={server.telemetry_after} ready={effectiveHealth === 'online'} />
|
|
<div class="metrics">
|
|
<div class="metric-cell name-cell">
|
|
<StatusPip health={effectiveHealth} size="md" />
|
|
<span class="health-label">
|
|
{effectiveHealth === 'online' ? 'Online' :
|
|
effectiveHealth === 'starting' ? 'Starting' :
|
|
effectiveHealth === 'provisioning' ? 'Provisioning' :
|
|
effectiveHealth === 'stale' ? 'Stale' : 'Offline'}
|
|
</span>
|
|
</div>
|
|
<div class="metric-cell">
|
|
<Stat
|
|
label="Players"
|
|
value={effectiveHealth === 'starting' ? '—' : effectivePlayers ?? '—'}
|
|
size="md"
|
|
tone={(effectivePlayers ?? 0) > 0 ? 'online' : 'primary'}
|
|
tween
|
|
/>
|
|
</div>
|
|
<div class="metric-cell">
|
|
<Stat label="Map" value={fleetIdentity(server).map} size="md" tween={false} />
|
|
{#if gametypeLabel(effectiveGametype)}
|
|
<div class="sub">{gametypeLabel(effectiveGametype)}</div>
|
|
{/if}
|
|
</div>
|
|
{#if resetElapsed}
|
|
<div class="metric-cell">
|
|
<Stat label="Resetting" value={resetElapsed} size="md" tween={false} />
|
|
</div>
|
|
{:else if activeElapsed}
|
|
<div class="metric-cell">
|
|
<Stat label="Active for" value={activeElapsed} size="md" tween={false} />
|
|
</div>
|
|
{:else if inactiveElapsed}
|
|
<div class="metric-cell">
|
|
<Stat label="Inactive for" value={inactiveElapsed} size="md" tween={false} />
|
|
</div>
|
|
{/if}
|
|
{#if gameElapsed}
|
|
<div class="metric-cell">
|
|
<Stat label="Match" value={gameElapsed} size="md" tween={false} tone="accent" />
|
|
</div>
|
|
{/if}
|
|
{#if effectiveHealth !== 'online'}
|
|
<div class="metric-cell">
|
|
<Stat label="Last seen" value={ago(effectiveSeen, $uiNow)} size="md" tween={false} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if effectiveHealth === 'online'}<RoundTelemetry {name} report={scoreboard} since={server?.telemetry_after ?? null} />{/if}
|
|
|
|
</Card>
|
|
|
|
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if}
|
|
{#if hasDiagnosticIssue}
|
|
<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.' : !native && integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</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={`/hosts/${server.host_id}`}>Check host</Button>{:else if integrations && (!integrations.rcon || !integrations.komodo)}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
|
|
</section>
|
|
{/if}
|
|
<div class="tabs-row">
|
|
<Tabs {tabs} value={activeTab} onchange={changeTab} ariaLabel="Server sections" idPrefix="server-tabs" panelId="server-panel" />
|
|
</div>
|
|
|
|
<div class="tab-panel" class:diagnostics={activeTab === 'diagnostics'} class:history-panel={activeTab === 'history'} 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 === 'scoreboard'}
|
|
<Card padding="lg">
|
|
{#key name}<PlayerScoreboard current={fresh} after={server?.telemetry_after ?? null} report={scoreboard} mapLabel={scoreboard ? fleetIdentity({ ...server, last_map: scoreboard.map }).map : undefined} />{/key}
|
|
</Card>
|
|
{:else if activeTab === 'chat'}
|
|
<Card padding="lg">
|
|
{#key name}<ChatPanel server={name} />{/key}
|
|
</Card>
|
|
{:else if activeTab === 'logs'}
|
|
<Card padding="lg">
|
|
<LogPanel
|
|
server={server.name}
|
|
tabs={[
|
|
{ key: 'console', label: 'console.log' },
|
|
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
|
|
]}
|
|
/>
|
|
</Card>
|
|
{:else if activeTab === 'config'}
|
|
{#key name}<ConfigInspector fixedServer={name} />{/key}
|
|
{:else if activeTab === 'inventory'}
|
|
<Card padding="lg">
|
|
<div class="panel-head">
|
|
<h2>Installed files</h2>
|
|
<span class="panel-count">{inventory.length}</span>
|
|
</div>
|
|
{#if tabLoading}<EmptyState title="Loading inventory…" />{:else if inventory.length === 0}
|
|
<EmptyState
|
|
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.slice(0,inventoryLimit) as f (f.relPath)}
|
|
<div class="inv-row">
|
|
<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>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Card>
|
|
<ListFooter total={inventory.length} bind:limit={inventoryLimit} label="files" />
|
|
{:else if activeTab === 'diagnostics'}
|
|
<Card padding="lg">
|
|
<div class="panel-head">
|
|
<h2>RCON diagnostic</h2>
|
|
{#if effectiveHealth === 'provisioning'}
|
|
<Badge variant="info" uppercase>Provisioning</Badge>
|
|
{:else if effectiveHealth === 'starting'}
|
|
<Badge variant="info" uppercase>Waiting for container</Badge>
|
|
{:else if hasDiagnosticIssue}
|
|
<Badge variant="warning" uppercase>Issue</Badge>
|
|
{:else}
|
|
<Badge variant="online" uppercase>Healthy</Badge>
|
|
{/if}
|
|
</div>
|
|
|
|
<p class="sub">Current player count: {server.status_source === 'telemetry' ? 'game telemetry' : server.status_source === 'rcon' ? 'RCON' : 'unavailable'}. RCON probes below show that connection's separate results.</p>
|
|
{#if native}
|
|
<div class="diag-grid"><div class="kv"><span class="k">Game connection</span><span class="v">RCON runs locally through the host agent</span></div><div class="kv"><span class="k">Host agent</span><span class="v {server.host?.connected ? 'ok' : 'bad'}">{server.host?.connectionState ?? 'unknown'}</span></div><div class="kv"><span class="k">Last heartbeat</span><span class="v">{server.host?.lastHeartbeat ? ago(server.host.lastHeartbeat, $uiNow) : 'Not received'}</span></div></div>
|
|
{:else if integrations}
|
|
<div class="diag-grid">
|
|
<div class="kv"><span class="k">Endpoint</span><span class="v mono">{endpointLabel(effectiveEndpoint)}</span></div>
|
|
<div class="kv"><span class="k">RCON password</span><span class="v mono {integrations.rcon ? 'ok' : 'bad'}">{integrations.rcon ? 'configured' : 'missing'}</span></div>
|
|
<div class="kv"><span class="k">Komodo</span><span class="v mono {integrations.komodo ? 'ok' : 'bad'}">{integrations.komodo ? 'configured' : 'missing'}</span></div>
|
|
<div class="kv"><span class="k">Timeout</span><span class="v mono">{integrations.rconTimeoutMs} ms</span></div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if effectiveReason}
|
|
<div class="alert alert-danger">
|
|
<Badge variant="danger" uppercase>{effectiveReason}</Badge>
|
|
<span class="alert-detail">{errorDetail}</span>
|
|
</div>
|
|
<div class="hint">{native ? 'Inspect the host agent connection and game logs. RCON is sent locally by the agent; public game-port forwarding does not affect this check.' : rconFixHint(effectiveReason)}</div>
|
|
{:else if effectiveErr}
|
|
<div class="alert alert-danger">
|
|
<Badge variant="neutral" uppercase>Error</Badge>
|
|
<span class="alert-detail">{effectiveErr}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if effectiveSnippet}
|
|
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
|
|
<summary>Raw reply ({effectiveSnippet.split('\n').length - 1} lines)</summary>
|
|
<pre class="snippet">{effectiveSnippet}</pre>
|
|
</details>
|
|
{/if}
|
|
|
|
<div class="probe-actions">
|
|
{#if effectiveEndpoint || native}
|
|
<Button variant="secondary" size="sm" onclick={probeRcon} disabled={probingRcon} loading={probingRcon}>
|
|
{native ? 'Probe RCON through agent' : 'Probe RCON'}
|
|
</Button>
|
|
{/if}
|
|
{#if !native && !effectiveEndpoint && integrations?.komodo}
|
|
<Button variant="secondary" size="sm" onclick={probeKomodo} disabled={probing} loading={probing}>
|
|
Probe Komodo
|
|
</Button>
|
|
{/if}
|
|
{#if !native}<Button variant="secondary" size="sm" onclick={diagnoseNetwork} disabled={diagnosing} loading={diagnosing}>
|
|
Diagnose network
|
|
</Button>{:else if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if}
|
|
</div>
|
|
|
|
{#if rconProbe?.ok}
|
|
<div class="alert alert-ok">
|
|
<Badge variant="online" uppercase>Connected</Badge>
|
|
<span class="alert-detail">
|
|
RCON: {rconProbe.status?.players ?? '?'} players
|
|
{#if rconProbe.status?.map} · map <strong>{rconProbe.status.map}</strong>{/if}
|
|
{#if rconProbe.status?.gametype} · {rconProbe.status.gametype}{/if}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !native && netDiagnosis}
|
|
{#if netDiagnosis.ok && netDiagnosis.results}
|
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
|
|
<div class="table-scroll" role="region" aria-label="Network diagnostics" tabindex="0">
|
|
<table class="net-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Trial</th><th>Resolved</th><th>Outcome</th>
|
|
<th class="num">Elapsed</th><th>Detail</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each netDiagnosis.results as r (r.label)}
|
|
<tr>
|
|
<td class="mono">{r.label}</td>
|
|
<td class="mono">{r.resolvedIp ?? '—'}</td>
|
|
<td>
|
|
<Badge
|
|
variant={r.outcome === 'connected' ? 'online' :
|
|
r.outcome === 'refused' ? 'warning' :
|
|
'danger'}
|
|
uppercase
|
|
>{r.outcome}</Badge>
|
|
</td>
|
|
<td class="mono num">{r.elapsedMs} ms</td>
|
|
<td class="mono detail-cell">{r.detail ?? ''}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{#if netDiagnosis.config}
|
|
<div class="net-meta">
|
|
KOMODO_ADDRESS = <code>{netDiagnosis.config.komodoAddress ?? '(unset)'}</code>
|
|
· RCON_HOST = <code>{netDiagnosis.config.rconHost}</code>
|
|
· probe timeout = <code>{netDiagnosis.config.probeTimeoutMs} ms</code>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="alert alert-danger">
|
|
<Badge variant="danger" uppercase>Error</Badge>
|
|
<span class="alert-detail">{netDiagnosis.error ?? 'network diagnostic failed'}</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if !native && komodoProbe}
|
|
{#if komodoProbe.ok}
|
|
<div class="alert alert-ok">
|
|
<Badge variant="online" uppercase>Resolved</Badge>
|
|
<span class="alert-detail">
|
|
Port <strong>{komodoProbe.port}</strong> cached — RCON will retry on the next tick.
|
|
</span>
|
|
</div>
|
|
{#if komodoProbe.snippet}
|
|
<details class="snippet-details">
|
|
<summary>Show response snippet</summary>
|
|
<pre class="snippet">{komodoProbe.snippet}</pre>
|
|
</details>
|
|
{/if}
|
|
{:else}
|
|
<div class="alert alert-danger">
|
|
<Badge variant="danger" uppercase>{komodoProbe.reason}</Badge>
|
|
<span class="alert-detail">
|
|
{komodoProbe.detail}{#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if}
|
|
</span>
|
|
</div>
|
|
{#if komodoProbe.url}
|
|
<div class="url-row"><span class="k">Tried</span><code>{komodoProbe.url}</code></div>
|
|
{/if}
|
|
{#if komodoProbe.reason}
|
|
<div class="hint">{komodoFixHint(komodoProbe.reason, komodoProbe.httpStatus)}</div>
|
|
{/if}
|
|
{#if komodoProbe.snippet}
|
|
<pre class="snippet">{komodoProbe.snippet}</pre>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="panel-head divider">
|
|
<h2>Configuration</h2>
|
|
</div>
|
|
<div class="diag-grid">
|
|
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={`/hosts/${server.host_id}`}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
|
|
{:else}<div class="kv"><span class="k">Komodo</span><span class="v mono">{server.komodo_type} · {server.komodo_name}</span></div><div class="kv"><span class="k">RCON override</span><span class="v mono">{server.rcon_host ?? '(default)'}</span></div>{/if}
|
|
<div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div>
|
|
<div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div>
|
|
<div class="kv"><span class="k">Map</span><span class="v mono">{server.map ?? '(unset)'}</span></div>
|
|
{#if server.held.length > 0}
|
|
<div class="kv"><span class="k">Held by beta</span><span class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</span></div>
|
|
{/if}
|
|
</div>
|
|
</Card>
|
|
<div id="telemetry">{#key name}<TelemetryDiagnostics server={name} />{/key}</div>
|
|
{:else if activeTab === 'matches'}
|
|
{#key name}<MatchHistory server={name} />{/key}
|
|
{:else if activeTab === 'history'}
|
|
{#if operations.length}
|
|
<Card padding="lg">
|
|
<div class="panel-head">
|
|
<h2>Operations</h2>
|
|
<span class="panel-count">{operations.length}</span>
|
|
</div>
|
|
<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>
|
|
</Card>
|
|
{/if}
|
|
<Card padding="lg">
|
|
<div class="panel-head">
|
|
<h2>Campaign history</h2>
|
|
<span class="panel-count">{history.length}</span>
|
|
</div>
|
|
{#if tabLoading}<EmptyState title="Loading history…" />{:else if history.length === 0}
|
|
<EmptyState
|
|
title="No campaigns 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.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">
|
|
<StatusBadge state={mine.restart_state} />
|
|
{#if mine.players !== null && mine.players > 0}
|
|
<span class="hist-players">{mine.players}p</span>
|
|
{/if}
|
|
</span>
|
|
<span class="hist-files">{mine.files_changed} file{mine.files_changed === 1 ? '' : 's'}</span>
|
|
<span class="hist-when">{ago(d.started_at, $uiNow)}</span>
|
|
{#if mine.error}<span class="hist-err">{mine.error}</span>{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</Card>
|
|
<ListFooter total={history.length} bind:limit={historyLimit} label="campaigns" />
|
|
{/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}
|
|
|
|
<style>
|
|
.server-actions { text-align: right; }
|
|
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
|
.server-actions[open] summary { margin-bottom: 10px; }
|
|
.connection-issue { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; padding: 16px; border: 1px solid var(--state-warning); border-radius: var(--radius-md); background: var(--surface); }
|
|
.connection-issue p { margin: 5px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
|
.issue-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
.server-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-6);
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
padding: var(--space-16);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
/* Hero metric grid */
|
|
.metrics {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
gap: var(--space-6);
|
|
padding-top: var(--space-4);
|
|
border-top: 1px solid var(--border-subtle);
|
|
margin-top: var(--space-2);
|
|
}
|
|
.metric-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
min-width: 0;
|
|
}
|
|
.name-cell {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
.health-label {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
font-weight: var(--weight-medium);
|
|
}
|
|
.sub {
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
/* Tabs row */
|
|
.tab-panel.diagnostics,
|
|
.tab-panel.history-panel {
|
|
display: grid;
|
|
gap: var(--space-6);
|
|
}
|
|
.tabs-row {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
/* Panel headers */
|
|
.panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-3);
|
|
margin-bottom: var(--space-4);
|
|
}
|
|
.panel-head.divider {
|
|
margin-top: var(--space-6);
|
|
padding-top: var(--space-5);
|
|
border-top: 1px solid var(--border-subtle);
|
|
}
|
|
.panel-head h2 {
|
|
margin: 0;
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-md);
|
|
font-weight: var(--weight-semibold);
|
|
color: var(--text-primary);
|
|
letter-spacing: var(--tracking-tight);
|
|
}
|
|
.panel-count {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-tertiary);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Diagnostics KV grid */
|
|
.diag-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
|
gap: var(--space-2);
|
|
margin-top: var(--space-2);
|
|
}
|
|
.kv {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding: var(--space-3) var(--space-4);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.kv .k {
|
|
font-size: var(--text-xs);
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--tracking-label);
|
|
color: var(--text-tertiary);
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
.kv .v {
|
|
font-size: var(--text-sm);
|
|
color: var(--text-primary);
|
|
word-break: break-word;
|
|
}
|
|
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.ok { color: var(--state-online); }
|
|
.bad { color: var(--state-danger); }
|
|
|
|
/* Inline alerts */
|
|
.alert {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
margin-top: var(--space-4);
|
|
padding: var(--space-3) var(--space-4);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid transparent;
|
|
}
|
|
.alert-danger { background: rgba(255, 86, 112, 0.06); border-color: rgba(255, 86, 112, 0.2); }
|
|
.alert-ok { background: rgba(124, 212, 255, 0.06); border-color: rgba(124, 212, 255, 0.2); }
|
|
.alert-detail {
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
}
|
|
.alert-detail strong { color: var(--state-online); }
|
|
|
|
.hint {
|
|
margin-top: var(--space-2);
|
|
padding: var(--space-3) var(--space-4);
|
|
font-size: var(--text-sm);
|
|
color: var(--text-secondary);
|
|
background: var(--surface-sunken);
|
|
border-left: 2px solid var(--accent-primary);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
|
|
.probe-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-2);
|
|
margin-top: var(--space-4);
|
|
}
|
|
|
|
.url-row {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
margin-top: var(--space-2);
|
|
padding: var(--space-2) var(--space-3);
|
|
background: var(--surface-sunken);
|
|
border-radius: var(--radius-md);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.url-row .k { color: var(--text-tertiary); }
|
|
.url-row code { color: var(--text-primary); word-break: break-all; }
|
|
|
|
/* Snippet details */
|
|
.snippet-details {
|
|
margin-top: var(--space-3);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
}
|
|
.snippet-details summary {
|
|
color: var(--text-tertiary);
|
|
cursor: pointer;
|
|
padding: var(--space-2) 0;
|
|
}
|
|
.snippet-details summary:hover { color: var(--text-primary); }
|
|
.snippet {
|
|
margin: var(--space-2) 0 0;
|
|
padding: var(--space-4);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
color: var(--text-secondary);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
line-height: var(--leading-mono);
|
|
max-height: 440px;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
/* Network table */
|
|
.table-scroll { max-width: 100%; overflow-x: auto; }
|
|
.table-scroll:focus-visible { box-shadow: var(--focus-ring); }
|
|
.net-table {
|
|
min-width: 600px;
|
|
width: 100%;
|
|
margin-top: var(--space-4);
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
font-size: var(--text-sm);
|
|
}
|
|
.net-table thead th {
|
|
text-align: left;
|
|
padding: var(--space-3) var(--space-4);
|
|
font-family: var(--font-body);
|
|
font-size: var(--text-xs);
|
|
font-weight: var(--weight-semibold);
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--tracking-label);
|
|
color: var(--text-tertiary);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
}
|
|
.net-table tbody td {
|
|
padding: var(--space-3) var(--space-4);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
color: var(--text-primary);
|
|
vertical-align: top;
|
|
}
|
|
.net-table tbody tr:last-child td { border-bottom: none; }
|
|
.net-table .num { text-align: right; }
|
|
.net-table .detail-cell {
|
|
color: var(--text-tertiary);
|
|
font-size: var(--text-xs);
|
|
word-break: break-word;
|
|
max-width: 320px;
|
|
}
|
|
|
|
.net-meta {
|
|
margin-top: var(--space-3);
|
|
font-family: var(--font-mono);
|
|
font-size: var(--text-xs);
|
|
color: var(--text-tertiary);
|
|
}
|
|
.net-meta code {
|
|
color: var(--text-primary);
|
|
background: var(--surface-sunken);
|
|
padding: 2px 6px;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
/* Inventory */
|
|
.inv-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
}
|
|
.inv-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 80px 140px 120px;
|
|
gap: var(--space-3);
|
|
padding: var(--space-2) var(--space-3);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border-subtle);
|
|
font-size: var(--text-xs);
|
|
font-family: var(--font-mono);
|
|
align-items: center;
|
|
}
|
|
.inv-row:hover { background: var(--surface-raised); }
|
|
.inv-row .rel { color: var(--text-primary); word-break: break-all; }
|
|
.inv-row .size,
|
|
.inv-row .src,
|
|
.inv-row .sha { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
|
|
|
/* History rows */
|
|
.history { display: flex; flex-direction: column; gap: var(--space-1); }
|
|
.hist-row {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 220px) auto 110px 100px 1fr;
|
|
gap: var(--space-3);
|
|
align-items: center;
|
|
padding: var(--space-3) var(--space-4);
|
|
text-decoration: none;
|
|
color: var(--text-secondary);
|
|
border-radius: var(--radius-md);
|
|
border: 1px solid var(--border-subtle);
|
|
transition: background-color var(--dur-fast) var(--ease-standard);
|
|
}
|
|
.hist-row:hover { background: var(--surface-raised); }
|
|
.hist-row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
|
.hist-action { color: var(--text-primary); font-weight: var(--weight-medium); font-size: var(--text-sm); }
|
|
.hist-state { display: inline-flex; align-items: center; gap: var(--space-2); }
|
|
.hist-players { color: var(--state-online); font-family: var(--font-mono); font-size: var(--text-xs); }
|
|
.hist-files, .hist-when { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
|
|
.hist-err { color: var(--state-danger); font-size: var(--text-xs); font-family: var(--font-mono); }
|
|
|
|
@media (max-width: 640px) {
|
|
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
.panel-head, .alert { flex-wrap: wrap; }
|
|
.hist-row {
|
|
grid-template-columns: 1fr;
|
|
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>
|