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 629b4c56b..92d431989 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 @@ -3,6 +3,7 @@ import { FileText } from "lucide-react"; import { Link, useParams } from "react-router"; import { trpc } from "~/api/trpc"; +import { cn } from "~/lib/utils"; import { Breadcrumb, BreadcrumbItem, @@ -10,7 +11,6 @@ import { 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 { @@ -42,13 +42,30 @@ function resultTitle(result: Result) { return `${result.environment.name} · ${result.resource.name} · ${result.agent.name}`; } -function ChangesCell({ - result, - onViewDiff, +function DiffStats({ + stats, }: { - result: Result; - onViewDiff: (resultId: string) => void; + stats: { added: number; removed: number } | null; }) { + if (stats == null) return null; + return ( + + {stats.added > 0 && ( + + +{stats.added} + + )} + {stats.added > 0 && stats.removed > 0 && ( + + )} + {stats.removed > 0 && ( + -{stats.removed} + )} + + ); +} + +function ChangesCell({ result }: { result: Result }) { if (result.status === "computing") return ; if (result.status === "errored") @@ -63,16 +80,7 @@ function ChangesCell({ if (result.status === "unsupported") return Unsupported; if (result.hasChanges === true) - return ( - - ); + return ; if (result.hasChanges === false) return No changes; return ; @@ -99,8 +107,12 @@ function ResultsTableRow({ result: Result; onViewDiff: (resultId: string) => void; }) { + const isClickable = result.hasChanges === true; return ( - + onViewDiff(result.resultId) : undefined} + > {result.environment.name} {result.resource.name} {result.agent.name} @@ -108,7 +120,7 @@ function ResultsTableRow({ - + ); diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 299b14492..c939d8361 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -32,6 +32,7 @@ "@trpc/server": "11.0.0-rc.364", "better-auth": "^1.4.6", "cel-js": "^0.8.2", + "diff": "^9.0.0", "lodash": "catalog:", "superjson": "catalog:", "ts-is-present": "catalog:", @@ -43,6 +44,7 @@ "@ctrlplane/prettier-config": "workspace:*", "@ctrlplane/tsconfig": "workspace:*", "@octokit/types": "^13.5.0", + "@types/diff": "^8.0.0", "@types/lodash": "catalog:", "@types/node": "catalog:node22", "@types/uuid": "^10.0.0", diff --git a/packages/trpc/src/routes/deployment-plans.ts b/packages/trpc/src/routes/deployment-plans.ts index 13329580a..70b89f02c 100644 --- a/packages/trpc/src/routes/deployment-plans.ts +++ b/packages/trpc/src/routes/deployment-plans.ts @@ -1,4 +1,6 @@ import { TRPCError } from "@trpc/server"; +import { diffLines } from "diff"; +import _ from "lodash"; import { z } from "zod"; import { and, count, desc, eq, inArray, takeFirstOrNull } from "@ctrlplane/db"; @@ -7,6 +9,18 @@ import { Permission } from "@ctrlplane/validators/auth"; import { protectedProcedure, router } from "../trpc.js"; +function computeDiffStats( + current: string | null, + proposed: string | null, +): { added: number; removed: number } | null { + if (current == null || proposed == null) return null; + const parts = diffLines(current, proposed); + return { + added: _.sumBy(parts, (p) => (p.added ? p.count : 0)), + removed: _.sumBy(parts, (p) => (p.removed ? p.count : 0)), + }; +} + type PlanSummary = { total: number; computing: number; @@ -165,6 +179,8 @@ export const deploymentPlansRouter = router({ hasChanges: schema.deploymentPlanTargetResult.hasChanges, message: schema.deploymentPlanTargetResult.message, contentHash: schema.deploymentPlanTargetResult.contentHash, + current: schema.deploymentPlanTargetResult.current, + proposed: schema.deploymentPlanTargetResult.proposed, startedAt: schema.deploymentPlanTargetResult.startedAt, completedAt: schema.deploymentPlanTargetResult.completedAt, dispatchContext: schema.deploymentPlanTargetResult.dispatchContext, @@ -205,6 +221,7 @@ export const deploymentPlansRouter = router({ status: r.status, hasChanges: r.hasChanges, message: r.message, + diffStats: computeDiffStats(r.current, r.proposed), contentHash: r.contentHash, startedAt: r.startedAt, completedAt: r.completedAt, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1d8f7daf..37f782fc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,7 +164,7 @@ importers: version: 2.4.3 better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) cel-js: specifier: ^0.8.2 version: 0.8.2 @@ -628,7 +628,7 @@ importers: version: 0.11.1(typescript@5.9.3)(zod@3.24.2) better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) lodash: specifier: 'catalog:' version: 4.17.21 @@ -912,10 +912,13 @@ importers: version: 11.0.0-rc.364 better-auth: specifier: ^1.4.6 - version: 1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.4.6(next@15.2.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1) cel-js: specifier: ^0.8.2 version: 0.8.2 + diff: + specifier: ^9.0.0 + version: 9.0.0 lodash: specifier: 'catalog:' version: 4.17.21 @@ -941,6 +944,9 @@ importers: '@ctrlplane/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/diff': + specifier: ^8.0.0 + version: 8.0.0 '@types/lodash': specifier: 'catalog:' version: 4.17.12 @@ -4403,6 +4409,10 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/diff@8.0.0': + resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} + deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -5556,6 +5566,10 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -12322,6 +12336,10 @@ snapshots: '@types/d3-transition': 3.0.8 '@types/d3-zoom': 3.0.8 + '@types/diff@8.0.0': + dependencies: + diff: 9.0.0 + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.0': @@ -12934,26 +12952,6 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - better-auth@1.4.6(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) - '@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.12))(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1)) - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@noble/ciphers': 2.0.1 - '@noble/hashes': 2.0.1 - better-call: 1.1.5(zod@4.1.12) - defu: 6.1.4 - jose: 6.1.0 - kysely: 0.28.8 - ms: 4.0.0-nightly.202508271359 - nanostores: 1.0.1 - zod: 4.1.12 - optionalDependencies: - next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - better-call@1.1.5(zod@4.1.12): dependencies: '@better-auth/utils': 0.3.0 @@ -13630,6 +13628,8 @@ snapshots: diff@4.0.2: optional: true + diff@9.0.0: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -15684,34 +15684,6 @@ snapshots: - babel-plugin-macros optional: true - next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.53.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): - dependencies: - '@next/env': 15.2.4 - '@swc/counter': 0.1.3 - '@swc/helpers': 0.5.15 - busboy: 1.6.0 - caniuse-lite: 1.0.30001760 - postcss: 8.4.31 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - styled-jsx: 5.1.6(@babel/core@7.24.5)(react@19.2.1) - optionalDependencies: - '@next/swc-darwin-arm64': 15.2.4 - '@next/swc-darwin-x64': 15.2.4 - '@next/swc-linux-arm64-gnu': 15.2.4 - '@next/swc-linux-arm64-musl': 15.2.4 - '@next/swc-linux-x64-gnu': 15.2.4 - '@next/swc-linux-x64-musl': 15.2.4 - '@next/swc-win32-arm64-msvc': 15.2.4 - '@next/swc-win32-x64-msvc': 15.2.4 - '@opentelemetry/api': 1.9.0 - '@playwright/test': 1.53.2 - sharp: 0.33.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - optional: true - no-case@2.3.2: dependencies: lower-case: 1.1.4