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

+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 });