Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions apps/web/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/web/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/public-url.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<HTMLElement | null>;
}

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<HTMLElement>(null);
const onCloseRef = useRef(onClose);
const initialFocusRef = useRef(initialFocus);
const previousFocusRef = useRef<HTMLElement | null>(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) {
Expand Down Expand Up @@ -57,7 +61,7 @@ export function Modal({ open, title, footer, onClose, wide = false, children }:
].join(",");
const frame = window.requestAnimationFrame(() => {
const preferred = dialog?.querySelector<HTMLElement>("input:not([disabled]), textarea:not([disabled]), select:not([disabled])");
const first = preferred ?? dialog?.querySelector<HTMLElement>(focusableSelector) ?? dialog;
const first = initialFocusRef.current?.current ?? preferred ?? dialog?.querySelector<HTMLElement>(focusableSelector) ?? dialog;
first?.focus();
});

Expand Down
58 changes: 46 additions & 12 deletions apps/web/src/features/sandbox/SandboxSetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*/
Expand All @@ -92,6 +96,9 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal
const [template, setTemplate] = useState(current?.e2bTemplate ?? "");
const [runtime, setRuntime] = useState<Partial<SandboxRuntimeRelease>>({});
const [busy, setBusy] = useState(false);
const [dockerConfirmed, setDockerConfirmed] = useState(current?.provider === "docker");
const [confirmingDocker, setConfirmingDocker] = useState(false);
const keepMicrosandbox = useRef<HTMLButtonElement>(null);
// Core's reason for rejecting the saved configuration, such as E2B with a loopback public_url.
const [rejection, setRejection] = useState<string | null>(null);
const installation = useQuery(installationQuery);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -156,19 +170,19 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal
let page: ReactNode;
if (step === "where") {
page = (
<Question title={t("Where should sandboxes run?")} help={t("E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with Docker or microsandbox.")}>
<Question title={t("Where should sandboxes run?")} help={t("E2B runs sandboxes in its cloud: no machines to manage, billed by E2B. Own machines run them on hosts you add, with microsandbox (recommended) or Docker.")}>
<div className="wizard-choices">
<Choice icon={Cloud} title={t("E2B cloud")} selected={where === "direct"} onClick={() => { setWhere("direct"); choose("e2b"); setStep("e2b"); }} />
<Choice icon={Server} title={t("Own machines")} selected={where === "nodes"} onClick={() => { setWhere("nodes"); setApiKey(""); if (provider === "e2b") setProvider(null); setStep("backend"); }} />
<Choice icon={Server} title={t("Own machines")} selected={where === "nodes"} onClick={() => { setWhere("nodes"); setApiKey(""); if (provider === null || provider === "e2b") choose("microsandbox"); setStep("backend"); }} />
</div>
</Question>
);
} else if (step === "backend") {
page = (
<Question title={t("Which sandbox backend?")} help={t("Docker runs each sandbox as a container: quick to set up on any Docker host, with CPU and memory limits but no disk quota. microsandbox runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM.")}>
<Question title={t("Which sandbox backend?")} help={t("microsandbox, the recommended default, runs each sandbox as a lightweight virtual machine: stronger isolation and its own root and data disks with size limits, but the host needs KVM. Docker runs each sandbox as a container on the host's kernel: CPU and memory limits but no disk quota, for trusted workloads or hosts without KVM.")}>
<div className="wizard-choices">
<Choice icon={Box} title="Docker" selected={provider === "docker"} onClick={() => { choose("docker"); setStep("size"); }} />
<Choice icon={Cpu} title="microsandbox" selected={provider === "microsandbox"} onClick={() => { choose("microsandbox"); setStep("size"); }} />
<Choice icon={Cpu} title="microsandbox" badge={t("Recommended")} selected={provider === "microsandbox"} onClick={() => { choose("microsandbox"); setStep("size"); }} />
<Choice icon={Box} title="Docker" selected={provider === "docker"} onClick={() => { if (dockerConfirmed) selectDocker(); else setConfirmingDocker(true); }} />
</div>
<Nav onBack={back} t={t} />
</Question>
Expand Down Expand Up @@ -324,6 +338,26 @@ export function SandboxSetupWizard({ coreUrl, current, disabled, switching = fal
{page}
</m.div>
</AnimatePresence>
{/* Portaled: the sliding page's transform would otherwise contain the fixed backdrop. */}
{createPortal(
<Modal
open={confirmingDocker}
title={t("Use Docker instead of microsandbox?")}
initialFocus={keepMicrosandbox}
onClose={() => setConfirmingDocker(false)}
footer={<>
<button type="button" className="button outline" onClick={selectDocker}>{t("Use Docker")}</button>
<button ref={keepMicrosandbox} type="button" className="button primary" onClick={() => setConfirmingDocker(false)}>{t("Keep microsandbox")}</button>
</>}
>
<ul className="wizard-docker-risks">
<li><strong>{t("Weaker isolation")}</strong>{t("Containers share the host's kernel, so a container escape reaches the host. microsandbox runs each sandbox in its own microVM.")}</li>
<li><strong>{t("Root-equivalent access")}</strong>{t("The node's service account joins the docker group, which is equivalent to root on that host.")}</li>
<li><strong>{t("Limited use")}</strong>{t("Docker suits only trusted workloads, or hosts without KVM.")}</li>
</ul>
</Modal>,
document.body,
)}
</section>
);
}
Expand All @@ -337,12 +371,12 @@ function Question({ title, help, children }: { title: string; help?: string; chi
);
}

/** A large option that selects and moves on in one click. */
function Choice({ icon: Icon, title, value, detail, selected, onClick }: { icon?: LucideIcon; title: string; value?: string; detail?: string; selected: boolean; onClick: () => void }) {
/** A large option that selects and moves on in one click; a badge, such as Recommended, sits beside its title. */
function Choice({ icon: Icon, title, badge, value, detail, selected, onClick }: { icon?: LucideIcon; title: string; badge?: string; value?: string; detail?: string; selected: boolean; onClick: () => void }) {
return (
<button type="button" className={selected ? "wizard-choice selected" : "wizard-choice"} aria-pressed={selected} onClick={onClick}>
{Icon ? <span className="wizard-choice-icon"><Icon size={20} strokeWidth={1.5} aria-hidden="true" /></span> : null}
<span className="wizard-choice-title">{title}</span>
<span className="wizard-choice-heading"><span className="wizard-choice-title">{title}</span>{badge ? <span className="pill">{badge}</span> : null}</span>
{value ? <span className="wizard-choice-value">{value}</span> : null}
{detail ? <span className="wizard-choice-value">{detail}</span> : null}
</button>
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/features/sandbox/sandbox-wizard.css
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,38 @@
.wizard-key-again .wizard-link {
color: var(--accent);
}

/* A choice's title with its badge, such as Recommended. */
.wizard-choice-heading {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4px 8px;
}

.wizard-choice-heading .pill {
margin: 0;
}

/* What Docker gives up against microsandbox, in its confirmation. */
.wizard-docker-risks {
display: grid;
gap: 12px;
margin: 0;
padding: 0;
font-size: 13px;
line-height: 1.5;
list-style: none;
}

.wizard-docker-risks li {
display: grid;
gap: 2px;
color: var(--ink-2);
}

.wizard-docker-risks strong {
color: var(--ink);
font-weight: 600;
}
Loading