diff --git a/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx b/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx index dfc615e99..6edd3bc5d 100644 --- a/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx +++ b/apps/web/app/routes/ws/deployments/_components/plans/PlanDiffDialog.tsx @@ -1,14 +1,18 @@ -import { useState } from "react"; +import type { RouterOutputs } from "@ctrlplane/trpc"; +import { useEffect, useState } from "react"; import { DiffEditor } from "@monaco-editor/react"; import { trpc } from "~/api/trpc"; import { useTheme } from "~/components/ThemeProvider"; +import { Badge } from "~/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "~/components/ui/dialog"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; type PlanDiffDialogProps = { @@ -16,67 +20,198 @@ type PlanDiffDialogProps = { resultId: string | undefined; title: string; open: boolean; + initialTab?: TopTab; onOpenChange: (open: boolean) => void; }; +type TopTab = "diff" | "validations"; type DiffView = "split" | "unified"; +type Validation = + RouterOutputs["deployment"]["plans"]["resultValidations"][number]; + +function ValidationsTab({ + deploymentId, + resultId, + open, +}: { + deploymentId: string; + resultId: string; + open: boolean; +}) { + const query = trpc.deployment.plans.resultValidations.useQuery( + { deploymentId, resultId }, + { enabled: open }, + ); + + if (query.isLoading) + return ( +
+ Loading validations... +
+ ); + + const validations = query.data ?? []; + if (validations.length === 0) + return ( +
+ No validations evaluated for this result +
+ ); + + return ( +
+ +
+ ); +} + +function ValidationItem({ validation }: { validation: Validation }) { + return ( +
  • +
    + + {validation.passed ? "Passed" : "Failed"} + + {validation.ruleName} +
    + {validation.ruleDescription && ( +

    + {validation.ruleDescription} +

    + )} + {validation.violations.length > 0 && ( + + )} +
  • + ); +} + +function DiffTab({ + deploymentId, + resultId, + open, + view, +}: { + deploymentId: string; + resultId: string; + open: boolean; + view: DiffView; +}) { + const { theme } = useTheme(); + + const diffQuery = trpc.deployment.plans.resultDiff.useQuery( + { deploymentId, resultId }, + { enabled: open }, + ); + + if (diffQuery.isLoading) + return ( +
    + Loading diff... +
    + ); + + if (diffQuery.data == null) + return ( +
    + No diff available +
    + ); + + return ( + + ); +} + export function PlanDiffDialog({ deploymentId, resultId, title, open, + initialTab = "diff", onOpenChange, }: PlanDiffDialogProps) { - const [view, setView] = useState("split"); - const { theme } = useTheme(); + const [tab, setTab] = useState(initialTab); + const [view, setView] = useState("unified"); - const diffQuery = trpc.deployment.plans.resultDiff.useQuery( - { deploymentId, resultId: resultId ?? "" }, - { enabled: open && resultId != null }, - ); + useEffect(() => { + if (open) setTab(initialTab); + }, [open, initialTab]); return ( {title} - setView(v as DiffView)}> - - Split - Unified - - +
    + {tab === "diff" && ( +
    + + setView(checked ? "split" : "unified") + } + /> + +
    + )} + setTab(v as TopTab)}> + + Diff + Validations + + +
    - {diffQuery.isLoading ? ( -
    - Loading diff... -
    - ) : diffQuery.data == null ? ( -
    - No diff available -
    + {resultId == null ? null : tab === "diff" ? ( + ) : ( - )}
    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 index bc01ce148..929c97dac 100644 --- a/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.$planId.tsx +++ b/apps/web/app/routes/ws/deployments/page.$deploymentId.plans.$planId.tsx @@ -1,4 +1,5 @@ import type { RouterOutputs } from "@ctrlplane/trpc"; +import { useState } from "react"; import { FileText } from "lucide-react"; import { Link, useParams } from "react-router"; @@ -77,6 +78,38 @@ function ChangesCell({ result }: { result: Result }) { return ; } +function ValidationsCell({ + validations, + onClick, +}: { + validations: Result["validations"]; + onClick: () => void; +}) { + if (validations.total === 0) + return ; + return ( + + ); +} + function ResultsTableHeader() { return ( @@ -86,6 +119,7 @@ function ResultsTableHeader() { Agent Status Changes + Validations ); @@ -93,16 +127,23 @@ function ResultsTableHeader() { function ResultsTableRow({ result, - onViewDiff, + onOpenResult, }: { result: Result; - onViewDiff: (resultId: string) => void; + onOpenResult: (resultId: string, tab?: "diff" | "validations") => void; }) { - const isClickable = result.hasChanges === true; + const hasChanges = result.hasChanges === true; + const hasValidations = result.validations.total > 0; + const isClickable = hasChanges || hasValidations; + const defaultTab = hasChanges ? "diff" : "validations"; return ( onViewDiff(result.resultId) : undefined} + onClick={ + isClickable + ? () => onOpenResult(result.resultId, defaultTab) + : undefined + } > {result.environment.name} {result.resource.name} @@ -113,6 +154,12 @@ function ResultsTableRow({ + + onOpenResult(result.resultId, "validations")} + /> + ); } @@ -140,6 +187,7 @@ export default function DeploymentPlanDetail() { const { deployment } = useDeployment(); const { planId } = useParams<{ planId: string }>(); const { resultId, openResult, closeResult } = usePlanResultParam(); + const [initialTab, setInitialTab] = useState<"diff" | "validations">("diff"); const resultsQuery = trpc.deployment.plans.results.useQuery( { deploymentId: deployment.id, planId: planId! }, @@ -150,6 +198,14 @@ export default function DeploymentPlanDetail() { const results = resultsQuery.data?.items ?? []; const activeResult = results.find((r) => r.resultId === resultId); + const handleOpenResult = ( + id: string, + tab: "diff" | "validations" = "diff", + ) => { + setInitialTab(tab); + openResult(id); + }; + return ( <>
    @@ -201,7 +257,7 @@ export default function DeploymentPlanDetail() { ))} @@ -213,6 +269,7 @@ export default function DeploymentPlanDetail() { resultId={resultId} title={activeResult ? resultTitle(activeResult) : ""} open={resultId != null} + initialTab={initialTab} onOpenChange={(o) => { if (!o) closeResult(); }} diff --git a/packages/trpc/src/routes/deployment-plans.ts b/packages/trpc/src/routes/deployment-plans.ts index 70b89f02c..21f03b0cd 100644 --- a/packages/trpc/src/routes/deployment-plans.ts +++ b/packages/trpc/src/routes/deployment-plans.ts @@ -41,6 +41,42 @@ const emptySummary = (): PlanSummary => ({ unchanged: 0, }); +type ValidationCounts = { total: number; passed: number; failed: number }; + +async function loadValidationCounts( + db: Parameters< + Parameters[0] + >[0]["ctx"]["db"], + resultIds: string[], +): Promise> { + const out = new Map(); + if (resultIds.length === 0) return out; + + const rows = await db + .select({ + resultId: schema.deploymentPlanTargetResultValidation.resultId, + passed: schema.deploymentPlanTargetResultValidation.passed, + count: count(), + }) + .from(schema.deploymentPlanTargetResultValidation) + .where( + inArray(schema.deploymentPlanTargetResultValidation.resultId, resultIds), + ) + .groupBy( + schema.deploymentPlanTargetResultValidation.resultId, + schema.deploymentPlanTargetResultValidation.passed, + ); + + for (const row of rows) { + const c = out.get(row.resultId) ?? { total: 0, passed: 0, failed: 0 }; + c.total += row.count; + if (row.passed) c.passed += row.count; + else c.failed += row.count; + out.set(row.resultId, c); + } + return out; +} + export const deploymentPlansRouter = router({ list: protectedProcedure .meta({ @@ -204,6 +240,11 @@ export const deploymentPlansRouter = router({ .where(eq(schema.deploymentPlanTarget.planId, input.planId)) .orderBy(schema.environment.name, schema.resource.name); + const validationCounts = await loadValidationCounts( + ctx.db, + rows.map((r) => r.resultId), + ); + return { version: { tag: plan.versionTag, name: plan.versionName }, items: rows.map((r) => { @@ -225,11 +266,90 @@ export const deploymentPlansRouter = router({ contentHash: r.contentHash, startedAt: r.startedAt, completedAt: r.completedAt, + validations: validationCounts.get(r.resultId) ?? { + total: 0, + passed: 0, + failed: 0, + }, }; }), }; }), + resultValidations: 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 owner = await ctx.db + .select({ 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 (owner == null || owner.deploymentId !== input.deploymentId) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Result not found", + }); + + const rows = await ctx.db + .select({ + id: schema.deploymentPlanTargetResultValidation.id, + ruleId: schema.deploymentPlanTargetResultValidation.ruleId, + passed: schema.deploymentPlanTargetResultValidation.passed, + violations: schema.deploymentPlanTargetResultValidation.violations, + evaluatedAt: schema.deploymentPlanTargetResultValidation.evaluatedAt, + ruleName: schema.policyRulePlanValidationOpa.name, + ruleDescription: schema.policyRulePlanValidationOpa.description, + }) + .from(schema.deploymentPlanTargetResultValidation) + .leftJoin( + schema.policyRulePlanValidationOpa, + eq( + schema.deploymentPlanTargetResultValidation.ruleId, + schema.policyRulePlanValidationOpa.id, + ), + ) + .where( + eq( + schema.deploymentPlanTargetResultValidation.resultId, + input.resultId, + ), + ) + .orderBy(schema.policyRulePlanValidationOpa.name); + + return rows.map((r) => ({ + id: r.id, + ruleId: r.ruleId, + ruleName: r.ruleName ?? "(unknown rule)", + ruleDescription: r.ruleDescription, + passed: r.passed, + violations: r.violations, + evaluatedAt: r.evaluatedAt, + })); + }), + resultDiff: protectedProcedure .meta({ authorizationCheck: ({ canUser, input }) =>