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
6 changes: 2 additions & 4 deletions apps/web/e2e/nodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@ test("adds a node: host requirements, a root/sudo command, a countdown, the same
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 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();
// What a host needs for the default command, which installs the node with sudo.
await expect(add.getByText("What the sandbox backend needs on the host; the installer checks it and names anything missing")).toBeVisible();
await expect(add.getByText("SELinux is not enforcing")).toBeVisible();
await expect(add.getByText("In sudo mode, one Core per host: a host already running a sudo-mode 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 https://core.example.com, as do its sandboxes")).toBeVisible();
await expect(add.getByText("oac-node joins the docker group, which is equivalent to root on this host.")).toBeVisible();
await expect(add.getByText(/\/dev\/kvm/)).toHaveCount(0);
// Node installation only offers a system service; there is no user-mode alternative.
await expect(add.getByText("No sudo on this host?")).toHaveCount(0);
await expect(add.getByLabel("One-time enrollment command without sudo", { exact: true })).toHaveCount(0);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/features/fleet/fleet-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ export interface CapacitySummary {
maxRetained: number;
reserved: number;
cleanupPending: number;
/** Sandboxes held suspended (microsandbox only): placed, but not counted as active. */
/** Sandboxes held suspended (where the Provider declares checkpoint support): placed, but not counted as active. */
suspended: number;
}

/**
* Sandboxes a node holds suspended: Core counts every unreleased placement as
* retained and only the ones not suspended as active, so the difference is
* what sleeps as a snapshot. Only microsandbox suspends; elsewhere it is 0.
* what sleeps as a snapshot. Only a Provider that declares checkpoint support
* suspends; elsewhere it is 0.
*/
export function suspendedSandboxes(node: SandboxNode): number {
return Math.max(0, node.retained - node.active);
Expand Down
21 changes: 12 additions & 9 deletions apps/web/src/features/metrics/SandboxMetricsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ export function SandboxMetricsPage() {
const [openNode, setOpenNode] = useState<string | null>(null);
const [openRuntime, setOpenRuntime] = useState<string | null>(null);
const rows = useMemo(() => (runtimeState.load ? hostedRuntimeRows(runtimeState.load, "", fleet) : []), [fleet, runtimeState.load]);
// E2B runs sandboxes in its cloud: no machines, so no node table, node column or node dialog.
const cloud = fleet?.deployment.provider === "e2b";
// Only microsandbox suspends sandboxes into snapshots; its nodes also show how many sleep.
const suspends = fleet?.deployment.provider === "microsandbox";
// A direct Provider runs sandboxes in its cloud: no machines, so no node table, node column or node dialog.
const cloud = fleet?.deployment.mode === "direct";
// Only a Provider that declares checkpoint support suspends sandboxes; its nodes also show how many sleep.
const suspends = Boolean(fleet?.deployment.suspension);

return (
<section className="page-section console-page metrics-page" aria-labelledby="sandbox-metrics-heading">
Expand Down Expand Up @@ -205,6 +205,7 @@ export function SandboxMetricsPage() {
stale={fleetObservationStale(fleetState)}
load={runtimeState.load}
range={range}
suspends={suspends}
onClose={() => setOpenNode(null)}
/>
<RuntimeDialog row={rows.find((row) => row.observation.session_id === openRuntime) ?? null} showNode={!cloud} onClose={() => setOpenRuntime(null)} />
Expand Down Expand Up @@ -310,7 +311,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R
{durable ? <RuntimeCharts samples={durable.samples} resolutionSeconds={durable.resolutionSeconds} />
: history.isError ? <p className="page-status" role="alert">{t("sandbox.charts.historyFailed", { reason: history.error instanceof Error ? history.error.message : "" })}</p>
: history.isFetched ? <p className="page-status">{t("sandbox.charts.historyUnavailable")}</p> : null}
<RuntimeTable rows={hostedRuntimeRows(load, "", fleet)} showProject showNode={fleet?.deployment.provider !== "e2b"} onOpen={onOpen} />
<RuntimeTable rows={hostedRuntimeRows(load, "", fleet)} showProject showNode={fleet?.deployment.mode !== "direct"} onOpen={onOpen} />
</>
);
}
Expand All @@ -322,7 +323,7 @@ function HostedRuntimeSection({ state, stale, fleet, range, onOpen }: { state: R
{t("sandbox.runtimeSection")}
{usage?.hosted ? (
<span className="section-meta">
{t(fleet?.deployment.provider === "microsandbox" ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", {
{t(fleet?.deployment.suspension ? "sandbox.runtimeMetaSuspended" : "sandbox.runtimeMeta", {
n: formatInteger(usage.hosted, locale),
sleeping: formatInteger(usage.sleeping, locale),
cpu: usage.cpuUsageCores === null ? MISSING : t("sandbox.cores", { value: formatCores(usage.cpuUsageCores, locale) }),
Expand Down Expand Up @@ -448,8 +449,10 @@ function useLast<T>(value: T | null): T | null {
* A node in a dialog: the host figures it reports with each heartbeat, and
* CPU and memory of the hosted sandboxes placed on it over the page's range.
*/
function NodeDialog({ node, rows, load, range, stale, onClose }: {
function NodeDialog({ node, rows, load, range, stale, suspends, onClose }: {
stale: boolean;
/** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */
suspends: boolean;
node: SandboxNode | null;
rows: readonly HostedRuntimeRow[];
load: HostedRuntimeLoad | null;
Expand Down Expand Up @@ -503,8 +506,8 @@ function NodeDialog({ node, rows, load, range, stale, onClose }: {
<div><dt>{t("sandbox.targetPreparation")}</dt><dd><NodeRolloutStatus node={shown} stale={stale} /></dd></div>
<div><dt>{t("sandbox.servingGeneration")}</dt><dd><span className="status-with-help">{shown.rollout.ready_generation ?? MISSING}<HelpTip>{t("sandbox.servingGenerationHelp")}</HelpTip></span></dd></div>
<div><dt>{t("sandbox.slots")}</dt><dd>{formatInteger(shown.active, locale)} / {formatInteger(shown.max_active, locale)}</dd></div>
{shown.provider === "microsandbox" ? <div><dt>{t("sandbox.suspended")}</dt><dd>{formatInteger(suspendedSandboxes(shown), locale)}</dd></div> : null}
{shown.provider === "microsandbox" ? <div><dt>{t("sandbox.nodeDialog.retainedSlots")}</dt><dd>{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}</dd></div> : null}
{suspends ? <div><dt>{t("sandbox.suspended")}</dt><dd>{formatInteger(suspendedSandboxes(shown), locale)}</dd></div> : null}
{suspends ? <div><dt>{t("sandbox.nodeDialog.retainedSlots")}</dt><dd>{formatInteger(shown.retained, locale)} / {formatInteger(shown.max_retained, locale)}</dd></div> : null}
<div><dt>{t("sandbox.cpus")}</dt><dd>{cpu}</dd></div>
<div><dt>{t("sandbox.memory")}</dt><dd>{memory}</dd></div>
<div><dt>{t("sandbox.freeDiskColumn")}</dt><dd>{online ? formatBytes(shown.available_disk_bytes) : MISSING}</dd></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ describe("fleet overview popovers", () => {
const html = renderToStaticMarkup(
<FleetOverview
nodes={[node("a", { name: "worker-a" }), node("b", { online: false })]}
suspends={false}
coreLabel="Running"
coreTone="ok"
stale={false}
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/features/overview/FleetOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ export interface CloudHost {
}

/** A compact fleet inventory with on-demand operational details. */
export function FleetOverview({ nodes, cloud, coreLabel, coreTone, stale, onOpenNode, onOpenBackend, onOpenSandboxMetrics, onOpenCoreMetrics }: {
export function FleetOverview({ nodes, cloud, suspends, coreLabel, coreTone, stale, onOpenNode, onOpenBackend, onOpenSandboxMetrics, onOpenCoreMetrics }: {
nodes: readonly SandboxNode[];
/** An E2B deployment: Core links to E2B's cloud instead of to machines. */
/** A direct deployment: Core links to the Provider's cloud instead of to machines. */
cloud?: CloudHost | null;
/** Whether the deployment's Provider suspends sandboxes, which its suspension policy declares. */
suspends: boolean;
onOpenBackend?: () => void;
coreLabel: string;
coreTone: Tone;
Expand Down Expand Up @@ -124,7 +126,7 @@ export function FleetOverview({ nodes, cloud, coreLabel, coreTone, stale, onOpen
<button className="text-action" type="button" onClick={() => onOpenNode(node)}>{t("fleet.openNode")}</button>
</>}
>
<NodeGlance node={node} health={health} stale={stale} />
<NodeGlance node={node} health={health} stale={stale} suspends={suspends} />
</ConsolePopover>
);
})}
Expand All @@ -141,7 +143,7 @@ function Fact({ label, children }: { label: string; children: ReactNode }) {
}

/** A node at a glance: reachability (with the reason a degraded provider is not ready), sandbox slots and what the node has left. */
function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHealth; stale: boolean }) {
function NodeGlance({ node, health, stale, suspends }: { node: SandboxNode; health: NodeHealth; stale: boolean; suspends: boolean }) {
const { t, i18n } = useTranslation("overview");
const locale = i18n.resolvedLanguage;
const now = Math.floor(Date.now() / 1000);
Expand All @@ -160,7 +162,7 @@ function NodeGlance({ node, health, stale }: { node: SandboxNode; health: NodeHe
<Fact label={t("fleet.servingGeneration")}><span className="status-with-help">{node.rollout.ready_generation ?? MISSING}<HelpTip>{t("fleet.servingGenerationHelp")}</HelpTip></span></Fact>
<Fact label={t("fleet.facts.lastSeen")}><span title={seen === null ? undefined : formatDateTime(seen, locale)}>{seen === null ? t("fleet.facts.never") : formatRelative(seen, now, locale)}</span></Fact>
<Fact label={t("fleet.facts.active")}>{count(node.active)}<span className="kpi-unit">/ {count(node.max_active)}</span></Fact>
{node.provider === "microsandbox" ? <Fact label={t("fleet.facts.suspended")}>{count(suspendedSandboxes(node))}</Fact> : null}
{suspends ? <Fact label={t("fleet.facts.suspended")}>{count(suspendedSandboxes(node))}</Fact> : null}
<Fact label={t("fleet.facts.cpu")}>{node.cpu_count === null ? MISSING : t("fleet.facts.cores", { count: node.cpu_count })}</Fact>
<Fact label={t("fleet.facts.memory")}>{formatBytes(node.available_memory_bytes)}</Fact>
<Fact label={t("fleet.facts.disk")}>{formatBytes(node.available_disk_bytes)}</Fact>
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/features/overview/OverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export function OverviewPage() {
help={t("kpi.slotsHelp")}
value={capacity ? <><LiveNumber value={capacity.active} /><span className="kpi-unit">/ {formatInteger(capacity.maxActive, locale)}</span></> : MISSING}
sub={capacity
? fleet?.deployment.provider === "microsandbox"
? fleet?.deployment.suspension
? t("tiles.nodesOnlineSuspended", { online: capacity.online, total: capacity.nodes, suspended: capacity.suspended })
: t("tiles.nodesOnline", { online: capacity.online, total: capacity.nodes })
: fleetDetail(fleetState, t)}
Expand Down Expand Up @@ -313,7 +313,7 @@ function fleetDetail(state: FleetState, t: TFunction<"overview">): string {
}

function cloudHost(fleet: FleetSnapshot | null): CloudHost | null {
if (fleet?.deployment.provider !== "e2b") return null;
if (fleet?.deployment.mode !== "direct") return null;
return { running: fleet.deployment.resources.allocations, pending: fleet.deployment.resources.pending, template: fleet.deployment.configuration?.template || null };
}

Expand Down Expand Up @@ -345,6 +345,7 @@ function FleetCard({ fleetState, core, localOnly }: { fleetState: FleetState; co
<FleetOverview
nodes={hosts}
cloud={cloud}
suspends={Boolean(fleet?.deployment.suspension)}
onOpenBackend={() => navigate("system", { id: "sandbox" })}
coreLabel={t(`coreStatus.${core}`)}
coreTone={coreTone[core]}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/overview/getting-started.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("Getting started steps", () => {
});
it("keeps local-only installations to do even with a ready node or cloud deployment", () => {
for (const provider of ["docker", "e2b"] as const) {
const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider })), projects: [], sessions: 1, harnesses: [], localOnly: true });
const steps = gettingStartedSteps({ sandboxReset: false, fleet: fleet(deployment({ provider, mode: provider === "e2b" ? "direct" : "nodes" })), projects: [], sessions: 1, harnesses: [], localOnly: true });
expect(steps.sandboxes).toMatchObject({ state: "todo", action: "nodes", cloud: provider === "e2b" });
}
});
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/features/overview/getting-started.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ export function gettingStartedSteps(input: {
const { sessions } = input;
return {
sandboxes: input.sandboxReset !== false
? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" }
? { state: input.sandboxReset === "failed" ? "unknown" : input.sandboxReset ? "todo" : null, action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" }
: input.localOnly === undefined || input.localOnly === "failed"
? { state: input.localOnly === "failed" ? "unknown" : null, action: "nodes", cloud: false }
: input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.provider === "e2b" } : sandboxStep(input.fleet),
: input.localOnly ? { state: "todo", action: "nodes", cloud: input.fleet.status === "ready" && input.fleet.snapshot.deployment.mode === "direct" } : sandboxStep(input.fleet),
model: modelStep(input.harnesses),
key: keyStep(input.projects),
session: {
Expand All @@ -56,19 +56,19 @@ export function gettingStartedSteps(input: {

/**
* Own machines are ready once the deployment is saved and a node is online
* with its provider ready; E2B once the deployment is saved, since Core admits
* only a ready template build. Only a build Core reports as not ready leaves
* with its provider ready; a direct Provider (E2B) once the deployment is
* saved, since Core admits only a ready template build. Only a build Core reports as not ready leaves
* the step to do; a selection saved before Core recorded its build has no
* status and counts as done.
*/
function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] {
if (fleet.status !== "ready") {
return { state: fleet.status === "failed" ? "unknown" : null, action: "nodes", cloud: false };
}
if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.provider === "e2b" };
if (fleet.error) return { state: "unknown", action: "nodes", cloud: fleet.snapshot.deployment.mode === "direct" };
const { deployment, nodes } = fleet.snapshot;
if (!deployment.provider) return { state: "todo", action: "setup", cloud: false };
if (deployment.provider === "e2b") {
if (deployment.mode === "direct") {
return { state: templateBuildStatus(deployment.metadata?.template_build) === "notReady" ? "todo" : "done", action: "nodes", cloud: true };
}
if (nodes.some(nodeServingReady)) return { state: "done", action: "nodes", cloud: false };
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/features/sandbox/NodeCleanupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export interface NodeCleanup {
name: 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;
}
Expand All @@ -23,7 +22,7 @@ export interface NodeCleanup {
* The installer first confirms with Core, at the node's own address, that the
* node is removed, which holds from the removal on. The command uses sudo unless the shell is already root. A node enrolled with an earlier address may find it gone; then
* `--force` skips only that confirmation. Nothing deletes sandboxes, volumes or
* images. Like Add node's, the command downloads from the installation's public
* images; the uninstaller prints what it kept. Like Add node's, the command downloads from the installation's public
* URL, which the dialog reads (again, if it is not at hand): until it is read, if
* the read fails (with Try again), or while other machines can't use it, the
* dialog says so in place of the command. It never opens empty.
Expand All @@ -49,8 +48,7 @@ export function NodeCleanupDialog({ cleanup, open, onClose }: { cleanup: NodeCle
</div> : cleanup ? <div className="sandbox-add-node form-stack">
<p>{t("{{name}} is removed from Core. To remove its service and files from the host, run:", { name: cleanup.name })}</p>
<CommandBlock key={command()} value={command()} label={t("Uninstall command")} autoFocus />
<p className="sandbox-cleanup-note">{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.")] : []))}</p>
<p className="sandbox-cleanup-note">{t("It never deletes sandboxes, volumes or images.")}</p>
{cleanup.oldAddress !== null ? <details className="sandbox-host-requirements">
<summary>{t("Old Core address gone?")}</summary>
<div className="sandbox-cleanup-details">
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/features/sandbox/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
coreUrl: string;
targetGeneration?: number;
stale: boolean;
/** The deployment's idle suspension policy; only microsandbox has one. */
/** The deployment's idle suspension policy; null unless its Provider declares checkpoint support. */
suspension: SandboxDeployment["suspension"];
}) {
const { t, i18n } = useTranslation("sandbox");
Expand All @@ -53,8 +53,8 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
const now = Math.floor(Date.now() / 1000);
const own = allocations.filter((allocation) => allocation.node_id === node.id);
const reporting = !stale && node.online;
// Only microsandbox suspends sandboxes into snapshots; Docker retains nothing.
const suspends = node.provider === "microsandbox";
// Only a Provider that declares checkpoint support suspends sandboxes; the node shares the deployment's.
const suspends = suspension !== null;
const state = nodeState(node, own, stale, coreUrl);
// As in the list, an old address is the status to act on; the node's health would only distract.
const diagnostic = stale || state === "old_address" ? "" : nodeDiagnostic(node);
Expand Down Expand Up @@ -108,7 +108,7 @@ export function NodeDetail({ node, allocations, coreUrl, targetGeneration, stale
<th scope="col">{t("Configuration generation")}</th>
<th scope="col">{t("Recorded state")}</th>
<th scope="col">{t("Recorded compute")}</th>
{/* Only microsandbox changes compute phase; under Docker it is always disabled. */}
{/* Only a Provider with a suspension policy changes compute phase; elsewhere it is always disabled. */}
{suspension ? <th scope="col"><span className="column-help">{t("In this state")}<HelpTip>{t("How long the sandbox has been in its compute state. For a suspended one, the reclaim time is estimated from when it was suspended and the deployment's retention; Core reclaims it around then. Older allocations show a dash until their state next changes.")}</HelpTip></span></th> : null}
<th scope="col">{t("Issue")}</th>
<th scope="col">{t("Created")}</th>
Expand Down
Loading
Loading