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..acd280724 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -253,7 +253,12 @@ 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, whose limits the node it enrolls takes; + // only microsandbox keeps a retained limit above the active one. + const input = await body(request); + const maxActive = input.max_active ?? 1; + state.enrollmentLimits = { max_active: maxActive, max_retained: state.deployment?.provider === "microsandbox" ? input.max_retained ?? maxActive : maxActive }; + 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 +325,17 @@ 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 the last enrollment command: not yet connected, + * provider not ready, with the token's limits. Like Core, it has no diagnostic + * field until one is reported. + */ +function registeredNode(nodeId) { + const limits = state.enrollmentLimits ?? { max_active: 1, max_retained: 1 }; + return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, 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 +346,16 @@ 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); + // an empty diagnostic removes the field, as Core omits it. + 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); + if (!node.diagnostic) delete node.diagnostic; + 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..f3e2f3a41 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,21 +1,97 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole } from "./console"; +import { expectManagementBoundary, openConsole, setNode, writes } 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 NODE_USER")).toBeVisible(); + await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB")).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)$/); + const progress = add.getByRole("status", { name: "Registration progress" }); + await expect(progress).toHaveText(/Waiting for registration.*Connect.*Docker check/); + + // 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(progress).toContainText("Command expired"); + 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 while the dialog is closed and the command lapses: reopening reads the + // node list, and the command's node outranks its expiry. await add.getByRole("button", { name: "Close dialog" }).click(); + await expect(add).toBeHidden(); + await setNode(request, { id: "node-new", name: "edge-04" }); + await page.clock.fastForward("10:30"); + // Every render from the reopening on is watched, so even a brief "Command expired" would count. + await page.evaluate(() => { + const watch = window as unknown as { expiredShown: boolean; expiredWatch: MutationObserver }; + watch.expiredShown = false; + watch.expiredWatch = new MutationObserver(() => { if (document.body.textContent?.includes("Command expired")) watch.expiredShown = true; }); + watch.expiredWatch.observe(document.body, { childList: true, subtree: true, characterData: true }); + }); + await page.getByRole("button", { name: "Add node" }).click(); + await expect(progress).toHaveText(/Registered · edge-04.*Waiting to connect.*Docker check/); + expect(await page.evaluate(() => { + const watch = window as unknown as { expiredShown: boolean; expiredWatch: MutationObserver }; + watch.expiredWatch.disconnect(); + return watch.expiredShown; + })).toBe(false); + await expect(add.getByText("Rerun only on edge-04 if asked")).toBeVisible(); + // Past the installer's minute without connecting, the dialog points at the node's log. + 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 expect(progress).toContainText("Waiting for Docker"); + await setNode(request, { id: "node-new", provider_ready: true, diagnostic: "" }); + await expect(progress).toHaveText("edge-04 · Connected"); + await add.getByRole("button", { name: "Done" }).click(); + await expect(add).toBeHidden(); + // A finished flow leaves no limits behind: the next node starts from the defaults. + await page.getByRole("button", { name: "Add node" }).click(); + await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2"); +}); +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(); @@ -35,7 +111,24 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist // The saved specification carries the Runtime read from the console's manifest. await expect(page.getByText("c0ffee000000")).toBeVisible(); - await expect(page.getByRole("button", { name: "Add node" })).toBeVisible(); + // Own machines continue straight to adding the first node, at its limits: no command is issued yet. + await expect(page.getByRole("dialog", { name: "Add node" }).getByLabel("Sandboxes at once")).toBeVisible(); + // None is requested within a second of opening, and Core saw only the deployment write. + const tokenRequested = await page.waitForRequest((sent) => sent.url().endsWith("/core/v1/sandbox/enrollment-tokens"), { timeout: 1000 }).then(() => true, () => false); + expect(tokenRequested).toBe(false); + expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); +}); + +test("saves E2B without opening Add node, as it has no machines", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none" }); + await page.getByRole("button", { name: "E2B cloud" }).click(); + await page.getByLabel("E2B API key").fill("fixture-private-key"); + await page.getByLabel("Template build").fill("template:94be54a1-138c-4f30-bc87-b13686272dbe"); + await page.getByRole("button", { name: "Next" }).click(); + await page.getByLabel("Core address").fill("https://core.example.com"); + await page.getByRole("button", { name: "Save configuration" }).click(); + await expect(page.getByRole("heading", { name: "Sandbox backend", level: 1 })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); }); test("keeps the saved size and Runtime for the same backend, and starts another from its defaults", async ({ page, request }) => { diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 53dbc869b..88ff352bf 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,14 +1,37 @@ import { createPortal } from "react-dom"; -import { Check, Copy, Plus, Terminal } from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; +import { Check, Copy, Terminal, TriangleAlert } from "lucide-react"; +import { useCallback, 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 { formatBytes } from "../../lib/format"; +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, progressSteps, USER_MANAGER_RESTART, type EnrollmentTarget, type StepState } 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. */ } +} + +/** The limits a new flow starts from. */ +const DEFAULT_ACTIVE = "2"; +const DEFAULT_RETAINED = "8"; + +/** A command, with the nodes registered before it and the limits it approved, which identify its node. */ +interface Enrollment extends EnrollmentTarget { + token: string; + expires_at: string; +} /** * Add node: the administrator sets the node's sandbox limits, then Core issues a @@ -16,36 +39,49 @@ 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: read on each opening, every few + * seconds while open, and once more at expiry, since a node registered by the + * command outranks its expiry. */ -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; - onRefresh: () => void; - onConnected?: (id: string) => void; + onClose: () => void; + /** Reads the page's node list again; settles when the read has. */ + onRefresh: () => Promise; }) { 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 [active, setActive] = useState(DEFAULT_ACTIVE); + const [retained, setRetained] = useState(DEFAULT_RETAINED); 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()); + // When the latest node-list read this dialog asked for began (Date.now()), once it has finished. + const [checkedAt, setCheckedAt] = useState(0); + const reading = useRef(false); 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 +93,92 @@ 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, suspends) : 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 expiresAt = enrollment ? Date.parse(enrollment.expires_at) : 0; + const lapsed = Boolean(enrollment && expiresAt <= now); + // Expired only once a read begun after the expiry found no node for the command. + const expired = lapsed && checkedAt >= expiresAt; + 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); + const check = useCallback(async () => { + if (reading.current) return; + reading.current = true; + const started = Date.now(); + try { await onRefresh(); } finally { reading.current = false; } + setCheckedAt((current) => Math.max(current, started)); + }, [onRefresh]); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); + useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); + // The command's node may have registered while the dialog was closed. + useEffect(() => { if (open && enrollment) void check(); }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + // At expiry, one more read decides between the command's node and "Command expired". useEffect(() => { - if (!open || !enrollment || !connected || revealedId.current === connected.id) return; - revealedId.current = connected.id; - onConnected?.(connected.id); - }, [open, enrollment, connected, onConnected]); + if (open && lapsed && !registered && checkedAt < expiresAt) void check(); + }, [open, lapsed, registered, checkedAt, expiresAt, check]); + // The installer's wait for readiness counts from when the node appears. useEffect(() => { - if (!open || !enrollment) return; + if (nodeId) setAppeared((current) => (current?.id === nodeId ? current : { id: nodeId, at: Date.now() })); + }, [nodeId]); + useEffect(() => { + 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; - const interval = window.setInterval(onRefresh, 3000); + if (!polling) return; + const interval = window.setInterval(() => void check(), 3000); return () => window.clearInterval(interval); - }, [open, enrollment, expired, onRefresh]); - function start() { - setActive("2"); setRetained("8"); - setOpen(true); - } + }, [polling, check]); 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); } + // The limits stay only with an unfinished flow: a command still waiting for its node. + if (!enrollment || ready) { setActive(DEFAULT_ACTIVE); setRetained(DEFAULT_RETAINED); } + 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)); + // A node the previous command enrolled since the last read is that command's success: + // follow it instead of folding it into a new command's known nodes. + if (enrollment && enrolledNode(current.data, enrollment, suspends)) { + await onRefresh(); + return; + } + 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, ...capacity }); 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 +190,119 @@ 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 size = deployment.specification?.resources; + const values = { + console: sourceUrl ?? "", core: coreUrl ?? "", + size: size ? t("{{cpus}} CPU · {{memory}}", { cpus: size.cpus, memory: formatBytes(size.memory_mib * 2 ** 20) }) : "", + }; + const requirements = deployment.provider === "docker" || deployment.provider === "microsandbox" ? ( +
setRequirementsOpen(event.currentTarget.open)}> + {t("Host requirements")} +
    + {hostPrerequisites(deployment.provider, Boolean(size)).map((item) => ( +
  • + {t(item.label, values)} + {item.command ? : null} +
  • + ))} +
+
+

{t("After a group change, sign in again as that user. If its systemd manager was already running, restart it (or reboot):")}

+ +
+
+ ) : 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}

+ // Tense tells a step's state: done in the past, the current one waiting, later ones as plain nouns. + // Readiness from an unconfirmed read still counts as waiting. + const labels: Record[] = [ + { done: t("Registered · {{name}}", { name: node?.name ?? "" }), current: t("Waiting for registration"), future: t("Waiting for registration") }, + { done: t("Connected"), current: t("Waiting to connect"), future: t("Connect") }, + { done: t("{{backend}} ready", { backend }), current: t("Waiting for {{backend}}", { backend }), future: t("{{backend}} check", { backend }) }, + ]; + const tones: Record = { done: "ok", current: progress.problem ? "warning" : "pending", future: "neutral" }; + const steps = progressSteps(progress.stage === "ready" ? "connected" : progress.stage).map((state, index) => ({ state, label: labels[index]![state], tone: tones[state] })); + 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")}