feat(panel): expose distributed deployment in platform settings
This commit is contained in:
12 files changed
+146
-16
No files matched your search
@@ -70,6 +70,8 @@ felis node list
|
|||||||
|
|
||||||
批准前 worker 带 `NoSchedule` 隔离 taint,且没有受保护的 approved 标签。批准命令检查架构、版本、节点在线状态、WireGuard、宿主机隔离、kubelet 修改受保护标签被拒绝,删除指定镜像后执行真实 `crictl pull`。随后创建临时探针,检查跨节点 Service、控制服务的正向可达性和游戏标签 Pod 的拒绝路径。A 从宿主机连接每个测试 Service,读取后端实际观察到的源地址,只将属于 A 的精确地址写入游戏策略。任何检查失败都保留隔离状态。批准过程中会删除指定缓存镜像,须在该节点尚无游戏任务时执行。
|
批准前 worker 带 `NoSchedule` 隔离 taint,且没有受保护的 approved 标签。批准命令检查架构、版本、节点在线状态、WireGuard、宿主机隔离、kubelet 修改受保护标签被拒绝,删除指定镜像后执行真实 `crictl pull`。随后创建临时探针,检查跨节点 Service、控制服务的正向可达性和游戏标签 Pod 的拒绝路径。A 从宿主机连接每个测试 Service,读取后端实际观察到的源地址,只将属于 A 的精确地址写入游戏策略。任何检查失败都保留隔离状态。批准过程中会删除指定缓存镜像,须在该节点尚无游戏任务时执行。
|
||||||
|
|
||||||
|
Owner 可在面板“平台设置 → 多节点分布式部署”查看部署模式、节点在线及批准状态、接入流程,并进入服务器节点选择与迁移。部署模式由安装器配置;节点批准仍通过主控命令执行完整校验。
|
||||||
|
|
||||||
节点批准后,管理员可在面板创建服务器时选择它,或在创建请求中传 `nodeName`。普通服主不能选节点或指定 PVC。失联节点拒绝新任务;operator 撤销该服务器 Service 的后端和 Ready 状态,Velocity 进入原有 fallback 流程,不换机。
|
节点批准后,管理员可在面板创建服务器时选择它,或在创建请求中传 `nodeName`。普通服主不能选节点或指定 PVC。失联节点拒绝新任务;operator 撤销该服务器 Service 的后端和 Ready 状态,Velocity 进入原有 fallback 流程,不换机。
|
||||||
|
|
||||||
## 停服迁移
|
## 停服迁移
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
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() }));
|
const calls = vi.hoisted(() => ({ nodes: vi.fn(), migration: vi.fn(), retryMigration: vi.fn(), migrateServer: vi.fn() }));
|
||||||
vi.mock("@/lib/api", async (importActual) => {
|
vi.mock("@/lib/api", async (importActual) => {
|
||||||
@@ -42,3 +42,16 @@ describe("durable migration", () => {
|
|||||||
expect(calls.retryMigration).not.toHaveBeenCalled();
|
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 { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
@@ -10,21 +13,25 @@ import { Label } from "@/components/ui/label";
|
|||||||
|
|
||||||
export function DistributedNodes() {
|
export function DistributedNodes() {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const nodes = useAsync(api.nodes);
|
const nodes = useAsync(api.nodes, [], { coalesce: true });
|
||||||
usePolling(nodes.reload, 10_000);
|
usePolling(nodes.reload, 10_000);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4 space-y-2">
|
<Card>
|
||||||
<h2 className="font-medium">{t("nodes")}</h2>
|
<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)} />}
|
{!!nodes.error && <InlineError message={humanizeError(nodes.error)} />}
|
||||||
<ul className="space-y-1 text-sm">{nodes.data?.map((n) => (
|
<ul className="space-y-1 text-sm">{nodes.data?.map((n) => (
|
||||||
<li key={n.name} className="flex flex-wrap gap-3">
|
<li key={n.name} className="flex flex-wrap gap-3">
|
||||||
<code>{n.name}</code><span>{n.role || t("node_pending")}</span>
|
<code>{n.name}</code><span>{t(n.role === "controller" ? "node_controller" : n.role === "worker" ? "node_worker" : "node_unassigned")}</span>
|
||||||
<span>{n.ready ? t("node_online") : t("node_offline")}</span>
|
<Badge variant={n.ready ? "default" : "destructive"}>{n.ready ? t("node_online") : t("node_offline")}</Badge>
|
||||||
<span>{n.approved ? t("node_approved") : t("node_pending")}</span>
|
{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>
|
<span className="text-muted-foreground">{n.architecture} · {n.addresses.join(", ")}</span>
|
||||||
</li>
|
</li>
|
||||||
))}</ul>
|
))}</ul>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,5 +321,19 @@
|
|||||||
"platform_unsaved": "Unsaved changes",
|
"platform_unsaved": "Unsaved changes",
|
||||||
"platform_live": "Saved changes apply immediately on all API replicas.",
|
"platform_live": "Saved changes apply immediately on all API replicas.",
|
||||||
"platform_discard": "Discard changes",
|
"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.",
|
"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_unavailable": "Authentication source management is unavailable.",
|
||||||
"auth_sources_changed": "Authentication sources changed. Discard your edits and reload before saving.",
|
"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.",
|
"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_show_rcon": "Show RCON connection logs",
|
||||||
"log_hide_rcon": "Hide 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_unsaved": "有尚未保存的更改",
|
||||||
"platform_live": "保存后会立即在所有 API 实例上生效。",
|
"platform_live": "保存后会立即在所有 API 实例上生效。",
|
||||||
"platform_discard": "放弃更改",
|
"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。",
|
"minecraft_profile_not_found": "所选认证源中不存在此角色。请检查角色名或 UUID。",
|
||||||
"auth_sources_unavailable": "认证源管理暂不可用。",
|
"auth_sources_unavailable": "认证源管理暂不可用。",
|
||||||
"auth_sources_changed": "认证源已被其他操作修改。请放弃当前更改并重新读取后再保存。",
|
"auth_sources_changed": "认证源已被其他操作修改。请放弃当前更改并重新读取后再保存。",
|
||||||
"auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。"
|
"auth_source_tag_locked": "已保存的认证源标识不能改名或移除,请使用停用。",
|
||||||
|
"distributed_unavailable": "分布式部署未配置。请先按部署流程配置主控与 worker 节点。"
|
||||||
}
|
}
|
||||||
@@ -296,5 +296,11 @@
|
|||||||
"host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。",
|
"host_recovery_verify": "Pod 仍在终止或节点不可达时,无法确认进程已停止。请检查对应节点及容器运行时;强制删除 Pod 不能作为进程退出的依据。",
|
||||||
"log_show_rcon": "显示 RCON 连接日志",
|
"log_show_rcon": "显示 RCON 连接日志",
|
||||||
"log_hide_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) {
|
switch (err.code) {
|
||||||
// Session doors (spec §B): every passwordless door 403s this when local
|
// Session doors (spec §B): every passwordless door 403s this when local
|
||||||
// sessions are disabled on a Zero-Trust-only deployment.
|
// sessions are disabled on a Zero-Trust-only deployment.
|
||||||
|
case "distributed_unavailable":
|
||||||
|
return t("distributed_unavailable");
|
||||||
case "local_auth_disabled":
|
case "local_auth_disabled":
|
||||||
return t("local_auth_disabled");
|
return t("local_auth_disabled");
|
||||||
case "staff_account":
|
case "staff_account":
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { PlatformSettingsPage } from "./PlatformSettingsPage";
|
import { PlatformSettingsPage } from "./PlatformSettingsPage";
|
||||||
import type { WakePolicySettings } from "@/lib/types";
|
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 }));
|
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 policy: WakePolicySettings = { maxRunningServers: 0, wakeCooldownSeconds: 0, revision: "initial", managed: false };
|
||||||
const limit = () => screen.getByRole("spinbutton", { name: "Running server limit (0–10000)" }) as HTMLInputElement;
|
const limit = () => screen.getByRole("spinbutton", { name: "Running server limit (0–10000)" }) as HTMLInputElement;
|
||||||
const cooldown = () => screen.getByRole("spinbutton", { name: "Wake cooldown (0–3600 seconds)" });
|
const cooldown = () => screen.getByRole("spinbutton", { name: "Wake cooldown (0–3600 seconds)" });
|
||||||
function page() { render(<MemoryRouter><PlatformSettingsPage /></MemoryRouter>); }
|
function page() { render(<MemoryRouter><PlatformSettingsPage /></MemoryRouter>); }
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
runtime.distributed = false;
|
||||||
|
runtime.fallback = false;
|
||||||
|
calls.nodes.mockResolvedValue([]);
|
||||||
calls.getWakePolicy.mockResolvedValue(policy);
|
calls.getWakePolicy.mockResolvedValue(policy);
|
||||||
calls.setWakePolicy.mockImplementation(async (body) => ({ ...body, revision: "saved", managed: true }));
|
calls.setWakePolicy.mockImplementation(async (body) => ({ ...body, revision: "saved", managed: true }));
|
||||||
});
|
});
|
||||||
@@ -46,3 +51,35 @@ describe("platform policy", () => {
|
|||||||
expect(limit().value).toBe("2");
|
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 { useEffect, useState } from "react";
|
||||||
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
|
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { DistributedNodes } from "@/components/DistributedNodes";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { MessageLine } from "@/components/MessageLine";
|
import { MessageLine } from "@/components/MessageLine";
|
||||||
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
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 { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
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";
|
import type { WakePolicySettings } from "@/lib/types";
|
||||||
|
|
||||||
export function PlatformSettingsPage() {
|
export function PlatformSettingsPage() {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
|
const config = useConfig();
|
||||||
const query = useAsync(api.getWakePolicy, []);
|
const query = useAsync(api.getWakePolicy, []);
|
||||||
const reauth = useReauth();
|
const reauth = useReauth();
|
||||||
const [saved, setSaved] = useState<WakePolicySettings | null>(null);
|
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>
|
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{reauth.dialog}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user