feat(panel): fleet
This commit is contained in:
14 files changed
+978
-38
No files matched your search
@@ -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."
|
||||
}
|
||||
@@ -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": "没有服务器符合当前的搜索或筛选条件。"
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user