From 36c7edaf06d083b09ce15f7ff287903ca05f5fc2 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sat, 26 Sep 2026 05:53:28 +0000 Subject: [PATCH] Default sandbox setup to microsandbox and confirm Docker Own machines now preselect microsandbox, marked Recommended, while a saved backend stays selected. Choosing Docker opens a confirmation listing its weaker isolation, root-equivalent docker group and limited use, with Keep microsandbox as the focused default; it asks once per wizard session and not for a saved Docker deployment. Modal gains an initialFocus option for the default action. --- apps/web/DESIGN.md | 37 +++++++----- apps/web/PRODUCT.md | 3 +- apps/web/e2e/nodes.spec.ts | 30 +++++++++- apps/web/e2e/public-url.spec.ts | 2 +- apps/web/src/components/Modal.tsx | 10 +++- .../features/sandbox/SandboxSetupWizard.tsx | 58 +++++++++++++++---- .../src/features/sandbox/sandbox-wizard.css | 35 +++++++++++ apps/web/src/lib/locale-strings.ts | 14 ++++- 8 files changed, 155 insertions(+), 34 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index f357761f6..5cec577c6 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -543,6 +543,13 @@ request runs. 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. +- **Use Docker instead of microsandbox?**: choosing Docker in sandbox setup lists + what it gives up, each point a 600 Ink lead over a Graphite line: weaker + isolation (containers share the host kernel; microsandbox gives each sandbox + its own microVM), root-equivalent access (the node's account joins the docker + group) and limited use (trusted workloads, or hosts without KVM). The footer + holds Use Docker (outline) and Keep microsandbox (primary), which takes focus; + closing or Escape keeps microsandbox too. - **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 @@ -662,20 +669,22 @@ sidebar's account controls, opens it again at any time. ### Sandbox setup Setting up hosted sandboxes, and changing the provider or resources in maintenance, -is a set of pages inside the Nodes page, one decision each: where sandboxes run (own machines -or E2B), then the backend (Docker or microsandbox, compared behind a help tip) or -the E2B account, then the size of each sandbox (three presets; E2B skips it, since -each sandbox takes the template build's size), then a review. -Choices are large cards that advance on a click; short indigo dashes show the -progress; pages slide and blur across. The review states where sandboxes run, -the size, the Runtime (taken from this console's distribution manifest) and the -Core address, read-only: it is config.json's `public_url`, and the console never -asks for it. A loopback address carries an amber line under it: only the Core -machine reaches it. When Core rejects the configuration for it (E2B with a -loopback `public_url`), a red-tinted block under the review keeps Core's message -and adds the config file and apply command as copyable values. A save attempt -clears the E2B key, so the review then says to enter it again, with a link to -that step. +is a set of pages inside the Nodes page, one decision each: where sandboxes run (own +machines or E2B), then the backend or the E2B account, then the size of each sandbox +(three presets; E2B skips it, since each sandbox takes the template build's size), +then a review. Choices are large cards that advance on a click; short indigo dashes +show the progress; pages slide and blur across. The backend page compares +microsandbox and Docker behind a help tip; microsandbox comes first, preselected (a +saved backend stays selected), with a neutral Recommended pill beside its title. +Docker takes a confirmation (see Dialogs) once per visit to setup; a saved Docker +deployment has already made it. The review states where sandboxes run, the size, the +Runtime (taken from this console's distribution manifest) and the Core address, +read-only: it is config.json's `public_url`, and the console never asks for it. A +loopback address carries an amber line under it: only the Core machine reaches it. +When Core rejects the configuration for it (E2B with a loopback `public_url`), a +red-tinted block under the review keeps Core's message and adds the config file and +apply command as copyable values. A save attempt clears the E2B key, so the review +then says to enter it again, with a link to that step. Advanced settings, one link away, hold the complete form: resources (not for E2B), the Runtime release and the E2B template. A change keeps the saved size and Runtime while the backend stays the same (a saved size outside the presets is diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index a534df76e..b826dfac6 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -78,7 +78,8 @@ workbench. and offer Delete. - **Platform**: Projects and keys (projects, their assets and usage, named keys, write history), Nodes (sandbox setup as pages — where sandboxes run, the - backend or E2B account, the size of each sandbox (own machines only; E2B takes the + backend (microsandbox by default; Docker only after a confirmation of its weaker + isolation) or E2B account, the size of each sandbox (own machines only; E2B takes the 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 diff --git a/apps/web/e2e/nodes.spec.ts b/apps/web/e2e/nodes.spec.ts index 36e7130e3..8cf826005 100644 --- a/apps/web/e2e/nodes.spec.ts +++ b/apps/web/e2e/nodes.spec.ts @@ -174,7 +174,7 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist await openConsole(page, request, "nodes", { sandbox: "none" }); await expect(page.getByRole("heading", { name: "Where should sandboxes run?" })).toBeVisible(); await page.getByRole("button", { name: "Own machines" }).click(); - await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: "microsandbox Recommended" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); await expect(page.getByRole("heading", { name: "Review and save" })).toBeVisible(); await page.getByRole("button", { name: "Save configuration" }).click(); @@ -189,6 +189,34 @@ test("sets up own-machine sandboxes page by page, with the Runtime from the dist expect(await writes(request)).toEqual(["POST /core/v1/sandbox/deployment"]); }); +test("preselects microsandbox and asks once before switching to Docker", async ({ page, request }) => { + await openConsole(page, request, "nodes", { sandbox: "none" }); + await page.getByRole("button", { name: "Own machines" }).click(); + const microsandbox = page.getByRole("button", { name: "microsandbox Recommended" }); + const docker = page.getByRole("button", { name: "Docker", exact: true }); + const confirm = page.getByRole("dialog", { name: "Use Docker instead of microsandbox?" }); + const sizeStep = page.getByRole("heading", { name: "How big is each sandbox?" }); + await expect(microsandbox).toHaveAttribute("aria-pressed", "true"); + + // Keeping microsandbox is the default action; it leaves microsandbox selected. + await docker.click(); + const keep = confirm.getByRole("button", { name: "Keep microsandbox" }); + await expect(keep).toBeFocused(); + await keep.click(); + await expect(confirm).toBeHidden(); + await expect(microsandbox).toHaveAttribute("aria-pressed", "true"); + + // Confirmed, Docker is selected and the wizard does not ask again. + await docker.click(); + await confirm.getByRole("button", { name: "Use Docker" }).click(); + await expect(sizeStep).toBeVisible(); + await page.getByRole("button", { name: "Back" }).click(); + await expect(docker).toHaveAttribute("aria-pressed", "true"); + await docker.click(); + await expect(sizeStep).toBeVisible(); + await expect(confirm).toHaveCount(0); +}); + 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(); diff --git a/apps/web/e2e/public-url.spec.ts b/apps/web/e2e/public-url.spec.ts index d49731c9b..0a822544a 100644 --- a/apps/web/e2e/public-url.spec.ts +++ b/apps/web/e2e/public-url.spec.ts @@ -9,7 +9,7 @@ test("sets up sandboxes with Core's address read-only, never sending it", async page.on("request", (sent) => { if (sent.url().includes("/core/v1/sandbox/deployment")) bodies.push(sent.postData() ?? ""); }); await openConsole(page, request, "nodes", { sandbox: "none" }); await page.getByRole("button", { name: "Own machines" }).click(); - await page.getByRole("button", { name: "Docker" }).click(); + await page.getByRole("button", { name: "microsandbox Recommended" }).click(); await page.getByRole("button", { name: /^Standard/ }).click(); const review = page.getByRole("definition").filter({ hasText: "https://core.example.com" }); await expect(review).toContainText("Set by public_url in config.json"); diff --git a/apps/web/src/components/Modal.tsx b/apps/web/src/components/Modal.tsx index 3cb7a7e12..38b7bc30b 100644 --- a/apps/web/src/components/Modal.tsx +++ b/apps/web/src/components/Modal.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useId, useRef, useState, type PropsWithChildren, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type PropsWithChildren, type ReactNode, type RefObject } from "react"; import { useTranslation } from "react-i18next"; interface ModalProps extends PropsWithChildren { @@ -9,18 +9,22 @@ interface ModalProps extends PropsWithChildren { onClose: () => void; /** A wider dialog for charts and facts; the default suits a form. */ wide?: boolean; + /** What takes focus on open, such as a confirmation's default action; otherwise the first field, then the first control. */ + initialFocus?: RefObject; } -export function Modal({ open, title, footer, onClose, wide = false, children }: ModalProps) { +export function Modal({ open, title, footer, onClose, wide = false, initialFocus, children }: ModalProps) { const { t } = useTranslation(); const dialogRef = useRef(null); const onCloseRef = useRef(onClose); + const initialFocusRef = useRef(initialFocus); const previousFocusRef = useRef(null); const wasOpenRef = useRef(false); const titleId = useId(); const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); onCloseRef.current = onClose; + initialFocusRef.current = initialFocus; useEffect(() => { if (open) { @@ -57,7 +61,7 @@ export function Modal({ open, title, footer, onClose, wide = false, children }: ].join(","); const frame = window.requestAnimationFrame(() => { const preferred = dialog?.querySelector("input:not([disabled]), textarea:not([disabled]), select:not([disabled])"); - const first = preferred ?? dialog?.querySelector(focusableSelector) ?? dialog; + const first = initialFocusRef.current?.current ?? preferred ?? dialog?.querySelector(focusableSelector) ?? dialog; first?.focus(); }); diff --git a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx index be3b3f1d1..1233866e6 100644 --- a/apps/web/src/features/sandbox/SandboxSetupWizard.tsx +++ b/apps/web/src/features/sandbox/SandboxSetupWizard.tsx @@ -3,10 +3,12 @@ import { useQuery } from "@tanstack/react-query"; import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { ArrowLeft, ArrowRight, Box, Cloud, Cpu, Server, SlidersHorizontal, type LucideIcon } from "lucide-react"; -import { useId, useState, type FormEvent, type ReactNode } from "react"; +import { useId, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { HelpTip } from "../../components/console-ui"; +import { Modal } from "../../components/Modal"; import { CopyableId } from "../../components/list-ui"; import { formatBytes } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; @@ -62,13 +64,15 @@ function presetOf(provider: SandboxProvider, resources: SandboxResources): Prese /** * Hosted sandbox setup as pages, one decision each: where sandboxes run, - * which backend (own machines) or the E2B account, how big each sandbox is - * (own machines only: E2B sandboxes take the template build's size), then a - * review. Advanced settings hold the complete form. The Runtime + * which backend (own machines, microsandbox preselected) or the E2B account, + * how big each sandbox is (own machines only: E2B sandboxes take the template + * build's size), then a review. Advanced settings hold the complete form. The Runtime * release comes from this console's distribution manifest when it serves one. * `current` pre-selects the saved choices when a deployment changes. Keeping * the backend keeps its saved size and Runtime; another backend starts from its * defaults and this console's Runtime. An E2B key must be entered again. + * Docker isolates less than microsandbox, so choosing it takes a confirmation, + * once per wizard session; a saved Docker deployment has already made it. * Core's address is config.json's `public_url`: the review only shows it, and * a configuration Core rejects for it is explained here, where it was saved. */ @@ -92,6 +96,9 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal const [template, setTemplate] = useState(current?.e2bTemplate ?? ""); const [runtime, setRuntime] = useState>({}); const [busy, setBusy] = useState(false); + const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker"); + const [confirmingDocker, setConfirmingDocker] = useState(false); + const keepMicrosandbox = useRef(null); // Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url. const [rejection, setRejection] = useState(null); const installation = useQuery(installationQuery); @@ -128,6 +135,13 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal setProvider(next); } + function selectDocker() { + setConfirmingDocker(false); + setDockerConfirmed(true); + choose("docker"); + setStep("size"); + } + async function save(event?: FormEvent) { event?.preventDefault(); if (!ready || !provider) return; @@ -156,19 +170,19 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal let page: ReactNode; if (step === "where") { page = ( - +
{ setWhere("direct"); choose("e2b"); setStep("e2b"); }} /> - { setWhere("nodes"); setApiKey(""); if (provider === "e2b") setProvider(null); setStep("backend"); }} /> + { setWhere("nodes"); setApiKey(""); if (provider === null || provider === "e2b") choose("microsandbox"); setStep("backend"); }} />
); } else if (step === "backend") { page = ( - +
- { choose("docker"); setStep("size"); }} /> - { choose("microsandbox"); setStep("size"); }} /> + { choose("microsandbox"); setStep("size"); }} /> + { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} />