diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/OverviewContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/OverviewContent.tsx index 7706a30e..16a42198 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/OverviewContent.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/OverviewContent.tsx @@ -68,6 +68,7 @@ export const OverviewContent: React.FC<{ string >) : null; + return (
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: {}