@skyhook-io/radar-app 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from 'react'
|
|
3
|
+
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
|
4
|
+
import { clsx } from 'clsx'
|
|
5
|
+
import {
|
|
6
|
+
AlertTriangle,
|
|
7
|
+
Anchor,
|
|
8
|
+
ArrowLeft,
|
|
9
|
+
ArrowLeftRight,
|
|
10
|
+
Check,
|
|
11
|
+
CheckCircle2,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Code,
|
|
14
|
+
FileText,
|
|
15
|
+
GitCompare,
|
|
16
|
+
Link2,
|
|
17
|
+
Package,
|
|
18
|
+
Settings,
|
|
19
|
+
} from 'lucide-react'
|
|
20
|
+
import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
|
|
21
|
+
import {
|
|
22
|
+
useCloudRole,
|
|
23
|
+
useHelmHooksDiff,
|
|
24
|
+
useHelmManifestDiff,
|
|
25
|
+
useHelmNotesDiff,
|
|
26
|
+
useHelmRelease,
|
|
27
|
+
useHelmResourceDiff,
|
|
28
|
+
useHelmValuesDiff,
|
|
29
|
+
} from '../../api/client'
|
|
30
|
+
import type { HelmHook, HelmRevision, HooksDiff, ResourceDiff } from '../../types'
|
|
31
|
+
import { getHelmStatusColor, getKindBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
|
|
32
|
+
import { formatDate } from './helm-utils'
|
|
33
|
+
import { RoleGatedPanel } from './RoleGatedPanel'
|
|
34
|
+
import { Tooltip } from '../ui/Tooltip'
|
|
35
|
+
|
|
36
|
+
type DiffTone = 'success' | 'warning' | 'error' | 'info' | 'neutral'
|
|
37
|
+
|
|
38
|
+
interface DiffStats {
|
|
39
|
+
changed: boolean
|
|
40
|
+
additions: number
|
|
41
|
+
removals: number
|
|
42
|
+
hunks: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ParsedReleaseParam {
|
|
46
|
+
namespace: string
|
|
47
|
+
name: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function HelmCompareRoute() {
|
|
51
|
+
const navigate = useNavigate()
|
|
52
|
+
const location = useLocation()
|
|
53
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
54
|
+
const { canAtLeast } = useCloudRole()
|
|
55
|
+
const canViewSensitive = canAtLeast('member')
|
|
56
|
+
|
|
57
|
+
const releaseRef = parseReleaseParam(searchParams.get('release'))
|
|
58
|
+
const storageNamespace = searchParams.get('releaseStorage') || undefined
|
|
59
|
+
const helmNamespace = storageNamespace || releaseRef?.namespace || ''
|
|
60
|
+
const releaseName = releaseRef?.name || ''
|
|
61
|
+
const revision1Param = parsePositiveInt(searchParams.get('revision1'))
|
|
62
|
+
const revision2Param = parsePositiveInt(searchParams.get('revision2'))
|
|
63
|
+
|
|
64
|
+
const releaseQuery = useHelmRelease(helmNamespace, releaseName)
|
|
65
|
+
const release = releaseQuery.data
|
|
66
|
+
const revisions = useMemo(
|
|
67
|
+
() => [...(release?.history || [])].sort((a, b) => a.revision - b.revision),
|
|
68
|
+
[release?.history],
|
|
69
|
+
)
|
|
70
|
+
const defaultRightRevision = revision2Param || release?.revision || revisions.at(-1)?.revision || 0
|
|
71
|
+
const defaultLeftRevision = revision1Param || previousRevision(revisions, defaultRightRevision) || revisions.at(0)?.revision || 0
|
|
72
|
+
const revision1 = defaultLeftRevision
|
|
73
|
+
const revision2 = defaultRightRevision
|
|
74
|
+
const pairReady = Boolean(helmNamespace && releaseName && revision1 > 0 && revision2 > 0 && revision1 !== revision2)
|
|
75
|
+
const diffEnabled = canViewSensitive && pairReady
|
|
76
|
+
|
|
77
|
+
const left = revisions.find((r) => r.revision === revision1)
|
|
78
|
+
const right = revisions.find((r) => r.revision === revision2)
|
|
79
|
+
|
|
80
|
+
const manifestDiff = useHelmManifestDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
|
|
81
|
+
const valuesDiff = useHelmValuesDiff(helmNamespace, releaseName, revision1, revision2, false, diffEnabled)
|
|
82
|
+
const notesDiff = useHelmNotesDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
|
|
83
|
+
const hooksDiff = useHelmHooksDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
|
|
84
|
+
const resourceDiff = useHelmResourceDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
|
|
85
|
+
|
|
86
|
+
const updateRevision = useCallback(
|
|
87
|
+
(key: 'revision1' | 'revision2', value: number) => {
|
|
88
|
+
const params = new URLSearchParams(searchParams)
|
|
89
|
+
params.set(key, String(value))
|
|
90
|
+
setSearchParams(params, { replace: true })
|
|
91
|
+
},
|
|
92
|
+
[searchParams, setSearchParams],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const swapRevisions = useCallback(() => {
|
|
96
|
+
if (!revision1 || !revision2) return
|
|
97
|
+
const params = new URLSearchParams(searchParams)
|
|
98
|
+
params.set('revision1', String(revision2))
|
|
99
|
+
params.set('revision2', String(revision1))
|
|
100
|
+
setSearchParams(params, { replace: true })
|
|
101
|
+
}, [revision1, revision2, searchParams, setSearchParams])
|
|
102
|
+
|
|
103
|
+
const backToRelease = useCallback(() => {
|
|
104
|
+
const params = new URLSearchParams()
|
|
105
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
106
|
+
if (globalNamespaces) params.set('namespaces', globalNamespaces)
|
|
107
|
+
if (releaseRef) params.set('release', `${releaseRef.namespace}/${releaseRef.name}`)
|
|
108
|
+
if (storageNamespace) params.set('releaseStorage', storageNamespace)
|
|
109
|
+
navigate({ pathname: '/helm', search: params.toString() })
|
|
110
|
+
}, [navigate, releaseRef, searchParams, storageNamespace])
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!location.hash) return
|
|
114
|
+
const sectionId = decodeURIComponent(location.hash.slice(1))
|
|
115
|
+
let cancelled = false
|
|
116
|
+
let attempts = 0
|
|
117
|
+
let timeout: number | undefined
|
|
118
|
+
const scroll = () => {
|
|
119
|
+
if (cancelled) return
|
|
120
|
+
document.getElementById(sectionId)?.scrollIntoView({ block: 'start' })
|
|
121
|
+
attempts += 1
|
|
122
|
+
if (attempts < 8) timeout = window.setTimeout(scroll, 75)
|
|
123
|
+
}
|
|
124
|
+
timeout = window.setTimeout(scroll, 0)
|
|
125
|
+
return () => {
|
|
126
|
+
cancelled = true
|
|
127
|
+
if (timeout) window.clearTimeout(timeout)
|
|
128
|
+
}
|
|
129
|
+
}, [location.hash, releaseName, revision1, revision2])
|
|
130
|
+
|
|
131
|
+
if (!releaseRef) {
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 bg-theme-base p-8 text-center">
|
|
134
|
+
<div className="text-sm font-medium text-theme-text-primary">This Helm compare link is missing a release.</div>
|
|
135
|
+
<button onClick={() => navigate('/helm')} className="btn-brand rounded-lg px-3 py-1.5 text-xs font-medium">
|
|
136
|
+
Back to Helm
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (releaseQuery.isLoading && !release) {
|
|
143
|
+
return <PaneLoader label="Loading release..." className="h-full bg-theme-base" />
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (releaseQuery.error && !release) {
|
|
147
|
+
return (
|
|
148
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 bg-theme-base p-8 text-center">
|
|
149
|
+
<AlertTriangle className="h-8 w-8 text-red-500" />
|
|
150
|
+
<div className="text-sm font-medium text-theme-text-primary">Release not found</div>
|
|
151
|
+
<div className="max-w-lg text-xs text-theme-text-secondary">
|
|
152
|
+
{releaseQuery.error instanceof Error ? releaseQuery.error.message : 'Radar could not load this Helm release.'}
|
|
153
|
+
</div>
|
|
154
|
+
<button onClick={backToRelease} className="rounded-lg border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs font-medium text-theme-text-primary hover:bg-theme-hover">
|
|
155
|
+
Back to release
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="flex h-full min-w-0 flex-col bg-theme-base">
|
|
163
|
+
<header className="shrink-0 border-b border-theme-border bg-theme-surface/95 px-4 py-3">
|
|
164
|
+
<div className="flex w-full flex-wrap items-start justify-between gap-3">
|
|
165
|
+
<div className="min-w-0">
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
onClick={backToRelease}
|
|
169
|
+
className="mb-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
|
|
170
|
+
>
|
|
171
|
+
<ArrowLeft className="h-3.5 w-3.5" />
|
|
172
|
+
Back to release
|
|
173
|
+
</button>
|
|
174
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
175
|
+
<Package className="h-5 w-5 shrink-0 text-purple-400" />
|
|
176
|
+
<h1 className="truncate text-lg font-semibold text-theme-text-primary">{releaseName}</h1>
|
|
177
|
+
{release && (
|
|
178
|
+
<span className={clsx('badge', getHelmStatusColor(release.status))}>{release.status}</span>
|
|
179
|
+
)}
|
|
180
|
+
{storageNamespace && storageNamespace !== releaseRef.namespace && (
|
|
181
|
+
<span className={clsx('badge', SEVERITY_BADGE.neutral)}>stored in {storageNamespace}</span>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
<div className="mt-1 text-sm text-theme-text-tertiary">{releaseRef.namespace}</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-theme-border bg-theme-base/70 p-2">
|
|
188
|
+
<RevisionSelect
|
|
189
|
+
label="From"
|
|
190
|
+
value={revision1}
|
|
191
|
+
revisions={revisions}
|
|
192
|
+
onChange={(rev) => updateRevision('revision1', rev)}
|
|
193
|
+
/>
|
|
194
|
+
<Tooltip content="Swap revisions" position="bottom">
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
onClick={swapRevisions}
|
|
198
|
+
disabled={!pairReady}
|
|
199
|
+
aria-label="Swap revisions"
|
|
200
|
+
className="rounded-md border border-theme-border bg-theme-elevated p-1.5 text-theme-text-secondary shadow-theme-sm transition-colors hover:bg-theme-hover hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
201
|
+
>
|
|
202
|
+
<ArrowLeftRight className="h-4 w-4" />
|
|
203
|
+
</button>
|
|
204
|
+
</Tooltip>
|
|
205
|
+
<RevisionSelect
|
|
206
|
+
label="To"
|
|
207
|
+
value={revision2}
|
|
208
|
+
revisions={revisions}
|
|
209
|
+
onChange={(rev) => updateRevision('revision2', rev)}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</header>
|
|
214
|
+
|
|
215
|
+
<RoleGatedPanel min="member" feature="release revision comparison">
|
|
216
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
217
|
+
<div className={clsx('grid w-full grid-cols-1 gap-4 px-4 py-4', pairReady && 'xl:grid-cols-[220px_minmax(0,1fr)]')}>
|
|
218
|
+
{pairReady && (
|
|
219
|
+
<nav className="hidden xl:block">
|
|
220
|
+
<div className="sticky top-4 rounded-xl border border-theme-border bg-theme-surface p-2 shadow-theme-sm">
|
|
221
|
+
<div className="px-2 pb-2 text-[11px] font-medium uppercase text-theme-text-tertiary">Compare</div>
|
|
222
|
+
{[
|
|
223
|
+
['summary', 'Summary'],
|
|
224
|
+
['manifest', 'Manifest'],
|
|
225
|
+
['resources', 'Resources'],
|
|
226
|
+
['values', 'Values'],
|
|
227
|
+
['hooks', 'Hooks'],
|
|
228
|
+
['notes', 'Notes'],
|
|
229
|
+
].map(([id, label]) => (
|
|
230
|
+
<a
|
|
231
|
+
key={id}
|
|
232
|
+
href={`#${id}`}
|
|
233
|
+
onClick={(event) => scrollCompareSection(event, id)}
|
|
234
|
+
className="block rounded-md px-2 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
|
|
235
|
+
>
|
|
236
|
+
{label}
|
|
237
|
+
</a>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
</nav>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
<main className="min-w-0 space-y-4">
|
|
244
|
+
{!pairReady ? (
|
|
245
|
+
<div className="card-inner-lg flex items-start gap-3">
|
|
246
|
+
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
|
|
247
|
+
<div>
|
|
248
|
+
<div className="text-sm font-medium text-theme-text-primary">Pick two different revisions to compare</div>
|
|
249
|
+
<div className="mt-1 text-sm text-theme-text-secondary">Use the revision selectors above to choose a source and target revision.</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
) : (
|
|
253
|
+
<>
|
|
254
|
+
<section id="summary" className="scroll-mt-4 space-y-4">
|
|
255
|
+
<CompareSummary
|
|
256
|
+
left={left}
|
|
257
|
+
right={right}
|
|
258
|
+
revision1={revision1}
|
|
259
|
+
revision2={revision2}
|
|
260
|
+
manifestDiff={manifestDiff.data?.diff}
|
|
261
|
+
manifestLoading={manifestDiff.isLoading}
|
|
262
|
+
manifestError={manifestDiff.error}
|
|
263
|
+
valuesDiff={valuesDiff.data?.diff}
|
|
264
|
+
valuesLoading={valuesDiff.isLoading}
|
|
265
|
+
valuesError={valuesDiff.error}
|
|
266
|
+
notesDiff={notesDiff.data?.diff}
|
|
267
|
+
notesLoading={notesDiff.isLoading}
|
|
268
|
+
notesError={notesDiff.error}
|
|
269
|
+
hooksDiff={hooksDiff.data}
|
|
270
|
+
hooksLoading={hooksDiff.isLoading}
|
|
271
|
+
hooksError={hooksDiff.error}
|
|
272
|
+
resourceDiff={resourceDiff.data}
|
|
273
|
+
resourceLoading={resourceDiff.isLoading}
|
|
274
|
+
resourceError={resourceDiff.error}
|
|
275
|
+
/>
|
|
276
|
+
</section>
|
|
277
|
+
|
|
278
|
+
<DiffSection
|
|
279
|
+
id="manifest"
|
|
280
|
+
icon={Code}
|
|
281
|
+
title="Rendered manifest diff"
|
|
282
|
+
description="Rendered Kubernetes YAML is the ground truth for what Helm would apply between these revisions."
|
|
283
|
+
diff={manifestDiff.data?.diff || ''}
|
|
284
|
+
isLoading={manifestDiff.isLoading}
|
|
285
|
+
error={manifestDiff.error}
|
|
286
|
+
emptyLabel="No rendered manifest changes found."
|
|
287
|
+
/>
|
|
288
|
+
|
|
289
|
+
<ResourceInventoryDiffSection
|
|
290
|
+
diff={resourceDiff.data}
|
|
291
|
+
isLoading={resourceDiff.isLoading}
|
|
292
|
+
error={resourceDiff.error}
|
|
293
|
+
left={left}
|
|
294
|
+
right={right}
|
|
295
|
+
revision1={revision1}
|
|
296
|
+
revision2={revision2}
|
|
297
|
+
/>
|
|
298
|
+
|
|
299
|
+
<DiffSection
|
|
300
|
+
id="values"
|
|
301
|
+
icon={Settings}
|
|
302
|
+
title="User-supplied values diff"
|
|
303
|
+
description="Only values explicitly supplied to the release are compared here; computed chart defaults can still affect the rendered manifest."
|
|
304
|
+
diff={valuesDiff.data?.diff || ''}
|
|
305
|
+
isLoading={valuesDiff.isLoading}
|
|
306
|
+
error={valuesDiff.error}
|
|
307
|
+
emptyLabel="No user-supplied value changes found."
|
|
308
|
+
/>
|
|
309
|
+
|
|
310
|
+
<HooksDiffSection diff={hooksDiff.data} isLoading={hooksDiff.isLoading} error={hooksDiff.error} />
|
|
311
|
+
|
|
312
|
+
<DiffSection
|
|
313
|
+
id="notes"
|
|
314
|
+
icon={FileText}
|
|
315
|
+
title="Release notes diff"
|
|
316
|
+
description="NOTES.txt output can reveal chart-level instructions that changed without changing live Kubernetes objects."
|
|
317
|
+
diff={notesDiff.data?.diff || ''}
|
|
318
|
+
isLoading={notesDiff.isLoading}
|
|
319
|
+
error={notesDiff.error}
|
|
320
|
+
emptyLabel="No release notes changes found."
|
|
321
|
+
/>
|
|
322
|
+
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
</main>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</RoleGatedPanel>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function RevisionSelect({
|
|
334
|
+
label,
|
|
335
|
+
value,
|
|
336
|
+
revisions,
|
|
337
|
+
onChange,
|
|
338
|
+
}: {
|
|
339
|
+
label: string
|
|
340
|
+
value: number
|
|
341
|
+
revisions: HelmRevision[]
|
|
342
|
+
onChange: (revision: number) => void
|
|
343
|
+
}) {
|
|
344
|
+
const [open, setOpen] = useState(false)
|
|
345
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
346
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
347
|
+
const listboxId = useId()
|
|
348
|
+
const selected = revisions.find((revision) => revision.revision === value)
|
|
349
|
+
const selectedIndex = Math.max(0, revisions.findIndex((revision) => revision.revision === value))
|
|
350
|
+
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!open) return
|
|
353
|
+
const close = () => setOpen(false)
|
|
354
|
+
const onDown = (event: globalThis.MouseEvent) => {
|
|
355
|
+
if (!rootRef.current?.contains(event.target as Node)) close()
|
|
356
|
+
}
|
|
357
|
+
const onKey = (event: KeyboardEvent) => {
|
|
358
|
+
if (event.key === 'Escape') close()
|
|
359
|
+
}
|
|
360
|
+
document.addEventListener('mousedown', onDown)
|
|
361
|
+
document.addEventListener('keydown', onKey)
|
|
362
|
+
window.addEventListener('resize', close)
|
|
363
|
+
return () => {
|
|
364
|
+
document.removeEventListener('mousedown', onDown)
|
|
365
|
+
document.removeEventListener('keydown', onKey)
|
|
366
|
+
window.removeEventListener('resize', close)
|
|
367
|
+
}
|
|
368
|
+
}, [open])
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (open) setActiveIndex(selectedIndex)
|
|
372
|
+
}, [open, selectedIndex])
|
|
373
|
+
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (!open) return
|
|
376
|
+
const revision = revisions[activeIndex]
|
|
377
|
+
if (!revision) return
|
|
378
|
+
document.getElementById(`${listboxId}-${revision.revision}`)?.scrollIntoView({ block: 'nearest' })
|
|
379
|
+
}, [activeIndex, listboxId, open, revisions])
|
|
380
|
+
|
|
381
|
+
const selectRevision = (revision: number) => {
|
|
382
|
+
setOpen(false)
|
|
383
|
+
if (revision !== value) onChange(revision)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const moveActive = (delta: number) => {
|
|
387
|
+
if (revisions.length === 0) return
|
|
388
|
+
setActiveIndex((current) => {
|
|
389
|
+
const next = current + delta
|
|
390
|
+
if (next < 0) return revisions.length - 1
|
|
391
|
+
if (next >= revisions.length) return 0
|
|
392
|
+
return next
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
397
|
+
if (revisions.length === 0) return
|
|
398
|
+
if (event.key === 'ArrowDown') {
|
|
399
|
+
event.preventDefault()
|
|
400
|
+
if (!open) {
|
|
401
|
+
setOpen(true)
|
|
402
|
+
setActiveIndex(selectedIndex)
|
|
403
|
+
} else {
|
|
404
|
+
moveActive(1)
|
|
405
|
+
}
|
|
406
|
+
} else if (event.key === 'ArrowUp') {
|
|
407
|
+
event.preventDefault()
|
|
408
|
+
if (!open) {
|
|
409
|
+
setOpen(true)
|
|
410
|
+
setActiveIndex(selectedIndex)
|
|
411
|
+
} else {
|
|
412
|
+
moveActive(-1)
|
|
413
|
+
}
|
|
414
|
+
} else if (event.key === 'Enter' && open) {
|
|
415
|
+
event.preventDefault()
|
|
416
|
+
selectRevision(revisions[activeIndex]?.revision || value)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<div ref={rootRef} className="relative flex items-center gap-2 text-xs text-theme-text-tertiary">
|
|
422
|
+
<span>{label}</span>
|
|
423
|
+
<button
|
|
424
|
+
type="button"
|
|
425
|
+
onClick={() => setOpen((next) => !next)}
|
|
426
|
+
onKeyDown={onKeyDown}
|
|
427
|
+
disabled={revisions.length === 0}
|
|
428
|
+
aria-haspopup="listbox"
|
|
429
|
+
aria-expanded={open}
|
|
430
|
+
aria-controls={listboxId}
|
|
431
|
+
aria-activedescendant={open ? `${listboxId}-${revisions[activeIndex]?.revision || value}` : undefined}
|
|
432
|
+
aria-label={`${label} revision`}
|
|
433
|
+
className={clsx(
|
|
434
|
+
'inline-flex max-w-[34rem] items-center gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 py-1.5 text-left text-sm font-medium text-theme-text-primary shadow-theme-sm transition-colors',
|
|
435
|
+
revisions.length === 0 ? 'cursor-not-allowed opacity-60' : 'hover:bg-theme-hover',
|
|
436
|
+
)}
|
|
437
|
+
>
|
|
438
|
+
<span className="min-w-0 truncate">{selected ? formatRevisionOption(selected) : 'No revisions'}</span>
|
|
439
|
+
<ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
|
|
440
|
+
</button>
|
|
441
|
+
{open && (
|
|
442
|
+
<div
|
|
443
|
+
id={listboxId}
|
|
444
|
+
role="listbox"
|
|
445
|
+
className="absolute right-0 top-full z-50 mt-1 max-h-80 w-[min(36rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-theme-border bg-theme-surface p-1 shadow-theme-lg"
|
|
446
|
+
>
|
|
447
|
+
{revisions.map((revision, index) => {
|
|
448
|
+
const selectedRevision = revision.revision === value
|
|
449
|
+
const activeRevision = index === activeIndex
|
|
450
|
+
return (
|
|
451
|
+
<button
|
|
452
|
+
key={revision.revision}
|
|
453
|
+
id={`${listboxId}-${revision.revision}`}
|
|
454
|
+
type="button"
|
|
455
|
+
role="option"
|
|
456
|
+
aria-selected={selectedRevision}
|
|
457
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
458
|
+
onClick={() => selectRevision(revision.revision)}
|
|
459
|
+
className={clsx(
|
|
460
|
+
'flex w-full min-w-0 items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm transition-colors',
|
|
461
|
+
selectedRevision ? 'selection selection-ring' : activeRevision ? 'bg-theme-hover' : 'hover:bg-theme-hover',
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
<span className="w-14 shrink-0 font-medium text-theme-text-primary">rev {revision.revision}</span>
|
|
465
|
+
<span className={clsx('badge-sm shrink-0', getHelmStatusColor(revision.status))}>{revision.status}</span>
|
|
466
|
+
<span className="min-w-0 flex-1 truncate text-theme-text-secondary">{revision.chart}</span>
|
|
467
|
+
{selectedRevision && <Check className="h-4 w-4 shrink-0 text-accent" />}
|
|
468
|
+
</button>
|
|
469
|
+
)
|
|
470
|
+
})}
|
|
471
|
+
</div>
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function formatRevisionOption(revision: HelmRevision): string {
|
|
478
|
+
return `rev ${revision.revision} - ${revision.status} - ${revision.chart}`
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function CompareSummary({
|
|
482
|
+
left,
|
|
483
|
+
right,
|
|
484
|
+
revision1,
|
|
485
|
+
revision2,
|
|
486
|
+
manifestDiff,
|
|
487
|
+
manifestLoading,
|
|
488
|
+
manifestError,
|
|
489
|
+
valuesDiff,
|
|
490
|
+
valuesLoading,
|
|
491
|
+
valuesError,
|
|
492
|
+
notesDiff,
|
|
493
|
+
notesLoading,
|
|
494
|
+
notesError,
|
|
495
|
+
hooksDiff,
|
|
496
|
+
hooksLoading,
|
|
497
|
+
hooksError,
|
|
498
|
+
resourceDiff,
|
|
499
|
+
resourceLoading,
|
|
500
|
+
resourceError,
|
|
501
|
+
}: {
|
|
502
|
+
left?: HelmRevision
|
|
503
|
+
right?: HelmRevision
|
|
504
|
+
revision1: number
|
|
505
|
+
revision2: number
|
|
506
|
+
manifestDiff?: string
|
|
507
|
+
manifestLoading: boolean
|
|
508
|
+
manifestError: unknown
|
|
509
|
+
valuesDiff?: string
|
|
510
|
+
valuesLoading: boolean
|
|
511
|
+
valuesError: unknown
|
|
512
|
+
notesDiff?: string
|
|
513
|
+
notesLoading: boolean
|
|
514
|
+
notesError: unknown
|
|
515
|
+
hooksDiff?: HooksDiff
|
|
516
|
+
hooksLoading: boolean
|
|
517
|
+
hooksError: unknown
|
|
518
|
+
resourceDiff?: ResourceDiff
|
|
519
|
+
resourceLoading: boolean
|
|
520
|
+
resourceError: unknown
|
|
521
|
+
}) {
|
|
522
|
+
const manifestStats = diffStats(manifestDiff || '')
|
|
523
|
+
const valuesStats = diffStats(valuesDiff || '')
|
|
524
|
+
const notesStats = diffStats(notesDiff || '')
|
|
525
|
+
const hookChanged = hooksDiff ? hooksDiff.added.length + hooksDiff.removed.length + hooksDiff.modified.length : 0
|
|
526
|
+
const resourceChanged = resourceDiff ? resourceDiff.added.length + resourceDiff.removed.length + resourceDiff.modified.length : 0
|
|
527
|
+
const resourceParseErrors = resourceDiff?.parseErrorCount || 0
|
|
528
|
+
|
|
529
|
+
return (
|
|
530
|
+
<div className="card-inner-lg">
|
|
531
|
+
<div className="mb-4">
|
|
532
|
+
<div>
|
|
533
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
534
|
+
<GitCompare className="h-4 w-4 text-theme-text-secondary" />
|
|
535
|
+
<h2 className="text-base font-semibold text-theme-text-primary">Revision {revision1} -> {revision2}</h2>
|
|
536
|
+
</div>
|
|
537
|
+
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
538
|
+
Start with the rendered manifest diff below, then use resources, values, hooks, and notes as supporting evidence.
|
|
539
|
+
</p>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
544
|
+
<span className="text-xs font-medium uppercase text-theme-text-tertiary">Evidence</span>
|
|
545
|
+
<SignalPill
|
|
546
|
+
label="Manifest"
|
|
547
|
+
loading={manifestLoading}
|
|
548
|
+
error={manifestError}
|
|
549
|
+
tone={manifestStats.changed ? 'info' : 'neutral'}
|
|
550
|
+
value={manifestStats.changed ? `${manifestStats.additions} add / ${manifestStats.removals} remove` : 'same'}
|
|
551
|
+
sectionId="manifest"
|
|
552
|
+
/>
|
|
553
|
+
<SignalPill
|
|
554
|
+
label="Resources"
|
|
555
|
+
loading={resourceLoading}
|
|
556
|
+
error={resourceError}
|
|
557
|
+
tone={resourceParseErrors > 0 ? 'warning' : resourceChanged > 0 ? 'warning' : 'neutral'}
|
|
558
|
+
value={resourceDiff ? (resourceParseErrors > 0 ? `partial, ${resourceChanged} changed` : resourceChanged > 0 ? `${resourceChanged} changed` : 'same') : 'same'}
|
|
559
|
+
sectionId="resources"
|
|
560
|
+
/>
|
|
561
|
+
<SignalPill
|
|
562
|
+
label="Values"
|
|
563
|
+
loading={valuesLoading}
|
|
564
|
+
error={valuesError}
|
|
565
|
+
tone={valuesStats.changed ? 'info' : 'neutral'}
|
|
566
|
+
value={valuesStats.changed ? `${valuesStats.additions} add / ${valuesStats.removals} remove` : 'same'}
|
|
567
|
+
sectionId="values"
|
|
568
|
+
/>
|
|
569
|
+
<SignalPill
|
|
570
|
+
label="Hooks"
|
|
571
|
+
loading={hooksLoading}
|
|
572
|
+
error={hooksError}
|
|
573
|
+
tone={hookChanged > 0 ? 'warning' : 'neutral'}
|
|
574
|
+
value={hooksDiff ? `${hookChanged} changed` : 'same'}
|
|
575
|
+
sectionId="hooks"
|
|
576
|
+
/>
|
|
577
|
+
<SignalPill
|
|
578
|
+
label="Notes"
|
|
579
|
+
loading={notesLoading}
|
|
580
|
+
error={notesError}
|
|
581
|
+
tone={notesStats.changed ? 'info' : 'neutral'}
|
|
582
|
+
value={notesStats.changed ? `${notesStats.additions} add / ${notesStats.removals} remove` : 'same'}
|
|
583
|
+
sectionId="notes"
|
|
584
|
+
/>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<div className="mt-4">
|
|
588
|
+
<div className="mb-2 text-xs font-medium uppercase text-theme-text-tertiary">Release metadata</div>
|
|
589
|
+
<MetadataDiffTable
|
|
590
|
+
revision1={revision1}
|
|
591
|
+
revision2={revision2}
|
|
592
|
+
rows={[
|
|
593
|
+
{ label: 'Chart', left: left?.chart, right: right?.chart },
|
|
594
|
+
{ label: 'Status', left: left?.status, right: right?.status, status: true },
|
|
595
|
+
{ label: 'App version', left: left?.appVersion, right: right?.appVersion },
|
|
596
|
+
{
|
|
597
|
+
label: 'Updated',
|
|
598
|
+
left: left?.updated ? formatDate(left.updated) : undefined,
|
|
599
|
+
right: right?.updated ? formatDate(right.updated) : undefined,
|
|
600
|
+
},
|
|
601
|
+
]}
|
|
602
|
+
/>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
interface MetadataDiffRow {
|
|
609
|
+
label: string
|
|
610
|
+
left?: string
|
|
611
|
+
right?: string
|
|
612
|
+
status?: boolean
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function MetadataDiffTable({ revision1, revision2, rows }: { revision1: number; revision2: number; rows: MetadataDiffRow[] }) {
|
|
616
|
+
return (
|
|
617
|
+
<div className="overflow-hidden rounded-lg border border-theme-border bg-theme-base/50">
|
|
618
|
+
<div className="hidden grid-cols-[7.5rem_minmax(0,1fr)_1.5rem_minmax(0,1fr)_5rem] items-center gap-3 border-b border-theme-border bg-theme-surface/70 px-3 py-2 text-xs font-medium text-theme-text-tertiary md:grid">
|
|
619
|
+
<span>Field</span>
|
|
620
|
+
<span>Rev {revision1}</span>
|
|
621
|
+
<span />
|
|
622
|
+
<span>Rev {revision2}</span>
|
|
623
|
+
<span className="text-right">Diff</span>
|
|
624
|
+
</div>
|
|
625
|
+
{rows.map((row) => <MetadataDiffTableRow key={row.label} row={row} />)}
|
|
626
|
+
</div>
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function MetadataDiffTableRow({ row }: { row: MetadataDiffRow }) {
|
|
631
|
+
const changed = (row.left || '') !== (row.right || '')
|
|
632
|
+
return (
|
|
633
|
+
<div
|
|
634
|
+
className={clsx(
|
|
635
|
+
'grid grid-cols-1 gap-1 border-t border-theme-border px-3 py-2.5 text-sm first:border-t-0 md:grid-cols-[7.5rem_minmax(0,1fr)_1.5rem_minmax(0,1fr)_5rem] md:items-center md:gap-3',
|
|
636
|
+
changed && 'bg-accent-muted/30',
|
|
637
|
+
)}
|
|
638
|
+
>
|
|
639
|
+
<div className="font-medium text-theme-text-tertiary md:text-theme-text-secondary">{row.label}</div>
|
|
640
|
+
<MetadataValue value={row.left} status={row.status} muted />
|
|
641
|
+
<div className="hidden text-center text-theme-text-tertiary md:block">-></div>
|
|
642
|
+
<MetadataValue value={row.right} status={row.status} />
|
|
643
|
+
<div className="pt-1 md:pt-0 md:text-right">
|
|
644
|
+
<span className={clsx('badge-sm', changed ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
|
|
645
|
+
{changed ? 'changed' : 'same'}
|
|
646
|
+
</span>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function MetadataValue({ value, status = false, muted = false }: { value?: string; status?: boolean; muted?: boolean }) {
|
|
653
|
+
if (status && value) {
|
|
654
|
+
return <span className={clsx('badge-sm w-fit', getHelmStatusColor(value))}>{value}</span>
|
|
655
|
+
}
|
|
656
|
+
return (
|
|
657
|
+
<div className={clsx('min-w-0 whitespace-normal break-words', muted ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>
|
|
658
|
+
{value || '-'}
|
|
659
|
+
</div>
|
|
660
|
+
)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function SignalPill({
|
|
664
|
+
label,
|
|
665
|
+
value,
|
|
666
|
+
tone,
|
|
667
|
+
loading,
|
|
668
|
+
error,
|
|
669
|
+
sectionId,
|
|
670
|
+
}: {
|
|
671
|
+
label: string
|
|
672
|
+
value: string
|
|
673
|
+
tone: DiffTone
|
|
674
|
+
loading: boolean
|
|
675
|
+
error: unknown
|
|
676
|
+
sectionId: string
|
|
677
|
+
}) {
|
|
678
|
+
const displayTone: DiffTone = error ? 'warning' : loading ? 'neutral' : tone
|
|
679
|
+
const displayValue = error ? 'failed' : loading ? 'loading' : value
|
|
680
|
+
return (
|
|
681
|
+
<a
|
|
682
|
+
href={`#${sectionId}`}
|
|
683
|
+
onClick={(event) => scrollCompareSection(event, sectionId)}
|
|
684
|
+
className="inline-flex items-center gap-2 rounded-md border border-theme-border bg-theme-surface px-2.5 py-1.5 text-sm transition-colors hover:bg-theme-elevated"
|
|
685
|
+
>
|
|
686
|
+
<span className="font-medium text-theme-text-secondary">{label}</span>
|
|
687
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[displayTone])}>{displayValue}</span>
|
|
688
|
+
</a>
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function scrollCompareSection(event: ReactMouseEvent<HTMLAnchorElement>, sectionId: string) {
|
|
693
|
+
event.preventDefault()
|
|
694
|
+
document.getElementById(sectionId)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
695
|
+
const nextUrl = `${window.location.pathname}${window.location.search}#${encodeURIComponent(sectionId)}`
|
|
696
|
+
window.history.replaceState(null, '', nextUrl)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function DiffSection({
|
|
700
|
+
id,
|
|
701
|
+
icon: Icon,
|
|
702
|
+
title,
|
|
703
|
+
description,
|
|
704
|
+
diff,
|
|
705
|
+
isLoading,
|
|
706
|
+
error,
|
|
707
|
+
emptyLabel,
|
|
708
|
+
}: {
|
|
709
|
+
id: string
|
|
710
|
+
icon: typeof Code
|
|
711
|
+
title: string
|
|
712
|
+
description: string
|
|
713
|
+
diff: string
|
|
714
|
+
isLoading: boolean
|
|
715
|
+
error: unknown
|
|
716
|
+
emptyLabel: string
|
|
717
|
+
}) {
|
|
718
|
+
const stats = diffStats(diff)
|
|
719
|
+
return (
|
|
720
|
+
<section id={id} className="card-inner-lg scroll-mt-4">
|
|
721
|
+
<SectionHeader icon={Icon} title={title} description={description}>
|
|
722
|
+
{!isLoading && !error && (
|
|
723
|
+
<div className="flex flex-wrap gap-1.5">
|
|
724
|
+
<span className={clsx('badge-sm', stats.changed ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
|
|
725
|
+
{stats.changed ? `${stats.hunks} hunks` : 'same'}
|
|
726
|
+
</span>
|
|
727
|
+
{stats.changed && (
|
|
728
|
+
<>
|
|
729
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE.success)}>+{stats.additions}</span>
|
|
730
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE.error)}>-{stats.removals}</span>
|
|
731
|
+
</>
|
|
732
|
+
)}
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
735
|
+
</SectionHeader>
|
|
736
|
+
|
|
737
|
+
{isLoading ? (
|
|
738
|
+
<PaneLoader label="Computing diff..." className="h-36" />
|
|
739
|
+
) : error ? (
|
|
740
|
+
<ErrorState error={error} />
|
|
741
|
+
) : !stats.changed ? (
|
|
742
|
+
<EmptyDiffState label={emptyLabel} />
|
|
743
|
+
) : (
|
|
744
|
+
<div className="mt-3 max-h-[620px] overflow-auto rounded-lg border border-theme-border bg-theme-base/60 font-mono text-xs">
|
|
745
|
+
<div className="min-w-max p-3">
|
|
746
|
+
{diff.split('\n').map((line, index) => (
|
|
747
|
+
<DiffLine key={index} line={line} />
|
|
748
|
+
))}
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
</section>
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function HooksDiffSection({ diff, isLoading, error }: { diff?: HooksDiff; isLoading: boolean; error: unknown }) {
|
|
757
|
+
const changed = diff ? diff.added.length + diff.removed.length + diff.modified.length : 0
|
|
758
|
+
return (
|
|
759
|
+
<section id="hooks" className="card-inner-lg scroll-mt-4">
|
|
760
|
+
<SectionHeader
|
|
761
|
+
icon={Anchor}
|
|
762
|
+
title="Hooks diff"
|
|
763
|
+
description="Helm hooks can fail before or after normal resources, so changed hooks are called out separately from the rendered manifest."
|
|
764
|
+
>
|
|
765
|
+
{!isLoading && !error && diff && (
|
|
766
|
+
<div className="flex flex-wrap gap-1.5">
|
|
767
|
+
<span className={clsx('badge-sm', diff.modified.length ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
|
|
768
|
+
{diff.modified.length} modified
|
|
769
|
+
</span>
|
|
770
|
+
<span className={clsx('badge-sm', diff.added.length ? SEVERITY_BADGE.success : SEVERITY_BADGE.neutral)}>
|
|
771
|
+
{diff.added.length} added
|
|
772
|
+
</span>
|
|
773
|
+
<span className={clsx('badge-sm', diff.removed.length ? SEVERITY_BADGE.error : SEVERITY_BADGE.neutral)}>
|
|
774
|
+
{diff.removed.length} removed
|
|
775
|
+
</span>
|
|
776
|
+
</div>
|
|
777
|
+
)}
|
|
778
|
+
</SectionHeader>
|
|
779
|
+
|
|
780
|
+
{isLoading ? (
|
|
781
|
+
<PaneLoader label="Comparing hooks..." className="h-32" />
|
|
782
|
+
) : error ? (
|
|
783
|
+
<ErrorState error={error} />
|
|
784
|
+
) : !diff || changed === 0 ? (
|
|
785
|
+
<EmptyDiffState label="No hook changes found." />
|
|
786
|
+
) : (
|
|
787
|
+
<div className="mt-3 space-y-3">
|
|
788
|
+
<HookGroup title="Modified hooks" tone="info" hooks={diff.modified} />
|
|
789
|
+
<HookGroup title="Added hooks" tone="success" hooks={diff.added} />
|
|
790
|
+
<HookGroup title="Removed hooks" tone="error" hooks={diff.removed} />
|
|
791
|
+
</div>
|
|
792
|
+
)}
|
|
793
|
+
</section>
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function HookGroup({ title, tone, hooks }: { title: string; tone: DiffTone; hooks: HelmHook[] }) {
|
|
798
|
+
if (hooks.length === 0) return null
|
|
799
|
+
return (
|
|
800
|
+
<div>
|
|
801
|
+
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
|
|
802
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{hooks.length}</span>
|
|
803
|
+
{title}
|
|
804
|
+
</div>
|
|
805
|
+
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
|
806
|
+
{hooks.map((hook) => (
|
|
807
|
+
<div key={hookKey(hook)} className="rounded-lg border border-theme-border bg-theme-base/50 p-3">
|
|
808
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
809
|
+
<span className={clsx('badge-sm', getKindBadgeColor(hook.kind))}>{hook.kind}</span>
|
|
810
|
+
<span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{hook.name}</span>
|
|
811
|
+
{hook.status && <span className={clsx('badge-sm', getHelmStatusColor(hook.status))}>{hook.status}</span>}
|
|
812
|
+
{hook.manifestChanged && <span className={clsx('badge-sm', SEVERITY_BADGE.info)}>manifest changed</span>}
|
|
813
|
+
</div>
|
|
814
|
+
<div className="mt-2 flex flex-wrap gap-1.5 text-xs text-theme-text-tertiary">
|
|
815
|
+
{hook.namespace && <span>{hook.namespace}</span>}
|
|
816
|
+
{hook.events.map((event) => <span key={event} className="badge-sm bg-theme-elevated text-theme-text-secondary">{event}</span>)}
|
|
817
|
+
{hook.weight !== 0 && <span>weight {hook.weight}</span>}
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
))}
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function ResourceInventoryDiffSection({
|
|
827
|
+
diff,
|
|
828
|
+
isLoading,
|
|
829
|
+
error,
|
|
830
|
+
left,
|
|
831
|
+
right,
|
|
832
|
+
revision1,
|
|
833
|
+
revision2,
|
|
834
|
+
}: {
|
|
835
|
+
diff?: ResourceDiff
|
|
836
|
+
isLoading: boolean
|
|
837
|
+
error: unknown
|
|
838
|
+
left?: HelmRevision
|
|
839
|
+
right?: HelmRevision
|
|
840
|
+
revision1: number
|
|
841
|
+
revision2: number
|
|
842
|
+
}) {
|
|
843
|
+
const changed = diff ? diff.added.length + diff.removed.length + diff.modified.length : 0
|
|
844
|
+
const identityOverlap = diff ? diff.modified.length + diff.unchanged.length : 0
|
|
845
|
+
const chartChanged = Boolean(left && right && left.chart !== right.chart)
|
|
846
|
+
const lowPairingConfidence = Boolean(diff && chartChanged && changed > 0 && identityOverlap === 0)
|
|
847
|
+
const parseWarning = Boolean(diff?.parseErrorCount)
|
|
848
|
+
|
|
849
|
+
return (
|
|
850
|
+
<section id="resources" className="card-inner-lg scroll-mt-4">
|
|
851
|
+
<SectionHeader
|
|
852
|
+
icon={Link2}
|
|
853
|
+
title="Rendered resources"
|
|
854
|
+
description="Compact index of rendered Kubernetes objects. Use the manifest diff for exact YAML."
|
|
855
|
+
>
|
|
856
|
+
{!isLoading && !error && diff && (
|
|
857
|
+
<div className="flex flex-wrap gap-1.5">
|
|
858
|
+
<span className={clsx('badge-sm', diff.modified.length ? SEVERITY_BADGE.info : SEVERITY_BADGE.neutral)}>
|
|
859
|
+
{diff.modified.length} modified
|
|
860
|
+
</span>
|
|
861
|
+
<span className={clsx('badge-sm', diff.added.length ? SEVERITY_BADGE.success : SEVERITY_BADGE.neutral)}>
|
|
862
|
+
{diff.added.length} added
|
|
863
|
+
</span>
|
|
864
|
+
<span className={clsx('badge-sm', diff.removed.length ? SEVERITY_BADGE.error : SEVERITY_BADGE.neutral)}>
|
|
865
|
+
{diff.removed.length} removed
|
|
866
|
+
</span>
|
|
867
|
+
{parseWarning && (
|
|
868
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE.warning)}>
|
|
869
|
+
{diff.parseErrorCount} unparsed
|
|
870
|
+
</span>
|
|
871
|
+
)}
|
|
872
|
+
</div>
|
|
873
|
+
)}
|
|
874
|
+
</SectionHeader>
|
|
875
|
+
|
|
876
|
+
{isLoading ? (
|
|
877
|
+
<PaneLoader label="Comparing rendered resources..." className="h-32" />
|
|
878
|
+
) : error ? (
|
|
879
|
+
<ErrorState error={error} />
|
|
880
|
+
) : !diff ? (
|
|
881
|
+
<EmptyDiffState label="No rendered resource changes found." />
|
|
882
|
+
) : (
|
|
883
|
+
<div className="mt-3 space-y-3">
|
|
884
|
+
{parseWarning && (
|
|
885
|
+
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-theme-text-secondary">
|
|
886
|
+
<div className="flex items-start gap-2">
|
|
887
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
888
|
+
<div>
|
|
889
|
+
<div className="font-medium text-theme-text-primary">Rendered resource list may be incomplete.</div>
|
|
890
|
+
<div className="mt-1">
|
|
891
|
+
Radar could not parse {diff.parseErrorCount} rendered manifest document{diff.parseErrorCount === 1 ? '' : 's'} for
|
|
892
|
+
resource grouping. Use the rendered manifest diff above as the source of truth.
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
</div>
|
|
896
|
+
</div>
|
|
897
|
+
)}
|
|
898
|
+
{lowPairingConfidence && (
|
|
899
|
+
<LowPairingConfidenceNotice added={diff.added} removed={diff.removed} />
|
|
900
|
+
)}
|
|
901
|
+
{changed === 0 ? (
|
|
902
|
+
<EmptyDiffState label={parseWarning ? 'No rendered resource changes found in parsed documents.' : 'No rendered resource changes found.'} />
|
|
903
|
+
) : lowPairingConfidence ? (
|
|
904
|
+
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
|
905
|
+
<ResourceChangeList title="Added" tone="success" resources={diff.added} initialLimit={8} />
|
|
906
|
+
<ResourceChangeList title="Removed" tone="error" resources={diff.removed} initialLimit={8} />
|
|
907
|
+
</div>
|
|
908
|
+
) : (
|
|
909
|
+
<>
|
|
910
|
+
<ModifiedResourceTable changes={diff.modified} revision1={revision1} revision2={revision2} />
|
|
911
|
+
<ResourceChangeList title="Added" tone="success" resources={diff.added} />
|
|
912
|
+
<ResourceChangeList title="Removed" tone="error" resources={diff.removed} />
|
|
913
|
+
</>
|
|
914
|
+
)}
|
|
915
|
+
</div>
|
|
916
|
+
)}
|
|
917
|
+
</section>
|
|
918
|
+
)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function ModifiedResourceTable({
|
|
922
|
+
changes,
|
|
923
|
+
revision1,
|
|
924
|
+
revision2,
|
|
925
|
+
}: {
|
|
926
|
+
changes: ResourceDiff['modified']
|
|
927
|
+
revision1: number
|
|
928
|
+
revision2: number
|
|
929
|
+
}) {
|
|
930
|
+
if (changes.length === 0) return null
|
|
931
|
+
return (
|
|
932
|
+
<div>
|
|
933
|
+
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
|
|
934
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE.info)}>{changes.length}</span>
|
|
935
|
+
Modified in place
|
|
936
|
+
</div>
|
|
937
|
+
<div className="space-y-3">
|
|
938
|
+
{changes.map((change) => (
|
|
939
|
+
<ModifiedResourceRows key={resourceKey(change)} change={change} revision1={revision1} revision2={revision2} />
|
|
940
|
+
))}
|
|
941
|
+
</div>
|
|
942
|
+
</div>
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function ModifiedResourceRows({
|
|
947
|
+
change,
|
|
948
|
+
revision1,
|
|
949
|
+
revision2,
|
|
950
|
+
}: {
|
|
951
|
+
change: ResourceDiff['modified'][number]
|
|
952
|
+
revision1: number
|
|
953
|
+
revision2: number
|
|
954
|
+
}) {
|
|
955
|
+
const explicitFields = change.fields.filter((field) => {
|
|
956
|
+
const oldValue = formatDiffValue(field.oldValue, field.path)
|
|
957
|
+
const newValue = formatDiffValue(field.newValue, field.path)
|
|
958
|
+
return !isGenericContentChange(field, oldValue, newValue)
|
|
959
|
+
})
|
|
960
|
+
const visibleFields = explicitFields.slice(0, 8)
|
|
961
|
+
const hiddenCount = Math.max(0, explicitFields.length - visibleFields.length) + Math.max(0, change.fieldCount - change.fields.length)
|
|
962
|
+
|
|
963
|
+
return (
|
|
964
|
+
<div className="overflow-hidden rounded-lg border border-theme-border bg-theme-base/50">
|
|
965
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b border-theme-border px-3 py-2">
|
|
966
|
+
<span className={clsx('badge-sm shrink-0', getKindBadgeColor(change.kind))}>{change.kind}</span>
|
|
967
|
+
<ResourceName resource={change} />
|
|
968
|
+
</div>
|
|
969
|
+
{visibleFields.length === 0 ? (
|
|
970
|
+
<div className="px-3 py-2 text-xs text-theme-text-secondary">
|
|
971
|
+
{change.summary && change.summary !== 'resource changed'
|
|
972
|
+
? change.summary
|
|
973
|
+
: 'Rendered manifest changed; field-level summary unavailable.'}
|
|
974
|
+
</div>
|
|
975
|
+
) : (
|
|
976
|
+
<div className="overflow-x-auto">
|
|
977
|
+
<table className="w-full min-w-[720px] border-collapse text-left text-xs">
|
|
978
|
+
<thead className="bg-theme-surface text-theme-text-tertiary">
|
|
979
|
+
<tr>
|
|
980
|
+
<th scope="col" className="w-[42%] px-3 py-2 font-medium">
|
|
981
|
+
Field
|
|
982
|
+
</th>
|
|
983
|
+
<th scope="col" className="w-[29%] px-3 py-2 font-medium">
|
|
984
|
+
Rev {revision1}
|
|
985
|
+
</th>
|
|
986
|
+
<th scope="col" className="w-[29%] px-3 py-2 font-medium">
|
|
987
|
+
Rev {revision2}
|
|
988
|
+
</th>
|
|
989
|
+
</tr>
|
|
990
|
+
</thead>
|
|
991
|
+
<tbody className="divide-y divide-theme-border">
|
|
992
|
+
{visibleFields.map((field, index) => (
|
|
993
|
+
<ModifiedFieldRow key={`${field.path}-${index}`} field={field} />
|
|
994
|
+
))}
|
|
995
|
+
</tbody>
|
|
996
|
+
</table>
|
|
997
|
+
</div>
|
|
998
|
+
)}
|
|
999
|
+
{hiddenCount > 0 && (
|
|
1000
|
+
<div className="border-t border-theme-border px-3 py-2 text-xs text-theme-text-tertiary">
|
|
1001
|
+
+{hiddenCount} more changed field{hiddenCount === 1 ? '' : 's'} in the manifest diff
|
|
1002
|
+
</div>
|
|
1003
|
+
)}
|
|
1004
|
+
</div>
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function ModifiedFieldRow({ field }: { field: ResourceDiff['modified'][number]['fields'][number] }) {
|
|
1009
|
+
const oldValue = formatDiffValue(field.oldValue, field.path)
|
|
1010
|
+
const newValue = formatDiffValue(field.newValue, field.path)
|
|
1011
|
+
const oldTooltip = formatFullDiffValue(field.oldValue, field.path)
|
|
1012
|
+
const newTooltip = formatFullDiffValue(field.newValue, field.path)
|
|
1013
|
+
return (
|
|
1014
|
+
<tr className="align-top">
|
|
1015
|
+
<td className="px-3 py-2">
|
|
1016
|
+
<Tooltip content={field.path} wrapperClassName="min-w-0 max-w-full">
|
|
1017
|
+
<code className="break-words font-mono text-theme-text-secondary">{formatPathLabel(field.path)}</code>
|
|
1018
|
+
</Tooltip>
|
|
1019
|
+
</td>
|
|
1020
|
+
<td className="px-3 py-2 text-theme-text-secondary">
|
|
1021
|
+
<Tooltip content={oldTooltip} wrapperClassName="min-w-0 max-w-full">
|
|
1022
|
+
<span className="block break-words">{oldValue}</span>
|
|
1023
|
+
</Tooltip>
|
|
1024
|
+
</td>
|
|
1025
|
+
<td className="px-3 py-2 font-medium text-theme-text-primary">
|
|
1026
|
+
<Tooltip content={newTooltip} wrapperClassName="min-w-0 max-w-full">
|
|
1027
|
+
<span className="block break-words">{newValue}</span>
|
|
1028
|
+
</Tooltip>
|
|
1029
|
+
</td>
|
|
1030
|
+
</tr>
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function LowPairingConfidenceNotice({
|
|
1035
|
+
added,
|
|
1036
|
+
removed,
|
|
1037
|
+
}: {
|
|
1038
|
+
added: ResourceDiff['added']
|
|
1039
|
+
removed: ResourceDiff['removed']
|
|
1040
|
+
}) {
|
|
1041
|
+
return (
|
|
1042
|
+
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-theme-text-secondary">
|
|
1043
|
+
<div className="flex items-start gap-2">
|
|
1044
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
1045
|
+
<div className="min-w-0">
|
|
1046
|
+
<div className="font-medium text-theme-text-primary">Resource identities do not overlap.</div>
|
|
1047
|
+
<div className="mt-1">
|
|
1048
|
+
The chart changed and Radar cannot pair old and new resources by name. Use the manifest diff above for the cause; the rows below are
|
|
1049
|
+
identity context.
|
|
1050
|
+
</div>
|
|
1051
|
+
<div className="mt-2 grid grid-cols-1 gap-2 text-xs md:grid-cols-2">
|
|
1052
|
+
<div>
|
|
1053
|
+
<span className="font-medium text-theme-text-primary">Added:</span>{' '}
|
|
1054
|
+
<span>{summarizeResourceKinds(added)}</span>
|
|
1055
|
+
</div>
|
|
1056
|
+
<div>
|
|
1057
|
+
<span className="font-medium text-theme-text-primary">Removed:</span>{' '}
|
|
1058
|
+
<span>{summarizeResourceKinds(removed)}</span>
|
|
1059
|
+
</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function ResourceChangeList({
|
|
1068
|
+
title,
|
|
1069
|
+
tone,
|
|
1070
|
+
resources,
|
|
1071
|
+
initialLimit = 12,
|
|
1072
|
+
}: {
|
|
1073
|
+
title: string
|
|
1074
|
+
tone: DiffTone
|
|
1075
|
+
resources: ResourceDiff['added']
|
|
1076
|
+
initialLimit?: number
|
|
1077
|
+
}) {
|
|
1078
|
+
const [expanded, setExpanded] = useState(false)
|
|
1079
|
+
if (resources.length === 0) return null
|
|
1080
|
+
const visible = expanded ? resources : resources.slice(0, initialLimit)
|
|
1081
|
+
return (
|
|
1082
|
+
<div>
|
|
1083
|
+
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-theme-text-primary">
|
|
1084
|
+
<span className={clsx('badge-sm', SEVERITY_BADGE[tone])}>{resources.length}</span>
|
|
1085
|
+
{title}
|
|
1086
|
+
</div>
|
|
1087
|
+
<div className="grid grid-cols-1 gap-1.5 2xl:grid-cols-2">
|
|
1088
|
+
{visible.map((resource) => (
|
|
1089
|
+
<ResourceChangeRow key={resourceKey(resource)} resource={resource} />
|
|
1090
|
+
))}
|
|
1091
|
+
</div>
|
|
1092
|
+
{resources.length > initialLimit && (
|
|
1093
|
+
<button
|
|
1094
|
+
type="button"
|
|
1095
|
+
onClick={() => setExpanded((value) => !value)}
|
|
1096
|
+
className="mt-2 rounded-md px-2 py-1 text-xs font-medium text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
|
|
1097
|
+
>
|
|
1098
|
+
{expanded ? 'Show fewer' : `Show ${resources.length - visible.length} more`}
|
|
1099
|
+
</button>
|
|
1100
|
+
)}
|
|
1101
|
+
</div>
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function ResourceChangeRow({ resource }: { resource: ResourceDiff['added'][number] }) {
|
|
1106
|
+
return (
|
|
1107
|
+
<div className="flex min-w-0 items-start gap-2 rounded-md border border-theme-border bg-theme-base/50 px-2 py-1.5">
|
|
1108
|
+
<span className={clsx('badge-sm shrink-0', getKindBadgeColor(resource.kind))}>{resource.kind}</span>
|
|
1109
|
+
<ResourceName resource={resource} />
|
|
1110
|
+
</div>
|
|
1111
|
+
)
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function ResourceName({ resource }: { resource: ResourceDiff['added'][number] }) {
|
|
1115
|
+
return (
|
|
1116
|
+
<div className="min-w-0 flex-1">
|
|
1117
|
+
<div className="break-words text-sm font-medium leading-snug text-theme-text-primary">{resource.name}</div>
|
|
1118
|
+
{resource.namespace && <div className="mt-0.5 text-xs text-theme-text-tertiary">{resource.namespace}</div>}
|
|
1119
|
+
</div>
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function isGenericContentChange(
|
|
1124
|
+
field: ResourceDiff['modified'][number]['fields'][number],
|
|
1125
|
+
oldValue: string,
|
|
1126
|
+
newValue: string,
|
|
1127
|
+
): boolean {
|
|
1128
|
+
return field.path === 'resource' && oldValue === 'changed' && newValue === 'changed'
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function summarizeResourceKinds(resources: ResourceDiff['added']): string {
|
|
1132
|
+
if (resources.length === 0) return 'none'
|
|
1133
|
+
const counts = new Map<string, number>()
|
|
1134
|
+
for (const resource of resources) {
|
|
1135
|
+
counts.set(resource.kind, (counts.get(resource.kind) || 0) + 1)
|
|
1136
|
+
}
|
|
1137
|
+
return [...counts.entries()]
|
|
1138
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1139
|
+
.map(([kind, count]) => `${count} ${kind}`)
|
|
1140
|
+
.join(', ')
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function SectionHeader({
|
|
1144
|
+
icon: Icon,
|
|
1145
|
+
title,
|
|
1146
|
+
description,
|
|
1147
|
+
children,
|
|
1148
|
+
}: {
|
|
1149
|
+
icon: typeof Code
|
|
1150
|
+
title: string
|
|
1151
|
+
description: string
|
|
1152
|
+
children?: ReactNode
|
|
1153
|
+
}) {
|
|
1154
|
+
return (
|
|
1155
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
1156
|
+
<div className="min-w-0">
|
|
1157
|
+
<div className="flex items-center gap-2">
|
|
1158
|
+
<Icon className="h-4 w-4 text-theme-text-secondary" />
|
|
1159
|
+
<h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
|
|
1160
|
+
</div>
|
|
1161
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{description}</p>
|
|
1162
|
+
</div>
|
|
1163
|
+
{children}
|
|
1164
|
+
</div>
|
|
1165
|
+
)
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function EmptyDiffState({ label }: { label: string }) {
|
|
1169
|
+
return (
|
|
1170
|
+
<div className="mt-3 flex items-center gap-2 rounded-lg border border-theme-border bg-theme-base/50 px-3 py-3 text-sm text-theme-text-secondary">
|
|
1171
|
+
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
|
1172
|
+
{label}
|
|
1173
|
+
</div>
|
|
1174
|
+
)
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function ErrorState({ error }: { error: unknown }) {
|
|
1178
|
+
return (
|
|
1179
|
+
<div className="mt-3 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-3 text-sm text-theme-text-secondary">
|
|
1180
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
|
|
1181
|
+
<div>{error instanceof Error ? error.message : 'Failed to load this comparison surface.'}</div>
|
|
1182
|
+
</div>
|
|
1183
|
+
)
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function parseReleaseParam(value: string | null): ParsedReleaseParam | null {
|
|
1187
|
+
if (!value) return null
|
|
1188
|
+
const separator = value.indexOf('/')
|
|
1189
|
+
if (separator <= 0 || separator === value.length - 1) return null
|
|
1190
|
+
return {
|
|
1191
|
+
namespace: value.slice(0, separator),
|
|
1192
|
+
name: value.slice(separator + 1),
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function parsePositiveInt(value: string | null): number | undefined {
|
|
1197
|
+
if (!value) return undefined
|
|
1198
|
+
const parsed = Number(value)
|
|
1199
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function previousRevision(revisions: HelmRevision[], revision: number): number | undefined {
|
|
1203
|
+
const lower = revisions.filter((item) => item.revision < revision)
|
|
1204
|
+
return lower.at(-1)?.revision
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function diffStats(diff: string): DiffStats {
|
|
1208
|
+
let additions = 0
|
|
1209
|
+
let removals = 0
|
|
1210
|
+
let hunks = 0
|
|
1211
|
+
for (const line of diff.split('\n')) {
|
|
1212
|
+
if (line.startsWith('@@')) hunks += 1
|
|
1213
|
+
else if (line.startsWith('+') && !line.startsWith('+++')) additions += 1
|
|
1214
|
+
else if (line.startsWith('-') && !line.startsWith('---')) removals += 1
|
|
1215
|
+
}
|
|
1216
|
+
return { changed: hasDiffBodyChange(diff), additions, removals, hunks }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function resourceKey(resource: ResourceDiff['added'][number]): string {
|
|
1220
|
+
return `${resource.apiVersion || ''}/${resource.kind}/${resource.namespace || ''}/${resource.name}`
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function hookKey(hook: HelmHook): string {
|
|
1224
|
+
return `${hook.namespace || ''}/${hook.kind}/${hook.name}/${hook.events.join(',')}`
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function formatPathLabel(path: string): string {
|
|
1228
|
+
return path
|
|
1229
|
+
.replace(/\[\*\]/g, '')
|
|
1230
|
+
.replace(/\./g, ' / ')
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function formatDiffValue(value: unknown, path?: string): string {
|
|
1234
|
+
if (value === null || value === undefined) return 'none'
|
|
1235
|
+
if (typeof value === 'string') return truncate(value)
|
|
1236
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
1237
|
+
const structured = formatStructuredDiffValue(value, path)
|
|
1238
|
+
if (structured) return truncate(structured)
|
|
1239
|
+
try {
|
|
1240
|
+
return truncate(JSON.stringify(value))
|
|
1241
|
+
} catch {
|
|
1242
|
+
return truncate(String(value))
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function formatFullDiffValue(value: unknown, path?: string): string {
|
|
1247
|
+
if (value === null || value === undefined) return 'none'
|
|
1248
|
+
if (typeof value === 'string') return value
|
|
1249
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
1250
|
+
const structured = formatStructuredDiffValue(value, path)
|
|
1251
|
+
if (structured) return structured
|
|
1252
|
+
try {
|
|
1253
|
+
return JSON.stringify(value, null, 2)
|
|
1254
|
+
} catch {
|
|
1255
|
+
return String(value)
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function formatStructuredDiffValue(value: unknown, path?: string): string | undefined {
|
|
1260
|
+
if (!isRecord(value)) return undefined
|
|
1261
|
+
if (path?.includes('Probe')) return formatProbeValue(value)
|
|
1262
|
+
return undefined
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function formatProbeValue(value: Record<string, unknown>): string | undefined {
|
|
1266
|
+
const handler = formatProbeHandler(value)
|
|
1267
|
+
if (!handler) return undefined
|
|
1268
|
+
const details: string[] = []
|
|
1269
|
+
const periodSeconds = numberField(value, 'periodSeconds')
|
|
1270
|
+
const timeoutSeconds = numberField(value, 'timeoutSeconds')
|
|
1271
|
+
const failureThreshold = numberField(value, 'failureThreshold')
|
|
1272
|
+
if (periodSeconds && periodSeconds > 0) details.push(`period ${periodSeconds}s`)
|
|
1273
|
+
if (timeoutSeconds && timeoutSeconds > 0) details.push(`timeout ${timeoutSeconds}s`)
|
|
1274
|
+
if (failureThreshold && failureThreshold > 0) details.push(`failure threshold ${failureThreshold}`)
|
|
1275
|
+
return details.length ? `${handler} (${details.join(', ')})` : handler
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function formatProbeHandler(value: Record<string, unknown>): string | undefined {
|
|
1279
|
+
const normalizedHandler = stringField(value, 'handler')
|
|
1280
|
+
if (normalizedHandler) return formatNormalizedProbeHandler(normalizedHandler)
|
|
1281
|
+
if (isRecord(value.httpGet)) {
|
|
1282
|
+
const method = stringField(value.httpGet, 'scheme') || 'HTTP'
|
|
1283
|
+
const path = stringField(value.httpGet, 'path') || '/'
|
|
1284
|
+
const port = value.httpGet.port
|
|
1285
|
+
return `${method} GET ${path}${port !== undefined ? ` on ${String(port)}` : ''}`
|
|
1286
|
+
}
|
|
1287
|
+
if (isRecord(value.tcpSocket)) {
|
|
1288
|
+
const port = value.tcpSocket.port
|
|
1289
|
+
return `TCP socket${port !== undefined ? ` on ${String(port)}` : ''}`
|
|
1290
|
+
}
|
|
1291
|
+
if (isRecord(value.grpc)) {
|
|
1292
|
+
const port = value.grpc.port
|
|
1293
|
+
const service = stringField(value.grpc, 'service')
|
|
1294
|
+
return `gRPC${service ? ` ${service}` : ''}${port !== undefined ? ` on ${String(port)}` : ''}`
|
|
1295
|
+
}
|
|
1296
|
+
if (isRecord(value.exec) && Array.isArray(value.exec.command)) {
|
|
1297
|
+
return `exec ${value.exec.command.map(String).join(' ')}`
|
|
1298
|
+
}
|
|
1299
|
+
return undefined
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function formatNormalizedProbeHandler(handler: string): string {
|
|
1303
|
+
if (handler.startsWith('httpGet:')) {
|
|
1304
|
+
const rest = handler.slice('httpGet:'.length)
|
|
1305
|
+
const schemeSeparator = rest.indexOf(':')
|
|
1306
|
+
const scheme = schemeSeparator >= 0 ? rest.slice(0, schemeSeparator) : ''
|
|
1307
|
+
const target = schemeSeparator >= 0 ? rest.slice(schemeSeparator + 1) : rest
|
|
1308
|
+
const slashIndex = target.indexOf('/')
|
|
1309
|
+
const port = slashIndex >= 0 ? target.slice(0, slashIndex) : target
|
|
1310
|
+
const path = slashIndex >= 0 ? target.slice(slashIndex) : '/'
|
|
1311
|
+
return `${scheme || 'HTTP'} GET ${path}${port ? ` on ${port}` : ''}`
|
|
1312
|
+
}
|
|
1313
|
+
if (handler.startsWith('tcpSocket:')) {
|
|
1314
|
+
const port = handler.slice('tcpSocket:'.length)
|
|
1315
|
+
return `TCP socket${port ? ` on ${port}` : ''}`
|
|
1316
|
+
}
|
|
1317
|
+
if (handler.startsWith('grpc:')) {
|
|
1318
|
+
const target = handler.slice('grpc:'.length)
|
|
1319
|
+
const [port, service] = target.split('/', 2)
|
|
1320
|
+
return `gRPC${service ? ` ${service}` : ''}${port ? ` on ${port}` : ''}`
|
|
1321
|
+
}
|
|
1322
|
+
return handler
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1326
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function stringField(value: Record<string, unknown>, field: string): string | undefined {
|
|
1330
|
+
const raw = value[field]
|
|
1331
|
+
return typeof raw === 'string' ? raw : undefined
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function numberField(value: Record<string, unknown>, field: string): number | undefined {
|
|
1335
|
+
const raw = value[field]
|
|
1336
|
+
return typeof raw === 'number' ? raw : undefined
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function truncate(value: string): string {
|
|
1340
|
+
return value.length > 96 ? `${value.slice(0, 93)}...` : value
|
|
1341
|
+
}
|