fix: gate worker management behind distributed activation

This commit is contained in:
Lemon-miaow committed 2026-10-07 12:49:26 +08:00
1 parent 1b49b4df06
commit 373af3b0bc
5 files changed
+49 -7

No files matched your search

+3 -1
View File
@@ -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();
+19 -1
View File
@@ -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(<NodeControlPanel distributed={false} />);
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(<NodeControlPanel distributed={false} />);
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();
});
});
+13 -3
View File
@@ -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<string | null>(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 <Card>
<CardHeader className="flex-row items-center justify-between gap-3"><CardTitle>{t("node_control_title")}</CardTitle><Button variant="outline" size="sm" disabled={tasks.loading} onClick={() => { tasks.reload(); progress.reload(); }}><RefreshCw />{t("node_control_reload")}</Button></CardHeader>
@@ -57,20 +59,28 @@ export function NodeControlPanel({ distributed }: { distributed: boolean | null
{tasks.error != null && <MessageLine kind="error" message={humanizeError(tasks.error)} />}
{tasks.data?.available === false && <p role="status" className="text-sm text-muted-foreground">{t("node_control_unavailable")}</p>}
{running && task?.state !== "running" && <div className="flex flex-wrap items-center gap-3"><p role="status" className="text-sm text-muted-foreground">{t("node_control_other_running")}</p><Button variant="outline" size="sm" onClick={() => { const active = tasks.data?.tasks.find((entry) => entry.state === "running"); if (active) setId(active.id); }}>{t("node_control_show_running")}</Button></div>}
{distributed === null && <p role="status" className="text-sm text-muted-foreground">{t("node_control_mode_loading")}</p>}
{distributed === false && !enabling && <div className="space-y-3 rounded-lg border border-border p-4">
<p className="text-sm leading-relaxed">{t("node_control_enable_required")}</p>
<Button disabled={!available || busy} onClick={() => { setEnabling(true); setConfirmed(false); }}>{t("node_control_enable")}</Button>
</div>}
{showForm && <>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2"><Label htmlFor="node-action">{t("node_control_action")}</Label><Select value={action} disabled={!available || busy} onValueChange={(value) => { setAction(value as NodeControlRequest["action"]); setConfirmed(false); }}><SelectTrigger id="node-action"><SelectValue /></SelectTrigger><SelectContent>{!distributed && <SelectItem value="enable">{t("node_control_enable")}</SelectItem>}<SelectItem value="join" disabled={!distributed}>{t("node_control_join")}</SelectItem><SelectItem value="approve" disabled={!distributed}>{t("node_control_approve")}</SelectItem></SelectContent></Select></div>
{distributed === true && <div className="space-y-2"><Label htmlFor="node-action">{t("node_control_action")}</Label><Select value={action} disabled={!available || busy} onValueChange={(value) => { setAction(value as NodeControlRequest["action"]); setConfirmed(false); }}><SelectTrigger id="node-action"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="join">{t("node_control_join")}</SelectItem><SelectItem value="approve">{t("node_control_approve")}</SelectItem></SelectContent></Select></div>}
{action !== "enable" && <>
<div className="space-y-2"><Label htmlFor="node-name">{t("node_control_name")}</Label><Input id="node-name" value={name} disabled={!available || busy} onChange={(e) => setName(e.target.value)} placeholder="worker-01" /></div>
<div className="space-y-2"><Label htmlFor="node-ssh">{t("node_control_ssh")}</Label><Input id="node-ssh" value={ssh} disabled={!available || busy} onChange={(e) => setSSH(e.target.value)} placeholder="[email protected]" /><p className="text-xs text-muted-foreground">{t("node_control_ssh_hint")}</p></div>
</>}
{action !== "approve" && <>
<div className="space-y-2"><Label htmlFor="node-ip">{t("node_control_ip")}</Label><Input id="node-ip" value={ip} disabled={!available || busy} onChange={(e) => setIP(e.target.value)} placeholder="192.168.1.20" /></div>
<div className="space-y-2"><Label htmlFor="node-ip">{t("node_control_ip")}</Label><Input id="node-ip" value={ip} disabled={!available || busy} onChange={(e) => setIP(e.target.value)} placeholder="192.168.1.20" /><p className="text-xs text-muted-foreground">{t(action === "enable" ? "node_control_enable_ip_hint" : "node_control_ip_hint")}</p></div>
<div className="space-y-2 md:col-span-2"><Label htmlFor="node-peers">{t("node_control_peers")}</Label><Input id="node-peers" value={peers} disabled={!available || busy} onChange={(e) => setPeers(e.target.value)} placeholder="192.168.1.21/32, 192.168.1.22/32" /><p className="text-xs text-muted-foreground">{t("node_control_peers_hint")}</p></div>
</>}
</div>
<div className="flex items-start gap-3 rounded-lg border border-border p-4"><Switch id="node-confirm" checked={confirmed} disabled={!available || busy} onCheckedChange={setConfirmed} /><Label htmlFor="node-confirm" className="text-sm leading-relaxed">{t(action === "enable" ? "node_control_confirm_enable" : "node_control_confirm_worker")}</Label></div>
{error && <MessageLine kind="error" message={error} />}
<Button disabled={!available || busy || !valid} onClick={() => void run()}>{submitting ? <Loader2 className="animate-spin" /> : <Play />}{t("node_control_submit")}</Button>
{available && !busy && !valid && <p className="text-sm text-muted-foreground">{t("node_control_incomplete")}</p>}
<div className="flex flex-wrap gap-3"><Button disabled={!available || busy || !valid} onClick={() => void run()}>{submitting ? <Loader2 className="animate-spin" /> : <Play />}{t(action === "enable" ? "node_control_enable" : "node_control_submit")}</Button>{distributed === false && <Button variant="outline" disabled={busy} onClick={() => { setEnabling(false); setConfirmed(false); }}>{t("node_control_back")}</Button>}</div>
</>}
<details className="rounded-lg border border-border p-4"><summary className="cursor-pointer text-sm font-medium">{t("node_control_prerequisites")}</summary><p className="mt-3 text-sm leading-relaxed text-muted-foreground">{t("node_control_prerequisites_body")}</p><Button asChild variant="outline" size="sm" className="mt-3"><a href="https://github.com/FelisMC/Felis/blob/main/docs/distributed.md" target="_blank" rel="noreferrer">{t("platform_distribution_runbook")}</a></Button></details>
{!!tasks.data?.tasks.length && <div className="space-y-2"><Label htmlFor="node-task">{t("node_control_history")}</Label><Select value={id ?? undefined} onValueChange={setId}><SelectTrigger id="node-task"><SelectValue /></SelectTrigger><SelectContent>{tasks.data.tasks.map((entry) => <SelectItem key={entry.id} value={entry.id}>{t(`node_control_${entry.request.action}`)} · {entry.request.name || t("node_control_controller")} · {new Date(entry.startedAt).toLocaleString()} · {t(`node_control_state_${entry.state}`)}</SelectItem>)}</SelectContent></Select></div>}
{progress.error != null && <MessageLine kind="error" message={t("node_control_connection_lost", { error: humanizeError(progress.error) })} />}
+7 -1
View File
@@ -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"
}
+7 -1
View File
@@ -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": "返回"
}