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>
1015 lines
37 KiB
Svelte
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>
|
|
· RCON_HOST = <code>{netDiagnosis.config.rconHost}</code>
|
|
· 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>
|