refactor(panel): unify servers cockpit layout, resolve duplicate pages and adjust spacing
This commit is contained in:
10 files changed
+139
-377
No files matched your search
+3
-13
@@ -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": "全平台服务器"
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,19 +469,21 @@ function FleetRow({
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 align-middle">
|
{isAdmin && (
|
||||||
{server.owner ? (
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
|
{server.owner ? (
|
||||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
|
||||||
<span className="truncate" title={server.owner}>
|
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
{server.owner}
|
<span className="truncate" title={server.owner}>
|
||||||
|
{server.owner}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
) : (
|
<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;
|
||||||
Reference in new issue
Block a user