diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 59f665632..8d3b1d113 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -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", diff --git a/apps/web/app/routes/ws/deployments/_components/DeploymentsNavbarTabs.tsx b/apps/web/app/routes/ws/deployments/_components/DeploymentsNavbarTabs.tsx index 96a9426f6..cb37dcf77 100644 --- a/apps/web/app/routes/ws/deployments/_components/DeploymentsNavbarTabs.tsx +++ b/apps/web/app/routes/ws/deployments/_components/DeploymentsNavbarTabs.tsx @@ -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"; @@ -44,15 +44,15 @@ export const DeploymentsNavbarTabs = () => { Variables - - Traces - Targets Policies + + Plans + Settings diff --git a/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx b/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx new file mode 100644 index 000000000..68c0cfa21 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx @@ -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("split"); + const { theme } = useTheme(); + + const diffQuery = trpc.deployment.plans.resultDiff.useQuery( + { deploymentId, resultId }, + { enabled: open }, + ); + + return ( + + {children} + + + {title} + setView(v as DiffView)}> + + Split + Unified + + + +
+ {diffQuery.isLoading ? ( +
+ Loading diff... +
+ ) : diffQuery.data == null ? ( +
+ No diff available +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/app/routes/ws/deployments/_components/plans/PlanStatusBadge.tsx b/apps/web/app/routes/ws/deployments/_components/plans/PlanStatusBadge.tsx new file mode 100644 index 000000000..96969be98 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/_components/plans/PlanStatusBadge.tsx @@ -0,0 +1,30 @@ +export const PlanStatusDisplayName: Record = { + computing: "Computing", + completed: "Completed", + errored: "Errored", + unsupported: "Unsupported", +}; + +const PlanStatusBadgeColor: Record = { + 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", +}; + +export function PlanStatusBadge({ status }: { status: string }) { + return ( + + {PlanStatusDisplayName[status] ?? status} + + ); +} diff --git a/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.$planId.tsx b/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.$planId.tsx new file mode 100644 index 000000000..df9d90f55 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.$planId.tsx @@ -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 ; + if (result.status === "errored") + return ( + + Errored + + ); + if (result.status === "unsupported") + return Unsupported; + if (result.hasChanges === true) + return ( + + + + ); + if (result.hasChanges === false) + return No changes; + return ; +} + +function ResultsTableHeader() { + return ( + + + Environment + Resource + Agent + Status + Changes + + + ); +} + +function ResultsTableRow({ + result, + deploymentId, +}: { + result: Result; + deploymentId: string; +}) { + return ( + + {result.environment.name} + {result.resource.name} + {result.agent.name} + + + + + + + + ); +} + +function NoResults() { + return ( +
+
+
+ +
+
+

No results

+

+ This plan has no release targets +

+
+
+
+ ); +} + +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 ( + <> +
+
+ + + + + + Deployments + + + + + {deployment.name} + + + + + + Plans + + + + {planId} + + +
+ +
+ +
+
+ + {results.length === 0 && !resultsQuery.isLoading ? ( + + ) : ( + + + + {results.map((r) => ( + + ))} + +
+ )} + + ); +} diff --git a/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.tsx b/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.tsx new file mode 100644 index 000000000..1160aec56 --- /dev/null +++ b/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.tsx @@ -0,0 +1,243 @@ +import type { RouterOutputs } from "@ctrlplane/trpc"; +import { FileText } from "lucide-react"; +import prettyMs from "pretty-ms"; +import { Link, useNavigate } from "react-router"; + +import { trpc } from "~/api/trpc"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb"; +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 { PlanStatusBadge } from "./_components/plans/PlanStatusBadge"; + +export function meta() { + return [ + { title: "Plans - Deployment Details - Ctrlplane" }, + { name: "description", content: "View deployment plans" }, + ]; +} + +type Plan = RouterOutputs["deployment"]["plans"]["list"][number]; + +function SourceCell({ plan }: { plan: Plan }) { + const owner = plan.version.metadata["github/owner"]; + const repo = plan.version.metadata["github/repo"]; + const sha = plan.version.metadata["git/sha"]; + const runId = plan.version.metadata["github/run-id"]; + + if (!owner || !repo) { + return ; + } + + const commitShort = sha?.slice(0, 7); + const repoUrl = `https://github.com/${owner}/${repo}`; + + if (runId) + return ( + + {commitShort ?? `${owner}/${repo}`} + + ); + + if (sha) + return ( + + {commitShort} + + ); + + return ; +} + +function ChangesCell({ summary }: { summary: Plan["summary"] }) { + if (summary.total === 0) { + return ; + } + return ( +
+ {summary.changed > 0 && ( + + {summary.changed} changed + + )} + {summary.unchanged > 0 && ( + + {summary.unchanged} unchanged + + )} + {summary.errored > 0 && ( + + {summary.errored} errored + + )} +
+ ); +} + +function PlansTableHeader() { + return ( + + + Version + Status + Targets + Changes + Source + Created + Expires + + + ); +} + +function PlansTableRow({ + plan, + onSelect, +}: { + plan: Plan; + onSelect: () => void; +}) { + const now = Date.now(); + const createdAgo = prettyMs(now - new Date(plan.createdAt).getTime(), { + compact: true, + }); + const expiresAt = new Date(plan.expiresAt).getTime(); + const expiresIn = + expiresAt > now + ? `in ${prettyMs(expiresAt - now, { compact: true })}` + : "expired"; + + return ( + + {plan.version.tag} + + + + + {plan.summary.total} + + + + + + + + {createdAgo} ago + {expiresIn} + + ); +} + +function NoPlans() { + return ( +
+
+
+ +
+
+

No plans yet

+

+ Plans will appear here when a pull request triggers a dry run +

+
+
+
+ ); +} + +export default function DeploymentPlans() { + const { workspace } = useWorkspace(); + const { deployment } = useDeployment(); + const navigate = useNavigate(); + + const plansQuery = trpc.deployment.plans.list.useQuery( + { deploymentId: deployment.id, limit: 100, offset: 0 }, + { refetchInterval: 5000 }, + ); + + const plans = plansQuery.data ?? []; + + return ( + <> +
+
+ + + + + + Deployments + + + + + {deployment.name} + + + + Plans + + +
+ +
+ +
+
+ + {plans.length === 0 && !plansQuery.isLoading ? ( + + ) : ( + + + + {plans.map((plan) => ( + + navigate( + `/${workspace.slug}/deployments/${deployment.id}/plans/${plan.id}`, + ) + } + /> + ))} + +
+ )} + + ); +} diff --git a/packages/trpc/src/routes/deployment-plans.ts b/packages/trpc/src/routes/deployment-plans.ts new file mode 100644 index 000000000..fdb362c07 --- /dev/null +++ b/packages/trpc/src/routes/deployment-plans.ts @@ -0,0 +1,267 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { and, count, desc, eq, inArray, takeFirstOrNull } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { protectedProcedure, router } from "../trpc.js"; + +type PlanSummary = { + total: number; + computing: number; + completed: number; + errored: number; + unsupported: number; + changed: number; + unchanged: number; +}; + +const emptySummary = (): PlanSummary => ({ + total: 0, + computing: 0, + completed: 0, + errored: 0, + unsupported: 0, + changed: 0, + unchanged: 0, +}); + +export const deploymentPlansRouter = router({ + list: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.DeploymentGet) + .on({ type: "deployment", id: input.deploymentId }), + }) + .input( + z.object({ + deploymentId: z.uuid(), + limit: z.number().min(1).max(1000).default(100), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ input, ctx }) => { + const rows = await ctx.db + .select({ + id: schema.deploymentPlan.id, + versionTag: schema.deploymentPlan.versionTag, + versionName: schema.deploymentPlan.versionName, + versionMetadata: schema.deploymentPlan.versionMetadata, + metadata: schema.deploymentPlan.metadata, + createdAt: schema.deploymentPlan.createdAt, + completedAt: schema.deploymentPlan.completedAt, + expiresAt: schema.deploymentPlan.expiresAt, + }) + .from(schema.deploymentPlan) + .where(eq(schema.deploymentPlan.deploymentId, input.deploymentId)) + .orderBy(desc(schema.deploymentPlan.createdAt)) + .limit(input.limit) + .offset(input.offset); + + const plans = rows.map((r) => ({ + id: r.id, + version: { + tag: r.versionTag, + name: r.versionName, + metadata: r.versionMetadata, + }, + metadata: r.metadata, + createdAt: r.createdAt, + completedAt: r.completedAt, + expiresAt: r.expiresAt, + })); + + if (plans.length === 0) return []; + + const planIds = plans.map((p) => p.id); + + const counts = await ctx.db + .select({ + planId: schema.deploymentPlanTarget.planId, + status: schema.deploymentPlanTargetResult.status, + hasChanges: schema.deploymentPlanTargetResult.hasChanges, + count: count(), + }) + .from(schema.deploymentPlanTargetResult) + .innerJoin( + schema.deploymentPlanTarget, + eq( + schema.deploymentPlanTargetResult.targetId, + schema.deploymentPlanTarget.id, + ), + ) + .where(inArray(schema.deploymentPlanTarget.planId, planIds)) + .groupBy( + schema.deploymentPlanTarget.planId, + schema.deploymentPlanTargetResult.status, + schema.deploymentPlanTargetResult.hasChanges, + ); + + const summaryByPlan = new Map(); + for (const row of counts) { + const s = summaryByPlan.get(row.planId) ?? emptySummary(); + s.total += row.count; + s[row.status] += row.count; + if (row.hasChanges === true) s.changed += row.count; + if (row.hasChanges === false) s.unchanged += row.count; + summaryByPlan.set(row.planId, s); + } + + return plans.map((p) => { + const summary = summaryByPlan.get(p.id) ?? emptySummary(); + const status = + summary.computing > 0 + ? "computing" + : summary.errored > 0 + ? "errored" + : "completed"; + return { ...p, status, summary }; + }); + }), + + results: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.DeploymentGet) + .on({ type: "deployment", id: input.deploymentId }), + }) + .input( + z.object({ + deploymentId: z.uuid(), + planId: z.uuid(), + }), + ) + .query(async ({ input, ctx }) => { + const plan = await ctx.db + .select({ id: schema.deploymentPlan.id }) + .from(schema.deploymentPlan) + .where( + and( + eq(schema.deploymentPlan.id, input.planId), + eq(schema.deploymentPlan.deploymentId, input.deploymentId), + ), + ) + .then(takeFirstOrNull); + + if (plan == null) + throw new TRPCError({ code: "NOT_FOUND", message: "Plan not found" }); + + const rows = await ctx.db + .select({ + resultId: schema.deploymentPlanTargetResult.id, + targetId: schema.deploymentPlanTarget.id, + environmentId: schema.deploymentPlanTarget.environmentId, + environmentName: schema.environment.name, + resourceId: schema.deploymentPlanTarget.resourceId, + resourceName: schema.resource.name, + status: schema.deploymentPlanTargetResult.status, + hasChanges: schema.deploymentPlanTargetResult.hasChanges, + message: schema.deploymentPlanTargetResult.message, + contentHash: schema.deploymentPlanTargetResult.contentHash, + startedAt: schema.deploymentPlanTargetResult.startedAt, + completedAt: schema.deploymentPlanTargetResult.completedAt, + dispatchContext: schema.deploymentPlanTargetResult.dispatchContext, + }) + .from(schema.deploymentPlanTargetResult) + .innerJoin( + schema.deploymentPlanTarget, + eq( + schema.deploymentPlanTargetResult.targetId, + schema.deploymentPlanTarget.id, + ), + ) + .innerJoin( + schema.environment, + eq(schema.deploymentPlanTarget.environmentId, schema.environment.id), + ) + .innerJoin( + schema.resource, + eq(schema.deploymentPlanTarget.resourceId, schema.resource.id), + ) + .where(eq(schema.deploymentPlanTarget.planId, input.planId)) + .orderBy(schema.environment.name, schema.resource.name); + + return rows.map((r) => { + const agent = r.dispatchContext.jobAgent ?? {}; + return { + resultId: r.resultId, + targetId: r.targetId, + environment: { id: r.environmentId, name: r.environmentName }, + resource: { id: r.resourceId, name: r.resourceName }, + agent: { + id: (agent.id as string | undefined) ?? "", + name: (agent.name as string | undefined) ?? "", + type: (agent.type as string | undefined) ?? "", + }, + status: r.status, + hasChanges: r.hasChanges, + message: r.message, + contentHash: r.contentHash, + startedAt: r.startedAt, + completedAt: r.completedAt, + }; + }); + }), + + resultDiff: protectedProcedure + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.DeploymentGet) + .on({ type: "deployment", id: input.deploymentId }), + }) + .input( + z.object({ + deploymentId: z.uuid(), + resultId: z.uuid(), + }), + ) + .query(async ({ input, ctx }) => { + const row = await ctx.db + .select({ + current: schema.deploymentPlanTargetResult.current, + proposed: schema.deploymentPlanTargetResult.proposed, + status: schema.deploymentPlanTargetResult.status, + hasChanges: schema.deploymentPlanTargetResult.hasChanges, + message: schema.deploymentPlanTargetResult.message, + deploymentId: schema.deploymentPlan.deploymentId, + }) + .from(schema.deploymentPlanTargetResult) + .innerJoin( + schema.deploymentPlanTarget, + eq( + schema.deploymentPlanTargetResult.targetId, + schema.deploymentPlanTarget.id, + ), + ) + .innerJoin( + schema.deploymentPlan, + eq(schema.deploymentPlanTarget.planId, schema.deploymentPlan.id), + ) + .where(eq(schema.deploymentPlanTargetResult.id, input.resultId)) + .then(takeFirstOrNull); + + if (row == null) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Result not found", + }); + + if (row.deploymentId !== input.deploymentId) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Result not found", + }); + + return { + current: row.current ?? "", + proposed: row.proposed ?? "", + status: row.status, + hasChanges: row.hasChanges, + message: row.message, + }; + }), +}); diff --git a/packages/trpc/src/routes/deployments.ts b/packages/trpc/src/routes/deployments.ts index 023349ecd..c39e878b8 100644 --- a/packages/trpc/src/routes/deployments.ts +++ b/packages/trpc/src/routes/deployments.ts @@ -14,8 +14,11 @@ import { Permission } from "@ctrlplane/validators/auth"; import { getClientFor } from "@ctrlplane/workspace-engine-sdk"; import { protectedProcedure, router } from "../trpc.js"; +import { deploymentPlansRouter } from "./deployment-plans.js"; export const deploymentsRouter = router({ + plans: deploymentPlansRouter, + get: protectedProcedure .input(z.object({ deploymentId: z.uuid() })) .meta({