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
8 changes: 8 additions & 0 deletions apps/web/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ export default [
":deploymentId/policies",
"routes/ws/deployments/page.$deploymentId.policies.tsx",
),
route(
":deploymentId/plans",
"routes/ws/deployments/page.$deploymentId.plans.tsx",
),
route(
":deploymentId/plans/:planId",
"routes/ws/deployments/page.$deploymentId.plans.$planId.tsx",
),
route(
":deploymentId/settings",
"routes/ws/deployments/settings/_layout.tsx",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ type DeploymentTab =
| "resources"
| "settings"
| "release-targets"
| "traces"
| "variables"
| "policies";
| "policies"
| "plans";

const useDeploymentTab = (baseUrl: string): DeploymentTab => {
const { pathname } = useLocation();
if (pathname === baseUrl) return "environments";
if (pathname.startsWith(`${baseUrl}/resources`)) return "resources";
if (pathname.startsWith(`${baseUrl}/settings/general`)) return "settings";
if (pathname.startsWith(`${baseUrl}/traces`)) return "traces";
if (pathname.startsWith(`${baseUrl}/variables`)) return "variables";
if (pathname.startsWith(`${baseUrl}/policies`)) return "policies";
if (pathname.startsWith(`${baseUrl}/plans`)) return "plans";
if (pathname.startsWith(`${baseUrl}/release-targets`))
return "release-targets";
return "environments";
Expand All @@ -44,15 +44,15 @@ export const DeploymentsNavbarTabs = () => {
<TabsTrigger value="variables" asChild>
<Link to={`${baseUrl}/variables`}>Variables</Link>
</TabsTrigger>
<TabsTrigger value="traces" asChild>
<Link to={`${baseUrl}/traces`}>Traces</Link>
</TabsTrigger>
<TabsTrigger value="release-targets" asChild>
<Link to={`${baseUrl}/release-targets`}>Targets</Link>
</TabsTrigger>
<TabsTrigger value="policies" asChild>
<Link to={`${baseUrl}/policies`}>Policies</Link>
</TabsTrigger>
<TabsTrigger value="plans" asChild>
<Link to={`${baseUrl}/plans`}>Plans</Link>
</TabsTrigger>
<TabsTrigger value="settings" asChild>
<Link to={`${baseUrl}/settings/general`}>Settings</Link>
</TabsTrigger>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState } from "react";
import { DiffEditor } from "@monaco-editor/react";

import { trpc } from "~/api/trpc";
import { useTheme } from "~/components/ThemeProvider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";

type PlanDiffDialogProps = {
deploymentId: string;
resultId: string;
title: string;
children: React.ReactNode;
};

type DiffView = "split" | "unified";

export function PlanDiffDialog({
deploymentId,
resultId,
title,
children,
}: PlanDiffDialogProps) {
const [open, setOpen] = useState(false);
const [view, setView] = useState<DiffView>("split");
const { theme } = useTheme();

const diffQuery = trpc.deployment.plans.resultDiff.useQuery(
{ deploymentId, resultId },
{ enabled: open },
);

return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="flex h-[90vh] w-[95vw] max-w-[95vw] flex-col p-0 sm:max-w-[95vw]">
<DialogHeader className="flex-row items-center justify-between border-b p-4 pr-12">
<DialogTitle>{title}</DialogTitle>
<Tabs value={view} onValueChange={(v) => setView(v as DiffView)}>
<TabsList>
<TabsTrigger value="split">Split</TabsTrigger>
<TabsTrigger value="unified">Unified</TabsTrigger>
</TabsList>
</Tabs>
</DialogHeader>
<div className="min-h-0 flex-1">
{diffQuery.isLoading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading diff...
</div>
) : diffQuery.data == null ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No diff available
</div>
) : (
<DiffEditor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "vs"}
original={diffQuery.data.current}
modified={diffQuery.data.proposed}
options={{
Comment on lines +62 to +68
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

PlanDiffDialog hardcodes the Monaco theme to "vs-dark". The app already has theme-aware Monaco usage elsewhere (e.g. VersionActionsPanel uses ThemeProvider to switch between "vs" and "vs-dark"), so this dialog will look wrong in light mode. Use the existing ThemeProvider to select the Monaco theme based on the current app theme.

Copilot uses AI. Check for mistakes.
readOnly: true,
renderSideBySide: view === "split",
minimap: { enabled: true },
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
)}
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export const PlanStatusDisplayName: Record<string, string> = {
computing: "Computing",
completed: "Completed",
errored: "Errored",
unsupported: "Unsupported",
};
Comment on lines +1 to +6
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

PlanStatusBadge only defines display names/colors for "computing", "completed", and "failed", but plan target results use statuses like "errored" and "unsupported" (and the plan detail page renders badges for those result statuses). As a result, badges for errored/unsupported results will fall back to the neutral styling and raw status text. Add mappings for the full set of statuses used by plan results (and consider standardizing on "errored" vs "failed" across the API/UI).

Copilot uses AI. Check for mistakes.

const PlanStatusBadgeColor: Record<string, string> = {
computing:
"bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800",
completed:
"bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800",
errored:
"bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 border-red-200 dark:border-red-800",
unsupported:
"bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800",
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function PlanStatusBadge({ status }: { status: string }) {
return (
<span
className={`inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-medium ${
PlanStatusBadgeColor[status] ??
"border-neutral-200 bg-neutral-100 text-neutral-700"
}`}
>
{PlanStatusDisplayName[status] ?? status}
</span>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import type { RouterOutputs } from "@ctrlplane/trpc";
import { FileText } from "lucide-react";
import { Link, useParams } from "react-router";

import { trpc } from "~/api/trpc";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/breadcrumb";
import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator";
import { SidebarTrigger } from "~/components/ui/sidebar";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { useWorkspace } from "~/components/WorkspaceProvider";
import { useDeployment } from "./_components/DeploymentProvider";
import { DeploymentsNavbarTabs } from "./_components/DeploymentsNavbarTabs";
import { PlanDiffDialog } from "./_components/plans/PlanDiffDialog";
import { PlanStatusBadge } from "./_components/plans/PlanStatusBadge";

export function meta() {
return [
{ title: "Plan Details - Ctrlplane" },
{ name: "description", content: "View plan results" },
];
}

type Result = RouterOutputs["deployment"]["plans"]["results"][number];

function ChangesCell({
result,
deploymentId,
}: {
result: Result;
deploymentId: string;
}) {
if (result.status === "computing")
return <span className="text-muted-foreground">—</span>;
if (result.status === "errored")
return (
<span
className="text-red-600 dark:text-red-400"
title={result.message ?? undefined}
>
Errored
</span>
);
if (result.status === "unsupported")
return <span className="text-muted-foreground">Unsupported</span>;
if (result.hasChanges === true)
return (
<PlanDiffDialog
deploymentId={deploymentId}
resultId={result.resultId}
title={`${result.environment.name} · ${result.resource.name} · ${result.agent.name}`}
>
<Button
variant="outline"
size="sm"
className="h-6 cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
View diff
</Button>
</PlanDiffDialog>
);
if (result.hasChanges === false)
return <span className="text-muted-foreground">No changes</span>;
return <span className="text-muted-foreground">—</span>;
}

function ResultsTableHeader() {
return (
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="font-medium">Environment</TableHead>
<TableHead className="font-medium">Resource</TableHead>
<TableHead className="font-medium">Agent</TableHead>
<TableHead className="font-medium">Status</TableHead>
<TableHead className="font-medium">Changes</TableHead>
</TableRow>
</TableHeader>
);
}

function ResultsTableRow({
result,
deploymentId,
}: {
result: Result;
deploymentId: string;
}) {
return (
<TableRow className="hover:bg-muted/50">
<TableCell>{result.environment.name}</TableCell>
<TableCell>{result.resource.name}</TableCell>
<TableCell>{result.agent.name}</TableCell>
<TableCell>
<PlanStatusBadge status={result.status} />
</TableCell>
<TableCell>
<ChangesCell result={result} deploymentId={deploymentId} />
</TableCell>
</TableRow>
);
}

function NoResults() {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="rounded-full bg-muted p-4">
<FileText className="h-8 w-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h3 className="font-semibold">No results</h3>
<p className="text-sm text-muted-foreground">
This plan has no release targets
</p>
</div>
</div>
</div>
);
}

export default function DeploymentPlanDetail() {
const { workspace } = useWorkspace();
const { deployment } = useDeployment();
const { planId } = useParams<{ planId: string }>();

const resultsQuery = trpc.deployment.plans.results.useQuery(
{ deploymentId: deployment.id, planId: planId! },
{ enabled: !!planId, refetchInterval: 5000 },
);

const results = resultsQuery.data ?? [];

return (
<>
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b pr-4">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link to={`/${workspace.slug}/deployments`}>Deployments</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link to={`/${workspace.slug}/deployments/${deployment.id}`}>
{deployment.name}
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<Link
to={`/${workspace.slug}/deployments/${deployment.id}/plans`}
>
Plans
</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbPage className="font-mono">{planId}</BreadcrumbPage>
</BreadcrumbList>
</Breadcrumb>
</div>

<div className="flex items-center gap-4">
<DeploymentsNavbarTabs />
</div>
</header>

{results.length === 0 && !resultsQuery.isLoading ? (
<NoResults />
) : (
<Table>
<ResultsTableHeader />
<TableBody>
{results.map((r) => (
<ResultsTableRow
key={r.resultId}
result={r}
deploymentId={deployment.id}
/>
))}
</TableBody>
</Table>
)}
</>
);
}
Loading
Loading