diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipContent.tsx
index 7357274d..0f61de5a 100644
--- a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipContent.tsx
+++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipContent.tsx
@@ -1,38 +1,20 @@
import type { Target } from "@ctrlplane/db/schema";
import { Card } from "@ctrlplane/ui/card";
-import { ReservedMetadataKey } from "@ctrlplane/validators/targets";
-import { api } from "~/trpc/react";
+import { TargetRelationshipsDiagram } from "./RelationshipsDiagram";
export const RelationshipsContent: React.FC<{
target: Target;
}> = ({ target }) => {
- const childrenTargets = api.target.byWorkspaceId.list.useQuery({
- workspaceId: target.workspaceId,
- metadataFilters: [
- {
- operator: "and",
- conditions: [
- {
- operator: "equals",
- key: ReservedMetadataKey.ParentTargetIdentifier,
- value: target.identifier,
- },
- ],
- },
- ],
- });
return (
Children
- {childrenTargets.data?.items.map((t) => (
-
- {t.name} {t.kind}
-
- ))}
+
+
+
diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipsDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipsDiagram.tsx
new file mode 100644
index 00000000..3e9ca8cc
--- /dev/null
+++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipsDiagram.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import type { NodeProps, NodeTypes, ReactFlowInstance } from "reactflow";
+import { useCallback, useEffect, useState } from "react";
+import { SiKubernetes, SiTerraform } from "react-icons/si";
+import { TbTarget } from "react-icons/tb";
+import ReactFlow, {
+ Handle,
+ MarkerType,
+ Position,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+ useReactFlow,
+} from "reactflow";
+import colors from "tailwindcss/colors";
+
+import { cn } from "@ctrlplane/ui";
+
+import { getLayoutedElementsDagre } from "~/app/[workspaceSlug]/_components/reactflow/layout";
+import { api } from "~/trpc/react";
+
+type TargetNodeProps = NodeProps<{
+ name: string;
+ label: string;
+ id: string;
+ kind: string;
+ version: string;
+}>;
+const TargetNode: React.FC
= (node) => {
+ const { data } = node;
+
+ const isKubernetes = data.version.includes("kubernetes");
+ const isTerraform = data.version.includes("terraform");
+
+ return (
+ <>
+
+
+ {isKubernetes ? (
+
+ ) : isTerraform ? (
+
+ ) : (
+
+ )}
+
+
+ {data.kind}
+
+
{data.label}
+
+
+
+
+ >
+ );
+};
+
+const nodeTypes: NodeTypes = {
+ target: TargetNode,
+};
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const useOnLayout = () => {
+ const { getNodes, fitView, setNodes, setEdges, getEdges } = useReactFlow();
+ return useCallback(() => {
+ const layouted = getLayoutedElementsDagre(
+ getNodes(),
+ getEdges(),
+ "LR",
+ 100,
+ );
+ setNodes([...layouted.nodes]);
+ setEdges([...layouted.edges]);
+
+ window.requestAnimationFrame(() => {
+ // hack to get it to center - we should figure out when the layout is done
+ // and then call fitView. We are betting that everything should be
+ // rendered correctly in 100ms before fitting the view.
+ sleep(100).then(() => fitView({ padding: 0.12, maxZoom: 1 }));
+ });
+ }, [getNodes, getEdges, setNodes, setEdges, fitView]);
+};
+
+const TargetDiagram: React.FC<{
+ targets: Array<{
+ id: string;
+ workpace_id: string;
+ name: string;
+ identifier: string;
+ level: number;
+ parent_identifier?: string;
+ parent_workspace_id?: string;
+ }>;
+}> = ({ targets }) => {
+ const [nodes, _, onNodesChange] = useNodesState(
+ targets.map((d) => ({
+ id: `${d.workpace_id}-${d.identifier}`,
+ type: "target",
+ position: { x: 0, y: 0 },
+ data: { ...d, label: d.name },
+ })),
+ );
+ const [edges, __, onEdgesChange] = useEdgesState(
+ targets
+ .filter((t) => t.parent_identifier != null)
+ .map((t) => {
+ return {
+ id: `${t.id}-${t.parent_identifier}`,
+ source:
+ t.level > 0
+ ? `${t.workpace_id}-${t.parent_identifier}`
+ : `${t.workpace_id}-${t.identifier}`,
+ target:
+ t.level > 0
+ ? `${t.workpace_id}-${t.identifier}`
+ : `${t.workpace_id}-${t.parent_identifier}`,
+ markerEnd: {
+ type: MarkerType.Arrow,
+ color: colors.neutral[700],
+ },
+ };
+ }),
+ );
+ const onLayout = useOnLayout();
+
+ const [reactFlowInstance, setReactFlowInstance] =
+ useState(null);
+ useEffect(() => {
+ if (reactFlowInstance != null) onLayout();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [reactFlowInstance]);
+ return (
+
+ );
+};
+
+export const TargetRelationshipsDiagram: React.FC<{ targetId: string }> = ({
+ targetId,
+}) => {
+ const hierarchy = api.target.relations.hierarchy.useQuery(targetId);
+
+ if (hierarchy.data == null) return null;
+ return (
+
+
+
+ );
+};
diff --git a/packages/api/src/router/target.ts b/packages/api/src/router/target.ts
index f37d050e..fd50c195 100644
--- a/packages/api/src/router/target.ts
+++ b/packages/api/src/router/target.ts
@@ -29,15 +29,143 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
import { targetMetadataGroupRouter } from "./target-metadata-group";
import { targetProviderRouter } from "./target-provider";
+const targetRelations = createTRPCRouter({
+ hierarchy: protectedProcedure
+ .input(z.string().uuid())
+ .query(async ({ ctx, input }) => {
+ const results = await ctx.db.execute<{
+ id: string;
+ workpace_id: string;
+ name: string;
+ identifier: string;
+ level: number;
+ parent_identifier: string;
+ parent_workspace_id: string;
+ }>(
+ sql`
+ -- Recursive CTE to find ancestors (parents)
+ WITH RECURSIVE ancestors AS (
+ -- Base case: start with the given target id, including parent info if exists
+ SELECT
+ t.id,
+ t.identifier,
+ t.workspace_id,
+ t.kind,
+ t.version,
+ t.name,
+ 0 AS level,
+ ARRAY[t.id] AS path,
+ parent_tm.value AS parent_identifier,
+ parent_t.workspace_id AS parent_workspace_id
+ FROM
+ target t
+ LEFT JOIN target_metadata parent_tm ON parent_tm.target_id = t.id AND parent_tm.key = 'ctrlplane/parent-target-identifier'
+ LEFT JOIN target parent_t ON parent_t.identifier = parent_tm.value AND parent_t.workspace_id = t.workspace_id
+ WHERE
+ t.id = ${input}
+
+ UNION ALL
+
+ -- Recursive term: find the parent
+ SELECT
+ parent_t.id,
+ parent_t.identifier,
+ parent_t.workspace_id,
+ parent_t.kind,
+ parent_t.version,
+ parent_t.name, -- Added name
+ a.level - 1 AS level,
+ a.path || parent_t.id,
+ grandparent_tm.value AS parent_identifier,
+ grandparent_t.workspace_id AS parent_workspace_id
+ FROM
+ ancestors a
+ JOIN target_metadata tm ON tm.target_id = a.id AND tm.key = 'ctrlplane/parent-target-identifier'
+ JOIN target parent_t ON parent_t.identifier = tm.value AND parent_t.workspace_id = a.workspace_id
+ LEFT JOIN target_metadata grandparent_tm ON grandparent_tm.target_id = parent_t.id AND grandparent_tm.key = 'ctrlplane/parent-target-identifier'
+ LEFT JOIN target grandparent_t ON grandparent_t.identifier = grandparent_tm.value AND grandparent_t.workspace_id = parent_t.workspace_id
+ WHERE
+ NOT parent_t.id = ANY(a.path)
+ ),
+
+ -- Recursive CTE to find descendants (children)
+ descendants AS (
+ -- Base case: start with the given target id, including parent info if exists
+ SELECT
+ t.id,
+ t.identifier,
+ t.workspace_id,
+ t.kind,
+ t.version,
+ t.name,
+ 0 AS level,
+ ARRAY[t.id] AS path,
+ parent_tm.value AS parent_identifier,
+ parent_t.workspace_id AS parent_workspace_id
+ FROM
+ target t
+ LEFT JOIN target_metadata parent_tm ON parent_tm.target_id = t.id AND parent_tm.key = 'ctrlplane/parent-target-identifier'
+ LEFT JOIN target parent_t ON parent_t.identifier = parent_tm.value AND parent_t.workspace_id = t.workspace_id
+ WHERE
+ t.id = ${input}
+
+ UNION ALL
+
+ -- Recursive term: find the children
+ SELECT
+ child_t.id,
+ child_t.identifier,
+ child_t.workspace_id,
+ child_t.kind,
+ child_t.version,
+ child_t.name, -- Added name
+ d.level + 1 AS level,
+ d.path || child_t.id,
+ child_parent_tm.value AS parent_identifier,
+ child_parent_t.workspace_id AS parent_workspace_id
+ FROM
+ descendants d
+ JOIN target_metadata tm ON tm.key = 'ctrlplane/parent-target-identifier' AND tm.value = d.identifier
+ JOIN target child_t ON child_t.id = tm.target_id AND child_t.workspace_id = d.workspace_id
+ LEFT JOIN target_metadata child_parent_tm ON child_parent_tm.target_id = child_t.id AND child_parent_tm.key = 'ctrlplane/parent-target-identifier'
+ LEFT JOIN target child_parent_t ON child_parent_t.identifier = child_parent_tm.value AND child_parent_t.workspace_id = child_t.workspace_id
+ WHERE
+ NOT child_t.id = ANY(d.path)
+ )
+
+ -- Combine the results from ancestors and descendants
+ SELECT DISTINCT
+ id,
+ identifier,
+ workspace_id,
+ kind,
+ version,
+ name,
+ level,
+ parent_identifier,
+ parent_workspace_id
+ FROM
+ (
+ SELECT * FROM ancestors
+ UNION ALL
+ SELECT * FROM descendants
+ ) AS combined;
+ `,
+ );
+ return results.rows;
+ }),
+ lineage: protectedProcedure.input(z.string().uuid()).query(() => null),
+});
+
+type _StringStringRecord = Record;
const targetQuery = (db: Tx, checks: Array>) =>
db
.select({
target: target,
targetProvider: targetProvider,
workspace: workspace,
- targetMetadata: sql<
- Record
- >`jsonb_object_agg(target_metadata.key,
+ targetMetadata:
+ sql<_StringStringRecord>`jsonb_object_agg(target_metadata.key,
target_metadata.value)`.as("target_metadata"),
})
.from(target)
@@ -51,6 +179,7 @@ const targetQuery = (db: Tx, checks: Array>) =>
export const targetRouter = createTRPCRouter({
metadataGroup: targetMetadataGroupRouter,
provider: targetProviderRouter,
+ relations: targetRelations,
byId: protectedProcedure
.meta({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e30be100..989e7c7f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,9 +9,6 @@ catalogs:
'@octokit/rest':
specifier: ^20.1.1
version: 20.1.1
- '@t3-oss/env-core':
- specifier: ^0.11.1
- version: 0.11.1
'@t3-oss/env-nextjs':
specifier: ^0.11.1
version: 0.11.1
@@ -21,9 +18,6 @@ catalogs:
eslint:
specifier: ^9.10.0
version: 9.10.0
- eslint-plugin-vitest:
- specifier: ^0.5.4
- version: 0.5.4
prettier:
specifier: ^3.3.3
version: 3.3.3
@@ -33,9 +27,6 @@ catalogs:
typescript:
specifier: ^5.6.2
version: 5.6.2
- typescript-eslint:
- specifier: ^8.3.0
- version: 8.6.0
zod:
specifier: ^3.23.8
version: 3.23.8
@@ -43,10 +34,6 @@ catalogs:
'@types/node':
specifier: ^20.12.0
version: 20.16.5
- node22:
- '@types/node':
- specifier: ^22.5.5
- version: 22.5.5
react18:
'@types/react':
specifier: ^18.3.5
@@ -12340,7 +12327,7 @@ snapshots:
'@types/connect@3.4.38':
dependencies:
- '@types/node': 20.16.5
+ '@types/node': 22.5.5
'@types/cookie-parser@1.4.7':
dependencies:
@@ -12600,7 +12587,7 @@ snapshots:
'@types/send@0.17.4':
dependencies:
'@types/mime': 1.3.5
- '@types/node': 20.16.5
+ '@types/node': 22.5.5
'@types/serve-static@1.15.7':
dependencies:
@@ -16799,7 +16786,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
- '@types/node': 20.16.5
+ '@types/node': 22.5.5
long: 5.2.3
protocols@2.0.1: {}