feat(panel): expose distributed deployment in platform settings
This commit is contained in:
12 files changed
+146
-16
No files matched your search
@@ -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(<DistributedNodes />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h2 className="font-medium">{t("nodes")}</h2>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle>{t("nodes")}</CardTitle><Button variant="outline" size="sm" disabled={nodes.loading} onClick={nodes.reload}><RefreshCw />{t("nodes_reload")}</Button></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{nodes.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("nodes_loading")}</p>}
|
||||
{!nodes.loading && !nodes.error && nodes.data?.length === 0 && <p className="text-sm text-muted-foreground">{t("nodes_empty")}</p>}
|
||||
{!!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>
|
||||
<code>{n.name}</code><span>{t(n.role === "controller" ? "node_controller" : n.role === "worker" ? "node_worker" : "node_unassigned")}</span>
|
||||
<Badge variant={n.ready ? "default" : "destructive"}>{n.ready ? t("node_online") : t("node_offline")}</Badge>
|
||||
{n.role === "worker" && <Badge variant={n.approved ? "default" : "muted"}>{n.approved ? t("node_approved") : t("node_pending")}</Badge>}
|
||||
<span className="text-muted-foreground">{n.architecture} · {n.addresses.join(", ")}</span>
|
||||
</li>
|
||||
))}</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "管理服务器节点与迁移"
|
||||
}
|
||||
@@ -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 节点。"
|
||||
}
|
||||
@@ -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": "重新读取"
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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<typeof import("@/lib/api")>(), api: calls }));
|
||||
const runtime = vi.hoisted(() => ({ distributed: false, fallback: false, apiBase: "/api/v1", rootDomain: "example.test" }));
|
||||
vi.mock("@/lib/hooks", async (original) => ({ ...await original<typeof import("@/lib/hooks")>(), 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(<MemoryRouter><PlatformSettingsPage /></MemoryRouter>); }
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<WakePolicySettings | null>(null);
|
||||
@@ -64,6 +68,29 @@ export function PlatformSettingsPage() {
|
||||
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{t("platform_distribution_title")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Badge variant={config?.distributed && !config.fallback ? "default" : "muted"}>
|
||||
{t(!config || config.fallback ? "platform_distribution_unknown" : config.distributed ? "platform_distribution_enabled" : "platform_distribution_disabled")}
|
||||
</Badge>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t("platform_distribution_architecture")}</p>
|
||||
{config && !config.fallback && !config.distributed && <p className="text-sm leading-relaxed">{t("platform_distribution_enable_hint")}</p>}
|
||||
<details className="rounded-lg border border-border p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t("platform_distribution_join")}</summary>
|
||||
<ol className="mt-4 list-decimal space-y-3 pl-5 text-sm leading-relaxed text-muted-foreground">
|
||||
<li>{t("platform_distribution_prepare")}</li>
|
||||
<li>{t("platform_distribution_token")}<pre className="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{"felis node token --name <worker-name> --ttl 10m --out /root/worker.bootstrap"}</pre></li>
|
||||
<li>{t("platform_distribution_worker")}</li>
|
||||
<li>{t("platform_distribution_approve")}<pre className="mt-2 overflow-x-auto rounded-md bg-muted p-3 text-xs">{"felis node approve --name <worker-name> --ssh-target <worker-ssh-alias> --image <felis-image>\nfelis node list"}</pre></li>
|
||||
</ol>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4"><a href="https://github.com/FelisMC/Felis/blob/main/docs/distributed.md" target="_blank" rel="noreferrer">{t("platform_distribution_runbook")}</a></Button>
|
||||
</details>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t("platform_distribution_management")}</p>
|
||||
<Button asChild variant="outline" size="sm"><Link to="/servers">{t("platform_distribution_servers")}</Link></Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{config?.distributed && !config.fallback && <DistributedNodes />}
|
||||
{reauth.dialog}
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user