diff --git a/docs/public/getting-started/first-pipeline.md b/docs/public/getting-started/first-pipeline.md index beb08177..4756992c 100644 --- a/docs/public/getting-started/first-pipeline.md +++ b/docs/public/getting-started/first-pipeline.md @@ -79,11 +79,11 @@ VectorFlow validates connection compatibility in real time. You cannot connect a {% endstep %} {% step %} -### Configure component keys +### Name your components -Each node has a **Component Key** in the detail panel (e.g., `demo_logs_0`). This key becomes the component ID in the generated Vector configuration. You can rename keys to something more descriptive like `demo_source`, `add_timestamp`, and `debug_output`. +Each node has a **Name** field in the detail panel. By default, new components are named after their type (e.g., "Demo Logs"). You can rename them to something more descriptive like "Demo Source", "Add Timestamp", and "Debug Output". -Keys must contain only letters, numbers, and underscores. +Renaming a component only requires saving the pipeline -- it does not require a redeploy. The backend component ID is auto-generated and shown as a read-only field below the name. {% endstep %} {% step %} diff --git a/docs/public/reference/api.md b/docs/public/reference/api.md index 3e044a83..267416f6 100644 --- a/docs/public/reference/api.md +++ b/docs/public/reference/api.md @@ -187,7 +187,8 @@ Each node in the `saveGraph` input: | Field | Type | Description | |-------|------|-------------| | `id` | `string?` | Optional ID (auto-generated if omitted) | -| `componentKey` | `string` | Unique identifier within the pipeline (e.g., `my_syslog_source`). Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` | +| `componentKey` | `string` | Auto-generated unique identifier within the pipeline (e.g., `syslog_k7xMp2nQ`). Must match `^[a-zA-Z_][a-zA-Z0-9_]*$` | +| `displayName` | `string?` | Optional human-readable name for the component (e.g., "Syslog Source") | | `componentType` | `string` | Vector component type (e.g., `syslog`, `remap`, `aws_s3`) | | `kind` | `"SOURCE" \| "TRANSFORM" \| "SINK"` | Component category | | `config` | `object` | Component configuration fields | diff --git a/docs/public/reference/pipeline-yaml.md b/docs/public/reference/pipeline-yaml.md index 1854c0ad..671611cf 100644 --- a/docs/public/reference/pipeline-yaml.md +++ b/docs/public/reference/pipeline-yaml.md @@ -62,12 +62,14 @@ sinks: ### Component keys -Each node in the visual editor has a **component key** -- a unique identifier within the pipeline. Component keys must: +Each node in the visual editor has a **component key** -- a unique, auto-generated identifier within the pipeline (e.g., `http_server_k7xMp2nQ`). Component keys are generated when a node is added and never change, even if you rename the component in the editor. + +Component keys must: - Start with a letter or underscore - Contain only letters, numbers, and underscores - Be between 1 and 128 characters -These keys become the YAML block names under `sources`, `transforms`, or `sinks`. +These keys become the YAML block names under `sources`, `transforms`, or `sinks`. The human-readable **Name** field in the editor is separate from the component key and does not affect the generated YAML. ### Connections via `inputs` diff --git a/docs/public/user-guide/pipeline-editor.md b/docs/public/user-guide/pipeline-editor.md index 08eeaf16..00569111 100644 --- a/docs/public/user-guide/pipeline-editor.md +++ b/docs/public/user-guide/pipeline-editor.md @@ -109,7 +109,8 @@ The panel has two tabs: The **Config** tab shows: - **Component name and kind** -- The display name, a badge indicating source/transform/sink, and a delete button. -- **Component Key** -- A unique identifier for this component within the pipeline (e.g. `traefik_logs`). Must contain only letters, numbers, and underscores. +- **Name** -- A human-readable label for the component (e.g. "Traefik Logs"). Changing the name requires saving, but does not require a redeploy. +- **Component ID** -- An auto-generated unique identifier used in the backend configuration (read-only). This key is set when the node is created and never changes. - **Enabled toggle** -- Disable a component to exclude it from the generated configuration without removing it from the canvas. - **Type** -- The Vector component type (read-only). - **Configuration form** -- Auto-generated form fields based on the component's configuration schema. Required fields are marked, and each field has contextual help. diff --git a/docs/public/user-guide/templates.md b/docs/public/user-guide/templates.md index 163a7ce0..7aa23d46 100644 --- a/docs/public/user-guide/templates.md +++ b/docs/public/user-guide/templates.md @@ -61,7 +61,7 @@ VectorFlow creates a new pipeline in the current environment with the template's Templates save the complete pipeline graph: -- **Nodes** -- Every source, transform, and sink component, including its component type, component key, and configuration. +- **Nodes** -- Every source, transform, and sink component, including its component type, component key, display name, and configuration. - **Edges** -- All connections between components, preserving the data flow topology. - **Layout** -- The X/Y positions of nodes on the canvas, so the visual layout is preserved. diff --git a/docs/superpowers/specs/2026-03-10-friendly-component-ids-design.md b/docs/superpowers/specs/2026-03-10-friendly-component-ids-design.md new file mode 100644 index 00000000..7b0608e7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-10-friendly-component-ids-design.md @@ -0,0 +1,103 @@ +# Friendly Component IDs + +Decouple pipeline component identity from user-facing display names. Component keys become immutable UUID-based identifiers used only in the backend (YAML, metrics, agent protocol). A new `displayName` field provides the human-readable label users see and edit in the GUI. + +## Problem + +Today, `componentKey` serves as both the backend identifier (YAML keys, metrics matching, event sampling) and the user-facing label. Renaming a component changes the key, which requires a redeploy and orphans in-flight metrics. Users working in the GUI shouldn't need to trigger infrastructure changes just to rename a component. + +## Design + +### Data Model + +Add a nullable `displayName` column to `PipelineNode`: + +```prisma +model PipelineNode { + // existing fields... + componentKey String // Immutable. Format: {type}_{nanoid(8)} + displayName String? // User-facing cosmetic name + // ... +} +``` + +- `componentKey` -- generated once at node creation, never modified. Format: `{componentType}_{nanoid(8)}` using a custom alphanumeric alphabet (`0-9A-Za-z`, no hyphens) to remain compatible with existing componentKey regex `/^[a-zA-Z_][a-zA-Z0-9_]*$/` and YAML key requirements. Examples: `http_server_k7xMp2nQ`, `remap_vT3bL9wR`. +- `displayName` -- nullable, editable anytime. Defaults to `componentDef.displayName` (e.g., "HTTP Server") on node creation. +- Display logic throughout the app: `displayName ?? componentKey`. + +### Component Key Generation & Immutability + +**Node creation** (flow-store `addNode`): +- `componentKey = {componentDef.type}_{nanoid(8)}` +- `displayName = componentDef.displayName` + +**Copy/paste & duplicate**: new UUID key generated, display name copied as-is (duplicates are fine -- display names are cosmetic). + +**Immutability enforcement**: +- Remove `updateNodeKey` action from flow store. +- No user input path for `componentKey` -- generated once, stored, done. + +**Validation**: +- `displayName`: permissive -- allow any printable characters (letters, numbers, spaces, hyphens, underscores, slashes, etc.) since some `componentDef.displayName` values may contain characters like `/`. Max 64 chars. +- `componentKey`: no user validation needed (system-generated). + +### GUI Changes + +**Node components** (source-node, transform-node, sink-node): +- Render `displayName ?? componentKey` as the node label. + +**Detail panel**: +- Current "Component Key" input becomes "Name", bound to `displayName`. +- Remove "Letters, numbers, and underscores only" hint. +- Add a small read-only "Component ID" field showing the UUID key (subtle, for debugging). +- Editing the name sets `isDirty: true` (requires save) but does not require redeploy. + +**Edge connections**: unaffected (use React Flow node `id`, not `componentKey`). + +**Metrics overlay**: unaffected (matched by `componentKey` which is unchanged). + +### Backend & Persistence + +**Save pipeline**: persists both `componentKey` and `displayName`. Key written once on creation, never updated. Display name updates are a normal save. + +**Deploy pipeline**: `generateVectorYaml()` uses `componentKey` for YAML keys. No changes. + +**Metrics router**: matching logic (`componentId === pn.componentKey`) unchanged. Include `displayName` in frontend responses for GUI labeling. + +**Event sampling**: uses `componentKey` throughout. No changes. + +**Pipeline versions**: `nodesSnapshot` naturally includes `displayName` as part of node data. + +**GitOps**: YAML uses UUID-based keys. Import leaves `displayName` null (falls back to key). No changes to git-sync or webhook handler. + +### Migration & Backwards Compatibility + +**Database migration**: `ALTER TABLE PipelineNode ADD COLUMN displayName TEXT` (nullable, no default). + +**Existing pipelines**: no data migration. Old components keep `{type}_{timestamp}` keys. `displayName` is `NULL` -- GUI shows `componentKey` as fallback. Users can optionally set display names on old components (just requires save). + +**New components**: get `{type}_{nanoid(8)}` keys and `displayName = componentDef.displayName`. + +**Mixed pipelines**: old timestamp keys and new UUID keys coexist. The system treats keys as opaque strings. + +**Not changing**: pipeline names, agent/heartbeat protocol, metrics store structure, YAML generation logic (beyond key format for new nodes), GitOps sync logic. Note: `displayName` is intentionally not written into YAML output. + +### Implementation Notes + +Files requiring `displayName` to be threaded through: + +- **Prisma schema**: add `displayName String?` to `PipelineNode` model +- **FlowNodeData type** (`flow-store.ts`): add `displayName` to the interface +- **ClipboardData type** (`flow-store.ts`): include `displayName` for copy/paste +- **computeFlowFingerprint** (`flow-store.ts`): include `displayName` so renames trigger "unsaved changes" +- **nodeSchema** (`pipeline.ts`): add `displayName: z.string().nullable().optional()` +- **templateNodeSchema** (`template.ts`): same as above +- **dbNodesToFlowNodes** (`pipelines/[id]/page.tsx`): map `displayName` from DB row to node data +- **pasteFromSession** (`flow-store.ts`): generate fresh nanoid key (not collision-appended timestamp), copy displayName as-is +- **copyPipelineGraph** (`copy-pipeline-graph.ts`): preserve componentKey and displayName on clone/promote +- **Metrics router responses**: include `displayName` alongside `componentKey` in `getComponentMetrics` and `getNodePipelineRates` responses + +### Known Trade-offs + +- **GitOps full-graph replacement**: bidirectional GitOps does delete-and-recreate on import, so display names set in the GUI will be lost if the pipeline is overwritten from Git. Acceptable given the current architecture. +- **No uniqueness enforcement on displayName**: two nodes can share the same display name. Users distinguish them by expanding the Component ID debug field if needed. diff --git a/package.json b/package.json index ed7d4cd1..0fe1381b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dotenv": "^17.3.1", "js-yaml": "^4.1.1", "lucide-react": "^0.575.0", + "nanoid": "^5.1.6", "next": "16.1.6", "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d77a2c2..11ee7960 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: lucide-react: specifier: ^0.575.0 version: 0.575.0(react@19.2.3) + nanoid: + specifier: ^5.1.6 + version: 5.1.6 next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3674,6 +3677,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -8453,6 +8461,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} diff --git a/prisma/migrations/20260310000000_add_display_name_to_pipeline_node/migration.sql b/prisma/migrations/20260310000000_add_display_name_to_pipeline_node/migration.sql new file mode 100644 index 00000000..5f445c9c --- /dev/null +++ b/prisma/migrations/20260310000000_add_display_name_to_pipeline_node/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "PipelineNode" ADD COLUMN "displayName" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0d69de6..01738443 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -363,6 +363,7 @@ model PipelineNode { pipelineId String pipeline Pipeline @relation(fields: [pipelineId], references: [id], onDelete: Cascade) componentKey String + displayName String? componentType String kind ComponentKind config Json diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx index 77e0e3c1..bf2d877e 100644 --- a/src/app/(dashboard)/pipelines/[id]/page.tsx +++ b/src/app/(dashboard)/pipelines/[id]/page.tsx @@ -56,6 +56,7 @@ function dbNodesToFlowNodes( dbNodes: Array<{ id: string; componentKey: string; + displayName: string | null; componentType: string; kind: string; config: unknown; @@ -82,6 +83,7 @@ function dbNodesToFlowNodes( configSchema: {}, }, componentKey: n.componentKey, + displayName: n.displayName ?? undefined, config: (n.config as Record) ?? {}, disabled: n.disabled ?? false, }, @@ -299,6 +301,7 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { nodes: state.nodes.map((n) => ({ id: n.id, componentKey: (n.data as Record).componentKey as string, + displayName: (n.data as Record).displayName as string | undefined, componentType: ((n.data as Record).componentDef as { type: string }).type, kind: (n.type?.toUpperCase() ?? "SOURCE") as "SOURCE" | "TRANSFORM" | "SINK", config: ((n.data as Record).config as Record) ?? {}, diff --git a/src/components/flow/detail-panel.tsx b/src/components/flow/detail-panel.tsx index 67f1219a..fa0d327b 100644 --- a/src/components/flow/detail-panel.tsx +++ b/src/components/flow/detail-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { Copy, Trash2, Lock, Info, MousePointerClick, Book } from "lucide-react"; import { useFlowStore } from "@/stores/flow-store"; import { SchemaForm } from "@/components/config-forms/schema-form"; @@ -118,7 +118,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { const nodes = useFlowStore((s) => s.nodes); const edges = useFlowStore((s) => s.edges); const updateNodeConfig = useFlowStore((s) => s.updateNodeConfig); - const updateNodeKey = useFlowStore((s) => s.updateNodeKey); + const updateDisplayName = useFlowStore((s) => s.updateDisplayName); const toggleNodeDisabled = useFlowStore((s) => s.toggleNodeDisabled); const removeNode = useFlowStore((s) => s.removeNode); @@ -126,12 +126,8 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { ? nodes.find((n) => n.id === selectedNodeId) : null; - const storeKey = (selectedNode?.data as { componentKey?: string })?.componentKey ?? ""; - const [displayKey, setDisplayKey] = useState(storeKey); - - useEffect(() => { - setDisplayKey(storeKey); - }, [storeKey]); + const componentKey = (selectedNode?.data as { componentKey?: string })?.componentKey ?? ""; + const currentDisplayName = (selectedNode?.data as { displayName?: string })?.displayName ?? ""; const upstream = useMemo( () => @@ -150,22 +146,14 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { [selectedNodeId, updateNodeConfig], ); - const handleKeyChange = useCallback( + const handleNameChange = useCallback( (raw: string) => { if (selectedNodeId) { - const sanitized = raw - .replace(/\s+/g, "_") - .replace(/[^a-zA-Z0-9_]/g, "") - .replace(/^(\d+)/, "_$1"); - if (sanitized) { - setDisplayKey(raw); - updateNodeKey(selectedNodeId, sanitized); - } else { - setDisplayKey(storeKey); - } + const trimmed = raw.slice(0, 64); + updateDisplayName(selectedNodeId, trimmed); } }, - [selectedNodeId, updateNodeKey, storeKey], + [selectedNodeId, updateDisplayName], ); const handleDelete = useCallback(() => { @@ -227,7 +215,8 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { const { componentDef, config, disabled, isSystemLocked } = selectedNode.data as { componentDef: VectorComponentDef; - componentKey: string; // used via displayKey/storeKey above + componentKey: string; + displayName?: string; config: Record; disabled?: boolean; isSystemLocked?: boolean; @@ -293,18 +282,22 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { - {/* Component Key */} + {/* Name */}
- + handleKeyChange(e.target.value)} + id="display-name" + value={currentDisplayName} + onChange={(e) => handleNameChange(e.target.value)} disabled={isSystemLocked} + placeholder="Component name" /> -

- Letters, numbers, and underscores only (e.g. traefik_logs) -

+
+ + {/* Component ID (read-only) */} +
+ +

{componentKey}

{/* Enabled toggle */} @@ -433,7 +426,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { diff --git a/src/components/flow/save-template-dialog.tsx b/src/components/flow/save-template-dialog.tsx index 7e1fc3fa..b3c378c3 100644 --- a/src/components/flow/save-template-dialog.tsx +++ b/src/components/flow/save-template-dialog.tsx @@ -68,6 +68,7 @@ export function SaveTemplateDialog({ open, onOpenChange }: SaveTemplateDialogPro id: n.id, componentType: ((n.data as Record).componentDef as VectorComponentDef).type, componentKey: (n.data as Record).componentKey as string, + displayName: (n.data as Record).displayName as string | undefined, kind: (n.type ?? "source") as "source" | "transform" | "sink", config: ((n.data as Record).config as Record) ?? {}, positionX: n.position.x, diff --git a/src/components/flow/sink-node.tsx b/src/components/flow/sink-node.tsx index 4d356cde..27625890 100644 --- a/src/components/flow/sink-node.tsx +++ b/src/components/flow/sink-node.tsx @@ -15,6 +15,7 @@ import { nodeStatusVariant } from "@/lib/status"; type SinkNodeData = { componentDef: VectorComponentDef; componentKey: string; + displayName?: string; config: Record; metrics?: NodeMetricsData; disabled?: boolean; @@ -23,7 +24,7 @@ type SinkNodeData = { type SinkNodeType = Node; function SinkNodeComponent({ data, selected }: NodeProps) { - const { componentDef, componentKey, metrics, disabled } = data; + const { componentDef, componentKey, displayName, metrics, disabled } = data; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return ( @@ -52,7 +53,7 @@ function SinkNodeComponent({ data, selected }: NodeProps) { {/* Body */}
-

{componentKey}

+

{displayName || componentKey}

{metrics && (

diff --git a/src/components/flow/source-node.tsx b/src/components/flow/source-node.tsx index 40874444..db300a96 100644 --- a/src/components/flow/source-node.tsx +++ b/src/components/flow/source-node.tsx @@ -16,6 +16,7 @@ import { nodeStatusVariant } from "@/lib/status"; type SourceNodeData = { componentDef: VectorComponentDef; componentKey: string; + displayName?: string; config: Record; metrics?: NodeMetricsData; disabled?: boolean; @@ -25,7 +26,7 @@ type SourceNodeData = { type SourceNodeType = Node; function SourceNodeComponent({ data, selected }: NodeProps) { - const { componentDef, componentKey, metrics, disabled, isSystemLocked } = data; + const { componentDef, componentKey, displayName, metrics, disabled, isSystemLocked } = data; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return ( @@ -51,7 +52,7 @@ function SourceNodeComponent({ data, selected }: NodeProps) { {/* Body */}

-

{componentKey}

+

{displayName || componentKey}

{metrics && (

diff --git a/src/components/flow/transform-node.tsx b/src/components/flow/transform-node.tsx index 8fbf5031..d5a3120d 100644 --- a/src/components/flow/transform-node.tsx +++ b/src/components/flow/transform-node.tsx @@ -15,6 +15,7 @@ import { nodeStatusVariant } from "@/lib/status"; type TransformNodeData = { componentDef: VectorComponentDef; componentKey: string; + displayName?: string; config: Record; metrics?: NodeMetricsData; disabled?: boolean; @@ -26,7 +27,7 @@ function TransformNodeComponent({ data, selected, }: NodeProps) { - const { componentDef, componentKey, metrics, disabled } = data; + const { componentDef, componentKey, displayName, metrics, disabled } = data; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return ( @@ -55,7 +56,7 @@ function TransformNodeComponent({ {/* Body */}

-

{componentKey}

+

{displayName || componentKey}

{metrics && (

diff --git a/src/lib/component-key.ts b/src/lib/component-key.ts new file mode 100644 index 00000000..b677fffa --- /dev/null +++ b/src/lib/component-key.ts @@ -0,0 +1,12 @@ +import { customAlphabet } from "nanoid"; + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; +const nanoid = customAlphabet(alphabet, 8); + +/** + * Generate an immutable component key: {componentType}_{nanoid(8)} + * Examples: http_server_k7xMp2nQ, remap_vT3bL9wR + */ +export function generateComponentKey(componentType: string): string { + return `${componentType}_${nanoid()}`; +} diff --git a/src/server/routers/metrics.ts b/src/server/routers/metrics.ts index 469cc3af..75fe96c6 100644 --- a/src/server/routers/metrics.ts +++ b/src/server/routers/metrics.ts @@ -66,6 +66,7 @@ export const metricsRouter = router({ const components: Record; @@ -80,6 +81,7 @@ export const metricsRouter = router({ if (matchingNode) { components[componentId] = { componentKey: matchingNode.componentKey, + displayName: matchingNode.displayName, componentType: matchingNode.componentType, kind: matchingNode.kind, samples, @@ -102,7 +104,7 @@ export const metricsRouter = router({ // Map componentKey → { pipelineId, kind } using pipeline nodes const pipelineNodes = await prisma.pipelineNode.findMany({ - select: { pipelineId: true, componentKey: true, kind: true }, + select: { pipelineId: true, componentKey: true, displayName: true, kind: true }, }); const rates: Record ({ id: n.id, componentKey: n.componentKey, + displayName: n.displayName, componentType: n.componentType, kind: n.kind, config: n.config, diff --git a/src/server/routers/template.ts b/src/server/routers/template.ts index 0c690b23..294a70cc 100644 --- a/src/server/routers/template.ts +++ b/src/server/routers/template.ts @@ -9,6 +9,7 @@ const templateNodeSchema = z.object({ id: z.string(), componentType: z.string(), componentKey: z.string().min(1).max(128).regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/), + displayName: z.string().max(64).nullable().optional(), kind: z.enum(["source", "transform", "sink"]), config: z.record(z.string(), z.any()), positionX: z.number(), diff --git a/src/server/services/copy-pipeline-graph.ts b/src/server/services/copy-pipeline-graph.ts index 3a1f60f7..634260d5 100644 --- a/src/server/services/copy-pipeline-graph.ts +++ b/src/server/services/copy-pipeline-graph.ts @@ -47,6 +47,7 @@ export async function copyPipelineGraph( data: { pipelineId: targetPipelineId, componentKey: node.componentKey, + displayName: node.displayName, componentType: node.componentType, kind: node.kind, config: finalConfig as Prisma.InputJsonValue, diff --git a/src/server/services/deploy-agent.ts b/src/server/services/deploy-agent.ts index d5882cdc..12b2ebff 100644 --- a/src/server/services/deploy-agent.ts +++ b/src/server/services/deploy-agent.ts @@ -107,6 +107,7 @@ export async function deployAgent( const nodesSnapshot = pipeline.nodes.map((n) => ({ id: n.id, componentKey: n.componentKey, + displayName: n.displayName, componentType: n.componentType, kind: n.kind, config: n.config, diff --git a/src/server/services/pipeline-version.ts b/src/server/services/pipeline-version.ts index 1f2c7231..dd427af5 100644 --- a/src/server/services/pipeline-version.ts +++ b/src/server/services/pipeline-version.ts @@ -129,6 +129,7 @@ export async function rollback( id: node.id as string, pipelineId, componentKey: node.componentKey as string, + displayName: (node.displayName as string) ?? null, componentType: node.componentType as string, kind: node.kind as ComponentKind, config: node.config as Prisma.InputJsonValue, diff --git a/src/stores/flow-store.ts b/src/stores/flow-store.ts index 5bb025b8..bf101b04 100644 --- a/src/stores/flow-store.ts +++ b/src/stores/flow-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { generateId } from "@/lib/utils"; +import { generateComponentKey } from "@/lib/component-key"; import { type Node, type Edge, @@ -17,6 +18,7 @@ import { findComponentDef } from "@/lib/vector/catalog"; interface FlowNodeData { componentDef: VectorComponentDef; componentKey: string; + displayName?: string; config: Record; disabled?: boolean; metrics?: NodeMetricsData; @@ -37,6 +39,7 @@ const MAX_HISTORY = 50; export interface ClipboardData { componentDef: VectorComponentDef; componentKey: string; + displayName?: string; config: Record; position: { x: number; y: number }; } @@ -78,7 +81,7 @@ export interface FlowState { removeNode: (id: string) => void; removeEdge: (id: string) => void; updateNodeConfig: (id: string, config: Record) => void; - updateNodeKey: (id: string, key: string) => void; + updateDisplayName: (id: string, displayName: string) => void; toggleNodeDisabled: (id: string) => void; updateNodeMetrics: (metricsMap: Map) => void; @@ -301,7 +304,8 @@ export const useFlowStore = create()((set, get) => ({ position, data: { componentDef, - componentKey: `${componentDef.type}_${Date.now()}`, + componentKey: generateComponentKey(componentDef.type), + displayName: componentDef.displayName, config, }, }; @@ -366,9 +370,8 @@ export const useFlowStore = create()((set, get) => ({ }); }, - updateNodeKey: (id, key) => { + updateDisplayName: (id, displayName) => { set((state) => { - // Prevent editing system-locked nodes const node = state.nodes.find((n) => n.id === id); if (node?.data?.isSystemLocked) return {}; @@ -377,7 +380,7 @@ export const useFlowStore = create()((set, get) => ({ ...history, nodes: state.nodes.map((n) => n.id === id - ? { ...n, data: { ...n.data, componentKey: key } } + ? { ...n, data: { ...n.data, displayName } } : n, ), isDirty: true, @@ -457,6 +460,7 @@ export const useFlowStore = create()((set, get) => ({ clipboard: { componentDef: node.data.componentDef as VectorComponentDef, componentKey: node.data.componentKey as string, + displayName: (node.data as unknown as FlowNodeData).displayName, config: { ...(node.data.config as Record) }, position: { x: node.position.x, y: node.position.y }, }, @@ -477,7 +481,8 @@ export const useFlowStore = create()((set, get) => ({ }, data: { componentDef: state.clipboard.componentDef, - componentKey: `${state.clipboard.componentDef.type}_${Date.now()}`, + componentKey: generateComponentKey(state.clipboard.componentDef.type), + displayName: state.clipboard.displayName, config: { ...state.clipboard.config }, }, selected: true, @@ -511,7 +516,8 @@ export const useFlowStore = create()((set, get) => ({ }, data: { componentDef: node.data.componentDef, - componentKey: `${(node.data.componentDef as VectorComponentDef).type}_${Date.now()}`, + componentKey: generateComponentKey((node.data.componentDef as VectorComponentDef).type), + displayName: (node.data as unknown as FlowNodeData).displayName, config: { ...(node.data.config as Record) }, }, selected: true, @@ -547,6 +553,7 @@ export const useFlowStore = create()((set, get) => ({ const payload = { nodes: selectedNodes.map((n) => ({ componentKey: (n.data as unknown as FlowNodeData).componentKey, + displayName: (n.data as unknown as FlowNodeData).displayName, componentType: (n.data as unknown as FlowNodeData).componentDef.type, kind: (n.data as unknown as FlowNodeData).componentDef.kind, config: (n.data as unknown as FlowNodeData).config, @@ -578,6 +585,7 @@ export const useFlowStore = create()((set, get) => ({ clipboard: { componentDef: (node.data as unknown as FlowNodeData).componentDef, componentKey: (node.data as unknown as FlowNodeData).componentKey, + displayName: (node.data as unknown as FlowNodeData).displayName, config: { ...(node.data as unknown as FlowNodeData).config }, position: { x: node.position.x, y: node.position.y }, }, @@ -600,6 +608,7 @@ export const useFlowStore = create()((set, get) => ({ let payload: { nodes: Array<{ componentKey: string; + displayName?: string; componentType: string; kind: string; config: Record; @@ -625,15 +634,10 @@ export const useFlowStore = create()((set, get) => ({ const cx = 400; const cy = 300; - const existingKeys = new Set(state.nodes.map((n) => (n.data as unknown as FlowNodeData).componentKey)); const keyMap = new Map(); const newNodes: Node[] = payload.nodes.map((pn) => { - let key = pn.componentKey; - while (existingKeys.has(key)) { - key = `${pn.componentKey}_${Date.now()}_${Math.random().toString(36).slice(2, 5)}`; - } - existingKeys.add(key); + const key = generateComponentKey(pn.componentType); keyMap.set(pn.componentKey, key); const componentDef = findComponentDef(pn.componentType, pn.kind as "source" | "transform" | "sink"); @@ -655,6 +659,7 @@ export const useFlowStore = create()((set, get) => ({ configSchema: {}, }, componentKey: key, + displayName: pn.displayName, config: pn.config, disabled: pn.disabled, },