Files
electric-cherry/src/routes/servers/[name]/+page.svelte
T
joshandClaude Opus 4.7 13a29e5ace Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming
Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto
key in the Fleet page, EC provisions the deployment via Komodo, allocates a
port in 28960-28980, creates the 0o777 host dir, and starts the container.
Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir,
transactional DB cleanup) and surfaces any partial-failure warnings inline.

Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.).
Komodo entity names now match EC server names 1:1 (no more _/- transform), and
the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie
maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id,
image, created_by, and destroyed_at columns for provisioning + audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 18:38:42 -04:00

1015 lines
37 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { statusByServer } from '$ui/stores/servers.js';
import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js';
import StatusDot from '$ui/components/StatusDot.svelte';
import PlayerRoster from '$ui/components/PlayerRoster.svelte';
import LogPanel from '$ui/components/LogPanel.svelte';
import { gametypeLabel } from '$ui/gametype.js';
import { decodeRconError, rconFixHint } from '$ui/rconError.js';
import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js';
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 {
name: string;
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 }>;
}
interface InventoryEntry {
relPath: 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 server = $state<Server | null>(null);
let integrations = $state<Integrations | null>(null);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
let name = $derived($page.params.name ?? '');
let komodoProbe = $state<KomodoProbeResult | null>(null);
let probing = $state(false);
let netDiagnosis = $state<NetDiagnosis | null>(null);
let diagnosing = $state(false);
let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false);
let destroying = $state(false);
let destroyError = $state<string | null>(null);
let destroyWarnings = $state<string[]>([]);
async function loadServer(): Promise<void> {
const [srvRes, depRes, invRes] = await Promise.all([
fetch('/api/servers'),
fetch('/api/deployments'),
fetch(`/api/servers/${encodeURIComponent(name)}/scripts`)
]);
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
const depBody = (await depRes.json()) as { deployments: DeploymentRow[] };
server = srvBody.servers.find((s) => s.name === name) ?? null;
integrations = srvBody.integrations;
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
if (invRes.ok) {
const invBody = (await invRes.json()) as { scripts: InventoryEntry[] };
inventory = invBody.scripts;
}
}
let poll: ReturnType<typeof setInterval> | null = null;
onMount(() => {
loadServer();
poll = setInterval(loadServer, 5000);
});
onDestroy(() => {
if (poll) clearInterval(poll);
});
let live = $derived($statusByServer.get(name));
let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null);
let effectiveMap = $derived(live?.map ?? server?.last_map ?? null);
let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null);
let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null);
let effectiveHealth = $derived<Health>(live?.health ?? server?.health ?? 'offline');
let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
let effectiveErr = $derived(live?.lastError ?? server?.last_error ?? null);
let effectiveEndpoint = $derived<RconEndpoint | null>(live?.endpoint ?? server?.endpoint ?? null);
let liveReason = $derived<RconFailure | null>(live?.reason ?? null);
let decoded = $derived(decodeRconError(effectiveErr));
let effectiveReason = $derived<RconFailure | null>(liveReason ?? decoded?.reason ?? null);
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
let effectiveSnippet = $derived<string | null>(
rconProbe?.rawReply ?? live?.replySnippet ?? server?.replySnippet ?? null
);
function ago(iso: string | null): string {
if (!iso) return 'never';
const d = Date.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;
// On success, refresh the server row so the endpoint field
// updates from `unresolved` to `host:port` without a full reload.
if (komodoProbe.ok && server) {
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
integrations = srvBody.integrations;
}
} 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 destroyThisServer(): Promise<void> {
if (destroying || !name) return;
const confirmed = confirm(
`Destroy ${name}?\n\nThis stops the container, deletes the Komodo deployment, ` +
`removes the host directory, and drops all deployment history for this server. ` +
`Cannot be undone.`
);
if (!confirmed) return;
destroying = true;
destroyError = null;
destroyWarnings = [];
try {
const res = await fetch(`/api/servers/${encodeURIComponent(name)}`, { method: 'DELETE' });
const body = (await res.json()) as {
ok: boolean;
error?: string;
warnings?: string[];
};
if (!body.ok) {
destroyError = body.error ?? 'destroy failed';
destroyWarnings = body.warnings ?? [];
destroying = false;
return;
}
// Best-effort partial failures still deleted the server — proceed
// to the fleet page and let the operator see the warnings there,
// but for now just show them here if any occurred.
if (body.warnings && body.warnings.length > 0) {
destroyWarnings = body.warnings;
// Give the operator a moment to notice, then navigate.
setTimeout(() => goto('/'), 1500);
return;
}
await goto('/');
} catch (err) {
destroyError = (err as Error).message;
destroying = 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;
// Refresh the server row so the header, roster, and health decay
// reflect what the probe just wrote.
const srvRes = await fetch('/api/servers');
const srvBody = (await srvRes.json()) as { servers: Server[]; integrations: Integrations };
server = srvBody.servers.find((s) => s.name === name) ?? server;
} catch (err) {
rconProbe = { ok: false, reason: 'send-error', detail: (err as Error).message };
} finally {
probingRcon = false;
}
}
</script>
{#if server}
<section class="hero">
<div class="hero-top">
<div class="name-row">
<StatusDot health={effectiveHealth} size={12} />
<h1>{server.name}</h1>
</div>
<div class="hero-right">
<div class="player-block">
<div class="player-count" class:has-players={(effectivePlayers ?? 0) > 0}>
{effectivePlayers ?? '—'}
</div>
<div class="player-label">players</div>
</div>
<button
class="danger destroy-btn"
onclick={destroyThisServer}
disabled={destroying}
title="Stop container, delete Komodo deployment, remove host dir, drop DB rows."
>
{destroying ? 'destroying…' : 'destroy server'}
</button>
</div>
</div>
{#if destroyError}
<p class="destroy-flash">{destroyError}</p>
{/if}
{#if destroyWarnings.length > 0}
<ul class="destroy-warn">
{#each destroyWarnings as w}<li>{w}</li>{/each}
</ul>
{/if}
<div class="hero-meta">
<span class="pair"><span class="k">map</span> <span class="v">{effectiveMap ?? '—'}</span></span>
{#if gametypeLabel(effectiveGametype)}
<span class="pair"><span class="k">mode</span> <span class="v">{gametypeLabel(effectiveGametype)}</span></span>
{/if}
{#if effectiveHealth !== 'online'}
<span class="pair"><span class="k">status</span> <span class="v health-{effectiveHealth}">{effectiveHealth}</span></span>
<span class="pair"><span class="k">last seen</span> <span class="v">{ago(effectiveSeen)}</span></span>
{/if}
</div>
</section>
{#if effectiveHealth !== 'online' && integrations}
<section class="diag panel">
<h2>RCON diagnostic</h2>
<div class="diag-grid">
<div class="stat">
<div class="k">endpoint</div>
<div class="v mono">{endpointLabel(effectiveEndpoint)}</div>
</div>
<div class="stat">
<div class="k">RCON password</div>
<div class="v mono flag-{integrations.rcon ? 'ok' : 'bad'}">{integrations.rcon ? 'configured' : 'missing'}</div>
</div>
<div class="stat">
<div class="k">Komodo</div>
<div class="v mono flag-{integrations.komodo ? 'ok' : 'bad'}">{integrations.komodo ? 'configured' : 'missing'}</div>
</div>
<div class="stat">
<div class="k">timeout</div>
<div class="v mono">{integrations.rconTimeoutMs}ms</div>
</div>
</div>
{#if effectiveReason}
<div class="reason-row">
<span class="reason-tag reason-{effectiveReason}">{effectiveReason}</span>
<span class="reason-detail">{errorDetail}</span>
</div>
<div class="hint">{rconFixHint(effectiveReason)}</div>
{:else if effectiveErr}
<div class="reason-row">
<span class="reason-tag reason-legacy">error</span>
<span class="reason-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}
{#if effectiveEndpoint}
<div class="probe-row">
<button class="probe-btn" onclick={probeRcon} disabled={probingRcon}>
{probingRcon ? 'probing rcon…' : 'probe rcon'}
</button>
<span class="probe-hint">Send a fresh RCON getstatus to {effectiveEndpoint.host}:{effectiveEndpoint.port} now.</span>
</div>
{/if}
{#if rconProbe?.ok}
<div class="probe-result probe-ok">
<span class="probe-tag ok">connected</span>
<span class="probe-detail">
{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 !effectiveEndpoint && integrations.komodo}
<div class="probe-row">
<button class="probe-btn" onclick={probeKomodo} disabled={probing}>
{probing ? 'probing komodo…' : 'probe komodo'}
</button>
<span class="probe-hint">Ask Komodo for this server's UDP port now.</span>
</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">
<span class="probe-tag ok">resolved</span>
<span class="probe-detail">
Port <strong>{komodoProbe.port}</strong> cached to server row — 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="probe-result probe-bad">
<span class="probe-tag bad">{komodoProbe.reason}</span>
<span class="probe-detail">
{komodoProbe.detail}
{#if komodoProbe.httpStatus} · HTTP {komodoProbe.httpStatus}{/if}
</span>
</div>
{#if komodoProbe.url}
<div class="probe-url">
<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}
</section>
{/if}
<section class="panel">
<h2>Roster</h2>
<PlayerRoster roster={effectiveRoster} health={effectiveHealth} />
</section>
<section class="panel">
<h2>Logs</h2>
<LogPanel
server={server.name}
tabs={[
{ key: 'console', label: 'console.log' },
{ key: 'games_sp', label: 'games_sp.log' }
]}
/>
</section>
<section class="panel">
<h2>Config</h2>
<div class="stats">
<div class="stat">
<div class="k">komodo</div>
<div class="v mono">{server.komodo_type} · {server.komodo_name}</div>
</div>
<div class="stat">
<div class="k">rcon override</div>
<div class="v mono">{server.rcon_host ?? '(default)'}</div>
</div>
<div class="stat">
<div class="k">game_port</div>
<div class="v mono">{server.game_port ?? '(unresolved)'}</div>
</div>
<div class="stat">
<div class="k">game</div>
<div class="v mono">{server.game ?? '(unset)'}</div>
</div>
<div class="stat">
<div class="k">map</div>
<div class="v mono">{server.map ?? '(unset)'}</div>
</div>
{#if server.held.length > 0}
<div class="stat">
<div class="k">held by beta</div>
<div class="v mono">{server.held.map((h) => `${h.target} (beta ${h.betaId})`).join(', ')}</div>
</div>
{/if}
</div>
</section>
<section class="panel">
<h2>Scripts on this server ({inventory.length})</h2>
{#if inventory.length === 0}
<p class="empty">no scripts tracked — upload one from the Scripts page and open a beta to deploy it here</p>
{:else}
<div class="inv-list">
{#each inventory as f (f.relPath)}
<div class="inv-row">
<span class="rel mono">{f.relPath}</span>
<span class="size mono">{f.size}B</span>
<span class="src mono">{f.source ?? '—'}</span>
<span class="sha mono">{f.sha256.slice(0, 12)}</span>
</div>
{/each}
</div>
{/if}
</section>
<section class="panel">
<h2>Deployment history</h2>
{#if history.length === 0}
<p class="empty">no deployments recorded on this server</p>
{:else}
{#each history as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="row row-{mine.restart_state}" href="/deployments/{d.id}">
<span class="a">{d.action}</span>
<span class="s">{mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''}</span>
<span class="f">{mine.files_changed} file{mine.files_changed === 1 ? '' : 's'}</span>
<span class="w">{ago(d.started_at)}</span>
{#if mine.error}<span class="err">{mine.error}</span>{/if}
</a>
{/each}
{/if}
</section>
{:else}
<p class="empty">loading…</p>
{/if}
<style>
.hero {
background:
linear-gradient(180deg, var(--bg-card) 0%, var(--bg-nav) 100%);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-panel) 32px;
margin-bottom: 24px;
box-shadow: var(--shadow-panel);
position: relative;
overflow: hidden;
}
.hero::before {
content: '';
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, transparent, var(--accent), var(--spark-500), transparent);
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.name-row { display: flex; align-items: center; gap: 14px; min-width: 0; }
.hero-right { display: flex; align-items: flex-start; gap: 20px; }
.destroy-btn {
background: var(--bg-nav);
border: 1px solid rgba(255, 48, 80, 0.4);
color: var(--danger-500);
border-radius: var(--r-sm);
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
align-self: center;
}
.destroy-btn:hover:not(:disabled) {
background: rgba(255, 48, 80, 0.10);
box-shadow: 0 0 0 1px rgba(255, 48, 80, 0.4);
}
.destroy-btn:disabled { opacity: 0.5; cursor: default; }
.destroy-flash {
margin: 16px 0 0;
color: var(--danger-500);
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.destroy-warn {
margin: 12px 0 0;
padding: 10px 16px;
color: var(--amber-500);
background: rgba(255, 176, 32, 0.08);
border: 1px solid rgba(255, 176, 32, 0.3);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
list-style: disc inside;
}
h1 {
margin: 0;
font-family: var(--font-display);
font-size: var(--text-2xl);
letter-spacing: 1.5px;
color: var(--cream);
line-height: var(--leading-tight);
}
.player-block { text-align: right; }
.player-count {
font-family: var(--font-mono);
font-size: 48px;
font-weight: 500;
color: var(--ink-500);
line-height: 1;
}
.player-count.has-players {
color: var(--spark-500);
text-shadow: var(--spark-glow);
}
.player-label {
font-family: var(--font-body);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-500);
margin-top: 6px;
}
.hero-meta {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid var(--border);
font-size: var(--text-base);
}
.pair { display: flex; align-items: baseline; gap: 8px; font-family: var(--font-mono); }
.pair .k {
color: var(--ink-500);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.pair .v { color: var(--ink-100); }
.v.health-online { color: var(--spark-500); }
.v.health-stale { color: var(--amber-500); }
.v.health-offline { color: var(--danger-500); }
.panel {
background: var(--bg-nav);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: var(--pad-card);
margin-bottom: 20px;
}
.diag { border-color: rgba(255, 48, 80, 0.35); }
.diag h2 { color: var(--danger-500); }
h2 {
margin: 0 0 14px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--ink-300);
}
.stats, .diag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 12px 14px;
}
.stat .k {
color: var(--ink-500);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.stat .v { color: var(--ink-100); margin-top: 6px; font-size: var(--text-base); word-break: break-word; }
.mono { font-family: var(--font-mono); }
.flag-ok { color: var(--spark-500); }
.flag-bad { color: var(--danger-500); }
.reason-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding: 12px 16px;
background: rgba(255, 48, 80, 0.08);
border: 1px solid rgba(255, 48, 80, 0.25);
border-radius: var(--r-sm);
}
.reason-tag {
padding: 3px 10px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
border: 1px solid transparent;
flex-shrink: 0;
}
.reason-no-config { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
.reason-no-port { background: rgba(255, 176, 32, 0.15); border-color: rgba(255, 176, 32, 0.4); color: var(--amber-500); }
.reason-timeout { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
.reason-send-error { background: rgba(255, 48, 80, 0.15); border-color: rgba(255, 48, 80, 0.4); color: var(--danger-500); }
.reason-bad-reply { background: rgba(124, 192, 245, 0.10); border-color: rgba(124, 192, 245, 0.28); color: var(--cream); }
.reason-no-players { background: rgba(124, 192, 245, 0.10); border-color: rgba(124, 192, 245, 0.28); color: var(--cream); }
.reason-legacy { background: var(--bg-card); border-color: var(--border); color: var(--ink-300); }
.reason-detail {
color: var(--ink-100);
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.hint {
margin-top: 12px;
padding: 10px 16px;
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--cream-dim);
background: var(--bg-card);
border-left: 3px solid var(--accent);
border-radius: var(--r-sm);
}
.probe-row {
display: flex;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.probe-btn {
background: var(--bg-card);
color: var(--cream);
border: 1px solid var(--accent);
border-radius: var(--r-sm);
padding: 10px 18px;
font-family: var(--font-body);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.3px;
cursor: pointer;
transition: box-shadow 0.15s, background 0.15s;
}
.probe-btn:hover:not(:disabled) {
box-shadow: var(--accent-glow);
background: var(--bg-card-hi);
}
.probe-btn:disabled { opacity: 0.5; cursor: default; }
.probe-hint {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.probe-result {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 12px 16px;
border-radius: var(--r-sm);
border: 1px solid transparent;
}
.probe-ok {
background: rgba(124, 212, 255, 0.08);
border-color: rgba(124, 212, 255, 0.3);
}
.probe-bad {
background: rgba(255, 48, 80, 0.08);
border-color: rgba(255, 48, 80, 0.25);
}
.probe-tag {
padding: 3px 10px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
border: 1px solid transparent;
flex-shrink: 0;
}
.probe-tag.ok {
background: rgba(124, 212, 255, 0.15);
border-color: rgba(124, 212, 255, 0.4);
color: var(--spark-500);
}
.probe-tag.bad {
background: rgba(255, 48, 80, 0.15);
border-color: rgba(255, 48, 80, 0.4);
color: var(--danger-500);
}
.probe-detail {
color: var(--ink-100);
font-family: var(--font-mono);
font-size: var(--text-mono);
}
.probe-detail strong { color: var(--spark-500); }
.probe-url {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 10px;
padding: 8px 14px;
background: var(--bg-card);
border-radius: var(--r-sm);
border: 1px solid var(--border);
font-size: var(--text-mono-sm);
}
.probe-url .k {
color: var(--ink-500);
font-family: var(--font-body);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
}
.probe-url code {
color: var(--cream);
font-family: var(--font-mono);
word-break: break-all;
}
.net-table {
width: 100%;
margin-top: 16px;
border-collapse: separate;
border-spacing: 0;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
font-size: var(--text-mono);
}
.net-table thead th {
text-align: left;
padding: 10px 14px;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--ink-500);
border-bottom: 1px solid var(--border);
background: var(--bg-nav);
}
.net-table tbody td {
padding: 10px 14px;
border-bottom: 1px solid rgba(30, 50, 96, 0.5);
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: var(--text-mono-sm);
word-break: break-word;
max-width: 320px;
}
.net-tag {
display: inline-block;
padding: 3px 10px;
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 1px;
border: 1px solid transparent;
}
.net-connected {
background: rgba(124, 212, 255, 0.15);
border-color: rgba(124, 212, 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: 12px;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
color: var(--ink-500);
}
.net-meta code {
color: var(--cream);
background: var(--bg-card);
padding: 2px 8px;
border-radius: var(--r-sm);
}
.snippet-details {
margin-top: 12px;
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
}
.snippet-details summary {
color: var(--ink-500);
cursor: pointer;
padding: 6px 0;
}
.snippet-details summary:hover { color: var(--ink-100); }
.snippet {
margin: 12px 0 0;
padding: 14px 16px;
background: var(--bg-page);
border: 1px solid var(--border);
border-radius: var(--r-sm);
color: var(--ink-300);
font-family: var(--font-mono);
font-size: var(--text-mono-sm);
line-height: var(--leading-mono);
max-height: 440px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.row {
display: grid;
grid-template-columns: 220px 180px 110px 130px 1fr;
gap: 14px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
margin: 6px 0;
color: var(--ink-100);
text-decoration: none;
font-size: var(--text-mono);
font-family: var(--font-mono);
}
.row:hover { border-color: var(--accent); box-shadow: var(--accent-glow); }
.row-done { border-left: 3px solid var(--spark-500); }
.row-failed { border-left: 3px solid var(--danger-500); }
.row-pending { border-left: 3px solid var(--amber-500); }
.row-held { border-left: 3px solid var(--cream); }
.row-manual { border-left: 3px solid var(--ink-500); }
.a { color: var(--cream); }
.s { color: var(--ink-300); }
.f { color: var(--ink-500); }
.w { color: var(--ink-500); }
.err { color: var(--danger-500); }
.empty {
color: var(--ink-500);
font-family: var(--font-mono);
font-size: var(--text-base);
}
.inv-list { display: flex; flex-direction: column; gap: 4px; }
.inv-row {
display: grid;
grid-template-columns: 1fr 90px 160px 140px;
gap: 14px;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-sm);
font-size: var(--text-mono-sm);
align-items: center;
}
.inv-row { border-left: 3px solid var(--spark-500); }
.inv-row .rel { color: var(--cream); word-break: break-all; }
.inv-row .size, .inv-row .src, .inv-row .sha { color: var(--ink-500); }
</style>