diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/ResourceVisualizationDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/ResourceVisualizationDiagram.tsx index 7ba2c3a90..23f5c2ec4 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/ResourceVisualizationDiagram.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/ResourceVisualizationDiagram.tsx @@ -1,10 +1,7 @@ "use client"; import type { RouterOutputs } from "@ctrlplane/api"; -import type * as SCHEMA from "@ctrlplane/db/schema"; -import type { EdgeTypes, NodeTypes } from "reactflow"; import React from "react"; -import { compact } from "lodash"; import ReactFlow, { ReactFlowProvider, useEdgesState, @@ -12,83 +9,25 @@ import ReactFlow, { } from "reactflow"; import { useLayoutAndFitView } from "~/app/[workspaceSlug]/(app)/_components/reactflow/layout"; -import { DepEdge } from "./DepEdge"; -import { - createEdgeFromProviderToResource, - createEdgesFromResourceToEnvironments, -} from "./edges"; -import { EnvironmentNode } from "./nodes/EnvironmentNode"; -import { ProviderNode } from "./nodes/ProviderNode"; -import { ResourceNode } from "./nodes/ResourceNode"; +import { edgeTypes, getEdges } from "./edges"; +import { getNodes, nodeTypes } from "./nodes/nodes"; type Relationships = NonNullable; type ResourceVisualizationDiagramProps = { - resource: SCHEMA.Resource; relationships: Relationships; }; -enum NodeType { - Resource = "resource", - Environment = "environment", - Provider = "provider", -} - -const nodeTypes: NodeTypes = { - [NodeType.Resource]: ResourceNode, - [NodeType.Environment]: EnvironmentNode, - [NodeType.Provider]: ProviderNode, -}; -const edgeTypes: EdgeTypes = { default: DepEdge }; - export const ResourceVisualizationDiagram: React.FC< ResourceVisualizationDiagramProps -> = ({ resource, relationships }) => { - const { workspace, provider } = relationships; - const { systems } = workspace; +> = ({ relationships }) => { const [nodes, _, onNodesChange] = useNodesState<{ label: string }>( - compact([ - { - id: resource.id, - type: NodeType.Resource, - data: { ...resource, label: resource.identifier }, - position: { x: 0, y: 0 }, - }, - ...systems.flatMap((system) => - system.environments.map((env) => ({ - id: env.id, - type: NodeType.Environment, - data: { - environment: { - ...env, - deployments: system.deployments, - resource, - }, - label: `${system.name}/${env.name}`, - }, - position: { x: 0, y: 0 }, - })), - ), - provider != null && { - id: provider.id, - type: NodeType.Provider, - data: { ...provider, label: provider.name }, - position: { x: 0, y: 0 }, - }, - ]), + getNodes(relationships), ); - const resourceToEnvEdges = createEdgesFromResourceToEnvironments( - resource, - systems.flatMap((s) => s.environments), - ); - const providerEdge = createEdgeFromProviderToResource(provider, resource); - - const [edges, __, onEdgesChange] = useEdgesState( - compact([...resourceToEnvEdges, providerEdge]), - ); + const [edges, __, onEdgesChange] = useEdgesState(getEdges(relationships)); - const setReactFlowInstance = useLayoutAndFitView(nodes, { direction: "LR" }); + const setReactFlowInstance = useLayoutAndFitView(nodes, { direction: "TB" }); return ( @@ -19,22 +26,93 @@ export const createEdgesFromResourceToEnvironments = ( id: `${resource.id}-${environment.id}`, source: resource.id, target: environment.id, - style: { stroke: colors.neutral[700] }, + style: { stroke: colors.neutral[800] }, markerEnd, label: "in", })); -export const createEdgeFromProviderToResource = ( +const createEdgeFromProviderToResource = ( provider: Provider | null, resource: SCHEMA.Resource, ) => provider != null ? { id: `${provider.id}-${resource.id}`, - source: provider.id, + source: `${provider.id}-${resource.id}`, target: resource.id, - style: { stroke: colors.neutral[700] }, + style: { stroke: colors.neutral[800] }, markerEnd, label: "discovered", } : null; + +type Relationships = NonNullable; + +const createEdgesFromEnvironmentToDeployments = ( + environments: SCHEMA.Environment[], + deployments: SCHEMA.Deployment[], +) => + environments + .flatMap((e) => deployments.map((d) => ({ e, d }))) + .map(({ e, d }) => ({ + id: `${e.id}-${d.id}`, + source: e.id, + target: `${e.id}-${d.id}`, + label: "deploys", + style: { stroke: colors.neutral[800] }, + markerEnd, + })); + +const createEdgesFromDeploymentsToResources = (relationships: Relationships) => + relationships.map((resource) => { + const { parent } = resource; + if (parent == null) return null; + + const allReleaseJobTriggers = relationships + .flatMap((r) => r.workspace.systems) + .flatMap((s) => s.environments) + .flatMap((e) => e.latestActiveReleases) + .map((rel) => rel.releaseJobTrigger); + + const releaseJobTrigger = allReleaseJobTriggers.find( + (j) => j.jobId === parent.jobId, + ); + if (releaseJobTrigger == null) return null; + + const { deploymentId } = releaseJobTrigger.release; + const { environmentId } = releaseJobTrigger; + + return { + id: `${releaseJobTrigger.jobId}-${resource.id}`, + source: `${environmentId}-${deploymentId}`, + target: resource.id, + style: { stroke: colors.neutral[800] }, + markerEnd, + label: "created", + }; + }); + +export const getEdges = (relationships: Relationships) => { + const resourceToEnvEdges = relationships.flatMap((r) => + createEdgesFromResourceToEnvironments( + r, + r.workspace.systems.flatMap((s) => s.environments), + ), + ); + const environmentToDeploymentEdges = relationships.flatMap((r) => + r.workspace.systems.flatMap((s) => + createEdgesFromEnvironmentToDeployments(s.environments, s.deployments), + ), + ); + const providerEdges = relationships.flatMap((r) => + r.provider != null ? [createEdgeFromProviderToResource(r.provider, r)] : [], + ); + const deploymentEdges = createEdgesFromDeploymentsToResources(relationships); + + return [ + ...resourceToEnvEdges, + ...environmentToDeploymentEdges, + ...providerEdges, + ...deploymentEdges, + ].filter(isPresent); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx new file mode 100644 index 000000000..724064008 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/DeploymentNode.tsx @@ -0,0 +1,107 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { NodeProps } from "reactflow"; +import React from "react"; +import { Handle, Position } from "reactflow"; + +import { cn } from "@ctrlplane/ui"; +import { JobStatus, JobStatusReadable } from "@ctrlplane/validators/jobs"; + +import { useJobDrawer } from "~/app/[workspaceSlug]/(app)/_components/job-drawer/useJobDrawer"; +import { api } from "~/trpc/react"; +import { ReleaseIcon } from "../../ReleaseCell"; + +type DeploymentNodeProps = NodeProps<{ + label: string; + deployment: SCHEMA.Deployment; + environment: SCHEMA.Environment; + resource: SCHEMA.Resource; +}>; + +export const DeploymentNode: React.FC = ({ data }) => { + const { deployment, environment, resource } = data; + const { setJobId } = useJobDrawer(); + + const resourceId = resource.id; + const environmentId = environment.id; + const latestActiveReleasesQ = + api.resource.activeReleases.byResourceAndEnvironmentId.useQuery( + { resourceId, environmentId }, + { refetchInterval: 5_000 }, + ); + const latestActiveReleases = latestActiveReleasesQ.data ?? []; + const activeRelease = latestActiveReleases.find( + (r) => r.releaseJobTrigger.release.deploymentId === deployment.id, + ); + + const isInProgress = latestActiveReleases.some( + (r) => r.releaseJobTrigger.job.status === JobStatus.InProgress, + ); + const isPending = latestActiveReleases.some( + (r) => r.releaseJobTrigger.job.status === JobStatus.Pending, + ); + const isCompleted = latestActiveReleases.every( + (r) => r.releaseJobTrigger.job.status === JobStatus.Completed, + ); + + const releaseJobTrigger = activeRelease?.releaseJobTrigger; + + return ( + <> +
{ + if (releaseJobTrigger != null) setJobId(releaseJobTrigger.job.id); + }} + > +
+
+ {deployment.name} +
+ {releaseJobTrigger != null && ( +
+ +
+
{releaseJobTrigger.release.version}
+
{JobStatusReadable[releaseJobTrigger.job.status]}
+
+
+ )} + {releaseJobTrigger == null && ( +
+ No active job +
+ )} +
+
+ + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/EnvironmentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/EnvironmentNode.tsx index 33ee352c7..6e1fd195d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/EnvironmentNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/EnvironmentNode.tsx @@ -1,127 +1,40 @@ "use client"; -import type * as SCHEMA from "@ctrlplane/db/schema"; +import type { RouterOutputs } from "@ctrlplane/api"; import type { NodeProps } from "reactflow"; import React from "react"; import { IconPlant } from "@tabler/icons-react"; -import _ from "lodash"; import { Handle, Position } from "reactflow"; -import { cn } from "@ctrlplane/ui"; -import { Skeleton } from "@ctrlplane/ui/skeleton"; -import { JobStatus, JobStatusReadable } from "@ctrlplane/validators/jobs"; - -import { api } from "~/trpc/react"; -import { ReleaseIcon } from "../../ReleaseCell"; - -type Environment = SCHEMA.Environment & { - deployments: SCHEMA.Deployment[]; - resource: SCHEMA.Resource; -}; +type Environment = NonNullable< + RouterOutputs["resource"]["relationships"][number] +>["workspace"]["systems"][number]["environments"][number]; type EnvironmentNodeProps = NodeProps<{ label: string; environment: Environment; }>; -const DeploymentCard: React.FC<{ - deploymentName: string; - job?: SCHEMA.Job; - releaseVersion: string; -}> = ({ deploymentName, job, releaseVersion }) => ( -
-
{deploymentName}
- {job != null && ( -
- -
-
{releaseVersion}
-
{JobStatusReadable[job.status]}
-
-
- )} - {job == null && ( -
- No active job -
- )} -
-); - export const EnvironmentNode: React.FC = (node) => { const { data } = node; - - const resourceId = data.environment.resource.id; - const environmentId = data.environment.id; - const latestActiveReleasesQ = - api.resource.activeReleases.byResourceAndEnvironmentId.useQuery( - { resourceId, environmentId }, - { refetchInterval: 5_000 }, - ); - - const latestActiveReleases = latestActiveReleasesQ.data ?? []; - - const isInProgress = latestActiveReleases.some( - (r) => r.releaseJobTrigger.job.status === JobStatus.InProgress, - ); - const isPending = latestActiveReleases.some( - (r) => r.releaseJobTrigger.job.status === JobStatus.Pending, - ); - const isCompleted = latestActiveReleases.every( - (r) => r.releaseJobTrigger.job.status === JobStatus.Completed, - ); - return ( <> -
+
- {data.label} -
-
- {latestActiveReleasesQ.isLoading && - _.range(3).map((i) => ( - - ))} - {!latestActiveReleasesQ.isLoading && - data.environment.deployments.map((deployment) => { - const latestActiveRelease = latestActiveReleases.find( - (r) => - r.releaseJobTrigger.release.deploymentId === deployment.id, - ); - return ( - - ); - })} + Environment
+
{data.label}
); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ProviderNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ProviderNode.tsx index 90f1fe4d2..8e17f34ba 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ProviderNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ProviderNode.tsx @@ -4,8 +4,6 @@ import React from "react"; import { IconBrandGoogleFilled, IconCube } from "@tabler/icons-react"; import { Handle, Position } from "reactflow"; -import { cn } from "@ctrlplane/ui"; - type ProviderNodeProps = NodeProps<{ id: string; name: string; @@ -25,8 +23,7 @@ export const ProviderIcon: React.FC<{ node: ProviderNodeProps }> = ({ const ProviderLabel: React.FC<{ node: ProviderNodeProps }> = ({ node }) => { const { google } = node.data; - if (google != null) - return Google Provider; + if (google != null) return Google Provider; return Resource Provider; }; @@ -34,12 +31,7 @@ export const ProviderNode: React.FC = (node) => { const { data } = node; return ( <> -
+
@@ -48,19 +40,13 @@ export const ProviderNode: React.FC = (node) => {
); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ResourceNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ResourceNode.tsx index 7f525939c..ed8da8e84 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ResourceNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/ResourceNode.tsx @@ -1,8 +1,6 @@ import type { NodeProps } from "reactflow"; import { Handle, Position } from "reactflow"; -import { cn } from "@ctrlplane/ui"; - import { TargetIcon as ResourceIcon } from "~/app/[workspaceSlug]/(app)/_components/TargetIcon"; type ResourceNodeProps = NodeProps<{ @@ -15,20 +13,9 @@ type ResourceNodeProps = NodeProps<{ export const ResourceNode: React.FC = (node) => { const { data } = node; - const isKubernetes = data.version.includes("kubernetes"); - const isTerraform = data.version.includes("terraform"); - const isSharedCluster = data.kind.toLowerCase().includes("sharedcluster"); - return ( <> -
+
{data.kind} @@ -38,23 +25,13 @@ export const ResourceNode: React.FC = (node) => { ); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/nodes.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/nodes.ts new file mode 100644 index 000000000..f53657bbc --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/nodes/nodes.ts @@ -0,0 +1,84 @@ +import type { RouterOutputs } from "@ctrlplane/api"; +import type { NodeTypes } from "reactflow"; +import { isPresent } from "ts-is-present"; + +import { DeploymentNode } from "./DeploymentNode"; +import { EnvironmentNode } from "./EnvironmentNode"; +import { ProviderNode } from "./ProviderNode"; +import { ResourceNode } from "./ResourceNode"; + +type Relationships = NonNullable; + +enum NodeType { + Resource = "resource", + Environment = "environment", + Provider = "provider", + Deployment = "deployment", +} + +export const nodeTypes: NodeTypes = { + [NodeType.Resource]: ResourceNode, + [NodeType.Environment]: EnvironmentNode, + [NodeType.Provider]: ProviderNode, + [NodeType.Deployment]: DeploymentNode, +}; + +const getResourceNodes = (relationships: Relationships) => + relationships.map((r) => ({ + id: r.id, + type: NodeType.Resource, + data: { ...r, label: r.identifier }, + position: { x: 0, y: 0 }, + })); + +const getProviderNodes = (relationships: Relationships) => + relationships + .map((r) => + r.provider != null + ? { + id: `${r.provider.id}-${r.id}`, + type: NodeType.Provider, + data: { ...r.provider, label: r.provider.name }, + position: { x: 0, y: 0 }, + } + : null, + ) + .filter(isPresent); + +const getEnvironmentNodes = (relationships: Relationships) => + relationships + .flatMap((r) => r.workspace.systems) + .flatMap((s) => s.environments.map((e) => ({ s, e }))) + .map(({ s, e }) => ({ + id: e.id, + type: NodeType.Environment, + data: { environment: e, label: `${s.name}/${e.name}` }, + position: { x: 0, y: 0 }, + })); + +const getDeploymentNodes = (relationships: Relationships) => + relationships.flatMap((r) => + r.workspace.systems.flatMap((system) => + system.environments.flatMap((environment) => + system.deployments.map((deployment) => ({ + id: `${environment.id}-${deployment.id}`, + type: NodeType.Deployment, + data: { + deployment, + environment, + resource: r, + label: deployment.name, + }, + position: { x: 0, y: 0 }, + })), + ), + ), + ); + +export const getNodes = (relationships: Relationships) => + [ + ...getResourceNodes(relationships), + ...getProviderNodes(relationships), + ...getEnvironmentNodes(relationships), + ...getDeploymentNodes(relationships), + ].filter(isPresent); diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/page.tsx index 3da323303..2344699a6 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/(targets)/targets/[targetId]/visualize/page.tsx @@ -1,5 +1,3 @@ -import { notFound } from "next/navigation"; - import { api } from "~/trpc/server"; import { ResourceVisualizationDiagramProvider } from "./ResourceVisualizationDiagram"; @@ -8,16 +6,6 @@ export default async function VisualizePage({ }: { params: { targetId: string }; }) { - const [resource, relationships] = await Promise.all([ - api.resource.byId(targetId), - api.resource.relationships(targetId), - ]); - if (resource == null || relationships == null) return notFound(); - - return ( - - ); + const relationships = await api.resource.relationships(targetId); + return ; } diff --git a/packages/api/src/router/resources.ts b/packages/api/src/router/resources.ts index 5bbd8797d..677d1c235 100644 --- a/packages/api/src/router/resources.ts +++ b/packages/api/src/router/resources.ts @@ -330,6 +330,191 @@ const environmentHasResource = ( }) .then((matchedResource) => matchedResource != null); +const latestActiveReleaseByResourceAndEnvironmentId = ( + db: Tx, + resourceId: string, + environmentId: string, +) => { + const rankSubquery = db + .select({ + rank: sql`ROW_NUMBER() OVER (PARTITION BY ${schema.release.deploymentId} ORDER BY ${schema.release.createdAt} DESC)`.as( + "rank", + ), + rankReleaseId: schema.release.id, + rankDeploymentId: schema.release.deploymentId, + }) + .from(schema.release) + .as("rank_subquery"); + + return db + .select() + .from(schema.deployment) + .innerJoin(schema.system, eq(schema.system.id, schema.deployment.systemId)) + .innerJoin( + schema.environment, + eq(schema.environment.systemId, schema.system.id), + ) + .innerJoin( + schema.release, + eq(schema.release.deploymentId, schema.deployment.id), + ) + .innerJoin( + rankSubquery, + and( + eq(rankSubquery.rankDeploymentId, schema.release.deploymentId), + eq(rankSubquery.rankReleaseId, schema.release.id), + ), + ) + .innerJoin( + schema.releaseJobTrigger, + and( + eq(schema.releaseJobTrigger.releaseId, schema.release.id), + eq(schema.releaseJobTrigger.environmentId, schema.environment.id), + ), + ) + .innerJoin( + schema.resource, + eq(schema.resource.id, schema.releaseJobTrigger.resourceId), + ) + .innerJoin(schema.job, eq(schema.releaseJobTrigger.jobId, schema.job.id)) + .where( + and( + eq(schema.resource.id, resourceId), + eq(schema.environment.id, environmentId), + isNull(schema.resource.deletedAt), + eq(rankSubquery.rank, 1), + ), + ) + .orderBy(schema.deployment.id, schema.releaseJobTrigger.createdAt) + .then((r) => + r.map((row) => ({ + ...row.deployment, + environment: row.environment, + system: row.system, + releaseJobTrigger: { + ...row.release_job_trigger, + job: row.job, + release: row.release, + resourceId: row.resource.id, + }, + })), + ); +}; + +const getNodeDataForResource = async (db: Tx, resourceId: string) => { + const hasFilter = isNotNull(schema.environment.resourceFilter); + const resource = await db.query.resource.findFirst({ + where: and(eq(schema.resource.id, resourceId), isNotDeleted), + with: { + provider: { with: { google: true } }, + workspace: { + with: { + systems: { + with: { environments: { where: hasFilter }, deployments: true }, + }, + }, + }, + }, + }); + if (resource == null) return null; + + const matchesIdentifier = eq( + schema.jobResourceRelationship.resourceIdentifier, + resource.identifier, + ); + const parent = await db.query.jobResourceRelationship.findFirst({ + where: matchesIdentifier, + }); + + const { systems } = resource.workspace; + const systemsWithResource = await _.chain( + systems.map(async (s) => + _.chain(s.environments) + .filter((e) => isPresent(e.resourceFilter)) + .map((e) => + environmentHasResource(db, resource.id, e.resourceFilter!).then( + async (t) => + t + ? { + ...e, + resource, + latestActiveReleases: + await latestActiveReleaseByResourceAndEnvironmentId( + db, + resource.id, + e.id, + ), + } + : null, + ), + ) + .thru((promises) => Promise.all(promises)) + .thru((results) => { + return results; + }) + .value() + .then((t) => t.filter(isPresent)) + .then((t) => (t.length > 0 ? { ...s, environments: t } : null)), + ), + ) + .thru((promises) => Promise.all(promises)) + .value() + .then((t) => t.filter(isPresent)); + + const provider = + resource.provider == null + ? null + : { + ...resource.provider, + google: resource.provider.google[0] ?? null, + }; + + return { + ...resource, + workspace: { ...resource.workspace, systems: systemsWithResource }, + provider, + parent: parent ?? null, + }; +}; + +type Node = Awaited>; + +const getNodesRecursivelyHelper = async ( + db: Tx, + node: Node, + nodes: NonNullable[], +): Promise[]> => { + if (node == null) return nodes; + const activeReleaseJobs = node.workspace.systems + .flatMap((s) => s.environments) + .flatMap((e) => e.latestActiveReleases) + .map((r) => r.releaseJobTrigger.job); + + const relationships = await db.query.jobResourceRelationship.findMany({ + where: inArray( + schema.jobResourceRelationship.jobId, + activeReleaseJobs.map((j) => j.id), + ), + with: { resource: true }, + }); + + const childrenPromises = relationships.map((r) => + getNodeDataForResource(db, r.resource.id), + ); + const children = await Promise.all(childrenPromises); + + const childrenNodesPromises = children.map((c) => + getNodesRecursivelyHelper(db, c, []), + ); + const childrenNodes = (await Promise.all(childrenNodesPromises)).flat(); + return [...nodes, node, ...childrenNodes].filter(isPresent); +}; + +const getNodesRecursively = async (db: Tx, resourceId: string) => { + const baseNode = await getNodeDataForResource(db, resourceId); + return getNodesRecursivelyHelper(db, baseNode, []); +}; + export const resourceRouter = createTRPCRouter({ metadataGroup: resourceMetadataGroupRouter, provider: resourceProviderRouter, @@ -373,79 +558,13 @@ export const resourceRouter = createTRPCRouter({ .perform(Permission.ResourceGet) .on({ type: "resource", id: input.resourceId }), }) - .query(async ({ ctx, input }) => { - const { resourceId, environmentId } = input; - const rankSubquery = ctx.db - .select({ - rank: sql`ROW_NUMBER() OVER (PARTITION BY ${schema.release.deploymentId} ORDER BY ${schema.release.createdAt} DESC)`.as( - "rank", - ), - rankReleaseId: schema.release.id, - rankDeploymentId: schema.release.deploymentId, - }) - .from(schema.release) - .as("rank_subquery"); - - return ctx.db - .select() - .from(schema.deployment) - .innerJoin( - schema.system, - eq(schema.system.id, schema.deployment.systemId), - ) - .innerJoin( - schema.environment, - eq(schema.environment.systemId, schema.system.id), - ) - .innerJoin( - schema.release, - eq(schema.release.deploymentId, schema.deployment.id), - ) - .innerJoin( - rankSubquery, - and( - eq(rankSubquery.rankDeploymentId, schema.release.deploymentId), - eq(rankSubquery.rankReleaseId, schema.release.id), - ), - ) - .innerJoin( - schema.releaseJobTrigger, - and( - eq(schema.releaseJobTrigger.releaseId, schema.release.id), - eq(schema.releaseJobTrigger.environmentId, schema.environment.id), - ), - ) - .innerJoin( - schema.resource, - eq(schema.resource.id, schema.releaseJobTrigger.resourceId), - ) - .innerJoin( - schema.job, - eq(schema.releaseJobTrigger.jobId, schema.job.id), - ) - .where( - and( - eq(schema.resource.id, resourceId), - eq(schema.environment.id, environmentId), - isNull(schema.resource.deletedAt), - eq(rankSubquery.rank, 1), - ), - ) - .orderBy(schema.deployment.id, schema.releaseJobTrigger.createdAt) - .then((r) => - r.map((row) => ({ - ...row.deployment, - environment: row.environment, - system: row.system, - releaseJobTrigger: { - ...row.release_job_trigger, - job: row.job, - release: row.release, - resourceId: row.resource.id, - }, - })), - ); - }), + .query(({ ctx, input }) => + latestActiveReleaseByResourceAndEnvironmentId( + ctx.db, + input.resourceId, + input.environmentId, + ), + ), }), relationships: protectedProcedure @@ -456,59 +575,7 @@ export const resourceRouter = createTRPCRouter({ .on({ type: "resource", id: input }), }) .input(z.string().uuid()) - .query(async ({ ctx, input }) => { - const hasFilter = isNotNull(schema.environment.resourceFilter); - const resource = await ctx.db.query.resource.findFirst({ - where: and(eq(schema.resource.id, input), isNotDeleted), - with: { - provider: { with: { google: true } }, - workspace: { - with: { - systems: { - with: { environments: { where: hasFilter }, deployments: true }, - }, - }, - }, - }, - }); - if (resource == null) return null; - - const { systems } = resource.workspace; - const systemsWithResource = await _.chain( - systems.map(async (s) => - _.chain(s.environments) - .filter((e) => isPresent(e.resourceFilter)) - .map((e) => - environmentHasResource( - ctx.db, - resource.id, - e.resourceFilter!, - ).then((t) => (t ? { ...e, resource } : null)), - ) - .thru((promises) => Promise.all(promises)) - .value() - .then((t) => t.filter(isPresent)) - .then((t) => (t.length > 0 ? { ...s, environments: t } : null)), - ), - ) - .thru((promises) => Promise.all(promises)) - .value() - .then((t) => t.filter(isPresent)); - - const provider = - resource.provider == null - ? null - : { - ...resource.provider, - google: resource.provider.google[0] ?? null, - }; - - return { - ...resource, - workspace: { ...resource.workspace, systems: systemsWithResource }, - provider, - }; - }), + .query(async ({ ctx, input }) => getNodesRecursively(ctx.db, input)), byWorkspaceId: createTRPCRouter({ list: protectedProcedure diff --git a/packages/db/src/schema/job.ts b/packages/db/src/schema/job.ts index 2cb3f423e..4f186ed63 100644 --- a/packages/db/src/schema/job.ts +++ b/packages/db/src/schema/job.ts @@ -47,7 +47,7 @@ import { deployment } from "./deployment.js"; import { environment } from "./environment.js"; import { jobAgent } from "./job-agent.js"; import { release, releaseJobTrigger } from "./release.js"; -import { resource } from "./resource.js"; +import { jobResourceRelationship, resource } from "./resource.js"; // if adding a new status, update the validators package @ctrlplane/validators/src/jobs/index.ts export const jobStatus = pgEnum("job_status", [ @@ -97,6 +97,7 @@ export const job = pgTable("job", { export const jobRelations = relations(job, ({ many }) => ({ releaseTrigger: many(releaseJobTrigger), + jobRelationships: many(jobResourceRelationship), })); export const jobMetadata = pgTable( diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index 68d2dd6e5..c93b74295 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -78,6 +78,7 @@ export const resourceRelations = relations(resource, ({ one, many }) => ({ references: [workspace.id], }), releaseTrigger: many(releaseJobTrigger), + jobRelationships: many(jobResourceRelationship), })); export type Resource = InferSelectModel; @@ -283,8 +284,20 @@ export const jobResourceRelationship = pgTable( .notNull(), resourceIdentifier: text("resource_identifier").notNull(), }, - (t) => ({ - uniq: uniqueIndex().on(t.jobId, t.resourceIdentifier), + (t) => ({ uniq: uniqueIndex().on(t.jobId, t.resourceIdentifier) }), +); + +export const jobResourceRelationshipRelations = relations( + jobResourceRelationship, + ({ one }) => ({ + job: one(job, { + fields: [jobResourceRelationship.jobId], + references: [job.id], + }), + resource: one(resource, { + fields: [jobResourceRelationship.resourceIdentifier], + references: [resource.identifier], + }), }), );