feat(panel): fleet

This commit is contained in:
Lemon-miaow committed 2026-07-01 03:46:07 +08:00
1 parent 742f15f348
commit d2de11af22
14 files changed
+978 -38

No files matched your search

+28 -3
View File
@@ -7,7 +7,32 @@
"footer_kubectl": "kubectl / CRD operations",
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
"fleet_title": "Fleet",
"fleet_subtitle": "Every server on the platform — phase, owner, capacity.",
"fleet_table_pending_note": "The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships.",
"ops_overview_pending_note": "A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers."
"fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.",
"fleet_live": "Live",
"fleet_refresh": "Refresh now",
"fleet_stat_total": "Total servers",
"fleet_stat_running": "Running",
"fleet_stat_players": "Online / capacity",
"fleet_stat_failed": "Failed",
"fleet_distribution": "Phase distribution",
"fleet_search_placeholder": "Search name, subdomain or owner…",
"fleet_filter_all": "All phases",
"fleet_count": "{{count}} servers",
"fleet_count_filtered": "{{shown}} / {{total}} servers",
"fleet_col_status": "Status",
"fleet_col_server": "Server",
"fleet_col_owner": "Owner",
"fleet_col_players": "Players",
"fleet_col_policy": "Autostart",
"fleet_col_endpoint": "Endpoint",
"fleet_col_actions": "Actions",
"fleet_unclaimed": "Unclaimed",
"fleet_endpoint_idle": "Not running",
"policy_owneronly": "Owner only",
"policy_public": "Public",
"policy_allowlist": "Allowlist",
"fleet_empty_title": "No servers",
"fleet_empty_hint": "No MinecraftServer exists in the cluster yet.",
"fleet_no_match_title": "No matches",
"fleet_no_match_hint": "No server matches the current search or filter."
}
+28 -3
View File
@@ -7,7 +7,32 @@
"footer_kubectl": "kubectl / CRD",
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
"fleet_title": "全平台服务器",
"fleet_subtitle": "平台所有服务器——运行状态、所有者、资源用量。",
"fleet_table_pending_note": "全平台服务器列表会读取所有服务器的状态(不限于你拥有的)。该管理员层接口为下一个待开发的后端模块,上线后数据将在此呈现。",
"ops_overview_pending_note": "全平台服务器读取(所有服务器,非仅个人拥有)支撑集群汇总及 3D 视图。该管理员层接口尚待开发,与用户层 GET /me/servers 不同。"
"fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。",
"fleet_live": "实时刷新",
"fleet_refresh": "立即刷新",
"fleet_stat_total": "服务器总数",
"fleet_stat_running": "运行中",
"fleet_stat_players": "在线 / 容量",
"fleet_stat_failed": "异常",
"fleet_distribution": "状态分布",
"fleet_search_placeholder": "搜索名称、域名或所有者…",
"fleet_filter_all": "全部状态",
"fleet_count": "共 {{count}} 台",
"fleet_count_filtered": "{{shown}} / {{total}} 台",
"fleet_col_status": "状态",
"fleet_col_server": "服务器",
"fleet_col_owner": "所有者",
"fleet_col_players": "在线",
"fleet_col_policy": "自启策略",
"fleet_col_endpoint": "连接地址",
"fleet_col_actions": "操作",
"fleet_unclaimed": "未认领",
"fleet_endpoint_idle": "未运行",
"policy_owneronly": "仅所有者",
"policy_public": "公开",
"policy_allowlist": "白名单",
"fleet_empty_title": "暂无服务器",
"fleet_empty_hint": "集群中尚未创建任何 MinecraftServer。",
"fleet_no_match_title": "无匹配结果",
"fleet_no_match_hint": "没有服务器符合当前的搜索或筛选条件。"
}
+44
View File
@@ -151,3 +151,47 @@ describe("local-password auth wire shapes", () => {
expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i);
});
});
// Pin the GET /fleet wire shape (the SysAdmin cockpit's read). It is the ONLY
// place the panel asserts the fleetServerView fields: the cockpit consumes the raw
// CRD names (playersOnline/playersMax, not players/maxPlayers) plus the joined
// `owner`, all crossing the untyped fetch().json() boundary. If the Go handler's
// JSON ever drifts to the /me/servers shape — or drops owner — typecheck/build stay
// green while the table silently renders blank players and "unclaimed" everywhere.
describe("api.fleet wire shape", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("GETs /fleet and unwraps .servers with the CRD field names + owner", async () => {
const fetchSpy = fakeFetch({
servers: [
{
name: "survival",
subdomain: "survival",
phase: "Running",
ready: true,
playersOnline: 3,
playersMax: 20,
owner: "[email protected]",
},
],
});
vi.stubGlobal("fetch", fetchSpy);
const servers = await api.fleet();
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/fleet");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
expect(servers).toHaveLength(1);
expect(servers[0].playersOnline).toBe(3);
expect(servers[0].playersMax).toBe(20);
expect(servers[0].owner).toBe("[email protected]");
});
it("defaults to [] when the body carries no servers key", async () => {
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
expect(await api.fleet()).toEqual([]);
});
});
+8
View File
@@ -1,6 +1,7 @@
import type {
ApiError,
CreateServerRequest,
FleetServer,
Identity,
LinkResult,
LinkStatus,
@@ -80,6 +81,13 @@ export const api = {
myServers: () =>
request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
// fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every
// server's CRD lifecycle view plus its owner. It 403s for a non-admin principal —
// the panel only renders the cockpit link behind is_admin, and the route guards
// again server-side regardless of what the UI shows.
fleet: () =>
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
status: (name: string) => request<ServerInfo>("GET", `/servers/${name}/status`),
wake: (name: string) =>
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
import { fuzzyScore, matchScore } from "./fuzzy";
describe("fuzzyScore", () => {
it("matches a contiguous substring and scores it far above a subsequence", () => {
const substring = fuzzyScore("survival", "surv");
const subsequence = fuzzyScore("survival", "srv");
expect(substring).toBeGreaterThan(0);
expect(subsequence).toBeGreaterThan(0);
// "surv" is contiguous (substring) → must outrank the "srv" subsequence.
expect(substring).toBeGreaterThan(subsequence);
});
it("is case-insensitive", () => {
expect(fuzzyScore("Survival SMP", "smp")).toBeGreaterThan(0);
expect(fuzzyScore("survival", "SURV")).toBeGreaterThan(0);
});
it("matches a non-contiguous subsequence (srv → survival)", () => {
expect(fuzzyScore("survival", "srv")).toBeGreaterThan(0);
expect(fuzzyScore("survival", "sl")).toBeGreaterThan(0);
});
it("returns -1 when a query char is absent or out of order", () => {
expect(fuzzyScore("creative", "surv")).toBe(-1); // no 's' in creative
expect(fuzzyScore("survival", "lavivrus")).toBe(-1); // right chars, wrong order
});
it("ranks an earlier / word-boundary hit above a later one", () => {
// "lab" at the start of the second word beats "ab" buried mid-word.
const boundary = fuzzyScore("creative lab", "lab");
const buried = fuzzyScore("xlabx", "lab");
expect(boundary).toBeGreaterThan(buried);
});
it("treats an empty query as a neutral match and empty text as no match", () => {
expect(fuzzyScore("anything", "")).toBe(0);
expect(fuzzyScore("", "x")).toBe(-1);
});
});
describe("matchScore", () => {
const fields = ["survival", "survival", "[email protected]"];
it("requires every term to hit some field (AND across terms)", () => {
// Both "owner" (owner field) and "surv" (name) match → included.
expect(matchScore(fields, ["owner", "surv"])).toBeGreaterThan(0);
// "owner" matches but "zzz" matches nothing → the whole record is rejected.
expect(matchScore(fields, ["owner", "zzz"])).toBe(-1);
});
it("matches a term against any field (OR across fields)", () => {
expect(matchScore(fields, ["mock"])).toBeGreaterThan(0); // only in the owner field
expect(matchScore(fields, ["survival"])).toBeGreaterThan(0); // only in the name field
});
it("sums per-term best scores, so more matched terms rank higher", () => {
const one = matchScore(fields, ["surv"]);
const two = matchScore(fields, ["surv", "owner"]);
expect(two).toBeGreaterThan(one);
});
});
+55
View File
@@ -0,0 +1,55 @@
// A dependency-free fuzzy matcher for client-side list search (the SysAdmin fleet
// table, and reusable elsewhere). Two levels: fuzzyScore ranks one string against
// one query; matchScore ranks a record (several fields) against a multi-term query.
/** fuzzyScore ranks how well `query` matches `text`, case-insensitively. It returns
* a score (higher = better) or -1 for no match. A contiguous substring scores
* highest (earlier / at a word boundary is better); failing that, a subsequence
* match — the query's chars appear in order but not necessarily adjacent, so "srv"
* matches "survival" — still matches, scored by how tight and early the run is.
* This is the model fuzzy finders (fzf, command palettes) use. */
export function fuzzyScore(text: string, query: string): number {
const t = text.toLowerCase();
const q = query.toLowerCase();
if (!q) return 0;
if (!t) return -1;
const idx = t.indexOf(q);
if (idx !== -1) {
// Substring hit dominates: base 1000, earlier is better, plus a word-start
// bonus (start of string or just after a separator like -, _, ., @, /).
const atBoundary = idx === 0 || /[-_.@/\s]/.test(t[idx - 1]);
return 1000 - idx + (atBoundary ? 100 : 0);
}
// Subsequence fallback: every query char must appear in order; reward adjacency.
let cursor = 0;
let score = 0;
let run = 0;
for (const ch of q) {
const found = t.indexOf(ch, cursor);
if (found === -1) return -1;
run = found === cursor ? run + 1 : 0;
score += 1 + run * 2;
cursor = found + 1;
}
return score;
}
/** matchScore is a record's overall relevance for a multi-term query, across its
* searchable `fields`. EVERY term must hit at least one field (AND across terms,
* OR across fields) — so "owner surv" finds a server named survival owned by
* owner@… — and the score is the sum of each term's best field score. Returns -1
* if any term fails to match any field. `terms` is expected pre-split + lowercased
* (callers usually `query.trim().toLowerCase().split(/\s+/).filter(Boolean)`). */
export function matchScore(fields: string[], terms: string[]): number {
let total = 0;
for (const term of terms) {
let best = -1;
for (const f of fields) {
const s = fuzzyScore(f, term);
if (s > best) best = s;
}
if (best < 0) return -1;
total += best;
}
return total;
}
+26
View File
@@ -34,6 +34,32 @@ export interface ServerInfo {
owned?: boolean;
}
/** FleetServer is one row of GET /api/v1/fleet — the SysAdmin cockpit's fleet-wide
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and
* the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers
* projection. Sharing one interface would silently read `undefined` across the
* fetch().json() boundary for every renamed field. */
export interface FleetServer {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
/** Owner's display identity (email, or username when the address is absent).
* Empty/absent for an unclaimed server or when the best-effort owner lookup
* failed — the cockpit renders that as "unclaimed". */
owner?: string;
}
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
image_ref: string;
+510 -18
View File
@@ -1,29 +1,521 @@
import { Network } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
Network,
Play,
Square,
Terminal,
ExternalLink,
Search,
RefreshCw,
Server,
Users,
AlertTriangle,
UserRound,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { PendingBackend } from "@/components/States";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
// The cockpit polls the fleet read on a fixed interval. It is a list, not a single
// server, so there is no SSE — a short poll is the right tool. The refresh is
// SILENT: the table is only torn down for the very first load (loading && !data);
// every poll swaps the rows underneath, so the table never blinks (a 10s flash is
// the quickest way to fail "好看"). The tick is skipped while the tab is hidden so
// a backgrounded cockpit makes no requests, and one fires on re-focus to catch up.
const REFRESH_MS = 10_000;
// The phases offered in the filter, in lifecycle order. Mirrors the Phase union;
// labels come from the shared servers:phase_* keys (via PHASE_KEY) so the filter,
// the badges and the legend always read identically.
const PHASES: Phase[] = [
"Running",
"Starting",
"Stopping",
"Stopped",
"Failed",
"Unknown",
];
const POLICY_KEY: Record<AutostartPolicy, string> = {
ownerOnly: "policy_owneronly",
public: "policy_public",
allowlist: "policy_allowlist",
};
// A phase is "live" (a pod is up or in flight) when it is Running or transitioning.
// The Start/Stop affordance and the players/endpoint columns all key off this.
function isLive(phase: Phase): boolean {
return phase === "Running" || phase === "Starting" || phase === "Stopping";
}
// The fleet is platform-wide — the longest list in the panel — so it paginates
// client-side over the already-fetched set (no server round-trip per page). Kept
// short so the table fits a screen without a wall of scrolling.
const PAGE_SIZE = 10;
/** Stat is one headline tile of the aggregate header. `accent` colours the icon
* chip so Running reads green and Failed reads red at a glance. */
function Stat({
icon: Icon,
label,
value,
accent,
}: {
icon: typeof Server;
label: string;
value: number | string;
accent?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div
className="rounded-md p-2"
style={{
backgroundColor: accent ? `${accent}26` : undefined,
color: accent,
}}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-2xl font-semibold leading-none tabular-nums">{value}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
);
}
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with
// phase + capacity, distinct from the User-Side "My servers". It depends on the
// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend
// slice. Honest placeholder until that lands — no fabricated rows.
export function FleetTable() {
const { t } = useTranslation("ops");
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.fleet(), []);
const servers = useMemo(() => data ?? [], [data]);
const [query, setQuery] = useState("");
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
const [page, setPage] = useState(1);
// Silent auto-refresh: skip while hidden, catch up on re-focus. `reload` is
// stable (useAsync memoises it on empty deps), so this effect mounts once.
useEffect(() => {
const id = window.setInterval(() => {
if (!document.hidden) reload();
}, REFRESH_MS);
const onVisible = () => {
if (!document.hidden) reload();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener("visibilitychange", onVisible);
};
}, [reload]);
// Aggregates are computed over the WHOLE fleet, never the filtered view — the
// header is a true cluster-wide rollup, the filters only narrow the table.
const stats = useMemo(() => {
const counts: Record<Phase, number> = {
Running: 0,
Starting: 0,
Stopping: 0,
Stopped: 0,
Failed: 0,
Unknown: 0,
};
let playersOnline = 0;
let playersMax = 0;
for (const s of servers) {
// A phase outside the modelled union folds into Unknown (it crosses an
// unvalidated JSON boundary), matching how the badge degrades.
if (counts[s.phase as Phase] !== undefined) counts[s.phase as Phase]++;
else counts.Unknown++;
playersOnline += s.playersOnline ?? 0;
playersMax += s.playersMax ?? 0;
}
return { counts, playersOnline, playersMax, total: servers.length };
}, [servers]);
// The visible list: phase-filtered, then fuzzy-matched and ranked by relevance
// when there is a query (best matches first); natural order otherwise.
const visible = useMemo(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const phaseOk = (s: FleetServer) => phaseFilter === "all" || s.phase === phaseFilter;
if (terms.length === 0) return servers.filter(phaseOk);
const scored: { s: FleetServer; score: number }[] = [];
for (const s of servers) {
if (!phaseOk(s)) continue;
const score = matchScore([s.name, s.subdomain ?? "", s.owner ?? ""], terms);
if (score >= 0) scored.push({ s, score });
}
// Stable sort: ties keep their natural (insertion) order.
scored.sort((a, b) => b.score - a.score);
return scored.map((x) => x.s);
}, [servers, query, phaseFilter]);
// Pagination is derived, not stored: clamp the page against the current result
// count so a shrinking list (a filter, or a poll that removed rows) can never
// strand the view on an empty page.
const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paged = visible.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const showInitialLoading = loading && !data;
const showInitialError = !!error && !data;
return (
<>
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("fleet_subtitle")}
</p>
<div className="space-y-6">
{/* Title row — always visible; the live pill + manual refresh live here. */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground">{t("fleet_subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{t("fleet_live")}
</span>
<Button
variant="outline"
size="sm"
onClick={reload}
disabled={loading}
title={t("fleet_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
<PendingBackend
endpoint="GET /api/v1/admin/servers"
note={t("fleet_table_pending_note")}
/>
{showInitialLoading ? (
<Loading />
) : showInitialError ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading />
) : (
<>
{/* A poll failed but we still have rows — keep the table, warn inline. */}
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{humanizeError(error)}
</div>
)}
{/* Aggregate header — true cluster-wide rollup. */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Stat icon={Server} label={t("fleet_stat_total")} value={stats.total} />
<Stat
icon={Play}
label={t("fleet_stat_running")}
value={stats.counts.Running}
accent={PHASE_COLOR.Running}
/>
<Stat
icon={Users}
label={t("fleet_stat_players")}
value={`${stats.playersOnline} / ${stats.playersMax}`}
/>
<Stat
icon={AlertTriangle}
label={t("fleet_stat_failed")}
value={stats.counts.Failed}
accent={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined}
/>
</div>
{/* Phase distribution bar + legend. */}
{stats.total > 0 && (
<Card>
<CardContent className="space-y-3 p-4">
<div className="text-xs font-medium text-muted-foreground">
{t("fleet_distribution")}
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-secondary">
{PHASES.map((p) =>
stats.counts[p] > 0 ? (
<div
key={p}
className="h-full transition-all"
style={{
width: `${(stats.counts[p] / stats.total) * 100}%`,
backgroundColor: PHASE_COLOR[p],
}}
/>
) : null,
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{PHASES.filter((p) => stats.counts[p] > 0).map((p) => (
<span key={p} className="inline-flex items-center gap-1.5 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: PHASE_COLOR[p] }}
/>
<span className="text-muted-foreground">{t(PHASE_KEY[p])}</span>
<span className="font-mono font-semibold text-foreground">
{stats.counts[p]}
</span>
</span>
))}
</div>
</CardContent>
</Card>
)}
{/* Controls — search + phase filter + result count. */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[14rem] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder={t("fleet_search_placeholder")}
className="pl-9"
/>
</div>
<Select
value={phaseFilter}
onValueChange={(v) => {
setPhaseFilter(v as Phase | "all");
setPage(1);
}}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("fleet_filter_all")}</SelectItem>
{PHASES.map((p) => (
<SelectItem key={p} value={p}>
{t(PHASE_KEY[p])}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-xs text-muted-foreground">
{visible.length === servers.length
? t("fleet_count", { count: servers.length })
: t("fleet_count_filtered", {
shown: visible.length,
total: servers.length,
})}
</span>
</div>
{/* The table. */}
{servers.length === 0 ? (
<EmptyState title={t("fleet_empty_title")} hint={t("fleet_empty_hint")} />
) : visible.length === 0 ? (
<EmptyState
title={t("fleet_no_match_title")}
hint={t("fleet_no_match_hint")}
/>
) : (
<>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[56rem] border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2.5 font-medium">{t("fleet_col_status")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_server")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_players")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>
<th className="px-4 py-2.5 text-right font-medium">
{t("fleet_col_actions")}
</th>
</tr>
</thead>
<tbody>
{paged.map((s) => (
<FleetRow key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</tbody>
</table>
</div>
</Card>
{totalPages > 1 && (
<Pagination
page={safePage}
pageSize={PAGE_SIZE}
total={visible.length}
onChange={setPage}
/>
)}
</>
)}
</>
)}
</div>
);
}
/** FleetRow is one server's row. Its busy/error state is LOCAL, and the row is
* keyed by name in the parent, so a background poll that swaps `data` re-renders
* the table without unmounting the row — an in-flight Start/Stop survives the
* refresh. Admins may operate ANY server (the wake/stop handlers bypass the owner
* check for an admin principal), so the action column is unconditional. */
function FleetRow({
server,
cfg,
onChanged,
}: {
server: FleetServer;
cfg: RuntimeConfig;
onChanged: () => void;
}) {
const { t } = useTranslation("ops");
const { t: ts } = useTranslation("servers");
const [busy, setBusy] = useState<null | "wake" | "stop">(null);
const [error, setError] = useState<string | null>(null);
async function act(kind: "wake" | "stop", fn: () => Promise<unknown>) {
setBusy(kind);
setError(null);
try {
await fn();
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(null);
}
}
const live = isLive(server.phase);
const host = server.subdomain ? hostFor(server.subdomain, cfg) : "";
// An endpoint address is only meaningful while the server is actually serving —
// a Failed/Stopped server can carry a stale address, so it is gated on `ready`.
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
return (
<>
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
<td className="px-4 py-3 align-middle">
<PhaseBadge phase={server.phase} />
</td>
<td className="px-4 py-3 align-middle">
<div className="font-medium text-foreground">{server.name}</div>
{host && (
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{host}
<ExternalLink className="h-3 w-3" />
</a>
)}
</td>
<td className="px-4 py-3 align-middle">
{server.owner ? (
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate" title={server.owner}>
{server.owner}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
)}
</td>
<td className="px-4 py-3 align-middle tabular-nums">
{live ? (
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
{server.playersOnline}
<span className="text-muted-foreground">/ {server.playersMax}</span>
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
{server.autostartPolicy ? (
<span className="text-xs text-muted-foreground">
{t(POLICY_KEY[server.autostartPolicy])}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
{endpoint ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{endpoint}
</code>
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_endpoint_idle")}</span>
)}
</td>
<td className="px-4 py-3 align-middle">
<div className="flex items-center justify-end gap-2">
{live ? (
<Button
size="sm"
variant="destructive"
disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))}
>
<Square /> {busy === "stop" ? ts("stopping") : ts("stop")}
</Button>
) : (
<Button
size="sm"
variant="default"
disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))}
>
<Play /> {busy === "wake" ? ts("waking") : ts("wake")}
</Button>
)}
<Link to={`/servers/${server.name}`}>
<Button size="sm" variant="ghost">
<Terminal /> {ts("console")}
</Button>
</Link>
</div>
</td>
</tr>
{error && (
<tr className="bg-destructive/5">
<td colSpan={7} className="px-4 py-1.5 text-xs text-destructive">
{error}
</td>
</tr>
)}
</>
);
}