diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/CreateMetadataGroupDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/CreateMetadataGroupDialog.tsx index 89afbf0f..fccdb797 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/CreateMetadataGroupDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/CreateMetadataGroupDialog.tsx @@ -2,10 +2,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { TbBulb, TbX } from "react-icons/tb"; +import { TbX } from "react-icons/tb"; import { z } from "zod"; -import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert"; import { Badge } from "@ctrlplane/ui/badge"; import { Button } from "@ctrlplane/ui/button"; import { @@ -32,7 +31,6 @@ import { Textarea } from "@ctrlplane/ui/textarea"; import { api } from "~/trpc/react"; import { MetadataFilterInput } from "./MetadataFilterInput"; -import { NullCombinationsExample } from "./NullCombinationsExample"; const metadataGroupFormSchema = z.object({ name: z.string().min(1), @@ -125,13 +123,8 @@ export const CreateMetadataGroupDialog: React.FC<{
- {fields.length === 0 && ( -

- No keys added -

- )} {fields.length > 0 && ( -
+
{fields.map((field, index) => ( )} -
-
-
- field.value)} - /> -
-
- +
+
+ field.value)} + /> +
+
+ +
@@ -182,21 +175,11 @@ export const CreateMetadataGroupDialog: React.FC<{ control={form.control} name="includeNullCombinations" render={({ field: { value, onChange } }) => ( - - Include Null Combinations? - - - Null Combinations - - If enabled, combinations with null values will be - included. For example, if the keys "env" and "tier" are - selected, the following combinations will be tracked in - this metadata group: - - - + + - + {" "} + Include Null Combinations? )} /> diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/EditMetadataGroupDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/EditMetadataGroupDialog.tsx index ff6c94a7..35c69985 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/EditMetadataGroupDialog.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/EditMetadataGroupDialog.tsx @@ -3,10 +3,9 @@ import type { TargetMetadataGroup } from "@ctrlplane/db/schema"; import { useState } from "react"; import { useRouter } from "next/navigation"; -import { TbBulb, TbX } from "react-icons/tb"; +import { TbX } from "react-icons/tb"; import { z } from "zod"; -import { Alert, AlertDescription, AlertTitle } from "@ctrlplane/ui/alert"; import { Badge } from "@ctrlplane/ui/badge"; import { Button } from "@ctrlplane/ui/button"; import { @@ -33,7 +32,6 @@ import { Textarea } from "@ctrlplane/ui/textarea"; import { api } from "~/trpc/react"; import { MetadataFilterInput } from "./MetadataFilterInput"; -import { NullCombinationsExample } from "./NullCombinationsExample"; const metadataGroupFormSchema = z.object({ name: z.string().min(1), @@ -130,13 +128,8 @@ export const EditMetadataGroupDialog: React.FC<{
- {fields.length === 0 && ( -

- No keys added -

- )} {fields.length > 0 && ( -
+
{fields.map((field, index) => ( )} -
-
-
- field.value)} - /> -
-
- +
+
+ field.value)} + /> +
+
+ +
@@ -187,21 +180,11 @@ export const EditMetadataGroupDialog: React.FC<{ control={form.control} name="includeNullCombinations" render={({ field: { value, onChange } }) => ( - - Include Null Combinations? - - - Null Combinations - - If enabled, combinations with null values will be - included. For example, if the keys "env" and "tier" are - selected, the following combinations will be tracked in - this metadata group: - - - + + - + {" "} + Include Null Combinations? )} /> diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/NullCombinationsExample.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/NullCombinationsExample.tsx deleted file mode 100644 index 54fcc74c..00000000 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/target-metadata-groups/NullCombinationsExample.tsx +++ /dev/null @@ -1,28 +0,0 @@ -export const NullCombinationsExample = () => ( -
- - {"{"}  -

"env": 

-

null, 

-

"tier": 

-

"3" 

- {"}"} -
- - {"{"}  -

"env": 

-

"dev", 

-

"tier": 

-

null 

- {"}"} -
- - {"{"}  -

"env": 

-

null, 

-

"tier": 

-

null 

- {"}"} -
-
-); 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..46597bf1 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 @@ -13,6 +13,7 @@ import { } from "@ctrlplane/ui/tooltip"; import { ReservedMetadataKey } from "@ctrlplane/validators/targets"; +import { api } from "~/trpc/react"; import { useMatchSorterWithSearch } from "~/utils/useMatchSorter"; const TargetMetadataInfo: React.FC<{ metadata: Record }> = ( @@ -35,7 +36,7 @@ const TargetMetadataInfo: React.FC<{ metadata: Record }> = ( onChange={(e) => setSearch(e.target.value)} />
-
+
{result.map(([key, value]) => (
@@ -68,6 +69,9 @@ export const OverviewContent: React.FC<{ string >) : null; + + const deployments = api.deployment.byTargetId.useQuery(target.id); + return (
@@ -189,11 +193,25 @@ export const OverviewContent: React.FC<{
-
Metadata
+
+ Metadata ({Object.keys(target.metadata).length}) +
+ +
+
Deployments
+
+ {deployments.data?.length === 0 && ( + + Target is not part of any deployments. + + )} + {deployments.data?.map((t) =>
{t.name}
)} +
+
); }; 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 deleted file mode 100644 index 9809bdd1..00000000 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/RelationshipContent.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Target } from "@ctrlplane/db/schema"; - -import { Card } from "@ctrlplane/ui/card"; -import { ReservedMetadataKey } from "@ctrlplane/validators/targets"; - -import { api } from "~/trpc/react"; - -export const RelationshipsContent: React.FC<{ - target: Target; -}> = ({ target }) => { - const childrenTargets = api.target.byWorkspaceId.list.useQuery({ - workspaceId: target.workspaceId, - filters: [ - { - type: "comparison", - operator: "and", - conditions: [ - { - type: "metadata", - 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/TargetDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/TargetDrawer.tsx index ab833e61..f5a08a58 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/TargetDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/TargetDrawer.tsx @@ -22,7 +22,7 @@ import { api } from "~/trpc/react"; import { DeploymentsContent } from "./DeploymentContent"; import { JobsContent } from "./JobsContent"; import { OverviewContent } from "./OverviewContent"; -import { RelationshipsContent } from "./RelationshipContent"; +import { RelationshipsContent } from "./relationships/RelationshipContent"; import { VariableContent } from "./VariablesContent"; const TabButton: React.FC<{ diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipContent.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipContent.tsx new file mode 100644 index 00000000..b43f4a06 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipContent.tsx @@ -0,0 +1,22 @@ +import type { Target } from "@ctrlplane/db/schema"; + +import { Card } from "@ctrlplane/ui/card"; + +import { TargetHierarchyRelationshipsDiagram } from "./RelationshipsDiagram"; + +export const RelationshipsContent: React.FC<{ + target: Target; +}> = ({ target }) => { + return ( +
+
+
Hierarchy
+ +
+ +
+
+
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipsDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipsDiagram.tsx new file mode 100644 index 00000000..486b5beb --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(targets)/targets/target-drawer/relationships/RelationshipsDiagram.tsx @@ -0,0 +1,231 @@ +"use client"; + +import type { + EdgeProps, + EdgeTypes, + 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, { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + 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 DepEdge: React.FC = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + label, + style = {}, + markerEnd, +}) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + <> + + +
+ {label} +
+
+ + ); +}; + +const nodeTypes: NodeTypes = { target: TargetNode }; +const edgeTypes: EdgeTypes = { default: DepEdge }; + +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[500] }, + style: { stroke: colors.neutral[500] }, + }; + }), + ); + 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 TargetHierarchyRelationshipsDiagram: 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 476f9723..90c093ad 100644 --- a/packages/api/src/router/target.ts +++ b/packages/api/src/router/target.ts @@ -27,15 +27,142 @@ 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; + }), +}); + +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) @@ -49,6 +176,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: {} diff --git a/providers/google-compute-scanner/src/config.ts b/providers/google-compute-scanner/src/config.ts index d4b13746..c24876a1 100644 --- a/providers/google-compute-scanner/src/config.ts +++ b/providers/google-compute-scanner/src/config.ts @@ -30,6 +30,8 @@ export const env = createEnv({ "gmp-public", "gke-managed-system", "gke-managed-cim", + "gke-gmp-system", + "gke-managed-filestorecsi", ].join(","), ), CTRLPLANE_COMPUTE_TARGET_NAME: z