{t("Select the command above and copy it manually.")}
: null}
+ {/* One live region for the whole flow; only its contents change, so each change is announced. */}
+
+ {ready && node ?
{t("{{name}} · Connected", { name: node.name })}
+ : expired && !registered ?
{t("Command expired")} · {t("Generate a new command to continue.")}
+ :
+ {steps.map((step, index) =>
)}
+ }
+
+ {problem && !ready ?
+
{problem.label} {problem.advice}
+
{t("Check the log on the host:")}
+
: null}
+ {!fresh ?
{t("Connection status unavailable. Refresh to check your node.")}
: null}
+ {!ready ? requirements : null}
+ >}
+
+ , document.body);
+}
+
+/** A short command to run on the host, copied with one click. */
+function CopyCommand({ value }: { value: string }) {
+ const { t } = useTranslation("sandbox");
+ const { state, copy } = useCopy(value);
+ const label = state === "copied" ? t("Copied") : t("Copy {{command}}", { command: value });
+ return
+
+ {value}
+
+
+ {state === "failed" ? {t("Select the command and copy it manually.")} : null}
+ ;
}
diff --git a/apps/web/src/features/sandbox/SandboxManagerView.css b/apps/web/src/features/sandbox/SandboxManagerView.css
index 51106dffc..4f9e8d27a 100644
--- a/apps/web/src/features/sandbox/SandboxManagerView.css
+++ b/apps/web/src/features/sandbox/SandboxManagerView.css
@@ -56,9 +56,31 @@
.node-status-fact, .node-diagnostic { display: inline-flex; align-items: center; gap: 4px; }
.sandbox-manager-page .console-section-title h2 .heading-count { margin-left: 6px; }
-/* The Add node dialog's first line carries the host requirements behind a help tip. */
-.sandbox-add-node-intro {
- display: flex;
- align-items: center;
- gap: 4px;
+/* Add node: the countdown sits beside Copy; requirements fold away once seen. */
+.sandbox-command-heading .sandbox-command-expiry { margin-left: auto; color: var(--fg-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
+.sandbox-add-node-warning,
+.sandbox-enrollment-problem {
+ padding: 10px 12px;
+ font-size: 12.5px;
+ background: color-mix(in srgb, var(--warning) 9%, var(--surface));
+ border: 1px solid color-mix(in srgb, var(--warning) 26%, var(--line));
+ border-radius: 8px;
}
+.sandbox-add-node .sandbox-add-node-warning { display: flex; gap: 8px; align-items: flex-start; color: var(--fg); }
+.sandbox-add-node-warning svg { flex-shrink: 0; margin-top: 3px; color: var(--warning); }
+.sandbox-enrollment-problem { display: grid; gap: 6px; }
+.sandbox-add-node .sandbox-enrollment-problem p { color: var(--fg); }
+.sandbox-log-hint { display: grid; gap: 4px; color: var(--fg-muted); font-size: 12px; }
+.sandbox-enrollment-progress { display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 0; padding: 0; list-style: none; }
+.sandbox-enrollment-progress .future .status-dot { color: var(--fg-muted); }
+.sandbox-host-requirements { border: 1px solid var(--line); border-radius: 8px; font-size: 12.5px; }
+.sandbox-host-requirements summary { padding: 9px 12px; color: var(--fg); cursor: pointer; }
+.sandbox-host-requirements ul { display: grid; gap: 8px; margin: 0; padding: 0 12px 0 30px; color: var(--fg-muted); line-height: 1.5; list-style: disc; }
+.sandbox-host-requirements li { display: list-item; }
+.sandbox-host-requirements li .sandbox-copy-command { margin-top: 4px; }
+.sandbox-host-requirements-note { display: grid; gap: 4px; padding: 8px 12px 10px; }
+.sandbox-add-node .sandbox-host-requirements-note p { font-size: 12px; }
+.sandbox-copy-command { display: grid; justify-items: start; gap: 2px; max-width: 100%; }
+.sandbox-copy-command-row { display: flex; align-items: flex-start; gap: 2px; max-width: 100%; }
+.sandbox-copy-command code { min-width: 0; padding: 2px 6px; color: var(--fg); font-family: var(--font-mono); font-size: 11.5px; background: var(--surface-subtle); border-radius: 5px; overflow-wrap: anywhere; }
+.sandbox-copy-command .icon-button { width: 24px; height: 24px; flex-basis: 24px; }
diff --git a/apps/web/src/features/sandbox/SandboxManagerView.tsx b/apps/web/src/features/sandbox/SandboxManagerView.tsx
index eef5baa3b..9aed25f82 100644
--- a/apps/web/src/features/sandbox/SandboxManagerView.tsx
+++ b/apps/web/src/features/sandbox/SandboxManagerView.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import type { InitializeSandboxDeployment, SandboxDeployment, SandboxNode } from "@agents-core-web/agents-client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, Pencil, Server, Trash2 } from "lucide-react";
+import { ArrowLeft, Pencil, Plus, Server, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { EmptyState, HelpTip, RefreshButton } from "../../components/console-ui";
@@ -67,6 +67,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
const [revision, setRevision] = useState(0);
const [removeTarget, setRemoveTarget] = useState(null);
const [editTarget, setEditTarget] = useState(null);
+ // The Add node dialog; it stays mounted with the page so its command survives closing.
+ const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState(false);
const [removeError, setRemoveError] = useState(null);
const initialCoreUrl = window.location.origin;
@@ -83,7 +85,8 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
const refresh = useCallback(() => {
// Each refresh starts a new read (cancelling one in flight) and resets the forms, as a reload did.
setRevision((value) => value + 1);
- void refetch();
+ // Settles when the read does; the enrollment dialog waits for it before calling a command expired.
+ return refetch();
}, [refetch]);
const toast = useToast();
// A refresh the administrator asks for reports its failure even while an earlier one is still unconfirmed;
@@ -99,9 +102,10 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
const controller = new AbortController(); lifetime.current = controller;
return () => { controller.abort(); lifetime.current = null; };
}, []);
- async function changeDeployment(operation: (signal: AbortSignal) => Promise) {
+ /** Whether Core confirmed the change. */
+ async function changeDeployment(operation: (signal: AbortSignal) => Promise): Promise {
const controller = lifetime.current;
- if (!controller || busy || loading || setupNeedsRefresh || !fresh) return;
+ if (!controller || busy || loading || setupNeedsRefresh || !fresh) return false;
setBusy(true); setWriteFailure(null);
try {
const deployment = await operation(controller.signal);
@@ -116,6 +120,7 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
void queryClient.invalidateQueries({ queryKey: sandboxDeploymentQuery.queryKey });
// Overview and Sandbox metrics read the fleet separately and lay out by provider.
void queryClient.invalidateQueries({ queryKey: ["sandbox-fleet"] });
+ return true;
}
} catch (error) {
if (!controller.signal.aborted) {
@@ -124,15 +129,17 @@ function SandboxManager({ consoleConfig }: { consoleConfig: SandboxConsoleConfig
void queryClient.invalidateQueries({ queryKey: sandboxScope, refetchType: "none" });
}
} finally { if (!controller.signal.aborted) setBusy(false); }
+ return false;
}
- function initialize(input: InitializeSandboxDeployment) {
- return changeDeployment((signal) => client.initializeDeployment(input, { signal }));
+ /** First setup; own machines continue straight to adding the first node. */
+ async function initialize(input: InitializeSandboxDeployment) {
+ if (await changeDeployment((signal) => client.initializeDeployment(input, { signal })) && input.provider !== "e2b") setAdding(true);
}
- function update(input: InitializeSandboxDeployment) {
- return changeDeployment((signal) => client.updateDeployment({ ...input, core_url: snapshot!.deployment.core_url, expected_generation: snapshot!.deployment.generation }, { signal }));
+ async function update(input: InitializeSandboxDeployment) {
+ await changeDeployment((signal) => client.updateDeployment({ ...input, core_url: snapshot!.deployment.core_url, expected_generation: snapshot!.deployment.generation }, { signal }));
}
- function maintenance(maintenance: boolean) {
- return changeDeployment((signal) => client.setMaintenance({ maintenance, expected_generation: snapshot!.deployment.generation }, { signal }));
+ async function maintenance(maintenance: boolean) {
+ await changeDeployment((signal) => client.setMaintenance({ maintenance, expected_generation: snapshot!.deployment.generation }, { signal }));
}
const writeDialog = {t("{{name}} will be removed from this deployment.", { name: removeName })}
{t("Core rejects removal while allocations or retained resources remain.")}
;
+ // Rendered first in both the list and a node's page, so an open command outlives the navigation.
+ const enrollment = hostedNodes && snapshot ? setAdding(false)} onRefresh={refresh} /> : null;
if (params.id && hostedNodes) {
const back = () => goBack("nodes");
return <>
+ {enrollment}
{refreshButton}
- {hostedNodes && snapshot ? : null}
+ {hostedNodes && snapshot ? : null}
>;
return <>
+ {enrollment}
{status}
diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts
index 3637cd117..b83709232 100644
--- a/apps/web/src/features/sandbox/enrollment-command.ts
+++ b/apps/web/src/features/sandbox/enrollment-command.ts
@@ -6,3 +6,9 @@ curl -fsS --max-time 30 --max-filesize 1048576 ${quote(sourceUrl + "/node-instal
printf '%s %s\\n' ${quote(scriptDigest)} "$d/install.py" | sha256sum -c --status &&
PARSAR_NODE_ENROLLMENT_TOKEN=${quote(token)} python3 "$d/install.py" --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`;
}
+
+/** The node service's journal: the installer names its systemd user unit after the installation (deploy/install/node_install.py:340, 410-411). */
+export function nodeLogCommand(installationId: string): string {
+ const unit = `parsar-node-${installationId}.service`;
+ return `journalctl --user -u ${/^[A-Za-z0-9._-]+$/.test(unit) ? unit : quote(unit)}`;
+}
diff --git a/apps/web/src/features/sandbox/node-enrollment.test.ts b/apps/web/src/features/sandbox/node-enrollment.test.ts
new file mode 100644
index 000000000..126993bac
--- /dev/null
+++ b/apps/web/src/features/sandbox/node-enrollment.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+
+import { node } from "../overview/test-fixtures";
+import { nodeLogCommand } from "./enrollment-command";
+import { enrolledNode, enrollmentProgress, formatCountdown, NODE_READY_WAIT_MS, progressSteps } from "./node-enrollment";
+
+describe("node enrollment", () => {
+ it("counts down to the expiry, reading 0:00 only once expired", () => {
+ expect(formatCountdown(600_000)).toBe("10:00");
+ expect(formatCountdown(59_001)).toBe("1:00");
+ expect(formatCountdown(1)).toBe("0:01");
+ expect(formatCountdown(-5)).toBe("0:00");
+ expect(formatCountdown(3_723_000)).toBe("1:02:03");
+ });
+
+ it("follows the newest new node from registered to ready, and reports it once the installer's wait has passed", () => {
+ const target = { known: new Set(["old"]), max_active: 4, max_retained: 8 };
+ const fresh = node("new", { online: false, provider_ready: false, created_at: "2026-09-25T00:00:02Z" });
+ expect(enrolledNode([node("old"), node("earlier", { created_at: "2026-09-25T00:00:01Z" }), fresh], target, true)?.id).toBe("new");
+ // A newer node from another command, with other limits, is not this command's.
+ const other = node("other", { max_active: 3, created_at: "2026-09-25T00:00:03Z" });
+ expect(enrolledNode([fresh, other], target, true)?.id).toBe("new");
+ expect(enrolledNode([node("retains", { max_retained: 5, created_at: "2026-09-25T00:00:03Z" }), fresh], target, true)?.id).toBe("new");
+ expect(enrolledNode([node("docker", { max_retained: 4, created_at: "2026-09-25T00:00:03Z" }), fresh], target, false)?.id).toBe("docker");
+ expect(enrollmentProgress(null, undefined, 0)).toEqual({ stage: "waiting", problem: "" });
+ expect(enrollmentProgress(fresh, 0, NODE_READY_WAIT_MS - 1)).toEqual({ stage: "registered", problem: "" });
+ expect(enrollmentProgress(fresh, 0, NODE_READY_WAIT_MS)).toEqual({ stage: "registered", problem: "not_connected" });
+ const connected = { ...fresh, online: true };
+ expect(enrollmentProgress(connected, 0, 1_000)).toEqual({ stage: "connected", problem: "" });
+ expect(enrollmentProgress(connected, 0, NODE_READY_WAIT_MS)).toEqual({ stage: "connected", problem: "provider_unavailable" });
+ expect(enrollmentProgress({ ...connected, diagnostic: "kvm_unavailable" }, 0, 1_000)).toEqual({ stage: "connected", problem: "kvm_unavailable" });
+ expect(enrollmentProgress({ ...connected, provider_ready: true }, 0, NODE_READY_WAIT_MS * 2)).toEqual({ stage: "ready", problem: "" });
+ });
+
+ it("marks the passed steps done and waits on the next one", () => {
+ expect(progressSteps("waiting")).toEqual(["current", "future", "future"]);
+ expect(progressSteps("registered")).toEqual(["done", "current", "future"]);
+ expect(progressSteps("connected")).toEqual(["done", "done", "current"]);
+ });
+
+ it("points at the installer's systemd user unit", () => {
+ expect(nodeLogCommand("7f3c2a90-fixture")).toBe("journalctl --user -u parsar-node-7f3c2a90-fixture.service");
+ expect(nodeLogCommand("a b")).toBe("journalctl --user -u 'parsar-node-a b.service'");
+ });
+});
diff --git a/apps/web/src/features/sandbox/node-enrollment.ts b/apps/web/src/features/sandbox/node-enrollment.ts
new file mode 100644
index 000000000..663ceedfe
--- /dev/null
+++ b/apps/web/src/features/sandbox/node-enrollment.ts
@@ -0,0 +1,131 @@
+import type { SandboxNode } from "@agents-core-web/agents-client";
+
+import type { MessageKey } from "../../lib/locale-strings";
+import { nodeProviderDiagnostic } from "../../lib/sandbox-diagnostic";
+
+/**
+ * How long the installer waits, after starting the node service, for Core to
+ * report the node connected and its provider ready
+ * (deploy/install/node_install.py `wait_ready`, timeout=60).
+ */
+export const NODE_READY_WAIT_MS = 60_000;
+
+export interface HostPrerequisite {
+ label: MessageKey;
+ /** A root command that prepares the host; NODE_USER stands for the node's user (`` would be shell redirection). */
+ command?: string;
+}
+
+/**
+ * What a node host needs, exactly as the enrollment command, the installer and
+ * the node's readiness probe check it (line numbers as of this revision):
+ * - the command runs curl, sha256sum and python3 (enrollment-command.ts);
+ * deploy/install/node_install.py:67-69 needs Python 3.9+, Linux amd64 and a non-root user;
+ * - Docker: node_install.py:73-74 runs `docker info` through /var/run/docker.sock,
+ * and the node is ready only when Docker enforces CPU and memory limits
+ * (services/agents-api/internal/sandbox/config/probe.go:37-38), which the
+ * installer waits for (node_install.py:363-364);
+ * - microsandbox: read/write /dev/kvm (node_install.py:75-76);
+ * - node_install.py:71-72 needs lingering. It starts the user's systemd manager,
+ * whose services, the node's included, keep the groups it started with; so the
+ * group changes above come first, or that manager is restarted after them. A
+ * shell open before the change lacks the group too, so the user signs in again;
+ * - microsandbox: the host libraries its binaries link (the ldd check,
+ * node_install.py:249-253), and a home short enough for ~/.parsar/m/<12 hex> to
+ * fit in 48 bytes (node_install.py:205-209): at most 48 - len("/.parsar/m/") - 12 = 25 bytes;
+ * - the host's CPUs and total memory hold one sandbox of the deployment's size
+ * (probe.go:43 and 75, config/capacity.go:9), else the node reports capacity_insufficient;
+ * - node_install.py:70 needs the user's own systemd session (`systemctl --user`),
+ * which sudo -u and su don't provide;
+ * - network: node files from the console (node_install.py:94-106), artifacts from
+ * the manifest's artifact_base_url, the console by default (node_install.py:139-140,
+ * distribution.py:150-154), Core's /api/v1 (node_spec.py:89, node_install.py:385),
+ * and sandboxes reach Core as well (node_install.py:213, 220-233).
+ * `sized` says whether the deployment's sandbox size is known for the capacity item.
+ */
+export function hostPrerequisites(provider: "docker" | "microsandbox", sized: boolean): HostPrerequisite[] {
+ const access: HostPrerequisite = provider === "docker"
+ ? { label: "Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits", command: "sudo usermod -aG docker NODE_USER" }
+ : { label: "Read and write access to /dev/kvm for that user", command: "sudo usermod -aG kvm NODE_USER" };
+ const microsandbox: HostPrerequisite[] = provider === "microsandbox" ? [
+ { label: "The shared libraries microsandbox needs, on a glibc system" },
+ { label: "A home directory of 25 bytes or less, such as /home/parsar" },
+ ] : [];
+ return [
+ { label: "Linux amd64 with Python 3.9+, curl and sha256sum, and a non-root user to run the node (NODE_USER below)" },
+ access,
+ { label: "systemd lingering for that user, enabled after the group change", command: "sudo loginctl enable-linger NODE_USER" },
+ ...microsandbox,
+ { label: sized ? "CPUs and memory for at least one sandbox: {{size}}" : "CPUs and memory for at least one sandbox" },
+ { label: "Run the command signed in as that user: over SSH, or with", command: "sudo machinectl shell NODE_USER@" },
+ { label: "Can reach {{console}}, {{core}} and the release downloads; sandboxes must reach {{core}}" },
+ ];
+}
+
+/** Restarts a user's systemd manager, so its services pick up a group change. */
+export const USER_MANAGER_RESTART = "sudo systemctl restart user@$(id -u NODE_USER).service";
+
+/** Time left as m:ss (h:mm:ss from an hour), rounded up so it reads 0:00 only once expired. */
+export function formatCountdown(milliseconds: number): string {
+ const total = Math.max(0, Math.ceil(milliseconds / 1000));
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.floor((total % 3600) / 60);
+ const seconds = String(total % 60).padStart(2, "0");
+ return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
+}
+
+/** What identifies the node a command enrolls: nodes that existed before it, and the limits it approved. */
+export interface EnrollmentTarget {
+ known: ReadonlySet;
+ max_active: number;
+ max_retained: number;
+}
+
+/**
+ * The newest node registered since the command was issued with the command's
+ * limits, which Core copies onto the node it enrolls
+ * (services/agents-api/internal/store/runtime_nodes.go:166); a node from another
+ * command with other limits is not this one. Only microsandbox keeps its own
+ * retained limit; Core sets Docker's to the active one.
+ */
+export function enrolledNode(nodes: readonly SandboxNode[], target: EnrollmentTarget, suspends: boolean): SandboxNode | null {
+ let newest: SandboxNode | null = null;
+ for (const node of nodes) {
+ if (target.known.has(node.id) || node.max_active !== target.max_active || (suspends && node.max_retained !== target.max_retained)) continue;
+ if (!newest || Date.parse(node.created_at) > Date.parse(newest.created_at)) newest = node;
+ }
+ return newest;
+}
+
+export type EnrollmentStage = "waiting" | "registered" | "connected" | "ready";
+
+export interface EnrollmentProgress {
+ stage: EnrollmentStage;
+ /**
+ * What to show about a node that is not ready: the diagnostic code it reports,
+ * or, once the installer's wait has passed, provider_unavailable for a
+ * connected node and "not_connected" for one that never connected. Else "".
+ */
+ problem: string;
+}
+
+export type StepState = "done" | "current" | "future";
+
+/**
+ * The state of each step (registered, connected, backend ready) while the node
+ * is on its way: the steps it has passed are done and the next one is current.
+ * A ready node ends the list, so the last step is never done here.
+ */
+export function progressSteps(stage: Exclude): [StepState, StepState, StepState] {
+ const passed = { waiting: 0, registered: 1, connected: 2 }[stage];
+ return [0, 1, 2].map((index) => (index < passed ? "done" : index === passed ? "current" : "future")) as [StepState, StepState, StepState];
+}
+
+/** Registration progress of the enrolled node, `appearedAt` being when the console first saw it. */
+export function enrollmentProgress(node: SandboxNode | null, appearedAt: number | undefined, now: number): EnrollmentProgress {
+ if (!node) return { stage: "waiting", problem: "" };
+ if (node.online && node.provider_ready) return { stage: "ready", problem: "" };
+ const late = appearedAt !== undefined && now - appearedAt >= NODE_READY_WAIT_MS;
+ if (!node.online) return { stage: "registered", problem: late ? "not_connected" : "" };
+ return { stage: "connected", problem: node.diagnostic || late ? nodeProviderDiagnostic(node) : "" };
+}
diff --git a/apps/web/src/lib/locale-strings.ts b/apps/web/src/lib/locale-strings.ts
index 0ac6c460b..0297c558a 100644
--- a/apps/web/src/lib/locale-strings.ts
+++ b/apps/web/src/lib/locale-strings.ts
@@ -16,8 +16,6 @@ export const chinese = {
"Enter a shorter name.": "名称太长了,请缩短。",
"Enter a whole number from 1 to 1,000,000.": "请输入 1 到 1,000,000 之间的整数。",
"Enter at least the number of sandboxes at once.": "不能少于同时运行的沙箱数。",
- "The host needs Linux with KVM: /dev/kvm must be available to the node. Each sandbox runs as a small virtual machine with its own disks.": "宿主机需要是 Linux 并支持 KVM:节点要能访问 /dev/kvm。每个沙箱是一台带独立磁盘的小型虚拟机。",
- "The host needs Docker Engine that enforces CPU and memory limits, and the node needs access to its socket. Each sandbox runs as a container.": "宿主机需要 Docker Engine,并能限制 CPU 和内存;节点要能访问它的 socket。每个沙箱是一个容器。",
"E2B runs this deployment's sandboxes in its cloud. There are no machines to add.": "这个部署的沙箱由 E2B 在云端运行,不需要添加机器。",
"Saving does not migrate Sessions or resume placement automatically.": "保存不会迁移 Session,也不会自动恢复分配。",
"This console address cannot be used by sandbox guests. Enter the HTTPS Core address that your nodes and guests can reach.": "沙箱无法使用此控制台地址。请输入节点和沙箱都能访问的 HTTPS Core 地址。",
@@ -39,13 +37,43 @@ export const chinese = {
"Terminal": "终端",
"Connected": "已连接",
"Command expired": "命令已过期",
- "Waiting for your node to connect…": "等待节点连接…",
"Connection status unavailable. Refresh to check your node.": "连接状态暂不可用,请刷新以查看节点。",
"Generate a new command to continue.": "请生成新命令后继续。",
"Generate new command": "生成新命令",
"Set the sandbox limits for the host you want to add.": "为要添加的主机设置沙箱上限。",
"Generate command": "生成命令",
"Change limits": "修改上限",
+ "Add another node": "添加另一个节点",
+ "Host requirements": "主机要求",
+ "Linux amd64 with Python 3.9+, curl and sha256sum, and a non-root user to run the node (NODE_USER below)": "Linux amd64,装有 Python 3.9+、curl 和 sha256sum,并有一个运行节点的非 root 用户(即下文的 NODE_USER)",
+ "Docker at /var/run/docker.sock for that user, enforcing CPU and memory limits": "该用户能使用 /var/run/docker.sock 上的 Docker,且 Docker 能限制 CPU 和内存",
+ "Read and write access to /dev/kvm for that user": "该用户能读写 /dev/kvm",
+ "The shared libraries microsandbox needs, on a glibc system": "microsandbox 需要的共享库(glibc 系统)",
+ "A home directory of 25 bytes or less, such as /home/parsar": "主目录路径不超过 25 字节,例如 /home/parsar",
+ "systemd lingering for that user, enabled after the group change": "为该用户开启 systemd lingering(在改用户组之后)",
+ "CPUs and memory for at least one sandbox: {{size}}": "CPU 和内存至少够一个沙箱:{{size}}",
+ "CPUs and memory for at least one sandbox": "CPU 和内存至少够一个沙箱",
+ "Run the command signed in as that user: over SSH, or with": "以该用户登录后运行命令:通过 SSH,或使用",
+ "Can reach {{console}}, {{core}} and the release downloads; sandboxes must reach {{core}}": "能访问 {{console}}、{{core}} 和版本下载地址;沙箱也要能访问 {{core}}",
+ "After a group change, sign in again as that user. If its systemd manager was already running, restart it (or reboot):": "改完用户组后请以该用户重新登录;如果它的 systemd 管理器已在运行,请重启它(或重启主机):",
+ "Copy {{command}}": "复制 {{command}}",
+ "Select the command and copy it manually.": "请选中命令手动复制。",
+ "This console is open at {{origin}}, which other machines can't reach. To add another machine, open the console at its HTTPS address, then generate the command.": "当前控制台地址 {{origin}} 其他机器访问不到。要添加其他机器,请用控制台的 HTTPS 地址打开后再生成命令。",
+ "Open this console over HTTPS to add a node: the installer downloads only over HTTPS.": "请通过 HTTPS 打开此控制台再添加节点:安装程序只通过 HTTPS 下载。",
+ "Expires in {{time}}": "{{time}} 后过期",
+ "Registration progress": "注册进度",
+ "Registered · {{name}}": "已注册 · {{name}}",
+ "{{name}} · Connected": "{{name}} · 已连接",
+ "Rerun only on {{name}} if asked": "仅在 {{name}} 上按需重跑",
+ "Waiting for registration": "等待注册",
+ "Waiting to connect": "等待连接",
+ "Connect": "连接",
+ "Waiting for {{backend}}": "等待 {{backend}} 就绪",
+ "{{backend}} check": "{{backend}} 检查",
+ "{{backend}} ready": "{{backend}} 已就绪",
+ "Not connected yet": "尚未连接",
+ "The node registered but its service hasn't reached Core.": "节点已注册,但节点服务还没有连上 Core。",
+ "Check the log on the host:": "在主机上查看日志:",
"Advanced network settings": "高级网络设置",
"Hosted Sandbox Manager": "托管沙箱管理",
@@ -86,7 +114,6 @@ export const chinese = {
"No sandbox allocations.": "暂无沙箱资源分配。",
"Add node": "添加节点",
"Install parsar-sandbox-node on the target host and prepare its provider configuration. Adjust the absolute paths, node name and capacity in the command before running it.": "在目标主机安装 parsar-sandbox-node 并准备运行后端配置。执行命令前,请调整绝对路径、节点名称和容量。",
- "One-time enrollment token expires": "一次性注册令牌到期时间:",
"One-time enrollment command": "一次性注册命令",
"Offline": "离线",
"Provider unavailable": "后端不可用",