From be7cc4f79d4335bb4b06e2586fff2431f3792448 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:03:59 +0000 Subject: [PATCH 01/18] feat: add SharedComponent model and PipelineNode link fields --- prisma/schema.prisma | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0d69de6..10f5a48b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,7 @@ model Environment { serviceAccounts ServiceAccount[] deployRequests DeployRequest[] teamDefaults Team[] @relation("teamDefault") + sharedComponents SharedComponent[] createdAt DateTime @default(now()) } @@ -369,6 +370,11 @@ model PipelineNode { positionX Float positionY Float disabled Boolean @default(false) + sharedComponentId String? + sharedComponent SharedComponent? @relation(fields: [sharedComponentId], references: [id], onDelete: SetNull) + sharedComponentVersion Int? + + @@index([sharedComponentId]) } enum ComponentKind { @@ -386,6 +392,25 @@ model PipelineEdge { sourcePort String? } +model SharedComponent { + id String @id @default(cuid()) + name String + description String? + componentType String + kind ComponentKind + config Json + version Int @default(1) + environmentId String + environment Environment @relation(fields: [environmentId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + linkedNodes PipelineNode[] + + @@unique([environmentId, name]) + @@index([environmentId]) +} + model PipelineVersion { id String @id @default(cuid()) pipelineId String From b5574d3c97021d315b4c033ab7101ebc973e7040 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:08:09 +0000 Subject: [PATCH 02/18] feat: add shared component router with CRUD and link management --- src/server/routers/shared-component.ts | 449 +++++++++++++++++++++++++ src/trpc/router.ts | 2 + 2 files changed, 451 insertions(+) create mode 100644 src/server/routers/shared-component.ts diff --git a/src/server/routers/shared-component.ts b/src/server/routers/shared-component.ts new file mode 100644 index 00000000..25bc722c --- /dev/null +++ b/src/server/routers/shared-component.ts @@ -0,0 +1,449 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { Prisma, ComponentKind } from "@/generated/prisma"; +import { router, protectedProcedure, withTeamAccess } from "@/trpc/init"; +import { prisma } from "@/lib/prisma"; +import { withAudit } from "@/server/middleware/audit"; +import { encryptNodeConfig, decryptNodeConfig } from "@/server/services/config-crypto"; + +export const sharedComponentRouter = router({ + /** List all shared components for an environment */ + list: protectedProcedure + .input(z.object({ environmentId: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + const components = await prisma.sharedComponent.findMany({ + where: { environmentId: input.environmentId }, + include: { + linkedNodes: { select: { pipelineId: true } }, + }, + orderBy: { updatedAt: "desc" }, + }); + + return components.map((sc) => ({ + id: sc.id, + name: sc.name, + description: sc.description, + componentType: sc.componentType, + kind: sc.kind, + config: decryptNodeConfig( + sc.componentType, + (sc.config as Record) ?? {}, + ), + version: sc.version, + linkedPipelineCount: new Set(sc.linkedNodes.map((n) => n.pipelineId)).size, + createdAt: sc.createdAt, + updatedAt: sc.updatedAt, + })); + }), + + /** Get a single shared component by ID with linked pipeline details */ + getById: protectedProcedure + .input(z.object({ id: z.string(), environmentId: z.string() })) + .use(withTeamAccess("VIEWER")) + .query(async ({ input }) => { + const sc = await prisma.sharedComponent.findUnique({ + where: { id: input.id }, + include: { + linkedNodes: { + include: { + pipeline: { select: { id: true, name: true } }, + }, + }, + }, + }); + + if (!sc) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Shared component not found", + }); + } + + // Group linked nodes by pipeline and determine staleness per pipeline + const pipelineMap = new Map< + string, + { id: string; name: string; isStale: boolean } + >(); + for (const node of sc.linkedNodes) { + const pid = node.pipelineId; + const existing = pipelineMap.get(pid); + const isStale = node.sharedComponentVersion !== sc.version; + if (!existing) { + pipelineMap.set(pid, { + id: node.pipeline.id, + name: node.pipeline.name, + isStale, + }); + } else if (isStale) { + // If any node in this pipeline is stale, mark pipeline as stale + existing.isStale = true; + } + } + + return { + id: sc.id, + name: sc.name, + description: sc.description, + componentType: sc.componentType, + kind: sc.kind, + config: decryptNodeConfig( + sc.componentType, + (sc.config as Record) ?? {}, + ), + version: sc.version, + environmentId: sc.environmentId, + createdAt: sc.createdAt, + updatedAt: sc.updatedAt, + linkedPipelines: Array.from(pipelineMap.values()), + }; + }), + + /** Create a new shared component */ + create: protectedProcedure + .input( + z.object({ + environmentId: z.string(), + name: z.string().min(1).max(100), + description: z.string().optional(), + componentType: z.string().min(1), + kind: z.nativeEnum(ComponentKind), + config: z.record(z.string(), z.any()), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.created", "SharedComponent")) + .mutation(async ({ input }) => { + // Check unique constraint (environmentId + name) + const existing = await prisma.sharedComponent.findUnique({ + where: { + environmentId_name: { + environmentId: input.environmentId, + name: input.name, + }, + }, + }); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: `A shared component named "${input.name}" already exists in this environment`, + }); + } + + return prisma.sharedComponent.create({ + data: { + environmentId: input.environmentId, + name: input.name, + description: input.description, + componentType: input.componentType, + kind: input.kind, + config: encryptNodeConfig(input.componentType, input.config) as Prisma.InputJsonValue, + }, + }); + }), + + /** Create a shared component from an existing pipeline node */ + createFromNode: protectedProcedure + .input( + z.object({ + nodeId: z.string(), + pipelineId: z.string(), + name: z.string().min(1).max(100), + description: z.string().optional(), + environmentId: z.string(), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.created", "SharedComponent")) + .mutation(async ({ input }) => { + const node = await prisma.pipelineNode.findUnique({ + where: { id: input.nodeId }, + }); + if (!node || node.pipelineId !== input.pipelineId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pipeline node not found", + }); + } + + // Check unique constraint + const existing = await prisma.sharedComponent.findUnique({ + where: { + environmentId_name: { + environmentId: input.environmentId, + name: input.name, + }, + }, + }); + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: `A shared component named "${input.name}" already exists in this environment`, + }); + } + + return prisma.$transaction(async (tx) => { + const sharedComponent = await tx.sharedComponent.create({ + data: { + environmentId: input.environmentId, + name: input.name, + description: input.description, + componentType: node.componentType, + kind: node.kind, + config: (node.config ?? {}) as Prisma.InputJsonValue, + }, + }); + + // Link the original node to the shared component + await tx.pipelineNode.update({ + where: { id: input.nodeId }, + data: { + sharedComponentId: sharedComponent.id, + sharedComponentVersion: sharedComponent.version, + }, + }); + + return sharedComponent; + }); + }), + + /** Update a shared component */ + update: protectedProcedure + .input( + z.object({ + id: z.string(), + environmentId: z.string(), + name: z.string().min(1).max(100).optional(), + description: z.string().nullable().optional(), + config: z.record(z.string(), z.any()).optional(), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.updated", "SharedComponent")) + .mutation(async ({ input }) => { + const sc = await prisma.sharedComponent.findUnique({ + where: { id: input.id }, + }); + if (!sc) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Shared component not found", + }); + } + + // If name changes, check for conflicts + if (input.name && input.name !== sc.name) { + const conflict = await prisma.sharedComponent.findUnique({ + where: { + environmentId_name: { + environmentId: sc.environmentId, + name: input.name, + }, + }, + }); + if (conflict) { + throw new TRPCError({ + code: "CONFLICT", + message: `A shared component named "${input.name}" already exists in this environment`, + }); + } + } + + const data: Record = {}; + if (input.name !== undefined) data.name = input.name; + if (input.description !== undefined) data.description = input.description; + + // If config changes, encrypt and bump version + if (input.config) { + data.config = encryptNodeConfig(sc.componentType, input.config) as Prisma.InputJsonValue; + data.version = sc.version + 1; + } + + return prisma.sharedComponent.update({ + where: { id: input.id }, + data, + }); + }), + + /** Delete a shared component */ + delete: protectedProcedure + .input(z.object({ id: z.string(), environmentId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.deleted", "SharedComponent")) + .mutation(async ({ input }) => { + const sc = await prisma.sharedComponent.findUnique({ + where: { id: input.id }, + }); + if (!sc) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Shared component not found", + }); + } + + // onDelete: SetNull handles unlinking automatically + return prisma.sharedComponent.delete({ + where: { id: input.id }, + }); + }), + + /** Accept latest shared component config into a pipeline node */ + acceptUpdate: protectedProcedure + .input(z.object({ nodeId: z.string(), pipelineId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.update_accepted", "SharedComponent")) + .mutation(async ({ input }) => { + const node = await prisma.pipelineNode.findUnique({ + where: { id: input.nodeId }, + include: { sharedComponent: true }, + }); + if (!node || node.pipelineId !== input.pipelineId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pipeline node not found", + }); + } + if (!node.sharedComponent) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Node is not linked to a shared component", + }); + } + + // Copy latest config from shared component into the node + return prisma.pipelineNode.update({ + where: { id: input.nodeId }, + data: { + config: node.sharedComponent.config ?? undefined, + sharedComponentVersion: node.sharedComponent.version, + }, + }); + }), + + /** Accept updates for all stale linked nodes in a pipeline */ + acceptUpdateBulk: protectedProcedure + .input(z.object({ pipelineId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.bulk_update_accepted", "Pipeline")) + .mutation(async ({ input }) => { + const pipeline = await prisma.pipeline.findUnique({ + where: { id: input.pipelineId }, + }); + if (!pipeline) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pipeline not found", + }); + } + + // Find all nodes in this pipeline that are linked to a shared component + const linkedNodes = await prisma.pipelineNode.findMany({ + where: { + pipelineId: input.pipelineId, + sharedComponentId: { not: null }, + }, + include: { sharedComponent: true }, + }); + + // Filter to only stale nodes + const staleNodes = linkedNodes.filter( + (n) => + n.sharedComponent && + n.sharedComponentVersion !== n.sharedComponent.version, + ); + + if (staleNodes.length === 0) { + return { updated: 0 }; + } + + await prisma.$transaction( + staleNodes.map((n) => + prisma.pipelineNode.update({ + where: { id: n.id }, + data: { + config: n.sharedComponent!.config ?? undefined, + sharedComponentVersion: n.sharedComponent!.version, + }, + }), + ), + ); + + return { updated: staleNodes.length }; + }), + + /** Unlink a pipeline node from its shared component */ + unlink: protectedProcedure + .input(z.object({ nodeId: z.string(), pipelineId: z.string() })) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.unlinked", "SharedComponent")) + .mutation(async ({ input }) => { + const node = await prisma.pipelineNode.findUnique({ + where: { id: input.nodeId }, + }); + if (!node || node.pipelineId !== input.pipelineId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pipeline node not found", + }); + } + + return prisma.pipelineNode.update({ + where: { id: input.nodeId }, + data: { + sharedComponentId: null, + sharedComponentVersion: null, + }, + }); + }), + + /** Link an existing pipeline node to a shared component */ + linkExisting: protectedProcedure + .input( + z.object({ + nodeId: z.string(), + pipelineId: z.string(), + sharedComponentId: z.string(), + }), + ) + .use(withTeamAccess("EDITOR")) + .use(withAudit("shared_component.linked", "SharedComponent")) + .mutation(async ({ input }) => { + const node = await prisma.pipelineNode.findUnique({ + where: { id: input.nodeId }, + }); + if (!node || node.pipelineId !== input.pipelineId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Pipeline node not found", + }); + } + + const sc = await prisma.sharedComponent.findUnique({ + where: { id: input.sharedComponentId }, + }); + if (!sc) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Shared component not found", + }); + } + + // Validate type/kind match + if (node.componentType !== sc.componentType || node.kind !== sc.kind) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Component type/kind mismatch: node is ${node.kind}/${node.componentType} but shared component is ${sc.kind}/${sc.componentType}`, + }); + } + + // Copy config from shared component and set link fields + return prisma.pipelineNode.update({ + where: { id: input.nodeId }, + data: { + config: sc.config ?? undefined, + sharedComponentId: sc.id, + sharedComponentVersion: sc.version, + }, + }); + }), +}); diff --git a/src/trpc/router.ts b/src/trpc/router.ts index 032e185a..dc0871a1 100644 --- a/src/trpc/router.ts +++ b/src/trpc/router.ts @@ -19,6 +19,7 @@ import { vrlSnippetRouter } from "@/server/routers/vrl-snippet"; import { alertRouter } from "@/server/routers/alert"; import { serviceAccountRouter } from "@/server/routers/service-account"; import { userPreferenceRouter } from "@/server/routers/user-preference"; +import { sharedComponentRouter } from "@/server/routers/shared-component"; export const appRouter = router({ team: teamRouter, @@ -41,6 +42,7 @@ export const appRouter = router({ alert: alertRouter, serviceAccount: serviceAccountRouter, userPreference: userPreferenceRouter, + sharedComponent: sharedComponentRouter, }); export type AppRouter = typeof appRouter; From 64b336074315d638e4e0a7137763ef8f92414476 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:09:52 +0000 Subject: [PATCH 03/18] feat: preserve shared component links and add stale detection to pipeline router --- src/server/routers/pipeline.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/server/routers/pipeline.ts b/src/server/routers/pipeline.ts index c1b9457d..a8c59662 100644 --- a/src/server/routers/pipeline.ts +++ b/src/server/routers/pipeline.ts @@ -38,6 +38,8 @@ const nodeSchema = z.object({ positionX: z.number(), positionY: z.number(), disabled: z.boolean().default(false), + sharedComponentId: z.string().nullable().optional(), + sharedComponentVersion: z.number().nullable().optional(), }); const edgeSchema = z.object({ @@ -99,6 +101,11 @@ export const pipelineRouter = router({ positionX: true, positionY: true, disabled: true, + sharedComponentId: true, + sharedComponentVersion: true, + sharedComponent: { + select: { version: true, name: true }, + }, }, }, edges: { @@ -189,6 +196,12 @@ export const pipelineRouter = router({ updatedBy: p.updatedBy, nodeStatuses: p.nodeStatuses, hasUndeployedChanges, + hasStaleComponents: p.nodes.some( + (n) => n.sharedComponentId && n.sharedComponent && (n.sharedComponentVersion ?? 0) < n.sharedComponent.version + ), + staleComponentNames: p.nodes + .filter((n) => n.sharedComponentId && n.sharedComponent && (n.sharedComponentVersion ?? 0) < n.sharedComponent.version) + .map((n) => n.sharedComponent!.name), }; })); @@ -202,7 +215,13 @@ export const pipelineRouter = router({ const pipeline = await prisma.pipeline.findUnique({ where: { id: input.id }, include: { - nodes: true, + nodes: { + include: { + sharedComponent: { + select: { name: true, version: true }, + }, + }, + }, edges: true, environment: { select: { teamId: true, gitOpsMode: true, name: true } }, nodeStatuses: { @@ -720,6 +739,8 @@ export const pipelineRouter = router({ positionX: node.positionX, positionY: node.positionY, disabled: node.disabled, + sharedComponentId: node.sharedComponentId ?? null, + sharedComponentVersion: node.sharedComponentVersion ?? null, }, }) ) From 8d960a7c0edd450f804875ddeabb41587425c04e Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:11:21 +0000 Subject: [PATCH 04/18] feat: wire shared component data through flow store and pipeline page --- src/app/(dashboard)/pipelines/[id]/page.tsx | 12 ++++++++++++ src/stores/flow-store.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx index 77e0e3c1..2a689fb0 100644 --- a/src/app/(dashboard)/pipelines/[id]/page.tsx +++ b/src/app/(dashboard)/pipelines/[id]/page.tsx @@ -62,6 +62,12 @@ function dbNodesToFlowNodes( positionX: number; positionY: number; disabled?: boolean; + sharedComponentId?: string | null; + sharedComponentVersion?: number | null; + sharedComponent?: { + name: string; + version: number; + } | null; }> ): Node[] { return dbNodes.map((n) => { @@ -84,6 +90,10 @@ function dbNodesToFlowNodes( componentKey: n.componentKey, config: (n.config as Record) ?? {}, disabled: n.disabled ?? false, + sharedComponentId: n.sharedComponentId ?? null, + sharedComponentVersion: n.sharedComponentVersion ?? null, + sharedComponentName: n.sharedComponent?.name ?? null, + sharedComponentLatestVersion: n.sharedComponent?.version ?? null, }, }; }); @@ -305,6 +315,8 @@ function PipelineBuilderInner({ pipelineId }: { pipelineId: string }) { positionX: n.position.x, positionY: n.position.y, disabled: !!((n.data as Record).disabled), + sharedComponentId: ((n.data as Record).sharedComponentId as string | null) ?? null, + sharedComponentVersion: ((n.data as Record).sharedComponentVersion as number | null) ?? null, })), edges: state.edges.map((e) => ({ id: e.id, diff --git a/src/stores/flow-store.ts b/src/stores/flow-store.ts index 5bb025b8..14014844 100644 --- a/src/stores/flow-store.ts +++ b/src/stores/flow-store.ts @@ -21,6 +21,10 @@ interface FlowNodeData { disabled?: boolean; metrics?: NodeMetricsData; isSystemLocked?: boolean; + sharedComponentId?: string | null; + sharedComponentVersion?: number | null; + sharedComponentName?: string | null; + sharedComponentLatestVersion?: number | null; } /* ------------------------------------------------------------------ */ @@ -128,7 +132,7 @@ function computeFlowFingerprint(nodes: Node[], edges: Edge[], globalConfig: Reco position: n.position, data: Object.fromEntries( Object.entries(n.data as Record).filter( - ([k]) => k !== "metrics" && k !== "measured" && k !== "isSystemLocked" + ([k]) => k !== "metrics" && k !== "measured" && k !== "isSystemLocked" && k !== "sharedComponentName" && k !== "sharedComponentLatestVersion" ) ), })); From 0f1d05f9506a2821f0670ad12852e396af2a161d Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:14:23 +0000 Subject: [PATCH 05/18] feat: add purple shared component visual treatment to pipeline nodes --- src/app/globals.css | 4 ++++ src/components/flow/sink-node.tsx | 24 +++++++++++++++++++++++- src/components/flow/source-node.tsx | 25 +++++++++++++++++++++++-- src/components/flow/transform-node.tsx | 24 +++++++++++++++++++++++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 6fabe042..6b9e2173 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,9 +21,11 @@ --color-node-source: var(--node-source); --color-node-transform: var(--node-transform); --color-node-sink: var(--node-sink); + --color-node-shared: var(--node-shared); --color-node-source-foreground: var(--node-source-foreground); --color-node-transform-foreground: var(--node-transform-foreground); --color-node-sink-foreground: var(--node-sink-foreground); + --color-node-shared-foreground: var(--node-shared-foreground); --color-status-healthy: var(--status-healthy); --color-status-healthy-bg: var(--status-healthy-bg); --color-status-healthy-foreground: var(--status-healthy-foreground); @@ -74,9 +76,11 @@ --node-source: oklch(0.65 0.18 145); --node-transform: oklch(0.60 0.15 250); --node-sink: oklch(0.55 0.20 295); + --node-shared: oklch(0.60 0.18 300); --node-source-foreground: oklch(0.98 0 0); --node-transform-foreground: oklch(0.98 0 0); --node-sink-foreground: oklch(0.98 0 0); + --node-shared-foreground: oklch(0.98 0 0); --status-healthy: oklch(0.55 0.17 145); --status-healthy-bg: oklch(0.55 0.17 145 / 15%); --status-healthy-foreground: oklch(0.35 0.12 145); diff --git a/src/components/flow/sink-node.tsx b/src/components/flow/sink-node.tsx index 4d356cde..e065d9cf 100644 --- a/src/components/flow/sink-node.tsx +++ b/src/components/flow/sink-node.tsx @@ -2,6 +2,7 @@ import { memo, useMemo } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { Link2 as LinkIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { VectorComponentDef } from "@/lib/vector/types"; import type { NodeMetricsData } from "@/stores/flow-store"; @@ -18,19 +19,28 @@ type SinkNodeData = { config: Record; metrics?: NodeMetricsData; disabled?: boolean; + sharedComponentId?: string | null; + sharedComponentVersion?: number | null; + sharedComponentLatestVersion?: number | null; + sharedComponentName?: string | null; }; type SinkNodeType = Node; function SinkNodeComponent({ data, selected }: NodeProps) { const { componentDef, componentKey, metrics, disabled } = data; + const isShared = !!data.sharedComponentId; + const isStale = isShared && data.sharedComponentLatestVersion != null && + (data.sharedComponentVersion ?? 0) < data.sharedComponentLatestVersion; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return (
@@ -70,6 +80,18 @@ function SinkNodeComponent({ data, selected }: NodeProps) { )}
)} + + {isShared && ( +
+ + {isStale ? ( + Update available + ) : ( + Shared + )} + {isStale && } +
+ )} ); } diff --git a/src/components/flow/source-node.tsx b/src/components/flow/source-node.tsx index 40874444..b9f2dbbb 100644 --- a/src/components/flow/source-node.tsx +++ b/src/components/flow/source-node.tsx @@ -2,7 +2,7 @@ import { memo, useMemo } from "react"; import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; -import { Lock } from "lucide-react"; +import { Lock, Link2 as LinkIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { VectorComponentDef } from "@/lib/vector/types"; import type { NodeMetricsData } from "@/stores/flow-store"; @@ -20,19 +20,28 @@ type SourceNodeData = { metrics?: NodeMetricsData; disabled?: boolean; isSystemLocked?: boolean; + sharedComponentId?: string | null; + sharedComponentVersion?: number | null; + sharedComponentLatestVersion?: number | null; + sharedComponentName?: string | null; }; type SourceNodeType = Node; function SourceNodeComponent({ data, selected }: NodeProps) { const { componentDef, componentKey, metrics, disabled, isSystemLocked } = data; + const isShared = !!data.sharedComponentId; + const isStale = isShared && data.sharedComponentLatestVersion != null && + (data.sharedComponentVersion ?? 0) < data.sharedComponentLatestVersion; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return (
) {
)} + {isShared && ( +
+ + {isStale ? ( + Update available + ) : ( + Shared + )} + {isStale && } +
+ )} + {/* Output handle on RIGHT */} ; metrics?: NodeMetricsData; disabled?: boolean; + sharedComponentId?: string | null; + sharedComponentVersion?: number | null; + sharedComponentLatestVersion?: number | null; + sharedComponentName?: string | null; }; type TransformNodeType = Node; @@ -27,13 +32,18 @@ function TransformNodeComponent({ selected, }: NodeProps) { const { componentDef, componentKey, metrics, disabled } = data; + const isShared = !!data.sharedComponentId; + const isStale = isShared && data.sharedComponentLatestVersion != null && + (data.sharedComponentVersion ?? 0) < data.sharedComponentLatestVersion; const Icon = useMemo(() => getIcon(componentDef.icon), [componentDef.icon]); return (
@@ -77,6 +87,18 @@ function TransformNodeComponent({
)} + {isShared && ( +
+ + {isStale ? ( + Update available + ) : ( + Shared + )} + {isStale && } +
+ )} + {/* Output handle on RIGHT */} Date: Tue, 10 Mar 2026 11:17:17 +0000 Subject: [PATCH 06/18] feat: add Shared tab to component palette with drag-to-add --- src/components/flow/component-palette.tsx | 158 ++++++++++++++++++++-- src/components/flow/flow-canvas.tsx | 39 ++++++ 2 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/components/flow/component-palette.tsx b/src/components/flow/component-palette.tsx index 5cebc6fc..5e40f174 100644 --- a/src/components/flow/component-palette.tsx +++ b/src/components/flow/component-palette.tsx @@ -1,13 +1,16 @@ "use client"; import { useMemo, useState } from "react"; -import { ChevronDown, ChevronRight, Search, PackageOpen } from "lucide-react"; +import { ChevronDown, ChevronRight, Search, PackageOpen, Link2 as LinkIcon } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { VECTOR_CATALOG } from "@/lib/vector/catalog"; import type { VectorComponentDef } from "@/lib/vector/types"; import { getIcon } from "./node-icon"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useEnvironmentStore } from "@/stores/environment-store"; const kindMeta: Record< VectorComponentDef["kind"], @@ -175,6 +178,17 @@ function CollapsibleSection({ export function ComponentPalette() { const [search, setSearch] = useState(""); + const [activeTab, setActiveTab] = useState<"catalog" | "shared">("catalog"); + const trpc = useTRPC(); + const { selectedEnvironmentId } = useEnvironmentStore(); + + const sharedComponentsQuery = useQuery( + trpc.sharedComponent.list.queryOptions( + { environmentId: selectedEnvironmentId! }, + { enabled: !!selectedEnvironmentId } + ) + ); + const sharedComponents = sharedComponentsQuery.data ?? []; const filtered = useMemo(() => { if (!search.trim()) return VECTOR_CATALOG; @@ -189,6 +203,16 @@ export function ComponentPalette() { ); }, [search]); + const filteredShared = useMemo(() => { + if (!search.trim()) return sharedComponents; + const term = search.toLowerCase().trim(); + return sharedComponents.filter( + (sc) => + sc.name.toLowerCase().includes(term) || + sc.componentType.toLowerCase().includes(term) + ); + }, [search, sharedComponents]); + const sources = useMemo( () => filtered.filter((d) => d.kind === "source"), [filtered] @@ -217,22 +241,126 @@ export function ComponentPalette() { + {/* Tab switcher */} +
+ + +
+ {/* Component list */}
-
- - - - - {filtered.length === 0 && ( -
- -

- No components match your search. -

-
- )} -
+ {activeTab === "catalog" && ( +
+ + + + + {filtered.length === 0 && ( +
+ +

+ No components match your search. +

+
+ )} +
+ )} + + {activeTab === "shared" && ( +
+ {filteredShared.length === 0 ? ( +
+ +

+ {search.trim() + ? "No shared components match your search." + : "No shared components in this environment."} +

+
+ ) : ( + filteredShared.map((sc) => { + const kindKey = sc.kind.toLowerCase() as VectorComponentDef["kind"]; + const meta = kindMeta[kindKey] ?? kindMeta.transform; + const Icon = getIcon( + VECTOR_CATALOG.find((d) => d.type === sc.componentType)?.icon + ); + return ( +
{ + e.dataTransfer.setData( + "application/vectorflow-component", + `${sc.kind.toLowerCase()}:${sc.componentType}` + ); + e.dataTransfer.setData( + "application/vectorflow-shared-component-id", + sc.id + ); + e.dataTransfer.setData( + "application/vectorflow-shared-component-data", + JSON.stringify(sc) + ); + e.dataTransfer.effectAllowed = "move"; + }} + className={cn( + "flex cursor-grab items-start gap-3 rounded-md border border-l-[3px] bg-card px-3 py-2.5 transition-colors hover:bg-accent active:cursor-grabbing", + meta.borderClass + )} + > +
+ +
+
+
+ + {sc.name} + + +
+
+ + {sc.componentType} + + {sc.linkedPipelineCount > 0 && ( + + {sc.linkedPipelineCount} pipeline{sc.linkedPipelineCount !== 1 ? "s" : ""} + + )} +
+
+
+ ); + }) + )} +
+ )}
); diff --git a/src/components/flow/flow-canvas.tsx b/src/components/flow/flow-canvas.tsx index c2cae534..7b4975da 100644 --- a/src/components/flow/flow-canvas.tsx +++ b/src/components/flow/flow-canvas.tsx @@ -114,6 +114,45 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { }); addNode(componentDef, position); + + // If this is a shared component drop, patch the newly added node's data + const sharedComponentData = event.dataTransfer.getData( + "application/vectorflow-shared-component-data" + ); + if (sharedComponentData) { + try { + const sc = JSON.parse(sharedComponentData) as { + id: string; + name: string; + version: number; + config: Record; + }; + // The newly added node is always last in the nodes array + const nodes = useFlowStore.getState().nodes; + const newNode = nodes[nodes.length - 1]; + if (newNode) { + useFlowStore.setState((state) => ({ + nodes: state.nodes.map((n) => + n.id === newNode.id + ? { + ...n, + data: { + ...n.data, + config: sc.config, + sharedComponentId: sc.id, + sharedComponentVersion: sc.version, + sharedComponentName: sc.name, + sharedComponentLatestVersion: sc.version, + }, + } + : n + ), + })); + } + } catch { + // Malformed shared component data — ignore, node was already added without link + } + } }, [reactFlowInstance, addNode] ); From 758ebb42b919d09eb9f097f59e185af1af505cc0 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Tue, 10 Mar 2026 11:22:13 +0000 Subject: [PATCH 07/18] feat: add shared component display, update banner, and save-as-shared dialog --- src/components/flow/detail-panel.tsx | 101 +++++++++++++++- src/components/flow/flow-canvas.tsx | 14 +++ src/components/flow/node-context-menu.tsx | 12 +- .../flow/save-shared-component-dialog.tsx | 112 ++++++++++++++++++ 4 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 src/components/flow/save-shared-component-dialog.tsx diff --git a/src/components/flow/detail-panel.tsx b/src/components/flow/detail-panel.tsx index 67f1219a..91434794 100644 --- a/src/components/flow/detail-panel.tsx +++ b/src/components/flow/detail-panel.tsx @@ -1,7 +1,10 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { Copy, Trash2, Lock, Info, MousePointerClick, Book } from "lucide-react"; +import { Copy, Trash2, Lock, Info, MousePointerClick, Book, Link2 as LinkIcon, Unlink, AlertTriangle } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { toast } from "sonner"; import { useFlowStore } from "@/stores/flow-store"; import { SchemaForm } from "@/components/config-forms/schema-form"; import { VrlEditor } from "@/components/vrl-editor/vrl-editor"; @@ -122,10 +125,46 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { const toggleNodeDisabled = useFlowStore((s) => s.toggleNodeDisabled); const removeNode = useFlowStore((s) => s.removeNode); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const selectedNode = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : null; + const isShared = !!selectedNode?.data.sharedComponentId; + const isStale = isShared && + selectedNode?.data.sharedComponentLatestVersion != null && + (selectedNode?.data.sharedComponentVersion ?? 0) < selectedNode?.data.sharedComponentLatestVersion; + + const acceptUpdateMutation = useMutation( + trpc.sharedComponent.acceptUpdate.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey({ id: pipelineId }) }); + toast.success("Component updated to latest version"); + }, + }) + ); + + const unlinkMutation = useMutation( + trpc.sharedComponent.unlink.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey({ id: pipelineId }) }); + toast.success("Component unlinked"); + }, + }) + ); + + const handleAcceptUpdate = () => { + if (!selectedNodeId) return; + acceptUpdateMutation.mutate({ nodeId: selectedNodeId, pipelineId }); + }; + + const handleUnlink = () => { + if (!selectedNodeId) return; + unlinkMutation.mutate({ nodeId: selectedNodeId, pipelineId }); + }; + const storeKey = (selectedNode?.data as { componentKey?: string })?.componentKey ?? ""; const [displayKey, setDisplayKey] = useState(storeKey); @@ -231,8 +270,14 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { config: Record; disabled?: boolean; isSystemLocked?: boolean; + sharedComponentId?: string; + sharedComponentName?: string; + sharedComponentVersion?: number; + sharedComponentLatestVersion?: number; }; + const isReadOnly = isSystemLocked || isShared; + return (
@@ -251,6 +296,41 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
)} + {/* ---- Shared component info banner ---- */} + {isShared && ( +
+ +
+ {selectedNode.data.sharedComponentName as string} +

+ This component is shared. Config is managed in the Library. +

+
+
+ )} + + {/* ---- Stale update banner ---- */} + {isStale && ( +
+ +
+ Update available +

+ This shared component has been updated since it was last synced. +

+
+ +
+
+
+ )} + {/* ---- Header ---- */} @@ -274,6 +354,17 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { > {componentDef.kind} + {isShared && !isSystemLocked && ( + + )} {isSystemLocked ? (
@@ -300,7 +391,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { id="component-key" value={displayKey} onChange={(e) => handleKeyChange(e.target.value)} - disabled={isSystemLocked} + disabled={isReadOnly} />

Letters, numbers, and underscores only (e.g. traefik_logs) @@ -316,7 +407,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) { onCheckedChange={() => { if (selectedNodeId) toggleNodeDisabled(selectedNodeId); }} - disabled={isSystemLocked} + disabled={isReadOnly} />

@@ -334,8 +425,8 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {

Configuration

- {isSystemLocked ? ( - /* Read-only config display for locked nodes */ + {isReadOnly ? ( + /* Read-only config display for locked/shared nodes */
{Object.entries(config).map(([key, value]) => (
diff --git a/src/components/flow/flow-canvas.tsx b/src/components/flow/flow-canvas.tsx index 7b4975da..192a042a 100644 --- a/src/components/flow/flow-canvas.tsx +++ b/src/components/flow/flow-canvas.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useRef, useState } from "react"; +import { useParams } from "next/navigation"; import { ReactFlow, Background, @@ -17,6 +18,7 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { nodeTypes } from "./node-types"; import { NodeContextMenu } from "./node-context-menu"; import { EdgeContextMenu } from "./edge-context-menu"; +import { SaveSharedComponentDialog } from "./save-shared-component-dialog"; import { findComponentDef } from "@/lib/vector/catalog"; import type { VectorComponentDef, DataType } from "@/lib/vector/types"; @@ -38,6 +40,8 @@ function hasOverlappingTypes(a: DataType[], b: DataType[]): boolean { export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { useKeyboardShortcuts({ onSave, onExport, onImport }); + const params = useParams<{ id: string }>(); + const pipelineId = params.id; const nodes = useFlowStore((s) => s.nodes); const edges = useFlowStore((s) => s.edges); const onNodesChange = useFlowStore((s) => s.onNodesChange); @@ -47,6 +51,7 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { const hasFitRef = useRef(false); const [contextMenu, setContextMenu] = useState<{ nodeId: string; x: number; y: number } | null>(null); const [edgeContextMenu, setEdgeContextMenu] = useState<{ edgeId: string; x: number; y: number } | null>(null); + const [saveSharedNodeId, setSaveSharedNodeId] = useState(null); const onNodeContextMenu: NodeMouseHandler = useCallback((event, node) => { event.preventDefault(); @@ -184,6 +189,7 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { x={contextMenu.x} y={contextMenu.y} onClose={() => setContextMenu(null)} + onSaveAsShared={(nodeId) => setSaveSharedNodeId(nodeId)} /> )} {edgeContextMenu && ( @@ -194,6 +200,14 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) { onClose={() => setEdgeContextMenu(null)} /> )} + {saveSharedNodeId && ( + !open && setSaveSharedNodeId(null)} + nodeId={saveSharedNodeId} + pipelineId={pipelineId} + /> + )}
); } diff --git a/src/components/flow/node-context-menu.tsx b/src/components/flow/node-context-menu.tsx index 4203c101..44e2013d 100644 --- a/src/components/flow/node-context-menu.tsx +++ b/src/components/flow/node-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; -import { Copy, ClipboardPaste, Trash2, CopyPlus } from "lucide-react"; +import { Copy, ClipboardPaste, Trash2, CopyPlus, Share2 } from "lucide-react"; import { useFlowStore } from "@/stores/flow-store"; interface NodeContextMenuProps { @@ -9,9 +9,10 @@ interface NodeContextMenuProps { x: number; y: number; onClose: () => void; + onSaveAsShared?: (nodeId: string) => void; } -export function NodeContextMenu({ nodeId, x, y, onClose }: NodeContextMenuProps) { +export function NodeContextMenu({ nodeId, x, y, onClose, onSaveAsShared }: NodeContextMenuProps) { const duplicateNode = useFlowStore((s) => s.duplicateNode); const removeNode = useFlowStore((s) => s.removeNode); const selectedNodeIds = useFlowStore((s) => s.selectedNodeIds); @@ -22,6 +23,7 @@ export function NodeContextMenu({ nodeId, x, y, onClose }: NodeContextMenuProps) const targetNode = nodes.find((n) => n.id === nodeId); const isLocked = !!targetNode?.data?.isSystemLocked; + const isShared = !!targetNode?.data?.sharedComponentId; useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -63,6 +65,12 @@ export function NodeContextMenu({ nodeId, x, y, onClose }: NodeContextMenuProps) disabled: isLocked, onClick: () => { if (isLocked) return; duplicateNode(nodeId); onClose(); }, }] : []), + ...(!isMulti && !isLocked && !isShared && onSaveAsShared ? [{ + label: "Save as Shared Component", + icon: Share2, + shortcut: "", + onClick: () => { onSaveAsShared(nodeId); onClose(); }, + }] : []), { separator: true as const }, { label: isMulti ? `Delete ${multiCount} components` : "Delete", diff --git a/src/components/flow/save-shared-component-dialog.tsx b/src/components/flow/save-shared-component-dialog.tsx new file mode 100644 index 00000000..321c8558 --- /dev/null +++ b/src/components/flow/save-shared-component-dialog.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useEnvironmentStore } from "@/stores/environment-store"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { toast } from "sonner"; + +interface SaveSharedComponentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + nodeId: string; + pipelineId: string; +} + +export function SaveSharedComponentDialog({ + open, + onOpenChange, + nodeId, + pipelineId, +}: SaveSharedComponentDialogProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { selectedEnvironmentId } = useEnvironmentStore(); + + const createMutation = useMutation( + trpc.sharedComponent.createFromNode.mutationOptions({ + onSuccess: () => { + toast.success("Shared component created"); + queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey({ id: pipelineId }) }); + queryClient.invalidateQueries({ queryKey: trpc.sharedComponent.list.queryKey() }); + setName(""); + setDescription(""); + onOpenChange(false); + }, + onError: (error) => { + toast.error(error.message); + }, + }) + ); + + const handleSave = () => { + if (!selectedEnvironmentId) return; + createMutation.mutate({ + nodeId, + pipelineId, + name, + description: description || undefined, + environmentId: selectedEnvironmentId, + }); + }; + + return ( + + + + Save as Shared Component + + Create a reusable component that can be linked across pipelines in this environment. + Editing it later will notify all linked pipelines. + + +
+
+ + setName(e.target.value)} + /> +
+
+ +