test(panel): 加组件测试与浏览器冒烟、hooks lint、OpenAPI 双向类型对齐,修复 CI 类型检查空转、列表不显示服务器名称与玩家停服不确认

This commit is contained in:
Lemon-miaow committed 2026-09-25 10:47:19 +08:00
1 parent ea425cffa4
commit 2f99874d5e
44 files changed
+10578 -216

No files matched your search

+2 -2
View File
@@ -20,7 +20,7 @@ import type {
QuotaView,
ServerFileEntry,
ServerJob,
ServerInfo,
MyServerView,
ServerStatus,
SessionView,
UserDetail,
@@ -300,7 +300,7 @@ export const api = rejectingSync({
me: () => request<Identity>("GET", "/me"),
myServers: () =>
request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
request<{ servers: MyServerView[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
// fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every
// server's CRD lifecycle view plus its owner. It 403s for a non-admin principal —
+3 -1
View File
@@ -58,8 +58,10 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt
useEffect(() => {
reload();
// Bumping the ticket on cleanup drops any response still in flight.
const tickets = seq;
return () => {
seq.current++;
tickets.current++;
};
}, [reload]);
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -1,12 +1,20 @@
import { describe, it, expect } from "vitest";
import { canManage, ownershipPending } from "./ownership";
import type { ServerInfo } from "./types";
import type { MyServerView } from "./types";
// The owner-tier gate once read `owned` off /servers/{name}/status, which never
// sends it, so owners lost the LuckPerms entry. These cases pin that ownership
// comes only from the /me/servers row for this exact server.
const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned });
const row = (name: string, owned = false): MyServerView => ({
name,
subdomain: name,
phase: "Running",
owned,
claimable: false,
playersOnline: 0,
playersMax: 0,
});
describe("canManage", () => {
it("lets an admin in without any /me/servers rows", () => {
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ServerInfo } from "./types";
import type { MyServerView } from "./types";
// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
// who may act from GET /me/servers: the status projection never carries
@@ -7,7 +7,7 @@ import type { ServerInfo } from "./types";
/** canManage reports whether the caller may use a server's owner-tier tools. */
export function canManage(
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
mine: readonly MyServerView[] | null | undefined,
name: string,
): boolean {
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
@@ -18,7 +18,7 @@ export function canManage(
export function ownershipPending(
tierLoading: boolean,
isAdmin: boolean,
mine: readonly ServerInfo[] | null | undefined,
mine: readonly MyServerView[] | null | undefined,
mineError: unknown,
): boolean {
return tierLoading || (!isAdmin && mine == null && !mineError);
+49
View File
@@ -0,0 +1,49 @@
// Compile-time parity between the hand-written wire types (types.ts) and the
// schemas in docs/openapi.yaml, via the generated openapi.gen.ts (`npm run
// gen:api`; CI fails when the generated file is stale). The Go side is held to
// the same schemas by internal/api/openapi_parity_test.go, so a field that
// changes in one place and not the others breaks a build instead of rendering
// `undefined`.
//
// For each pair three things must hold:
// - the two key sets are equal;
// - a key the docs mark optional is optional here too (the panel must not
// count on a field the server may omit);
// - every documented value fits the panel type (enums included).
// A failure names the offending keys in the error's type.
import type { components } from "./openapi.gen";
import type * as T from "./types";
type S = components["schemas"];
type OptionalKeys<X> = { [K in keyof X]-?: object extends Pick<X, K> ? K : never }[keyof X];
type Parity<Panel, Docs> = [Exclude<keyof Panel, keyof Docs>, Exclude<keyof Docs, keyof Panel>] extends [
never,
never,
]
? [Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>>] extends [never]
? Docs extends Panel
? true
: { docsValueDoesNotFitPanel: { [K in keyof Docs & keyof Panel]: Docs[K] extends Panel[K] ? never : K }[keyof Docs & keyof Panel] }
: { optionalInDocsButRequiredInPanel: Exclude<OptionalKeys<Docs>, OptionalKeys<Panel>> }
: { onlyInPanel: Exclude<keyof Panel, keyof Docs>; onlyInDocs: Exclude<keyof Docs, keyof Panel> };
type Holds<X extends true> = X;
export type WireParity = [
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
Holds<Parity<T.BackupView, S["BackupView"]>>,
Holds<Parity<T.Build, S["Build"]>>,
Holds<Parity<T.WhitelistImage, S["Image"]>>,
Holds<Parity<T.Submission, S["Submission"]>>,
Holds<Parity<T.UserView, S["UserView"]>>,
Holds<Parity<T.UserDetail, S["UserDetail"]>>,
Holds<Parity<T.QuotaView, S["QuotaView"]>>,
Holds<Parity<T.SessionView, S["SessionView"]>>,
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
];
+53 -37
View File
@@ -17,35 +17,53 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server. */
export interface ServerInfo {
/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server.
* The live fields come from the CRD best-effort; desiredState, autostartPolicy
* and playerCountUnknown are present on the caller's own rows only. */
export interface MyServerView {
name: string;
subdomain: string;
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
playersOnline?: number;
playersMax?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
/** Whether the caller owns it. */
owned?: boolean;
owned: boolean;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable: boolean;
phase?: Phase;
playersOnline: number;
playersMax: number;
displayName?: string;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
/** True while the operator cannot read the player count; a stop may drop players. */
playerCountUnknown?: boolean;
}
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
* `owned` or `claimable`; owner-tier gates read /me/servers via lib/ownership.
* A caller who does not own the server gets the public subset, so everything
* past the counts may be absent. */
export interface ServerStatus {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
autostartPolicy?: AutostartPolicy;
desiredState?: "Running" | "Stopped";
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
displayName?: string;
image?: string;
javaMemory?: string;
storageSize?: string;
cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
idleStopSeconds?: number;
idleStopSeconds: number;
/** True while the operator cannot read the player count; idle stop waits. */
playerCountUnknown?: boolean;
}
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
* `claimable`; owner-tier gates read /me/servers via lib/ownership. */
export type ServerStatus = Omit<ServerInfo, "owned" | "claimable">;
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
@@ -82,7 +100,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
* ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
* ONLY source of WHO is online — MyServerView.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -108,21 +126,11 @@ export interface KickResult {
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display.
*
* It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
* It is a DISTINCT type from MyServerView, not a reuse: /fleet emits the raw CRD
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
* required — whereas ServerInfo is the /me/servers projection with them optional.
* required — whereas MyServerView is the /me/servers projection.
* Sharing one interface would blur which fields each face actually guarantees. */
export interface FleetServer {
name: string;
subdomain: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
autostartPolicy?: AutostartPolicy;
endpointMode?: string;
endpointAddress?: string;
playersOnline: number;
playersMax: number;
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". */
@@ -191,7 +199,11 @@ export interface ServerJob {
export interface WhitelistImage {
image_ref: string;
enabled: boolean;
source?: string;
source: string;
/** Set when the image came out of a panel build (build.Image BuildID). */
build_id?: string;
added_by: string;
added_at: string;
}
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
@@ -288,6 +300,8 @@ export interface Build {
error?: string;
created_at: string;
finished_at?: string;
/** sha256 of the build context the Job actually fetched. */
context_digest?: string;
}
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
@@ -343,7 +357,8 @@ export interface DBBackupStatus {
export interface UserView {
id: string;
username: string;
email: string;
/** Omitted for accounts created without one (bind-code / op-login only). */
email?: string;
role: "admin" | "user" | "owner";
disabled: boolean;
email_verified: boolean;
@@ -359,8 +374,9 @@ export interface LinkedAccount {
}
export interface UserDetail extends UserView {
deleted_at?: string | null;
linked_accounts: LinkedAccount[];
deleted_at?: string;
/** Omitted when the user has no linked Minecraft account. */
linked_accounts?: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
@@ -398,12 +414,12 @@ export interface PasskeyCredential {
name: string;
aaguid?: string;
created_at: string;
last_used_at?: string | null;
last_used_at?: string;
}
export interface SessionView {
token_hash: string;
created_at: string;
expires_at: string;
revoked_at?: string | null;
revoked_at?: string;
}