diff --git a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsx b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsx index 59c2bafb2..1cd4d32ba 100644 --- a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsx +++ b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/DeploymentVersion.tsx @@ -6,6 +6,7 @@ import type { ApprovalDetailProps } from "./rule-results/ApprovalDetail"; import { trpc } from "~/api/trpc"; import { Spinner } from "~/components/ui/spinner"; import { ApprovalDetail } from "./rule-results/ApprovalDetail"; +import { DependencyDetail } from "./rule-results/DependencyDetail"; import { DeploymentWindowDetail } from "./rule-results/DeploymentWindowDetail"; import { EnvironmentProgressionDetail } from "./rule-results/EnvironmentProgressionDetail"; import { GradRolloutDetail } from "./rule-results/GradRolloutDetail"; @@ -14,7 +15,7 @@ import { VersionStatusDetail } from "./rule-results/VersionStatusDetail"; type DeploymentVersionProps = { version: { id: string; status: DeploymentVersionStatus }; - environment: { id: string }; + environment: { id: string; name: string }; }; export function DeploymentVersion(props: DeploymentVersionProps) { @@ -45,9 +46,8 @@ export function DeploymentVersion(props: DeploymentVersionProps) { ); - if (data == null) return null; - - const approvalEval = "approval" in rules ? rules.approval[0] : undefined; + const approvalEval = + data != null && "approval" in rules ? rules.approval[0] : undefined; const approvalDetail = approvalEval?.details as | ApprovalDetailProps | undefined; @@ -83,6 +83,7 @@ export function DeploymentVersion(props: DeploymentVersionProps) { skippedRuleIds={skippedRuleIds} /> )} + ); diff --git a/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx new file mode 100644 index 000000000..ea16ed076 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx @@ -0,0 +1,362 @@ +import { useState } from "react"; +import { evaluate } from "cel-js"; +import { + CheckCircle2Icon, + ChevronDown, + ChevronRight, + CircleAlertIcon, +} from "lucide-react"; +import { Link } from "react-router"; + +import { trpc } from "~/api/trpc"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Spinner } from "~/components/ui/spinner"; +import { useWorkspace } from "~/components/WorkspaceProvider"; +import { cn } from "~/lib/utils"; + +type CurrentVersion = { + id: string; + tag: string; + name: string; + status: string; +}; + +type Target = { + resourceId: string; + resourceName: string; + environmentId: string; + environmentName: string; + currentVersion: CurrentVersion | null; +}; + +type Dependency = { + dependencyDeploymentId: string; + dependencyDeploymentName: string | null; + versionSelector: string; + targets: Target[]; +}; + +type EvaluatedTarget = Target & { satisfied: boolean }; +type EvaluatedDependency = Omit & { + targets: EvaluatedTarget[]; + satisfiedCount: number; + total: number; + allSatisfied: boolean; +}; + +function evalSelector(selector: string, version: CurrentVersion | null) { + if (version == null) return false; + try { + return Boolean( + evaluate(selector, { + version: { + id: version.id, + tag: version.tag, + name: version.name, + status: version.status, + }, + }), + ); + } catch { + return false; + } +} + +function evaluateForEnvironment( + dependencies: Dependency[], + environmentId: string, +): EvaluatedDependency[] { + return dependencies.map((dep) => { + const targets: EvaluatedTarget[] = dep.targets + .filter((t) => t.environmentId === environmentId) + .map((t) => ({ + ...t, + satisfied: evalSelector(dep.versionSelector, t.currentVersion), + })); + const satisfiedCount = targets.filter((t) => t.satisfied).length; + return { + ...dep, + targets, + satisfiedCount, + total: targets.length, + allSatisfied: satisfiedCount === targets.length, + }; + }); +} + +function summarizeBlockedTargets(deps: EvaluatedDependency[]) { + const blockedKeys = new Set(); + let totalKeys = new Set(); + for (const dep of deps) { + for (const t of dep.targets) { + const key = t.resourceId; + totalKeys.add(key); + if (!t.satisfied) blockedKeys.add(key); + } + } + return { blocked: blockedKeys.size, total: totalKeys.size }; +} + +type SummaryRowProps = { + blocked: number; + total: number; +}; + +function SummaryRow({ blocked, total }: SummaryRowProps) { + const allSatisfied = blocked === 0; + return ( + + {allSatisfied ? ( + + ) : ( + + )} + + Dependencies + {total > 0 && ` (${total - blocked}/${total} targets satisfied)`} + + + ); +} + +type StatusIconProps = { satisfied: boolean }; + +function StatusIcon({ satisfied }: StatusIconProps) { + if (satisfied) + return ; + return ; +} + +type DependencyTargetRowProps = { + target: EvaluatedTarget; +}; + +function DependencyTargetRow({ target }: DependencyTargetRowProps) { + return ( +
+ + {target.resourceName} + + {target.currentVersion == null + ? "—" + : target.currentVersion.name || target.currentVersion.tag} + +
+ ); +} + +type DependencyGroupHeaderProps = { + dependency: EvaluatedDependency; + workspaceSlug: string; + open: boolean; + onToggle: () => void; +}; + +function DependencyGroupHeader({ + dependency, + workspaceSlug, + open, + onToggle, +}: DependencyGroupHeaderProps) { + const Caret = open ? ChevronDown : ChevronRight; + return ( +
+ + + {dependency.dependencyDeploymentName ?? + dependency.dependencyDeploymentId} + + + + {dependency.satisfiedCount} / {dependency.total} satisfied + +
+ ); +} + +function SelectorRow({ selector }: { selector: string }) { + return ( +
+ selector:{" "} + {selector} +
+ ); +} + +function EmptyTargets() { + return ( +
+ No release targets in this environment. +
+ ); +} + +type DependencyTargetListProps = { + targets: EvaluatedTarget[]; +}; + +function DependencyTargetList({ targets }: DependencyTargetListProps) { + if (targets.length === 0) return ; + return ( +
+ {targets.map((t) => ( + + ))} +
+ ); +} + +type DependencyGroupBodyProps = { + dependency: EvaluatedDependency; +}; + +function DependencyGroupBody({ dependency }: DependencyGroupBodyProps) { + return ( +
+ + +
+ ); +} + +type DependencyGroupProps = { + dependency: EvaluatedDependency; + workspaceSlug: string; + defaultOpen: boolean; +}; + +function DependencyGroup({ + dependency, + workspaceSlug, + defaultOpen, +}: DependencyGroupProps) { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ setOpen((v) => !v)} + /> + {open && } +
+ ); +} + +type DependencyDialogContentProps = { + versionLabel: string; + environmentName: string; + blocked: number; + total: number; + evaluated: EvaluatedDependency[]; + workspaceSlug: string; +}; + +function DependencyDialogContent({ + versionLabel, + environmentName, + blocked, + total, + evaluated, + workspaceSlug, +}: DependencyDialogContentProps) { + return ( + <> + + Dependencies — {versionLabel} + +
+

+ {versionLabel} declares {evaluated.length}{" "} + {evaluated.length === 1 ? "dependency" : "dependencies"}.{" "} + {blocked === 0 + ? `All ${total} release target${total === 1 ? "" : "s"} in ${environmentName} are satisfied.` + : `${blocked} of ${total} release target${total === 1 ? "" : "s"} in ${environmentName} blocked.`} +

+
+ {evaluated.map((dep) => ( + + ))} +
+
+ + ); +} + +export type DependencyDetailProps = { + versionId: string; + environment: { id: string; name: string }; +}; + +export function DependencyDetail({ + versionId, + environment, +}: DependencyDetailProps) { + const { workspace } = useWorkspace(); + const { data, isLoading } = trpc.deploymentVersions.dependencies.useQuery( + { versionId }, + { refetchInterval: 15_000 }, + ); + + if (isLoading) { + return ( +
+ + Loading dependencies… +
+ ); + } + + if (data == null) return null; + if (data.dependencies.length === 0) return null; + + const evaluated = evaluateForEnvironment(data.dependencies, environment.id); + const summary = summarizeBlockedTargets(evaluated); + const versionLabel = data.version.name || data.version.tag; + + return ( + + + + + + + ); +} diff --git a/apps/web/app/routes/ws/deployments/_components/release-targets/Dependencies.tsx b/apps/web/app/routes/ws/deployments/_components/release-targets/Dependencies.tsx new file mode 100644 index 000000000..297b55ec4 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/release-targets/Dependencies.tsx @@ -0,0 +1,227 @@ +import { evaluate } from "cel-js"; +import { Check, GitBranch, X } from "lucide-react"; +import { Link } from "react-router"; + +import { trpc } from "~/api/trpc"; +import { Badge } from "~/components/ui/badge"; +import { Card, CardContent, CardHeader } from "~/components/ui/card"; +import { Skeleton } from "~/components/ui/skeleton"; + +type CurrentVersion = { + id: string; + tag: string; + name: string; + status: string; + environmentId: string; + completedAt: string | null; +}; + +type Dependency = { + dependencyDeploymentId: string; + dependencyDeploymentName: string | null; + versionSelector: string; + currentVersion: CurrentVersion | null; +}; + +type EvaluatedDependency = Dependency & { satisfied: boolean }; + +function evalSelector(selector: string, version: CurrentVersion | null) { + if (version == null) return false; + try { + return Boolean( + evaluate(selector, { + version: { + id: version.id, + tag: version.tag, + name: version.name, + status: version.status, + }, + }), + ); + } catch { + return false; + } +} + +function evaluateDependencies(dependencies: Dependency[]) { + const evaluated: EvaluatedDependency[] = dependencies.map((dep) => ({ + ...dep, + satisfied: evalSelector(dep.versionSelector, dep.currentVersion), + })); + const satisfiedCount = evaluated.filter((d) => d.satisfied).length; + return { evaluated, satisfiedCount, total: dependencies.length }; +} + +function StatusIcon({ satisfied }: { satisfied: boolean }) { + if (satisfied) return ; + return ; +} + +type DependenciesHeaderProps = { + versionLabel: string; + satisfiedCount: number; + total: number; +}; + +function DependenciesHeader({ + versionLabel, + satisfiedCount, + total, +}: DependenciesHeaderProps) { + const allSatisfied = satisfiedCount === total; + return ( + +
+
+ + Dependencies +
+ + {satisfiedCount} / {total} satisfied + +
+

+ Declared by{" "} + {versionLabel} +

+
+ ); +} + +type DependencyNameProps = { + workspaceSlug: string; + deploymentId: string; + name: string | null; +}; + +function DependencyName({ + workspaceSlug, + deploymentId, + name, +}: DependencyNameProps) { + return ( + + {name ?? deploymentId} + + ); +} + +function DependencySelector({ selector }: { selector: string }) { + return ( + + {selector} + + ); +} + +function DependencyCurrent({ + currentVersion, +}: { + currentVersion: CurrentVersion | null; +}) { + return ( +
+ Current: + {currentVersion == null ? ( + + not deployed on this resource + + ) : ( + + {currentVersion.name || currentVersion.tag} + + )} +
+ ); +} + +type DependencyRowProps = { + workspaceSlug: string; + dependency: EvaluatedDependency; +}; + +function DependencyRow({ workspaceSlug, dependency }: DependencyRowProps) { + return ( +
+
+ +
+
+ + + +
+
+ ); +} + +function DependenciesLoading() { + return ( + + +
+ + Dependencies +
+
+ + + +
+ ); +} + +type DependenciesProps = { + workspaceSlug: string; + deploymentId: string; + environmentId: string; + resourceId: string; +}; + +export function Dependencies({ + workspaceSlug, + deploymentId, + environmentId, + resourceId, +}: DependenciesProps) { + const { data, isLoading } = trpc.releaseTargets.dependencies.useQuery( + { deploymentId, environmentId, resourceId }, + { refetchInterval: 15_000 }, + ); + + if (isLoading) return ; + if (data?.version == null) return null; + if (data.dependencies.length === 0) return null; + + const { evaluated, satisfiedCount, total } = evaluateDependencies( + data.dependencies, + ); + + return ( + + + +
+ {evaluated.map((dep) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsx b/apps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsx index 4372664e1..cd985f0de 100644 --- a/apps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsx +++ b/apps/web/app/routes/ws/deployments/page.$deploymentId.release-targets.$releaseTargetKey.tsx @@ -38,6 +38,7 @@ import { Spinner } from "~/components/ui/spinner"; import { useWorkspace } from "~/components/WorkspaceProvider"; import { useDeployment } from "./_components/DeploymentProvider"; import { DeploymentsNavbarTabs } from "./_components/DeploymentsNavbarTabs"; +import { Dependencies } from "./_components/release-targets/Dependencies"; function parseReleaseTargetKey(key: string) { if (key.length !== 110) return null; @@ -548,6 +549,15 @@ export default function ReleaseTargetEvaluationsPage() { + {parsed != null && ( + + )} + {evaluationsQuery.isLoading && (
diff --git a/packages/trpc/src/routes/deployment-versions.ts b/packages/trpc/src/routes/deployment-versions.ts index 828b2a8bd..74c216242 100644 --- a/packages/trpc/src/routes/deployment-versions.ts +++ b/packages/trpc/src/routes/deployment-versions.ts @@ -1,7 +1,15 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { and, eq, takeFirstOrNull } from "@ctrlplane/db"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + takeFirstOrNull, +} from "@ctrlplane/db"; import { enqueuePolicyEval, enqueueReleaseTargetsForDeployment, @@ -156,4 +164,191 @@ export const deploymentVersionsRouter = router({ return policyEvaluations; }), + + dependencies: protectedProcedure + .input(z.object({ versionId: z.uuid() })) + .query(async ({ ctx, input }) => { + const versionRow = await ctx.db.query.deploymentVersion.findFirst({ + where: eq(schema.deploymentVersion.id, input.versionId), + }); + if (versionRow == null) return null; + + const edges = await ctx.db + .select({ + dependencyDeploymentId: + schema.deploymentVersionDependency.dependencyDeploymentId, + versionSelector: schema.deploymentVersionDependency.versionSelector, + }) + .from(schema.deploymentVersionDependency) + .where( + eq( + schema.deploymentVersionDependency.deploymentVersionId, + input.versionId, + ), + ) + .orderBy( + asc(schema.deploymentVersionDependency.dependencyDeploymentId), + ); + + const version = { + id: versionRow.id, + tag: versionRow.tag, + name: versionRow.name, + deploymentId: versionRow.deploymentId, + }; + + if (edges.length === 0) return { version, dependencies: [] }; + + const targets = await ctx.db + .selectDistinct({ + environmentId: schema.computedEnvironmentResource.environmentId, + environmentName: schema.environment.name, + resourceId: schema.computedDeploymentResource.resourceId, + resourceName: schema.resource.name, + }) + .from(schema.computedDeploymentResource) + .innerJoin( + schema.computedEnvironmentResource, + eq( + schema.computedEnvironmentResource.resourceId, + schema.computedDeploymentResource.resourceId, + ), + ) + .innerJoin( + schema.systemDeployment, + eq( + schema.systemDeployment.deploymentId, + schema.computedDeploymentResource.deploymentId, + ), + ) + .innerJoin( + schema.systemEnvironment, + and( + eq( + schema.systemEnvironment.environmentId, + schema.computedEnvironmentResource.environmentId, + ), + eq( + schema.systemEnvironment.systemId, + schema.systemDeployment.systemId, + ), + ), + ) + .innerJoin( + schema.resource, + eq(schema.resource.id, schema.computedDeploymentResource.resourceId), + ) + .innerJoin( + schema.environment, + eq( + schema.environment.id, + schema.computedEnvironmentResource.environmentId, + ), + ) + .where( + eq( + schema.computedDeploymentResource.deploymentId, + versionRow.deploymentId, + ), + ); + + const dependencyDeploymentIds = edges.map( + (e) => e.dependencyDeploymentId, + ); + const dependencyDeployments = await ctx.db + .select({ id: schema.deployment.id, name: schema.deployment.name }) + .from(schema.deployment) + .where(inArray(schema.deployment.id, dependencyDeploymentIds)); + const depNameById = new Map( + dependencyDeployments.map((d) => [d.id, d.name]), + ); + + // Fetch the latest successful release per (depDeployment, env, resource) + // for every dep edge × every release target in a single round-trip. + // Ordering by completedAt DESC + dedupe-on-first per composite key + // gives us "latest successful release in this env on this resource." + type CurrentVersion = { + id: string; + tag: string; + name: string; + status: string; + }; + const currentReleaseKey = ( + depDeploymentId: string, + environmentId: string, + resourceId: string, + ) => `${depDeploymentId}:${environmentId}:${resourceId}`; + const currentByKey = new Map(); + + const resourceIds = targets.map((t) => t.resourceId); + if (resourceIds.length > 0 && dependencyDeploymentIds.length > 0) { + const rows = await ctx.db + .select({ + deploymentId: schema.release.deploymentId, + environmentId: schema.release.environmentId, + resourceId: schema.release.resourceId, + versionId: schema.release.versionId, + tag: schema.deploymentVersion.tag, + name: schema.deploymentVersion.name, + status: schema.deploymentVersion.status, + }) + .from(schema.release) + .innerJoin( + schema.releaseJob, + eq(schema.releaseJob.releaseId, schema.release.id), + ) + .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) + .innerJoin( + schema.deploymentVersion, + eq(schema.deploymentVersion.id, schema.release.versionId), + ) + .where( + and( + inArray(schema.release.deploymentId, dependencyDeploymentIds), + inArray(schema.release.resourceId, resourceIds), + eq(schema.job.status, "successful"), + isNotNull(schema.job.completedAt), + ), + ) + .orderBy(desc(schema.job.completedAt)); + + for (const row of rows) { + const key = currentReleaseKey( + row.deploymentId, + row.environmentId, + row.resourceId, + ); + if (currentByKey.has(key)) continue; + currentByKey.set(key, { + id: row.versionId, + tag: row.tag, + name: row.name, + status: row.status, + }); + } + } + + const dependencies = edges.map((edge) => ({ + dependencyDeploymentId: edge.dependencyDeploymentId, + dependencyDeploymentName: + depNameById.get(edge.dependencyDeploymentId) ?? null, + versionSelector: edge.versionSelector, + targets: targets.map((t) => ({ + resourceId: t.resourceId, + resourceName: t.resourceName, + environmentId: t.environmentId, + environmentName: t.environmentName, + currentVersion: + currentByKey.get( + currentReleaseKey( + edge.dependencyDeploymentId, + t.environmentId, + t.resourceId, + ), + ) ?? null, + })), + })); + + return { version, dependencies }; + }), }); diff --git a/packages/trpc/src/routes/release-targets.ts b/packages/trpc/src/routes/release-targets.ts index 4f1c40490..a21f68521 100644 --- a/packages/trpc/src/routes/release-targets.ts +++ b/packages/trpc/src/routes/release-targets.ts @@ -1,6 +1,6 @@ import z from "zod"; -import { and, desc, eq, inArray, sql } from "@ctrlplane/db"; +import { and, asc, desc, eq, inArray, isNotNull, sql } from "@ctrlplane/db"; import * as schema from "@ctrlplane/db/schema"; import { protectedProcedure, router } from "../trpc.js"; @@ -278,4 +278,191 @@ export const releaseTargetsRouter = router({ return { ...r, policy: p }; }); }), + + dependencies: protectedProcedure + .input( + z.object({ + deploymentId: z.uuid(), + environmentId: z.uuid(), + resourceId: z.uuid(), + }), + ) + .query(async ({ ctx, input }) => { + const desired = await ctx.db + .select({ + version: { + id: schema.deploymentVersion.id, + tag: schema.deploymentVersion.tag, + name: schema.deploymentVersion.name, + status: schema.deploymentVersion.status, + createdAt: schema.deploymentVersion.createdAt, + }, + }) + .from(schema.releaseTargetDesiredRelease) + .innerJoin( + schema.release, + eq( + schema.releaseTargetDesiredRelease.desiredReleaseId, + schema.release.id, + ), + ) + .innerJoin( + schema.deploymentVersion, + eq(schema.release.versionId, schema.deploymentVersion.id), + ) + .where( + and( + eq( + schema.releaseTargetDesiredRelease.deploymentId, + input.deploymentId, + ), + eq( + schema.releaseTargetDesiredRelease.environmentId, + input.environmentId, + ), + eq(schema.releaseTargetDesiredRelease.resourceId, input.resourceId), + ), + ) + .limit(1); + + const desiredVersion = desired[0]?.version ?? null; + const latestVersion = + desiredVersion != null + ? null + : (( + await ctx.db + .select({ + id: schema.deploymentVersion.id, + tag: schema.deploymentVersion.tag, + name: schema.deploymentVersion.name, + status: schema.deploymentVersion.status, + createdAt: schema.deploymentVersion.createdAt, + }) + .from(schema.deploymentVersion) + .where( + eq(schema.deploymentVersion.deploymentId, input.deploymentId), + ) + .orderBy(desc(schema.deploymentVersion.createdAt)) + .limit(1) + )[0] ?? null); + + const version = desiredVersion ?? latestVersion; + + if (version == null) return { version: null, dependencies: [] }; + + const edges = await ctx.db + .select({ + dependencyDeploymentId: + schema.deploymentVersionDependency.dependencyDeploymentId, + versionSelector: schema.deploymentVersionDependency.versionSelector, + }) + .from(schema.deploymentVersionDependency) + .where( + eq( + schema.deploymentVersionDependency.deploymentVersionId, + version.id, + ), + ) + .orderBy( + asc(schema.deploymentVersionDependency.dependencyDeploymentId), + ); + + if (edges.length === 0) return { version, dependencies: [] }; + + const dependencyDeploymentIds = edges.map( + (e) => e.dependencyDeploymentId, + ); + + const dependencyDeployments = await ctx.db + .select({ + id: schema.deployment.id, + name: schema.deployment.name, + }) + .from(schema.deployment) + .where(inArray(schema.deployment.id, dependencyDeploymentIds)); + const deploymentById = new Map( + dependencyDeployments.map((d) => [d.id, d]), + ); + + // Fetch the latest successful release across all dep deployments in one + // round-trip, scoped to the target's resource AND environment so the UI + // shows the dep's release in the same env as the target being inspected. + // Ordering by completedAt DESC + dedupe-on-first per dep gives us the + // "latest successful" per dep deployment. + type CurrentRelease = { + versionId: string; + versionTag: string; + versionName: string; + versionStatus: string; + environmentId: string; + completedAt: Date | null; + }; + const currentByDep = new Map(); + + const upstreamRows = await ctx.db + .select({ + deploymentId: schema.release.deploymentId, + versionId: schema.release.versionId, + versionTag: schema.deploymentVersion.tag, + versionName: schema.deploymentVersion.name, + versionStatus: schema.deploymentVersion.status, + environmentId: schema.release.environmentId, + completedAt: schema.job.completedAt, + }) + .from(schema.release) + .innerJoin( + schema.releaseJob, + eq(schema.releaseJob.releaseId, schema.release.id), + ) + .innerJoin(schema.job, eq(schema.job.id, schema.releaseJob.jobId)) + .innerJoin( + schema.deploymentVersion, + eq(schema.deploymentVersion.id, schema.release.versionId), + ) + .where( + and( + inArray(schema.release.deploymentId, dependencyDeploymentIds), + eq(schema.release.resourceId, input.resourceId), + eq(schema.release.environmentId, input.environmentId), + eq(schema.job.status, "successful"), + isNotNull(schema.job.completedAt), + ), + ) + .orderBy(desc(schema.job.completedAt)); + + for (const row of upstreamRows) { + if (currentByDep.has(row.deploymentId)) continue; + currentByDep.set(row.deploymentId, { + versionId: row.versionId, + versionTag: row.versionTag, + versionName: row.versionName, + versionStatus: row.versionStatus, + environmentId: row.environmentId, + completedAt: row.completedAt, + }); + } + + const dependencies = edges.map((edge) => { + const cur = currentByDep.get(edge.dependencyDeploymentId) ?? null; + const dep = deploymentById.get(edge.dependencyDeploymentId) ?? null; + return { + dependencyDeploymentId: edge.dependencyDeploymentId, + dependencyDeploymentName: dep?.name ?? null, + versionSelector: edge.versionSelector, + currentVersion: + cur == null + ? null + : { + id: cur.versionId, + tag: cur.versionTag, + name: cur.versionName, + status: cur.versionStatus, + environmentId: cur.environmentId, + completedAt: cur.completedAt?.toISOString() ?? null, + }, + }; + }); + + return { version, dependencies }; + }), });