From bb9c2168dff9587c6ce6f82bd37255aaf8e36882 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 12:36:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(panel):=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=9A=84=E5=BD=92=E5=B1=9E=E4=B8=8E=E5=8F=AF?= =?UTF-8?q?=E8=AE=A4=E9=A2=86=E6=94=B9=E7=94=B1=E5=90=8E=E7=AB=AF=E6=8C=89?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=88=A4=E5=AE=9A=EF=BC=8C=E6=97=A0=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E7=AE=A1=E7=90=86=E5=91=98=E4=B9=9F=E8=83=BD=E8=AE=A4?= =?UTF-8?q?=E5=87=BA=E8=87=AA=E5=B7=B1=E7=9A=84=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E6=89=80=E6=9C=89=E8=80=85=E6=9F=A5=E8=AF=A2=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E6=98=BE=E7=A4=BA=E6=9C=AA=E7=9F=A5=E4=B8=94?= =?UTF-8?q?=E4=B8=8D=E7=BB=99=E8=AE=A4=E9=A2=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 16 ++++ internal/api/api_test.go | 81 ++++++++++++----- internal/api/handlers_user.go | 32 +++++-- internal/api/pgrepo.go | 28 +++--- internal/api/repo.go | 28 ++++-- internal/pgint/pgint_test.go | 48 ++++++++++ panel/dev/mockApi.ts | 9 +- panel/src/components/StatCard.tsx | 2 +- panel/src/i18n/resources/en-US/ops.json | 3 + panel/src/i18n/resources/zh-CN/ops.json | 3 + panel/src/lib/openapi.gen.ts | 6 ++ panel/src/lib/types.ts | 10 ++- panel/src/pages/servers/ServersPage.test.tsx | 94 ++++++++++++++++++++ panel/src/pages/servers/ServersPage.tsx | 33 +++++-- 14 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 panel/src/pages/servers/ServersPage.test.tsx diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 038538b..01e9f13 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -339,6 +339,7 @@ components: allOf: - $ref: '#/components/schemas/ServerInfo' - type: object + required: [owned, claimable] properties: owner: type: string @@ -346,6 +347,21 @@ components: 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. + owned: + type: boolean + description: >- + True when the caller claimed this server, decided by account id so an + owner without an email is still recognized. + claimable: + type: boolean + description: >- + True for a live, unclaimed, non-system server, the same rule the claim + route enforces. False whenever ownership is unknown. + ownerUnknown: + type: boolean + description: >- + Present and true when the owner lookup failed, so an absent owner says + nothing about whether the server is claimed. system: type: boolean description: >- diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5668f1c..6585910 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -33,10 +33,10 @@ 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 + // owners mirrors the ServerOwners join (name -> claim state); a live unclaimed + // server appears with an empty OwnerID. ownersErr forces the lookup to fail so + // a test can prove the fleet read degrades rather than 500ing. + owners map[string]ServerOwnership ownersErr error claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound // claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4) @@ -255,7 +255,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{}, + owners: map[string]ServerOwnership{}, claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{}, serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{}, seeded: map[string]bool{}, aliases: map[string]string{}, @@ -750,7 +750,7 @@ func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error // into the slice it gets. return append([]MyServerView(nil), f.mine[u]...), nil } -func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) { +func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]ServerOwnership, error) { if f.ownersErr != nil { return nil, f.ownersErr } @@ -1971,11 +1971,13 @@ func TestFleetAdminRead(t *testing.T) { }) // 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). + // the ownership join. type fleetRow struct { - Name string `json:"name"` - Owner string `json:"owner"` + Name string `json:"name"` + Owner string `json:"owner"` + Owned bool `json:"owned"` + Claimable bool `json:"claimable"` + OwnerUnknown bool `json:"ownerUnknown"` } adminAPI := func(repo *fakeRepo) *API { api := newTestAPI(repo, cl) @@ -2042,33 +2044,66 @@ func TestFleetAdminRead(t *testing.T) { } }) - t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) { + t.Run("ownership comes from the account id", func(t *testing.T) { repo := newFakeRepo() - // Only "survival" is claimed; "creative"/"skyblock" stay unowned. - repo.owners["survival"] = "alice@example.net" - byName := map[string]string{} + // The caller (a1) owns "survival" but has no email, so its display is the + // username; "creative" belongs to someone else; "skyblock" is unclaimed. + repo.owners["survival"] = ServerOwnership{OwnerID: "a1", Owner: "a1-username"} + repo.owners["creative"] = ServerOwnership{OwnerID: "u2", Owner: "alice@example.net"} + repo.owners["skyblock"] = ServerOwnership{} + byName := map[string]fleetRow{} for _, r := range readFleet(t, adminAPI(repo)) { - byName[r.Name] = r.Owner + byName[r.Name] = r } - if byName["survival"] != "alice@example.net" { - t.Fatalf("survival owner = %q, want alice@example.net", byName["survival"]) + want := map[string]fleetRow{ + "survival": {Name: "survival", Owner: "a1-username", Owned: true}, + "creative": {Name: "creative", Owner: "alice@example.net"}, + "skyblock": {Name: "skyblock", Claimable: true}, } - if byName["creative"] != "" { - t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"]) + for name, w := range want { + if byName[name] != w { + t.Errorf("%s = %+v, want %+v", name, byName[name], w) + } } }) - t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) { + t.Run("a server without a business row cannot be claimed", func(t *testing.T) { + // A CRD the servers table does not know (or a soft-deleted row) answers a + // claim with 404, so the row must not offer one. + rows := readFleet(t, adminAPI(newFakeRepo())) + for _, r := range rows { + if r.Claimable || r.Owned || r.OwnerUnknown { + t.Errorf("%s = %+v, want no claim state (no business row)", r.Name, r) + } + } + }) + + t.Run("system services are never claimable", func(t *testing.T) { + sysCl := newFakeCluster() + sysCl.list = []ServerInfo{{Name: "lobby", Phase: "Running", Ready: true}} repo := newFakeRepo() - repo.owners["survival"] = "alice@example.net" // would merge, but the lookup errors + repo.owners["lobby"] = ServerOwnership{} + api := newTestAPI(repo, sysCl) + api.External = staticExternal{p: &Principal{UserID: "a1", Email: "a1@example.net", + Role: "admin", ViaAdminAccess: true}} + rows := readFleet(t, api) + if len(rows) != 1 || rows[0].Claimable { + t.Fatalf("rows = %+v, want lobby present and not claimable", rows) + } + }) + + t.Run("owner lookup failure marks ownership unknown", func(t *testing.T) { + repo := newFakeRepo() + repo.owners["survival"] = ServerOwnership{OwnerID: "u2", Owner: "alice@example.net"} // would merge, but the lookup errors + repo.owners["skyblock"] = ServerOwnership{} 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) + if r.Owner != "" || r.Claimable || r.Owned || !r.OwnerUnknown { + t.Fatalf("%s = %+v, want owner unknown and nothing to claim", r.Name, r) } } }) diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 782c8ae..63d421f 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -296,15 +296,20 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } - // 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()) + // Ownership is best-effort. The cockpit exists for the lifecycle view, so a + // Postgres hiccup must never 500 the whole fleet; the rows say the owner is + // unknown instead, and none offers a claim that may already be taken. + p := principalFromContext(r.Context()) + owners, err := a.Repo.ServerOwners(r.Context()) + unknown := err != nil views := make([]fleetServerView, len(servers)) for i, s := range servers { - views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name], - System: naming.IsSystemServer(s.Name)} + o, known := owners[s.Name] + system := naming.IsSystemServer(s.Name) + views[i] = fleetServerView{ServerInfo: s, Owner: o.Owner, System: system, + Owned: o.OwnerID != "" && o.OwnerID == p.UserID, + Claimable: known && o.OwnerID == "" && !system, + OwnerUnknown: unknown} } writeJSON(w, http.StatusOK, map[string]any{"servers": views}) } @@ -316,9 +321,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) { 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". + // address is absent), or "" when the server is unclaimed or the owner lookup + // failed (OwnerUnknown tells the two apart). Owner string `json:"owner,omitempty"` + // Owned is true when the caller claimed this server, decided by account id so + // an owner without an email is still recognized. + Owned bool `json:"owned"` + // Claimable is true for a live, unclaimed, non-system server: the same rule + // ClaimServer enforces. It is false whenever ownership is unknown. + Claimable bool `json:"claimable"` + // OwnerUnknown is true when the best-effort owner lookup failed, so an empty + // Owner says nothing about whether the server is claimed. + OwnerUnknown bool `json:"ownerUnknown,omitempty"` // System marks a platform-provisioned system service (the login gate and the // lobby, naming.IsSystemServer). Their names are reserved, so every per-server // API route rejects them — the cockpit must render them read-only rather than diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 5f7a68f..a2de4a1 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -609,29 +609,29 @@ 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 +// ServerOwners returns name -> claim state for every non-deleted server (the +// SysAdmin cockpit's fleet read). The LEFT JOIN keeps unclaimed servers (owner_id +// NULL) with an empty OwnerID, and the deleted_at filter drops soft-deleted ones. +// 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]ServerOwnership, error) { + const q = `SELECT s.name, COALESCE(s.owner_id, ''), COALESCE(NULLIF(u.email, ''), u.username, '') + FROM servers s LEFT 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) + out := make(map[string]ServerOwnership) for rows.Next() { - var name, owner string - if err := rows.Scan(&name, &owner); err != nil { + var name string + var o ServerOwnership + if err := rows.Scan(&name, &o.OwnerID, &o.Owner); err != nil { return nil, err } - out[name] = owner + out[name] = o } return out, rows.Err() } diff --git a/internal/api/repo.go b/internal/api/repo.go index c9e6e65..9da1b8f 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -38,6 +38,17 @@ type MyServerView struct { PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"` } +// ServerOwnership is one live server's claim state as the fleet read joins it. +type ServerOwnership struct { + // OwnerID is the claiming account, "" while the server is unclaimed. The fleet + // compares it with the caller's id: an account without an email shows its + // username as Owner, so the display text cannot say whose server it is. + OwnerID string + // Owner is the claiming account's display identity (email, or username when + // the address is absent), "" while unclaimed. + Owner string +} + // AuditEntry is one row written to audit_logs (spec §6). Actor is display text: // a verified email or the username for people (auditActor), the component name // for internal callers. ActorUserID is the account that acted, the column to @@ -286,15 +297,14 @@ 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) + // ServerOwners maps every live server to its claim state, for the SysAdmin + // cockpit's fleet read. It is a READ-ONLY 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. An unclaimed server is present with an empty + // OwnerID; a soft-deleted one, or a CRD with no business row, is absent, and + // cannot be claimed. The cockpit treats it as best-effort: a lookup error leaves + // ownership unknown rather than failing the fleet read. + ServerOwners(ctx context.Context) (map[string]ServerOwnership, 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/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index 8feffff..77a6faa 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -551,6 +551,54 @@ func TestClaimServerQuotaAtomicGate(t *testing.T) { } } +// The fleet read needs every live server's claim state: the owner's id to tell +// the caller's own servers apart, the display name (email, else username), and +// the unclaimed rows too, since only those may be claimed. A soft-deleted row is +// gone. +func TestServerOwnersJoin(t *testing.T) { + ctx := context.Background() + sfx := suffix(t) + withEmail, err := repo.CreateUser(ctx, + api.CreateUserInput{Username: "own-mail-" + sfx, Email: "own-" + sfx + "@example.test", Role: "user"}, "pgint") + if err != nil { + t.Fatalf("CreateUser with email: %v", err) + } + noEmail := newUser(t, "admin", "own-bare") + seed := func(name string, owner any, deleted bool) { + t.Helper() + if _, err := db.ExecContext(ctx, + `INSERT INTO servers (name, owner_id, deleted_at, cached_cpu_milli, cached_memory_mb, cached_storage_mb) + VALUES ($1, $2, CASE WHEN $3 THEN now() END, 100, 128, 1)`, + name, owner, deleted); err != nil { + t.Fatalf("seed server %s: %v", name, err) + } + } + mailed, bare, free, gone := "om-"+sfx, "ob-"+sfx, "of-"+sfx, "od-"+sfx + seed(mailed, withEmail.ID, false) + seed(bare, noEmail.ID, false) + seed(free, nil, false) + seed(gone, nil, true) + + owners, err := repo.ServerOwners(ctx) + if err != nil { + t.Fatalf("ServerOwners: %v", err) + } + want := map[string]api.ServerOwnership{ + mailed: {OwnerID: withEmail.ID, Owner: "own-" + sfx + "@example.test"}, + bare: {OwnerID: noEmail.ID, Owner: noEmail.Username}, + free: {}, + } + for name, w := range want { + got, ok := owners[name] + if !ok || got != w { + t.Errorf("owners[%s] = %+v (present %v), want %+v", name, got, ok, w) + } + } + if o, ok := owners[gone]; ok { + t.Errorf("owners[%s] = %+v, want absent (soft-deleted)", gone, o) + } +} + // An admin email edit must not carry a verification over to an address nobody // proved: the verified flag is exactly what the pre-session login resolves on // (UserByEmail), and only VerifyEmailOTP may assert it — the same rationale as diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index a6ca9ab..112eed6 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -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 { 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 diff --git a/panel/src/components/StatCard.tsx b/panel/src/components/StatCard.tsx index 16bbf4f..3a2e09d 100644 --- a/panel/src/components/StatCard.tsx +++ b/panel/src/components/StatCard.tsx @@ -32,7 +32,7 @@ export function StatCard({ icon: Icon, label, value, accentClass, accentColor, v
({ isAdmin: true, identity: { email: "admin@example.test" } })); +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(); + return { + ...actual, + loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test" }), + }; +}); +vi.mock("@/lib/api", async (importActual) => { + const actual = await importActual(); + return { ...actual, api: { ...actual.api, ...calls } }; +}); + +function row(name: string, over: Partial): 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: "alice@example.test" }), + row("skyblock", { claimable: true }), + ]); + render( + + + , + ); + + 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( + + + , + ); + + const survival = await tableRow("survival"); + expect(survival.getByText("Unknown")).toBeTruthy(); + expect(survival.queryByText("Unclaimed")).toBeNull(); + expect(survival.queryByRole("button", { name: /Claim/ })).toBeNull(); + }); +}); diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx index 5450a00..7be3321 100644 --- a/panel/src/pages/servers/ServersPage.tsx +++ b/panel/src/pages/servers/ServersPage.tsx @@ -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>( @@ -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 = { @@ -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. */} -
    + {/* 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. */} +
      {paged.map((s) => ( ))}
    - +
    @@ -551,12 +554,24 @@ function OwnerLabel({ server }: { server: UnifiedServer }) { return ( + {server.owned && ( + + {t("fleet_owner_you")} + + )} {server.owner} ); } + if (server.ownerUnknown) { + return ( + + {t("fleet_owner_unknown")} + + ); + } return {t("fleet_unclaimed")}; }