Skip to content

Commit

Permalink
add relationships view
Browse files Browse the repository at this point in the history
  • Loading branch information
jsbroks committed Sep 22, 2024
1 parent 94a680e commit d792331
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const OverviewContent: React.FC<{
string
>)
: null;

return (
<div className="space-y-4">
<div className="space-y-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<div className="space-y-2 text-sm">
<div>Children</div>
<Card className="px-3 py-2">
{childrenTargets.data?.items.map((t) => (
<div key={t.id}>
{t.name} {t.kind}
</div>
))}
<div className="h-[650px] w-full">
<TargetRelationshipsDiagram targetId={target.id} />
</div>
</Card>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TargetNodeProps> = (node) => {
const { data } = node;

const isKubernetes = data.version.includes("kubernetes");
const isTerraform = data.version.includes("terraform");

return (
<>
<div
className={cn(
"flex flex-col items-center justify-center text-center",
"w-[250px] gap-2 rounded-md border bg-neutral-900 px-4 py-3",
isKubernetes && "border-blue-500/70 bg-blue-500/20",
isTerraform && "border-purple-500/70 bg-purple-500/20",
)}
>
<div className="flex h-12 w-12 items-center justify-center rounded-full">
{isKubernetes ? (
<SiKubernetes className="h-8 w-8 text-blue-500" />
) : isTerraform ? (
<SiTerraform className="h-8 w-8 text-purple-300" />
) : (
<TbTarget className="h-8 w-8 text-neutral-500" />
)}
</div>
<div className="text-sm font-medium text-muted-foreground">
{data.kind}
</div>
<div className="text-base font-semibold">{data.label}</div>
</div>

<Handle
type="target"
className="h-2 w-2 rounded-full border border-neutral-500"
style={{ background: colors.neutral[800] }}
position={Position.Left}
/>
<Handle
type="source"
className="h-2 w-2 rounded-full border border-neutral-500"
style={{ background: colors.neutral[800] }}
position={Position.Right}
/>
</>
);
};

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<ReactFlowInstance | null>(null);
useEffect(() => {
if (reactFlowInstance != null) onLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reactFlowInstance]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
proOptions={{ hideAttribution: true }}
deleteKeyCode={[]}
onInit={setReactFlowInstance}
nodesDraggable
nodeTypes={nodeTypes}
edgeTypes={{}}
/>
);
};

export const TargetRelationshipsDiagram: React.FC<{ targetId: string }> = ({
targetId,
}) => {
const hierarchy = api.target.relations.hierarchy.useQuery(targetId);

if (hierarchy.data == null) return null;
return (
<ReactFlowProvider>
<TargetDiagram targets={hierarchy.data} />
</ReactFlowProvider>
);
};
135 changes: 132 additions & 3 deletions packages/api/src/router/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
const targetQuery = (db: Tx, checks: Array<SQL<unknown>>) =>
db
.select({
target: target,
targetProvider: targetProvider,
workspace: workspace,
targetMetadata: sql<
Record<string, string>
>`jsonb_object_agg(target_metadata.key,
targetMetadata:
sql<_StringStringRecord>`jsonb_object_agg(target_metadata.key,
target_metadata.value)`.as("target_metadata"),
})
.from(target)
Expand All @@ -51,6 +179,7 @@ const targetQuery = (db: Tx, checks: Array<SQL<unknown>>) =>
export const targetRouter = createTRPCRouter({
metadataGroup: targetMetadataGroupRouter,
provider: targetProviderRouter,
relations: targetRelations,

byId: protectedProcedure
.meta({
Expand Down
Loading

0 comments on commit d792331

Please sign in to comment.