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
+139 -377

No files matched your search

+3 -13
View File
@@ -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() {
<Route element={<AppShell />}>
{/* User-Side — app-tier */}
<Route index element={<Dashboard />} />
<Route path="servers" element={<MyServers />} />
<Route path="servers" element={<ServersPage />} />
<Route path="servers/:name" element={<ServerConsole />} />
<Route path="servers/:name/players" element={<ServerPlayers />} />
<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
so the section root and any stale bookmarks land somewhere useful. */}
<Route path="admin" element={<RequireAdmin />}>
<Route index element={<Navigate to="/admin/servers" replace />} />
<Route path="servers" element={<ServerAdmin />} />
<Route index element={<Navigate to="/admin/images" replace />} />
<Route path="images" element={<ImageAdmin />} />
</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>
</Route>
@@ -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"
}
@@ -1,10 +1,7 @@
{
"dashboard": "仪表盘",
"my_servers": "我的服务器",
"my_servers": "服务器",
"account": "账户",
"admin_section": "管理",
"admin_servers": "服务器",
"admin_images": "镜像",
"sysadmin_section": "系统管理",
"sysadmin_fleet": "全平台服务器"
"admin_images": "镜像"
}
+2 -2
View File
@@ -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", () => {
+1 -12
View File
@@ -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 },
],
},
];
/**
+13 -25
View File
@@ -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);
}
-1
View File
@@ -36,7 +36,6 @@ export type ViewMode = NavSection["id"];
const VIEW_RANK: Record<ViewMode, number> = {
user: 0,
admin: 1,
ops: 2,
};
/** 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 { 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<AutostartPolicy, string> = {
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({
<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 className="text-2xl font-semibold leading-none tabular-nums text-left">{value}</div>
<div className="mt-1.5 truncate text-xs text-muted-foreground text-left">{label}</div>
</div>
</CardContent>
</Card>
);
}
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<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();
@@ -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<UnifiedServer[]>(() => {
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<Phase, number> = {
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 (
<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 className="text-left">
<h1 className="text-2xl font-semibold tracking-tight">
{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 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="h-1.5 w-1.5 rounded-full bg-[#22c55e] animate-pulse" />
{t("fleet_live")}
@@ -218,7 +245,6 @@ export function FleetTable() {
<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" />
@@ -226,7 +252,7 @@ export function FleetTable() {
</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">
<Stat icon={Server} label={t("fleet_stat_total")} value={stats.total} />
<Stat
@@ -248,11 +274,11 @@ export function FleetTable() {
/>
</div>
{/* Phase distribution bar + legend. */}
{/* Distribution card in a full grid row */}
{stats.total > 0 && (
<Card>
<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")}
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-secondary">
@@ -287,7 +313,7 @@ export function FleetTable() {
</Card>
)}
{/* Controls — search + phase filter + result count. */}
{/* Independent Search & Filter bar */}
<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" />
@@ -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"
/>
</div>
@@ -330,24 +356,27 @@ export function FleetTable() {
</span>
</div>
{/* The table. */}
{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 ? (
<EmptyState
title={t("fleet_no_match_title")}
hint={t("fleet_no_match_hint")}
title={isAdmin ? t("fleet_no_match_title") : t("servers:search_no_match")}
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">
<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>
{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_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>
@@ -358,7 +387,13 @@ export function FleetTable() {
</thead>
<tbody>
{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>
</table>
@@ -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 (
<>
<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} />
</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>
{host && (
<a
@@ -439,19 +469,21 @@ function FleetRow({
</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}
{isAdmin && (
<td className="px-4 py-3 align-middle text-left">
{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>
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
)}
</td>
<td className="px-4 py-3 align-middle tabular-nums">
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
)}
</td>
)}
<td className="px-4 py-3 align-middle tabular-nums text-left">
{live ? (
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
@@ -462,7 +494,7 @@ function FleetRow({
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
<td className="px-4 py-3 align-middle text-left">
{server.autostartPolicy ? (
<span className="text-xs text-muted-foreground">
{t(POLICY_KEY[server.autostartPolicy])}
@@ -471,7 +503,7 @@ function FleetRow({
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
<td className="px-4 py-3 align-middle text-left">
{endpoint ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{endpoint}
@@ -502,7 +534,7 @@ function FleetRow({
</Button>
)}
<Link to={`/servers/${server.name}`}>
<Button size="sm" variant="ghost">
<Button size="sm" variant="outline">
<Terminal /> {ts("console")}
</Button>
</Link>
@@ -511,7 +543,7 @@ function FleetRow({
</tr>
{error && (
<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}
</td>
</tr>
@@ -519,3 +551,5 @@ function FleetRow({
</>
);
}
export default ServersPage;