Diagnose network button: raw TCP probe matrix

The Komodo probe answers a specific question (can we resolve a port from
Komodo's API), but leaves ambiguity when it fails: is DNS broken, is the
route blocked, is the target port unreachable, or is the target refusing?
A 10-second timeout looks the same for all of those.

Adds a second button, "diagnose network", that fires parallel raw TCP
connect attempts (no HTTP, no auth, no bytes sent) at a fixed matrix of
candidate hosts against the configured Komodo port:

  - 127.0.0.1  (sanity — container loopback)
  - host.docker.internal  (bridge-gateway alias)
  - 172.17.0.1  (raw default bridge gateway)
  - the KOMODO_ADDRESS host (if different)
  - RCON_HOST (if different)

Each row shows resolved IP, outcome (connected/refused/timeout/dns-fail/
unreachable/error), elapsed ms, and detail. Together they pinpoint which
network layer the traffic dies at without guessing.

New helper: src/lib/server/integrations/netProbe.ts — DNS lookup + net.
createConnection with explicit timeout, classifying errors by errno
(ECONNREFUSED, EHOSTUNREACH, ENOTFOUND). Runs trials in parallel so the
whole diagnostic completes in one probe-timeout budget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 09:48:27 -04:00
co-authored by Claude Opus 4.7
parent 08de5a4064
commit 1e9d08f27e
3 changed files with 335 additions and 0 deletions
+165
View File
@@ -19,6 +19,29 @@
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 Server {
name: string;
komodo_type: string;
@@ -95,6 +118,8 @@
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
let diagnosing = $state(false);
async function probeKomodo(): Promise<void> {
if (probing || !name) return;
@@ -123,6 +148,22 @@
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;
}
}
</script>
{#if server}
@@ -191,6 +232,52 @@
</div>
{/if}
<div class="probe-row">
<button class="probe-btn" onclick={diagnoseNetwork} disabled={diagnosing}>
{diagnosing ? 'diagnosing…' : 'diagnose network'}
</button>
<span class="probe-hint">Raw TCP reachability probe across candidate hosts.</span>
</div>
{#if netDiagnosis}
{#if netDiagnosis.ok && netDiagnosis.results}
<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><span class="net-tag net-{r.outcome}">{r.outcome}</span></td>
<td class="mono num">{r.elapsedMs}ms</td>
<td class="mono detail-cell">{r.detail ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
{#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="probe-result probe-bad">
<span class="probe-tag bad">error</span>
<span class="probe-detail">{netDiagnosis.error ?? 'network diagnostic failed'}</span>
</div>
{/if}
{/if}
{#if komodoProbe}
{#if komodoProbe.ok}
<div class="probe-result probe-ok">
@@ -536,6 +623,84 @@
word-break: break-all;
}
.net-table {
width: 100%;
margin-top: 14px;
border-collapse: separate;
border-spacing: 0;
background: var(--cherry-975);
border: 1px solid var(--cherry-700);
border-radius: var(--r-sm);
overflow: hidden;
font-size: 12px;
}
.net-table thead th {
text-align: left;
padding: 8px 12px;
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--ink-500);
border-bottom: 1px solid var(--cherry-700);
background: var(--cherry-925);
}
.net-table tbody td {
padding: 8px 12px;
border-bottom: 1px solid rgba(74, 15, 30, 0.4);
color: var(--ink-100);
vertical-align: top;
}
.net-table tbody tr:last-child td { border-bottom: none; }
.net-table .mono { font-family: var(--font-mono); }
.net-table .num { text-align: right; }
.net-table .detail-cell {
color: var(--ink-300);
font-size: 11px;
word-break: break-word;
max-width: 320px;
}
.net-tag {
display: inline-block;
padding: 2px 8px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
border: 1px solid transparent;
}
.net-connected {
background: rgba(34, 224, 255, 0.15);
border-color: rgba(34, 224, 255, 0.4);
color: var(--spark-500);
}
.net-refused {
background: rgba(255, 176, 32, 0.15);
border-color: rgba(255, 176, 32, 0.4);
color: var(--amber-500);
}
.net-timeout, .net-unreachable, .net-dns-fail, .net-error {
background: rgba(255, 48, 80, 0.15);
border-color: rgba(255, 48, 80, 0.4);
color: var(--danger-500);
}
.net-meta {
margin-top: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--ink-500);
}
.net-meta code {
color: var(--cream);
background: var(--cherry-875);
padding: 1px 6px;
border-radius: var(--r-sm);
}
.snippet-details {
margin-top: 10px;
font-family: var(--font-mono);