From 3b4298d777fbef794d3972e93826b185619f8e4e Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 2 Jul 2026 03:25:57 +0800 Subject: [PATCH] refactor(panel): unify servers cockpit layout, resolve duplicate pages and adjust spacing --- panel/src/App.tsx | 16 +- .../src/i18n/resources/en-US/navigation.json | 7 +- .../src/i18n/resources/zh-CN/navigation.json | 7 +- panel/src/lib/nav.test.ts | 4 +- panel/src/lib/nav.ts | 13 +- panel/src/lib/viewmode.test.ts | 38 ++-- panel/src/lib/viewmode.ts | 1 - panel/src/pages/MyServers.tsx | 163 -------------- panel/src/pages/admin/ServerAdmin.tsx | 69 ------ .../ServersPage.tsx} | 198 ++++++++++-------- 10 files changed, 139 insertions(+), 377 deletions(-) delete mode 100644 panel/src/pages/MyServers.tsx delete mode 100644 panel/src/pages/admin/ServerAdmin.tsx rename panel/src/pages/{ops/FleetTable.tsx => servers/ServersPage.tsx} (73%) diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 40217f5..b25c3bc 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -7,14 +7,12 @@ import { RequireAuth } from "@/components/RequireAuth"; import { Login } from "@/pages/Login"; import { ChangePassword } from "@/pages/ChangePassword"; import { Dashboard } from "@/pages/Dashboard"; -import { MyServers } from "@/pages/MyServers"; +import { ServersPage } from "@/pages/servers/ServersPage"; import { ServerConsole } from "@/pages/ServerConsole"; import { ServerPlayers } from "@/pages/ServerPlayers"; import { ServerBackups } from "@/pages/ServerBackups"; import { Account } from "@/pages/Account"; -import { ServerAdmin } from "@/pages/admin/ServerAdmin"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; -import { FleetTable } from "@/pages/ops/FleetTable"; // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // / User-Side — app-tier, every authenticated principal @@ -41,7 +39,7 @@ export default function App() { }> {/* User-Side — app-tier */} } /> - } /> + } /> } /> } /> } /> @@ -51,18 +49,10 @@ export default function App() { /admin has no landing page; redirect to the first concrete view so the section root and any stale bookmarks land somewhere useful. */} }> - } /> - } /> + } /> } /> - {/* SysAdmin-Side — admin-tier (platform observability). - Same redirect treatment as /admin — fleet is the meaningful view. */} - }> - } /> - } /> - - } /> diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json index 422926a..3d738b9 100644 --- a/panel/src/i18n/resources/en-US/navigation.json +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -1,10 +1,7 @@ { "dashboard": "Dashboard", - "my_servers": "My servers", + "my_servers": "Servers", "account": "Account", "admin_section": "Admin", - "admin_servers": "Servers", - "admin_images": "Images", - "sysadmin_section": "SysAdmin", - "sysadmin_fleet": "Fleet" + "admin_images": "Images" } diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json index 99848c7..7d17b03 100644 --- a/panel/src/i18n/resources/zh-CN/navigation.json +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -1,10 +1,7 @@ { "dashboard": "仪表盘", - "my_servers": "我的服务器", + "my_servers": "服务器", "account": "账户", "admin_section": "管理", - "admin_servers": "服务器", - "admin_images": "镜像", - "sysadmin_section": "系统管理", - "sysadmin_fleet": "全平台服务器" + "admin_images": "镜像" } diff --git a/panel/src/lib/nav.test.ts b/panel/src/lib/nav.test.ts index 69d3df5..23af294 100644 --- a/panel/src/lib/nav.test.ts +++ b/panel/src/lib/nav.test.ts @@ -19,9 +19,9 @@ describe("visibleSections", () => { expect(visibleSections(false)[0].id).toBe("user"); }); - it("shows User-Side, Admin-Side and SysAdmin-Side to an admin", () => { + it("shows User-Side and Admin-Side to an admin", () => { const ids = visibleSections(true).map((s) => s.id); - expect(ids).toEqual(["user", "admin", "ops"]); + expect(ids).toEqual(["user", "admin"]); }); it("keeps the User-Side section ungated so it survives both branches", () => { diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index 45cfce3..1ae3f48 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -2,9 +2,7 @@ import { LayoutDashboard, Server, UserRound, - ServerCog, Boxes, - Network, type LucideIcon, } from "lucide-react"; @@ -28,7 +26,7 @@ export interface NavItem { } export interface NavSection { - id: "user" | "admin" | "ops"; + id: "user" | "admin"; /** Section heading key; null renders no heading (User-Side flat list). */ titleKey: string | null; /** When true the section is shown only to admins (is_admin === true). */ @@ -52,18 +50,9 @@ export const NAV_SECTIONS: NavSection[] = [ titleKey: "admin_section", adminOnly: true, items: [ - { to: "/admin/servers", key: "admin_servers", icon: ServerCog }, { to: "/admin/images", key: "admin_images", icon: Boxes }, ], }, - { - id: "ops", - titleKey: "sysadmin_section", - adminOnly: true, - items: [ - { to: "/ops/fleet", key: "sysadmin_fleet", icon: Network }, - ], - }, ]; /** diff --git a/panel/src/lib/viewmode.test.ts b/panel/src/lib/viewmode.test.ts index 9e02971..ef0bea8 100644 --- a/panel/src/lib/viewmode.test.ts +++ b/panel/src/lib/viewmode.test.ts @@ -30,32 +30,30 @@ describe("availableViewModes", () => { }); it("offers an admin every home, ordered least- to most-revealing", () => { - expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]); + expect(availableViewModes(true)).toEqual(["user", "admin"]); }); it("returns a fresh array so a caller cannot mutate the canonical list", () => { const a = availableViewModes(true); a.push("user"); - expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]); + expect(availableViewModes(true)).toEqual(["user", "admin"]); }); }); describe("VIEW_MODES", () => { - it("is the three homes ordered by how much they reveal", () => { - expect(VIEW_MODES).toEqual(["user", "admin", "ops"]); + it("is the two homes ordered by how much they reveal", () => { + expect(VIEW_MODES).toEqual(["user", "admin"]); }); }); describe("effectiveViewMode (fail-closed resolution)", () => { it("collapses any admin-level request from a non-admin to user", () => { expect(effectiveViewMode("admin", false)).toBe("user"); - expect(effectiveViewMode("ops", false)).toBe("user"); }); it("honours an admin's request for any home they are entitled to", () => { expect(effectiveViewMode("user", true)).toBe("user"); expect(effectiveViewMode("admin", true)).toBe("admin"); - expect(effectiveViewMode("ops", true)).toBe("ops"); }); it("defaults a null/undefined request to the user home for either tier", () => { @@ -76,17 +74,17 @@ describe("parseViewMode (shape only, no gating)", () => { it("accepts each known home verbatim", () => { expect(parseViewMode("user")).toBe("user"); expect(parseViewMode("admin")).toBe("admin"); - expect(parseViewMode("ops")).toBe("ops"); }); it("rejects anything that is not a known home, returning null", () => { + expect(parseViewMode("ops")).toBeNull(); expect(parseViewMode("operator")).toBeNull(); expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose expect(parseViewMode("")).toBeNull(); expect(parseViewMode(null)).toBeNull(); expect(parseViewMode(undefined)).toBeNull(); expect(parseViewMode(2)).toBeNull(); - expect(parseViewMode({ mode: "ops" })).toBeNull(); + expect(parseViewMode({ mode: "admin" })).toBeNull(); }); }); @@ -96,22 +94,18 @@ describe("restoreViewMode (the persisted-value re-gate — escalation vector)", // every load. The contract is that it is re-gated against the LIVE flag every // time and never trusted on its own. - it("honours a stored admin/ops home only while the principal is still an admin", () => { - expect(restoreViewMode("ops", true)).toBe("ops"); + it("honours a stored admin home only while the principal is still an admin", () => { expect(restoreViewMode("admin", true)).toBe("admin"); expect(restoreViewMode("user", true)).toBe("user"); }); - it("collapses a stored admin/ops home to user for a non-admin (stale or tampered)", () => { - // A hand-edited localStorage "ops" on a non-admin account must NOT grant the - // SysAdmin view; and a once-admin who has since been demoted re-reads as user. - expect(restoreViewMode("ops", false)).toBe("user"); + it("collapses a stored admin home to user for a non-admin (stale or tampered)", () => { + // An admin who has since been demoted re-reads as user. expect(restoreViewMode("admin", false)).toBe("user"); }); - it("collapses to user while the admin flag is still fail-closed false (loading)", () => { - // Until /me resolves, isAdmin is false; a restored "ops" must wait at user, not - // flash the SysAdmin home and then yank it back. + it("collapses a garbage stored value to user even for an admin", () => { + expect(restoreViewMode("ops", true)).toBe("user"); expect(restoreViewMode("ops", false)).toBe("user"); }); @@ -125,7 +119,7 @@ describe("restoreViewMode (the persisted-value re-gate — escalation vector)", describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { it("shows a non-admin only the User-Side regardless of the requested view", () => { - for (const v of ["user", "admin", "ops"] as ViewMode[]) { + for (const v of ["user", "admin"] as ViewMode[]) { expect(sectionsForView(v, false).map((s) => s.id)).toEqual(["user"]); } }); @@ -133,11 +127,6 @@ describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { it("foregrounds homes up to the chosen ceiling for an admin", () => { expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]); expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]); - expect(sectionsForView("ops", true).map((s) => s.id)).toEqual([ - "user", - "admin", - "ops", - ]); }); it("lets an admin step DOWN to the User-home and see only User-Side", () => { @@ -146,7 +135,6 @@ describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { const ids = sectionsForView("user", true).map((s) => s.id); expect(ids).toEqual(["user"]); expect(ids).not.toContain("admin"); - expect(ids).not.toContain("ops"); }); it("never returns more than visibleSections already permits (subset invariant)", () => { @@ -154,7 +142,7 @@ describe("sectionsForView (UX ceiling, composed on visibleSections)", () => { // be a subset of visibleSections(isAdmin) — it can never widen access. for (const isAdmin of [true, false]) { const permitted = new Set(visibleSections(isAdmin).map((s) => s.id)); - for (const v of ["user", "admin", "ops"] as ViewMode[]) { + for (const v of ["user", "admin"] as ViewMode[]) { for (const s of sectionsForView(v, isAdmin)) { expect(permitted.has(s.id)).toBe(true); } diff --git a/panel/src/lib/viewmode.ts b/panel/src/lib/viewmode.ts index e81ea87..d3f3dd6 100644 --- a/panel/src/lib/viewmode.ts +++ b/panel/src/lib/viewmode.ts @@ -36,7 +36,6 @@ export type ViewMode = NavSection["id"]; const VIEW_RANK: Record = { user: 0, admin: 1, - ops: 2, }; /** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is diff --git a/panel/src/pages/MyServers.tsx b/panel/src/pages/MyServers.tsx deleted file mode 100644 index a90f595..0000000 --- a/panel/src/pages/MyServers.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useState } from "react"; -import { Server, Search, X } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { ServerCard } from "@/components/ServerCard"; -import { Pagination } from "@/components/Pagination"; -import { Loading, ErrorState, EmptyState } from "@/components/States"; -import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; -import { api } from "@/lib/api"; -import { useAsync, useConfig } from "@/lib/hooks"; -import { hostFor } from "@/lib/config"; - -const PAGE_SIZE = 12; - -export function MyServers() { - const cfg = useConfig(); - const { data, error, loading, reload } = useAsync(() => api.myServers(), []); - const { t } = useTranslation("servers"); - const servers = data ?? []; - const [page, setPage] = useState(1); - const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - - const filtered = servers.filter((s) => { - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase().trim(); - const host = cfg ? hostFor(s.subdomain, cfg).toLowerCase() : ""; - const name = s.name.toLowerCase(); - const displayName = (s.displayName || "").toLowerCase(); - if (!name.includes(q) && !displayName.includes(q) && !host.includes(q)) { - return false; - } - } - if (statusFilter !== "all") { - if (s.phase !== statusFilter) { - return false; - } - } - return true; - }); - - const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - return ( -
-
- -
-

{t("my_servers_title")}

-

- {t("my_servers_subtitle")} -

-
-
- - {servers.length > 0 && cfg && ( -
-
- - { - setSearchQuery(e.target.value); - setPage(1); - }} - autoComplete="off" - spellCheck={false} - /> - {searchQuery && ( - - )} -
- - - {filtered.length === servers.length - ? t("servers_count", { count: servers.length }) - : t("servers_count_filtered", { - shown: filtered.length, - total: servers.length, - })} - -
- )} - - {loading && !data ? ( - - ) : error ? ( - - ) : !cfg ? ( - - ) : servers.length === 0 ? ( - - ) : filtered.length === 0 ? ( -
-

{t("search_no_match")}

- -
- ) : ( -
- {paged.map((s) => ( - - ))} -
- )} - - {totalPages > 1 && ( -
- { setPage(p); if (p > totalPages) setPage(totalPages); }} - /> -
- )} -
- ); -} diff --git a/panel/src/pages/admin/ServerAdmin.tsx b/panel/src/pages/admin/ServerAdmin.tsx deleted file mode 100644 index e7102b9..0000000 --- a/panel/src/pages/admin/ServerAdmin.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import { ServerCog } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { ServerCard } from "@/components/ServerCard"; -import { Pagination } from "@/components/Pagination"; -import { CreateServerDialog } from "@/components/CreateServerDialog"; -import { Loading, ErrorState, EmptyState } from "@/components/States"; -import { api } from "@/lib/api"; -import { useAsync, useConfig } from "@/lib/hooks"; - -const PAGE_SIZE = 12; - -export function ServerAdmin() { - const cfg = useConfig(); - const { data, error, loading, reload } = useAsync(() => api.myServers(), []); - const { t } = useTranslation("admin"); - const { t: ts } = useTranslation("servers"); - const servers = data ?? []; - const [page, setPage] = useState(1); - const totalPages = Math.max(1, Math.ceil(servers.length / PAGE_SIZE)); - const paged = servers.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - - return ( - <> -
-
- -
-

{t("servers")}

-

- {t("server_admin_subtitle")} -

-
-
- {cfg && } -
- - {loading && !data ? ( - - ) : error ? ( - - ) : !cfg ? ( - - ) : servers.length === 0 ? ( - - ) : ( -
- {paged.map((s) => ( - - ))} -
- )} - - {servers.length > 0 && ( -
- { setPage(p); if (p > totalPages) setPage(totalPages); }} - /> -
- )} - - ); -} diff --git a/panel/src/pages/ops/FleetTable.tsx b/panel/src/pages/servers/ServersPage.tsx similarity index 73% rename from panel/src/pages/ops/FleetTable.tsx rename to panel/src/pages/servers/ServersPage.tsx index 24673b0..cf9bfa6 100644 --- a/panel/src/pages/ops/FleetTable.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -27,24 +27,17 @@ import { import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge"; import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Pagination } from "@/components/Pagination"; +import { CreateServerDialog } from "@/components/CreateServerDialog"; import { api, humanizeError } from "@/lib/api"; import { useAsync, useConfig } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; import { hostFor, type RuntimeConfig } from "@/lib/config"; import { matchScore } from "@/lib/fuzzy"; -import type { AutostartPolicy, FleetServer, Phase } from "@/lib/types"; +import type { AutostartPolicy, FleetServer, Phase, ServerInfo } 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", @@ -60,19 +53,27 @@ const POLICY_KEY: Record = { 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; +const PAGE_SIZE = 6; + +interface UnifiedServer { + name: string; + subdomain: string; + phase: Phase; + ready: boolean; + desiredState?: "Running" | "Stopped"; + autostartPolicy?: AutostartPolicy; + playersOnline: number; + playersMax: number; + owner?: string; + endpointAddress?: string | null; + claimable?: boolean; + owned?: boolean; +} -/** 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, @@ -97,26 +98,26 @@ function Stat({
-
{value}
-
{label}
+
{value}
+
{label}
); } -export function FleetTable() { - const { t } = useTranslation("ops"); +export function ServersPage() { + const { t } = useTranslation(["ops", "servers"]); + const { isAdmin } = useTier(); const cfg = useConfig(); - const { data, error, loading, reload } = useAsync(() => api.fleet(), []); - const servers = useMemo(() => data ?? [], [data]); + + const fetchFn = useMemo(() => (isAdmin ? api.fleet : api.myServers), [isAdmin]); + const { data, error, loading, reload } = useAsync(fetchFn, [fetchFn]); const [query, setQuery] = useState(""); const [phaseFilter, setPhaseFilter] = useState("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(); @@ -131,8 +132,38 @@ export function FleetTable() { }; }, [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 servers = useMemo(() => { + if (!data) return []; + if (isAdmin) { + return (data as FleetServer[]).map((s) => ({ + name: s.name, + subdomain: s.subdomain, + phase: s.phase, + ready: s.ready, + desiredState: s.desiredState, + autostartPolicy: s.autostartPolicy, + playersOnline: s.playersOnline, + playersMax: s.playersMax, + owner: s.owner, + endpointAddress: s.endpointAddress, + })); + } else { + return (data as ServerInfo[]).map((s) => ({ + name: s.name, + subdomain: s.subdomain, + phase: s.phase, + ready: s.phase === "Running", + desiredState: s.desiredState, + autostartPolicy: s.autostartPolicy, + playersOnline: s.players ?? 0, + playersMax: s.maxPlayers ?? 0, + owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined, + claimable: s.claimable, + owned: s.owned, + })); + } + }, [data, isAdmin, t]); + const stats = useMemo(() => { const counts: Record = { Running: 0, @@ -145,8 +176,6 @@ export function FleetTable() { 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; @@ -155,26 +184,20 @@ export function FleetTable() { 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; + const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || s.phase === phaseFilter; if (terms.length === 0) return servers.filter(phaseOk); - const scored: { s: FleetServer; score: number }[] = []; + const scored: { s: UnifiedServer; 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); @@ -184,16 +207,20 @@ export function FleetTable() { return (
- {/* Title row — always visible; the live pill + manual refresh live here. */}
-
-

{t("fleet_title")}

-

{t("fleet_subtitle")}

+
+

+ {isAdmin ? t("fleet_title") : t("servers:my_servers_title")} +

+

+ {isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")} +

+ {isAdmin && cfg && } {t("fleet_live")} @@ -218,7 +245,6 @@ export function FleetTable() { ) : ( <> - {/* A poll failed but we still have rows — keep the table, warn inline. */} {error && (
@@ -226,7 +252,7 @@ export function FleetTable() {
)} - {/* Aggregate header — true cluster-wide rollup. */} + {/* Stats Cards in a full grid row */}
- {/* Phase distribution bar + legend. */} + {/* Distribution card in a full grid row */} {stats.total > 0 && ( -
+
{t("fleet_distribution")}
@@ -287,7 +313,7 @@ export function FleetTable() { )} - {/* Controls — search + phase filter + result count. */} + {/* Independent Search & Filter bar */}
@@ -297,7 +323,7 @@ export function FleetTable() { setQuery(e.target.value); setPage(1); }} - placeholder={t("fleet_search_placeholder")} + placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")} className="pl-9" />
@@ -330,24 +356,27 @@ export function FleetTable() {
- {/* The table. */} {servers.length === 0 ? ( - + ) : visible.length === 0 ? ( ) : ( <> - + {/* Clean Table Card */} +
- + {isAdmin && } @@ -358,7 +387,13 @@ export function FleetTable() { {paged.map((s) => ( - + ))}
{t("fleet_col_status")} {t("fleet_col_server")}{t("fleet_col_owner")}{t("fleet_col_owner")}{t("fleet_col_players")} {t("fleet_col_policy")} {t("fleet_col_endpoint")}
@@ -381,18 +416,15 @@ export function FleetTable() { ); } -/** 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({ +function ServerRow({ server, cfg, + isAdmin, onChanged, }: { - server: FleetServer; + server: UnifiedServer; cfg: RuntimeConfig; + isAdmin: boolean; onChanged: () => void; }) { const { t } = useTranslation("ops"); @@ -415,17 +447,15 @@ function FleetRow({ 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 ( <> - + - +
{server.name}
{host && ( )} - - {server.owner ? ( - - - - {server.owner} + {isAdmin && ( + + {server.owner ? ( + + + + {server.owner} + - - ) : ( - {t("fleet_unclaimed")} - )} - - + ) : ( + {t("fleet_unclaimed")} + )} + + )} + {live ? ( @@ -462,7 +494,7 @@ function FleetRow({ — )} - + {server.autostartPolicy ? ( {t(POLICY_KEY[server.autostartPolicy])} @@ -471,7 +503,7 @@ function FleetRow({ — )} - + {endpoint ? ( {endpoint} @@ -502,7 +534,7 @@ function FleetRow({ )} - @@ -511,7 +543,7 @@ function FleetRow({ {error && ( - + {error} @@ -519,3 +551,5 @@ function FleetRow({ ); } + +export default ServersPage;