diff --git a/docs/public/SUMMARY.md b/docs/public/SUMMARY.md
index c9b8ba59..0502a1e7 100644
--- a/docs/public/SUMMARY.md
+++ b/docs/public/SUMMARY.md
@@ -19,6 +19,7 @@
* [Fleet Management](user-guide/fleet.md)
* [Alerts](user-guide/alerts.md)
* [Templates](user-guide/templates.md)
+* [Shared Components](user-guide/shared-components.md)
## Operations
diff --git a/docs/public/user-guide/pipeline-editor.md b/docs/public/user-guide/pipeline-editor.md
index 00569111..1dfe6fc5 100644
--- a/docs/public/user-guide/pipeline-editor.md
+++ b/docs/public/user-guide/pipeline-editor.md
@@ -17,20 +17,19 @@ The editor is divided into four main areas:
## Component palette
-The left sidebar lists every available Vector component, grouped into three sections:
+The left sidebar has two tabs:
-- **Sources** -- Components that ingest data into the pipeline.
-- **Transforms** -- Components that process, filter, or reshape data in flight.
-- **Sinks** -- Components that send data to downstream destinations.
+- **Catalog** -- Lists every available Vector component, grouped by kind (Sources, Transforms, Sinks). Each section can be collapsed and is further organized by category (e.g. "Cloud Platform", "Aggregating", "Messaging").
+- **Shared** -- Lists [shared components](shared-components.md) available in the current environment. Filter by kind using the All / Source / Transform / Sink buttons.
-Each section can be collapsed. When a section contains many components, they are further organized by category (e.g. "Cloud Platform", "Aggregating", "Messaging").
-
-Use the **search bar** at the top of the palette to filter components by name, type, description, or category.
+Use the **search bar** at the top of the palette to filter components by name, type, description, or category. The search applies to whichever tab is active.
### Adding a component
Drag a component from the palette and drop it onto the canvas. A new node appears at the drop position, pre-configured with sensible defaults. You can also right-click the canvas to paste previously copied nodes.
+Dragging a shared component from the **Shared** tab creates a **linked node** -- the node's configuration is pre-filled from the shared component and pinned to its current version. See [Shared Components](shared-components.md) for details on linked node behavior.
+
## Canvas
The canvas is where your pipeline takes visual shape. Each component is represented as a **node**, and data flow between components is represented as **edges** (connections).
@@ -47,7 +46,7 @@ The canvas is where your pipeline takes visual shape. Each component is represen
### Context menus
-- **Right-click a node** -- Opens a menu with Copy, Paste, Duplicate, and Delete actions.
+- **Right-click a node** -- Opens a menu with Copy, Paste, Duplicate, Delete, and **Save as Shared Component** actions. The shared component option extracts the node's configuration into a reusable [shared component](shared-components.md) and links the node to it.
- **Right-click an edge** -- Opens a menu with a Delete connection action.
## Node types
@@ -116,6 +115,15 @@ The **Config** tab shows:
- **Configuration form** -- Auto-generated form fields based on the component's configuration schema. Required fields are marked, and each field has contextual help.
- **Secret picker** -- Sensitive fields (passwords, API keys, tokens) display a secret picker instead of a text input. You must select an existing secret or create a new one inline -- plaintext values cannot be entered directly into sensitive fields. See [Security](../operations/security.md#sensitive-fields) for details.
+### Linked shared components
+
+When a node is linked to a [shared component](shared-components.md), the detail panel shows additional elements:
+
+- A **purple banner** with the shared component name and an "Open in Library" link.
+- **Read-only configuration** -- All config fields are disabled. Edits must be made on the shared component's Library page.
+- An **Unlink** button to convert the node back to a regular editable component (the current config snapshot is preserved).
+- When the shared component has been updated since the node was last synced, an **amber update banner** appears with an "Accept update" button to pull in the latest configuration.
+
### VRL editor for transforms
For **remap**, **filter**, and **route** transforms, the detail panel includes an integrated VRL editor (powered by Monaco) instead of a plain text field. The VRL editor provides:
diff --git a/docs/public/user-guide/pipelines.md b/docs/public/user-guide/pipelines.md
index 30df9301..8dc23fc3 100644
--- a/docs/public/user-guide/pipelines.md
+++ b/docs/public/user-guide/pipelines.md
@@ -29,6 +29,7 @@ A pipeline moves through several states during its lifecycle:
- **Stopped** -- The pipeline is deployed but all agent nodes have stopped processing it.
- **Crashed** -- One or more agent nodes report that the pipeline has crashed. Check the pipeline logs for details.
- **Pending deploy** -- Shown as an additional badge when the saved configuration differs from what is currently deployed. Deploy the pipeline to push the latest changes.
+- **Updates available** -- Shown as an amber badge when the pipeline contains nodes linked to [shared components](shared-components.md) that have been updated. Hover over the badge to see which shared components have pending updates. Open the pipeline editor to review and accept the changes.
- **Pending Approval** -- Shown when an editor has submitted a deploy request that is waiting for admin approval. See [Deploy approval workflows](#deploy-approval-workflows).
## Creating a pipeline
diff --git a/docs/public/user-guide/shared-components.md b/docs/public/user-guide/shared-components.md
new file mode 100644
index 00000000..fe30ccd6
--- /dev/null
+++ b/docs/public/user-guide/shared-components.md
@@ -0,0 +1,165 @@
+# Shared Components
+
+Shared components are reusable, environment-scoped pipeline component configurations that can be linked into multiple pipelines. Edit a shared component in one place and every linked pipeline sees the update -- no more hunting through dozens of pipelines to change an endpoint.
+
+## How shared components work
+
+A shared component stores a canonical configuration for a specific component type (e.g., an Elasticsearch sink with your production cluster settings). When you link a shared component into a pipeline, the pipeline node receives a snapshot of that configuration and pins to a specific version.
+
+When someone edits the shared component, its version number increments. Linked pipelines don't change automatically -- instead, they surface an **Update available** indicator at three levels:
+
+- **Pipeline list** -- An amber "Updates available" badge on the pipeline row.
+- **Canvas** -- The linked node's footer changes to "Update available" with an amber dot.
+- **Detail panel** -- A banner with an "Accept update" button.
+
+Pipeline owners review and explicitly accept updates before redeploying. This review-first model prevents surprise breakage across production pipelines.
+
+{% hint style="info" %}
+Shared components are scoped to a single environment. A production Kafka config is separate from a staging one. If you need the same component in multiple environments, create a shared component in each.
+{% endhint %}
+
+## Finding shared components
+
+Shared components are managed in the **Library** page, accessible from the sidebar. The Library has two sections:
+
+- **Templates** -- Reusable pipeline blueprints (see [Templates](templates.md)).
+- **Shared Components** -- Reusable component configurations (this page).
+
+### Shared components list
+
+The list shows all shared components in the currently selected environment:
+
+| Column | Description |
+|--------|-------------|
+| **Name** | The shared component name. Click to open the detail page. |
+| **Type** | The Vector component type (e.g., `elasticsearch`, `kafka`). |
+| **Kind** | A badge indicating Source, Transform, or Sink. |
+| **Linked Pipelines** | Number of distinct pipelines using this shared component. |
+| **Version** | Current version number (increments on each config change). |
+| **Last Updated** | When the configuration was last modified. |
+
+## Creating a shared component
+
+There are two ways to create a shared component.
+
+### From the Library page
+
+{% stepper %}
+{% step %}
+### Open the Library
+Navigate to **Library > Shared Components** in the sidebar and click **New Shared Component**.
+{% endstep %}
+{% step %}
+### Choose a component type
+Select the component type from the catalog grid (e.g., Elasticsearch, Kafka, S3).
+{% endstep %}
+{% step %}
+### Configure
+Enter a **Name** and optional **Description**, then fill in the component configuration using the form editor. Click **Create** to save.
+{% endstep %}
+{% endstepper %}
+
+### From the pipeline editor
+
+{% stepper %}
+{% step %}
+### Right-click a node
+In the pipeline editor, right-click any configured node on the canvas.
+{% endstep %}
+{% step %}
+### Select Save as Shared Component
+Choose **Save as Shared Component** from the context menu. A dialog opens.
+{% endstep %}
+{% step %}
+### Name and save
+Enter a **Name** and optional **Description**, then click **Save**. The node's current configuration becomes the shared component, and the node is automatically linked to it.
+{% endstep %}
+{% endstepper %}
+
+## Using shared components in pipelines
+
+### Adding from the component palette
+
+The component palette (left sidebar in the pipeline editor) has two tabs:
+
+- **Catalog** -- The standard list of all Vector component types.
+- **Shared** -- Shared components available in the current environment.
+
+The Shared tab includes kind filter buttons (All, Source, Transform, Sink) and supports search by name or component type. Drag a shared component from the list onto the canvas to create a linked node with the shared configuration pre-filled.
+
+### Linked node appearance
+
+Linked nodes are visually distinct on the canvas:
+
+- **Purple accent border** -- A purple border and subtle glow distinguish linked nodes from regular ones.
+- **Footer** -- Shows "Shared" with a link icon.
+- **Stale indicator** -- When an update is available, the footer changes to "Update available" in amber, and a small amber dot appears on the node corner.
+
+### Detail panel for linked nodes
+
+When you select a linked node, the detail panel shows:
+
+- A **purple banner** with the shared component name and an "Open in Library" link.
+- **Read-only configuration** -- Config fields are disabled because the configuration is managed centrally. To edit, go to the Library page.
+- An **Unlink** button in the header to convert back to a regular editable node.
+
+{% hint style="warning" %}
+Unlinking a node preserves its current configuration but breaks the connection to the shared component. Future updates to the shared component will not reach this node.
+{% endhint %}
+
+## Editing a shared component
+
+{% stepper %}
+{% step %}
+### Open the detail page
+Navigate to **Library > Shared Components** and click the shared component you want to edit.
+{% endstep %}
+{% step %}
+### Modify the configuration
+Update the configuration using the form editor. You can also edit the name and description.
+{% endstep %}
+{% step %}
+### Save
+Click **Save**. The version number increments automatically, and all linked pipelines will see an "Update available" indicator.
+{% endstep %}
+{% endstepper %}
+
+The detail page also shows a **Linked Pipelines** section listing every pipeline that uses this shared component, with a badge indicating whether each is up to date or has a pending update.
+
+## Accepting updates
+
+When a shared component is updated, linked pipeline nodes become stale. Pipeline owners decide when to accept the new configuration.
+
+### Single node
+
+Select the stale node in the pipeline editor. The detail panel shows an amber banner with an **Accept update** button. Click it to pull in the latest configuration and update the pinned version.
+
+### All nodes in a pipeline
+
+If a pipeline has multiple stale linked nodes, you can accept all updates at once from the pipeline's toolbar or through the API.
+
+{% hint style="info" %}
+Accepting an update modifies the pipeline's saved configuration but does not automatically redeploy. You still need to deploy the pipeline to push the changes to agents.
+{% endhint %}
+
+## Deleting a shared component
+
+On the shared component detail page, scroll to the **Danger Zone** section and click **Delete**. A confirmation dialog explains what will happen:
+
+- All linked pipeline nodes are **unlinked** -- their configurations remain intact, but they become regular standalone nodes.
+- The shared component is permanently removed from the Library.
+
+{% hint style="danger" %}
+Deleting a shared component cannot be undone. Linked nodes keep their last-synced configuration but lose the ability to receive future updates.
+{% endhint %}
+
+## Edge cases
+
+| Scenario | Behavior |
+|----------|----------|
+| **Cloning a pipeline** | Linked nodes in the clone remain linked to the same shared components. |
+| **Promoting a pipeline** | Shared component links are stripped during cross-environment promotion. Nodes retain their config snapshots as regular standalone nodes. |
+| **Saving as template** | Templates are portable across environments, so shared component links are stripped. Template nodes store config snapshots only. |
+| **Discarding changes** | Reverting to a previous version restores the shared component links as they were at that version. |
+| **Copy/paste nodes** | Pasted nodes retain their link to the same shared component. |
+| **GitOps / YAML export** | Generated YAML uses the resolved config snapshot. Importing from YAML creates regular unlinked nodes. |
diff --git a/docs/public/user-guide/templates.md b/docs/public/user-guide/templates.md
index 7aa23d46..49b72e4d 100644
--- a/docs/public/user-guide/templates.md
+++ b/docs/public/user-guide/templates.md
@@ -4,7 +4,7 @@ Templates are reusable pipeline blueprints that capture a complete pipeline grap
## Template library
-The **Templates** page displays all templates available to your team as a card grid. Each card shows:
+Templates are found in the **Library** page (accessible from the sidebar), under the **Templates** tab. The page displays all templates available to your team as a card grid. Each card shows:
- **Name** -- The template name.
- **Category** -- A label such as Logging, Metrics, Archival, Streaming, or a custom category you define.
@@ -44,8 +44,8 @@ Click **Save Template**. The template now appears in the template library for al
{% stepper %}
{% step %}
-### Open the Templates page
-Navigate to **Templates** in the sidebar. Make sure you have an environment selected in the header.
+### Open the Library
+Navigate to **Library > Templates** in the sidebar. Make sure you have an environment selected in the header.
{% endstep %}
{% step %}
### Choose a template
@@ -72,6 +72,7 @@ Templates are designed to be portable across environments, so they intentionally
- **Secrets** -- Secret values (API keys, passwords, tokens) are never stored in templates. You must configure secrets in the target environment after creating a pipeline from a template.
- **Certificates** -- TLS certificates are environment-specific and are not included.
- **Environment bindings** -- Templates are not tied to any specific environment. They can be used in any environment within the team.
+- **Shared component links** -- Nodes that are linked to [shared components](shared-components.md) have their links stripped when saved as a template. The node's configuration snapshot is preserved, but the template node becomes a standalone component.
- **Deployment state** -- Pipelines created from templates start as drafts. You deploy them when ready.
{% hint style="warning" %}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 01738443..19eb7b7f 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())
}
@@ -370,6 +371,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 {
@@ -387,6 +393,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
diff --git a/src/app/(dashboard)/library/layout.tsx b/src/app/(dashboard)/library/layout.tsx
new file mode 100644
index 00000000..575b6201
--- /dev/null
+++ b/src/app/(dashboard)/library/layout.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { usePathname, useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { FileText, Link2 } from "lucide-react";
+
+const libraryNavItems = [
+ { title: "Templates", href: "/library/templates", icon: FileText },
+ { title: "Shared Components", href: "/library/shared-components", icon: Link2 },
+];
+
+export default function LibraryLayout({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const router = useRouter();
+
+ return (
+
+ );
+}
diff --git a/src/app/(dashboard)/library/page.tsx b/src/app/(dashboard)/library/page.tsx
new file mode 100644
index 00000000..dae1da6c
--- /dev/null
+++ b/src/app/(dashboard)/library/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function LibraryPage() {
+ redirect("/library/templates");
+}
diff --git a/src/app/(dashboard)/library/shared-components/[id]/page.tsx b/src/app/(dashboard)/library/shared-components/[id]/page.tsx
new file mode 100644
index 00000000..30cf78e5
--- /dev/null
+++ b/src/app/(dashboard)/library/shared-components/[id]/page.tsx
@@ -0,0 +1,388 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { useEnvironmentStore } from "@/stores/environment-store";
+import { findComponentDef } from "@/lib/vector/catalog";
+import { toast } from "sonner";
+import Link from "next/link";
+import {
+ ArrowLeft,
+ ExternalLink,
+ Link2,
+ Loader2,
+ Save,
+ Trash2,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { SchemaForm } from "@/components/config-forms/schema-form";
+
+/* ------------------------------------------------------------------ */
+/* Kind badge styling */
+/* ------------------------------------------------------------------ */
+
+const kindVariant: Record = {
+ SOURCE:
+ "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
+ TRANSFORM:
+ "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
+ SINK: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
+};
+
+/* ------------------------------------------------------------------ */
+/* Page Component */
+/* ------------------------------------------------------------------ */
+
+export default function SharedComponentDetailPage() {
+ const params = useParams<{ id: string }>();
+ const router = useRouter();
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const selectedEnvironmentId = useEnvironmentStore(
+ (s) => s.selectedEnvironmentId,
+ );
+
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [config, setConfig] = useState>({});
+ const [hasChanges, setHasChanges] = useState(false);
+ const [initialized, setInitialized] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const componentQuery = useQuery(
+ trpc.sharedComponent.getById.queryOptions(
+ { id: params.id, environmentId: selectedEnvironmentId! },
+ {
+ enabled: !!selectedEnvironmentId && !!params.id,
+ },
+ ),
+ );
+
+ const sc = componentQuery.data;
+
+ // Initialize form state from fetched data
+ if (sc && !initialized) {
+ setName(sc.name);
+ setDescription(sc.description ?? "");
+ setConfig((sc.config as Record) ?? {});
+ setInitialized(true);
+ }
+
+ const componentDef = sc
+ ? findComponentDef(sc.componentType, sc.kind.toLowerCase() as "source" | "transform" | "sink")
+ : undefined;
+
+ const handleNameChange = useCallback((val: string) => {
+ setName(val);
+ setHasChanges(true);
+ }, []);
+
+ const handleDescriptionChange = useCallback((val: string) => {
+ setDescription(val);
+ setHasChanges(true);
+ }, []);
+
+ const handleConfigChange = useCallback((vals: Record) => {
+ setConfig(vals);
+ setHasChanges(true);
+ }, []);
+
+ const updateMutation = useMutation(
+ trpc.sharedComponent.update.mutationOptions({
+ onSuccess: () => {
+ toast.success("Shared component updated");
+ setHasChanges(false);
+ queryClient.invalidateQueries({
+ queryKey: trpc.sharedComponent.getById.queryKey(),
+ });
+ queryClient.invalidateQueries({
+ queryKey: trpc.sharedComponent.list.queryKey(),
+ });
+ },
+ onError: (err) => {
+ toast.error(err.message);
+ },
+ }),
+ );
+
+ const deleteMutation = useMutation(
+ trpc.sharedComponent.delete.mutationOptions({
+ onSuccess: () => {
+ toast.success("Shared component deleted");
+ router.push("/library/shared-components");
+ },
+ onError: (err) => {
+ toast.error(err.message);
+ },
+ }),
+ );
+
+ const handleSave = () => {
+ if (!sc || !selectedEnvironmentId) return;
+ updateMutation.mutate({
+ id: sc.id,
+ environmentId: selectedEnvironmentId,
+ name,
+ description: description || null,
+ config,
+ });
+ };
+
+ const handleDelete = () => {
+ if (!sc || !selectedEnvironmentId) return;
+ deleteMutation.mutate({ id: sc.id, environmentId: selectedEnvironmentId });
+ };
+
+ if (!selectedEnvironmentId) {
+ return (
+
+
+ Select an environment from the header to view this component
+
+
+ );
+ }
+
+ if (componentQuery.isLoading) {
+ return (
+
+ );
+ }
+
+ if (!sc) {
+ return (
+
+
+ Shared component not found
+
+
+ );
+ }
+
+ return (
+
+ {/* Back + Header */}
+
+
+
+ Back to Shared Components
+
+
+
+
+
+
{sc.name}
+
+ {sc.kind}
+
+ v{sc.version}
+
+
+ {sc.componentType} · {sc.linkedPipelines.length} linked{" "}
+ {sc.linkedPipelines.length === 1 ? "pipeline" : "pipelines"}
+
+
+
+ {updateMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {updateMutation.isPending ? "Saving..." : "Save"}
+
+
+
+
+ {/* Content */}
+
+ {/* Left column */}
+
+ {/* Details card */}
+
+
+ Details
+
+
+
+ Name
+ handleNameChange(e.target.value)}
+ />
+
+
+ Description
+
+
+
+
+ {/* Config card */}
+
+
+ Configuration
+
+ Edit the component configuration below.
+
+
+
+ {componentDef ? (
+ >; required?: string[] }}
+ values={config}
+ onChange={handleConfigChange}
+ />
+ ) : (
+
+ Component definition not found for type "{sc.componentType}".
+
+ )}
+
+
+
+
+ {/* Right column */}
+
+ {/* Linked pipelines card */}
+
+
+
+
+ Linked Pipelines
+
+
+ Pipelines using this shared component
+
+
+
+ {sc.linkedPipelines.length === 0 ? (
+
+ No pipelines are linked to this component.
+
+ ) : (
+
+ {sc.linkedPipelines.map((pipeline) => (
+
+
+
+ {pipeline.name}
+
+ {pipeline.isStale ? (
+
+ Update pending
+
+ ) : (
+
+ Up to date
+
+ )}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {/* Danger zone */}
+
+
+ Danger Zone
+
+ Permanently delete this shared component. All linked pipeline
+ nodes will be unlinked.
+
+
+
+ setDeleteOpen(true)}
+ disabled={deleteMutation.isPending}
+ >
+
+ Delete Component
+
+
+
+
+
+
+
+ Permanently delete{" "}
+ {sc.name} ? All linked pipeline
+ nodes will be unlinked. This action cannot be undone.
+ >
+ }
+ confirmLabel="Delete"
+ isPending={deleteMutation.isPending}
+ pendingLabel="Deleting..."
+ onConfirm={handleDelete}
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/library/shared-components/new/page.tsx b/src/app/(dashboard)/library/shared-components/new/page.tsx
new file mode 100644
index 00000000..c4ecfb56
--- /dev/null
+++ b/src/app/(dashboard)/library/shared-components/new/page.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useMutation } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { useEnvironmentStore } from "@/stores/environment-store";
+import { VECTOR_CATALOG } from "@/lib/vector/catalog";
+import { toast } from "sonner";
+import Link from "next/link";
+import { ArrowLeft, Loader2, Plus, Search } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { SchemaForm } from "@/components/config-forms/schema-form";
+
+import type { VectorComponentDef } from "@/lib/vector/types";
+
+/* ------------------------------------------------------------------ */
+/* Kind badge styling */
+/* ------------------------------------------------------------------ */
+
+const kindVariant: Record = {
+ source:
+ "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
+ transform:
+ "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
+ sink: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
+};
+
+/* ------------------------------------------------------------------ */
+/* Page Component */
+/* ------------------------------------------------------------------ */
+
+type Step = "select" | "configure";
+
+export default function NewSharedComponentPage() {
+ const router = useRouter();
+ const trpc = useTRPC();
+ const selectedEnvironmentId = useEnvironmentStore(
+ (s) => s.selectedEnvironmentId,
+ );
+
+ const [step, setStep] = useState("select");
+ const [search, setSearch] = useState("");
+ const [selectedComponent, setSelectedComponent] =
+ useState(null);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [config, setConfig] = useState>({});
+
+ const filteredCatalog = useMemo(() => {
+ if (!search) return VECTOR_CATALOG;
+ const q = search.toLowerCase();
+ return VECTOR_CATALOG.filter(
+ (c) =>
+ c.displayName.toLowerCase().includes(q) ||
+ c.type.toLowerCase().includes(q) ||
+ c.kind.toLowerCase().includes(q) ||
+ c.description.toLowerCase().includes(q),
+ );
+ }, [search]);
+
+ const createMutation = useMutation(
+ trpc.sharedComponent.create.mutationOptions({
+ onSuccess: (sc) => {
+ toast.success("Shared component created");
+ router.push(`/library/shared-components/${sc.id}`);
+ },
+ onError: (err) => {
+ toast.error(err.message);
+ },
+ }),
+ );
+
+ const handleSelectComponent = (comp: VectorComponentDef) => {
+ setSelectedComponent(comp);
+ setName(comp.displayName);
+ setConfig({});
+ setStep("configure");
+ };
+
+ const handleCreate = () => {
+ if (!selectedComponent || !selectedEnvironmentId || !name.trim()) return;
+ createMutation.mutate({
+ environmentId: selectedEnvironmentId,
+ name: name.trim(),
+ description: description || undefined,
+ componentType: selectedComponent.type,
+ kind: selectedComponent.kind.toUpperCase() as "SOURCE" | "TRANSFORM" | "SINK",
+ config,
+ });
+ };
+
+ if (!selectedEnvironmentId) {
+ return (
+
+
+
+ Back to Shared Components
+
+
+ Select an environment from the header to create a shared component
+
+
+ );
+ }
+
+ return (
+
+ {/* Back link */}
+ {step === "select" ? (
+
+
+ Back to Shared Components
+
+ ) : (
+
setStep("select")}
+ className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
+ >
+
+ Back to Component Selection
+
+ )}
+
+ {step === "select" && (
+ <>
+
+
+ New Shared Component
+
+
+ Choose a component type to create a reusable shared component.
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {/* Component grid */}
+
+ {filteredCatalog.map((comp) => (
+
handleSelectComponent(comp)}
+ >
+
+
+
+ {comp.displayName}
+
+
+ {comp.kind}
+
+
+
+
+
+ {comp.description}
+
+
+
+ ))}
+
+
+ {filteredCatalog.length === 0 && (
+
+
+ No components match your search.
+
+
+ )}
+ >
+ )}
+
+ {step === "configure" && selectedComponent && (
+ <>
+
+
+
+ New Shared Component
+
+
+ {selectedComponent.kind}
+
+
+
+ Configure your {selectedComponent.displayName} shared component.
+
+
+
+
+ {/* Details */}
+
+
+ Details
+
+
+
+ Name
+ setName(e.target.value)}
+ placeholder="Component name"
+ />
+
+
+ Description
+
+
+
+
+ {/* Config */}
+
+
+ Configuration
+
+ Configure the {selectedComponent.displayName} component.
+
+
+
+ >; required?: string[] }}
+ values={config}
+ onChange={setConfig}
+ />
+
+
+
+ {/* Create button */}
+
+
+ {createMutation.isPending ? (
+
+ ) : (
+
+ )}
+ {createMutation.isPending
+ ? "Creating..."
+ : "Create Shared Component"}
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/library/shared-components/page.tsx b/src/app/(dashboard)/library/shared-components/page.tsx
new file mode 100644
index 00000000..bffbeb71
--- /dev/null
+++ b/src/app/(dashboard)/library/shared-components/page.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { useEnvironmentStore } from "@/stores/environment-store";
+import { Link2, Plus, Search } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Skeleton } from "@/components/ui/skeleton";
+import { PageHeader } from "@/components/page-header";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function formatRelativeTime(date: Date | string | null | undefined): string {
+ if (!date) return "Never";
+ const d = typeof date === "string" ? new Date(date) : date;
+ const now = Date.now();
+ const diffMs = now - d.getTime();
+ const diffSec = Math.floor(diffMs / 1000);
+ if (diffSec < 60) return "Just now";
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return `${diffMin}m ago`;
+ const diffHr = Math.floor(diffMin / 60);
+ if (diffHr < 24) return `${diffHr}h ago`;
+ const diffDay = Math.floor(diffHr / 24);
+ return `${diffDay}d ago`;
+}
+
+/* ------------------------------------------------------------------ */
+/* Kind badge styling */
+/* ------------------------------------------------------------------ */
+
+const kindVariant: Record = {
+ SOURCE:
+ "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
+ TRANSFORM:
+ "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300",
+ SINK: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
+};
+
+/* ------------------------------------------------------------------ */
+/* Page Component */
+/* ------------------------------------------------------------------ */
+
+export default function SharedComponentsPage() {
+ const trpc = useTRPC();
+ const router = useRouter();
+ const selectedEnvironmentId = useEnvironmentStore(
+ (s) => s.selectedEnvironmentId,
+ );
+ const [search, setSearch] = useState("");
+
+ const componentsQuery = useQuery(
+ trpc.sharedComponent.list.queryOptions(
+ { environmentId: selectedEnvironmentId! },
+ { enabled: !!selectedEnvironmentId },
+ ),
+ );
+
+ const components = componentsQuery.data ?? [];
+
+ const filtered = components.filter((sc) => {
+ if (!search) return true;
+ const q = search.toLowerCase();
+ return (
+ sc.name.toLowerCase().includes(q) ||
+ sc.componentType.toLowerCase().includes(q)
+ );
+ });
+
+ if (!selectedEnvironmentId) {
+ return (
+
+
+
+ Select an environment from the header to view shared components
+
+
+ );
+ }
+
+ return (
+
+
router.push("/library/shared-components/new")}>
+
+ New Shared Component
+
+ }
+ />
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+ {componentsQuery.isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {components.length === 0
+ ? "No shared components yet. Create one to get started."
+ : "No components match your search."}
+
+
+ ) : (
+
+
+
+
+ Name
+ Type
+ Kind
+ Linked Pipelines
+ Version
+ Last Updated
+
+
+
+ {filtered.map((sc) => (
+ router.push(`/library/shared-components/${sc.id}`)}
+ >
+
+
+
+ {sc.name}
+
+
+
+ {sc.componentType}
+
+
+
+ {sc.kind}
+
+
+ {sc.linkedPipelineCount}
+ v{sc.version}
+
+ {formatRelativeTime(sc.updatedAt)}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/(dashboard)/library/templates/page.tsx b/src/app/(dashboard)/library/templates/page.tsx
new file mode 100644
index 00000000..54b07227
--- /dev/null
+++ b/src/app/(dashboard)/library/templates/page.tsx
@@ -0,0 +1,279 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTRPC } from "@/trpc/client";
+import { useEnvironmentStore } from "@/stores/environment-store";
+import { generateId } from "@/lib/utils";
+import { useTeamStore } from "@/stores/team-store";
+import {
+ FileText,
+ Trash2,
+ ArrowRight,
+ Database,
+ Cloud,
+ Radio,
+ Cpu,
+ Terminal,
+ Play,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import { PageHeader } from "@/components/page-header";
+
+/* ------------------------------------------------------------------ */
+/* Category icon mapping */
+/* ------------------------------------------------------------------ */
+
+const categoryIcons: Record = {
+ "Getting Started": ,
+ Logging: ,
+ Archival: ,
+ Streaming: ,
+ Metrics: ,
+};
+
+const categoryColors: Record = {
+ "Getting Started":
+ "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
+ Logging:
+ "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
+ Archival:
+ "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
+ Streaming:
+ "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
+ Metrics:
+ "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300",
+};
+
+/* ------------------------------------------------------------------ */
+/* Page Component */
+/* ------------------------------------------------------------------ */
+
+export default function TemplatesPage() {
+ const trpc = useTRPC();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const selectedEnvironmentId = useEnvironmentStore(
+ (s) => s.selectedEnvironmentId,
+ );
+
+ const selectedTeamId = useTeamStore((s) => s.selectedTeamId);
+
+ // Fetch templates for the selected team
+ const templatesQuery = useQuery(
+ trpc.template.list.queryOptions(
+ { teamId: selectedTeamId! },
+ { enabled: !!selectedTeamId },
+ ),
+ );
+
+ const templates = templatesQuery.data ?? [];
+
+ // Create pipeline from template
+ const createPipelineMutation = useMutation(
+ trpc.pipeline.create.mutationOptions({
+ onSuccess: async (pipeline) => {
+ // Now load the template graph into the new pipeline
+ return pipeline;
+ },
+ }),
+ );
+
+ const saveGraphMutation = useMutation(
+ trpc.pipeline.saveGraph.mutationOptions({
+ onSuccess: (pipeline) => {
+ router.push(`/pipelines/${pipeline.id}`);
+ },
+ }),
+ );
+
+ const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
+
+ const deleteTemplateMutation = useMutation(
+ trpc.template.delete.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: trpc.template.list.queryKey() });
+ setDeleteConfirm(null);
+ },
+ }),
+ );
+
+ const handleUseTemplate = async (templateId: string) => {
+ if (!selectedEnvironmentId) return;
+
+ // Get the full template data
+ const template = await queryClient.fetchQuery(
+ trpc.template.get.queryOptions({ id: templateId }),
+ );
+
+ // Create a new pipeline
+ const pipeline = await createPipelineMutation.mutateAsync({
+ name: `${template.name} Pipeline`,
+ description: `Created from template: ${template.description}`,
+ environmentId: selectedEnvironmentId,
+ });
+
+ // Map template nodes to pipeline nodes
+ const templateNodes = template.nodes as Array<{
+ id: string;
+ componentType: string;
+ componentKey: string;
+ kind: string;
+ config: Record;
+ positionX: number;
+ positionY: number;
+ }>;
+
+ const templateEdges = template.edges as Array<{
+ id: string;
+ sourceNodeId: string;
+ targetNodeId: string;
+ sourcePort?: string;
+ }>;
+
+ // Generate new IDs for nodes and update edge references
+ const idMap = new Map();
+ const pipelineNodes = templateNodes.map((n) => {
+ const newId = generateId();
+ idMap.set(n.id, newId);
+ return {
+ id: newId,
+ componentKey: n.componentKey,
+ componentType: n.componentType,
+ kind: n.kind.toUpperCase() as "SOURCE" | "TRANSFORM" | "SINK",
+ config: n.config,
+ positionX: n.positionX,
+ positionY: n.positionY,
+ };
+ });
+
+ const pipelineEdges = templateEdges.map((e) => ({
+ id: generateId(),
+ sourceNodeId: idMap.get(e.sourceNodeId) ?? e.sourceNodeId,
+ targetNodeId: idMap.get(e.targetNodeId) ?? e.targetNodeId,
+ sourcePort: e.sourcePort,
+ }));
+
+ await saveGraphMutation.mutateAsync({
+ pipelineId: pipeline.id,
+ nodes: pipelineNodes,
+ edges: pipelineEdges,
+ });
+ };
+
+ const isLoading = templatesQuery.isLoading;
+ const isCreating =
+ createPipelineMutation.isPending || saveGraphMutation.isPending;
+
+ return (
+
+
+ {/* Environment notice */}
+ {!selectedEnvironmentId && (
+
+ Select an environment from the header to use templates
+
+ )}
+
+ {isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : templates.length === 0 ? (
+
+
+
+ No templates yet. Save a pipeline as a template to get started.
+
+
+ ) : (
+
+
+ {templates.map((template) => (
+
+
+
+
+ {template.name}
+
+
+ {categoryIcons[template.category] ?? null}
+
+ {template.category}
+
+
+
+
+ {template.description}
+
+
+
+
+
+
+ {template.nodeCount} nodes
+
+
+
+ {template.edgeCount} edges
+
+
+
+
+ handleUseTemplate(template.id)}
+ >
+ {isCreating ? "Creating..." : "Use Template"}
+
+ setDeleteConfirm({ id: template.id, name: template.name })}
+ disabled={deleteTemplateMutation.isPending}
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
!open && setDeleteConfirm(null)}
+ title="Delete template?"
+ description={<>Permanently delete {deleteConfirm?.name} ? This action cannot be undone.>}
+ confirmLabel="Delete"
+ isPending={deleteTemplateMutation.isPending}
+ pendingLabel="Deleting..."
+ onConfirm={() => {
+ if (!deleteConfirm) return;
+ deleteTemplateMutation.mutate({ id: deleteConfirm.id });
+ }}
+ />
+
+ );
+}
diff --git a/src/app/(dashboard)/pipelines/[id]/page.tsx b/src/app/(dashboard)/pipelines/[id]/page.tsx
index bf2d877e..45eba29f 100644
--- a/src/app/(dashboard)/pipelines/[id]/page.tsx
+++ b/src/app/(dashboard)/pipelines/[id]/page.tsx
@@ -63,6 +63,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) => {
@@ -86,6 +92,10 @@ function dbNodesToFlowNodes(
displayName: n.displayName ?? undefined,
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,
},
};
});
@@ -308,6 +318,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/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx
index 22ed3103..77434eca 100644
--- a/src/app/(dashboard)/pipelines/page.tsx
+++ b/src/app/(dashboard)/pipelines/page.tsx
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { toast } from "sonner";
-import { Plus, MoreHorizontal, Copy, Trash2, BarChart3, ArrowUpRight, Clock } from "lucide-react";
+import { Plus, MoreHorizontal, Copy, Trash2, BarChart3, ArrowUpRight, Clock, AlertTriangle } from "lucide-react";
import { useEnvironmentStore } from "@/stores/environment-store";
import { useTeamStore } from "@/stores/team-store";
@@ -323,6 +323,16 @@ export default function PipelinesPage() {
Pending Approval
)}
+ {pipeline.hasStaleComponents && (
+
+
+ Updates available
+
+ )}
{/* Health */}
diff --git a/src/app/(dashboard)/templates/page.tsx b/src/app/(dashboard)/templates/page.tsx
index 54b07227..1c5a5e6e 100644
--- a/src/app/(dashboard)/templates/page.tsx
+++ b/src/app/(dashboard)/templates/page.tsx
@@ -1,279 +1,5 @@
-"use client";
+import { redirect } from "next/navigation";
-import { useState } from "react";
-import { useRouter } from "next/navigation";
-import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { useTRPC } from "@/trpc/client";
-import { useEnvironmentStore } from "@/stores/environment-store";
-import { generateId } from "@/lib/utils";
-import { useTeamStore } from "@/stores/team-store";
-import {
- FileText,
- Trash2,
- ArrowRight,
- Database,
- Cloud,
- Radio,
- Cpu,
- Terminal,
- Play,
-} from "lucide-react";
-
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Skeleton } from "@/components/ui/skeleton";
-import { ConfirmDialog } from "@/components/confirm-dialog";
-import { PageHeader } from "@/components/page-header";
-
-/* ------------------------------------------------------------------ */
-/* Category icon mapping */
-/* ------------------------------------------------------------------ */
-
-const categoryIcons: Record = {
- "Getting Started": ,
- Logging: ,
- Archival: ,
- Streaming: ,
- Metrics: ,
-};
-
-const categoryColors: Record = {
- "Getting Started":
- "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
- Logging:
- "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
- Archival:
- "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300",
- Streaming:
- "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300",
- Metrics:
- "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300",
-};
-
-/* ------------------------------------------------------------------ */
-/* Page Component */
-/* ------------------------------------------------------------------ */
-
-export default function TemplatesPage() {
- const trpc = useTRPC();
- const router = useRouter();
- const queryClient = useQueryClient();
- const selectedEnvironmentId = useEnvironmentStore(
- (s) => s.selectedEnvironmentId,
- );
-
- const selectedTeamId = useTeamStore((s) => s.selectedTeamId);
-
- // Fetch templates for the selected team
- const templatesQuery = useQuery(
- trpc.template.list.queryOptions(
- { teamId: selectedTeamId! },
- { enabled: !!selectedTeamId },
- ),
- );
-
- const templates = templatesQuery.data ?? [];
-
- // Create pipeline from template
- const createPipelineMutation = useMutation(
- trpc.pipeline.create.mutationOptions({
- onSuccess: async (pipeline) => {
- // Now load the template graph into the new pipeline
- return pipeline;
- },
- }),
- );
-
- const saveGraphMutation = useMutation(
- trpc.pipeline.saveGraph.mutationOptions({
- onSuccess: (pipeline) => {
- router.push(`/pipelines/${pipeline.id}`);
- },
- }),
- );
-
- const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
-
- const deleteTemplateMutation = useMutation(
- trpc.template.delete.mutationOptions({
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: trpc.template.list.queryKey() });
- setDeleteConfirm(null);
- },
- }),
- );
-
- const handleUseTemplate = async (templateId: string) => {
- if (!selectedEnvironmentId) return;
-
- // Get the full template data
- const template = await queryClient.fetchQuery(
- trpc.template.get.queryOptions({ id: templateId }),
- );
-
- // Create a new pipeline
- const pipeline = await createPipelineMutation.mutateAsync({
- name: `${template.name} Pipeline`,
- description: `Created from template: ${template.description}`,
- environmentId: selectedEnvironmentId,
- });
-
- // Map template nodes to pipeline nodes
- const templateNodes = template.nodes as Array<{
- id: string;
- componentType: string;
- componentKey: string;
- kind: string;
- config: Record;
- positionX: number;
- positionY: number;
- }>;
-
- const templateEdges = template.edges as Array<{
- id: string;
- sourceNodeId: string;
- targetNodeId: string;
- sourcePort?: string;
- }>;
-
- // Generate new IDs for nodes and update edge references
- const idMap = new Map();
- const pipelineNodes = templateNodes.map((n) => {
- const newId = generateId();
- idMap.set(n.id, newId);
- return {
- id: newId,
- componentKey: n.componentKey,
- componentType: n.componentType,
- kind: n.kind.toUpperCase() as "SOURCE" | "TRANSFORM" | "SINK",
- config: n.config,
- positionX: n.positionX,
- positionY: n.positionY,
- };
- });
-
- const pipelineEdges = templateEdges.map((e) => ({
- id: generateId(),
- sourceNodeId: idMap.get(e.sourceNodeId) ?? e.sourceNodeId,
- targetNodeId: idMap.get(e.targetNodeId) ?? e.targetNodeId,
- sourcePort: e.sourcePort,
- }));
-
- await saveGraphMutation.mutateAsync({
- pipelineId: pipeline.id,
- nodes: pipelineNodes,
- edges: pipelineEdges,
- });
- };
-
- const isLoading = templatesQuery.isLoading;
- const isCreating =
- createPipelineMutation.isPending || saveGraphMutation.isPending;
-
- return (
-
-
- {/* Environment notice */}
- {!selectedEnvironmentId && (
-
- Select an environment from the header to use templates
-
- )}
-
- {isLoading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
- ) : templates.length === 0 ? (
-
-
-
- No templates yet. Save a pipeline as a template to get started.
-
-
- ) : (
-
-
- {templates.map((template) => (
-
-
-
-
- {template.name}
-
-
- {categoryIcons[template.category] ?? null}
-
- {template.category}
-
-
-
-
- {template.description}
-
-
-
-
-
-
- {template.nodeCount} nodes
-
-
-
- {template.edgeCount} edges
-
-
-
-
- handleUseTemplate(template.id)}
- >
- {isCreating ? "Creating..." : "Use Template"}
-
- setDeleteConfirm({ id: template.id, name: template.name })}
- disabled={deleteTemplateMutation.isPending}
- >
-
-
-
-
- ))}
-
-
- )}
-
!open && setDeleteConfirm(null)}
- title="Delete template?"
- description={<>Permanently delete {deleteConfirm?.name} ? This action cannot be undone.>}
- confirmLabel="Delete"
- isPending={deleteTemplateMutation.isPending}
- pendingLabel="Deleting..."
- onConfirm={() => {
- if (!deleteConfirm) return;
- deleteTemplateMutation.mutate({ id: deleteConfirm.id });
- }}
- />
-
- );
+export default function TemplatesRedirect() {
+ redirect("/library/templates");
}
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/app-sidebar.tsx b/src/components/app-sidebar.tsx
index d766a82d..235082a3 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -43,7 +43,7 @@ const navItems = [
{ title: "Pipelines", href: "/pipelines", icon: Workflow },
{ title: "Fleet", href: "/fleet", icon: Server },
{ title: "Environments", href: "/environments", icon: Layers },
- { title: "Templates", href: "/templates", icon: FileText },
+ { title: "Library", href: "/library", icon: FileText },
{ title: "Audit Log", href: "/audit", icon: ScrollText },
{ title: "Alerts", href: "/alerts", icon: Bell },
{ title: "Analytics", href: "/analytics", icon: BarChart3 },
diff --git a/src/components/flow/component-palette.tsx b/src/components/flow/component-palette.tsx
index 5cebc6fc..93917798 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,7 +178,17 @@ function CollapsibleSection({
export function ComponentPalette() {
const [search, setSearch] = useState("");
+ const [activeTab, setActiveTab] = useState<"catalog" | "shared">("catalog");
+ const [sharedKindFilter, setSharedKindFilter] = useState<"all" | "source" | "transform" | "sink">("all");
+ const trpc = useTRPC();
+ const { selectedEnvironmentId } = useEnvironmentStore();
+ const sharedComponentsQuery = useQuery(
+ trpc.sharedComponent.list.queryOptions(
+ { environmentId: selectedEnvironmentId! },
+ { enabled: !!selectedEnvironmentId }
+ )
+ );
const filtered = useMemo(() => {
if (!search.trim()) return VECTOR_CATALOG;
@@ -189,6 +202,20 @@ export function ComponentPalette() {
);
}, [search]);
+ const filteredShared = useMemo(() => {
+ let items = sharedComponentsQuery.data ?? [];
+ if (sharedKindFilter !== "all") {
+ items = items.filter((sc) => sc.kind.toLowerCase() === sharedKindFilter);
+ }
+ if (!search.trim()) return items;
+ const term = search.toLowerCase().trim();
+ return items.filter(
+ (sc) =>
+ sc.name.toLowerCase().includes(term) ||
+ sc.componentType.toLowerCase().includes(term)
+ );
+ }, [search, sharedComponentsQuery.data, sharedKindFilter]);
+
const sources = useMemo(
() => filtered.filter((d) => d.kind === "source"),
[filtered]
@@ -217,22 +244,142 @@ export function ComponentPalette() {
+ {/* Tab switcher */}
+
+ setActiveTab("catalog")}
+ >
+ Catalog
+
+ setActiveTab("shared")}
+ >
+
+
+ Shared
+
+
+
+
{/* Component list */}
-
-
-
-
-
- {filtered.length === 0 && (
-
-
-
- No components match your search.
-
+ {activeTab === "catalog" && (
+
+
+
+
+
+ {filtered.length === 0 && (
+
+
+
+ No components match your search.
+
+
+ )}
+
+ )}
+
+ {activeTab === "shared" && (
+
+
+ {(["all", "source", "transform", "sink"] as const).map((kind) => (
+ setSharedKindFilter(kind)}
+ >
+ {kind}
+
+ ))}
- )}
-
+ {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/detail-panel.tsx b/src/components/flow/detail-panel.tsx
index fa0d327b..4aea047e 100644
--- a/src/components/flow/detail-panel.tsx
+++ b/src/components/flow/detail-panel.tsx
@@ -1,7 +1,11 @@
"use client";
import { useCallback, useMemo } from "react";
-import { Copy, Trash2, Lock, Info, MousePointerClick, Book } from "lucide-react";
+import { Copy, Trash2, Lock, Info, MousePointerClick, Book, Link2 as LinkIcon, Unlink, AlertTriangle, ExternalLink } from "lucide-react";
+import Link from "next/link";
+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";
@@ -121,11 +125,57 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
const updateDisplayName = useFlowStore((s) => s.updateDisplayName);
const toggleNodeDisabled = useFlowStore((s) => s.toggleNodeDisabled);
const removeNode = useFlowStore((s) => s.removeNode);
+ const acceptNodeSharedUpdate = useFlowStore((s) => s.acceptNodeSharedUpdate);
+ const unlinkNodeStore = useFlowStore((s) => s.unlinkNode);
+
+ 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: (data, variables) => {
+ // Sync the Zustand store so saveGraph doesn't revert the update
+ acceptNodeSharedUpdate(
+ variables.nodeId,
+ data.config as Record
,
+ data.version,
+ );
+ queryClient.invalidateQueries({ queryKey: trpc.pipeline.get.queryKey({ id: pipelineId }) });
+ toast.success("Component updated to latest version");
+ },
+ })
+ );
+
+ const unlinkMutation = useMutation(
+ trpc.sharedComponent.unlink.mutationOptions({
+ onSuccess: (_data, variables) => {
+ // Sync the Zustand store so saveGraph doesn't revert the unlink
+ unlinkNodeStore(variables.nodeId);
+ 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 componentKey = (selectedNode?.data as { componentKey?: string })?.componentKey ?? "";
const currentDisplayName = (selectedNode?.data as { displayName?: string })?.displayName ?? "";
@@ -220,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 (
@@ -240,6 +296,47 @@ 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.
+
+
+ Open in Library
+
+
+
+ )}
+
+ {/* ---- Stale update banner ---- */}
+ {isStale && (
+
+
+
+
Update available
+
+ This shared component has been updated since it was last synced.
+
+
+
+ Accept update
+
+
+
+
+ )}
+
{/* ---- Header ---- */}
@@ -263,6 +360,17 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
>
{componentDef.kind}
+ {isShared && !isSystemLocked && (
+
+
+
+ )}
{isSystemLocked ? (
@@ -289,7 +397,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
id="display-name"
value={currentDisplayName}
onChange={(e) => handleNameChange(e.target.value)}
- disabled={isSystemLocked}
+ disabled={isReadOnly}
placeholder="Component name"
/>
@@ -309,7 +417,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
onCheckedChange={() => {
if (selectedNodeId) toggleNodeDisabled(selectedNodeId);
}}
- disabled={isSystemLocked}
+ disabled={isReadOnly}
/>
@@ -327,8 +435,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 c2cae534..71c745ff 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();
@@ -114,6 +119,35 @@ 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.getState().patchNodeSharedData(newNode.id, {
+ config: sc.config,
+ sharedComponentId: sc.id,
+ sharedComponentVersion: sc.version,
+ sharedComponentName: sc.name,
+ sharedComponentLatestVersion: sc.version,
+ });
+ }
+ } catch {
+ // Malformed shared component data — ignore, node was already added without link
+ }
+ }
},
[reactFlowInstance, addNode]
);
@@ -145,6 +179,7 @@ export function FlowCanvas({ onSave, onExport, onImport }: FlowCanvasProps) {
x={contextMenu.x}
y={contextMenu.y}
onClose={() => setContextMenu(null)}
+ onSaveAsShared={(nodeId) => setSaveSharedNodeId(nodeId)}
/>
)}
{edgeContextMenu && (
@@ -155,6 +190,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..5f8c3541
--- /dev/null
+++ b/src/components/flow/save-shared-component-dialog.tsx
@@ -0,0 +1,126 @@
+"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";
+import { useFlowStore } from "@/stores/flow-store";
+
+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 patchNodeSharedData = useFlowStore((s) => s.patchNodeSharedData);
+ const nodes = useFlowStore((s) => s.nodes);
+
+ const createMutation = useMutation(
+ trpc.sharedComponent.createFromNode.mutationOptions({
+ onSuccess: (data) => {
+ // Sync the Zustand store so the canvas immediately shows the purple shared treatment
+ const node = nodes.find((n) => n.id === nodeId);
+ const config = (node?.data as { config?: Record
})?.config ?? {};
+ patchNodeSharedData(nodeId, {
+ config,
+ sharedComponentId: data.id,
+ sharedComponentVersion: data.version,
+ sharedComponentName: data.name,
+ sharedComponentLatestVersion: data.version,
+ });
+ 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.
+
+
+
+
+ Name
+ setName(e.target.value)}
+ />
+
+
+ Description (optional)
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {createMutation.isPending ? "Saving..." : "Save"}
+
+
+
+
+ );
+}
diff --git a/src/components/flow/sink-node.tsx b/src/components/flow/sink-node.tsx
index 27625890..4a2b5ac5 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";
@@ -19,19 +20,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, displayName, 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 (
@@ -71,6 +81,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 db300a96..63b9f2e7 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";
@@ -21,19 +21,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, displayName, 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;
@@ -28,13 +33,18 @@ function TransformNodeComponent({
selected,
}: NodeProps) {
const { componentDef, componentKey, displayName, 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 (
@@ -82,6 +92,18 @@ function TransformNodeComponent({
)}
+ {isShared && (
+
+
+ {isStale ? (
+ Update available
+ ) : (
+ Shared
+ )}
+ {isStale && }
+
+ )}
+
{/* Output handle on RIGHT */}
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),
};
}));
@@ -203,7 +216,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: {
@@ -639,6 +658,7 @@ export const pipelineRouter = router({
await copyPipelineGraph(tx, {
sourcePipelineId: input.pipelineId,
targetPipelineId: created.id,
+ stripSharedComponentLinks: true,
transformConfig: (config, componentKey) => {
const result = stripEnvRefs(config, componentKey);
allStrippedSecrets.push(...result.strippedSecrets);
@@ -691,6 +711,34 @@ export const pipelineRouter = router({
};
return prisma.$transaction(async (tx) => {
+ // Validate all sharedComponentIds belong to the same environment
+ const sharedComponentIds = [
+ ...new Set(input.nodes.map((n) => n.sharedComponentId).filter(Boolean) as string[]),
+ ];
+ if (sharedComponentIds.length > 0) {
+ const sharedComponents = await tx.sharedComponent.findMany({
+ where: { id: { in: sharedComponentIds } },
+ select: { id: true, environmentId: true },
+ });
+ const foundIds = new Set(sharedComponents.map((sc) => sc.id));
+ for (const scId of sharedComponentIds) {
+ if (!foundIds.has(scId)) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Shared component ${scId} not found`,
+ });
+ }
+ }
+ for (const sc of sharedComponents) {
+ if (sc.environmentId !== existing.environmentId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Shared component does not belong to this pipeline's environment",
+ });
+ }
+ }
+ }
+
await tx.pipeline.update({
where: { id: input.pipelineId },
data: {
@@ -722,6 +770,8 @@ export const pipelineRouter = router({
positionX: node.positionX,
positionY: node.positionY,
disabled: node.disabled,
+ sharedComponentId: node.sharedComponentId ?? null,
+ sharedComponentVersion: node.sharedComponentVersion ?? null,
},
})
)
@@ -822,6 +872,8 @@ export const pipelineRouter = router({
positionX: node.positionX as number,
positionY: node.positionY as number,
disabled: (node.disabled as boolean) ?? false,
+ sharedComponentId: ((node as Record).sharedComponentId as string | null) ?? null,
+ sharedComponentVersion: ((node as Record).sharedComponentVersion as number | null) ?? null,
},
})
)
@@ -885,6 +937,8 @@ export const pipelineRouter = router({
positionX: n.positionX,
positionY: n.positionY,
disabled: n.disabled,
+ sharedComponentId: n.sharedComponentId ?? null,
+ sharedComponentVersion: n.sharedComponentVersion ?? null,
}));
const edgesSnapshot = pipeline.edges.map((e) => ({
id: e.id,
diff --git a/src/server/routers/shared-component.ts b/src/server/routers/shared-component.ts
new file mode 100644
index 00000000..b0f66911
--- /dev/null
+++ b/src/server/routers/shared-component.ts
@@ -0,0 +1,480 @@
+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 || sc.environmentId !== input.environmentId) {
+ 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 }) => {
+ return prisma.$transaction(async (tx) => {
+ // Check unique constraint inside transaction to prevent TOCTOU race
+ const existing = await tx.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 tx.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 },
+ include: { pipeline: { select: { environmentId: true } } },
+ });
+ if (!node || node.pipelineId !== input.pipelineId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Pipeline node not found",
+ });
+ }
+
+ // Verify the pipeline belongs to the claimed environment
+ if (node.pipeline.environmentId !== input.environmentId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Pipeline does not belong to the specified environment",
+ });
+ }
+
+ return prisma.$transaction(async (tx) => {
+ // Check unique constraint inside transaction to prevent TOCTOU race
+ const existing = await tx.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`,
+ });
+ }
+
+ 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 }) => {
+ return prisma.$transaction(async (tx) => {
+ const sc = await tx.sharedComponent.findUnique({
+ where: { id: input.id },
+ });
+ if (!sc || sc.environmentId !== input.environmentId) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Shared component not found",
+ });
+ }
+
+ // If name changes, check for conflicts inside transaction
+ if (input.name && input.name !== sc.name) {
+ const conflict = await tx.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: Prisma.SharedComponentUpdateInput = {};
+ if (input.name !== undefined) data.name = input.name;
+ if (input.description !== undefined) data.description = input.description;
+
+ // If config changes, encrypt and bump version atomically
+ if (input.config) {
+ data.config = encryptNodeConfig(sc.componentType, input.config) as Prisma.InputJsonValue;
+ data.version = { increment: 1 };
+ }
+
+ return tx.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 || sc.environmentId !== input.environmentId) {
+ 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
+ await prisma.pipelineNode.update({
+ where: { id: input.nodeId },
+ data: {
+ config: node.sharedComponent.config ?? undefined,
+ sharedComponentVersion: node.sharedComponent.version,
+ },
+ });
+
+ // Return decrypted config and version for the client to sync its local store
+ return {
+ config: decryptNodeConfig(
+ node.sharedComponent.componentType,
+ (node.sharedComponent.config as Record) ?? {},
+ ),
+ version: 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 },
+ include: { pipeline: { select: { environmentId: true } } },
+ });
+ 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 shared component belongs to the same environment as the pipeline
+ if (sc.environmentId !== node.pipeline.environmentId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Shared component belongs to a different environment",
+ });
+ }
+
+ // 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/server/services/copy-pipeline-graph.ts b/src/server/services/copy-pipeline-graph.ts
index 634260d5..0f6136fa 100644
--- a/src/server/services/copy-pipeline-graph.ts
+++ b/src/server/services/copy-pipeline-graph.ts
@@ -9,6 +9,8 @@ interface CopyPipelineGraphOptions {
config: Record,
componentKey: string,
) => Record;
+ /** When true, shared component links are stripped (e.g. cross-environment promote). */
+ stripSharedComponentLinks?: boolean;
}
/**
@@ -24,7 +26,7 @@ export async function copyPipelineGraph(
tx: Tx,
opts: CopyPipelineGraphOptions,
) {
- const { sourcePipelineId, targetPipelineId, transformConfig } = opts;
+ const { sourcePipelineId, targetPipelineId, transformConfig, stripSharedComponentLinks } = opts;
const sourceNodes = await tx.pipelineNode.findMany({
where: { pipelineId: sourcePipelineId },
@@ -54,6 +56,8 @@ export async function copyPipelineGraph(
positionX: node.positionX,
positionY: node.positionY,
disabled: node.disabled,
+ sharedComponentId: stripSharedComponentLinks ? null : (node.sharedComponentId ?? null),
+ sharedComponentVersion: stripSharedComponentLinks ? null : (node.sharedComponentVersion ?? null),
},
});
diff --git a/src/stores/flow-store.ts b/src/stores/flow-store.ts
index bf101b04..5de01e73 100644
--- a/src/stores/flow-store.ts
+++ b/src/stores/flow-store.ts
@@ -23,6 +23,10 @@ interface FlowNodeData {
disabled?: boolean;
metrics?: NodeMetricsData;
isSystemLocked?: boolean;
+ sharedComponentId?: string | null;
+ sharedComponentVersion?: number | null;
+ sharedComponentName?: string | null;
+ sharedComponentLatestVersion?: number | null;
}
/* ------------------------------------------------------------------ */
@@ -83,6 +87,15 @@ export interface FlowState {
updateNodeConfig: (id: string, config: Record) => void;
updateDisplayName: (id: string, displayName: string) => void;
toggleNodeDisabled: (id: string) => void;
+ patchNodeSharedData: (id: string, data: {
+ config: Record;
+ sharedComponentId: string;
+ sharedComponentVersion: number;
+ sharedComponentName: string;
+ sharedComponentLatestVersion: number;
+ }) => void;
+ acceptNodeSharedUpdate: (id: string, config: Record, version: number) => void;
+ unlinkNode: (id: string) => void;
updateNodeMetrics: (metricsMap: Map) => void;
// Global config
@@ -131,7 +144,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"
)
),
}));
@@ -407,6 +420,77 @@ export const useFlowStore = create()((set, get) => ({
});
},
+ patchNodeSharedData: (id, data) => {
+ set((state) => {
+ // Amend the last history entry — the addNode call already pushed a snapshot
+ return {
+ nodes: state.nodes.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ config: data.config,
+ sharedComponentId: data.sharedComponentId,
+ sharedComponentVersion: data.sharedComponentVersion,
+ sharedComponentName: data.sharedComponentName,
+ sharedComponentLatestVersion: data.sharedComponentLatestVersion,
+ },
+ }
+ : n,
+ ),
+ isDirty: true,
+ };
+ });
+ },
+
+ acceptNodeSharedUpdate: (id, config, version) => {
+ set((state) => {
+ const history = pushSnapshot(state);
+ return {
+ ...history,
+ nodes: state.nodes.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ config,
+ sharedComponentVersion: version,
+ sharedComponentLatestVersion: version,
+ },
+ }
+ : n,
+ ),
+ isDirty: true,
+ };
+ });
+ },
+
+ unlinkNode: (id) => {
+ set((state) => {
+ const history = pushSnapshot(state);
+ return {
+ ...history,
+ nodes: state.nodes.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ sharedComponentId: null,
+ sharedComponentVersion: null,
+ sharedComponentName: null,
+ sharedComponentLatestVersion: null,
+ },
+ }
+ : n,
+ ),
+ isDirty: true,
+ };
+ });
+ },
+
updateNodeMetrics: (metricsMap) => {
set((state) => ({
nodes: state.nodes.map((n) => {
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;