diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/RelationshipsDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/RelationshipsDiagram.tsx new file mode 100644 index 000000000..231ffa208 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/RelationshipsDiagram.tsx @@ -0,0 +1,131 @@ +"use client"; + +import type * as schema from "@ctrlplane/db/schema"; +import type { EdgeTypes, NodeTypes } from "reactflow"; +import ReactFlow, { + MarkerType, + ReactFlowProvider, + useEdgesState, + useNodesState, +} from "reactflow"; +import colors from "tailwindcss/colors"; + +import { useLayoutAndFitView } from "~/app/[workspaceSlug]/(app)/_components/reactflow/layout"; +import { DepEdge } from "./DepEdge"; +import { ResourceNode } from "./ResourceNode"; + +type ParentRelationship = { + ruleId: string; + type: schema.ResourceDependencyType; + target: schema.Resource; + reference: string; +}; + +type ChildRelationship = { + ruleId: string; + type: schema.ResourceDependencyType; + source: schema.Resource; + reference: string; +}; + +type RelationshipsDiagramProps = { + resource: schema.Resource; + parents: ParentRelationship[]; + children: ChildRelationship[]; +}; + +const getNodes = (resources: schema.Resource[]) => + resources.map((r) => ({ + id: r.id, + type: "resource", + data: { ...r, label: r.identifier }, + position: { x: 0, y: 0 }, + })); + +const markerEnd = { + type: MarkerType.Arrow, + color: colors.neutral[800], +}; + +const nodeTypes: NodeTypes = { resource: ResourceNode }; +const edgeTypes: EdgeTypes = { default: DepEdge }; + +const getParentEdges = ( + parents: ParentRelationship[], + resource: schema.Resource, +) => + parents.map((p) => ({ + id: `${p.ruleId}-${p.target.id}`, + source: p.target.id, + target: resource.id, + style: { stroke: colors.neutral[800] }, + markerEnd, + label: p.type, + })); + +const getChildEdges = ( + children: ChildRelationship[], + resource: schema.Resource, +) => + children.map((c) => ({ + id: `${c.ruleId}-${c.source.id}`, + source: resource.id, + target: c.source.id, + style: { stroke: colors.neutral[800] }, + markerEnd, + label: c.type, + })); + +export const RelationshipsDiagram: React.FC = ({ + resource, + parents, + children, +}) => { + const [nodes, _, onNodesChange] = useNodesState<{ label: string }>( + getNodes([ + resource, + ...parents.map((p) => p.target), + ...children.map((c) => c.source), + ]), + ); + + const [edges, __, onEdgesChange] = useEdgesState([ + ...getParentEdges(parents, resource), + ...getChildEdges(children, resource), + ]); + + const { setReactFlowInstance } = useLayoutAndFitView(nodes, { + direction: "LR", + extraEdgeLength: 50, + focusedNodeId: resource.id, + }); + + return ( + + ); +}; + +export const RelationshipsDiagramProvider: React.FC< + RelationshipsDiagramProps +> = ({ resource, parents, children }) => { + return ( + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ResourceNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceNode.tsx similarity index 88% rename from apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ResourceNode.tsx rename to apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceNode.tsx index 5cd61690d..4132f9bff 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ResourceNode.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceNode.tsx @@ -1,4 +1,7 @@ +"use client"; + import type { NodeProps } from "reactflow"; +import { useParams } from "next/navigation"; import { Handle, Position } from "reactflow"; import { cn } from "@ctrlplane/ui"; @@ -12,17 +15,17 @@ type ResourceNodeProps = NodeProps<{ id: string; kind: string; version: string; - isBaseNode: boolean; }>; export const ResourceNode: React.FC = (node) => { const { data } = node; + const { resourceId } = useParams<{ resourceId: string }>(); const { setResourceId } = useResourceDrawer(); return ( <>
setResourceId(data.id)} > diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceVisualizationDiagram.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceVisualizationDiagram.tsx deleted file mode 100644 index 02dcb771c..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/ResourceVisualizationDiagram.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@ctrlplane/api"; -import React from "react"; -import { IconLoader2, IconNetworkOff } from "@tabler/icons-react"; -import ReactFlow, { - ReactFlowProvider, - useEdgesState, - useNodesState, -} from "reactflow"; - -import { useLayoutAndFitView } from "~/app/[workspaceSlug]/(app)/_components/reactflow/layout"; -import { api } from "~/trpc/react"; -import { edgeTypes, getEdges } from "./edges"; -import { getNodes, nodeTypes } from "./nodes/nodes"; - -type Relationships = NonNullable; - -type ResourceVisualizationDiagramProps = { - relationships: Relationships; -}; - -export const ResourceVisualizationDiagram: React.FC< - ResourceVisualizationDiagramProps -> = ({ relationships }) => { - const [nodes, _, onNodesChange] = useNodesState<{ label: string }>( - getNodes(relationships), - ); - - const [edges, __, onEdgesChange] = useEdgesState(getEdges(relationships)); - - const { setReactFlowInstance } = useLayoutAndFitView(nodes, { - direction: "LR", - extraEdgeLength: 50, - focusedNodeId: relationships.resource.id, - }); - - return ( - - ); -}; - -type ResourceVisualizationDiagramProviderProps = { - resourceId: string; -}; - -export const ResourceVisualizationDiagramProvider: React.FC< - ResourceVisualizationDiagramProviderProps -> = ({ resourceId }) => { - const { data: relationships, isLoading } = - api.resource.relationships.useQuery(resourceId, { - refetchInterval: 60_000, - }); - - if (isLoading) - return ( -
- -
- ); - - if (!relationships) - return ( -
- No relationships found -
- ); - - return ( - - - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/edges.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/edges.ts deleted file mode 100644 index 7092366a4..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/edges.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { RouterOutputs } from "@ctrlplane/api"; -import type * as SCHEMA from "@ctrlplane/db/schema"; -import type { EdgeTypes } from "reactflow"; -import { MarkerType } from "reactflow"; -import colors from "tailwindcss/colors"; -import { isPresent } from "ts-is-present"; - -import { DepEdge } from "./DepEdge"; - -type Provider = SCHEMA.ResourceProvider & { - google: SCHEMA.ResourceProviderGoogle | null; -}; - -const markerEnd = { - type: MarkerType.Arrow, - color: colors.neutral[800], -}; - -export const edgeTypes: EdgeTypes = { default: DepEdge }; - -const createEdgesFromResourceToEnvironments = ( - resource: SCHEMA.Resource, - environments: SCHEMA.Environment[], -) => - environments.map((environment) => ({ - id: `${resource.id}-${environment.id}`, - source: resource.id, - target: environment.id, - style: { stroke: colors.neutral[800] }, - markerEnd, - label: "in", - })); - -const createEdgeFromProviderToResource = ( - provider: Provider | null, - resource: SCHEMA.Resource, -) => - provider != null - ? { - id: `${provider.id}-${resource.id}`, - source: `${provider.id}-${resource.id}`, - target: resource.id, - 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.nodes.map((resource) => { - const { parent } = resource; - if (parent == null) return null; - - const allReleaseJobTriggers = relationships.nodes - .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.deploymentVersion; - 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.nodes.flatMap((r) => - createEdgesFromResourceToEnvironments( - r, - r.workspace.systems.flatMap((s) => s.environments), - ), - ); - const environmentToDeploymentEdges = relationships.nodes.flatMap((r) => - r.workspace.systems.flatMap((s) => - createEdgesFromEnvironmentToDeployments(s.environments, s.deployments), - ), - ); - const providerEdges = relationships.nodes.flatMap((r) => - r.provider != null ? [createEdgeFromProviderToResource(r.provider, r)] : [], - ); - const deploymentEdges = createEdgesFromDeploymentsToResources(relationships); - - const { resource } = relationships; - - const fromEdges = relationships.associations.from.map((r) => ({ - id: `${r.resource.id}-${resource.id}`, - source: r.resource.id, - target: resource.id, - style: { stroke: colors.neutral[800] }, - markerEnd, - label: r.type, - })); - - const toEdges = relationships.associations.to.map((r) => ({ - id: `${resource.id}-${r.resource.id}`, - source: resource.id, - target: r.resource.id, - style: { stroke: colors.neutral[800] }, - markerEnd, - label: r.type, - })); - - return [ - ...resourceToEnvEdges, - ...environmentToDeploymentEdges, - ...providerEdges, - ...deploymentEdges, - ...fromEdges, - ...toEdges, - ].filter(isPresent); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/DeploymentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/DeploymentNode.tsx deleted file mode 100644 index 959c9587c..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/DeploymentNode.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; -import type { NodeProps } from "reactflow"; -import React from "react"; -import { - IconAlertCircle, - IconCircleCheck, - IconCircleDashed, - IconCircleX, - IconClock, - IconLoader2, -} from "@tabler/icons-react"; -import { Handle, Position } from "reactflow"; - -import { cn } from "@ctrlplane/ui"; -import { JobStatus, JobStatusReadable } from "@ctrlplane/validators/jobs"; - -import { useDeploymentEnvResourceDrawer } from "~/app/[workspaceSlug]/(app)/_components/deployments/resource-drawer/useDeploymentResourceDrawer"; -import { api } from "~/trpc/react"; - -const StatusIcon: React.FC<{ - job?: SCHEMA.Job; -}> = ({ job }) => { - if (job?.status === JobStatus.Pending) - return ( -
- -
- ); - - if (job?.status === JobStatus.InProgress) - return ( -
- -
- ); - - if (job?.status === JobStatus.Successful) - return ( -
- -
- ); - - if (job?.status === JobStatus.Cancelled) - return ( -
- -
- ); - - if (job?.status === JobStatus.Failure) - return ( -
- -
- ); - - if (job?.status === JobStatus.Skipped) - return ( -
- -
- ); - - if ( - job?.status === JobStatus.InvalidJobAgent || - job?.status === JobStatus.InvalidIntegration - ) - return ( -
- -
- ); - - return null; -}; - -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 { setDeploymentEnvResourceId } = useDeploymentEnvResourceDrawer(); - - const resourceId = resource.id; - const environmentId = environment.id; - const latestDeployedVersionsQ = - api.resource.latestDeployedVersions.byResourceAndEnvironmentId.useQuery( - { resourceId, environmentId }, - { refetchInterval: 5_000 }, - ); - const latestDeployedVersions = latestDeployedVersionsQ.data ?? []; - const latestDeployedVersion = latestDeployedVersions.find( - (r) => r.releaseJobTrigger.deploymentVersion.deploymentId === deployment.id, - ); - - const isInProgress = latestDeployedVersions.some( - (r) => r.releaseJobTrigger.job.status === JobStatus.InProgress, - ); - const isPending = latestDeployedVersions.some( - (r) => r.releaseJobTrigger.job.status === JobStatus.Pending, - ); - const isSuccess = latestDeployedVersions.every( - (r) => r.releaseJobTrigger.job.status === JobStatus.Successful, - ); - - const releaseJobTrigger = latestDeployedVersion?.releaseJobTrigger; - - return ( - <> -
- setDeploymentEnvResourceId(deployment.id, environment.id, resource.id) - } - > - -
- {deployment.name} - {releaseJobTrigger != null && ( - - {releaseJobTrigger.deploymentVersion.name} -{" "} - {JobStatusReadable[releaseJobTrigger.job.status]} - - )} - {releaseJobTrigger == null && ( - - No versions deployed - - )} -
-
- - - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/EnvironmentNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/EnvironmentNode.tsx deleted file mode 100644 index 8fa542a7f..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/EnvironmentNode.tsx +++ /dev/null @@ -1,47 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@ctrlplane/api"; -import type { NodeProps } from "reactflow"; -import React from "react"; -import { IconPlant } from "@tabler/icons-react"; -import { Handle, Position } from "reactflow"; - -import { useEnvironmentDrawer } from "~/app/[workspaceSlug]/(app)/_components/environment/drawer/EnvironmentDrawer"; - -type Environment = NonNullable< - RouterOutputs["resource"]["relationships"] ->["nodes"][number]["workspace"]["systems"][number]["environments"][number]; - -type EnvironmentNodeProps = NodeProps<{ - label: string; - environment: Environment; -}>; - -export const EnvironmentNode: React.FC = (node) => { - const { data } = node; - const { setEnvironmentId } = useEnvironmentDrawer(); - return ( - <> -
setEnvironmentId(data.environment.id)} - > -
- - Environment -
- {data.label} -
- - - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ProviderNode.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ProviderNode.tsx deleted file mode 100644 index f194704a9..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/ProviderNode.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type * as SCHEMA from "@ctrlplane/db/schema"; -import type { NodeProps } from "reactflow"; -import React from "react"; -import { IconBrandGoogleFilled, IconCube } from "@tabler/icons-react"; -import { Handle, Position } from "reactflow"; - -type ProviderNodeProps = NodeProps<{ - id: string; - name: string; - label: string; - workspaceId: string; - google: SCHEMA.ResourceProviderGoogle | null; -}>; - -export const ProviderIcon: React.FC<{ node: ProviderNodeProps }> = ({ - node, -}) => { - const { google } = node.data; - if (google != null) - return ; - return ; -}; - -const ProviderLabel: React.FC<{ node: ProviderNodeProps }> = ({ node }) => { - const { google } = node.data; - if (google != null) return Google Provider; - return Resource Provider; -}; - -export const ProviderNode: React.FC = (node) => { - const { data } = node; - return ( - <> -
-
- - -
- {data.label} -
- - - - ); -}; diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/nodes.ts b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/nodes.ts deleted file mode 100644 index 7e08b4d9a..000000000 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/nodes/nodes.ts +++ /dev/null @@ -1,88 +0,0 @@ -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.nodes.map((r) => ({ - id: r.id, - type: NodeType.Resource, - data: { - ...r, - label: r.identifier, - isBaseNode: r.id === relationships.resource.id, - }, - position: { x: 0, y: 0 }, - })); - -const getProviderNodes = (relationships: Relationships) => - relationships.nodes - .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.nodes - .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.nodes.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)/resources/(raw)/[resourceId]/visualize/page.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/page.tsx index 27a2b16bf..ddacf3f42 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/page.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/resources/(raw)/[resourceId]/visualize/page.tsx @@ -1,10 +1,48 @@ -import { ResourceVisualizationDiagramProvider } from "./ResourceVisualizationDiagram"; +import type * as schema from "@ctrlplane/db/schema"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; -export default async function VisualizePage(props: { - params: Promise<{ resourceId: string }>; -}) { - const params = await props.params; - const { resourceId } = params; +import { api } from "~/trpc/server"; +import { RelationshipsDiagramProvider } from "./RelationshipsDiagram"; - return ; +type PageProps = { + params: Promise<{ workspaceSlug: string; resourceId: string }>; +}; + +export async function generateMetadata(props: PageProps): Promise { + const { workspaceSlug, resourceId } = await props.params; + const [workspace, resource] = await Promise.all([ + api.workspace.bySlug(workspaceSlug), + api.resource.byId(resourceId), + ]); + + if (workspace == null || resource == null) return notFound(); + + return { + title: `Visualize | ${resource.name} | ${workspace.name}`, + }; +} + +export default async function RelationshipsPage(props: PageProps) { + const { resourceId } = await props.params; + const { resource, ...relationships } = + await api.resource.relationships(resourceId); + + const parents = Object.values(relationships.parents).map((p) => ({ + ...p, + type: p.type as schema.ResourceDependencyType, + })); + + const children = relationships.children.map((c) => ({ + ...c, + type: c.type as schema.ResourceDependencyType, + })); + + return ( + + ); } diff --git a/packages/api/src/router/resources.ts b/packages/api/src/router/resources.ts index 72569f78e..f892b648e 100644 --- a/packages/api/src/router/resources.ts +++ b/packages/api/src/router/resources.ts @@ -1,5 +1,4 @@ import type { SQL, Tx } from "@ctrlplane/db"; -import type { ResourceCondition } from "@ctrlplane/validators/resources"; import _ from "lodash"; import { isPresent } from "ts-is-present"; import { z } from "zod"; @@ -10,7 +9,6 @@ import { desc, eq, inArray, - isNotNull, isNull, not, sql, @@ -18,6 +16,7 @@ import { takeFirstOrNull, } from "@ctrlplane/db"; import { + getResourceChildren, getResourceParents, getResourceRelationshipRules, } from "@ctrlplane/db/queries"; @@ -69,21 +68,6 @@ const resourceQuery = (db: Tx, checks: Array>) => schema.workspace.id, ); -const environmentHasResource = ( - db: Tx, - resourceId: string, - resourceSelector: ResourceCondition, -) => - db.query.resource - .findFirst({ - where: and( - eq(schema.resource.id, resourceId), - schema.resourceMatchesMetadata(db, resourceSelector), - isNotDeleted, - ), - }) - .then((matchedResource) => matchedResource != null); - const latestDeployedVersionByResourceAndEnvironmentId = ( db: Tx, resourceId: string, @@ -164,201 +148,6 @@ const latestDeployedVersionByResourceAndEnvironmentId = ( ); }; -const getNodeDataForResource = async ( - db: Tx, - resourceId: string, - jobId?: string, -) => { - const hasFilter = isNotNull(schema.environment.resourceSelector); - 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 matchesJobId = - jobId == null ? undefined : eq(schema.jobResourceRelationship.jobId, jobId); - const parent = await db.query.jobResourceRelationship.findFirst({ - where: and(matchesIdentifier, matchesJobId), - }); - - const { systems } = resource.workspace; - const systemsWithResource = await _.chain( - systems.map(async (s) => - _.chain(s.environments) - .filter((e) => isPresent(e.resourceSelector)) - .map((e) => - environmentHasResource(db, resource.id, e.resourceSelector!).then( - async (t) => - t - ? { - ...e, - resource, - latestActiveReleases: - await latestDeployedVersionByResourceAndEnvironmentId( - 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 getChildrenNodesRecursivelyHelper = 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 jobIds = activeReleaseJobs.map((j) => j.id); - const relationships = await db - .select() - .from(schema.jobResourceRelationship) - .leftJoin( - schema.resource, - eq( - schema.jobResourceRelationship.resourceIdentifier, - schema.resource.identifier, - ), - ) - .where(inArray(schema.jobResourceRelationship.jobId, jobIds)) - .then((rows) => - rows - .map((r) => - r.resource != null - ? { ...r.job_resource_relationship, resource: r.resource } - : null, - ) - .filter(isPresent), - ); - - const childrenPromises = relationships.map((r) => - getNodeDataForResource(db, r.resource.id, r.jobId), - ); - const children = await Promise.all(childrenPromises); - - const childrenNodesPromises = children.map((c) => - getChildrenNodesRecursivelyHelper(db, c, []), - ); - const childrenNodes = (await Promise.all(childrenNodesPromises)).flat(); - return [...nodes, node, ...childrenNodes].filter(isPresent); -}; - -const getChildrenNodesRecursively = async (db: Tx, resourceId: string) => { - const baseNode = await getNodeDataForResource(db, resourceId); - return getChildrenNodesRecursivelyHelper(db, baseNode, []); -}; - -type ParentNodesResult = { - parentNodes: NonNullable[]; - node: Node; -}; - -const getParentNodesRecursivelyHelper = async ( - db: Tx, - node: Node, - nodes: NonNullable[], -): Promise => { - if (node == null) return { parentNodes: nodes, node }; - - const parentJob = await db - .select() - .from(schema.jobResourceRelationship) - .innerJoin( - schema.job, - eq(schema.jobResourceRelationship.jobId, schema.job.id), - ) - .innerJoin( - schema.releaseJobTrigger, - eq(schema.releaseJobTrigger.jobId, schema.job.id), - ) - .innerJoin( - schema.resource, - eq(schema.releaseJobTrigger.resourceId, schema.resource.id), - ) - .where( - and( - eq(schema.jobResourceRelationship.resourceIdentifier, node.identifier), - isNull(schema.resource.deletedAt), - eq(schema.resource.workspaceId, node.workspaceId), - ), - ) - .orderBy(desc(schema.releaseJobTrigger.createdAt)) - .limit(1) - .then(takeFirstOrNull); - - if (parentJob == null) return { parentNodes: nodes, node }; - - const parentNode = await getNodeDataForResource(db, parentJob.resource.id); - if (parentNode == null) return { parentNodes: nodes, node }; - - const { job_resource_relationship: parentRelationship } = parentJob; - - const { parentNodes, node: parentNodeWithData } = - await getParentNodesRecursivelyHelper(db, parentNode, []); - - const nodeWithParent = { ...node, parent: parentRelationship }; - - return { - parentNodes: [...parentNodes, parentNodeWithData].filter(isPresent), - node: nodeWithParent, - }; -}; - -const getParentNodesRecursively = async (db: Tx, resourceId: string) => { - const baseNode = await getNodeDataForResource(db, resourceId); - return getParentNodesRecursivelyHelper(db, baseNode, []); -}; - export const resourceRouter = createTRPCRouter({ metadataGroup: resourceMetadataGroupRouter, provider: resourceProviderRouter, @@ -454,89 +243,19 @@ export const resourceRouter = createTRPCRouter({ }) .input(z.string().uuid()) .query(async ({ ctx, input }) => { - const resource = await ctx.db.query.resource.findFirst({ - where: eq(schema.resource.id, input), - }); - if (resource == null) return null; - const childrenNodes = await getChildrenNodesRecursively(ctx.db, input); - const { parentNodes, node } = await getParentNodesRecursively( - ctx.db, - input, - ); - - const childrenNodesUpdated = childrenNodes.map((n) => - n.id === node?.id ? node : n, - ); - - const nodesQuery = ctx.db + const resource = await ctx.db .select() - .from(schema.resourceRelationship) - .innerJoin( - schema.resource, - eq( - schema.resourceRelationship.fromIdentifier, - schema.resource.identifier, - ), - ); - - const fromNodesPromises = nodesQuery - .where( - and( - eq(schema.resourceRelationship.workspaceId, resource.workspaceId), - eq(schema.resourceRelationship.toIdentifier, resource.identifier), - ), - ) - .then((rows) => - rows.map(async (row) => ({ - ...row, - node: await getNodeDataForResource(ctx.db, row.resource.id), - })), - ) - .then((promises) => Promise.all(promises)); - - const toNodesPromises = nodesQuery - .where( - and( - eq(schema.resourceRelationship.workspaceId, resource.workspaceId), - eq(schema.resourceRelationship.fromIdentifier, resource.identifier), - ), - ) - .then((rows) => - rows.map(async (row) => ({ - ...row, - node: await getNodeDataForResource(ctx.db, row.resource.id), - })), - ) - .then((promises) => Promise.all(promises)); + .from(schema.resource) + .where(eq(schema.resource.id, input)) + .then(takeFirst); - const [fromNodes, toNodes] = await Promise.all([ - fromNodesPromises, - toNodesPromises, - ]); + const { relationships: parents } = await getResourceParents( + ctx.db, + resource.id, + ); + const children = await getResourceChildren(ctx.db, resource.id); - return { - resource, - nodes: [ - ...parentNodes, - ...childrenNodesUpdated, - ...fromNodes.map((n) => n.node), - ...toNodes.map((n) => n.node), - ].filter(isPresent), - associations: { - from: fromNodes - .filter((n) => isPresent(n.node)) - .map((n) => ({ - ...n.resource_relationship, - resource: n.node!, - })), - to: toNodes - .filter((n) => isPresent(n.node)) - .map((n) => ({ - ...n.resource_relationship, - resource: n.node!, - })), - }, - }; + return { resource, parents, children }; }), byWorkspaceId: createTRPCRouter({