diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index 5f940ca97..f357761f6 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -523,6 +523,26 @@ 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. The + log command follows the command last copied; after the no-sudo one it adds the + system service's, for a root shell. Until the installation is read, a line says + it is being checked; a failed read, a loopback public URL, or a console without + the provider's node files replaces the limits with one line saying why (the + failed read with Try again), 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, a Graphite line that it deletes no + sandboxes, volumes or images (and, for microsandbox, keeps its image store and + data), and the no-sudo form behind an "Installed without sudo?" disclosure. A + node enrolled with an earlier Core address adds an "Old Core address gone?" + disclosure with the `--force` form; a loopback console carries Add node's amber + note. Done dismisses it and focus returns to the page heading. - **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..a534df76e 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -82,7 +82,11 @@ 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 before the installation is read, while the public URL is + loopback, or when 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..4740fc031 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,16 @@ 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("Rootful Docker Engine running, its socket owned by the docker group with mode 0660, enforcing CPU and memory limits (cgroup v2)")).toBeVisible(); + await expect(add.getByText("SELinux is not enforcing (otherwise use the no-sudo command)")).toBeVisible(); + await expect(add.getByText("One Core per host: a host already running a node for another Core is refused.")).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 +33,12 @@ 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.getByText("If the command is interrupted or the download stalls, run the same command again: the download resumes.")).toBeVisible(); 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/); @@ -79,9 +87,20 @@ test("adds a node: host requirements, a countdown, the same command after closin 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"); + const problem = add.getByRole("alert").filter({ hasText: "Check the log on the host:" }); 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 and the same command without sudo. Once that one is copied, the log + // hint names its user service, and the system service in case root ran it. + 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).not.toContainText("journalctl --user"); + await add.getByRole("button", { name: "Copy command without sudo" }).click(); await expect(problem).toContainText("journalctl --user -u parsar-node-7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f.service"); + await expect(problem).toContainText("If root ran it, it is a system service:sudo journalctl -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" }); await expect(problem).toContainText("Docker limits unsupported"); @@ -95,14 +114,41 @@ 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 before the installation is read, for a loopback public URL or without node files, and sees a fix on reopening", async ({ page, request }) => { + // Until the installation is read, and while it can't be, nothing is issued: the read decides. + let release = () => {}; + const held = new Promise((resolve) => { release = resolve; }); + await page.route("**/core/v1/installation", async (route) => { + await held; + await route.fulfill({ status: 500, json: { error: { message: "Unavailable.", type: "server_error", code: null, param: null } } }); + }); + 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("Checking this installation's public URL…"); + await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); + release(); + await expect(add.getByRole("alert")).toContainText("The installation couldn't be read, so no command can be issued."); + await expect(add.getByRole("button", { name: "Generate command" })).toHaveCount(0); + await page.unroute("**/core/v1/installation"); + await add.getByRole("button", { name: "Try again" }).click(); + // Nodes on other machines can't reach a loopback public_url; this replaces the note about the browser's address. + 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 +158,16 @@ 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'\)$/); + // Nothing to force for a node on the current address; closing leaves focus on the page, as the row is gone. + await expect(cleanup.getByText("Old Core address gone?")).toHaveCount(0); + await cleanup.getByRole("button", { name: "Done" }).click(); + await expect(cleanup).toBeHidden(); + await expect(page.getByRole("heading", { name: "Nodes", level: 1 })).toBeFocused(); }); test("sets up own-machine sandboxes page by page, with the Runtime from the distribution", async ({ page, request }) => { diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index 7ef359f2d..d49731c9b 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -65,4 +65,9 @@ test("warns on Nodes about a node bound to an old Core address until it is remov await page.getByRole("dialog", { name: "Remove node" }).getByRole("button", { name: "Confirm removal" }).click(); await expect(page.getByRole("table", { name: "Sandbox nodes" })).not.toContainText("core-01"); await expect(warning).toHaveCount(0); + // Its host's uninstall confirms the removal at that old address; if it no longer answers, --force skips the check. + const cleanup = page.getByRole("dialog", { name: "Clean up the host" }); + await cleanup.getByText("Old Core address gone?").click(); + await expect(cleanup).toContainText("core-01 still points at the old Core address https://core-old.example.com."); + await expect(cleanup.getByLabel("Uninstall command with --force", { exact: true })).toHaveValue(/--uninstall --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f' --force\)$/); }); diff --git a/apps/web/src/features/sandbox/NodeCleanupDialog.tsx b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx new file mode 100644 index 000000000..6ec17d80b --- /dev/null +++ b/apps/web/src/features/sandbox/NodeCleanupDialog.tsx @@ -0,0 +1,62 @@ +import { TriangleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Modal } from "../../components/Modal"; +import { sandboxSetupOrigin } from "./core-origin"; +import { nodeUninstallCommand, type NodeInstallMode } 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; + /** This console's address, which the command downloads the installer from. */ + sourceUrl: string; + installationId: string; + scriptDigest: string; + provider: string; + /** The Core address the node enrolled with, when it is no longer the deployment's; else null. */ + oldAddress: string | null; +} + +/** + * After Remove: the command that removes the node's service and files from its + * host (deploy/install/node_install.py `uninstall_system` and `uninstall_user`). + * The installer first confirms with Core, at the node's own address, that the + * node is removed, which holds 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. A node enrolled with an earlier address may find it gone; then + * `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or + * images. + */ +export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCleanup | null; open: boolean; onClose: () => void }) { + const { t, i18n } = useTranslation("sandbox"); + // Sentences run on with a space in English and without one in Chinese. + const join = (...sentences: string[]) => sentences.join(i18n.resolvedLanguage?.startsWith("zh") ? "" : " "); + const command = (mode: NodeInstallMode, force = false) => cleanup + ? nodeUninstallCommand({ sourceUrl: cleanup.sourceUrl, installationId: cleanup.installationId, scriptDigest: cleanup.scriptDigest, mode, force }) : ""; + return {t("Done")}}> + {cleanup ?
+

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

+ {/* Like Add node's command, this one downloads from the console's own address. */} + {sandboxSetupOrigin(cleanup.sourceUrl) === null ?

: null} + +

{join(t("It never deletes sandboxes, volumes or images."), + ...(cleanup.provider === "microsandbox" ? [t("It keeps microsandbox's image store and sandbox data, and prints how to remove them by hand.")] : []))}

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

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

+ +
+
+ {cleanup.oldAddress !== null ?
+ {t("Old Core address gone?")} +
+

{join(t("{{name}} still points at the old Core address {{address}}.", { name: cleanup.name, address: cleanup.oldAddress }), + t("If this node's old Core address no longer responds, first remove it on the Nodes page, then add --force to the uninstall command."))}

+ +
+
: null} +
: null} +
; +} diff --git a/apps/web/src/features/sandbox/NodeEnrollment.tsx b/apps/web/src/features/sandbox/NodeEnrollment.tsx index 685a4d018..ca1b0238f 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,15 @@ 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, and the log hint follows the + * command last copied. No command is issued until the installation is read: one + * whose public URL is loopback (`local_only`), an unreadable one, or a console + * that reports no node files for the deployment's provider (`node_artifacts`) + * says so instead. Each opening, and each return to the window while open, reads + * the installation and the console 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 +72,13 @@ 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()); + const [noSudoOpen, setNoSudoOpen] = useState(false); + // The command last copied, so the log hint names that one's service. + const [copiedMode, setCopiedMode] = useState("sudo"); + 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 +87,21 @@ 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, nor before the installation is read: a failed read (an older Core, say) proves nothing. + const blocker: { text: string; failed?: boolean } | null = installation.data === undefined + ? installation.isError ? { text: t("The installation couldn't be read, so no command can be issued."), failed: true } : { text: t("Checking this installation's public URL…") } + : installation.data.local_only + ? { text: 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) + ? { text: 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 +123,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 +137,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,11 +172,11 @@ 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. - if (!enrollment || ready) { setActive(DEFAULT_ACTIVE); setRetained(DEFAULT_RETAINED); } + // The limits, and which command was copied, stay only with an unfinished flow: a command still waiting for its node. + if (!enrollment || ready) { setActive(DEFAULT_ACTIVE); setRetained(DEFAULT_RETAINED); forgetMode(); } onClose(); } /** @@ -151,14 +186,18 @@ 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); forgetMode(); + } + /** A flow that ends or starts over forgets which command was copied, and folds the no-sudo command away. */ + function forgetMode() { + setCopiedMode("sudo"); setNoSudoOpen(false); } 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. @@ -172,7 +211,7 @@ export function NodeEnrollment({ client, consoleConfig, deployment, nodes, open, } const result = await client.createEnrollment({ signal: controller.signal }, capacity); if (!controller.signal.aborted) { - setEnrollment({ token: result.token, expires_at: result.expires_at, enrollment_id: result.enrollment_id }); setAppeared(null); setNow(Date.now()); + setEnrollment({ token: result.token, expires_at: result.expires_at, enrollment_id: result.enrollment_id }); setAppeared(null); setNow(Date.now()); forgetMode(); // Seen with the limits; the command comes first now. setRequirementsOpen(false); } @@ -182,36 +221,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 ? <> + + setCopiedMode("user")} /> + : null; const limitsForm = `${id}-limits`; - const footer = !available || (!enrollment && !nodeFiles) ? undefined + const footer = !available || (!enrollment && blocker) ? undefined : !enrollment ? <> @@ -220,7 +240,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 +260,13 @@ 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.failed + ?

{blocker.text}

+ :

{blocker.text}

: !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 +285,10 @@ 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} - -
-