From 4fd40c228113fc7e92bb64928e70ca0b5c3b5a22 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 25 Sep 2026 17:21:17 +0000 Subject: [PATCH 01/16] Guide Add node through host requirements, expiry and registration The Add node dialog now: - lists the host requirements the installer and the node's readiness probe actually check, per backend, with copyable root commands; - warns before generating when the console's own address is loopback, so a remote host would download from itself; - counts down to the token's expires_at and keeps the command across closing until it expires or its node connects, offering a new command for the same limits only on request; - follows the new node from the polled node list (registered, connected, backend ready), showing its readiness diagnostic, or a missing connection after the installer's 60-second wait, with the journalctl command for the installer's systemd user unit. The acceptance fixture issues ten-minute tokens as Core does and can register and change a node for the new end-to-end flow. --- apps/web/e2e/console.ts | 5 + apps/web/e2e/fixture-console.mjs | 18 +- apps/web/e2e/nodes.spec.ts | 58 ++++- .../src/features/sandbox/NodeEnrollment.tsx | 224 ++++++++++++------ .../features/sandbox/SandboxManagerView.css | 30 ++- .../features/sandbox/SandboxManagerView.tsx | 10 +- .../features/sandbox/enrollment-command.ts | 6 + .../features/sandbox/node-enrollment.test.ts | 34 +++ .../src/features/sandbox/node-enrollment.ts | 92 +++++++ apps/web/src/lib/locale-strings.ts | 25 +- 10 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/features/sandbox/node-enrollment.test.ts create mode 100644 apps/web/src/features/sandbox/node-enrollment.ts diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index e870d5138..f16bb9789 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -24,6 +24,11 @@ export async function failNext(request: APIRequestContext, failure: { method: st await request.post(`${fixture}/__fixture/fail-next`, { data: failure }); } +/** Registers a node, or changes one, as a host running an enrollment command would. */ +export async function setNode(request: APIRequestContext, node: { id: string; name?: string; online?: boolean; provider_ready?: boolean; diagnostic?: string }) { + await request.post(`${fixture}/__fixture/node`, { data: node }); +} + /** Archives a project behind the console's back, as another administrator would. */ export async function archiveProject(request: APIRequestContext, projectId: string) { await request.post(`${fixture}/core/v1/projects/${projectId}/archive`, { headers: { cookie: "core_console=fixture-session" } }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index 083571559..4bf7edd5a 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -253,7 +253,8 @@ async function sandboxRoute(request, response, path) { } if (path === "/nodes") return send(response, 200, { data: state.nodes }); if (path === "/enrollment-tokens" && request.method === "POST") { - return send(response, 201, { token: `enroll_fixture_${state.nextId++}`, expires_at: new Date(Date.now() + 15 * 60_000).toISOString() }); + // As Core: a one-time token valid for ten minutes. + return send(response, 201, { token: `enroll_fixture_${state.nextId++}`, expires_at: new Date(Date.now() + 10 * 60_000).toISOString() }); } let m; if ((m = path.match(/^\/nodes\/([^/]+)\/allocations$/))) return send(response, 200, { data: state.allocations.filter((entry) => entry.node_id === m[1]) }); @@ -320,7 +321,12 @@ async function executorCredentialRoute(request, response, projectId, environment return error(response, 404, "Not found."); } -/** Test controls: reset state, inject one failure, and read what the browser sent. */ +/** A node as it first registers with an enrollment command: not yet connected, provider not ready. */ +function registeredNode(nodeId) { + return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", online: false, provider_ready: false, diagnostic: "", cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, max_active: 2, max_retained: 2, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; +} + +/** Test controls: reset state, inject one failure, register or change a node, and read what the browser sent. */ async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { @@ -331,6 +337,14 @@ async function fixtureRoute(request, response, url) { state.failNext = await body(request); // { method, path, status, code?, message? } return send(response, 200, { ok: true }); } + if (url.pathname === "/__fixture/node" && request.method === "POST") { + // { id, ...fields }: registers the node on first use, then applies the fields (online, provider_ready, diagnostic). + const { id: nodeId, ...fields } = await body(request); + let node = state.nodes.find((entry) => entry.id === nodeId); + if (!node) state.nodes.push(node = registeredNode(nodeId)); + Object.assign(node, fields); + return send(response, 200, node); + } if (url.pathname === "/__fixture/requests") return send(response, 200, { violations: state.violations, writes: state.writes }); return error(response, 404, "Not found."); } diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index c196af769..a9210150b 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,21 +1,71 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, openConsole, setNode } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); -test("prepares a one-time node command for the limits set first, and removes a node after confirmation", async ({ page, request }) => { +test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then registration", async ({ page, request }) => { + await page.clock.install(); + // Core counts a token's ten minutes on its own clock; the page's clock stands in for it, so fast-forwarding expires a command. + await page.route("**/core/v1/sandbox/enrollment-tokens", async (route) => { + const response = await route.fetch(); + const now = await page.evaluate(() => Date.now()); + await route.fulfill({ response, json: { ...await response.json(), expires_at: new Date(now + 10 * 60_000).toISOString() } }); + }); await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Add node" }).click(); const add = page.getByRole("dialog", { name: "Add node" }); + // What a Docker host needs, with the root commands that prepare it. + await expect(add.getByText("Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits")).toBeVisible(); + await expect(add.getByText("sudo usermod -aG docker ")).toBeVisible(); + await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); + // The fixture console runs on loopback, where another machine can't download from it. + await expect(add.getByRole("note")).toContainText("other machines can't reach"); await add.getByLabel("Sandboxes at once").fill("3"); - const issued = page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); + const tokenRequest = () => page.waitForRequest((sent) => sent.method() === "POST" && sent.url().endsWith("/core/v1/sandbox/enrollment-tokens")); + const issued = tokenRequest(); await add.getByRole("button", { name: "Generate command" }).click(); // Docker never suspends, so it retains exactly the sandboxes it runs. expect((await issued).postDataJSON()).toEqual({ max_active: 3, max_retained: 3 }); - await expect(add.getByLabel("One-time enrollment command")).toHaveValue(/enroll_fixture_/); + const field = add.getByLabel("One-time enrollment command"); + await expect(field).toHaveValue(/enroll_fixture_/); + await expect(add.getByRole("timer")).toHaveText(/^Expires in (10:00|9:\d\d)$/); + + // Closing keeps the command for the next opening. + const first = await field.inputValue(); await add.getByRole("button", { name: "Close dialog" }).click(); + await expect(add).toBeHidden(); + await page.getByRole("button", { name: "Add node" }).click(); + await expect(field).toHaveValue(first); + + // Once expired, a new command is issued only on request, for the same limits. + await page.clock.fastForward("10:30"); + await expect(add.getByText("Command expired")).toBeVisible(); + await expect(field).toBeHidden(); + const reissued = tokenRequest(); + await add.getByRole("button", { name: "Generate new command" }).click(); + expect((await reissued).postDataJSON()).toEqual({ max_active: 3, max_retained: 3 }); + await expect(field).toHaveValue(/enroll_fixture_/); + await expect(field).not.toHaveValue(first); + // The node registers; past the installer's minute without connecting, the dialog points at its log. + await setNode(request, { id: "node-new", name: "edge-04" }); + await expect(add.getByRole("status", { name: "Registration progress" })).toContainText("Registered · edge-04"); + await page.clock.fastForward("01:01"); + const problem = add.getByRole("alert"); + await expect(problem).toContainText("Not connected yet"); + await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-fixture.service"); + // Connected, it reports why Docker isn't ready; once ready, the node is connected. + await setNode(request, { id: "node-new", online: true, diagnostic: "docker_limits_unsupported" }); + await expect(problem).toContainText("Docker limits unsupported"); + await setNode(request, { id: "node-new", provider_ready: true, diagnostic: "" }); + await expect(add.getByText("edge-04 · Connected")).toBeVisible(); + await add.getByRole("button", { name: "Done" }).click(); + await expect(add).toBeHidden(); +}); + +test("removes a node after confirmation", async ({ page, request }) => { + await openConsole(page, request, "nodes"); await page.getByRole("button", { name: "Remove edge-03" }).click(); const confirm = page.getByRole("dialog", { name: "Remove node" }); await confirm.getByRole("button", { name: "Confirm removal" }).click(); diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 53dbc869b..44d2531ae 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,14 +1,33 @@ import { createPortal } from "react-dom"; -import { Check, Copy, Plus, Terminal } from "lucide-react"; +import { Check, Copy, Terminal, TriangleAlert } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client"; import { useTranslation } from "react-i18next"; -import { HelpTip } from "../../components/console-ui"; +import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; +import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { sandboxCoreOrigin } from "./core-origin"; +import { useCopy } from "../api-keys/IssuedKey"; +import { sandboxCoreOrigin, sandboxSetupOrigin } from "./core-origin"; import type { SandboxConsoleConfig } from "./console-config"; -import { nodeInstallCommand } from "./enrollment-command"; +import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command"; +import { enrolledNode, enrollmentProgress, formatCountdown, hostPrerequisites } from "./node-enrollment"; + +/** The host requirements open by default until this browser has shown them once. */ +const REQUIREMENTS_SEEN = "agents-core-web.node-requirements-seen"; +function requirementsSeen(): boolean { + try { return window.localStorage.getItem(REQUIREMENTS_SEEN) === "1"; } catch { return false; } +} +function rememberRequirementsSeen() { + try { window.localStorage.setItem(REQUIREMENTS_SEEN, "1"); } catch { /* Storage can be unavailable; the list then opens each time. */ } +} + +interface Enrollment { + token: string; + expires_at: string; + /** Nodes registered before the command was issued; a new one is the node it enrolls. */ + known: ReadonlySet; +} /** * Add node: the administrator sets the node's sandbox limits, then Core issues a @@ -16,36 +35,43 @@ import { nodeInstallCommand } from "./enrollment-command"; * (`POST /core/v1/sandbox/enrollment-tokens`). Only microsandbox suspends * sandboxes, so only it asks for a retained limit; Docker retains exactly the * sandboxes it runs at once. + * + * The page keeps this dialog mounted, so a command survives closing it: it is + * shown again until it expires or its node connects. An expired command is + * replaced only when the administrator asks. After the command, the dialog + * follows the new node through the node list it polls. */ -export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disabled, fresh, onRefresh, onConnected }: { +export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, fresh, onClose, onRefresh }: { client: SandboxAdminClient; consoleConfig: SandboxConsoleConfig; deployment: SandboxDeployment; nodes: SandboxNode[]; - disabled: boolean; + open: boolean; fresh: boolean; + onClose: () => void; onRefresh: () => void; - onConnected?: (id: string) => void; }) { const { t, i18n } = useTranslation("sandbox"); const locale = i18n.resolvedLanguage?.startsWith("zh") ? "zh" : "en"; const id = useId(); - const [open, setOpen] = useState(false); const [active, setActive] = useState("2"); const [retained, setRetained] = useState("8"); const [busy, setBusy] = useState(false); - const [enrollment, setEnrollment] = useState<{ token: string; expires_at: string } | null>(null); + const [enrollment, setEnrollment] = useState(null); + const [appeared, setAppeared] = useState<{ id: string; at: number } | null>(null); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [copyFailed, setCopyFailed] = useState(false); - const [now, setNow] = useState(Date.now()); + const [now, setNow] = useState(Date.now); + const [requirementsOpen, setRequirementsOpen] = useState(() => !requirementsSeen()); const generation = useRef(0); const request = useRef(null); - const knownIds = useRef(new Set()); - const revealedId = useRef(null); const sourceUrl = sandboxCoreOrigin(window.location.origin); const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); + // The command downloads from this console's own address. A loopback one (or any + // address sandboxSetupOrigin refuses for guests) resolves to the node host itself. + const localOnly = sourceUrl !== null && sandboxSetupOrigin(sourceUrl) === null; // Core takes whole numbers from 1 to a million, with the retained limit at least the active one. const suspends = deployment.provider === "microsandbox"; const whole = (value: string) => (/^\d+$/.test(value.trim()) ? Number(value.trim()) : null); @@ -57,59 +83,68 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab : !inRange(retainedLimit) ? t("Enter a whole number from 1 to 1,000,000.") : inRange(activeLimit) && retainedLimit < activeLimit ? t("Enter at least the number of sandboxes at once.") : null; const limitsReady = !activeProblem && !retainedProblem; - const expired = Boolean(enrollment && new Date(enrollment.expires_at).getTime() <= now); - const connected = enrollment && fresh ? nodes.find((node) => !knownIds.current.has(node.id) && node.online && node.provider_ready) : undefined; - const command = enrollment && available && !expired && !connected + const node = enrollment ? enrolledNode(nodes, enrollment.known) : null; + const progress = enrollmentProgress(node, node && appeared?.id === node.id ? appeared.at : undefined, now); + // Registration uses the token, so from then on its expiry no longer matters; rerunning + // the command on that host resumes with the node's retained identity. + const registered = progress.stage !== "waiting"; + const ready = progress.stage === "ready" && fresh; + const expired = Boolean(enrollment && Date.parse(enrollment.expires_at) <= now); + const command = enrollment && available && (registered || !expired) && !ready ? nodeInstallCommand(enrollment.token, coreUrl!, sourceUrl!, deployment.provider, deployment.installation_id, consoleConfig.node_installer_sha256) : ""; + const nodeId = node?.id ?? null; + const polling = open && enrollment !== null && !ready && (registered || !expired); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); + useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); + // The installer's wait for readiness counts from when the node appears. useEffect(() => { - if (!open || !enrollment || !connected || revealedId.current === connected.id) return; - revealedId.current = connected.id; - onConnected?.(connected.id); - }, [open, enrollment, connected, onConnected]); + if (nodeId) setAppeared((current) => (current?.id === nodeId ? current : { id: nodeId, at: Date.now() })); + }, [nodeId]); useEffect(() => { - if (!open || !enrollment) return; + if (!open || !enrollment || ready) return; + setNow(Date.now()); const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer); - }, [open, enrollment]); + }, [open, enrollment, ready]); useEffect(() => { - if (!open || !enrollment || expired) return; + if (!polling) return; const interval = window.setInterval(onRefresh, 3000); return () => window.clearInterval(interval); - }, [open, enrollment, expired, onRefresh]); - function start() { - setActive("2"); setRetained("8"); - setOpen(true); - } + }, [polling, onRefresh]); function close() { generation.current++; request.current?.abort(); request.current = null; - setOpen(false); setEnrollment(null); setError(null); setBusy(false); - setCopied(false); setCopyFailed(false); + setError(null); setBusy(false); setCopied(false); setCopyFailed(false); + // A connected node ends the command; otherwise it waits here for the next opening. + if (ready) { setEnrollment(null); setAppeared(null); } + onClose(); } /** * Back to the limits, whose first field takes focus again. The command already - * shown is abandoned (it lapses when it expires); Generate issues a fresh one. + * shown is abandoned (it lapses when it expires), as is a node that registered + * but is not ready; Generate issues a fresh command. */ function changeLimits() { generation.current++; - setEnrollment(null); setError(null); setCopied(false); setCopyFailed(false); + setEnrollment(null); setAppeared(null); setError(null); setCopied(false); setCopyFailed(false); } async function generate() { if (request.current || !available || !limitsReady || activeLimit === null || retainedLimit === null) return; const capacity = { max_active: activeLimit, max_retained: retainedLimit }; generation.current++; const controller = new AbortController(); request.current = controller; - knownIds.current = new Set(nodes.map((node) => node.id)); - revealedId.current = null; setBusy(true); setError(null); setCopied(false); setCopyFailed(false); try { // Capture the current node set before issuing a one-time enrollment token. const current = await client.listNodes({ signal: controller.signal }); if (controller.signal.aborted) return; - knownIds.current = new Set(current.data.map((node) => node.id)); + const known = new Set(current.data.map((entry) => entry.id)); const result = await client.createEnrollment({ signal: controller.signal }, capacity); - if (!controller.signal.aborted) { setEnrollment(result); setNow(Date.now()); } + if (!controller.signal.aborted) { + setEnrollment({ token: result.token, expires_at: result.expires_at, known }); setAppeared(null); setNow(Date.now()); + // Seen with the limits; the command comes first now. + setRequirementsOpen(false); + } } catch (error) { // The failure stays in the dialog, on the limits, so they can be corrected and sent again. if (!controller.signal.aborted) { setEnrollment(null); setError(error); } @@ -121,49 +156,102 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, disab try { await navigator.clipboard.writeText(command); if (current === generation.current) { setCopied(true); setCopyFailed(false); } } catch { if (current === generation.current) { setCopied(false); setCopyFailed(true); } } } - // The host needs what this deployment's backend needs: a Docker engine, or Linux with KVM. - const hostRequirements = {t(suspends ? "The host needs Linux with KVM: /dev/kvm must be available to the node. Each sandbox runs as a small virtual machine with its own disks." : "The host needs Docker Engine that enforces CPU and memory limits, and the node needs access to its socket. Each sandbox runs as a container.")}; + const backend = suspends ? "microsandbox" : "Docker"; + const requirements = deployment.provider === "docker" || deployment.provider === "microsandbox" ? ( +
setRequirementsOpen(event.currentTarget.open)}> + {t("Host requirements")} +
    + {hostPrerequisites(deployment.provider).map((item) => ( +
  • + {t(item.label, { console: sourceUrl ?? "", core: coreUrl ?? "" })} + {item.command ? : null} +
  • + ))} +
+

{t("Group changes apply at the user's next sign-in.")}

+
+ ) : null; const limitsForm = `${id}-limits`; const footer = !available ? undefined : !enrollment ? <> - : connected ? + : ready ? + : registered ? : <> {expired ? : null} ; - return <> - - {createPortal( -
- {!available ?

{t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

: !enrollment ? ( -
{ event.preventDefault(); void generate(); }}> -

{t("Set the sandbox limits for the host you want to add.")}{hostRequirements}

+ const steps: { label: string; tone: Tone }[] = [t("Registered"), t("Connected"), t("{{backend}} ready", { backend })].map((label, index) => { + const done = { waiting: 0, registered: 1, connected: 2, ready: 3 }[progress.stage]; + return { label, tone: index < done ? "ok" : index > done ? "neutral" : progress.problem ? "warning" : "pending" }; + }); + const problem = progress.problem === "not_connected" + ? { label: t("Not connected yet"), advice: t("The node registered but its service hasn't reached Core.") } + : sandboxDiagnosticMessage(progress.problem, locale); + return createPortal( +
+ {!available ?

{consoleConfig.node_installer && coreUrl && !sourceUrl + ? t("Open this console over HTTPS to add a node: the installer downloads only over HTTPS.") + : t("Node installation is unavailable. Ask the deployment administrator to enable the node installer on this console.")}

: !enrollment ? ( + { event.preventDefault(); void generate(); }}> +

{t("Set the sandbox limits for the host you want to add.")}

+ {localOnly ?

: null} +
+ {t("The most sandboxes Core places on this node at the same time.")} + setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} /> + {activeProblem ? {activeProblem} : null} +
+ {suspends ? (
- {t("The most sandboxes Core places on this node at the same time.")} - setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} /> - {activeProblem ? {activeProblem} : null} + {t("Sandboxes kept on this node for resuming, the running ones included. At least the number at once.")} + setRetained(event.target.value)} aria-invalid={Boolean(retainedProblem)} /> + {retainedProblem ? {retainedProblem} : null}
- {suspends ? ( -
- {t("Sandboxes kept on this node for resuming, the running ones included. At least the number at once.")} - setRetained(event.target.value)} aria-invalid={Boolean(retainedProblem)} /> - {retainedProblem ? {retainedProblem} : null} -
- ) : null} - {error !== null ?

{sandboxRequestError(error, locale)}

: null} - - ) : <> - {!connected ?

{t("Run on the host you want to add.")}{hostRequirements}

: null} - {command ?
{t("Terminal")}