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
-2
View File
@@ -165,8 +165,6 @@
</div>{/if}
{#if integrations?.native && !integrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {integrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !integrations.native && (!integrations.komodo || !integrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!integrations.komodo ? 'Komodo' : ''}{!integrations.komodo && !integrations.rcon ? ' and ' : ''}{!integrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
{/if}
{#if problems.length || attentionOperations.length || starting || provisioning}
+1 -1
View File
@@ -4,7 +4,7 @@ import { hostApiError, installerCommand, objectBody, requireManagementOrigin } f
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts().map(presentHostPreparation), defaultRuntimeImage: config.komodo.defaultImage }); }
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: listHosts().map(presentHostPreparation), defaultRuntimeImage: config.defaultRuntimeImage }); }
export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request);
try {
+1 -1
View File
@@ -10,7 +10,7 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) });
return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.defaultRuntimeImage, deletion: hostDeletionStatus(host.id) });
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
@@ -1,77 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getServer } from '$lib/server/fleet/servers.js';
import { config } from '$lib/server/config.js';
import { tcpProbeAll } from '$lib/server/integrations/netProbe.js';
/**
* Multi-target TCP reachability probe. Fires parallel connect attempts at a
* fixed matrix of candidate hosts + the configured KOMODO_ADDRESS host, so
* we can see at a glance which network paths reach anything and where
* packets die (DNS, routing, or refused-at-port).
*/
export const POST: RequestHandler = async ({ params }) => {
const name = params.name;
if (!name) return json({ ok: false, error: 'missing server name' }, { status: 400 });
const row = getServer(name);
if (!row) return json({ ok: false, error: `unknown server: ${name}` }, { status: 404 });
// Parse the configured Komodo URL to extract host+port.
let komodoHost: string | null = null;
let komodoPort: number | null = null;
if (config.komodo.address) {
try {
const u = new URL(config.komodo.address);
komodoHost = u.hostname;
komodoPort = u.port ? Number.parseInt(u.port, 10) : u.protocol === 'https:' ? 443 : 80;
} catch {
/* leave null */
}
}
const probePort = komodoPort ?? 9120;
// Build the trial matrix. Every trial hits the same target port (the
// Komodo port) via a different network path, so we can see which paths
// reach the same service.
const trials: Array<{ label: string; host: string; port: number }> = [
{ label: `127.0.0.1:${probePort} (container loopback)`, host: '127.0.0.1', port: probePort },
{ label: `host.docker.internal:${probePort} (bridge gateway alias)`, host: 'host.docker.internal', port: probePort },
{ label: `172.17.0.1:${probePort} (default bridge gateway)`, host: '172.17.0.1', port: probePort }
];
if (komodoHost && !trials.some((t) => t.host === komodoHost)) {
trials.push({
label: `${komodoHost}:${probePort} (KOMODO_ADDRESS)`,
host: komodoHost,
port: probePort
});
}
// RCON's default host is worth testing too — even though RCON is UDP,
// if TCP to the same host also fails, it's a routing/firewall issue,
// not a UDP-specific one.
const rconHost = row.rcon_host ?? config.rcon.host;
if (rconHost && !trials.some((t) => t.host === rconHost)) {
trials.push({
label: `${rconHost}:${probePort} (RCON_HOST)`,
host: rconHost,
port: probePort
});
}
const results = await tcpProbeAll(trials, config.httpTimeoutMs);
return json({
ok: true,
config: {
komodoAddress: config.komodo.address || null,
komodoHost,
komodoPort,
rconHost,
probeTimeoutMs: config.httpTimeoutMs
},
results
});
};
@@ -1,27 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { komodoResolvePortDetailed } from '$lib/server/integrations/komodo.js';
import { getServer, setServerField } from '$lib/server/fleet/servers.js';
/**
* Trigger a fresh Komodo GetDeployment lookup for a specific server and
* return the structured result — including a snippet of the response so
* the UI can render "here's where we found the port" (or "here's what
* Komodo returned; extend extractUdpPort for this shape").
*
* On success, the resolved port is written to servers.game_port so the
* next RCON attempt uses it without waiting for another sweep tick.
*/
export const POST: RequestHandler = async ({ params }) => {
const name = params.name;
if (!name) return json({ ok: false, reason: 'no-name', detail: 'missing server name' }, { status: 400 });
const row = getServer(name);
if (!row) return json({ ok: false, reason: 'no-name', detail: `unknown server: ${name}` }, { status: 404 });
const result = await komodoResolvePortDetailed(name);
if (result.ok) {
setServerField(name, 'game_port', result.port);
}
return json(result);
};
+13 -42
View File
@@ -1,59 +1,30 @@
import { json } from '@sveltejs/kit';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import { listServers } from '$lib/server/fleet/servers.js';
import { listServerKeys } from '$lib/server/fleet/server-keys.js';
import { serverObservation } from '$lib/server/fleet/status.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { tcpProbe } from '$lib/server/integrations/netProbe.js';
import type { SetupCheck, SystemStatus } from '$lib/system-status.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { listLibraries } from '$lib/server/game-files/index.js';
export async function GET({ url }: { url: URL }) {
export async function GET() {
const servers = listServers();
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
const savedKeys = listServerKeys().length;
const hosts = listHosts();
if (hosts.length || !komodoConfigured()) {
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
}
const missing = [!config.komodo.address && 'KOMODO_ADDRESS', !config.komodo.apiKey && 'KOMODO_API_KEY', !config.komodo.apiSecret && 'KOMODO_API_SECRET'].filter(Boolean);
const connection: SetupCheck = { id: 'komodo', title: 'Komodo connection', state: komodoConfigured() ? 'unverified' : 'unconfigured', detail: komodoConfigured() ? 'Credentials configured. Run connection check to verify host reachability.' : `Set ${missing.join(', ')} in the app environment.`, href: '#setup-guide', action: 'Setup instructions' };
if (komodoConfigured() && url.searchParams.get('probe') === '1') {
try {
const address = new URL(config.komodo.address);
if (!['http:', 'https:'].includes(address.protocol)) throw new Error('invalid address');
const result = await Promise.race([
tcpProbe('Komodo', address.hostname, Number(address.port || (address.protocol === 'https:' ? 443 : 80)), 3000),
new Promise<null>(resolve => setTimeout(() => resolve(null), 4000))
]);
const reachable = result?.outcome === 'connected';
connection.state = reachable ? 'ready' : 'attention';
connection.detail = reachable ? 'Host reachable. This network check does not validate API credentials; deployment previews check launch prerequisites.' : 'Host unreachable from Electric Cherry. Check the address, DNS, port, and network access.';
} catch { connection.state = 'attention'; connection.detail = 'Komodo address is invalid or could not be reached. Check KOMODO_ADDRESS.'; }
}
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
connection,
{ id: 'target', title: 'Deployment host', state: config.komodo.targetServer ? 'ready' : 'unconfigured', detail: config.komodo.targetServer ? `New servers target ${config.komodo.targetServer}.` : 'Set KOMODO_TARGET_SERVER to the exact host name shown in Komodo.', href: '#setup-guide', action: 'Setup instructions' },
{ id: 'rcon', title: 'Game server connection', state: !rconConfigured() ? 'unconfigured' : rconServers ? 'ready' : 'unverified', detail: !rconConfigured() ? 'Set RCON_PASSWORD to the password used by your game servers.' : rconServers ? `${rconServers} servers have fresh successful RCON reports.` : 'RCON password configured. A running server is needed to verify a response.', href: servers.length ? '/' : '#setup-guide', action: servers.length ? 'Review fleet' : 'Setup instructions' },
{ id: 'game-files', title: 'Game files on the host', state: 'unverified', detail: 'T5 and T6 game-file volumes must be populated on the Komodo host. Their contents cannot be verified from this app.', href: '#game-files', action: 'Game-file instructions' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Key presence does not confirm validity with Plutonium.`, href: '/server-keys', action: savedKeys ? 'Manage keys' : 'Add a key' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Servers are registered but none has a fresh healthy report. Open diagnostics to investigate.' : 'Create your first server after completing the setup checks.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'T5 / T6 game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
];
const body: SystemStatus = { checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys };
return json(body, { headers: { 'cache-control': 'no-store' } });
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
}
+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;