refactor(panel): unify servers cockpit layout, resolve duplicate pages and adjust spacing

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:51 +08:00
1 parent 6e43a466e0
commit 3b4298d777
10 files changed
+129 -367

No files matched your search

+3 -13
View File
@@ -7,14 +7,12 @@ import { RequireAuth } from "@/components/RequireAuth";
import { Login } from "@/pages/Login"; import { Login } from "@/pages/Login";
import { ChangePassword } from "@/pages/ChangePassword"; import { ChangePassword } from "@/pages/ChangePassword";
import { Dashboard } from "@/pages/Dashboard"; import { Dashboard } from "@/pages/Dashboard";
import { MyServers } from "@/pages/MyServers"; import { ServersPage } from "@/pages/servers/ServersPage";
import { ServerConsole } from "@/pages/ServerConsole"; import { ServerConsole } from "@/pages/ServerConsole";
import { ServerPlayers } from "@/pages/ServerPlayers"; import { ServerPlayers } from "@/pages/ServerPlayers";
import { ServerBackups } from "@/pages/ServerBackups"; import { ServerBackups } from "@/pages/ServerBackups";
import { Account } from "@/pages/Account"; import { Account } from "@/pages/Account";
import { ServerAdmin } from "@/pages/admin/ServerAdmin";
import { ImageAdmin } from "@/pages/admin/ImageAdmin"; import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { FleetTable } from "@/pages/ops/FleetTable";
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal // / User-Side — app-tier, every authenticated principal
@@ -41,7 +39,7 @@ export default function App() {
<Route element={<AppShell />}> <Route element={<AppShell />}>
{/* User-Side — app-tier */} {/* User-Side — app-tier */}
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="servers" element={<MyServers />} /> <Route path="servers" element={<ServersPage />} />
<Route path="servers/:name" element={<ServerConsole />} /> <Route path="servers/:name" element={<ServerConsole />} />
<Route path="servers/:name/players" element={<ServerPlayers />} /> <Route path="servers/:name/players" element={<ServerPlayers />} />
<Route path="servers/:name/backups" element={<ServerBackups />} /> <Route path="servers/:name/backups" element={<ServerBackups />} />
@@ -51,18 +49,10 @@ export default function App() {
/admin has no landing page; redirect to the first concrete view /admin has no landing page; redirect to the first concrete view
so the section root and any stale bookmarks land somewhere useful. */} so the section root and any stale bookmarks land somewhere useful. */}
<Route path="admin" element={<RequireAdmin />}> <Route path="admin" element={<RequireAdmin />}>
<Route index element={<Navigate to="/admin/servers" replace />} /> <Route index element={<Navigate to="/admin/images" replace />} />
<Route path="servers" element={<ServerAdmin />} />
<Route path="images" element={<ImageAdmin />} /> <Route path="images" element={<ImageAdmin />} />
</Route> </Route>
{/* SysAdmin-Side — admin-tier (platform observability).
Same redirect treatment as /admin — fleet is the meaningful view. */}
<Route path="ops" element={<RequireAdmin />}>
<Route index element={<Navigate to="/ops/fleet" replace />} />
<Route path="fleet" element={<FleetTable />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Route> </Route>
@@ -1,10 +1,7 @@
{ {
"dashboard": "Dashboard", "dashboard": "Dashboard",
"my_servers": "My servers", "my_servers": "Servers",
"account": "Account", "account": "Account",
"admin_section": "Admin", "admin_section": "Admin",
"admin_servers": "Servers", "admin_images": "Images"
"admin_images": "Images",
"sysadmin_section": "SysAdmin",
"sysadmin_fleet": "Fleet"
} }
@@ -1,10 +1,7 @@
{ {
"dashboard": "仪表盘", "dashboard": "仪表盘",
"my_servers": "我的服务器", "my_servers": "服务器",
"account": "账户", "account": "账户",
"admin_section": "管理", "admin_section": "管理",
"admin_servers": "服务器", "admin_images": "镜像"
"admin_images": "镜像",
"sysadmin_section": "系统管理",
"sysadmin_fleet": "全平台服务器"
} }
+2 -2
View File
@@ -19,9 +19,9 @@ describe("visibleSections", () => {
expect(visibleSections(false)[0].id).toBe("user"); 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); 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", () => { it("keeps the User-Side section ungated so it survives both branches", () => {
+1 -12
View File
@@ -2,9 +2,7 @@ import {
LayoutDashboard, LayoutDashboard,
Server, Server,
UserRound, UserRound,
ServerCog,
Boxes, Boxes,
Network,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -28,7 +26,7 @@ export interface NavItem {
} }
export interface NavSection { export interface NavSection {
id: "user" | "admin" | "ops"; id: "user" | "admin";
/** Section heading key; null renders no heading (User-Side flat list). */ /** Section heading key; null renders no heading (User-Side flat list). */
titleKey: string | null; titleKey: string | null;
/** When true the section is shown only to admins (is_admin === true). */ /** When true the section is shown only to admins (is_admin === true). */
@@ -52,18 +50,9 @@ export const NAV_SECTIONS: NavSection[] = [
titleKey: "admin_section", titleKey: "admin_section",
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: "/admin/servers", key: "admin_servers", icon: ServerCog },
{ to: "/admin/images", key: "admin_images", icon: Boxes }, { to: "/admin/images", key: "admin_images", icon: Boxes },
], ],
}, },
{
id: "ops",
titleKey: "sysadmin_section",
adminOnly: true,
items: [
{ to: "/ops/fleet", key: "sysadmin_fleet", icon: Network },
],
},
]; ];
/** /**
+13 -25
View File
@@ -30,32 +30,30 @@ describe("availableViewModes", () => {
}); });
it("offers an admin every home, ordered least- to most-revealing", () => { 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", () => { it("returns a fresh array so a caller cannot mutate the canonical list", () => {
const a = availableViewModes(true); const a = availableViewModes(true);
a.push("user"); a.push("user");
expect(availableViewModes(true)).toEqual(["user", "admin", "ops"]); expect(availableViewModes(true)).toEqual(["user", "admin"]);
}); });
}); });
describe("VIEW_MODES", () => { describe("VIEW_MODES", () => {
it("is the three homes ordered by how much they reveal", () => { it("is the two homes ordered by how much they reveal", () => {
expect(VIEW_MODES).toEqual(["user", "admin", "ops"]); expect(VIEW_MODES).toEqual(["user", "admin"]);
}); });
}); });
describe("effectiveViewMode (fail-closed resolution)", () => { describe("effectiveViewMode (fail-closed resolution)", () => {
it("collapses any admin-level request from a non-admin to user", () => { it("collapses any admin-level request from a non-admin to user", () => {
expect(effectiveViewMode("admin", false)).toBe("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", () => { it("honours an admin's request for any home they are entitled to", () => {
expect(effectiveViewMode("user", true)).toBe("user"); expect(effectiveViewMode("user", true)).toBe("user");
expect(effectiveViewMode("admin", true)).toBe("admin"); 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", () => { 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", () => { it("accepts each known home verbatim", () => {
expect(parseViewMode("user")).toBe("user"); expect(parseViewMode("user")).toBe("user");
expect(parseViewMode("admin")).toBe("admin"); expect(parseViewMode("admin")).toBe("admin");
expect(parseViewMode("ops")).toBe("ops");
}); });
it("rejects anything that is not a known home, returning null", () => { it("rejects anything that is not a known home, returning null", () => {
expect(parseViewMode("ops")).toBeNull();
expect(parseViewMode("operator")).toBeNull(); expect(parseViewMode("operator")).toBeNull();
expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose expect(parseViewMode("Admin")).toBeNull(); // case-sensitive on purpose
expect(parseViewMode("")).toBeNull(); expect(parseViewMode("")).toBeNull();
expect(parseViewMode(null)).toBeNull(); expect(parseViewMode(null)).toBeNull();
expect(parseViewMode(undefined)).toBeNull(); expect(parseViewMode(undefined)).toBeNull();
expect(parseViewMode(2)).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 // every load. The contract is that it is re-gated against the LIVE flag every
// time and never trusted on its own. // time and never trusted on its own.
it("honours a stored admin/ops home only while the principal is still an admin", () => { it("honours a stored admin home only while the principal is still an admin", () => {
expect(restoreViewMode("ops", true)).toBe("ops");
expect(restoreViewMode("admin", true)).toBe("admin"); expect(restoreViewMode("admin", true)).toBe("admin");
expect(restoreViewMode("user", true)).toBe("user"); expect(restoreViewMode("user", true)).toBe("user");
}); });
it("collapses a stored admin/ops home to user for a non-admin (stale or tampered)", () => { it("collapses a stored admin home to user for a non-admin (stale or tampered)", () => {
// A hand-edited localStorage "ops" on a non-admin account must NOT grant the // An admin who has since been demoted re-reads as user.
// SysAdmin view; and a once-admin who has since been demoted re-reads as user.
expect(restoreViewMode("ops", false)).toBe("user");
expect(restoreViewMode("admin", false)).toBe("user"); expect(restoreViewMode("admin", false)).toBe("user");
}); });
it("collapses to user while the admin flag is still fail-closed false (loading)", () => { it("collapses a garbage stored value to user even for an admin", () => {
// Until /me resolves, isAdmin is false; a restored "ops" must wait at user, not expect(restoreViewMode("ops", true)).toBe("user");
// flash the SysAdmin home and then yank it back.
expect(restoreViewMode("ops", false)).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)", () => { describe("sectionsForView (UX ceiling, composed on visibleSections)", () => {
it("shows a non-admin only the User-Side regardless of the requested view", () => { 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"]); 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", () => { it("foregrounds homes up to the chosen ceiling for an admin", () => {
expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]); expect(sectionsForView("user", true).map((s) => s.id)).toEqual(["user"]);
expect(sectionsForView("admin", true).map((s) => s.id)).toEqual(["user", "admin"]); 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", () => { 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); const ids = sectionsForView("user", true).map((s) => s.id);
expect(ids).toEqual(["user"]); expect(ids).toEqual(["user"]);
expect(ids).not.toContain("admin"); expect(ids).not.toContain("admin");
expect(ids).not.toContain("ops");
}); });
it("never returns more than visibleSections already permits (subset invariant)", () => { 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. // be a subset of visibleSections(isAdmin) — it can never widen access.
for (const isAdmin of [true, false]) { for (const isAdmin of [true, false]) {
const permitted = new Set(visibleSections(isAdmin).map((s) => s.id)); 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)) { for (const s of sectionsForView(v, isAdmin)) {
expect(permitted.has(s.id)).toBe(true); expect(permitted.has(s.id)).toBe(true);
} }
-1
View File
@@ -36,7 +36,6 @@ export type ViewMode = NavSection["id"];
const VIEW_RANK: Record<ViewMode, number> = { const VIEW_RANK: Record<ViewMode, number> = {
user: 0, user: 0,
admin: 1, admin: 1,
ops: 2,
}; };
/** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is /** VIEW_MODES lists every home, ordered by how much it reveals (User → Ops). It is
-163
View File
@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Server className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("my_servers_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("my_servers_subtitle")}
</p>
</div>
</div>
{servers.length > 0 && cfg && (
<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
placeholder={t("search_placeholder")}
className="pl-9 pr-9"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setPage(1);
}}
autoComplete="off"
spellCheck={false}
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
>
<SelectTrigger className="w-44">
<SelectValue placeholder={t("filter_status_all")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter_status_all")}</SelectItem>
<SelectItem value="Running">{t("phase_running")}</SelectItem>
<SelectItem value="Starting">{t("phase_starting")}</SelectItem>
<SelectItem value="Stopping">{t("phase_stopping")}</SelectItem>
<SelectItem value="Stopped">{t("phase_stopped")}</SelectItem>
<SelectItem value="Failed">{t("phase_failed")}</SelectItem>
</SelectContent>
</Select>
<span className="shrink-0 text-xs text-muted-foreground">
{filtered.length === servers.length
? t("servers_count", { count: servers.length })
: t("servers_count_filtered", {
shown: filtered.length,
total: servers.length,
})}
</span>
</div>
)}
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label={t("common:loading_config")} />
) : servers.length === 0 ? (
<EmptyState
title={t("no_servers_linked")}
hint={t("no_servers_hint")}
/>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center border border-dashed rounded-lg border-border bg-card/50">
<p className="text-sm font-medium text-foreground">{t("search_no_match")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => {
setSearchQuery("");
setStatusFilter("all");
setPage(1);
}}
>
{t("search_clear_btn")}
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{paged.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</div>
)}
{totalPages > 1 && (
<div className="pt-3">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={filtered.length}
onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }}
/>
</div>
)}
</div>
);
}
-69
View File
@@ -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 (
<>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<ServerCog className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("servers")}</h1>
<p className="text-sm text-muted-foreground">
{t("server_admin_subtitle")}
</p>
</div>
</div>
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label={ts("common:loading_config")} />
) : servers.length === 0 ? (
<EmptyState
title={ts("no_servers_managed")}
hint={ts("no_servers_managed_hint")}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{paged.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</div>
)}
{servers.length > 0 && (
<div className="pt-3">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={servers.length}
onChange={(p) => { setPage(p); if (p > totalPages) setPage(totalPages); }}
/>
</div>
)}
</>
);
}
@@ -27,24 +27,17 @@ import {
import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge"; import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks"; import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config"; import { hostFor, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy"; 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"; 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; 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[] = [ const PHASES: Phase[] = [
"Running", "Running",
"Starting", "Starting",
@@ -60,19 +53,27 @@ const POLICY_KEY: Record<AutostartPolicy, string> = {
allowlist: "policy_allowlist", 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 { function isLive(phase: Phase): boolean {
return phase === "Running" || phase === "Starting" || phase === "Stopping"; return phase === "Running" || phase === "Starting" || phase === "Stopping";
} }
// The fleet is platform-wide — the longest list in the panel — so it paginates const PAGE_SIZE = 6;
// 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. interface UnifiedServer {
const PAGE_SIZE = 10; 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({ function Stat({
icon: Icon, icon: Icon,
label, label,
@@ -97,26 +98,26 @@ function Stat({
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-2xl font-semibold leading-none tabular-nums">{value}</div> <div className="text-2xl font-semibold leading-none tabular-nums text-left">{value}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{label}</div> <div className="mt-1.5 truncate text-xs text-muted-foreground text-left">{label}</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
export function FleetTable() { export function ServersPage() {
const { t } = useTranslation("ops"); const { t } = useTranslation(["ops", "servers"]);
const { isAdmin } = useTier();
const cfg = useConfig(); 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 [query, setQuery] = useState("");
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all"); const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
const [page, setPage] = useState(1); 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(() => { useEffect(() => {
const id = window.setInterval(() => { const id = window.setInterval(() => {
if (!document.hidden) reload(); if (!document.hidden) reload();
@@ -131,8 +132,38 @@ export function FleetTable() {
}; };
}, [reload]); }, [reload]);
// Aggregates are computed over the WHOLE fleet, never the filtered view — the const servers = useMemo<UnifiedServer[]>(() => {
// header is a true cluster-wide rollup, the filters only narrow the table. 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 stats = useMemo(() => {
const counts: Record<Phase, number> = { const counts: Record<Phase, number> = {
Running: 0, Running: 0,
@@ -145,8 +176,6 @@ export function FleetTable() {
let playersOnline = 0; let playersOnline = 0;
let playersMax = 0; let playersMax = 0;
for (const s of servers) { 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]++; if (counts[s.phase as Phase] !== undefined) counts[s.phase as Phase]++;
else counts.Unknown++; else counts.Unknown++;
playersOnline += s.playersOnline ?? 0; playersOnline += s.playersOnline ?? 0;
@@ -155,26 +184,20 @@ export function FleetTable() {
return { counts, playersOnline, playersMax, total: servers.length }; return { counts, playersOnline, playersMax, total: servers.length };
}, [servers]); }, [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 visible = useMemo(() => {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); 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); 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) { for (const s of servers) {
if (!phaseOk(s)) continue; if (!phaseOk(s)) continue;
const score = matchScore([s.name, s.subdomain ?? "", s.owner ?? ""], terms); const score = matchScore([s.name, s.subdomain ?? "", s.owner ?? ""], terms);
if (score >= 0) scored.push({ s, score }); if (score >= 0) scored.push({ s, score });
} }
// Stable sort: ties keep their natural (insertion) order.
scored.sort((a, b) => b.score - a.score); scored.sort((a, b) => b.score - a.score);
return scored.map((x) => x.s); return scored.map((x) => x.s);
}, [servers, query, phaseFilter]); }, [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 totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages); const safePage = Math.min(page, totalPages);
const paged = visible.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); const paged = visible.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
@@ -184,16 +207,20 @@ export function FleetTable() {
return ( return (
<div className="space-y-6"> <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 flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" /> <Network className="h-6 w-6 text-primary" />
<div> <div className="text-left">
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1> <h1 className="text-2xl font-semibold tracking-tight">
<p className="text-sm text-muted-foreground">{t("fleet_subtitle")}</p> {isAdmin ? t("fleet_title") : t("servers:my_servers_title")}
</h1>
<p className="text-sm text-muted-foreground">
{isAdmin ? t("fleet_subtitle") : t("servers:my_servers_subtitle")}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isAdmin && cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <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" /> <span className="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{t("fleet_live")} {t("fleet_live")}
@@ -218,7 +245,6 @@ export function FleetTable() {
<Loading /> <Loading />
) : ( ) : (
<> <>
{/* A poll failed but we still have rows — keep the table, warn inline. */}
{error && ( {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"> <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" /> <AlertTriangle className="h-3.5 w-3.5 shrink-0" />
@@ -226,7 +252,7 @@ export function FleetTable() {
</div> </div>
)} )}
{/* Aggregate header — true cluster-wide rollup. */} {/* Stats Cards in a full grid row */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <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={Server} label={t("fleet_stat_total")} value={stats.total} />
<Stat <Stat
@@ -248,11 +274,11 @@ export function FleetTable() {
/> />
</div> </div>
{/* Phase distribution bar + legend. */} {/* Distribution card in a full grid row */}
{stats.total > 0 && ( {stats.total > 0 && (
<Card> <Card>
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-3 p-4">
<div className="text-xs font-medium text-muted-foreground"> <div className="text-xs font-medium text-muted-foreground text-left">
{t("fleet_distribution")} {t("fleet_distribution")}
</div> </div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-secondary"> <div className="flex h-2.5 w-full overflow-hidden rounded-full bg-secondary">
@@ -287,7 +313,7 @@ export function FleetTable() {
</Card> </Card>
)} )}
{/* Controls — search + phase filter + result count. */} {/* Independent Search & Filter bar */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[14rem] flex-1"> <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" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
@@ -297,7 +323,7 @@ export function FleetTable() {
setQuery(e.target.value); setQuery(e.target.value);
setPage(1); setPage(1);
}} }}
placeholder={t("fleet_search_placeholder")} placeholder={isAdmin ? t("fleet_search_placeholder") : t("servers:search_placeholder")}
className="pl-9" className="pl-9"
/> />
</div> </div>
@@ -330,24 +356,27 @@ export function FleetTable() {
</span> </span>
</div> </div>
{/* The table. */}
{servers.length === 0 ? ( {servers.length === 0 ? (
<EmptyState title={t("fleet_empty_title")} hint={t("fleet_empty_hint")} /> <EmptyState
title={isAdmin ? t("fleet_empty_title") : t("servers:no_servers_linked")}
hint={isAdmin ? t("fleet_empty_hint") : t("servers:no_servers_hint")}
/>
) : visible.length === 0 ? ( ) : visible.length === 0 ? (
<EmptyState <EmptyState
title={t("fleet_no_match_title")} title={isAdmin ? t("fleet_no_match_title") : t("servers:search_no_match")}
hint={t("fleet_no_match_hint")} hint={isAdmin ? t("fleet_no_match_hint") : t("servers:search_clear_btn")}
/> />
) : ( ) : (
<> <>
<Card className="overflow-hidden"> {/* Clean Table Card */}
<Card className="overflow-hidden border border-border/80">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[56rem] border-collapse text-sm"> <table className="w-full min-w-[56rem] border-collapse text-sm">
<thead> <thead>
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground"> <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_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_server")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_owner")}</th> {isAdmin && <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_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_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th> <th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>
@@ -358,7 +387,13 @@ export function FleetTable() {
</thead> </thead>
<tbody> <tbody>
{paged.map((s) => ( {paged.map((s) => (
<FleetRow key={s.name} server={s} cfg={cfg} onChanged={reload} /> <ServerRow
key={s.name}
server={s}
cfg={cfg}
isAdmin={isAdmin}
onChanged={reload}
/>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -381,18 +416,15 @@ export function FleetTable() {
); );
} }
/** FleetRow is one server's row. Its busy/error state is LOCAL, and the row is function ServerRow({
* 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, server,
cfg, cfg,
isAdmin,
onChanged, onChanged,
}: { }: {
server: FleetServer; server: UnifiedServer;
cfg: RuntimeConfig; cfg: RuntimeConfig;
isAdmin: boolean;
onChanged: () => void; onChanged: () => void;
}) { }) {
const { t } = useTranslation("ops"); const { t } = useTranslation("ops");
@@ -415,17 +447,15 @@ function FleetRow({
const live = isLive(server.phase); const live = isLive(server.phase);
const host = server.subdomain ? hostFor(server.subdomain, cfg) : ""; 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; const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
return ( return (
<> <>
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40"> <tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle text-left">
<PhaseBadge phase={server.phase} /> <PhaseBadge phase={server.phase} />
</td> </td>
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle text-left">
<div className="font-medium text-foreground">{server.name}</div> <div className="font-medium text-foreground">{server.name}</div>
{host && ( {host && (
<a <a
@@ -439,7 +469,8 @@ function FleetRow({
</a> </a>
)} )}
</td> </td>
<td className="px-4 py-3 align-middle"> {isAdmin && (
<td className="px-4 py-3 align-middle text-left">
{server.owner ? ( {server.owner ? (
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate"> <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" /> <UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
@@ -451,7 +482,8 @@ function FleetRow({
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span> <span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
)} )}
</td> </td>
<td className="px-4 py-3 align-middle tabular-nums"> )}
<td className="px-4 py-3 align-middle tabular-nums text-left">
{live ? ( {live ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" /> <Users className="h-3.5 w-3.5 text-muted-foreground" />
@@ -462,7 +494,7 @@ function FleetRow({
<span className="text-muted-foreground">—</span> <span className="text-muted-foreground">—</span>
)} )}
</td> </td>
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle text-left">
{server.autostartPolicy ? ( {server.autostartPolicy ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{t(POLICY_KEY[server.autostartPolicy])} {t(POLICY_KEY[server.autostartPolicy])}
@@ -471,7 +503,7 @@ function FleetRow({
<span className="text-muted-foreground">—</span> <span className="text-muted-foreground">—</span>
)} )}
</td> </td>
<td className="px-4 py-3 align-middle"> <td className="px-4 py-3 align-middle text-left">
{endpoint ? ( {endpoint ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{endpoint} {endpoint}
@@ -502,7 +534,7 @@ function FleetRow({
</Button> </Button>
)} )}
<Link to={`/servers/${server.name}`}> <Link to={`/servers/${server.name}`}>
<Button size="sm" variant="ghost"> <Button size="sm" variant="outline">
<Terminal /> {ts("console")} <Terminal /> {ts("console")}
</Button> </Button>
</Link> </Link>
@@ -511,7 +543,7 @@ function FleetRow({
</tr> </tr>
{error && ( {error && (
<tr className="bg-destructive/5"> <tr className="bg-destructive/5">
<td colSpan={7} className="px-4 py-1.5 text-xs text-destructive"> <td colSpan={isAdmin ? 7 : 6} className="px-4 py-1.5 text-xs text-destructive text-left">
{error} {error}
</td> </td>
</tr> </tr>
@@ -519,3 +551,5 @@ function FleetRow({
</> </>
); );
} }
export default ServersPage;