@skyhook-io/radar-app 1.16.2 → 1.17.0

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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -3,12 +3,12 @@ import { Search, RefreshCw, Package, Database, AlertCircle, ExternalLink, Chevro
3
3
  import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
5
5
  import { useHelmRepositories, useSearchCharts, useUpdateRepository, useUpdateRepositorySilent, useArtifactHubSearch, type ArtifactHubSortOption } from '../../api/client'
6
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
6
+ import { useCanHelmWrite, useIsAuthEnabled } from '../../contexts/CapabilitiesContext'
7
7
  import type { ChartInfo, HelmRepository, ArtifactHubChart, ChartSource } from '../../types'
8
- import { formatAge } from './helm-utils'
9
8
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
10
9
  import { Tooltip } from '../ui/Tooltip'
11
10
  import { useToast } from '../ui/Toast'
11
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
12
12
 
13
13
  interface ChartBrowserProps {
14
14
  onChartSelect: (repo: string, chart: string, version: string, source: ChartSource) => void
@@ -25,12 +25,11 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
25
25
  const [showVerifiedOnly, setShowVerifiedOnly] = useState(false)
26
26
  const [artifactHubSort, setArtifactHubSort] = useState<ArtifactHubSortOption>('relevance')
27
27
 
28
- // Repo refresh is gated only by `requireHelmWrite` on the backend
29
- // (handleUpdateRepository deliberately skips requireCloudRole — it
30
- // mutates pod-local chart cache, not cluster state). So the frontend gate
31
- // here must NOT include the Cloud role check, or Cloud viewers with
32
- // rbac.helm=true would be blocked from a refresh the backend allows.
33
- const canHelmWrite = useCanHelmWrite()
28
+ // Refreshing repositories only touches Radar's own chart cache. With auth on,
29
+ // any signed-in user may do it; without auth it follows rbac.helm.
30
+ const helmWrite = useCanHelmWrite()
31
+ const authEnabled = useIsAuthEnabled()
32
+ const canHelmWrite = helmWrite || authEnabled
34
33
  const helmWriteReason = canHelmWrite ? '' : 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.'
35
34
 
36
35
  // Local repo hooks
@@ -432,7 +431,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
432
431
  {repo.name}
433
432
  {repo.lastUpdated && (
434
433
  <span className="text-xs text-theme-text-tertiary ml-2">
435
- {formatAge(repo.lastUpdated)}
434
+ {formatRelativeAgeTime(repo.lastUpdated)}
436
435
  </span>
437
436
  )}
438
437
  </button>
@@ -515,7 +514,7 @@ interface ArtifactHubChartCardProps {
515
514
  function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
516
515
  // Format Unix timestamp to relative age
517
516
  const lastUpdated = chart.updatedAt
518
- ? formatAge(new Date(chart.updatedAt * 1000).toISOString())
517
+ ? formatRelativeAgeTime(new Date(chart.updatedAt * 1000).toISOString())
519
518
  : null
520
519
 
521
520
  return (
@@ -19,7 +19,6 @@ import {
19
19
  } from 'lucide-react'
20
20
  import { PaneLoader, DiffLine, hasDiffBodyChange } from '@skyhook-io/k8s-ui'
21
21
  import {
22
- useCloudRole,
23
22
  useHelmHooksDiff,
24
23
  useHelmManifestDiff,
25
24
  useHelmNotesDiff,
@@ -30,7 +29,6 @@ import {
30
29
  import type { HelmHook, HelmRevision, HooksDiff, ResourceDiff } from '../../types'
31
30
  import { getHelmStatusColor, getKindBadgeColor, SEVERITY_BADGE } from '../../utils/badge-colors'
32
31
  import { formatDate } from './helm-utils'
33
- import { RoleGatedPanel } from './RoleGatedPanel'
34
32
  import { Tooltip } from '../ui/Tooltip'
35
33
  import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
36
34
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
@@ -53,8 +51,6 @@ export function HelmCompareRoute() {
53
51
  const navigate = useNavigate()
54
52
  const location = useLocation()
55
53
  const [searchParams, setSearchParams] = useSearchParams()
56
- const { canAtLeast } = useCloudRole()
57
- const canViewSensitive = canAtLeast('member')
58
54
 
59
55
  const releaseRef = parseReleaseParam(searchParams.get('release'))
60
56
  const storageNamespace = searchParams.get('releaseStorage') || undefined
@@ -74,16 +70,15 @@ export function HelmCompareRoute() {
74
70
  const revision1 = defaultLeftRevision
75
71
  const revision2 = defaultRightRevision
76
72
  const pairReady = Boolean(helmNamespace && releaseName && revision1 > 0 && revision2 > 0 && revision1 !== revision2)
77
- const diffEnabled = canViewSensitive && pairReady
78
73
 
79
74
  const left = revisions.find((r) => r.revision === revision1)
80
75
  const right = revisions.find((r) => r.revision === revision2)
81
76
 
82
- const manifestDiff = useHelmManifestDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
83
- const valuesDiff = useHelmValuesDiff(helmNamespace, releaseName, revision1, revision2, false, diffEnabled)
84
- const notesDiff = useHelmNotesDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
85
- const hooksDiff = useHelmHooksDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
86
- const resourceDiff = useHelmResourceDiff(helmNamespace, releaseName, revision1, revision2, diffEnabled)
77
+ const manifestDiff = useHelmManifestDiff(helmNamespace, releaseName, revision1, revision2, pairReady)
78
+ const valuesDiff = useHelmValuesDiff(helmNamespace, releaseName, revision1, revision2, false, pairReady)
79
+ const notesDiff = useHelmNotesDiff(helmNamespace, releaseName, revision1, revision2, pairReady)
80
+ const hooksDiff = useHelmHooksDiff(helmNamespace, releaseName, revision1, revision2, pairReady)
81
+ const resourceDiff = useHelmResourceDiff(helmNamespace, releaseName, revision1, revision2, pairReady)
87
82
 
88
83
  const updateRevision = useCallback(
89
84
  (key: 'revision1' | 'revision2', value: number) => {
@@ -214,120 +209,118 @@ export function HelmCompareRoute() {
214
209
  </div>
215
210
  </header>
216
211
 
217
- <RoleGatedPanel min="member" feature="release revision comparison">
218
- <div className="min-h-0 flex-1 overflow-y-auto">
219
- <div className={clsx('grid w-full grid-cols-1 gap-4 px-4 py-4', pairReady && 'xl:grid-cols-[220px_minmax(0,1fr)]')}>
220
- {pairReady && (
221
- <nav className="hidden xl:block">
222
- <div className="sticky top-4 rounded-xl border border-theme-border bg-theme-surface p-2 shadow-theme-sm">
223
- <div className="px-2 pb-2 text-[11px] font-medium uppercase text-theme-text-tertiary">Compare</div>
224
- {[
225
- ['summary', 'Summary'],
226
- ['manifest', 'Manifest'],
227
- ['resources', 'Resources'],
228
- ['values', 'Values'],
229
- ['hooks', 'Hooks'],
230
- ['notes', 'Notes'],
231
- ].map(([id, label]) => (
232
- <a
233
- key={id}
234
- href={`#${id}`}
235
- onClick={(event) => scrollCompareSection(event, id)}
236
- className="block rounded-md px-2 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
237
- >
238
- {label}
239
- </a>
240
- ))}
241
- </div>
242
- </nav>
243
- )}
212
+ <div className="min-h-0 flex-1 overflow-y-auto">
213
+ <div className={clsx('grid w-full grid-cols-1 gap-4 px-4 py-4', pairReady && 'xl:grid-cols-[220px_minmax(0,1fr)]')}>
214
+ {pairReady && (
215
+ <nav className="hidden xl:block">
216
+ <div className="sticky top-4 rounded-xl border border-theme-border bg-theme-surface p-2 shadow-theme-sm">
217
+ <div className="px-2 pb-2 text-[11px] font-medium uppercase text-theme-text-tertiary">Compare</div>
218
+ {[
219
+ ['summary', 'Summary'],
220
+ ['manifest', 'Manifest'],
221
+ ['resources', 'Resources'],
222
+ ['values', 'Values'],
223
+ ['hooks', 'Hooks'],
224
+ ['notes', 'Notes'],
225
+ ].map(([id, label]) => (
226
+ <a
227
+ key={id}
228
+ href={`#${id}`}
229
+ onClick={(event) => scrollCompareSection(event, id)}
230
+ className="block rounded-md px-2 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary"
231
+ >
232
+ {label}
233
+ </a>
234
+ ))}
235
+ </div>
236
+ </nav>
237
+ )}
244
238
 
245
- <main className="min-w-0 space-y-4">
246
- {!pairReady ? (
247
- <div className="card-inner-lg flex items-start gap-3">
248
- <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
249
- <div>
250
- <div className="text-sm font-medium text-theme-text-primary">Pick two different revisions to compare</div>
251
- <div className="mt-1 text-sm text-theme-text-secondary">Use the revision selectors above to choose a source and target revision.</div>
252
- </div>
239
+ <main className="min-w-0 space-y-4">
240
+ {!pairReady ? (
241
+ <div className="card-inner-lg flex items-start gap-3">
242
+ <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" />
243
+ <div>
244
+ <div className="text-sm font-medium text-theme-text-primary">Pick two different revisions to compare</div>
245
+ <div className="mt-1 text-sm text-theme-text-secondary">Use the revision selectors above to choose a source and target revision.</div>
253
246
  </div>
254
- ) : (
255
- <>
256
- <section id="summary" className="scroll-mt-4 space-y-4">
257
- <CompareSummary
258
- left={left}
259
- right={right}
260
- revision1={revision1}
261
- revision2={revision2}
262
- manifestDiff={manifestDiff.data?.diff}
263
- manifestLoading={manifestDiff.isLoading}
264
- manifestError={manifestDiff.error}
265
- valuesDiff={valuesDiff.data?.diff}
266
- valuesLoading={valuesDiff.isLoading}
267
- valuesError={valuesDiff.error}
268
- notesDiff={notesDiff.data?.diff}
269
- notesLoading={notesDiff.isLoading}
270
- notesError={notesDiff.error}
271
- hooksDiff={hooksDiff.data}
272
- hooksLoading={hooksDiff.isLoading}
273
- hooksError={hooksDiff.error}
274
- resourceDiff={resourceDiff.data}
275
- resourceLoading={resourceDiff.isLoading}
276
- resourceError={resourceDiff.error}
277
- />
278
- </section>
279
-
280
- <DiffSection
281
- id="manifest"
282
- icon={Code}
283
- title="Rendered manifest diff"
284
- description="Rendered Kubernetes YAML is the ground truth for what Helm would apply between these revisions."
285
- diff={manifestDiff.data?.diff || ''}
286
- isLoading={manifestDiff.isLoading}
287
- error={manifestDiff.error}
288
- emptyLabel="No rendered manifest changes found."
289
- />
290
-
291
- <ResourceInventoryDiffSection
292
- diff={resourceDiff.data}
293
- isLoading={resourceDiff.isLoading}
294
- error={resourceDiff.error}
247
+ </div>
248
+ ) : (
249
+ <>
250
+ <section id="summary" className="scroll-mt-4 space-y-4">
251
+ <CompareSummary
295
252
  left={left}
296
253
  right={right}
297
254
  revision1={revision1}
298
255
  revision2={revision2}
256
+ manifestDiff={manifestDiff.data?.diff}
257
+ manifestLoading={manifestDiff.isLoading}
258
+ manifestError={manifestDiff.error}
259
+ valuesDiff={valuesDiff.data?.diff}
260
+ valuesLoading={valuesDiff.isLoading}
261
+ valuesError={valuesDiff.error}
262
+ notesDiff={notesDiff.data?.diff}
263
+ notesLoading={notesDiff.isLoading}
264
+ notesError={notesDiff.error}
265
+ hooksDiff={hooksDiff.data}
266
+ hooksLoading={hooksDiff.isLoading}
267
+ hooksError={hooksDiff.error}
268
+ resourceDiff={resourceDiff.data}
269
+ resourceLoading={resourceDiff.isLoading}
270
+ resourceError={resourceDiff.error}
299
271
  />
272
+ </section>
273
+
274
+ <DiffSection
275
+ id="manifest"
276
+ icon={Code}
277
+ title="Rendered manifest diff"
278
+ description="Rendered Kubernetes YAML is the ground truth for what Helm would apply between these revisions."
279
+ diff={manifestDiff.data?.diff || ''}
280
+ isLoading={manifestDiff.isLoading}
281
+ error={manifestDiff.error}
282
+ emptyLabel="No rendered manifest changes found."
283
+ />
284
+
285
+ <ResourceInventoryDiffSection
286
+ diff={resourceDiff.data}
287
+ isLoading={resourceDiff.isLoading}
288
+ error={resourceDiff.error}
289
+ left={left}
290
+ right={right}
291
+ revision1={revision1}
292
+ revision2={revision2}
293
+ />
294
+
295
+ <DiffSection
296
+ id="values"
297
+ icon={Settings}
298
+ title="User-supplied values diff"
299
+ description="Only values explicitly supplied to the release are compared here; computed chart defaults can still affect the rendered manifest."
300
+ diff={valuesDiff.data?.diff || ''}
301
+ isLoading={valuesDiff.isLoading}
302
+ error={valuesDiff.error}
303
+ emptyLabel="No user-supplied value changes found."
304
+ />
305
+
306
+ <HooksDiffSection diff={hooksDiff.data} isLoading={hooksDiff.isLoading} error={hooksDiff.error} />
307
+
308
+ <DiffSection
309
+ id="notes"
310
+ icon={FileText}
311
+ title="Release notes diff"
312
+ description="NOTES.txt output can reveal chart-level instructions that changed without changing live Kubernetes objects."
313
+ diff={notesDiff.data?.diff || ''}
314
+ isLoading={notesDiff.isLoading}
315
+ error={notesDiff.error}
316
+ emptyLabel="No release notes changes found."
317
+ />
300
318
 
301
- <DiffSection
302
- id="values"
303
- icon={Settings}
304
- title="User-supplied values diff"
305
- description="Only values explicitly supplied to the release are compared here; computed chart defaults can still affect the rendered manifest."
306
- diff={valuesDiff.data?.diff || ''}
307
- isLoading={valuesDiff.isLoading}
308
- error={valuesDiff.error}
309
- emptyLabel="No user-supplied value changes found."
310
- />
311
-
312
- <HooksDiffSection diff={hooksDiff.data} isLoading={hooksDiff.isLoading} error={hooksDiff.error} />
313
-
314
- <DiffSection
315
- id="notes"
316
- icon={FileText}
317
- title="Release notes diff"
318
- description="NOTES.txt output can reveal chart-level instructions that changed without changing live Kubernetes objects."
319
- diff={notesDiff.data?.diff || ''}
320
- isLoading={notesDiff.isLoading}
321
- error={notesDiff.error}
322
- emptyLabel="No release notes changes found."
323
- />
324
-
325
- </>
326
- )}
327
- </main>
328
- </div>
319
+ </>
320
+ )}
321
+ </main>
329
322
  </div>
330
- </RoleGatedPanel>
323
+ </div>
331
324
  </div>
332
325
  )
333
326
  }
@@ -18,8 +18,7 @@ import type { SelectedHelmRelease, HelmHook, ChartDependency, HelmOperation, Hel
18
18
  import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } from '../../utils/navigation'
19
19
  import { formatDate } from './helm-utils'
20
20
  import { getHelmStatusColor, getKindBadgeColor, getResourceStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
21
- import { useCanHelmAct, useCloudRole } from '../../api/client'
22
- import { RoleGatedPanel } from './RoleGatedPanel'
21
+ import { useCanHelmAct } from '../../api/client'
23
22
  import { RevisionHistory } from './RevisionHistory'
24
23
  import { ManifestViewer } from './ManifestViewer'
25
24
  import { ValuesViewer } from './ValuesViewer'
@@ -129,14 +128,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
129
128
  const resizeStartWidth = useRef(DEFAULT_WIDTH)
130
129
  const targetVersionRef = useRef('')
131
130
  const editedUpgradeYamlRef = useRef('')
132
- const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
133
- // Cloud viewers can't view release manifests / values / diffs
134
- // (backend gate at requireCloudRole('member')). Skip the queries
135
- // when the role would 403 — saves a round-trip and avoids a
136
- // transient error state under the role-gated panel.
137
- const { canAtLeast } = useCloudRole()
138
- const canViewSensitive = canAtLeast('member')
139
131
  const helmNamespace = release.storageNamespace || release.namespace
132
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct(helmNamespace)
140
133
 
141
134
  const { data: releaseDetail, isLoading, error: releaseError, refetch: refetchRelease } = useHelmRelease(
142
135
  helmNamespace,
@@ -149,7 +142,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
149
142
  helmNamespace,
150
143
  release.name,
151
144
  selectedRevision,
152
- canViewSensitive,
153
145
  )
154
146
 
155
147
  // Fetch values
@@ -157,7 +149,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
157
149
  helmNamespace,
158
150
  release.name,
159
151
  showAllValues,
160
- canViewSensitive,
152
+ true,
161
153
  selectedRevision,
162
154
  )
163
155
  const {
@@ -168,7 +160,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
168
160
  helmNamespace,
169
161
  release.name,
170
162
  false,
171
- canViewSensitive && showUpgradeConfirm && adjustValues,
163
+ showUpgradeConfirm && adjustValues,
172
164
  )
173
165
 
174
166
  // Lazy check for upgrade availability
@@ -714,7 +706,6 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
714
706
  releaseNamespace={releaseDetail.namespace}
715
707
  managedByFluxHelmRelease={releaseDetail.managedByFluxHelmRelease}
716
708
  hookDiagnostics={releaseDetail.hookDiagnostics}
717
- canCompare={canViewSensitive}
718
709
  onCompare={handleCompareRevisions}
719
710
  onNavigateToResource={onNavigateToResource}
720
711
  />
@@ -732,32 +723,28 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
732
723
  />
733
724
  )}
734
725
  {activeTab === 'manifest' && (
735
- <RoleGatedPanel min="member" feature="release manifests">
736
- <ManifestViewer
737
- manifest={manifest || ''}
738
- isLoading={manifestLoading}
739
- revision={selectedRevision}
740
- onCopy={(text) => copyToClipboard(text, 'manifest')}
741
- copied={copied === 'manifest'}
742
- />
743
- </RoleGatedPanel>
726
+ <ManifestViewer
727
+ manifest={manifest || ''}
728
+ isLoading={manifestLoading}
729
+ revision={selectedRevision}
730
+ onCopy={(text) => copyToClipboard(text, 'manifest')}
731
+ copied={copied === 'manifest'}
732
+ />
744
733
  )}
745
734
  {activeTab === 'values' && (
746
- <RoleGatedPanel min="member" feature="release values">
747
- <ValuesViewer
748
- values={values}
749
- isLoading={valuesLoading}
750
- showAllValues={showAllValues}
751
- onToggleAllValues={setShowAllValues}
752
- onCopy={(text) => copyToClipboard(text, 'values')}
753
- copied={copied === 'values'}
754
- namespace={helmNamespace}
755
- name={release.name}
756
- revision={selectedRevision}
757
- currentRevision={releaseDetail.revision}
758
- onApplySuccess={() => refetch()}
759
- />
760
- </RoleGatedPanel>
735
+ <ValuesViewer
736
+ values={values}
737
+ isLoading={valuesLoading}
738
+ showAllValues={showAllValues}
739
+ onToggleAllValues={setShowAllValues}
740
+ onCopy={(text) => copyToClipboard(text, 'values')}
741
+ copied={copied === 'values'}
742
+ namespace={helmNamespace}
743
+ name={release.name}
744
+ revision={selectedRevision}
745
+ currentRevision={releaseDetail.revision}
746
+ onApplySuccess={() => refetch()}
747
+ />
761
748
  )}
762
749
  {activeTab === 'resources' && (
763
750
  <OwnedResources
@@ -899,7 +886,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
899
886
  )}
900
887
  </div>
901
888
  )}
902
- {upgradeProgress.length === 0 && canHelmWrite && canViewSensitive && (
889
+ {upgradeProgress.length === 0 && canHelmWrite && (
903
890
  <div className="mt-3 border-t border-theme-border pt-3">
904
891
  <Tooltip content="Edit the user-supplied Helm values that Radar will pass to this upgrade.">
905
892
  <button
@@ -1067,7 +1054,6 @@ function HelmOperationBanner({
1067
1054
  releaseNamespace,
1068
1055
  managedByFluxHelmRelease,
1069
1056
  hookDiagnostics,
1070
- canCompare,
1071
1057
  onCompare,
1072
1058
  onNavigateToResource,
1073
1059
  }: {
@@ -1076,7 +1062,6 @@ function HelmOperationBanner({
1076
1062
  releaseNamespace: string
1077
1063
  managedByFluxHelmRelease?: string
1078
1064
  hookDiagnostics?: HookDiagnostic[]
1079
- canCompare?: boolean
1080
1065
  onCompare?: (rev1: number, rev2: number) => void
1081
1066
  onNavigateToResource?: NavigateToResource
1082
1067
  }) {
@@ -1147,7 +1132,6 @@ function HelmOperationBanner({
1147
1132
  <OperationInsightSignals
1148
1133
  insight={operationInsight}
1149
1134
  releaseNamespace={releaseNamespace}
1150
- canCompare={Boolean(canCompare)}
1151
1135
  onCompare={onCompare}
1152
1136
  onNavigateToResource={onNavigateToResource}
1153
1137
  />
@@ -1166,13 +1150,11 @@ function HelmOperationBanner({
1166
1150
  function OperationInsightSignals({
1167
1151
  insight,
1168
1152
  releaseNamespace,
1169
- canCompare,
1170
1153
  onCompare,
1171
1154
  onNavigateToResource,
1172
1155
  }: {
1173
1156
  insight?: HelmOperationInsight
1174
1157
  releaseNamespace: string
1175
- canCompare: boolean
1176
1158
  onCompare?: (rev1: number, rev2: number) => void
1177
1159
  onNavigateToResource?: NavigateToResource
1178
1160
  }) {
@@ -1181,7 +1163,7 @@ function OperationInsightSignals({
1181
1163
  const relatedCount = primaryResource
1182
1164
  ? Math.max(0, (insight?.signalCount ?? 0) - 1)
1183
1165
  : insight?.relatedResources?.length ?? 0
1184
- const showCompare = Boolean(compare && canCompare && onCompare)
1166
+ const showCompare = Boolean(compare && onCompare)
1185
1167
  if (!primaryResource && !showCompare) {
1186
1168
  return null
1187
1169
  }
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
2
  import { X, Package, ChevronRight, ChevronLeft, Play, Loader2, AlertTriangle, CheckCircle, User, BookOpen, Link as LinkIcon, Star, BadgeCheck, Shield, Globe, Building2, Plus, Minus, Terminal } from 'lucide-react'
3
- import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
3
+ import { PaneLoader, Input, useDebouncedValue } from '@skyhook-io/k8s-ui'
4
4
  import { clsx } from 'clsx'
5
5
  import yaml from 'yaml'
6
6
  import { createPatch } from 'diff'
@@ -66,7 +66,6 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
66
66
  const progressEndRef = useRef<HTMLDivElement>(null)
67
67
 
68
68
  const queryClient = useQueryClient()
69
- const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
70
69
 
71
70
  // Choose the right data based on source
72
71
  const isLocal = source === 'local'
@@ -209,6 +208,10 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
209
208
  )
210
209
  const releaseNameError = releaseNameValidation.valid ? null : releaseNameValidation.error
211
210
  const namespaceError = namespaceValidation.valid ? null : namespaceValidation.error
211
+ // Checked in the namespace being installed into, once it's a valid name.
212
+ // Debounced: each new namespace is a capabilities round-trip of several SARs.
213
+ const helmActNamespace = useDebouncedValue(namespaceValidation.valid ? namespace.trim() : undefined, 300)
214
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct(helmActNamespace)
212
215
  const canProceedFromInfo = releaseNameValidation.valid && namespaceValidation.valid
213
216
  const canInstall = canProceedFromInfo && !yamlError
214
217
 
@@ -2,9 +2,10 @@ import { useState } from 'react'
2
2
  import { History, Eye, GitCompare, Check, RotateCcw } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { HelmOperation, HelmRevision } from '../../types'
5
- import { getStatusColor, formatDate, formatAge } from './helm-utils'
5
+ import { getStatusColor, formatDate } from './helm-utils'
6
6
  import { SEVERITY_BADGE } from '../../utils/badge-colors'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
8
9
 
9
10
  interface RevisionHistoryProps {
10
11
  history: HelmRevision[]
@@ -117,7 +118,7 @@ export function RevisionHistory({ history, currentRevision, operations = [], onV
117
118
 
118
119
  <div className="flex items-center gap-4 mt-1 text-xs text-theme-text-tertiary">
119
120
  <Tooltip content={revision.updated}><span>{formatDate(revision.updated)}</span></Tooltip>
120
- <span>{formatAge(revision.updated)} ago</span>
121
+ <span>{formatRelativeAgeTime(revision.updated)}</span>
121
122
  </div>
122
123
 
123
124
  {revision.description && (
@@ -2,7 +2,7 @@ import { useState, useId } from 'react'
2
2
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
3
  import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
- import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo, useCapabilities } from '../../api/client'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo, useCapabilities, useCloudRole } from '../../api/client'
6
6
  import { OperatorManagedNotice } from '../settings/OperatorManagedNotice'
7
7
  import { Input } from '@skyhook-io/k8s-ui'
8
8
  import type { UpgradeInfo } from '../../types'
@@ -58,7 +58,10 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
58
58
  const removeSource = useRemoveOCISource()
59
59
  const { data: capabilities } = useCapabilities()
60
60
  const operatorManaged = capabilities?.configManagement === 'operator'
61
- const canEdit = capabilities != null && !operatorManaged
61
+ // Chart sources are shared configuration that every user's upgrades resolve
62
+ // against, so in Radar Cloud only owners change them (the server enforces it).
63
+ const ownerOnly = !useCloudRole().canAtLeast('owner')
64
+ const canEdit = capabilities != null && !operatorManaged && !ownerOnly
62
65
 
63
66
  // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
64
67
  // points at an empty /tmp), so private registries can't authenticate — only
@@ -102,6 +105,11 @@ export function TrackChartSourceDialog({ open, onClose, chartName, sourceIssue,
102
105
 
103
106
  <div className="p-4 space-y-4">
104
107
  {operatorManaged && <OperatorManagedNotice helmValue="helm.ociSources" />}
108
+ {!operatorManaged && ownerOnly && (
109
+ <p className="text-sm text-theme-text-secondary">
110
+ Only organization owners can add or remove chart sources.
111
+ </p>
112
+ )}
105
113
  {sourceIssueCopy && (
106
114
  <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-sm">
107
115
  <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-theme-text-secondary" />
@@ -46,7 +46,7 @@ export function ValuesViewer({
46
46
 
47
47
  const previewMutation = useHelmPreviewValues()
48
48
  const applyMutation = useHelmApplyValues()
49
- const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
49
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct(namespace)
50
50
  const isHistoricalRevision = typeof revision === 'number' && typeof currentRevision === 'number' && revision !== currentRevision
51
51
 
52
52
  const canEdit = Boolean(namespace && name) && canHelmWrite && !isHistoricalRevision
@@ -177,8 +177,12 @@ export function ClusterHealthCard({
177
177
 
178
178
  // Nodes ring segments
179
179
  const cordonedCount = counts.nodes.cordoned ?? 0
180
+ const removingCount = counts.nodes.removing ?? 0
181
+ const removingUnhealthyCount = counts.nodes.removingUnhealthy ?? 0
180
182
  const nodesRingSegments = [
181
183
  { value: counts.nodes.ready, color: '#22c55e' },
184
+ { value: removingCount - removingUnhealthyCount, color: 'var(--text-secondary)' },
185
+ { value: removingUnhealthyCount, color: 'var(--color-error)' },
182
186
  { value: cordonedCount, color: '#eab308' }, // amber for cordoned
183
187
  { value: counts.nodes.notReady, color: '#ef4444' },
184
188
  ]
@@ -394,6 +398,9 @@ export function ClusterHealthCard({
394
398
  {cordonedCount > 0 && (
395
399
  <span className="text-yellow-500">{cordonedCount} cordoned</span>
396
400
  )}
401
+ {removingCount > 0 && (
402
+ <span className="text-theme-text-secondary">{removingCount} removing{removingUnhealthyCount > 0 ? ` (${removingUnhealthyCount} unhealthy)` : ''}</span>
403
+ )}
397
404
  {counts.nodes.notReady > 0 && (
398
405
  <span className="text-red-500">{counts.nodes.notReady} not ready</span>
399
406
  )}
@@ -58,7 +58,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
58
58
  { arg: 'name', required: true, desc: 'resource name' },
59
59
  { arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
60
60
  { arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
61
- { arg: 'include', desc: 'events, metrics, changes, revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
61
+ { arg: 'include', desc: 'events, metrics, changes, issues, drain-plan (Node pod/PDB estimate), revisions (rollback targets for Deployment/StatefulSet/DaemonSet/Rollout)' },
62
62
  { arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
63
63
  ],
64
64
  },
@@ -1,6 +1,7 @@
1
1
  import { ResourceDetailDrawer as BaseResourceDetailDrawer } from '@skyhook-io/k8s-ui'
2
2
  import type { SelectedResource } from '../../types'
3
3
  import { WorkloadView } from '../workload/WorkloadView'
4
+ import { CNPGDrawerTrailBack } from '../cnpg/CNPGDrawerTrail'
4
5
 
5
6
  interface ResourceDetailDrawerProps {
6
7
  resource: SelectedResource
@@ -31,6 +32,9 @@ export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
31
32
  return (
32
33
  <BaseResourceDetailDrawer {...props}>
33
34
  {({ resource, expanded, active, initialTab, onClose, onExpand, onExpandIntent, onCancelExpandIntent, onBack, onNavigateToResource, onCollapseToDrawer }) => (
35
+ <div className="flex h-full min-h-0 flex-col">
36
+ {!expanded && <CNPGDrawerTrailBack resource={resource} />}
37
+ <div className="min-h-0 flex-1">
34
38
  <WorkloadView
35
39
  kind={resource.kind}
36
40
  namespace={resource.namespace}
@@ -47,6 +51,8 @@ export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
47
51
  onNavigateToResource={onNavigateToResource}
48
52
  onCollapseToDrawer={onCollapseToDrawer}
49
53
  />
54
+ </div>
55
+ </div>
50
56
  )}
51
57
  </BaseResourceDetailDrawer>
52
58
  )