fix(panel): 服务器列表的归属与可认领改由后端按账号判定,无邮箱管理员也能认出自己的服务器,所有者查询失败时显示未知且不给认领
This commit is contained in:
14 files changed
+323
-70
No files matched your search
@@ -590,8 +590,9 @@ function visibleServers(state: MockState, accountInfo: MockAccount): MyServerVie
|
||||
// the CRD field names (playersOnline/playersMax, ready, endpoint*) plus the
|
||||
// runtime `ready`/`endpoint*` fields, and the owner joined as the email
|
||||
// (COALESCE(email, username) server-side). Endpoint and live player counts are
|
||||
// gated on Running, exactly as the real cluster reports them.
|
||||
function fleetView(state: MockState): FleetServer[] {
|
||||
// gated on Running, exactly as the real cluster reports them. Ownership is
|
||||
// decided by account id for the caller, as the Go handler does.
|
||||
function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
|
||||
return state.servers.map((s, i) => {
|
||||
const { owner, ...wire } = s;
|
||||
return {
|
||||
@@ -600,6 +601,8 @@ function fleetView(state: MockState): FleetServer[] {
|
||||
endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
|
||||
playersOnline: s.ready ? s.playersOnline : 0,
|
||||
owner: owner ? state.accounts[owner].email : "",
|
||||
owned: owner === accountInfo.id,
|
||||
claimable: owner === null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -903,7 +906,7 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendError(ctx.res, 403, "forbidden", "admin account required");
|
||||
return true;
|
||||
}
|
||||
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) });
|
||||
sendJSON(ctx.res, 200, { servers: fleetView(ctx.state, ctx.account) });
|
||||
return true;
|
||||
case "GET backups":
|
||||
// Admin sees every archive; a user only worlds they formerly owned — mirrors
|
||||
|
||||
@@ -32,7 +32,7 @@ export function StatCard({ icon: Icon, label, value, accentClass, accentColor, v
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-xl sm:text-2xl font-bold font-mono leading-none truncate text-foreground",
|
||||
"text-lg sm:text-2xl font-bold font-mono leading-none truncate text-foreground",
|
||||
valueClass,
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"fleet_col_endpoint": "Endpoint",
|
||||
"fleet_col_actions": "Actions",
|
||||
"fleet_unclaimed": "Unclaimed",
|
||||
"fleet_owner_you": "You",
|
||||
"fleet_owner_unknown": "Unknown",
|
||||
"fleet_owner_unknown_hint": "Couldn't look up the owner just now; it shows again on the next refresh.",
|
||||
"fleet_endpoint_idle": "Not running",
|
||||
"policy_owneronly": "Owner only",
|
||||
"policy_public": "Public",
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
"fleet_col_endpoint": "连接地址",
|
||||
"fleet_col_actions": "操作",
|
||||
"fleet_unclaimed": "未认领",
|
||||
"fleet_owner_you": "你",
|
||||
"fleet_owner_unknown": "未知",
|
||||
"fleet_owner_unknown_hint": "暂时查不到所有者,下次刷新时会重新显示。",
|
||||
"fleet_endpoint_idle": "未运行",
|
||||
"policy_owneronly": "仅所有者",
|
||||
"policy_public": "公开",
|
||||
|
||||
@@ -2005,6 +2005,12 @@ export interface components {
|
||||
FleetServer: components["schemas"]["ServerInfo"] & {
|
||||
/** @description The owner's display identity (email, or username when the address is absent). Absent for an unclaimed server or when the best-effort owner lookup failed. */
|
||||
owner?: string;
|
||||
/** @description True when the caller claimed this server, decided by account id so an owner without an email is still recognized. */
|
||||
owned: boolean;
|
||||
/** @description True for a live, unclaimed, non-system server, the same rule the claim route enforces. False whenever ownership is unknown. */
|
||||
claimable: boolean;
|
||||
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
|
||||
ownerUnknown?: boolean;
|
||||
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
|
||||
system?: boolean;
|
||||
};
|
||||
|
||||
@@ -132,9 +132,15 @@ export interface KickResult {
|
||||
* Sharing one interface would blur which fields each face actually guarantees. */
|
||||
export interface FleetServer extends ServerStatus {
|
||||
/** Owner's display identity (email, or username when the address is absent).
|
||||
* Empty/absent for an unclaimed server or when the best-effort owner lookup
|
||||
* failed — the cockpit renders that as "unclaimed". */
|
||||
* Empty/absent for an unclaimed server or when the owner lookup failed;
|
||||
* ownerUnknown tells the two apart. */
|
||||
owner?: string;
|
||||
/** The caller claimed this server, decided by account id on the server. */
|
||||
owned: boolean;
|
||||
/** Live, unclaimed and not a system service; false while ownership is unknown. */
|
||||
claimable: boolean;
|
||||
/** The owner lookup failed: an absent owner says nothing about the claim. */
|
||||
ownerUnknown?: boolean;
|
||||
/** True for a platform-provisioned system service (the login gate, the lobby).
|
||||
* Their reserved names are rejected by every per-server route, so the cockpit
|
||||
* renders them read-only instead of offering actions that would 400. */
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { FleetServer } from "@/lib/types";
|
||||
import { ServersPage } from "./ServersPage";
|
||||
|
||||
const tier = vi.hoisted(() => ({ isAdmin: true, identity: { email: "[email protected]" } }));
|
||||
const calls = vi.hoisted(() => ({ fleet: vi.fn(), myServers: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
|
||||
vi.mock("@/lib/config", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/config")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/api", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
function row(name: string, over: Partial<FleetServer>): FleetServer {
|
||||
return {
|
||||
name,
|
||||
subdomain: name,
|
||||
phase: "Stopped",
|
||||
ready: false,
|
||||
playersOnline: 0,
|
||||
playersMax: 20,
|
||||
owned: false,
|
||||
claimable: false,
|
||||
...over,
|
||||
} as FleetServer;
|
||||
}
|
||||
|
||||
// The desktop table row of one server (the phone cards render the same data).
|
||||
async function tableRow(name: string) {
|
||||
const table = await screen.findByRole("table");
|
||||
const cell = within(table).getByText(name);
|
||||
const tr = cell.closest("tr");
|
||||
if (!tr) throw new Error(`no row for ${name}`);
|
||||
return within(tr);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls.fleet.mockReset();
|
||||
calls.myServers.mockReset();
|
||||
});
|
||||
|
||||
describe("ServersPage fleet ownership", () => {
|
||||
it("trusts the server's ownership and claim flags over the owner text", async () => {
|
||||
calls.fleet.mockResolvedValue([
|
||||
// The caller has no email: its own server shows the username, which never
|
||||
// equals identity.email, yet the row is still marked as the caller's.
|
||||
row("survival", { owner: "steve-mc", owned: true }),
|
||||
row("creative", { owner: "[email protected]" }),
|
||||
row("skyblock", { claimable: true }),
|
||||
]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServersPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const survival = await tableRow("survival");
|
||||
expect(survival.getByText("You")).toBeTruthy();
|
||||
expect(survival.getByText("steve-mc")).toBeTruthy();
|
||||
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||
|
||||
const creative = await tableRow("creative");
|
||||
expect(creative.queryByText("You")).toBeNull();
|
||||
expect(creative.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||
|
||||
const skyblock = await tableRow("skyblock");
|
||||
expect(skyblock.getByText("Unclaimed")).toBeTruthy();
|
||||
expect(skyblock.getByRole("button", { name: /Claim/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says the owner is unknown and offers no claim when the lookup failed", async () => {
|
||||
calls.fleet.mockResolvedValue([row("survival", { ownerUnknown: true })]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServersPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const survival = await tableRow("survival");
|
||||
expect(survival.getByText("Unknown")).toBeTruthy();
|
||||
expect(survival.queryByText("Unclaimed")).toBeNull();
|
||||
expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -85,12 +85,14 @@ interface UnifiedServer {
|
||||
endpointAddress?: string | null;
|
||||
claimable?: boolean;
|
||||
owned?: boolean;
|
||||
/** The fleet's owner lookup failed, so an absent owner proves nothing. */
|
||||
ownerUnknown?: boolean;
|
||||
system?: boolean;
|
||||
}
|
||||
|
||||
export function ServersPage() {
|
||||
const { t } = useTranslation(["ops", "servers"]);
|
||||
const { isAdmin, identity } = useTier();
|
||||
const { isAdmin } = useTier();
|
||||
const cfg = useConfig();
|
||||
|
||||
const fetchFn = useMemo<() => Promise<FleetServer[] | MyServerView[]>>(
|
||||
@@ -133,8 +135,9 @@ export function ServersPage() {
|
||||
playerCountUnknown: s.playerCountUnknown,
|
||||
owner: s.owner,
|
||||
endpointAddress: s.endpointAddress,
|
||||
claimable: !s.owner,
|
||||
owned: s.owner === identity?.email,
|
||||
claimable: s.claimable,
|
||||
owned: s.owned,
|
||||
ownerUnknown: s.ownerUnknown,
|
||||
system: s.system,
|
||||
}));
|
||||
} else {
|
||||
@@ -154,7 +157,7 @@ export function ServersPage() {
|
||||
owned: s.owned,
|
||||
}));
|
||||
}
|
||||
}, [data, isAdmin, t, identity]);
|
||||
}, [data, isAdmin, t]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const counts: Record<Phase, number> = {
|
||||
@@ -352,10 +355,10 @@ export function ServersPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Cards below xl (two per row from md); the table needs about
|
||||
1000px of content width for all its columns, which the page
|
||||
only has from xl beside the sidebar. */}
|
||||
<ul className="grid gap-3 md:grid-cols-2 xl:hidden">
|
||||
{/* Cards below 2xl (two per row from md); the admin table needs
|
||||
about 1100px of content width for all its columns, which the
|
||||
page only has from 2xl beside the sidebar. */}
|
||||
<ul className="grid gap-3 md:grid-cols-2 2xl:hidden">
|
||||
{paged.map((s) => (
|
||||
<ServerMobileCard
|
||||
key={s.name}
|
||||
@@ -366,7 +369,7 @@ export function ServersPage() {
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<Card className="hidden overflow-hidden border border-border/80 xl:block">
|
||||
<Card className="hidden overflow-hidden border border-border/80 2xl:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
@@ -551,12 +554,24 @@ function OwnerLabel({ server }: { server: UnifiedServer }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5 md:max-w-[13rem]">
|
||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{server.owned && (
|
||||
<span className="shrink-0 rounded-full border px-1.5 text-[10px] font-medium leading-4 text-foreground">
|
||||
{t("fleet_owner_you")}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate" title={server.owner}>
|
||||
{server.owner}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (server.ownerUnknown) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/70" title={t("fleet_owner_unknown_hint")}>
|
||||
{t("fleet_owner_unknown")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user