feat(panel): fleet

This commit is contained in:
Lemon-miaow committed 2026-07-01 03:46:07 +08:00
1 parent 742f15f348
commit d2de11af22
14 files changed
+978 -38

No files matched your search

+15 -4
View File
@@ -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':
+64 -5
View File
@@ -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: "[email protected]",
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"] = "[email protected]"
byName := map[string]string{}
for _, r := range readFleet(t, adminAPI(repo)) {
byName[r.Name] = r.Owner
}
if byName["survival"] != "[email protected]" {
t.Fatalf("survival owner = %q, want [email protected]", 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"] = "[email protected]" // 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)
}
}
})
}
+27 -5
View File
@@ -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
+27
View File
@@ -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
+9
View File
@@ -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)
+75
View File
@@ -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<boolean> {
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 });
+28 -3
View File
@@ -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."
}
+28 -3
View File
@@ -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": "没有服务器符合当前的搜索或筛选条件。"
}
+44
View File
@@ -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: "[email protected]",
},
],
});
vi.stubGlobal("fetch", fetchSpy);
const servers = await api.fleet();
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).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("[email protected]");
});
it("defaults to [] when the body carries no servers key", async () => {
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
expect(await api.fleet()).toEqual([]);
});
});
+8
View File
@@ -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<ServerInfo>("GET", `/servers/${name}/status`),
wake: (name: string) =>
+62
View File
@@ -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", "[email protected]"];
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);
});
});
+55
View File
@@ -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;
}
+26
View File
@@ -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;
+510 -18
View File
@@ -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<AutostartPolicy, string> = {
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 (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div
className="rounded-md p-2"
style={{
backgroundColor: accent ? `${accent}26` : undefined,
color: accent,
}}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-2xl font-semibold leading-none tabular-nums">{value}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
);
}
// 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<Phase | "all">("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<Phase, number> = {
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 (
<>
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("fleet_subtitle")}
</p>
<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 items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground">{t("fleet_subtitle")}</p>
</div>
</div>
<div className="flex items-center gap-3">
<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" />
{t("fleet_live")}
</span>
<Button
variant="outline"
size="sm"
onClick={reload}
disabled={loading}
title={t("fleet_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
<PendingBackend
endpoint="GET /api/v1/admin/servers"
note={t("fleet_table_pending_note")}
/>
{showInitialLoading ? (
<Loading />
) : showInitialError ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading />
) : (
<>
{/* A poll failed but we still have rows — keep the table, warn inline. */}
{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">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{humanizeError(error)}
</div>
)}
{/* Aggregate header — true cluster-wide rollup. */}
<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={Play}
label={t("fleet_stat_running")}
value={stats.counts.Running}
accent={PHASE_COLOR.Running}
/>
<Stat
icon={Users}
label={t("fleet_stat_players")}
value={`${stats.playersOnline} / ${stats.playersMax}`}
/>
<Stat
icon={AlertTriangle}
label={t("fleet_stat_failed")}
value={stats.counts.Failed}
accent={stats.counts.Failed > 0 ? PHASE_COLOR.Failed : undefined}
/>
</div>
{/* Phase distribution bar + legend. */}
{stats.total > 0 && (
<Card>
<CardContent className="space-y-3 p-4">
<div className="text-xs font-medium text-muted-foreground">
{t("fleet_distribution")}
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-secondary">
{PHASES.map((p) =>
stats.counts[p] > 0 ? (
<div
key={p}
className="h-full transition-all"
style={{
width: `${(stats.counts[p] / stats.total) * 100}%`,
backgroundColor: PHASE_COLOR[p],
}}
/>
) : null,
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
{PHASES.filter((p) => stats.counts[p] > 0).map((p) => (
<span key={p} className="inline-flex items-center gap-1.5 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: PHASE_COLOR[p] }}
/>
<span className="text-muted-foreground">{t(PHASE_KEY[p])}</span>
<span className="font-mono font-semibold text-foreground">
{stats.counts[p]}
</span>
</span>
))}
</div>
</CardContent>
</Card>
)}
{/* Controls — search + phase filter + result count. */}
<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
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder={t("fleet_search_placeholder")}
className="pl-9"
/>
</div>
<Select
value={phaseFilter}
onValueChange={(v) => {
setPhaseFilter(v as Phase | "all");
setPage(1);
}}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("fleet_filter_all")}</SelectItem>
{PHASES.map((p) => (
<SelectItem key={p} value={p}>
{t(PHASE_KEY[p])}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-xs text-muted-foreground">
{visible.length === servers.length
? t("fleet_count", { count: servers.length })
: t("fleet_count_filtered", {
shown: visible.length,
total: servers.length,
})}
</span>
</div>
{/* The table. */}
{servers.length === 0 ? (
<EmptyState title={t("fleet_empty_title")} hint={t("fleet_empty_hint")} />
) : visible.length === 0 ? (
<EmptyState
title={t("fleet_no_match_title")}
hint={t("fleet_no_match_hint")}
/>
) : (
<>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[56rem] border-collapse text-sm">
<thead>
<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_server")}</th>
<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_policy")}</th>
<th className="px-4 py-2.5 font-medium">{t("fleet_col_endpoint")}</th>
<th className="px-4 py-2.5 text-right font-medium">
{t("fleet_col_actions")}
</th>
</tr>
</thead>
<tbody>
{paged.map((s) => (
<FleetRow key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</tbody>
</table>
</div>
</Card>
{totalPages > 1 && (
<Pagination
page={safePage}
pageSize={PAGE_SIZE}
total={visible.length}
onChange={setPage}
/>
)}
</>
)}
</>
)}
</div>
);
}
/** 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 | "wake" | "stop">(null);
const [error, setError] = useState<string | null>(null);
async function act(kind: "wake" | "stop", fn: () => Promise<unknown>) {
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 (
<>
<tr className="border-b border-border/50 transition-colors last:border-0 hover:bg-muted/40">
<td className="px-4 py-3 align-middle">
<PhaseBadge phase={server.phase} />
</td>
<td className="px-4 py-3 align-middle">
<div className="font-medium text-foreground">{server.name}</div>
{host && (
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{host}
<ExternalLink className="h-3 w-3" />
</a>
)}
</td>
<td className="px-4 py-3 align-middle">
{server.owner ? (
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate" title={server.owner}>
{server.owner}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_unclaimed")}</span>
)}
</td>
<td className="px-4 py-3 align-middle tabular-nums">
{live ? (
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
{server.playersOnline}
<span className="text-muted-foreground">/ {server.playersMax}</span>
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
{server.autostartPolicy ? (
<span className="text-xs text-muted-foreground">
{t(POLICY_KEY[server.autostartPolicy])}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-4 py-3 align-middle">
{endpoint ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{endpoint}
</code>
) : (
<span className="text-xs text-muted-foreground/70">{t("fleet_endpoint_idle")}</span>
)}
</td>
<td className="px-4 py-3 align-middle">
<div className="flex items-center justify-end gap-2">
{live ? (
<Button
size="sm"
variant="destructive"
disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))}
>
<Square /> {busy === "stop" ? ts("stopping") : ts("stop")}
</Button>
) : (
<Button
size="sm"
variant="default"
disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))}
>
<Play /> {busy === "wake" ? ts("waking") : ts("wake")}
</Button>
)}
<Link to={`/servers/${server.name}`}>
<Button size="sm" variant="ghost">
<Terminal /> {ts("console")}
</Button>
</Link>
</div>
</td>
</tr>
{error && (
<tr className="bg-destructive/5">
<td colSpan={7} className="px-4 py-1.5 text-xs text-destructive">
{error}
</td>
</tr>
)}
</>
);
}