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