From 0bdef113e73273febcca8ee3611acce7496fc17d Mon Sep 17 00:00:00 2001 From: eust-w Date: Mon, 30 Mar 2026 03:02:44 +0800 Subject: [PATCH] :sparkles: feat: add custom docker node registry tab --- README.md | 3 + apps/api/src/runtime/mongo-store.ts | 219 +++++++++++++++- apps/api/src/runtime/server.ts | 39 ++- .../api/test/runtime-http.integration.spec.ts | 120 +++++++++ .../src/features/assets/assets-page.test.tsx | 1 + apps/web/src/features/layout/app-shell.tsx | 1 + apps/web/src/runtime/api-client.ts | 39 ++- apps/web/src/runtime/app.tsx | 243 +++++++++++++++++- apps/web/src/runtime/i18n.test.ts | 2 + apps/web/src/runtime/i18n.tsx | 77 ++++++ apps/worker/src/executors/docker-executor.ts | 119 +++++++-- apps/worker/test/mongo-worker-runtime.spec.ts | 65 +++++ .../03-workflows/workflow-execution-model.md | 32 +++ ...nformation-architecture-and-key-screens.md | 19 ++ design/05-data/mongodb-data-model.md | 31 +++ ...26-03-26-emboflow-v1-foundation-and-mvp.md | 1 + 16 files changed, 970 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index c1473d4..fcf5c4d 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ EmboFlow is a B/S embodied-data workflow platform for raw asset ingestion, deliv - Project-scoped workspace shell with a dedicated Projects page and active project selector in the header - Asset workspace that supports local asset registration, probe summaries, storage connection management, and dataset creation +- Project-scoped custom node registry with Docker image and Dockerfile based node definitions - Workflow templates as first-class objects, including default project templates and creating project workflows from a template - Blank workflow creation and a large React Flow editor with drag-and-drop nodes, free canvas movement, edge validation, Docker-first node runtime presets, and Python code-hook injection - Workflow-level `Save As Template` so edited graphs can be promoted into reusable project templates @@ -80,6 +81,7 @@ The editor now also persists per-node runtime config in workflow versions, inclu The runtime web shell now exposes a visible `中文 / English` language toggle. The core workspace shell and workflow authoring surface are translated through a lightweight i18n layer. The shell now also exposes a dedicated Projects page plus an active project selector, so assets, datasets, workflow templates, workflows, and runs all switch together at the project boundary. The Assets workspace now includes first-class storage connections and datasets. A dataset is distinct from a raw asset and binds project source assets to a selected local or object-storage-backed destination. +The shell now also exposes a dedicated Nodes page for project-scoped custom container nodes. Custom nodes can be registered from an existing Docker image or a self-contained Dockerfile, and each node declares whether it consumes a single asset set or multiple upstream asset sets plus what kind of output it produces. The Workflows workspace now includes a template gallery. Projects can start from default or saved templates, or create a blank workflow directly. The workflow editor center panel now uses a real draggable node canvas with zoom, pan, mini-map, dotted background, handle-based edge creation, persisted node positions, and localized validation feedback instead of a static list of node cards. The workflow editor right panel now also supports saving the current workflow draft as a reusable workflow template, in addition to editing per-node runtime settings and Python hooks. @@ -87,6 +89,7 @@ The node library now supports both click-to-append and drag-and-drop placement i The Runs workspace now shows project-scoped run history, run-level aggregated summaries, cancel/retry controls, and run detail views with persisted task summaries, stdout/stderr sections, result previews, and artifact links into Explore. Selected run tasks now expose the frozen node definition id, executor config snapshot, and code-hook metadata that were captured when the run was created. Most built-in delivery nodes now default to `executorType=docker`. When a node uses `executorType=docker` and provides `executorConfig.image`, the worker runs a real local Docker container with mounted `input.json` / `output.json` exchange files plus read-only mounts for bound asset paths. If no image is configured, the executor falls back to the lightweight simulated behavior used by older demo tasks. +Custom Docker nodes follow the same runtime contract. The container reads the task snapshot and execution context from `EMBOFLOW_INPUT_PATH`, writes `{\"result\": ...}` JSON to `EMBOFLOW_OUTPUT_PATH`, and if it declares an asset-set output contract it must return `result.assetIds` as a string array. Dockerfile-based custom nodes are built locally on first execution and then reused by tag. When a node uses the built-in Python path without a custom hook, `source-asset` now emits bound asset metadata from Mongo-backed asset records and `validate-structure` now performs a real directory validation pass against local source paths. On the current sample path `/Users/longtaowu/workspace/emboldata/data`, that validation reports `valid=false`, `videoFileCount=407`, and missing delivery files because the sample root is a mixed dataset collection rather than a delivery package. The worker now also carries direct upstream task results into execution context so set-operation utility nodes can compute narrowed asset sets and pass those effective asset ids to downstream tasks. diff --git a/apps/api/src/runtime/mongo-store.ts b/apps/api/src/runtime/mongo-store.ts index e515f52..d40ac5a 100644 --- a/apps/api/src/runtime/mongo-store.ts +++ b/apps/api/src/runtime/mongo-store.ts @@ -88,6 +88,45 @@ type StorageConnectionDocument = Timestamped & { createdBy: string; }; +type CustomNodeCategory = "Source" | "Transform" | "Inspect" | "Annotate" | "Export" | "Utility"; +type CustomNodeInputMode = "single_asset_set" | "multi_asset_set"; +type CustomNodeOutputMode = "report" | "asset_set" | "asset_set_with_report"; + +type CustomNodeContractDocument = { + version: "emboflow.node.v1"; + inputMode: CustomNodeInputMode; + outputMode: CustomNodeOutputMode; + artifactType: "json" | "directory" | "video"; +}; + +type CustomNodeSourceDocument = + | { + kind: "image"; + image: string; + command?: string[]; + } + | { + kind: "dockerfile"; + imageTag: string; + dockerfileContent: string; + command?: string[]; + }; + +type CustomNodeDocument = Timestamped & { + _id: string; + definitionId: string; + workspaceId: string; + projectId: string; + name: string; + slug: string; + description: string; + category: CustomNodeCategory; + status: "active"; + contract: CustomNodeContractDocument; + source: CustomNodeSourceDocument; + createdBy: string; +}; + type DatasetDocument = Timestamped & { _id: string; workspaceId: string; @@ -402,6 +441,7 @@ function buildRuntimeSnapshot( runtimeGraph: Record, logicGraph: WorkflowDefinitionVersionDocument["logicGraph"], pluginRefs: string[], + resolveDefaults: (definitionId: string) => NodeRuntimeConfig | undefined = buildDefaultNodeRuntimeConfig, ): RunRuntimeSnapshot { const graph = runtimeGraph as WorkflowRuntimeGraph; const nodeBindings: Record = {}; @@ -412,7 +452,7 @@ function buildRuntimeSnapshot( nodeBindings[node.id] = definitionId; const config = mergeNodeRuntimeConfigs( definitionId, - buildDefaultNodeRuntimeConfig(definitionId), + resolveDefaults(definitionId), sanitizeNodeRuntimeConfig(graph.nodeConfigs?.[node.id], definitionId), ); if (config) { @@ -428,6 +468,120 @@ function buildRuntimeSnapshot( }; } +function sanitizeStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : undefined; +} + +function sanitizeCustomNodeCategory(value: unknown): CustomNodeCategory { + return value === "Source" || value === "Transform" || value === "Inspect" || value === "Annotate" || + value === "Export" || value === "Utility" + ? value + : "Utility"; +} + +function sanitizeCustomNodeContract(value: unknown): CustomNodeContractDocument { + const input = isRecord(value) ? value : {}; + const inputMode = input.inputMode === "multi_asset_set" ? "multi_asset_set" : "single_asset_set"; + const outputMode = + input.outputMode === "asset_set" || input.outputMode === "asset_set_with_report" + ? input.outputMode + : "report"; + const artifactType = sanitizeArtifactType(input.artifactType) ?? "json"; + + return { + version: "emboflow.node.v1", + inputMode, + outputMode, + artifactType, + }; +} + +function sanitizeCustomNodeSource(value: unknown): CustomNodeSourceDocument { + if (!isRecord(value)) { + throw new Error("custom node source is required"); + } + + const command = sanitizeStringArray(value.command); + if (value.kind === "image") { + const image = typeof value.image === "string" ? value.image.trim() : ""; + if (!image) { + throw new Error("custom node image is required"); + } + return { + kind: "image", + image, + ...(command && command.length > 0 ? { command } : {}), + }; + } + + if (value.kind === "dockerfile") { + const dockerfileContent = typeof value.dockerfileContent === "string" ? value.dockerfileContent.trim() : ""; + if (!dockerfileContent) { + throw new Error("custom node dockerfileContent is required"); + } + const imageTag = typeof value.imageTag === "string" && value.imageTag.trim().length > 0 + ? value.imageTag.trim() + : `emboflow/custom-node:${slugify(String(Date.now()))}`; + return { + kind: "dockerfile", + imageTag, + dockerfileContent, + ...(command && command.length > 0 ? { command } : {}), + }; + } + + throw new Error("custom node source kind must be image or dockerfile"); +} + +function mapCustomNodeToDefinition(node: CustomNodeDocument) { + return { + id: node.definitionId, + name: node.name, + category: node.category, + description: node.description, + defaultExecutorType: "docker" as const, + defaultExecutorConfig: { + ...(node.source.kind === "image" + ? { + image: node.source.image, + } + : { + imageTag: node.source.imageTag, + dockerfileContent: node.source.dockerfileContent, + }), + ...(node.source.command ? { command: [...node.source.command] } : {}), + contract: node.contract, + sourceKind: node.source.kind, + }, + allowsMultipleIncoming: node.contract.inputMode === "multi_asset_set", + supportsCodeHook: false, + customNodeId: node._id, + }; +} + +function buildNodeRuntimeResolver(customNodes: CustomNodeDocument[]) { + const customDefinitionMap = new Map(customNodes.map((node) => [node.definitionId, node])); + + return (definitionId: string) => { + const builtin = buildDefaultNodeRuntimeConfig(definitionId); + if (builtin) { + return builtin; + } + + const customNode = customDefinitionMap.get(definitionId); + if (!customNode) { + return undefined; + } + + return { + executorType: "docker", + executorConfig: mapCustomNodeToDefinition(customNode).defaultExecutorConfig, + } satisfies NodeRuntimeConfig; + }; +} + function collectRetryNodeIds(tasks: RunTaskDocument[], rootNodeId: string) { const pending = [rootNodeId]; const collected = new Set([rootNodeId]); @@ -701,6 +855,57 @@ export class MongoAppStore { .findOne({ _id: storageConnectionId }); } + async createCustomNode(input: { + workspaceId: string; + projectId: string; + name: string; + description?: string; + category?: CustomNodeCategory; + source: unknown; + contract: unknown; + createdBy: string; + }) { + const baseSlug = slugify(input.name); + const collection = this.db.collection("custom_nodes"); + let slug = baseSlug; + let definitionId = `custom-${slug}`; + let suffix = 1; + + while (await collection.findOne({ projectId: input.projectId, definitionId })) { + suffix += 1; + slug = `${baseSlug}-${suffix}`; + definitionId = `custom-${slug}`; + } + + const node: CustomNodeDocument = { + _id: `custom-node-${randomUUID()}`, + definitionId, + workspaceId: input.workspaceId, + projectId: input.projectId, + name: input.name, + slug, + description: input.description ?? "", + category: sanitizeCustomNodeCategory(input.category), + status: "active", + contract: sanitizeCustomNodeContract(input.contract), + source: sanitizeCustomNodeSource(input.source), + createdBy: input.createdBy, + createdAt: nowIso(), + updatedAt: nowIso(), + }; + + await collection.insertOne(node); + return node; + } + + async listCustomNodes(projectId: string) { + return this.db + .collection("custom_nodes") + .find({ projectId, status: "active" }) + .sort({ createdAt: 1 }) + .toArray(); + } + async registerAsset(input: { workspaceId: string; projectId: string; @@ -1103,10 +1308,12 @@ export class MongoAppStore { throw new Error("bound assets must belong to the workflow project"); } + const customNodes = await this.listCustomNodes(version.projectId); const runtimeSnapshot = buildRuntimeSnapshot( version.runtimeGraph, version.logicGraph, version.pluginRefs, + buildNodeRuntimeResolver(customNodes), ); const run: WorkflowRunDocument = { @@ -1422,7 +1629,13 @@ export class MongoAppStore { .toArray(); } - listNodeDefinitions() { - return DELIVERY_NODE_DEFINITIONS; + async listNodeDefinitions(projectId?: string) { + const builtinDefinitions = [...DELIVERY_NODE_DEFINITIONS]; + if (!projectId) { + return builtinDefinitions; + } + + const customNodes = await this.listCustomNodes(projectId); + return [...builtinDefinitions, ...customNodes.map((node) => mapCustomNodeToDefinition(node))]; } } diff --git a/apps/api/src/runtime/server.ts b/apps/api/src/runtime/server.ts index 4470d15..1a686d3 100644 --- a/apps/api/src/runtime/server.ts +++ b/apps/api/src/runtime/server.ts @@ -234,8 +234,43 @@ export async function createApiRuntime(config = resolveApiRuntimeConfig()) { } }); - app.get("/api/node-definitions", (_request, response) => { - response.json(store.listNodeDefinitions()); + app.post("/api/custom-nodes", async (request, response, next) => { + try { + response.json( + await store.createCustomNode({ + workspaceId: request.body.workspaceId, + projectId: request.body.projectId, + name: request.body.name, + description: request.body.description, + category: request.body.category, + source: request.body.source, + contract: request.body.contract, + createdBy: request.body.createdBy ?? "local-user", + }), + ); + } catch (error) { + next(error); + } + }); + + app.get("/api/custom-nodes", async (request, response, next) => { + try { + response.json(await store.listCustomNodes(String(request.query.projectId))); + } catch (error) { + next(error); + } + }); + + app.get("/api/node-definitions", async (request, response, next) => { + try { + response.json( + await store.listNodeDefinitions( + request.query.projectId ? String(request.query.projectId) : undefined, + ), + ); + } catch (error) { + next(error); + } }); app.post("/api/workflow-templates", async (request, response, next) => { diff --git a/apps/api/test/runtime-http.integration.spec.ts b/apps/api/test/runtime-http.integration.spec.ts index 1a39380..312d198 100644 --- a/apps/api/test/runtime-http.integration.spec.ts +++ b/apps/api/test/runtime-http.integration.spec.ts @@ -1327,3 +1327,123 @@ test("mongo-backed runtime can cancel a run, retry a run snapshot, and retry a f assert.match(refreshedFailedTask?.logLines?.[0] ?? "", /retry/i); assert.equal(refreshedDownstreamTask?.status, "pending"); }); + +test("mongo-backed runtime manages custom docker nodes and exposes them as project node definitions", async (t) => { + const mongod = await MongoMemoryServer.create({ + instance: { + ip: "127.0.0.1", + port: 27131, + }, + }); + t.after(async () => { + await mongod.stop(); + }); + + const server = await startRuntimeServer({ + host: "127.0.0.1", + port: 0, + mongoUri: mongod.getUri(), + database: "emboflow-runtime-custom-nodes", + corsOrigin: "http://127.0.0.1:3000", + }); + t.after(async () => { + await server.close(); + }); + + const bootstrap = await readJson<{ + workspace: { _id: string }; + project: { _id: string }; + }>( + await fetch(`${server.baseUrl}/api/dev/bootstrap`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId: "custom-node-user", projectName: "Custom Node Project" }), + }), + ); + + const imageNode = await readJson<{ _id: string; definitionId: string }>( + await fetch(`${server.baseUrl}/api/custom-nodes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: bootstrap.workspace._id, + projectId: bootstrap.project._id, + name: "Merge Labels", + description: "Combine label reports from upstream nodes", + category: "Utility", + source: { + kind: "image", + image: "python:3.11-alpine", + command: ["python3", "-c", "print('custom image node')"], + }, + contract: { + inputMode: "multi_asset_set", + outputMode: "asset_set_with_report", + artifactType: "json", + }, + createdBy: "custom-node-user", + }), + }), + ); + + const dockerfileNode = await readJson<{ _id: string; definitionId: string }>( + await fetch(`${server.baseUrl}/api/custom-nodes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: bootstrap.workspace._id, + projectId: bootstrap.project._id, + name: "Dockerfile Union", + description: "Union assets with a self-contained Dockerfile", + category: "Utility", + source: { + kind: "dockerfile", + imageTag: "emboflow-test/dockerfile-union:latest", + dockerfileContent: [ + "FROM python:3.11-alpine", + "CMD [\"python3\", \"-c\", \"print('dockerfile custom node')\"]", + ].join("\n"), + }, + contract: { + inputMode: "multi_asset_set", + outputMode: "asset_set", + artifactType: "json", + }, + createdBy: "custom-node-user", + }), + }), + ); + + const nodeDefinitions = await readJson< + Array<{ + id: string; + defaultExecutorType?: string; + defaultExecutorConfig?: Record; + allowsMultipleIncoming?: boolean; + }> + >( + await fetch( + `${server.baseUrl}/api/node-definitions?projectId=${encodeURIComponent(bootstrap.project._id)}`, + ), + ); + + const imageDefinition = nodeDefinitions.find((definition) => definition.id === imageNode.definitionId); + const dockerfileDefinition = nodeDefinitions.find((definition) => definition.id === dockerfileNode.definitionId); + + assert.equal(imageDefinition?.defaultExecutorType, "docker"); + assert.equal(imageDefinition?.allowsMultipleIncoming, true); + assert.equal(imageDefinition?.defaultExecutorConfig?.image, "python:3.11-alpine"); + assert.equal( + (imageDefinition?.defaultExecutorConfig?.contract as { outputMode?: string } | undefined)?.outputMode, + "asset_set_with_report", + ); + assert.equal(dockerfileDefinition?.defaultExecutorType, "docker"); + assert.equal( + typeof dockerfileDefinition?.defaultExecutorConfig?.dockerfileContent, + "string", + ); + assert.equal( + (dockerfileDefinition?.defaultExecutorConfig?.contract as { outputMode?: string } | undefined)?.outputMode, + "asset_set", + ); +}); diff --git a/apps/web/src/features/assets/assets-page.test.tsx b/apps/web/src/features/assets/assets-page.test.tsx index b5e52a2..390e685 100644 --- a/apps/web/src/features/assets/assets-page.test.tsx +++ b/apps/web/src/features/assets/assets-page.test.tsx @@ -13,6 +13,7 @@ test("app shell renders primary navigation", () => { }); assert.match(html, /Assets/); + assert.match(html, /Nodes/); assert.match(html, /Workflows/); assert.match(html, /Runs/); assert.match(html, /Explore/); diff --git a/apps/web/src/features/layout/app-shell.tsx b/apps/web/src/features/layout/app-shell.tsx index 24eebd6..56f4231 100644 --- a/apps/web/src/features/layout/app-shell.tsx +++ b/apps/web/src/features/layout/app-shell.tsx @@ -3,6 +3,7 @@ import { renderWorkspaceSwitcher } from "../workspaces/workspace-switcher.tsx"; export const PRIMARY_NAV_ITEMS = [ "Assets", + "Nodes", "Workflows", "Runs", "Explore", diff --git a/apps/web/src/runtime/api-client.ts b/apps/web/src/runtime/api-client.ts index 01f1b35..67cb084 100644 --- a/apps/web/src/runtime/api-client.ts +++ b/apps/web/src/runtime/api-client.ts @@ -187,8 +187,43 @@ export class ApiClient { ); } - async listNodeDefinitions() { - return readJson(await fetch(`${this.baseUrl}/api/node-definitions`)); + async listNodeDefinitions(projectId?: string) { + const search = new URLSearchParams(); + if (projectId) { + search.set("projectId", projectId); + } + return readJson( + await fetch(`${this.baseUrl}/api/node-definitions${search.toString() ? `?${search.toString()}` : ""}`), + ); + } + + async listCustomNodes(projectId: string) { + return readJson( + await fetch(`${this.baseUrl}/api/custom-nodes?projectId=${encodeURIComponent(projectId)}`), + ); + } + + async createCustomNode(input: { + workspaceId: string; + projectId: string; + name: string; + description?: string; + category?: "Source" | "Transform" | "Inspect" | "Annotate" | "Export" | "Utility"; + source: Record; + contract: { + inputMode: "single_asset_set" | "multi_asset_set"; + outputMode: "report" | "asset_set" | "asset_set_with_report"; + artifactType: "json" | "directory" | "video"; + }; + createdBy?: string; + }) { + return readJson( + await fetch(`${this.baseUrl}/api/custom-nodes`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + ); } async listWorkflowTemplates(input: { diff --git a/apps/web/src/runtime/app.tsx b/apps/web/src/runtime/app.tsx index e956b78..804233e 100644 --- a/apps/web/src/runtime/app.tsx +++ b/apps/web/src/runtime/app.tsx @@ -60,6 +60,9 @@ function normalizePathnameForProjectSwitch(pathname: string) { if (pathname.startsWith("/assets/")) { return "/assets"; } + if (pathname.startsWith("/nodes")) { + return "/nodes"; + } if (pathname.startsWith("/workflows/")) { return "/workflows"; } @@ -95,7 +98,7 @@ function mapConnectionValidationReasonToKey( } } -type NavItem = "Projects" | "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin"; +type NavItem = "Projects" | "Assets" | "Nodes" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin"; type BootstrapContext = { userId: string; @@ -220,9 +223,10 @@ function AppShell(props: { children: React.ReactNode; }) { const { language, setLanguage, t } = useI18n(); - const navItems: Array<{ label: NavItem; href: string; key: "navProjects" | "navAssets" | "navWorkflows" | "navRuns" | "navExplore" | "navLabels" | "navAdmin" }> = [ + const navItems: Array<{ label: NavItem; href: string; key: "navProjects" | "navAssets" | "navNodes" | "navWorkflows" | "navRuns" | "navExplore" | "navLabels" | "navAdmin" }> = [ { label: "Projects", href: "/projects", key: "navProjects" }, { label: "Assets", href: "/assets", key: "navAssets" }, + { label: "Nodes", href: "/nodes", key: "navNodes" }, { label: "Workflows", href: "/workflows", key: "navWorkflows" }, { label: "Runs", href: "/runs", key: "navRuns" }, { label: "Explore", href: "/explore", key: "navExplore" }, @@ -393,6 +397,13 @@ function ProjectsPage(props: { ); } +function parseCommandLines(value: string) { + return value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + function AssetsPage(props: { api: ApiClient; bootstrap: BootstrapContext; @@ -752,6 +763,206 @@ function AssetsPage(props: { ); } +function NodesPage(props: { + api: ApiClient; + bootstrap: BootstrapContext; +}) { + const { t } = useI18n(); + const [nodes, setNodes] = useState([]); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState<"Transform" | "Inspect" | "Utility" | "Export">("Utility"); + const [sourceKind, setSourceKind] = useState<"image" | "dockerfile">("image"); + const [image, setImage] = useState("python:3.11-alpine"); + const [dockerfileContent, setDockerfileContent] = useState(""); + const [commandText, setCommandText] = useState(""); + const [inputMode, setInputMode] = useState<"single_asset_set" | "multi_asset_set">("single_asset_set"); + const [outputMode, setOutputMode] = useState<"report" | "asset_set" | "asset_set_with_report">("report"); + const [artifactType, setArtifactType] = useState<"json" | "directory" | "video">("json"); + const [error, setError] = useState(null); + + const loadCustomNodes = useCallback(async () => { + try { + setNodes(await props.api.listCustomNodes(props.bootstrap.project._id)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : t("failedLoadCustomNodes")); + } + }, [props.api, props.bootstrap.project._id, t]); + + useEffect(() => { + void loadCustomNodes(); + }, [loadCustomNodes]); + + return ( +
+
+

{t("nodesTitle")}

+

{t("nodesDescription")}

+ {error ?

{error}

: null} +
+ + +