diff --git a/panel/e2e/startup-platform.smoke.spec.ts b/panel/e2e/startup-platform.smoke.spec.ts index 2171d8c..d30b341 100644 --- a/panel/e2e/startup-platform.smoke.spec.ts +++ b/panel/e2e/startup-platform.smoke.spec.ts @@ -98,8 +98,10 @@ test("Owner executes node management and receives stage, failure logs and retry" await page.route("**/api/v1/settings/node-control/tasks/test-task", (route) => route.fulfill({ json: task })); await page.route("**/api/v1/settings/node-control/tasks/test-task/retry", (route) => { task = { ...task, state: "running", error: undefined }; return route.fulfill({ status: 202, json: task }); }); await page.goto("/admin/platform"); + await expect(page.getByLabel(t("admin:node_control_ip"))).toHaveCount(0); + await page.getByRole("button", { name: t("admin:node_control_enable"), exact: true }).click(); await page.getByLabel(t("admin:node_control_ip")).fill("192.0.2.10"); - const submit = page.getByRole("button", { name: t("admin:node_control_submit"), exact: true }); + const submit = page.getByRole("button", { name: t("admin:node_control_enable"), exact: true }); await expect(submit).toBeDisabled(); await page.getByRole("switch").click(); await submit.click(); diff --git a/panel/src/components/NodeControlPanel.test.tsx b/panel/src/components/NodeControlPanel.test.tsx index db15102..776322e 100644 --- a/panel/src/components/NodeControlPanel.test.tsx +++ b/panel/src/components/NodeControlPanel.test.tsx @@ -53,11 +53,29 @@ describe("host node management", () => { expect(calls.retryNodeTask).toHaveBeenCalledWith("task-1"); await waitFor(() => expect(calls.nodeTask).toHaveBeenCalledWith("task-2")); }); + it("gates worker operations behind explicit activation and explains its required input", async () => { + const running = { ...failed, state: "running" as const, request: { action: "enable" as const, confirmMaintenance: true } }; + calls.startNodeTask.mockResolvedValue(running); calls.nodeTask.mockResolvedValue(running); + render(); + expect(screen.queryByRole("combobox", { name: "Operation" })).toBeNull(); + expect(screen.queryByLabelText("Fixed node IP")).toBeNull(); + const enable = screen.getByRole("button", { name: "Enable distributed deployment" }); + await waitFor(() => expect(enable).toHaveProperty("disabled", false)); + await userEvent.click(enable); + expect(screen.getByText(/Required. Enter the controller/)).toBeTruthy(); + expect(screen.queryByLabelText("Node name")).toBeNull(); + await userEvent.click(screen.getByRole("switch")); + expect(screen.getByRole("button", { name: "Enable distributed deployment" })).toHaveProperty("disabled", true); + fireEvent.change(screen.getByLabelText("Fixed node IP"), { target: { value: "192.0.2.10" } }); + await userEvent.click(screen.getByRole("button", { name: "Enable distributed deployment" })); + expect(calls.startNodeTask).toHaveBeenCalledWith({ action: "enable", externalIP: "192.0.2.10", peers: [], confirmMaintenance: true }); + }); it("reports a host connection failure and disables changes", async () => { calls.nodeTasks.mockRejectedValue({ status: 503, code: "node_control_unavailable" }); render(); expect(await screen.findByRole("alert")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Execute operation" })).toHaveProperty("disabled", true); + expect(screen.getByRole("button", { name: "Enable distributed deployment" })).toHaveProperty("disabled", true); + expect(screen.queryByLabelText("Fixed node IP")).toBeNull(); expect(calls.startNodeTask).not.toHaveBeenCalled(); }); }); diff --git a/panel/src/components/NodeControlPanel.tsx b/panel/src/components/NodeControlPanel.tsx index 12ab785..c5dfc63 100644 --- a/panel/src/components/NodeControlPanel.tsx +++ b/panel/src/components/NodeControlPanel.tsx @@ -25,6 +25,7 @@ export function NodeControlPanel({ distributed }: { distributed: boolean | null const [ip, setIP] = useState(""); const [peers, setPeers] = useState(""); const [confirmed, setConfirmed] = useState(false); + const [enabling, setEnabling] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const progress = useAsync(() => id ? api.nodeTask(id) : Promise.resolve(null), [id], { coalesce: true }); @@ -48,6 +49,7 @@ export function NodeControlPanel({ distributed }: { distributed: boolean | null } catch (e) { if (!isReauthCancelled(e)) setError(humanizeError(e)); } finally { setSubmitting(false); } } + const showForm = distributed === true || distributed === false && enabling; const valid = confirmed && (action === "enable" || !!name.trim() && !!ssh.trim()) && (action === "approve" || !!ip.trim()); return {t("node_control_title")} @@ -57,20 +59,28 @@ export function NodeControlPanel({ distributed }: { distributed: boolean | null {tasks.error != null && } {tasks.data?.available === false &&

{t("node_control_unavailable")}

} {running && task?.state !== "running" &&

{t("node_control_other_running")}

} + {distributed === null &&

{t("node_control_mode_loading")}

} + {distributed === false && !enabling &&
+

{t("node_control_enable_required")}

+ +
} + {showForm && <>
-
+ {distributed === true &&
} {action !== "enable" && <>
setName(e.target.value)} placeholder="worker-01" />
setSSH(e.target.value)} placeholder="root@192.168.1.20" />

{t("node_control_ssh_hint")}

} {action !== "approve" && <> -
setIP(e.target.value)} placeholder="192.168.1.20" />
+
setIP(e.target.value)} placeholder="192.168.1.20" />

{t(action === "enable" ? "node_control_enable_ip_hint" : "node_control_ip_hint")}

setPeers(e.target.value)} placeholder="192.168.1.21/32, 192.168.1.22/32" />

{t("node_control_peers_hint")}

}
{error && } - + {available && !busy && !valid &&

{t("node_control_incomplete")}

} +
{distributed === false && }
+ }
{t("node_control_prerequisites")}

{t("node_control_prerequisites_body")}

{!!tasks.data?.tasks.length &&
} {progress.error != null && } diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index f6022ef..520ba7f 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -387,5 +387,11 @@ "node_control_started": "Started: {{time}}", "node_control_timeout": "Operation timeout: 45 minutes", "node_control_other_running": "Another node task is running; new node operations are temporarily blocked.", - "node_control_show_running": "View running task" + "node_control_show_running": "View running task", + "node_control_mode_loading": "Reading deployment mode.", + "node_control_enable_required": "The platform is in single-node mode. Enable distributed deployment before enrolling or approving workers. Activation changes controller configuration and may temporarily interrupt panel access.", + "node_control_enable_ip_hint": "Required. Enter the controller’s currently registered fixed IP, using a static address or DHCP reservation.", + "node_control_ip_hint": "Required. Enter the fixed IP configured on the target worker.", + "node_control_incomplete": "Complete all required fields and acknowledge the maintenance conditions before executing.", + "node_control_back": "Back" } diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index 76efcc3..5c84288 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -383,5 +383,11 @@ "node_control_started": "开始时间:{{time}}", "node_control_timeout": "操作超时上限为 45 分钟", "node_control_other_running": "另一项节点任务正在执行,当前暂不允许新的节点操作。", - "node_control_show_running": "查看执行中任务" + "node_control_show_running": "查看执行中任务", + "node_control_mode_loading": "正在读取部署模式。", + "node_control_enable_required": "当前为单节点模式。接入或批准 worker 前,需先启用分布式部署。启用过程将变更主控配置,并可能暂时中断面板访问。", + "node_control_enable_ip_hint": "必填。填写主控当前登记的固定 IP;应为静态地址或已配置 DHCP 地址保留。", + "node_control_ip_hint": "必填。填写目标 worker 已配置的固定 IP。", + "node_control_incomplete": "填写全部必填项并确认维护条件后,方可执行。", + "node_control_back": "返回" }