feat(panel): fleet
This commit is contained in:
14 files changed
+978
-38
No files matched your search
+15
-4
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "没有服务器符合当前的搜索或筛选条件。"
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user