From d2de11af226c4d9e4bc9b3b402e398af382bbeb9 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Wed, 1 Jul 2026 03:45:41 +0800 Subject: [PATCH] feat(panel): fleet --- docs/openapi.yaml | 19 +- internal/api/api_test.go | 69 +++- internal/api/handlers_user.go | 32 +- internal/api/pgrepo.go | 27 ++ internal/api/repo.go | 9 + panel/dev/mockApi.ts | 75 ++++ panel/src/i18n/resources/en-US/ops.json | 31 +- panel/src/i18n/resources/zh-CN/ops.json | 31 +- panel/src/lib/api.test.ts | 44 ++ panel/src/lib/api.ts | 8 + panel/src/lib/fuzzy.test.ts | 62 +++ panel/src/lib/fuzzy.ts | 55 +++ panel/src/lib/types.ts | 26 ++ panel/src/pages/ops/FleetTable.tsx | 528 +++++++++++++++++++++++- 14 files changed, 978 insertions(+), 38 deletions(-) create mode 100644 panel/src/lib/fuzzy.test.ts create mode 100644 panel/src/lib/fuzzy.ts diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b9ed352..02a038a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1417,14 +1417,15 @@ paths: Every MinecraftServer's CRD+status lifecycle view, for the SysAdmin FleetTable. Admin-tier — it reads every owner's server. A path distinct from the internal velocity GET /api/v1/servers because one {method, path} - cannot carry both the service and admin tiers. CRD truth only: owner and - the other Postgres business fields are deliberately not joined (§1). + cannot carry both the service and admin tiers. Lifecycle is CRD truth (§1); + the owner is the only business field, joined READ-ONLY from Postgres (§6) + for display — best-effort, so a Postgres blip degrades to owner-less rows. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] responses: '200': - description: Every server's status projection (fleet-wide). + description: Every server's status projection (fleet-wide), each with its owner. content: application/json: schema: @@ -1433,7 +1434,17 @@ paths: properties: servers: type: array - items: { $ref: '#/components/schemas/ServerInfo' } + items: + allOf: + - $ref: '#/components/schemas/ServerInfo' + - type: object + properties: + owner: + type: string + 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. '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 6fe0ef6..8449c8b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -31,6 +31,11 @@ type fakeRepo struct { // the account_links-bridged web view of the same data. allowUUID map[string]map[string]bool mine map[string][]MyServerView + // owners mirrors the ServerOwners join (name -> owner display identity); only + // claimed servers appear. ownersErr forces the lookup to fail so a test can + // prove the fleet read degrades to owner-less rows rather than 500ing. + owners map[string]string + ownersErr error claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound audits []AuditEntry joins []string @@ -139,6 +144,7 @@ func newFakeRepo() *fakeRepo { linked: map[string]bool{}, quota: map[string]bool{}, allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{}, mine: map[string][]MyServerView{}, + owners: map[string]string{}, claimOK: map[string]bool{}, seeded: map[string]bool{}, aliases: map[string]string{}, linkCodes: map[string]fakeLinkCode{}, links: map[string]string{}, @@ -411,6 +417,12 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error { func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) { return f.mine[u], nil } +func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) { + if f.ownersErr != nil { + return nil, f.ownersErr + } + return f.owners, nil +} func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string) error { if f.seedErr != nil { return f.seedErr @@ -861,21 +873,68 @@ func TestFleetAdminRead(t *testing.T) { } }) - t.Run("admin reads the whole fleet", func(t *testing.T) { - api := newTestAPI(newFakeRepo(), cl) + // fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus + // the presentational owner join. A server absent from ServerOwners (unclaimed) + // or a failed lookup must serialize owner as "" (omitempty drops it). + type fleetRow struct { + Name string `json:"name"` + Owner string `json:"owner"` + } + adminAPI := func(repo *fakeRepo) *API { + api := newTestAPI(repo, cl) api.External = staticExternal{p: &Principal{UserID: "a1", Email: "a1@example.net", Role: "admin", ViaAdminAccess: true}} + return api + } + readFleet := func(t *testing.T, api *API) []fleetRow { + t.Helper() w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } - var got map[string][]ServerInfo + var got map[string][]fleetRow if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("body not JSON: %v", err) } + return got["servers"] + } + + t.Run("admin reads the whole fleet", func(t *testing.T) { + rows := readFleet(t, adminAPI(newFakeRepo())) // Fleet-wide: all three servers, not a caller-scoped subset. - if len(got["servers"]) != 3 { - t.Fatalf("servers = %d, want 3 (the fleet read must not be caller-scoped)", len(got["servers"])) + if len(rows) != 3 { + t.Fatalf("servers = %d, want 3 (the fleet read must not be caller-scoped)", len(rows)) + } + }) + + t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) { + repo := newFakeRepo() + // Only "survival" is claimed; "creative"/"skyblock" stay unowned. + repo.owners["survival"] = "alice@example.net" + byName := map[string]string{} + for _, r := range readFleet(t, adminAPI(repo)) { + byName[r.Name] = r.Owner + } + if byName["survival"] != "alice@example.net" { + t.Fatalf("survival owner = %q, want alice@example.net", byName["survival"]) + } + if byName["creative"] != "" { + t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"]) + } + }) + + t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) { + repo := newFakeRepo() + repo.owners["survival"] = "alice@example.net" // would merge, but the lookup errors + repo.ownersErr = fmt.Errorf("postgres unreachable") + rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500 + if len(rows) != 3 { + t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows)) + } + for _, r := range rows { + if r.Owner != "" { + t.Fatalf("%s owner = %q, want empty (owner lookup failed → degrade)", r.Name, r.Owner) + } } }) } diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 93d3ed6..febab50 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -208,17 +208,39 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) { // same CRD-truth source as the velocity pull, §1) but is a DISTINCT handler so // each route's provenance and tier stay honest, and so the two never share a // {method, path} key — the OpenAPI parity test forbids one path carrying both the -// service and admin tiers across faces. CRD truth only: owner and the other -// Postgres business fields are deliberately not joined here (§1 — the CRD is the -// lifecycle authority, Postgres the business authority; this read stays on the -// lifecycle side). +// service and admin tiers across faces. Lifecycle is read from the CRD (§1); the +// one business field the cockpit needs — the owner — is joined READ-ONLY from +// Postgres at request time (§6 business authority) purely for display. This keeps +// §1 honest: owner is never written back to the CRD and the CRD is never treated +// as its source; the two stores keep their split, the read just renders both. func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) { servers, err := a.Cluster.ListServers(r.Context()) if err != nil { writeError(w, r, err) return } - writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) + // Owner is presentational and best-effort. The cockpit exists for the lifecycle + // view, so a Postgres hiccup must degrade to owner-less rows, never 500 the whole + // fleet: a lookup error is swallowed and owners stays nil, leaving every row's + // Owner "" (a nil map reads as zero values). + owners, _ := a.Repo.ServerOwners(r.Context()) + views := make([]fleetServerView, len(servers)) + for i, s := range servers { + views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name]} + } + writeJSON(w, http.StatusOK, map[string]any{"servers": views}) +} + +// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD +// lifecycle view (ServerInfo, §1 authority) with the owner's display identity +// joined alongside. The embed keeps every lifecycle field flat in the JSON so the +// shape is a strict superset of ServerInfo; Owner is the only addition. +type fleetServerView struct { + ServerInfo + // Owner is the claiming user's display identity (email, or username when the + // address is absent), or "" when the server is unclaimed or the best-effort + // owner lookup failed — the cockpit renders "" as "unclaimed". + Owner string `json:"owner,omitempty"` } // createServerRequest is the structured §15 create-server form. This is the diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 1a8dc6c..84167d8 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -259,6 +259,33 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView, return out, rows.Err() } +// ServerOwners returns name -> owner display identity for every currently-owned, +// non-deleted server (the SysAdmin cockpit's fleet read). The INNER JOIN drops +// unclaimed servers (owner_id NULL) and the deleted_at filter drops soft-deleted +// ones, so the map holds only servers that have a live owner — the cockpit reads a +// missing key as "no owner". The display value prefers the recognizable email +// (the same identity the audit log records as the human actor, §6) and falls back +// to the never-NULL username when the address is absent. +func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]string, error) { + const q = `SELECT s.name, COALESCE(NULLIF(u.email, ''), u.username) + FROM servers s JOIN users u ON u.id = s.owner_id + WHERE s.deleted_at IS NULL` + rows, err := p.db.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]string) + for rows.Next() { + var name, owner string + if err := rows.Scan(&name, &owner); err != nil { + return nil, err + } + out[name] = owner + } + return out, rows.Err() +} + // SeedServer inserts the business rows backing a newly created server (spec // §15): the servers row (owner_id left NULL — the server is created unowned and // claimed later, spec §9.3) and its subdomain alias. Both inserts are diff --git a/internal/api/repo.go b/internal/api/repo.go index b1e9122..96220f6 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -176,6 +176,15 @@ type Repo interface { RecordJoin(ctx context.Context, name, mcUUID string) error // MyServers lists the servers a user owns or may claim. MyServers(ctx context.Context, userID string) ([]MyServerView, error) + // ServerOwners maps each currently-owned server to its owner's display identity + // (email, or username when the address is absent), for the SysAdmin cockpit's + // fleet read. It is a READ-ONLY presentational join: owner stays authored in + // Postgres (§6 business authority) and is never written back to the CRD, so this + // does not breach §1's store-of-record split. Unclaimed and soft-deleted servers + // are simply absent from the map, so a missing key reads as "no owner". The + // cockpit treats it as best-effort — a lookup error degrades to owner-less rows + // rather than failing the fleet read — so callers may ignore the error. + ServerOwners(ctx context.Context) (map[string]string, error) // AllBackups lists every present world backup, newest first (spec §7 GET // /backups, admin scope). Expired/deleted rows are never returned. AllBackups(ctx context.Context) ([]BackupView, error) diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 87f6ffd..e4ad8d9 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -3,6 +3,7 @@ import type { Plugin } from "vite"; import type { AutostartPolicy, CreateServerRequest, + FleetServer, Identity, LoginResult, Phase, @@ -133,6 +134,12 @@ function initialState(): MockState { server("survival", "Survival SMP", "Running", "owner", { players: 7, maxPlayers: 20, + autostartPolicy: "public", + }), + server("lobby", "Hub Lobby", "Running", "linked", { + players: 28, + maxPlayers: 60, + autostartPolicy: "public", }), server("creative", "Creative Lab", "Stopped", "user", { autostartPolicy: "public", @@ -142,13 +149,47 @@ function initialState(): MockState { autostartPolicy: "allowlist", maxPlayers: 12, }), + server("broken", "Broken Node", "Failed", "user", { + autostartPolicy: "ownerOnly", + maxPlayers: 8, + }), server("claim-me", "Claimable Node", "Stopped", null, { maxPlayers: 10, }), + ...generatedServers(), ], }; } +// generatedServers fills the mock fleet past one page so the SysAdmin cockpit's +// pagination and fuzzy search are actually exercisable in dev. Deterministic (no +// Math.random) so the demo is stable across reloads: phase / owner / policy / +// capacity all cycle. 8 themes × 3 = 24 servers; with the 6 hand-authored ones the +// fleet is 30 → two pages at PAGE_SIZE 20. +function generatedServers(): MockServer[] { + const phases: Phase[] = ["Running", "Stopped", "Starting", "Failed", "Running", "Stopped"]; + const owners: (AccountID | null)[] = ["owner", "linked", "user", null]; + const policies: AutostartPolicy[] = ["ownerOnly", "public", "allowlist"]; + const themes = ["smp", "creative", "skyblock", "anarchy", "minigames", "build", "pvp", "vanilla"]; + const out: MockServer[] = []; + let i = 0; + for (const theme of themes) { + for (let n = 1; n <= 3; n++) { + const phase = phases[i % phases.length]; + const max = 10 + ((i * 7) % 50); + out.push( + server(`${theme}-${String(n).padStart(2, "0")}`, `${theme} #${n}`, phase, owners[i % owners.length], { + players: phase === "Running" ? 1 + ((i * 3) % max) : 0, + maxPlayers: max, + autostartPolicy: policies[i % policies.length], + }), + ); + i++; + } + } + return out; +} + function mockStartupMessage(): string { return [ "", @@ -287,6 +328,31 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[ .map((serverInfo) => projectServer(serverInfo, accountInfo)); } +// fleetView projects the internal mock servers into the GET /fleet wire shape +// (the SysAdmin cockpit's read). It is the mock mirror of the Go fleetServerView: +// the CRD field names (playersOnline/playersMax, ready, endpoint*) — NOT the +// me/servers projection's players/maxPlayers — plus 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[] { + return state.servers.map((s, i) => { + const ready = s.phase === "Running"; + return { + name: s.name, + subdomain: s.subdomain, + phase: s.phase, + ready, + desiredState: s.desiredState, + autostartPolicy: s.autostartPolicy, + endpointMode: "domain", + endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined, + playersOnline: ready ? s.players ?? 0 : 0, + playersMax: s.maxPlayers ?? 0, + owner: s.owner ? state.accounts[s.owner].email : "", + }; + }); +} + function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo { const { owner: _owner, ...wire } = serverInfo; const owned = canManage(accountInfo, serverInfo); @@ -372,6 +438,15 @@ async function handleSession(ctx: SessionContext): Promise { case "GET me": sendJSON(ctx.res, 200, identity(ctx.account)); return true; + case "GET fleet": + // Admin-tier, fleet-wide — mirrors the real adminOnly gate (a non-admin is + // 403'd before the handler) so the cockpit's RequireAdmin path is exercised. + if (ctx.account.role !== "admin") { + sendError(ctx.res, 403, "forbidden", "admin account required"); + return true; + } + sendJSON(ctx.res, 200, { servers: fleetView(ctx.state) }); + return true; case "POST auth/change-password": ctx.account.mustChangePassword = false; sendJSON(ctx.res, 200, { ok: true }); diff --git a/panel/src/i18n/resources/en-US/ops.json b/panel/src/i18n/resources/en-US/ops.json index 17a5d01..b94a764 100644 --- a/panel/src/i18n/resources/en-US/ops.json +++ b/panel/src/i18n/resources/en-US/ops.json @@ -7,7 +7,32 @@ "footer_kubectl": "kubectl / CRD operations", "footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.", "fleet_title": "Fleet", - "fleet_subtitle": "Every server on the platform — phase, owner, capacity.", - "fleet_table_pending_note": "The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships.", - "ops_overview_pending_note": "A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers." + "fleet_subtitle": "Every server on the platform — phase, owner, players online; start, stop and open a console inline.", + "fleet_live": "Live", + "fleet_refresh": "Refresh now", + "fleet_stat_total": "Total servers", + "fleet_stat_running": "Running", + "fleet_stat_players": "Online / capacity", + "fleet_stat_failed": "Failed", + "fleet_distribution": "Phase distribution", + "fleet_search_placeholder": "Search name, subdomain or owner…", + "fleet_filter_all": "All phases", + "fleet_count": "{{count}} servers", + "fleet_count_filtered": "{{shown}} / {{total}} servers", + "fleet_col_status": "Status", + "fleet_col_server": "Server", + "fleet_col_owner": "Owner", + "fleet_col_players": "Players", + "fleet_col_policy": "Autostart", + "fleet_col_endpoint": "Endpoint", + "fleet_col_actions": "Actions", + "fleet_unclaimed": "Unclaimed", + "fleet_endpoint_idle": "Not running", + "policy_owneronly": "Owner only", + "policy_public": "Public", + "policy_allowlist": "Allowlist", + "fleet_empty_title": "No servers", + "fleet_empty_hint": "No MinecraftServer exists in the cluster yet.", + "fleet_no_match_title": "No matches", + "fleet_no_match_hint": "No server matches the current search or filter." } diff --git a/panel/src/i18n/resources/zh-CN/ops.json b/panel/src/i18n/resources/zh-CN/ops.json index 915f892..7764bfd 100644 --- a/panel/src/i18n/resources/zh-CN/ops.json +++ b/panel/src/i18n/resources/zh-CN/ops.json @@ -7,7 +7,32 @@ "footer_kubectl": "kubectl / CRD", "footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。", "fleet_title": "全平台服务器", - "fleet_subtitle": "平台所有服务器——运行状态、所有者、资源用量。", - "fleet_table_pending_note": "全平台服务器列表会读取所有服务器的状态(不限于你拥有的)。该管理员层接口为下一个待开发的后端模块,上线后数据将在此呈现。", - "ops_overview_pending_note": "全平台服务器读取(所有服务器,非仅个人拥有)支撑集群汇总及 3D 视图。该管理员层接口尚待开发,与用户层 GET /me/servers 不同。" + "fleet_subtitle": "平台所有服务器——运行状态、所有者、在线人数,可直接启停与进入控制台。", + "fleet_live": "实时刷新", + "fleet_refresh": "立即刷新", + "fleet_stat_total": "服务器总数", + "fleet_stat_running": "运行中", + "fleet_stat_players": "在线 / 容量", + "fleet_stat_failed": "异常", + "fleet_distribution": "状态分布", + "fleet_search_placeholder": "搜索名称、域名或所有者…", + "fleet_filter_all": "全部状态", + "fleet_count": "共 {{count}} 台", + "fleet_count_filtered": "{{shown}} / {{total}} 台", + "fleet_col_status": "状态", + "fleet_col_server": "服务器", + "fleet_col_owner": "所有者", + "fleet_col_players": "在线", + "fleet_col_policy": "自启策略", + "fleet_col_endpoint": "连接地址", + "fleet_col_actions": "操作", + "fleet_unclaimed": "未认领", + "fleet_endpoint_idle": "未运行", + "policy_owneronly": "仅所有者", + "policy_public": "公开", + "policy_allowlist": "白名单", + "fleet_empty_title": "暂无服务器", + "fleet_empty_hint": "集群中尚未创建任何 MinecraftServer。", + "fleet_no_match_title": "无匹配结果", + "fleet_no_match_hint": "没有服务器符合当前的搜索或筛选条件。" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 41d24fe..b5413a3 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -151,3 +151,47 @@ describe("local-password auth wire shapes", () => { expect(humanizeError({ code: "password_unchanged" })).toMatch(/differ/i); }); }); + +// Pin the GET /fleet wire shape (the SysAdmin cockpit's read). It is the ONLY +// place the panel asserts the fleetServerView fields: the cockpit consumes the raw +// CRD names (playersOnline/playersMax, not players/maxPlayers) plus the joined +// `owner`, all crossing the untyped fetch().json() boundary. If the Go handler's +// JSON ever drifts to the /me/servers shape — or drops owner — typecheck/build stay +// green while the table silently renders blank players and "unclaimed" everywhere. +describe("api.fleet wire shape", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("GETs /fleet and unwraps .servers with the CRD field names + owner", async () => { + const fetchSpy = fakeFetch({ + servers: [ + { + name: "survival", + subdomain: "survival", + phase: "Running", + ready: true, + playersOnline: 3, + playersMax: 20, + owner: "alice@example.test", + }, + ], + }); + vi.stubGlobal("fetch", fetchSpy); + const servers = await api.fleet(); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock + .calls[0]; + expect(String(url)).toBe("/fleet"); + expect((opts as RequestInit).method).toBe("GET"); + expect((opts as RequestInit).credentials).toBe("include"); + expect(servers).toHaveLength(1); + expect(servers[0].playersOnline).toBe(3); + expect(servers[0].playersMax).toBe(20); + expect(servers[0].owner).toBe("alice@example.test"); + }); + + it("defaults to [] when the body carries no servers key", async () => { + const fetchSpy = fakeFetch({}); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.fleet()).toEqual([]); + }); +}); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index ef12d7a..93c7528 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -1,6 +1,7 @@ import type { ApiError, CreateServerRequest, + FleetServer, Identity, LinkResult, LinkStatus, @@ -80,6 +81,13 @@ export const api = { myServers: () => request<{ servers: ServerInfo[] }>("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 — + // the panel only renders the cockpit link behind is_admin, and the route guards + // again server-side regardless of what the UI shows. + fleet: () => + request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []), + status: (name: string) => request("GET", `/servers/${name}/status`), wake: (name: string) => diff --git a/panel/src/lib/fuzzy.test.ts b/panel/src/lib/fuzzy.test.ts new file mode 100644 index 0000000..b511cbe --- /dev/null +++ b/panel/src/lib/fuzzy.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { fuzzyScore, matchScore } from "./fuzzy"; + +describe("fuzzyScore", () => { + it("matches a contiguous substring and scores it far above a subsequence", () => { + const substring = fuzzyScore("survival", "surv"); + const subsequence = fuzzyScore("survival", "srv"); + expect(substring).toBeGreaterThan(0); + expect(subsequence).toBeGreaterThan(0); + // "surv" is contiguous (substring) → must outrank the "srv" subsequence. + expect(substring).toBeGreaterThan(subsequence); + }); + + it("is case-insensitive", () => { + expect(fuzzyScore("Survival SMP", "smp")).toBeGreaterThan(0); + expect(fuzzyScore("survival", "SURV")).toBeGreaterThan(0); + }); + + it("matches a non-contiguous subsequence (srv → survival)", () => { + expect(fuzzyScore("survival", "srv")).toBeGreaterThan(0); + expect(fuzzyScore("survival", "sl")).toBeGreaterThan(0); + }); + + it("returns -1 when a query char is absent or out of order", () => { + expect(fuzzyScore("creative", "surv")).toBe(-1); // no 's' in creative + expect(fuzzyScore("survival", "lavivrus")).toBe(-1); // right chars, wrong order + }); + + it("ranks an earlier / word-boundary hit above a later one", () => { + // "lab" at the start of the second word beats "ab" buried mid-word. + const boundary = fuzzyScore("creative lab", "lab"); + const buried = fuzzyScore("xlabx", "lab"); + expect(boundary).toBeGreaterThan(buried); + }); + + it("treats an empty query as a neutral match and empty text as no match", () => { + expect(fuzzyScore("anything", "")).toBe(0); + expect(fuzzyScore("", "x")).toBe(-1); + }); +}); + +describe("matchScore", () => { + const fields = ["survival", "survival", "owner@mock.felis.local"]; + + it("requires every term to hit some field (AND across terms)", () => { + // Both "owner" (owner field) and "surv" (name) match → included. + expect(matchScore(fields, ["owner", "surv"])).toBeGreaterThan(0); + // "owner" matches but "zzz" matches nothing → the whole record is rejected. + expect(matchScore(fields, ["owner", "zzz"])).toBe(-1); + }); + + it("matches a term against any field (OR across fields)", () => { + expect(matchScore(fields, ["mock"])).toBeGreaterThan(0); // only in the owner field + expect(matchScore(fields, ["survival"])).toBeGreaterThan(0); // only in the name field + }); + + it("sums per-term best scores, so more matched terms rank higher", () => { + const one = matchScore(fields, ["surv"]); + const two = matchScore(fields, ["surv", "owner"]); + expect(two).toBeGreaterThan(one); + }); +}); diff --git a/panel/src/lib/fuzzy.ts b/panel/src/lib/fuzzy.ts new file mode 100644 index 0000000..128f985 --- /dev/null +++ b/panel/src/lib/fuzzy.ts @@ -0,0 +1,55 @@ +// A dependency-free fuzzy matcher for client-side list search (the SysAdmin fleet +// table, and reusable elsewhere). Two levels: fuzzyScore ranks one string against +// one query; matchScore ranks a record (several fields) against a multi-term query. + +/** fuzzyScore ranks how well `query` matches `text`, case-insensitively. It returns + * a score (higher = better) or -1 for no match. A contiguous substring scores + * highest (earlier / at a word boundary is better); failing that, a subsequence + * match — the query's chars appear in order but not necessarily adjacent, so "srv" + * matches "survival" — still matches, scored by how tight and early the run is. + * This is the model fuzzy finders (fzf, command palettes) use. */ +export function fuzzyScore(text: string, query: string): number { + const t = text.toLowerCase(); + const q = query.toLowerCase(); + if (!q) return 0; + if (!t) return -1; + const idx = t.indexOf(q); + if (idx !== -1) { + // Substring hit dominates: base 1000, earlier is better, plus a word-start + // bonus (start of string or just after a separator like -, _, ., @, /). + const atBoundary = idx === 0 || /[-_.@/\s]/.test(t[idx - 1]); + return 1000 - idx + (atBoundary ? 100 : 0); + } + // Subsequence fallback: every query char must appear in order; reward adjacency. + let cursor = 0; + let score = 0; + let run = 0; + for (const ch of q) { + const found = t.indexOf(ch, cursor); + if (found === -1) return -1; + run = found === cursor ? run + 1 : 0; + score += 1 + run * 2; + cursor = found + 1; + } + return score; +} + +/** matchScore is a record's overall relevance for a multi-term query, across its + * searchable `fields`. EVERY term must hit at least one field (AND across terms, + * OR across fields) — so "owner surv" finds a server named survival owned by + * owner@… — and the score is the sum of each term's best field score. Returns -1 + * if any term fails to match any field. `terms` is expected pre-split + lowercased + * (callers usually `query.trim().toLowerCase().split(/\s+/).filter(Boolean)`). */ +export function matchScore(fields: string[], terms: string[]): number { + let total = 0; + for (const term of terms) { + let best = -1; + for (const f of fields) { + const s = fuzzyScore(f, term); + if (s > best) best = s; + } + if (best < 0) return -1; + total += best; + } + return total; +} diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 03b2451..e5b0190 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -34,6 +34,32 @@ export interface ServerInfo { owned?: boolean; } +/** FleetServer is one row of GET /api/v1/fleet — the SysAdmin cockpit's fleet-wide + * 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 + * shape — `playersOnline`/`playersMax` (not players/maxPlayers), plus `ready` and + * the `endpoint*` runtime fields — whereas ServerInfo is the /me/servers + * projection. Sharing one interface would silently read `undefined` across the + * fetch().json() boundary for every renamed field. */ +export interface FleetServer { + name: string; + subdomain: string; + phase: Phase; + ready: boolean; + desiredState?: "Running" | "Stopped"; + autostartPolicy?: AutostartPolicy; + endpointMode?: string; + endpointAddress?: string; + playersOnline: number; + playersMax: number; + /** 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". */ + owner?: string; +} + /** WhitelistImage is one row of GET /images (the create-form dropdown source). */ export interface WhitelistImage { image_ref: string; diff --git a/panel/src/pages/ops/FleetTable.tsx b/panel/src/pages/ops/FleetTable.tsx index 95ea90f..24673b0 100644 --- a/panel/src/pages/ops/FleetTable.tsx +++ b/panel/src/pages/ops/FleetTable.tsx @@ -1,29 +1,521 @@ -import { Network } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + Network, + Play, + Square, + Terminal, + ExternalLink, + Search, + RefreshCw, + Server, + Users, + AlertTriangle, + UserRound, +} from "lucide-react"; import { useTranslation } from "react-i18next"; -import { PendingBackend } from "@/components/States"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from "@/components/ui/select"; +import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { Pagination } from "@/components/Pagination"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync, useConfig } from "@/lib/hooks"; +import { hostFor, type RuntimeConfig } from "@/lib/config"; +import { matchScore } from "@/lib/fuzzy"; +import type { AutostartPolicy, FleetServer, Phase } 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", + "Stopping", + "Stopped", + "Failed", + "Unknown", +]; + +const POLICY_KEY: Record = { + ownerOnly: "policy_owneronly", + public: "policy_public", + 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; + +/** 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, + value, + accent, +}: { + icon: typeof Server; + label: string; + value: number | string; + accent?: string; +}) { + return ( + + +
+ +
+
+
{value}
+
{label}
+
+
+
+ ); +} -// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with -// phase + capacity, distinct from the User-Side "My servers". It depends on the -// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend -// slice. Honest placeholder until that lands — no fabricated rows. export function FleetTable() { const { t } = useTranslation("ops"); + const cfg = useConfig(); + const { data, error, loading, reload } = useAsync(() => api.fleet(), []); + const servers = useMemo(() => data ?? [], [data]); + + const [query, setQuery] = useState(""); + const [phaseFilter, setPhaseFilter] = useState("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(); + }, REFRESH_MS); + const onVisible = () => { + if (!document.hidden) reload(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [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 stats = useMemo(() => { + const counts: Record = { + Running: 0, + Starting: 0, + Stopping: 0, + Stopped: 0, + Failed: 0, + Unknown: 0, + }; + 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; + playersMax += s.playersMax ?? 0; + } + 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; + if (terms.length === 0) return servers.filter(phaseOk); + const scored: { s: FleetServer; 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); + + const showInitialLoading = loading && !data; + const showInitialError = !!error && !data; + return ( - <> -
- -
-

{t("fleet_title")}

-

- {t("fleet_subtitle")} -

+
+ {/* Title row — always visible; the live pill + manual refresh live here. */} +
+
+ +
+

{t("fleet_title")}

+

{t("fleet_subtitle")}

+
+
+
+ + + {t("fleet_live")} + +
- + {showInitialLoading ? ( + + ) : showInitialError ? ( + + ) : !cfg ? ( + + ) : ( + <> + {/* A poll failed but we still have rows — keep the table, warn inline. */} + {error && ( +
+ + {humanizeError(error)} +
+ )} + + {/* Aggregate header — true cluster-wide rollup. */} +
+ + + + 0 ? PHASE_COLOR.Failed : undefined} + /> +
+ + {/* Phase distribution bar + legend. */} + {stats.total > 0 && ( + + +
+ {t("fleet_distribution")} +
+
+ {PHASES.map((p) => + stats.counts[p] > 0 ? ( +
+ ) : null, + )} +
+
+ {PHASES.filter((p) => stats.counts[p] > 0).map((p) => ( + + + {t(PHASE_KEY[p])} + + {stats.counts[p]} + + + ))} +
+ + + )} + + {/* Controls — search + phase filter + result count. */} +
+
+ + { + setQuery(e.target.value); + setPage(1); + }} + placeholder={t("fleet_search_placeholder")} + className="pl-9" + /> +
+ + + {visible.length === servers.length + ? t("fleet_count", { count: servers.length }) + : t("fleet_count_filtered", { + shown: visible.length, + total: servers.length, + })} + +
+ + {/* The table. */} + {servers.length === 0 ? ( + + ) : visible.length === 0 ? ( + + ) : ( + <> + +
+ + + + + + + + + + + + + + {paged.map((s) => ( + + ))} + +
{t("fleet_col_status")}{t("fleet_col_server")}{t("fleet_col_owner")}{t("fleet_col_players")}{t("fleet_col_policy")}{t("fleet_col_endpoint")} + {t("fleet_col_actions")} +
+
+
+ + {totalPages > 1 && ( + + )} + + )} + + )} +
+ ); +} + +/** 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({ + server, + cfg, + onChanged, +}: { + server: FleetServer; + cfg: RuntimeConfig; + onChanged: () => void; +}) { + const { t } = useTranslation("ops"); + const { t: ts } = useTranslation("servers"); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + async function act(kind: "wake" | "stop", fn: () => Promise) { + setBusy(kind); + setError(null); + try { + await fn(); + onChanged(); + } catch (e) { + setError(humanizeError(e)); + } finally { + setBusy(null); + } + } + + 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 ( + <> + + + + + +
{server.name}
+ {host && ( + + {host} + + + )} + + + {server.owner ? ( + + + + {server.owner} + + + ) : ( + {t("fleet_unclaimed")} + )} + + + {live ? ( + + + {server.playersOnline} + / {server.playersMax} + + ) : ( + — + )} + + + {server.autostartPolicy ? ( + + {t(POLICY_KEY[server.autostartPolicy])} + + ) : ( + — + )} + + + {endpoint ? ( + + {endpoint} + + ) : ( + {t("fleet_endpoint_idle")} + )} + + +
+ {live ? ( + + ) : ( + + )} + + + +
+ + + {error && ( + + + {error} + + + )} ); }