From 637bdde1bcd7db158103ab41cc341f929a427476 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 00:58:28 +0000 Subject: [PATCH 1/4] Add nodes with one sudo command and clean up hosts after removal Add node now generates the sudo-mode command: a leading space, the checked installer run as root (sudo unless the shell is root) and the one-time token passed only on standard input. A "No sudo on this host?" disclosure gives the same command without sudo, with what the node's own user needs, and the log hint follows the mode on screen. The host requirements describe sudo mode. The dialog issues no command while the installation's public URL is loopback, and it reads the console configuration and the installation again on opening and on window focus, so a fix on the Core host shows without a reload. After Remove, a Clean up the host dialog gives the host's uninstall command in both forms. --- apps/web/DESIGN.md | 14 ++ apps/web/PRODUCT.md | 5 +- apps/web/e2e/nodes.spec.ts | 52 ++++++-- .../features/sandbox/NodeCleanupDialog.tsx | 37 +++++ .../src/features/sandbox/NodeEnrollment.tsx | 126 ++++++++---------- .../features/sandbox/SandboxManagerView.css | 8 +- .../features/sandbox/SandboxManagerView.tsx | 12 ++ .../sandbox/enrollment-command.test.ts | 83 ++++++++---- .../features/sandbox/enrollment-command.ts | 59 ++++++-- .../src/features/sandbox/node-commands.tsx | 112 ++++++++++++++++ .../features/sandbox/node-enrollment.test.ts | 6 - .../src/features/sandbox/node-enrollment.ts | 83 +++++++----- apps/web/src/lib/locale-strings.ts | 27 +++- docs/web/protocol-coverage.md | 6 +- 14 files changed, 465 insertions(+), 165 deletions(-) create mode 100644 apps/web/src/features/sandbox/NodeCleanupDialog.tsx create mode 100644 apps/web/src/features/sandbox/node-commands.tsx diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 5f940ca97..937912c16 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -523,6 +523,20 @@ request runs. Graphite hint for automation (`chmod 600`, `--credential-file`). Done is outline and forgets the credential; closing the dialog keeps it in a pending card, which points to the Connect a host command below. +- **Add node**: the sandbox limits first, then the one-time command in a Terminal + block (expiry countdown and Copy command in its header), the three progress + steps, and, once the installer's minute passes, an amber card with the reason + and a copyable log command. Below, two folded Hairline disclosures: Host + requirements for the default command, which uses sudo (open until this browser + has shown it once, with notes that it creates the `parsar-node` system service + and, for Docker, that the docker group is root-equivalent), and "No sudo on this + host?", with what the node's own user needs and the command without sudo. While + it is open, the log command is the user service's. A loopback public URL, or a + console without the provider's node files, replaces the limits with one line + saying why, and the footer offers nothing to generate. +- **Clean up the host**: after a node is removed, a dialog gives the host's + uninstall command in the same Terminal block, with the no-sudo form behind an + "Installed without sudo?" disclosure; Done dismisses it. - **How to call**: wherever a new key is shown, a card under it gives three copyable samples, each a Margin Gray block with a Hairline and its label and copy button in a header row: a Shell block exporting `OPENAI_BASE_URL` (the diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index c0ab4fc0c..033074557 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -82,7 +82,10 @@ workbench. template build's), a review, and advanced settings with the complete form — then the node list with each node's capacity, host figures and allocations, enrollment, renaming, sandbox limits and removal; Add node - asks for the node's sandbox limits before it issues the one-time command), System (the + asks for the node's sandbox limits before it issues the one-time command, which installs + the node with sudo as a system service (a disclosure gives the command without sudo, as a + user service); it issues none while the public URL is loopback or the console lacks the + provider's node files; after Remove, a dialog gives the host's uninstall command), System (the installation's public address, API base URL, installation ID and source commit, read-only; each harness's default model, set, replaced or cleared there beside its read-only startup state; the sandbox configuration every project shares, with a link to Nodes where it diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 6d637cd00..7d1adf76b 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -1,10 +1,10 @@ import { expect, test } from "@playwright/test"; -import { expectManagementBoundary, openConsole, setNode, writes } from "./console"; +import { expectManagementBoundary, openConsole, resetFixture, setNode, writes } from "./console"; test.afterEach(async ({ request }) => expectManagementBoundary(request)); -test("adds a node: host requirements, a countdown, the same command after closing, a new one after expiry, then its own node's registration", async ({ page, request }) => { +test("adds a node: host requirements, a sudo command and one without, a countdown, the same command after closing, a new one after expiry, then its own node's 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) => { @@ -15,12 +15,14 @@ test("adds a node: host requirements, a countdown, the same command after closin 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(/^Can reach http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes must reach https:\/\/core\.example\.com$/)).toBeVisible(); + // What a Docker host needs for the default command, which installs the node with sudo. + await expect(add.getByText("Docker Engine installed and running, enforcing CPU and memory limits (cgroup v2)")).toBeVisible(); + await expect(add.getByText("CPUs and memory for at least one sandbox: 2 CPU · 4 GiB; about 2 GB of disk for the Runtime image")).toBeVisible(); + await expect(add.getByText(/^Reaches http:\/\/127\.0\.0\.1:\d+ and https:\/\/core\.example\.com; sandboxes reach https:\/\/core\.example\.com$/)).toBeVisible(); + await expect(add.getByText("parsar-node joins the docker group, which is equivalent to root on this host.")).toBeVisible(); await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0); + // Preparing a user instead of using sudo waits behind its disclosure. + await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeHidden(); // 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"); @@ -29,8 +31,11 @@ test("adds a node: host requirements, a countdown, the same command after closin 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 }); - const field = add.getByLabel("One-time enrollment command"); + const field = add.getByLabel("One-time enrollment command", { exact: true }); await expect(field).toHaveValue(/enroll_fixture_/); + // The token goes on stdin to the checked installer, run with sudo unless the shell is root. + await expect(field).toHaveValue(/^ \(umask 077;.*\|\| s=sudo\n/); + await expect(field).toHaveValue(/\| \$s python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /); 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/); @@ -81,6 +86,13 @@ test("adds a node: host requirements, a countdown, the same command after closin await page.clock.fastForward("01:01"); const problem = add.getByRole("alert"); await expect(problem).toContainText("Not connected yet"); + await expect(problem).toContainText("sudo journalctl -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); + // Without sudo: what that user needs, the same command without sudo, and its user service's log. + await add.getByText("No sudo on this host?").click(); + await expect(add.getByText("sudo usermod -aG docker NODE_USER")).toBeVisible(); + const userCommand = add.getByLabel("One-time enrollment command without sudo", { exact: true }); + await expect(userCommand).toHaveValue(/EXIT\ncurl/); + await expect(userCommand).toHaveValue(/\| python3 "\$d\/node-install\.pyz" --enrollment-token-stdin /); await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.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" }); @@ -95,14 +107,27 @@ test("adds a node: host requirements, a countdown, the same command after closin await expect(add.getByLabel("Sandboxes at once")).toHaveValue("2"); }); -test("says the console has no node files for the provider and issues no command", async ({ page, request }) => { +test("issues no command for a loopback public URL or without node files, and sees a fix on reopening", async ({ page, request }) => { + // Nodes on other machines can't reach a loopback public_url; this replaces the note about the browser's address. + await openConsole(page, request, "nodes", { installation: "local" }); + await page.getByRole("button", { name: "Add node" }).click(); + const add = page.getByRole("dialog", { name: "Add node" }); + await expect(add.getByRole("status")).toHaveText("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply"); + await expect(add.getByRole("note")).toHaveCount(0); + await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); + await add.getByRole("button", { name: "Close dialog" }).click(); + // A thin bundle: the console holds no node files at all. await openConsole(page, request, "nodes", { nodeArtifacts: [] }); await page.getByRole("button", { name: "Add node" }).click(); - const add = page.getByRole("dialog", { name: "Add node" }); await expect(add.getByRole("status")).toHaveText("This console has no node files for Docker. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh."); await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); expect(await writes(request)).toEqual([]); + // Rerunning ./install.sh adds them: reopening reads the console again, without a reload. + await add.getByRole("button", { name: "Close dialog" }).click(); + await resetFixture(request); + await page.getByRole("button", { name: "Add node" }).click(); + await expect(add.getByRole("button", { name: "Generate command" })).toBeVisible(); }); test("removes a node after confirmation", async ({ page, request }) => { @@ -112,6 +137,13 @@ test("removes a node after confirmation", async ({ page, request }) => { await confirm.getByRole("button", { name: "Confirm removal" }).click(); await expect(confirm).toBeHidden(); await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("edge-03"); + // The host still runs the node until it is uninstalled there: with sudo, or as the user that installed it. + const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); + await expect(cleanup.getByLabel("Uninstall command", { exact: true })).toHaveValue(/\| s=sudo\n[^]*\n\$s python3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await cleanup.getByText("Installed without sudo?").click(); + await expect(cleanup.getByLabel("Uninstall command without sudo", { exact: true })).toHaveValue(/\npython3 "\$d\/node-install\.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f'\)$/); + await cleanup.getByRole("button", { name: "Done" }).click(); + await expect(cleanup).toBeHidden(); }); test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx new file mode 100644 index 000000000..2d04859ba --- /dev/null +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from "react-i18next"; + +import { Modal } from "../../components/Modal"; +import { nodeUninstallCommand } from "./enrollment-command"; +import { CommandBlock } from "./node-commands"; + +/** A node Core has just removed, with what builds its host's uninstall command. */ +export interface NodeCleanup { + name: string; + sourceUrl: string; + installationId: string; + scriptDigest: string; +} + +/** + * After Remove: the command that removes the node's service and files from its + * host. The installer uninstalls only once Core refuses the node's credential, + * which it does from the removal on. The sudo form escalates by itself, so a node + * installed without sudo gets its own command, run as that node's user. + */ +export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) { + const { t } = useTranslation("sandbox"); + const command = (mode: "sudo" | "user") => cleanup ? nodeUninstallCommand({ sourceUrl: cleanup.sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode }) : ""; + return {t("Done")}}> + {cleanup ?
+

{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}

+ +
+ {t("Installed without sudo?")} +
+

{t("Run this as that user instead:")}

+ +
+
+
: null} +
; +} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 685a4d018..7b5b88f1f 100644 --- a/apps/web/src/features/sandbox/NodeEnrollment.tsx +++ b/apps/web/src/features/sandbox/NodeEnrollment.tsx @@ -1,18 +1,21 @@ import { createPortal } from "react-dom"; -import { Check, Copy, Terminal, TriangleAlert } from "lucide-react"; +import { TriangleAlert } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import type { SandboxAdminClient, SandboxDeployment, SandboxEnrollment, SandboxNode } from "@agents-core-web/agents-client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { HelpTip, StatusDot, type Tone } from "../../components/console-ui"; import { Modal } from "../../components/Modal"; import { formatBytes } from "../../lib/format"; +import { installationQuery } from "../../lib/installation"; import { sandboxDiagnosticMessage } from "../../lib/sandbox-diagnostic"; import { sandboxRequestError } from "../../lib/sandbox-labels"; -import { useCopy } from "../api-keys/IssuedKey"; import { sandboxCoreOrigin, sandboxSetupOrigin } from "./core-origin"; import { nodeFilesAvailable, type SandboxConsoleConfig } from "./console-config"; -import { nodeInstallCommand, nodeLogCommand } from "./enrollment-command"; -import { enrolledNode, enrollmentProgress, formatCountdown, hostPrerequisites, progressSteps, USER_MANAGER_RESTART, type StepState } from "./node-enrollment"; +import { nodeInstallCommand, nodeLogCommand, type NodeInstallMode } from "./enrollment-command"; +import { CommandBlock, CopyCommand, HostRequirements, NoSudoGuide } from "./node-commands"; +import { enrolledNode, enrollmentProgress, formatCountdown, progressSteps, type StepState } from "./node-enrollment"; +import { sandboxConsoleConfigQuery } from "./sandbox-queries"; /** The host requirements open by default until this browser has shown them once. */ const REQUIREMENTS_SEEN = "agents-core-web.node-requirements-seen"; @@ -32,8 +35,13 @@ const DEFAULT_RETAINED = "8"; * one-time enrollment command that approves them * (`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. A console that reports no node files for the - * deployment's provider (`node_artifacts`) says so instead and issues no command. + * sandboxes it runs at once. The command runs the installer with sudo, which + * installs the node as a system service; a disclosure offers the same command + * without sudo, which installs a user service. An installation whose public URL + * is loopback (`local_only`), or a console that reports no node files for the + * deployment's provider (`node_artifacts`), says so instead and issues no command. + * Each opening, and each return to the window while open, reads both again, so a + * fix on the Core host shows without a reload. * * 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 @@ -62,10 +70,12 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, 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 [requirementsOpen, setRequirementsOpen] = useState(() => !requirementsSeen()); + // The no-sudo disclosure; while it is open, log hints are for a user service. + const [noSudoOpen, setNoSudoOpen] = useState(false); + const queryClient = useQueryClient(); + const installation = useQuery(installationQuery); // 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); @@ -74,11 +84,18 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, const sourceUrl = sandboxCoreOrigin(window.location.origin); const coreUrl = sandboxCoreOrigin(deployment.core_url || window.location.origin); const available = Boolean(consoleConfig.node_installer && sourceUrl && coreUrl); - // Without the provider's node files the installer would fail on the host, so no command is issued. - const nodeFiles = nodeFilesAvailable(consoleConfig, deployment.provider); + const provider = deployment.provider === "docker" || deployment.provider === "microsandbox" ? deployment.provider : null; + const backend = provider === "microsandbox" ? "microsandbox" : "Docker"; + // Nodes and their sandboxes reach Core at its public URL, so a loopback one serves no other machine; + // and without the provider's node files the installer would fail on the host. Either way no command is issued. + const blocker = installation.data?.local_only === true + ? t("Nodes need an HTTPS public URL that other machines and their sandboxes can reach: set public_url in config.json and run parsar apply") + : !nodeFilesAvailable(consoleConfig, deployment.provider) + ? t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend }) + : null; // 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; + const consoleLoopback = 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); @@ -100,8 +117,9 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, 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 commandFor = (mode: NodeInstallMode) => enrollment && provider && available && (registered || !expired) && !ready + ? nodeInstallCommand({ token: enrollment.token, coreUrl: coreUrl!, sourceUrl: sourceUrl!, provider, installationId: deployment.installation_id, scriptDigest: consoleConfig.node_installer_sha256, mode }) : ""; + const command = commandFor("sudo"); const nodeId = node?.id ?? null; const polling = open && enrollment !== null && !ready && (registered || !expired); const check = useCallback(async () => { @@ -113,6 +131,17 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, }, [onRefresh]); useEffect(() => () => { generation.current++; request.current?.abort(); }, []); useEffect(() => { if (open) rememberRequirementsSeen(); }, [open]); + // Rerunning ./install.sh or parsar apply on the Core host changes what the console and the installation report. + useEffect(() => { + if (!open) return; + const reread = () => { + void queryClient.invalidateQueries({ queryKey: sandboxConsoleConfigQuery.queryKey }); + void queryClient.invalidateQueries({ queryKey: installationQuery.queryKey }); + }; + reread(); + window.addEventListener("focus", reread); + return () => window.removeEventListener("focus", reread); + }, [open, queryClient]); // 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". @@ -137,7 +166,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, function close() { generation.current++; request.current?.abort(); request.current = null; - setError(null); setBusy(false); setCopied(false); setCopyFailed(false); + setError(null); setBusy(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. @@ -151,14 +180,14 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, */ function changeLimits() { generation.current++; - setEnrollment(null); setAppeared(null); setError(null); setCopied(false); setCopyFailed(false); + setEnrollment(null); setAppeared(null); setError(null); } async function generate() { - if (request.current || !available || !nodeFiles || !limitsReady || activeLimit === null || retainedLimit === null) return; + if (request.current || !available || blocker || !limitsReady || activeLimit === null || retainedLimit === null) return; const capacity = { max_active: activeLimit, max_retained: retainedLimit }; generation.current++; const controller = new AbortController(); request.current = controller; - setBusy(true); setError(null); setCopied(false); setCopyFailed(false); + setBusy(true); setError(null); try { // A node the previous command enrolled since the last read is that command's success: // follow it instead of issuing another command. @@ -182,36 +211,17 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, } finally { if (!controller.signal.aborted) { request.current = null; setBusy(false); } } } - async function copyCommand() { - const current = generation.current; - try { await navigator.clipboard.writeText(command); if (current === generation.current) { setCopied(true); setCopyFailed(false); } } - catch { if (current === generation.current) { setCopied(false); setCopyFailed(true); } } - } - 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 requirements = provider ? <> + + + : null; const limitsForm = `${id}-limits`; - const footer = !available || (!enrollment && !nodeFiles) ? undefined + const footer = !available || (!enrollment && blocker) ? undefined : !enrollment ? <> @@ -220,7 +230,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, : registered ? : <> - {expired && nodeFiles ? : null} + {expired && !blocker ? : null} ; // 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. @@ -240,11 +250,11 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, {!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 && !nodeFiles ?

{t("This console has no node files for {{provider}}. Install Core from the offline bundle, or add the release artifacts and rerun ./install.sh.", { provider: backend })}

+ : !enrollment && blocker ?

{blocker}

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

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

- {localOnly ?

: null} + {consoleLoopback ?

: null}
{t("The most sandboxes Core places on this node at the same time.")} setActive(event.target.value)} aria-invalid={Boolean(activeProblem)} /> @@ -263,15 +273,8 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, ) : <> {/* Once used, the command only recovers its own node: running it on another host fails. */} {command ?

{registered && node ? t("Rerun only on {{name}} if asked", { name: node.name }) : t("Run on the host you want to add.")}

: null} - {command ?
-
- {t("Terminal")} - {!registered ? {t("Expires in {{time}}", { time: formatCountdown(Date.parse(enrollment.expires_at) - now) })} : null} - -
-