diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bc4d67c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,94 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**ChartImpact** is a full-stack web application that helps teams understand potentially disruptive Helm chart changes before deployment. It compares two versions of a Helm chart and surfaces availability and security risk signals. + +- **Frontend**: Next.js 16 (App Router), React 18, TypeScript, TailwindCSS — runs on port 3000 +- **Backend**: Go REST API using Helm SDK and internal diff engine — runs on port 8080 +- **Database**: PostgreSQL 15 (optional, enables stored results and analytics) + +## Commands + +### Frontend (`/frontend`) +```bash +npm install # Install dependencies +npm run dev # Dev server (port 3000) +npm run build # Production build +npm run lint # ESLint +npm run type-check # TypeScript check +npm test # Jest tests +npm run test:watch # Jest in watch mode +npm run test:e2e # Playwright E2E tests +npm run test:coverage +``` + +Run a single Jest test file: +```bash +npm test -- --testPathPattern= +``` + +### Backend (`/backend`) +```bash +go run cmd/server/main.go # Dev server (port 8080) +go test ./... # All tests +go test ./internal/diff/... # Single package +go test -run TestFuncName ./... # Single test +go test -race ./... # Race detector +go build -o server cmd/server # Build binary +``` + +### Full Stack +```bash +docker-compose up # Start frontend + backend + postgres +``` + +Health check: `curl http://localhost:8080/api/health` + +## Architecture + +### Request Flow +1. User submits: repo URL, chart path, two versions, optional values file +2. Backend shallow-clones the repo, renders Helm manifests for each version +3. Internal diff engine (`internal/diff/`) compares rendered YAML, returns structured JSON +4. Frontend displays results in **Classic view** (text diff) or **Explorer view** (structured, resource-level) +5. Results optionally stored (disk or PostgreSQL) with 30-day TTL + +### Backend (`/backend`) +- **Entry point**: `cmd/server/main.go` — sets up Gorilla Mux routing, middleware, storage +- **Handlers**: `internal/api/handlers/` — `compare.go`, `versions.go`, `health.go`, `analysis.go` +- **Helm service**: `internal/service/helm.go` — chart rendering and repo operations +- **Diff engine**: `internal/diff/` — Kubernetes-aware YAML comparison; enabled by default via `INTERNAL_DIFF_ENABLED=true` +- **Storage**: `internal/storage/` — plugin interface with `disk.go` and `postgres.go` implementations + +### Frontend (`/frontend`) +- **Pages**: `app/page.tsx` (main), `app/demo/` (mock data, no backend needed), `app/analysis/[id]/` (stored result replay), `app/analytics/` +- **Components**: `components/CompareForm.tsx`, `components/DiffDisplay.tsx`, `components/explorer/` (Explorer v2) +- **Lib**: `lib/api-client.ts` (backend calls), `lib/risk-assessment.ts` (client-side risk signals), `lib/url-state.ts` (shareable URL state), `lib/types.ts` + +### Storage Plugin System +Controlled by env vars `STORAGE_ENABLED` and `STORAGE_TYPE` (disk | postgres). The storage interface (`internal/storage/interface.go`) makes it easy to swap implementations. + +### Risk Assessment +Risk signals (availability, security) are computed **client-side** in `lib/risk-assessment.ts` from the structured diff returned by the API. No risk logic lives in the backend. + +## Key Configuration + +**Backend** (`backend/.env.example`): `PORT`, `CORS_ALLOWED_ORIGINS`, `COMPARE_TIMEOUT`, `VERSIONS_TIMEOUT`, `INTERNAL_DIFF_ENABLED`, `STORAGE_ENABLED`, `STORAGE_TYPE`, `DATABASE_URL` + +**Frontend** (`frontend/.env.example`): Only `NEXT_PUBLIC_API_URL` (points to backend) + +## Testing + +- Backend tests use Go's built-in testing + testify; run with race detector in CI +- Frontend unit/integration tests use Jest + React Testing Library +- E2E tests use Playwright (`/frontend/e2e/`); require a running backend (disabled in CI by default) +- CI runs backend and frontend test jobs independently based on path changes (`.github/workflows/ci.yml`) + +## Git Conventions + +- **Commits**: Conventional commits — `feat:`, `fix:`, `docs:`, `test:`, `refactor:`, `chore:`, `ci:` +- **Branches**: `feature/`, `fix/`, `docs/`, `refactor/` prefixes +- **Releases**: Git tags `v*.*.*` trigger the release workflow diff --git a/frontend/app/analysis/page.tsx b/frontend/app/analysis/page.tsx index 88a1030..9268d79 100644 --- a/frontend/app/analysis/page.tsx +++ b/frontend/app/analysis/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { CompareRequest, CompareResponse, ImpactSummary } from '@/lib/types'; import { API_ENDPOINTS } from '@/lib/api-config'; @@ -24,6 +24,9 @@ function AnalysisContent() { const [progressMessage, setProgressMessage] = useState('Initializing...'); const [progressStep, setProgressStep] = useState(0); const [progressTotal] = useState(7); + const [contextExpanded, setContextExpanded] = useState(false); + const [selectedResource, setSelectedResource] = useState(null); + const explorerRef = useRef(null); // Auto-execute comparison on mount with progress tracking useEffect(() => { @@ -156,6 +159,14 @@ function AnalysisContent() { router.push('/'); }; + const handleSelectResourceFromSummary = (resourceId: string) => { + setSelectedResource(resourceId); + // Small delay so the state propagates before scrolling + setTimeout(() => { + explorerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 50); + }; + // Helper to extract org/repo from URL const getRepoShortName = (url: string) => { try { @@ -372,154 +383,160 @@ function AnalysisContent() { )} - {/* Analysis Context Section */} + {/* Analysis Context Section — collapsible, collapsed by default */} {params && (
-

- Analysis Context -

-
- {/* Repository */} -
-
- Repository -
- e.currentTarget.style.textDecoration = 'underline'} - onMouseLeave={(e) => e.currentTarget.style.textDecoration = 'none'} - > - {params.repository ? getRepoShortName(params.repository) : 'Unknown'} - ↗ - + {/* Collapsible header */} + + + {/* Expanded content */} + {contextExpanded && ( + - {/* Version Comparison */} -
-
- Version Comparison -
-
- +
- {params.version1} - - → - +
- {params.version2} - + {params.chartPath} +
-
- {/* Inputs */} -
-
- Configuration + {/* Version Comparison */} +
+
+ Version Comparison +
+
+ + {params.version1} + + → + + {params.version2} + +
-
- {params.valuesFile ? ( - {params.valuesFile} - ) : ( - Defaults only - )} + + {/* Configuration */} +
+
+ Configuration +
+
+ {params.valuesFile ? ( + {params.valuesFile} + ) : ( + Defaults only + )} +
-
+ )}
)} @@ -529,21 +546,26 @@ function AnalysisContent() { {/* Summary Section */} {summary && (
-
)} {/* Detailed Explorer Section */} -
- +
+
)} diff --git a/frontend/components/ImpactSummary.tsx b/frontend/components/ImpactSummary.tsx index ae57672..3217c86 100644 --- a/frontend/components/ImpactSummary.tsx +++ b/frontend/components/ImpactSummary.tsx @@ -11,21 +11,24 @@ import { useState } from 'react'; import { ImpactSummary, RiskSignal } from '@/lib/types'; -import { - SPACING, - SEMANTIC_COLORS, - BORDER_RADIUS, - getRiskColors, +import { + SPACING, + SEMANTIC_COLORS, + BORDER_RADIUS, + getRiskColors, getRiskLabel, - FONT_WEIGHTS + FONT_WEIGHTS } from '@/lib/design-tokens'; +import { generateImpactStatement } from '@/lib/risk-assessment'; interface ImpactSummaryProps { summary: ImpactSummary; onViewExplorer?: () => void; + onSelectResource?: (resourceId: string) => void; } -export function ImpactSummaryComponent({ summary, onViewExplorer }: ImpactSummaryProps) { +export function ImpactSummaryComponent({ summary, onViewExplorer, onSelectResource }: ImpactSummaryProps) { + const impactStatement = generateImpactStatement(summary); // Verdict styling const verdictConfig = { @@ -77,25 +80,38 @@ export function ImpactSummaryComponent({ summary, onViewExplorer }: ImpactSummar }}>
- {verdict.icon} -
+ {verdict.icon} +

{verdict.message}

+ {impactStatement && ( +

+ {impactStatement} +

+ )}

{verdict.description}

@@ -139,6 +155,7 @@ export function ImpactSummaryComponent({ summary, onViewExplorer }: ImpactSummar title="⚡ Availability Impact" signals={summary.availabilityImpact} defaultExpanded={true} + onSelectResource={onSelectResource} /> )} @@ -148,6 +165,7 @@ export function ImpactSummaryComponent({ summary, onViewExplorer }: ImpactSummar title="🔐 Security Impact" signals={summary.securityImpact} defaultExpanded={true} + onSelectResource={onSelectResource} /> )} @@ -157,6 +175,7 @@ export function ImpactSummaryComponent({ summary, onViewExplorer }: ImpactSummar title="📝 Other Changes" signals={summary.otherChanges} defaultExpanded={false} + onSelectResource={onSelectResource} /> )} @@ -188,9 +207,10 @@ interface SectionProps { title: string; signals: RiskSignal[]; defaultExpanded: boolean; + onSelectResource?: (resourceId: string) => void; } -function Section({ title, signals, defaultExpanded }: SectionProps) { +function Section({ title, signals, defaultExpanded, onSelectResource }: SectionProps) { const [expanded, setExpanded] = useState(defaultExpanded); return ( @@ -225,7 +245,7 @@ function Section({ title, signals, defaultExpanded }: SectionProps) { {expanded && (
{signals.map((signal, index) => ( - + ))}
)} @@ -235,19 +255,29 @@ function Section({ title, signals, defaultExpanded }: SectionProps) { interface RiskSignalCardProps { signal: RiskSignal; + onSelectResource?: (resourceId: string) => void; } -function RiskSignalCard({ signal }: RiskSignalCardProps) { +function RiskSignalCard({ signal, onSelectResource }: RiskSignalCardProps) { const riskColors = getRiskColors(signal.level); + const [hovered, setHovered] = useState(false); + const isClickable = !!onSelectResource; return ( -
+
onSelectResource(signal.resource) : undefined} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + marginBottom: SPACING.md, + padding: SPACING.md, + background: hovered && isClickable ? riskColors.border : riskColors.bg, + border: `1px solid ${hovered && isClickable ? riskColors.text : riskColors.border}`, + borderRadius: BORDER_RADIUS.sm, + cursor: isClickable ? 'pointer' : 'default', + transition: 'background 0.15s, border-color 0.15s', + }} + >
{signal.resource} - - {getRiskLabel(signal.level)} - +
+ {isClickable && hovered && ( + + ↓ View in explorer + + )} + + {getRiskLabel(signal.level)} + +

(null); + + // Sync with external selection (from ImpactSummary) + useEffect(() => { + if (externalSelectedResource !== undefined && externalSelectedResource !== null) { + setSelectedResource(externalSelectedResource); + } + }, [externalSelectedResource]); const [viewMode, setViewMode] = useState<'tree' | 'table' | 'sidebyside'>('tree'); const [filters, setFilters] = useState({ changeType: [] as string[], diff --git a/frontend/lib/risk-assessment.ts b/frontend/lib/risk-assessment.ts index db344e8..ac945ce 100644 --- a/frontend/lib/risk-assessment.ts +++ b/frontend/lib/risk-assessment.ts @@ -347,3 +347,109 @@ function analyzeChange(resourceName: string, kind: string, change: ChangeV2): Ri return null; } + +/** + * Generate a plain-language impact statement from a summary. + * e.g. "2 workloads will restart and 1 service port will change." + */ +export function generateImpactStatement(summary: ImpactSummary): string | null { + if (summary.verdict === 'no-changes') return null; + + const all = [...summary.availabilityImpact, ...summary.securityImpact, ...summary.otherChanges]; + + const imageRestarts = new Set(); + let scaleDown = 0; + let scaleUp = 0; + let portChanges = 0; + let serviceTypeChanges = 0; + let rbacChanges = 0; + let networkPolicyChanges = 0; + let workloadsRemoved = 0; + let workloadsAdded = 0; + let resourceLimitChanges = 0; + + for (const signal of all) { + switch (signal.title) { + case 'Container image changed': + imageRestarts.add(signal.resource); + break; + case 'Replica count decreased': + scaleDown++; + break; + case 'Replica count increased': + scaleUp++; + break; + case 'Service port changed': + portChanges++; + break; + case 'Service type changed': + serviceTypeChanges++; + break; + case 'Network policy modified': + networkPolicyChanges++; + break; + case 'RBAC permissions changed': + rbacChanges++; + break; + case 'Resource requirements changed': + resourceLimitChanges++; + break; + } + if (signal.title.endsWith(' removed') && AVAILABILITY_CRITICAL_KINDS.includes(signal.kind)) { + workloadsRemoved++; + } + if (signal.title.endsWith(' added') && AVAILABILITY_CRITICAL_KINDS.includes(signal.kind)) { + workloadsAdded++; + } + } + + const parts: string[] = []; + + const restartCount = imageRestarts.size; + if (restartCount > 0) { + parts.push(`${restartCount} workload${restartCount > 1 ? 's' : ''} will restart`); + } + if (workloadsRemoved > 0) { + parts.push(`${workloadsRemoved} workload${workloadsRemoved > 1 ? 's' : ''} will be deleted`); + } + if (workloadsAdded > 0) { + parts.push(`${workloadsAdded} workload${workloadsAdded > 1 ? 's' : ''} will be added`); + } + if (scaleDown > 0) { + parts.push(`${scaleDown} workload${scaleDown > 1 ? 's' : ''} will scale down`); + } + if (scaleUp > 0) { + parts.push(`${scaleUp} workload${scaleUp > 1 ? 's' : ''} will scale up`); + } + if (portChanges > 0) { + parts.push(`${portChanges} service port${portChanges > 1 ? 's' : ''} will change`); + } + if (serviceTypeChanges > 0) { + parts.push(`${serviceTypeChanges} service type${serviceTypeChanges > 1 ? 's' : ''} will change`); + } + if (rbacChanges > 0) { + parts.push(`${rbacChanges} RBAC permission${rbacChanges > 1 ? 's' : ''} will be modified`); + } + if (networkPolicyChanges > 0) { + parts.push(`${networkPolicyChanges} network polic${networkPolicyChanges > 1 ? 'ies' : 'y'} will change`); + } + if (resourceLimitChanges > 0 && parts.length === 0) { + parts.push(`${resourceLimitChanges} resource limit${resourceLimitChanges > 1 ? 's' : ''} will change`); + } + + if (parts.length === 0) { + if (summary.verdict === 'low-risk') { + return `${summary.totalChangedResources} resource${summary.totalChangedResources !== 1 ? 's' : ''} changed — no significant availability or security impact detected.`; + } + return null; + } + + if (parts.length === 1) return cap(parts[0]) + '.'; + if (parts.length === 2) return `${cap(parts[0])} and ${parts[1]}.`; + const last = parts[parts.length - 1]; + return `${cap(parts[0])}, ${parts.slice(1, -1).join(', ')}, and ${last}.`; +} + +function cap(s: string) { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f2adecf..deb7dff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,6 +74,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1925,6 +1926,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -2309,6 +2311,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2417,6 +2420,7 @@ "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", @@ -2932,6 +2936,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3495,6 +3500,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4537,6 +4543,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4706,6 +4713,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6471,6 +6479,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8494,6 +8503,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8705,6 +8715,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8717,6 +8728,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9716,6 +9728,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10013,6 +10026,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"