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