Remove Komodo backend in favor of native hosts

This commit is contained in:
2026-10-05 18:07:07 -04:00
parent 701066aaf8
commit 4b0f341d64
116 changed files with 557 additions and 8132 deletions
+6 -222
View File
@@ -29,7 +29,6 @@
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';
@@ -41,29 +40,6 @@
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;
@@ -82,7 +58,6 @@
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;
@@ -98,9 +73,6 @@
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 }>;
@@ -108,7 +80,7 @@
let name = $derived($page.params.name ?? '');
const detail = $derived(createFleetStore(name));
const { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } = $derived(detail);
const { fleet, fleetServers, fleetError, refreshFleet } = $derived(detail);
$effect(() => {
detail.hydrateFleet(data.serverSnapshot);
return detail.startFleetMonitor();
@@ -117,13 +89,8 @@
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null);
const native = $derived(!!server?.native_instance_id);
let integrations = $derived($fleetIntegrations ?? data.serverSnapshot.integrations);
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);
@@ -171,7 +138,7 @@
tabLoading = tab === 'history' || tab === 'inventory';
return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
});
$effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; });
$effect(() => { name; rconProbe = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false);
let effectivePlayers = $derived(fresh ? server?.last_players ?? null : null);
@@ -214,29 +181,6 @@
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;
@@ -337,8 +281,8 @@
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>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>
<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={`/hosts/${server.host_id}`}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
</section>
{/if}
<div class="tabs-row">
@@ -410,13 +354,6 @@
<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}
@@ -445,14 +382,7 @@
{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}
{#if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if}
</div>
{#if rconProbe?.ok}
@@ -466,91 +396,12 @@
</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>
&nbsp;·&nbsp; RCON_HOST = <code>{netDiagnosis.config.rconHost}</code>
&nbsp;·&nbsp; 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}
{/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>
@@ -777,19 +628,6 @@
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);
@@ -818,60 +656,6 @@
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;