Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -45,9 +46,8 @@ export function DeploymentVersion(props: DeploymentVersionProps) {
</div>
);

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;
Expand Down Expand Up @@ -83,6 +83,7 @@ export function DeploymentVersion(props: DeploymentVersionProps) {
skippedRuleIds={skippedRuleIds}
/>
)}
<DependencyDetail versionId={version.id} environment={environment} />
<VersionStatusDetail version={version} />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Dependency, "targets"> & {
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<string>();
let totalKeys = new Set<string>();
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 (
<DialogTrigger className="flex w-full items-center gap-2 rounded-sm p-1 text-left hover:bg-accent">
{allSatisfied ? (
<CheckCircle2Icon className="size-3 text-green-500" />
) : (
<CircleAlertIcon className="size-3 text-amber-500" />
)}
<span className="grow">
Dependencies
{total > 0 && ` (${total - blocked}/${total} targets satisfied)`}
</span>
</DialogTrigger>
);
}

type StatusIconProps = { satisfied: boolean };

function StatusIcon({ satisfied }: StatusIconProps) {
if (satisfied)
return <CheckCircle2Icon className="size-3.5 text-green-500" />;
return <CircleAlertIcon className="size-3.5 text-amber-500" />;
}

type DependencyTargetRowProps = {
target: EvaluatedTarget;
};

function DependencyTargetRow({ target }: DependencyTargetRowProps) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs">
<StatusIcon satisfied={target.satisfied} />
<span className="grow truncate">{target.resourceName}</span>
<span className="font-mono text-muted-foreground">
{target.currentVersion == null
? "—"
: target.currentVersion.name || target.currentVersion.tag}
</span>
</div>
);
}

type DependencyGroupHeaderProps = {
dependency: EvaluatedDependency;
workspaceSlug: string;
open: boolean;
onToggle: () => void;
};

function DependencyGroupHeader({
dependency,
workspaceSlug,
open,
onToggle,
}: DependencyGroupHeaderProps) {
const Caret = open ? ChevronDown : ChevronRight;
return (
<div className="flex items-center gap-2 px-3 py-2 hover:bg-accent">
<button
type="button"
onClick={onToggle}
className="flex shrink-0 items-center gap-2"
aria-expanded={open}
aria-label={open ? "Collapse" : "Expand"}
>
<Caret className="size-3.5 text-muted-foreground" />
<StatusIcon satisfied={dependency.allSatisfied} />
</button>
<Link
to={`/${workspaceSlug}/deployments/${dependency.dependencyDeploymentId}`}
target="_blank"
className="text-sm font-medium hover:underline"
>
{dependency.dependencyDeploymentName ??
dependency.dependencyDeploymentId}
</Link>
<span className="grow" />
<span
className={cn(
"text-xs",
dependency.allSatisfied ? "text-muted-foreground" : "text-amber-500",
)}
>
{dependency.satisfiedCount} / {dependency.total} satisfied
</span>
</div>
);
}

function SelectorRow({ selector }: { selector: string }) {
return (
<div className="bg-muted/50 px-3 py-1.5 text-xs">
<span className="text-muted-foreground">selector:</span>{" "}
<code className="font-mono">{selector}</code>
</div>
);
}

function EmptyTargets() {
return (
<div className="px-3 py-2 text-xs italic text-muted-foreground">
No release targets in this environment.
</div>
);
}

type DependencyTargetListProps = {
targets: EvaluatedTarget[];
};

function DependencyTargetList({ targets }: DependencyTargetListProps) {
if (targets.length === 0) return <EmptyTargets />;
return (
<div className="divide-y">
{targets.map((t) => (
<DependencyTargetRow key={t.resourceId} target={t} />
))}
</div>
);
}

type DependencyGroupBodyProps = {
dependency: EvaluatedDependency;
};

function DependencyGroupBody({ dependency }: DependencyGroupBodyProps) {
return (
<div className="border-t">
<SelectorRow selector={dependency.versionSelector} />
<DependencyTargetList targets={dependency.targets} />
</div>
);
}

type DependencyGroupProps = {
dependency: EvaluatedDependency;
workspaceSlug: string;
defaultOpen: boolean;
};

function DependencyGroup({
dependency,
workspaceSlug,
defaultOpen,
}: DependencyGroupProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-md border">
<DependencyGroupHeader
dependency={dependency}
workspaceSlug={workspaceSlug}
open={open}
onToggle={() => setOpen((v) => !v)}
/>
{open && <DependencyGroupBody dependency={dependency} />}
</div>
);
}

type DependencyDialogContentProps = {
versionLabel: string;
environmentName: string;
blocked: number;
total: number;
evaluated: EvaluatedDependency[];
workspaceSlug: string;
};

function DependencyDialogContent({
versionLabel,
environmentName,
blocked,
total,
evaluated,
workspaceSlug,
}: DependencyDialogContentProps) {
return (
<>
<DialogHeader>
<DialogTitle>Dependencies — {versionLabel}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{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.`}
</p>
<div className="max-h-96 space-y-2 overflow-auto">
{evaluated.map((dep) => (
<DependencyGroup
key={dep.dependencyDeploymentId}
dependency={dep}
workspaceSlug={workspaceSlug}
defaultOpen={!dep.allSatisfied}
/>
))}
</div>
</div>
</>
);
}

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 },
);
Comment on lines +326 to +329
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect where DependencyDetail is mounted and whether it can fan out across lists of versions.
rg -n -C3 '\bDependencyDetail\b|deploymentVersions\.dependencies\.useQuery' apps/web/app/routes/ws/deployments --iglob '*.tsx'
rg -n -C3 '\bDeploymentVersion\b' apps/web/app/routes/ws/deployments --iglob '*.tsx'

Repository: ctrlplanedev/ctrlplane

Length of output: 9629


🏁 Script executed:

# Read DependencyDetail.tsx to understand the dialog structure and the query at lines 345-357
cat -n apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx | sed -n '300,370p'

Repository: ctrlplanedev/ctrlplane

Length of output: 2077


🏁 Script executed:

# Search for Dialog usage patterns with state management
rg -n -B2 -A5 'useState.*open|Dialog.*open' apps/web/app/routes/ws/deployments --iglob '*.tsx' | head -80

Repository: ctrlplanedev/ctrlplane

Length of output: 5873


🏁 Script executed:

# Get the complete DependencyDetail component
cat -n apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx | head -360

Repository: ctrlplanedev/ctrlplane

Length of output: 11998


Gate the polling interval on the dialog's open state.

Every mounted DependencyDetail polls every 15 seconds regardless of whether its dialog is open. Since DeploymentVersion renders this per visible version, this creates one background query per version row.

Add dialog state management and conditionally enable the query:

  • Track dialog open state with useState
  • Pass open={open} onOpenChange={setOpen} to the <Dialog> component
  • Gate the query with an enabled condition or conditionally set refetchInterval to false when the dialog is closed

This follows the pattern used elsewhere in the codebase for controlled dialogs (e.g., RedeployDialog, VersionActionsPanel).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/routes/ws/deployments/_components/environmentversiondecisions/rule-results/DependencyDetail.tsx`
around lines 323 - 326, DependencyDetail currently polls via
trpc.deploymentVersions.dependencies.useQuery({ versionId }, { refetchInterval:
15_000 }) even when its dialog is closed; add local dialog state (const [open,
setOpen] = useState(false)), pass open={open} onOpenChange={setOpen} to the
Dialog component, and gate the query by either using the enabled: open option or
set refetchInterval: open ? 15_000 : false so the background polling only runs
while the dialog is open.


if (isLoading) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner className="size-3 animate-spin" />
Loading dependencies…
</div>
);
}

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 (
<Dialog>
<SummaryRow blocked={summary.blocked} total={summary.total} />
<DialogContent className="max-w-2xl">
<DependencyDialogContent
versionLabel={versionLabel}
environmentName={environment.name}
blocked={summary.blocked}
total={summary.total}
evaluated={evaluated}
workspaceSlug={workspace.slug}
/>
</DialogContent>
</Dialog>
);
}
Loading
Loading