@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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -1,19 +1,21 @@
1
1
  import { useState } from 'react'
2
2
  import { History, Eye, GitCompare, Check, RotateCcw } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import type { HelmRevision } from '../../types'
4
+ import type { HelmOperation, HelmRevision } from '../../types'
5
5
  import { getStatusColor, formatDate, formatAge } from './helm-utils'
6
6
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
7
+ import { Tooltip } from '../ui/Tooltip'
7
8
 
8
9
  interface RevisionHistoryProps {
9
10
  history: HelmRevision[]
10
11
  currentRevision: number
12
+ operations?: HelmOperation[]
11
13
  onViewRevision: (revision: number) => void
12
14
  onCompare: (rev1: number, rev2: number) => void
13
15
  onRollback?: (revision: number) => void
14
16
  }
15
17
 
16
- export function RevisionHistory({ history, currentRevision, onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
18
+ export function RevisionHistory({ history, currentRevision, operations = [], onViewRevision, onCompare, onRollback }: RevisionHistoryProps) {
17
19
  const [selectedForCompare, setSelectedForCompare] = useState<number | null>(null)
18
20
 
19
21
  const handleCompareClick = (revision: number) => {
@@ -62,6 +64,7 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
62
64
  {history.map((revision, index) => {
63
65
  const isCurrent = revision.revision === currentRevision
64
66
  const isSelectedForCompare = selectedForCompare === revision.revision
67
+ const annotations = operationAnnotationsForRevision(operations, revision.revision, revision.status)
65
68
 
66
69
  return (
67
70
  <div
@@ -105,10 +108,15 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
105
108
  Current
106
109
  </span>
107
110
  )}
111
+ {annotations.map((annotation) => (
112
+ <span key={annotation.label} className={clsx('badge-sm', annotation.className)}>
113
+ {annotation.label}
114
+ </span>
115
+ ))}
108
116
  </div>
109
117
 
110
118
  <div className="flex items-center gap-4 mt-1 text-xs text-theme-text-tertiary">
111
- <span title={revision.updated}>{formatDate(revision.updated)}</span>
119
+ <Tooltip content={revision.updated}><span>{formatDate(revision.updated)}</span></Tooltip>
112
120
  <span>{formatAge(revision.updated)} ago</span>
113
121
  </div>
114
122
 
@@ -165,3 +173,44 @@ export function RevisionHistory({ history, currentRevision, onViewRevision, onCo
165
173
  </div>
166
174
  )
167
175
  }
176
+
177
+ function operationAnnotationsForRevision(operations: HelmOperation[], revision: number, revisionStatus: string): Array<{ label: string; className: string }> {
178
+ const annotations: Array<{ label: string; className: string }> = []
179
+ const seen = new Set<string>()
180
+ const add = (label: string, className: string) => {
181
+ if (seen.has(label)) return
182
+ seen.add(label)
183
+ annotations.push({ label, className })
184
+ }
185
+ const addFailure = (label: string) => {
186
+ if (revisionStatus.toLowerCase() === 'failed') return
187
+ add(label, SEVERITY_BADGE.error)
188
+ }
189
+
190
+ for (const op of operations) {
191
+ if (op.failedRevision === revision) {
192
+ addFailure('Failed upgrade')
193
+ }
194
+ if (op.rollbackRevision === revision) {
195
+ add('Rollback revision', SEVERITY_BADGE.warning)
196
+ }
197
+ if (op.revision === revision) {
198
+ switch (op.kind) {
199
+ case 'upgrade_failed':
200
+ addFailure('Failed upgrade')
201
+ break
202
+ case 'release_failed':
203
+ addFailure('Failed')
204
+ break
205
+ case 'rollback':
206
+ add('Rollback', SEVERITY_BADGE.warning)
207
+ break
208
+ case 'pending':
209
+ add('Pending', SEVERITY_BADGE.warning)
210
+ break
211
+ }
212
+ }
213
+ }
214
+
215
+ return annotations
216
+ }
@@ -22,7 +22,7 @@ interface RoleGatedPanelProps {
22
22
  *
23
23
  * Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
24
24
  * true when no Cloud role is present. The backend gate has the same
25
- * shape, so the SPA stays in lockstep.
25
+ * shape, so the frontend stays in lockstep.
26
26
  */
27
27
  export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
28
28
  const { role, canAtLeast } = useCloudRole()
@@ -38,8 +38,8 @@ export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps)
38
38
  Your role can't view {feature}
39
39
  </div>
40
40
  <div className="mt-1 max-w-md text-xs text-theme-text-secondary">
41
- You're signed in as <span className="font-mono text-theme-text-primary">{role ?? 'viewer'}</span>.
42
- This view requires <span className="font-mono text-theme-text-primary">{min}</span> or higher.
41
+ You're signed in as <span className="inline-code">{role ?? 'viewer'}</span>.
42
+ This view requires <span className="inline-code">{min}</span> or higher.
43
43
  Ask a {min} or owner if you need access.
44
44
  </div>
45
45
  </div>
@@ -0,0 +1,185 @@
1
+ import { useState } from 'react'
2
+ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
+ import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
6
+ import { Input } from '@skyhook-io/k8s-ui'
7
+ import type { UpgradeInfo } from '../../types'
8
+
9
+ interface TrackChartSourceDialogProps {
10
+ open: boolean
11
+ onClose: () => void
12
+ /** Chart name of the release this was opened from, for the example prompt. */
13
+ chartName?: string
14
+ sourceIssue?: UpgradeInfo['sourceIssue']
15
+ sourceError?: string
16
+ }
17
+
18
+ function getSourceIssueCopy(sourceIssue: UpgradeInfo['sourceIssue'], sourceError?: string) {
19
+ switch (sourceIssue) {
20
+ case 'repo_index_error':
21
+ return {
22
+ title: 'A Helm repo index failed',
23
+ body: 'Fix, refresh, or remove the broken Helm repo index. If this release came from OCI, add its registry prefix and Radar will check OCI before reporting that repo error.',
24
+ }
25
+ case 'ambiguous_repository':
26
+ return {
27
+ title: 'Multiple Helm repos match',
28
+ body: 'Radar will not guess between classic repos. Adding an OCI prefix will not enable direct upgrades while those repos remain ambiguous.',
29
+ }
30
+ case 'untracked':
31
+ return {
32
+ title: 'Source not tracked',
33
+ body: 'Helm does not record the install source. Add the OCI prefix that contains this chart.',
34
+ }
35
+ default:
36
+ return sourceError
37
+ ? { title: 'Source unresolved', body: sourceError }
38
+ : undefined
39
+ }
40
+ }
41
+
42
+ // TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
43
+ // OCI analog of `helm repo add`. Helm doesn't persist the ref a release was
44
+ // installed from, so for charts published to an OCI registry (and not managed by
45
+ // GitOps) Radar can only track upgrades once the user declares where they live.
46
+ // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
47
+ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue, sourceError }: TrackChartSourceDialogProps) {
48
+ const [value, setValue] = useState('')
49
+ const { data: sources } = useHelmOCISources()
50
+ const { data: clusterInfo } = useClusterInfo()
51
+ const addSource = useAddOCISource()
52
+ const removeSource = useRemoveOCISource()
53
+
54
+ // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
55
+ // points at an empty /tmp), so private registries can't authenticate — only
56
+ // public charts can be tracked. Be honest about it rather than silently failing.
57
+ const inCluster = clusterInfo?.inCluster ?? false
58
+
59
+ const trimmed = value.trim()
60
+ const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
61
+ const normalizedInput = trimmed.replace(/\/+$/, '')
62
+ const normalizedChartName = chartName?.trim().replace(/^\/+|\/+$/g, '') ?? ''
63
+ const probedRef = normalizedInput && normalizedChartName ? `${normalizedInput}/${normalizedChartName}` : ''
64
+ const looksLikeFullChartRef = Boolean(normalizedInput && normalizedChartName && normalizedInput.endsWith(`/${normalizedChartName}`))
65
+ const sourceIssueCopy = getSourceIssueCopy(sourceIssue, sourceError)
66
+
67
+ const handleAdd = () => {
68
+ if (!trimmed || invalid) return
69
+ addSource.mutate(trimmed, { onSuccess: () => setValue('') })
70
+ }
71
+
72
+ return (
73
+ <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
74
+ <div className="flex items-start gap-3 p-4 border-b border-theme-border">
75
+ <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
76
+ <Link2 className="w-5 h-5 text-theme-text-secondary" />
77
+ </div>
78
+ <div className="flex-1 min-w-0">
79
+ <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
80
+ <p className="text-sm text-theme-text-secondary mt-1">
81
+ Helm doesn&apos;t record where a chart was installed from. Register your OCI
82
+ registry prefix and Radar will check it for newer versions of your charts.
83
+ </p>
84
+ </div>
85
+ <button
86
+ onClick={onClose}
87
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
88
+ >
89
+ <X className="w-5 h-5" />
90
+ </button>
91
+ </div>
92
+
93
+ <div className="p-4 space-y-4">
94
+ {sourceIssueCopy && (
95
+ <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-sm">
96
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-theme-text-secondary" />
97
+ <div>
98
+ <p className="font-medium text-theme-text-primary">{sourceIssueCopy.title}</p>
99
+ <p className="text-theme-text-secondary">{sourceIssueCopy.body}</p>
100
+ </div>
101
+ </div>
102
+ )}
103
+
104
+ <div>
105
+ <label className="block text-sm font-medium text-theme-text-secondary mb-2">
106
+ OCI registry prefix
107
+ </label>
108
+ <div className="flex gap-2">
109
+ <Input
110
+ value={value}
111
+ onChange={(e) => setValue(e.target.value)}
112
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
113
+ placeholder="oci://ghcr.io/myorg/charts"
114
+ aria-invalid={invalid ? true : undefined}
115
+ className={clsx(
116
+ 'flex-1 px-3 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
117
+ invalid ? 'border-red-500/60 focus:ring-red-500' : 'border-theme-border-light focus:ring-accent',
118
+ )}
119
+ />
120
+ <button
121
+ onClick={handleAdd}
122
+ disabled={!trimmed || invalid || addSource.isPending}
123
+ className="btn-brand px-3 py-2 text-sm inline-flex items-center gap-1 disabled:opacity-50 disabled:pointer-events-none"
124
+ >
125
+ <Plus className="w-4 h-4" />
126
+ Add
127
+ </button>
128
+ </div>
129
+ <p className="mt-1 text-xs text-theme-text-tertiary">
130
+ {invalid
131
+ ? 'Must be an oci:// reference.'
132
+ : looksLikeFullChartRef
133
+ ? `This looks like a full chart ref. Radar would probe "${probedRef}"; remove the final "/${normalizedChartName}" if "${normalizedChartName}" is the chart name.`
134
+ : chartName
135
+ ? `Radar will probe "${probedRef || `<prefix>/${normalizedChartName}`}".`
136
+ : 'Radar probes <prefix>/<chartName> for each untracked release.'}
137
+ </p>
138
+ </div>
139
+
140
+ {sources && sources.length > 0 && (
141
+ <div>
142
+ <p className="text-xs font-medium text-theme-text-tertiary uppercase tracking-wide mb-2">
143
+ Registered sources
144
+ </p>
145
+ <ul className="space-y-1">
146
+ {sources.map((src) => (
147
+ <li
148
+ key={src}
149
+ className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
150
+ >
151
+ <span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
152
+ <button
153
+ onClick={() => removeSource.mutate(src)}
154
+ disabled={removeSource.isPending}
155
+ className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
156
+ aria-label={`Remove ${src}`}
157
+ >
158
+ <Trash2 className="w-4 h-4" />
159
+ </button>
160
+ </li>
161
+ ))}
162
+ </ul>
163
+ </div>
164
+ )}
165
+
166
+ {inCluster ? (
167
+ <div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
168
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
169
+ <span>
170
+ Radar is running in-cluster, where it has no{' '}
171
+ <span className="font-mono">helm registry login</span> credentials — only{' '}
172
+ <strong>public</strong> charts can be tracked. Private-registry support for in-cluster
173
+ Radar isn&apos;t available yet.
174
+ </span>
175
+ </div>
176
+ ) : (
177
+ <p className="text-xs text-theme-text-tertiary">
178
+ Credentials are reused from your <span className="font-mono">helm registry login</span>.
179
+ Radar stores no registry secrets.
180
+ </p>
181
+ )}
182
+ </div>
183
+ </DialogPortal>
184
+ )
185
+ }
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
2
3
  import { X, Play, Loader2, FileText, AlertTriangle } from 'lucide-react'
3
4
  import { clsx } from 'clsx'
5
+ import { classifyDiffLine } from '@skyhook-io/k8s-ui'
4
6
  import type { ValuesPreviewResponse } from '../../types'
5
7
 
6
8
  interface ValuesDiffPreviewProps {
@@ -8,6 +10,8 @@ interface ValuesDiffPreviewProps {
8
10
  onClose: () => void
9
11
  onApply: () => void
10
12
  isApplying: boolean
13
+ title?: string
14
+ applyLabel?: string
11
15
  }
12
16
 
13
17
  export function ValuesDiffPreview({
@@ -15,6 +19,8 @@ export function ValuesDiffPreview({
15
19
  onClose,
16
20
  onApply,
17
21
  isApplying,
22
+ title = 'Preview Changes',
23
+ applyLabel = 'Apply Changes',
18
24
  }: ValuesDiffPreviewProps) {
19
25
  const dialogRef = useRef<HTMLDivElement>(null)
20
26
 
@@ -40,7 +46,10 @@ export function ValuesDiffPreview({
40
46
  const hasChanges = previewData.manifestDiff.trim().length > 0 &&
41
47
  previewData.manifestDiff.split('\n').some(line => line.startsWith('+') || line.startsWith('-'))
42
48
 
43
- return (
49
+ // Portal to body so the modal escapes any CSS-transformed ancestor (the release
50
+ // drawer, the upgrade ConfirmDialog) and layers on top rather than being trapped
51
+ // in a lower stacking context.
52
+ return createPortal(
44
53
  <div className="fixed inset-0 z-50 flex items-center justify-center">
45
54
  {/* Backdrop */}
46
55
  <div
@@ -51,6 +60,8 @@ export function ValuesDiffPreview({
51
60
  {/* Dialog */}
52
61
  <div
53
62
  ref={dialogRef}
63
+ role="dialog"
64
+ aria-modal="true"
54
65
  tabIndex={-1}
55
66
  className="relative dialog max-w-4xl w-full mx-4 max-h-[85vh] flex flex-col outline-none"
56
67
  >
@@ -58,7 +69,7 @@ export function ValuesDiffPreview({
58
69
  <div className="flex items-center justify-between px-4 py-3 border-b border-theme-border shrink-0">
59
70
  <div className="flex items-center gap-2">
60
71
  <FileText className="w-5 h-5 text-blue-400" />
61
- <h3 className="text-lg font-semibold text-theme-text-primary">Preview Changes</h3>
72
+ <h3 className="text-lg font-semibold text-theme-text-primary">{title}</h3>
62
73
  </div>
63
74
  <button
64
75
  onClick={onClose}
@@ -120,12 +131,13 @@ export function ValuesDiffPreview({
120
131
  ) : (
121
132
  <Play className="w-4 h-4" />
122
133
  )}
123
- Apply Changes
134
+ {applyLabel}
124
135
  </button>
125
136
  </div>
126
137
  </div>
127
138
  </div>
128
- </div>
139
+ </div>,
140
+ document.body
129
141
  )
130
142
  }
131
143
 
@@ -158,9 +170,7 @@ interface DiffLineProps {
158
170
  }
159
171
 
160
172
  function DiffLine({ line, lineNumber }: DiffLineProps) {
161
- const isAddition = line.startsWith('+') && !line.startsWith('+++')
162
- const isDeletion = line.startsWith('-') && !line.startsWith('---')
163
- const isHeader = line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')
173
+ const { isAddition, isRemoval: isDeletion, isHeader } = classifyDiffLine(line)
164
174
 
165
175
  return (
166
176
  <div
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useEffect } from 'react'
2
2
  import { Copy, Check, Settings, Pencil, X, Eye, Play, Loader2 } from 'lucide-react'
3
3
  import { PaneLoader } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
@@ -9,6 +9,7 @@ import { YamlEditor } from '../ui/YamlEditor'
9
9
  import { useHelmPreviewValues, useHelmApplyValues } from '../../api/client'
10
10
  import { useCanHelmAct } from '../../api/client'
11
11
  import { ValuesDiffPreview } from './ValuesDiffPreview'
12
+ import { Tooltip } from '../ui/Tooltip'
12
13
 
13
14
  interface ValuesViewerProps {
14
15
  values?: HelmValues
@@ -20,9 +21,10 @@ interface ValuesViewerProps {
20
21
  // Required for editing
21
22
  namespace?: string
22
23
  name?: string
24
+ revision?: number
25
+ currentRevision?: number
23
26
  onApplySuccess?: () => void
24
27
  }
25
-
26
28
  export function ValuesViewer({
27
29
  values,
28
30
  isLoading,
@@ -32,6 +34,8 @@ export function ValuesViewer({
32
34
  copied,
33
35
  namespace,
34
36
  name,
37
+ revision,
38
+ currentRevision,
35
39
  onApplySuccess,
36
40
  }: ValuesViewerProps) {
37
41
  const [isEditing, setIsEditing] = useState(false)
@@ -43,8 +47,9 @@ export function ValuesViewer({
43
47
  const previewMutation = useHelmPreviewValues()
44
48
  const applyMutation = useHelmApplyValues()
45
49
  const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
50
+ const isHistoricalRevision = typeof revision === 'number' && typeof currentRevision === 'number' && revision !== currentRevision
46
51
 
47
- const canEdit = Boolean(namespace && name) && canHelmWrite
52
+ const canEdit = Boolean(namespace && name) && canHelmWrite && !isHistoricalRevision
48
53
 
49
54
  const displayValues = showAllValues && values?.computed ? values.computed : values?.userSupplied
50
55
  const isEmpty = !displayValues || Object.keys(displayValues).length === 0
@@ -71,6 +76,12 @@ export function ValuesViewer({
71
76
  setShowPreview(false)
72
77
  }, [])
73
78
 
79
+ useEffect(() => {
80
+ if (isHistoricalRevision && isEditing) {
81
+ handleCancelEdit()
82
+ }
83
+ }, [isHistoricalRevision, isEditing, handleCancelEdit])
84
+
74
85
  // Parse YAML and validate
75
86
  const parseYaml = useCallback((yamlStr: string): Record<string, unknown> | null => {
76
87
  try {
@@ -85,7 +96,7 @@ export function ValuesViewer({
85
96
 
86
97
  // Preview changes
87
98
  const handlePreview = useCallback(async () => {
88
- if (!namespace || !name) return
99
+ if (!namespace || !name || isHistoricalRevision) return
89
100
  const parsed = parseYaml(editedYaml)
90
101
  if (!parsed) return
91
102
 
@@ -100,11 +111,11 @@ export function ValuesViewer({
100
111
  } catch {
101
112
  // Error is handled by mutation
102
113
  }
103
- }, [namespace, name, editedYaml, parseYaml, previewMutation])
114
+ }, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, previewMutation])
104
115
 
105
116
  // Apply changes
106
117
  const handleApply = useCallback(async () => {
107
- if (!namespace || !name) return
118
+ if (!namespace || !name || isHistoricalRevision) return
108
119
  const parsed = parseYaml(editedYaml)
109
120
  if (!parsed) return
110
121
 
@@ -119,11 +130,11 @@ export function ValuesViewer({
119
130
  } catch {
120
131
  // Error is handled by mutation
121
132
  }
122
- }, [namespace, name, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
133
+ }, [namespace, name, isHistoricalRevision, editedYaml, parseYaml, applyMutation, handleCancelEdit, onApplySuccess])
123
134
 
124
135
  // Apply from preview modal
125
136
  const handleApplyFromPreview = useCallback(async () => {
126
- if (!previewData || !namespace || !name) return
137
+ if (!previewData || !namespace || !name || isHistoricalRevision) return
127
138
  try {
128
139
  await applyMutation.mutateAsync({
129
140
  namespace,
@@ -136,7 +147,7 @@ export function ValuesViewer({
136
147
  } catch {
137
148
  // Error is handled by mutation
138
149
  }
139
- }, [previewData, namespace, name, applyMutation, handleCancelEdit, onApplySuccess])
150
+ }, [previewData, namespace, name, isHistoricalRevision, applyMutation, handleCancelEdit, onApplySuccess])
140
151
 
141
152
  if (isLoading) {
142
153
  return <PaneLoader label="Loading values…" className="h-32" />
@@ -146,7 +157,12 @@ export function ValuesViewer({
146
157
  return (
147
158
  <div className="p-4">
148
159
  <div className="flex items-center justify-between mb-3">
149
- <span className="text-sm font-medium text-theme-text-secondary">Values</span>
160
+ <div className="flex items-center gap-2">
161
+ <span className="text-sm font-medium text-theme-text-secondary">Values</span>
162
+ {isHistoricalRevision && (
163
+ <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
164
+ )}
165
+ </div>
150
166
  <div className="flex items-center gap-2">
151
167
  <ToggleButton showAll={showAllValues} onToggle={onToggleAllValues} disabled={isEditing} />
152
168
  {canEdit && (
@@ -160,6 +176,11 @@ export function ValuesViewer({
160
176
  )}
161
177
  </div>
162
178
  </div>
179
+ {isHistoricalRevision && (
180
+ <div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
181
+ Viewing historical values. Switch back to the latest revision before editing or applying changes.
182
+ </div>
183
+ )}
163
184
  <div className="flex flex-col items-center justify-center h-32 text-theme-text-tertiary gap-2">
164
185
  <Settings className="w-8 h-8 text-theme-text-disabled" />
165
186
  <span>{showAllValues ? 'No computed values' : 'No user-supplied values'}</span>
@@ -178,6 +199,9 @@ export function ValuesViewer({
178
199
  <span className="text-sm font-medium text-theme-text-secondary">
179
200
  {isEditing ? 'Editing Values' : showAllValues ? 'All Values (Computed)' : 'User-Supplied Values'}
180
201
  </span>
202
+ {isHistoricalRevision && !isEditing && (
203
+ <span className="badge-sm bg-theme-hover/50 text-theme-text-secondary">revision {revision}</span>
204
+ )}
181
205
  {isEditing && (
182
206
  <span className="badge-sm bg-amber-500/20 text-amber-400 border-amber-500/30">
183
207
  unsaved
@@ -217,7 +241,7 @@ export function ValuesViewer({
217
241
  </button>
218
242
  <button
219
243
  onClick={handlePreview}
220
- disabled={!!yamlError || previewMutation.isPending}
244
+ disabled={!!yamlError || previewMutation.isPending || isHistoricalRevision}
221
245
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded border border-theme-border disabled:opacity-50 disabled:cursor-not-allowed"
222
246
  >
223
247
  {previewMutation.isPending ? (
@@ -227,11 +251,11 @@ export function ValuesViewer({
227
251
  )}
228
252
  Preview
229
253
  </button>
254
+ <Tooltip content={!canHelmWrite ? helmActReason : ''}>
230
255
  <button
231
256
  onClick={handleApply}
232
- disabled={!!yamlError || applyMutation.isPending || !canHelmWrite}
233
- className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed"
234
- title={!canHelmWrite ? helmActReason : undefined}
257
+ disabled={!!yamlError || applyMutation.isPending || !canHelmWrite || isHistoricalRevision}
258
+ className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed disabled:pointer-events-none"
235
259
  >
236
260
  {applyMutation.isPending ? (
237
261
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -240,11 +264,18 @@ export function ValuesViewer({
240
264
  )}
241
265
  Apply
242
266
  </button>
267
+ </Tooltip>
243
268
  </>
244
269
  )}
245
270
  </div>
246
271
  </div>
247
272
 
273
+ {isHistoricalRevision && (
274
+ <div className="mb-3 rounded border border-theme-border bg-theme-elevated/40 px-3 py-2 text-xs text-theme-text-secondary">
275
+ Viewing historical values. Switch back to the latest revision before editing or applying changes.
276
+ </div>
277
+ )}
278
+
248
279
  {/* Error message */}
249
280
  {yamlError && (
250
281
  <div className="mb-3 px-3 py-2 text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded">
@@ -265,6 +296,7 @@ export function ValuesViewer({
265
296
  <YamlEditor
266
297
  value={editedYaml}
267
298
  onChange={setEditedYaml}
299
+ showProblems={false}
268
300
  height="calc(100vh - 400px)"
269
301
  onValidate={(isValid, errors) => {
270
302
  setYamlError(isValid ? null : errors[0] || 'Invalid YAML')
@@ -320,43 +352,7 @@ function ToggleButton({ showAll, onToggle, disabled }: { showAll: boolean; onTog
320
352
  )
321
353
  }
322
354
 
323
- // Simple JSON to YAML converter for display
324
- function jsonToYaml(obj: Record<string, unknown>, indent = 0): string {
325
- const spaces = ' '.repeat(indent)
326
- let result = ''
327
-
328
- for (const [key, value] of Object.entries(obj)) {
329
- if (value === null || value === undefined) {
330
- result += `${spaces}${key}: null\n`
331
- } else if (typeof value === 'object' && !Array.isArray(value)) {
332
- result += `${spaces}${key}:\n`
333
- result += jsonToYaml(value as Record<string, unknown>, indent + 1)
334
- } else if (Array.isArray(value)) {
335
- result += `${spaces}${key}:\n`
336
- for (const item of value) {
337
- if (typeof item === 'object' && item !== null) {
338
- result += `${spaces}- \n`
339
- const itemYaml = jsonToYaml(item as Record<string, unknown>, indent + 2)
340
- result += itemYaml
341
- } else {
342
- result += `${spaces}- ${formatValue(item)}\n`
343
- }
344
- }
345
- } else {
346
- result += `${spaces}${key}: ${formatValue(value)}\n`
347
- }
348
- }
349
-
350
- return result
351
- }
352
-
353
- function formatValue(value: unknown): string {
354
- if (typeof value === 'string') {
355
- // Quote strings that contain special characters
356
- if (value.includes(':') || value.includes('#') || value.includes('\n') || value.startsWith(' ') || value.endsWith(' ')) {
357
- return `"${value.replace(/"/g, '\\"')}"`
358
- }
359
- return value
360
- }
361
- return String(value)
355
+ function jsonToYaml(obj: Record<string, unknown>): string {
356
+ if (!obj || Object.keys(obj).length === 0) return ''
357
+ return yaml.stringify(obj, { lineWidth: 0 })
362
358
  }
@@ -35,3 +35,7 @@ export function getChartDisplay(chart: string, version: string): string {
35
35
 
36
36
  // Re-export kindToPlural from centralized navigation utils
37
37
  export { kindToPlural } from '../../utils/navigation'
38
+
39
+ // Re-export from k8s-ui where the predicate lives next to the
40
+ // Helm status color palette and is unit-tested.
41
+ export { isHelmReleaseActionable } from '../../utils/badge-colors'
@@ -82,6 +82,9 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
82
82
  limit: 1000,
83
83
  })
84
84
 
85
+ // Intentionally re-sample 'now' only when events refresh (not every render),
86
+ // so the timeline window stays stable between data updates.
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
88
  const now = useMemo(() => Date.now(), [events])
86
89
  const spanMs = SPAN_MINUTES * 60 * 1000
87
90
  const startTime = now - spanMs
@@ -125,7 +128,7 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
125
128
  <div className="flex-1 min-h-0 overflow-hidden px-4 py-1.5">
126
129
  {isLoading ? (
127
130
  <div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
128
- Loading...
131
+ Loading
129
132
  </div>
130
133
  ) : error ? (
131
134
  <div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">