diff --git a/docs/distributed.md b/docs/distributed.md
index 5112a28..8fe3b02 100644
--- a/docs/distributed.md
+++ b/docs/distributed.md
@@ -70,6 +70,8 @@ felis node list
批准前 worker 带 `NoSchedule` 隔离 taint,且没有受保护的 approved 标签。批准命令检查架构、版本、节点在线状态、WireGuard、宿主机隔离、kubelet 修改受保护标签被拒绝,删除指定镜像后执行真实 `crictl pull`。随后创建临时探针,检查跨节点 Service、控制服务的正向可达性和游戏标签 Pod 的拒绝路径。A 从宿主机连接每个测试 Service,读取后端实际观察到的源地址,只将属于 A 的精确地址写入游戏策略。任何检查失败都保留隔离状态。批准过程中会删除指定缓存镜像,须在该节点尚无游戏任务时执行。
+Owner 可在面板“平台设置 → 多节点分布式部署”查看部署模式、节点在线及批准状态、接入流程,并进入服务器节点选择与迁移。部署模式由安装器配置;节点批准仍通过主控命令执行完整校验。
+
节点批准后,管理员可在面板创建服务器时选择它,或在创建请求中传 `nodeName`。普通服主不能选节点或指定 PVC。失联节点拒绝新任务;operator 撤销该服务器 Service 的后端和 Ready 状态,Velocity 进入原有 fallback 流程,不换机。
## 停服迁移
diff --git a/panel/src/components/DistributedNodes.test.tsx b/panel/src/components/DistributedNodes.test.tsx
index d7d6f93..710cc92 100644
--- a/panel/src/components/DistributedNodes.test.tsx
+++ b/panel/src/components/DistributedNodes.test.tsx
@@ -2,7 +2,7 @@
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";
+import { DistributedNodes, 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) => {
@@ -42,3 +42,16 @@ describe("durable migration", () => {
expect(calls.retryMigration).not.toHaveBeenCalled();
});
});
+
+describe("execution node overview", () => {
+ it("shows a failed read and reloads without interpreting failure as an empty cluster", async () => {
+ calls.nodes.mockRejectedValueOnce({ status: 503, code: "distributed_unavailable" });
+ calls.nodes.mockResolvedValueOnce([]);
+ render();
+ expect(await screen.findByRole("alert")).toHaveProperty("textContent", "Distributed deployment is not configured. Configure the controller and worker nodes using the deployment runbook first.");
+ expect(screen.queryByText(/No execution nodes were found/)).toBeNull();
+ await userEvent.click(screen.getByRole("button", { name: "Reload nodes" }));
+ expect(await screen.findByText(/No execution nodes were found/)).toBeTruthy();
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+});
diff --git a/panel/src/components/DistributedNodes.tsx b/panel/src/components/DistributedNodes.tsx
index 0e9ac2f..7a82819 100644
--- a/panel/src/components/DistributedNodes.tsx
+++ b/panel/src/components/DistributedNodes.tsx
@@ -1,3 +1,6 @@
+import { RefreshCw, Loader2 } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api, humanizeError } from "@/lib/api";
@@ -10,21 +13,25 @@ import { Label } from "@/components/ui/label";
export function DistributedNodes() {
const { t } = useTranslation("servers");
- const nodes = useAsync(api.nodes);
+ const nodes = useAsync(api.nodes, [], { coalesce: true });
usePolling(nodes.reload, 10_000);
return (
-
-
{t("nodes")}
+
+ {t("nodes")}
+
+ {nodes.loading && {t("nodes_loading")}
}
+ {!nodes.loading && !nodes.error && nodes.data?.length === 0 && {t("nodes_empty")}
}
{!!nodes.error && }
{nodes.data?.map((n) => (
-
-
{n.name}{n.role || t("node_pending")}
- {n.ready ? t("node_online") : t("node_offline")}
- {n.approved ? t("node_approved") : t("node_pending")}
+ {n.name}{t(n.role === "controller" ? "node_controller" : n.role === "worker" ? "node_worker" : "node_unassigned")}
+ {n.ready ? t("node_online") : t("node_offline")}
+ {n.role === "worker" && {n.approved ? t("node_approved") : t("node_pending")}}
{n.architecture} · {n.addresses.join(", ")}
))}
-
+
+
);
}
diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json
index ba37788..8bec0ea 100644
--- a/panel/src/i18n/resources/en-US/admin.json
+++ b/panel/src/i18n/resources/en-US/admin.json
@@ -321,5 +321,19 @@
"platform_unsaved": "Unsaved changes",
"platform_live": "Saved changes apply immediately on all API replicas.",
"platform_discard": "Discard changes",
- "platform_save": "Save and apply"
+ "platform_save": "Save and apply",
+ "platform_distribution_title": "Distributed deployment",
+ "platform_distribution_unknown": "Deployment mode unconfirmed",
+ "platform_distribution_enabled": "Distributed mode enabled",
+ "platform_distribution_disabled": "Single-node mode",
+ "platform_distribution_architecture": "One controller runs the API, operator, database, registry, and public entry point. Multiple workers run game servers and maintenance tasks. Multiple controllers, controller high availability, and automatic failover are not supported.",
+ "platform_distribution_enable_hint": "Enabling distributed mode requires installer configuration of controller identity, WireGuard, archive transport, and node isolation during a maintenance window. This deployment configuration cannot be switched live from the panel.",
+ "platform_distribution_join": "Connect and approve worker nodes",
+ "platform_distribution_prepare": "Back up the database and k3s state, configure the controller using the deployment runbook, and update the complete peer address list on all existing nodes. Workers must use the same architecture and k3s version as the controller.",
+ "platform_distribution_token": "On the controller, create a separate expiring bootstrap token for each worker as root. Transfer the token file over trusted SSH/SCP. Do not provide the server token or administrator kubeconfig to workers. Replace command placeholders with actual values.",
+ "platform_distribution_worker": "Run felis node join on the worker using the runbook, supplying the controller address, fixed node address, token file, Registry address, and complete peer list. New nodes remain isolated with NoSchedule until approval.",
+ "platform_distribution_approve": "Approve the node from the controller. The command verifies identity, network isolation, image pulling, and cross-node connectivity. Failed checks preserve isolation. Approval removes the specified cached image and must run before the node hosts game workloads.",
+ "platform_distribution_runbook": "Deployment and acceptance runbook",
+ "platform_distribution_management": "Online approved workers can be selected when creating a server. Existing servers must be fully stopped before migration. Successful migration leaves the server stopped and retains its source world volume.",
+ "platform_distribution_servers": "Manage server placement and migration"
}
diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json
index 7b0095e..b3b553c 100644
--- a/panel/src/i18n/resources/en-US/errors.json
+++ b/panel/src/i18n/resources/en-US/errors.json
@@ -144,5 +144,6 @@
"minecraft_profile_not_found": "The profile was not found in the selected authentication source. Check the profile name or UUID.",
"auth_sources_unavailable": "Authentication source management is unavailable.",
"auth_sources_changed": "Authentication sources changed. Discard your edits and reload before saving.",
- "auth_source_tag_locked": "Saved source IDs cannot be renamed or removed. Disable the source instead."
+ "auth_source_tag_locked": "Saved source IDs cannot be renamed or removed. Disable the source instead.",
+ "distributed_unavailable": "Distributed deployment is not configured. Configure the controller and worker nodes using the deployment runbook first."
}
diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json
index 78541bb..d56c6d3 100644
--- a/panel/src/i18n/resources/en-US/servers.json
+++ b/panel/src/i18n/resources/en-US/servers.json
@@ -297,5 +297,11 @@
"host_recovery_verify": "If a Pod is terminating or its node is unreachable, process termination cannot be confirmed. Inspect the node and container runtime. Force-deleting a Pod is not evidence of process termination.",
"log_show_rcon": "Show RCON connection logs",
"log_hide_rcon": "Hide RCON connection logs",
- "log_rcon_only": "RCON connection lifecycle logs are hidden. Other logs will appear when available."
+ "log_rcon_only": "RCON connection lifecycle logs are hidden. Other logs will appear when available.",
+ "nodes_loading": "Loading execution nodes…",
+ "nodes_empty": "No execution nodes were found. Check cluster connectivity and node enrollment.",
+ "node_controller": "Controller",
+ "node_worker": "Worker",
+ "node_unassigned": "Unassigned role",
+ "nodes_reload": "Reload nodes"
}
diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json
index aee9c96..65ffe01 100644
--- a/panel/src/i18n/resources/zh-CN/admin.json
+++ b/panel/src/i18n/resources/zh-CN/admin.json
@@ -317,5 +317,19 @@
"platform_unsaved": "有尚未保存的更改",
"platform_live": "保存后会立即在所有 API 实例上生效。",
"platform_discard": "放弃更改",
- "platform_save": "保存并生效"
+ "platform_save": "保存并生效",
+ "platform_distribution_title": "多节点分布式部署",
+ "platform_distribution_unknown": "部署模式未确认",
+ "platform_distribution_enabled": "分布式模式已启用",
+ "platform_distribution_disabled": "单节点模式",
+ "platform_distribution_architecture": "一个主控运行 API、operator、数据库、镜像仓库和公网入口;多个 worker 节点承载游戏服务器及维护任务。当前不支持多个主控、主控高可用或自动故障迁移。",
+ "platform_distribution_enable_hint": "启用分布式模式需要在维护窗口使用安装器配置主控身份、WireGuard、归档服务和节点隔离。该部署配置不能通过面板即时切换。",
+ "platform_distribution_join": "接入与批准 worker 节点",
+ "platform_distribution_prepare": "先备份数据库与 k3s 状态,按部署文档配置主控,并更新所有现有节点的完整对等地址列表。worker 必须与主控使用相同架构和 k3s 版本。",
+ "platform_distribution_token": "在主控以 root 为每个 worker 创建独立的限时接入令牌。令牌文件须通过可信 SSH/SCP 传输,不得向 worker 提供 server token 或管理员 kubeconfig。以下命令中的占位符须替换为实际值。",
+ "platform_distribution_worker": "在 worker 执行部署文档中的 felis node join,填写主控地址、固定节点地址、令牌文件、Registry 地址及完整对等地址列表。新节点在批准前保持 NoSchedule 隔离状态。",
+ "platform_distribution_approve": "在主控批准节点。该命令验证节点身份、网络隔离、镜像拉取和跨节点连通性;任何检查失败均保留隔离状态。批准检查会删除指定缓存镜像,须在该节点尚无游戏任务时执行。",
+ "platform_distribution_runbook": "查看完整部署与验收流程",
+ "platform_distribution_management": "在线且已批准的 worker 可在创建服务器时选择。已有服务器须完全停止后才能迁移;迁移完成后仍保持停止,源世界卷保留。",
+ "platform_distribution_servers": "管理服务器节点与迁移"
}
diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json
index e3952ea..c113042 100644
--- a/panel/src/i18n/resources/zh-CN/errors.json
+++ b/panel/src/i18n/resources/zh-CN/errors.json
@@ -144,5 +144,6 @@
"minecraft_profile_not_found": "所选认证源中不存在此角色。请检查角色名或 UUID。",
"auth_sources_unavailable": "认证源管理暂不可用。",
"auth_sources_changed": "认证源已被其他操作修改。请放弃当前更改并重新读取后再保存。",
- "auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。"
+ "auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。",
+ "distributed_unavailable": "分布式部署未配置。请先按部署流程配置主控与 worker 节点。"
}
diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json
index faa34f8..3268029 100644
--- a/panel/src/i18n/resources/zh-CN/servers.json
+++ b/panel/src/i18n/resources/zh-CN/servers.json
@@ -296,5 +296,11 @@
"host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。",
"log_show_rcon": "显示 RCON 连接日志",
"log_hide_rcon": "隐藏 RCON 连接日志",
- "log_rcon_only": "RCON 连接生命周期日志已隐藏。其他日志将在产生后显示。"
+ "log_rcon_only": "RCON 连接生命周期日志已隐藏。其他日志将在产生后显示。",
+ "nodes_loading": "正在读取执行节点…",
+ "nodes_empty": "未发现执行节点。请检查集群连接及节点接入状态。",
+ "node_controller": "主控节点",
+ "node_worker": "Worker 节点",
+ "node_unassigned": "未分配角色",
+ "nodes_reload": "重新读取"
}
diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts
index c40b5fd..2cd26b5 100644
--- a/panel/src/lib/api.ts
+++ b/panel/src/lib/api.ts
@@ -1253,6 +1253,8 @@ export function humanizeError(e: unknown): string {
switch (err.code) {
// Session doors (spec §B): every passwordless door 403s this when local
// sessions are disabled on a Zero-Trust-only deployment.
+ case "distributed_unavailable":
+ return t("distributed_unavailable");
case "local_auth_disabled":
return t("local_auth_disabled");
case "staff_account":
diff --git a/panel/src/pages/admin/PlatformSettingsPage.test.tsx b/panel/src/pages/admin/PlatformSettingsPage.test.tsx
index 022445e..6054aba 100644
--- a/panel/src/pages/admin/PlatformSettingsPage.test.tsx
+++ b/panel/src/pages/admin/PlatformSettingsPage.test.tsx
@@ -5,14 +5,19 @@ import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { PlatformSettingsPage } from "./PlatformSettingsPage";
import type { WakePolicySettings } from "@/lib/types";
-const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn() }));
+const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn(), nodes: vi.fn() }));
vi.mock("@/lib/api", async (original) => ({ ...await original(), api: calls }));
+const runtime = vi.hoisted(() => ({ distributed: false, fallback: false, apiBase: "/api/v1", rootDomain: "example.test" }));
+vi.mock("@/lib/hooks", async (original) => ({ ...await original(), useConfig: () => runtime }));
const policy: WakePolicySettings = { maxRunningServers: 0, wakeCooldownSeconds: 0, revision: "initial", managed: false };
const limit = () => screen.getByRole("spinbutton", { name: "Running server limit (0–10000)" }) as HTMLInputElement;
const cooldown = () => screen.getByRole("spinbutton", { name: "Wake cooldown (0–3600 seconds)" });
function page() { render(); }
beforeEach(() => {
vi.clearAllMocks();
+ runtime.distributed = false;
+ runtime.fallback = false;
+ calls.nodes.mockResolvedValue([]);
calls.getWakePolicy.mockResolvedValue(policy);
calls.setWakePolicy.mockImplementation(async (body) => ({ ...body, revision: "saved", managed: true }));
});
@@ -46,3 +51,35 @@ describe("platform policy", () => {
expect(limit().value).toBe("2");
});
});
+
+describe("distributed deployment entry", () => {
+ it("explains single-node deployment and enrollment without requesting unavailable nodes", async () => {
+ page();
+ await waitFor(() => expect(limit().disabled).toBe(false));
+ expect(screen.getByText("Single-node mode")).toBeTruthy();
+ expect(screen.getByText(/cannot be switched live from the panel/)).toBeTruthy();
+ expect(screen.getByText("Connect and approve worker nodes")).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Deployment and acceptance runbook" }).getAttribute("href")).toContain("docs/distributed.md");
+ expect(calls.nodes).not.toHaveBeenCalled();
+ });
+
+ it("shows existing execution nodes and links to server placement and migration", async () => {
+ runtime.distributed = true;
+ calls.nodes.mockResolvedValue([{ name: "worker-b", role: "worker", ready: true, approved: false, architecture: "arm64", addresses: ["192.0.2.11"] }]);
+ page();
+ expect(await screen.findByText("worker-b")).toBeTruthy();
+ expect(screen.getByText("Distributed mode enabled")).toBeTruthy();
+ expect(screen.getByText("Pending approval")).toBeTruthy();
+ expect(screen.getByRole("link", { name: "Manage server placement and migration" }).getAttribute("href")).toBe("/servers");
+ });
+
+ it("keeps a failed config read distinct from single-node mode", async () => {
+ runtime.fallback = true;
+ runtime.distributed = true;
+ page();
+ await waitFor(() => expect(limit().disabled).toBe(false));
+ expect(screen.getByText("Deployment mode unconfirmed")).toBeTruthy();
+ expect(screen.queryByText("Single-node mode")).toBeNull();
+ expect(calls.nodes).not.toHaveBeenCalled();
+ });
+});
diff --git a/panel/src/pages/admin/PlatformSettingsPage.tsx b/panel/src/pages/admin/PlatformSettingsPage.tsx
index 0c4086d..da3f626 100644
--- a/panel/src/pages/admin/PlatformSettingsPage.tsx
+++ b/panel/src/pages/admin/PlatformSettingsPage.tsx
@@ -1,6 +1,9 @@
+import { Link } from "react-router-dom";
import { useEffect, useState } from "react";
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
import { useTranslation } from "react-i18next";
+import { DistributedNodes } from "@/components/DistributedNodes";
+import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/PageHeader";
import { MessageLine } from "@/components/MessageLine";
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
@@ -9,11 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api";
-import { useAsync, useUnsavedGuard } from "@/lib/hooks";
+import { useAsync, useConfig, useUnsavedGuard } from "@/lib/hooks";
import type { WakePolicySettings } from "@/lib/types";
export function PlatformSettingsPage() {
const { t } = useTranslation("admin");
+ const config = useConfig();
const query = useAsync(api.getWakePolicy, []);
const reauth = useReauth();
const [saved, setSaved] = useState(null);
@@ -64,6 +68,29 @@ export function PlatformSettingsPage() {
+
+ {t("platform_distribution_title")}
+
+
+ {t(!config || config.fallback ? "platform_distribution_unknown" : config.distributed ? "platform_distribution_enabled" : "platform_distribution_disabled")}
+
+ {t("platform_distribution_architecture")}
+ {config && !config.fallback && !config.distributed && {t("platform_distribution_enable_hint")}
}
+
+ {t("platform_distribution_join")}
+
+ - {t("platform_distribution_prepare")}
+ - {t("platform_distribution_token")}
{"felis node token --name --ttl 10m --out /root/worker.bootstrap"}
+ - {t("platform_distribution_worker")}
+ - {t("platform_distribution_approve")}
{"felis node approve --name --ssh-target --image \nfelis node list"}
+
+
+
+ {t("platform_distribution_management")}
+
+
+
+ {config?.distributed && !config.fallback && }
{reauth.dialog}
;
}