feat(distributed): 支持单主控多节点部署和停服迁移
复用现有 k3s 调度和 Job 生命周期,增加 worker 接入与批准、受保护节点身份、归档传输、持久迁移锁及活动 PVC 切换;同步管理员 API、CLI、面板和隔离规则。分布式模式默认关闭,保持单机兼容。 验证:Go 全量测试与 vet;面板 874 个测试、lint/build;Linux VM 安装器测试、清单服务端 dry-run、网络命名空间防火墙实测。A/B/C 三机 WireGuard、Velocity 和迁移验收仍待完成。
This commit is contained in:
77 files changed
+5224
-73
No files matched your search
@@ -6,7 +6,7 @@ import { CreateServerDialog } from "./CreateServerDialog";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import type { CreateServerRequest, WhitelistImage } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn() }));
|
||||
const calls = vi.hoisted(() => ({ listImages: vi.fn(), createServer: vi.fn(), nodes: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
@@ -258,3 +258,25 @@ describe("CreateServerDialog on reopening", () => {
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("distributed placement", () => {
|
||||
it("requires an online approved worker and sends its name", async () => {
|
||||
calls.nodes.mockResolvedValue([
|
||||
{name:"b",role:"worker",ready:true,approved:true},
|
||||
{name:"c",role:"worker",ready:false,approved:true},
|
||||
{name:"a",role:"controller",ready:true,approved:true},
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
render(<CreateServerDialog cfg={{...cfg,distributed:true}} onCreated={onCreated} />);
|
||||
await user.click(screen.getByRole("button",{name:"New server"}));
|
||||
await fillValid(user);
|
||||
expect(create().disabled).toBe(true);
|
||||
await user.click(screen.getByRole("combobox",{name:"Execution node"}));
|
||||
expect(screen.queryByRole("option",{name:"c"})).toBeNull();
|
||||
expect(screen.queryByRole("option",{name:"a"})).toBeNull();
|
||||
await user.click(await screen.findByRole("option",{name:"b"}));
|
||||
await user.click(create());
|
||||
expect(sent().nodeName).toBe("b");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,9 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
// or retired since the page loaded shows up or drops out without a reload.
|
||||
const images = useAsync(() => (open ? api.listImages() : Promise.resolve(null)), [open]);
|
||||
|
||||
const nodes = useAsync(() => (open && cfg.distributed ? api.nodes() : Promise.resolve(null)), [open, cfg.distributed]);
|
||||
const workers = (nodes.data ?? []).filter((n) => n.role === "worker" && n.ready && n.approved);
|
||||
|
||||
const [form, setForm] = useState<CreateServerRequest>({
|
||||
name: "",
|
||||
subdomain: "",
|
||||
@@ -89,6 +92,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
nameIssue === null &&
|
||||
subdomainIssue === null &&
|
||||
!!image &&
|
||||
(!cfg.distributed || workers.some((n) => n.name === form.nodeName)) &&
|
||||
!!form.memory &&
|
||||
!!form.storage &&
|
||||
!submitting;
|
||||
@@ -216,6 +220,18 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cfg.distributed && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cs-node">{t("node")}</Label>
|
||||
<Select value={form.nodeName ?? ""} onValueChange={(v) => set("nodeName", v)}>
|
||||
<SelectTrigger id="cs-node"><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
|
||||
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
|
||||
{!nodes.loading && !nodes.error && workers.length === 0 && <p className="text-xs text-muted-foreground">{t("node_none")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="create-server-memory">{t("create_server_memory")}</Label>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MigrationDialog } from "./DistributedNodes";
|
||||
|
||||
const calls = vi.hoisted(() => ({ nodes: vi.fn(), migration: vi.fn(), retryMigration: vi.fn(), migrateServer: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
calls.nodes.mockResolvedValue([{ name: "c", role: "worker", ready: true, approved: true }]);
|
||||
calls.migration.mockResolvedValue({ id: "persisted-op", state: "failed", sourceNode: "b", targetNode: "c", error: "source node is offline" });
|
||||
calls.retryMigration.mockResolvedValue({ id: "persisted-op", state: "backing_up" });
|
||||
});
|
||||
|
||||
describe("durable migration", () => {
|
||||
it("loads the persisted operation on opening and retries its ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
const changed = vi.fn();
|
||||
render(<MigrationDialog name="survival" nodeName="b" stopped onChanged={changed} />);
|
||||
await user.click(screen.getByRole("button", { name: "Migrate world" }));
|
||||
await screen.findByText("source node is offline");
|
||||
expect(calls.migration).toHaveBeenCalledWith("survival");
|
||||
expect(screen.getByText("persisted-op")).toBeTruthy();
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
await waitFor(() => expect(calls.retryMigration).toHaveBeenCalledWith("survival", "persisted-op"));
|
||||
expect(changed).toHaveBeenCalled();
|
||||
expect(calls.migrateServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps retry disabled until the server is stopped", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<MigrationDialog name="survival" nodeName="b" stopped={false} onChanged={() => {}} />);
|
||||
await user.click(screen.getByRole("button", { name: "Migrate world" }));
|
||||
const retry = await screen.findByRole("button", { name: "Try again" });
|
||||
expect((retry as HTMLButtonElement).disabled).toBe(true);
|
||||
await user.click(retry);
|
||||
expect(calls.retryMigration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, usePolling } from "@/lib/hooks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function DistributedNodes() {
|
||||
const { t } = useTranslation("servers");
|
||||
const nodes = useAsync(api.nodes);
|
||||
usePolling(nodes.reload, 10_000);
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h2 className="font-medium">{t("nodes")}</h2>
|
||||
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
|
||||
<ul className="space-y-1 text-sm">{nodes.data?.map((n) => (
|
||||
<li key={n.name} className="flex flex-wrap gap-3">
|
||||
<code>{n.name}</code><span>{n.role || t("node_pending")}</span>
|
||||
<span>{n.ready ? t("node_online") : t("node_offline")}</span>
|
||||
<span>{n.approved ? t("node_approved") : t("node_pending")}</span>
|
||||
<span className="text-muted-foreground">{n.architecture} · {n.addresses.join(", ")}</span>
|
||||
</li>
|
||||
))}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MigrationDialog({ name, nodeName, stopped, onChanged }: {
|
||||
name: string; nodeName?: string; stopped: boolean; onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [target, setTarget] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const nodes = useAsync(() => open ? api.nodes() : Promise.resolve(null), [open]);
|
||||
const progress = useAsync(async () => {
|
||||
if (!open) return null;
|
||||
try { return await api.migration(name); }
|
||||
catch (e) { if ((e as { status?: number }).status === 404) return null; throw e; }
|
||||
}, [open, name]);
|
||||
usePolling(() => { if (open) progress.reload(); }, 3_000);
|
||||
const op = progress.data;
|
||||
const active = !!op && op.state !== "succeeded" && op.state !== "failed";
|
||||
const workers = (nodes.data ?? []).filter((n) => n.ready && n.approved && n.role === "worker" && n.name !== nodeName);
|
||||
async function run(retry: boolean) {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
if (retry && op) await api.retryMigration(name, op.id);
|
||||
else await api.migrateServer(name, target);
|
||||
progress.reload(); onChanged();
|
||||
} catch (e) { setError(humanizeError(e)); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm" variant="outline">{t("migration")}</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{t("migration")}</DialogTitle><DialogDescription>{t("migration_hint")}</DialogDescription></DialogHeader>
|
||||
{nodeName && <p className="text-sm">{t("node")}: {nodeName}</p>}
|
||||
{op && <div className="space-y-1 text-sm" role="status">
|
||||
<p>{op.sourceNode} → {op.targetNode}</p>
|
||||
<p>{t(`migration_state_${op.state}`, { defaultValue: op.state })}</p>
|
||||
<code className="text-xs break-all">{op.id}</code>
|
||||
{op.error && <InlineError message={op.error} />}
|
||||
</div>}
|
||||
{!!progress.error && <InlineError message={humanizeError(progress.error)} />}
|
||||
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
|
||||
<Label htmlFor={`target-${name}`}>{t("node_choose")}</Label>
|
||||
<Select value={target} onValueChange={setTarget} disabled={active || busy || op?.state === "failed"}>
|
||||
<SelectTrigger id={`target-${name}`}><SelectValue placeholder={t("node_choose")} /></SelectTrigger>
|
||||
<SelectContent>{workers.map((n) => <SelectItem key={n.name} value={n.name}>{n.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
{!stopped && <p className="text-sm text-muted-foreground">{t("migration_stop")}</p>}
|
||||
<InlineError message={error} />
|
||||
{op?.state === "failed" ? (
|
||||
<Button onClick={() => run(true)} disabled={!stopped || busy}>{t("common:try_again")}</Button>
|
||||
) : (
|
||||
<Button onClick={() => run(false)} disabled={!stopped || active || busy || progress.loading || !!progress.error || !workers.some((n) => n.name === target)}>{t("migration_start")}</Button>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -251,5 +251,22 @@
|
||||
"retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.",
|
||||
"retiring_badge_release": "Given up",
|
||||
"retiring_badge_delete": "Deleting",
|
||||
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then."
|
||||
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then.",
|
||||
"nodes": "Execution nodes",
|
||||
"node": "Execution node",
|
||||
"node_choose": "Choose an approved node",
|
||||
"node_none": "No online approved worker nodes",
|
||||
"node_online": "Online",
|
||||
"node_offline": "Offline",
|
||||
"node_approved": "Approved",
|
||||
"node_pending": "Pending approval",
|
||||
"migration": "Migrate world",
|
||||
"migration_hint": "The server stays stopped after migration. The source volume is retained; check the world before starting it.",
|
||||
"migration_stop": "Stop the server and wait for the game process to exit first.",
|
||||
"migration_start": "Start migration",
|
||||
"migration_state_backing_up": "Archiving source world",
|
||||
"migration_state_restoring": "Restoring and verifying target world",
|
||||
"migration_state_switching": "Switching active volume",
|
||||
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
||||
"migration_state_failed": "Migration failed; server remains stopped"
|
||||
}
|
||||
@@ -250,5 +250,22 @@
|
||||
"retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。",
|
||||
"retiring_badge_release": "等待回收",
|
||||
"retiring_badge_delete": "等待删除",
|
||||
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。"
|
||||
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。",
|
||||
"nodes": "执行节点",
|
||||
"node": "执行位置",
|
||||
"node_choose": "选择已批准的节点",
|
||||
"node_none": "没有在线且已批准的执行节点",
|
||||
"node_online": "在线",
|
||||
"node_offline": "失联",
|
||||
"node_approved": "已批准",
|
||||
"node_pending": "待批准",
|
||||
"migration": "停服迁移",
|
||||
"migration_hint": "迁移完成后仍保持停服。源卷保留,请检查后手动启动。",
|
||||
"migration_stop": "请先停服并等待游戏进程退出。",
|
||||
"migration_start": "开始迁移",
|
||||
"migration_state_backing_up": "正在归档源卷",
|
||||
"migration_state_restoring": "正在恢复并校验目标卷",
|
||||
"migration_state_switching": "正在切换活动卷",
|
||||
"migration_state_succeeded": "迁移完成,等待手动启动",
|
||||
"migration_state_failed": "迁移失败,服务器保持停服"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
AccessResult,
|
||||
ExecutionNode,
|
||||
WorldMigration,
|
||||
AllowlistEntry,
|
||||
ApiError,
|
||||
AutostartPolicy,
|
||||
@@ -594,6 +596,13 @@ export const api = rejectingSync({
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
nodes: () => request<{ nodes: ExecutionNode[] }>("GET", "/nodes").then((r) => r.nodes),
|
||||
migration: (name: string) => request<WorldMigration>("GET", `/servers/${encodeURIComponent(name)}/migrations`),
|
||||
migrateServer: (name: string, targetNode: string) =>
|
||||
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations`, { targetNode }),
|
||||
retryMigration: (name: string, id: string) =>
|
||||
request<WorldMigration>("POST", `/servers/${encodeURIComponent(name)}/migrations/${encodeURIComponent(id)}/retry`),
|
||||
|
||||
createServer: (req: CreateServerRequest) =>
|
||||
request<{ name: string; subdomain: string; desiredState: string }>(
|
||||
"POST",
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("loadConfig", () => {
|
||||
const cfg = await loadConfig();
|
||||
expect(cfg).toEqual({
|
||||
apiBase: "/api/v1",
|
||||
distributed: false,
|
||||
rootDomain: "mc.example",
|
||||
panelHostname: undefined,
|
||||
adminHostname: "op.console.mc.example",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface BuildInfo {
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
distributed?: boolean;
|
||||
apiBase: string;
|
||||
rootDomain: string;
|
||||
/** Player-console hostname (console.<root>), absent when unconfigured. */
|
||||
@@ -79,6 +80,7 @@ export async function loadConfig(): Promise<RuntimeConfig> {
|
||||
}
|
||||
cached = {
|
||||
apiBase: raw.apiBase ?? FALLBACK.apiBase,
|
||||
distributed: raw.distributed === true,
|
||||
rootDomain: raw.rootDomain,
|
||||
panelHostname: raw.panelHostname,
|
||||
adminHostname: raw.adminHostname,
|
||||
|
||||
@@ -4,6 +4,75 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/nodes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List execution nodes (administrator). */
|
||||
get: operations["executionNodes"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/migrations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read durable migration progress. */
|
||||
get: operations["latestWorldMigrationStatus"];
|
||||
put?: never;
|
||||
/** Migrate an already stopped world; persistent lock survives controller restart. */
|
||||
post: operations["startWorldMigration"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/migrations/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read durable migration progress. */
|
||||
get: operations["worldMigrationStatus"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/migrations/{id}/retry": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Retry a failed migration, retaining the source and stopped state. */
|
||||
post: operations["retryWorldMigration"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/healthz": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2810,8 +2879,42 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
Phase: "Unknown" | "Stopped" | "Starting" | "Running" | "Stopping" | "Failed";
|
||||
ExecutionNode: {
|
||||
name: string;
|
||||
role: string;
|
||||
ready: boolean;
|
||||
approved: boolean;
|
||||
addresses: string[];
|
||||
architecture: string;
|
||||
};
|
||||
WorldMigration: {
|
||||
id: string;
|
||||
server: string;
|
||||
/** @enum {string} */
|
||||
state: "backing_up" | "restoring" | "switching" | "succeeded" | "failed";
|
||||
stage: string;
|
||||
sourceNode: string;
|
||||
targetNode: string;
|
||||
sourcePVC: string;
|
||||
targetPVC: string;
|
||||
backup: {
|
||||
ref: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256: string;
|
||||
};
|
||||
/** Format: date-time */
|
||||
started: string;
|
||||
/** Format: date-time */
|
||||
updated: string;
|
||||
switched: boolean;
|
||||
attempt: number;
|
||||
error?: string;
|
||||
};
|
||||
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
|
||||
ServerInfo: {
|
||||
/** @description Execution node; legacy servers report the observed node. */
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
phase: components["schemas"]["Phase"];
|
||||
@@ -3561,6 +3664,154 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
executionNodes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Accepted operation or current state. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
nodes: components["schemas"]["ExecutionNode"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
latestWorldMigrationStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Accepted operation or current state. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorldMigration"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
startWorldMigration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
targetNode: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Accepted operation or current state. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorldMigration"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
worldMigrationStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Accepted operation or current state. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorldMigration"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
retryWorldMigration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Accepted operation or current state. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WorldMigration"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
healthz: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3721,6 +3972,8 @@ export interface operations {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @description Required approved worker in distributed mode; administrator only. */
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
/** @description Trimmed; at most 64 characters, all visible ones or spaces (400 bad_display_name otherwise). */
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MyServerView {
|
||||
* A caller who does not own the server gets the public subset, so everything
|
||||
* past the counts may be absent. */
|
||||
export interface ServerStatus {
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
phase: Phase;
|
||||
@@ -340,6 +341,7 @@ export interface WhitelistImage {
|
||||
|
||||
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
|
||||
export interface CreateServerRequest {
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
displayName?: string;
|
||||
@@ -716,3 +718,25 @@ export interface SessionView {
|
||||
/** On the holder's own list only: the session this request came in on. */
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutionNode {
|
||||
name: string;
|
||||
role: string;
|
||||
ready: boolean;
|
||||
approved: boolean;
|
||||
addresses: string[];
|
||||
architecture: string;
|
||||
}
|
||||
export interface WorldMigration {
|
||||
id: string;
|
||||
server: string;
|
||||
state: string;
|
||||
stage: string;
|
||||
sourceNode: string;
|
||||
targetNode: string;
|
||||
sourcePVC: string;
|
||||
targetPVC: string;
|
||||
error?: string;
|
||||
switched: boolean;
|
||||
attempt: number;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { PhaseBadge, PHASE_KEY, PHASE_COLOR, shownPhase, startFailure } from "@/
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { DistributedNodes, MigrationDialog } from "@/components/DistributedNodes";
|
||||
import { CreateServerDialog } from "@/components/CreateServerDialog";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
@@ -106,6 +107,7 @@ function compareBy(key: SortKey): (a: UnifiedServer, b: UnifiedServer) => number
|
||||
}
|
||||
|
||||
interface UnifiedServer {
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
/** The owner-chosen label; the list leads with it and keeps the name beside. */
|
||||
@@ -163,6 +165,7 @@ export function ServersPage() {
|
||||
if (isAdmin) {
|
||||
return (data as FleetServer[]).map((s) => ({
|
||||
name: s.name,
|
||||
nodeName: s.nodeName,
|
||||
displayName: s.displayName,
|
||||
subdomain: s.subdomain,
|
||||
phase: s.phase,
|
||||
@@ -276,6 +279,8 @@ export function ServersPage() {
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{isAdmin && cfg?.distributed && <DistributedNodes />}
|
||||
|
||||
{showInitialLoading ? (
|
||||
<Loading />
|
||||
) : showInitialError ? (
|
||||
@@ -508,6 +513,7 @@ function ServerActions({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t: ts } = useTranslation("servers");
|
||||
const cfg = useConfig();
|
||||
const [busy, setBusy] = useState<null | "claim">(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -543,6 +549,7 @@ function ServerActions({
|
||||
return (
|
||||
<div className={cn("flex flex-col items-end gap-1", className)}>
|
||||
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
|
||||
{isAdmin && cfg?.distributed && <MigrationDialog name={server.name} nodeName={server.nodeName} stopped={server.phase === "Stopped" && !server.ready && server.desiredState === "Stopped" && !server.retiring} onChanged={onChanged} />}
|
||||
{claimable && (
|
||||
<>
|
||||
<Button
|
||||
@@ -655,6 +662,10 @@ function PlayersLabel({ server }: { server: UnifiedServer }) {
|
||||
}
|
||||
|
||||
function EndpointLabel({ server }: { server: UnifiedServer }) {
|
||||
return <div className="space-y-1">{server.nodeName && <code className="text-xs">{server.nodeName}</code>}<EndpointAddress server={server} /></div>;
|
||||
}
|
||||
|
||||
function EndpointAddress({ server }: { server: UnifiedServer }) {
|
||||
const { t } = useTranslation("ops");
|
||||
const endpoint = server.ready && server.endpointAddress ? server.endpointAddress : null;
|
||||
if (!endpoint) {
|
||||
|
||||
Reference in new issue
Block a user