-
- 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 (
+
+ );
+};
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