@skyhook-io/radar-app 1.16.2 → 1.16.3

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 (42) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +46 -5
  3. package/src/api/client.ts +53 -47
  4. package/src/api/cnpg.ts +95 -0
  5. package/src/api/timelineSource.ts +1 -1
  6. package/src/components/NoClusterAccessBanner.tsx +68 -0
  7. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  8. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  9. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  10. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  11. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  12. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  13. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  14. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  15. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  16. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  17. package/src/components/cnpg/CNPGView.tsx +156 -0
  18. package/src/components/cnpg/paths.ts +13 -0
  19. package/src/components/cnpg/routes.test.ts +60 -0
  20. package/src/components/cnpg/routes.ts +112 -0
  21. package/src/components/cnpg/shared.tsx +290 -0
  22. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  23. package/src/components/helm/ChartBrowser.tsx +6 -7
  24. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  25. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  26. package/src/components/helm/InstallWizard.tsx +5 -2
  27. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  28. package/src/components/helm/ValuesViewer.tsx +1 -1
  29. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  30. package/src/components/resources/ResourcesView.tsx +6 -38
  31. package/src/components/timeline/TimelineList.test.ts +47 -29
  32. package/src/components/timeline/TimelineList.tsx +57 -11
  33. package/src/components/timeline/TimelineView.tsx +9 -2
  34. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  35. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  36. package/src/components/whats-new/WhatsNew.tsx +146 -86
  37. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  38. package/src/components/whats-new/releaseNotes.ts +155 -24
  39. package/src/components/workload/WorkloadView.tsx +30 -4
  40. package/src/contexts/CapabilitiesContext.tsx +9 -1
  41. package/src/hooks/useResourceCounts.ts +49 -0
  42. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -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,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
@@ -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
  )
@@ -10,6 +10,8 @@ import { useAPIResources } from '../../api/apiResources'
10
10
  import { useConnection } from '../../context/ConnectionContext'
11
11
  import { initNavigationMap, getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
12
12
  import { usePinnedKinds } from '../../hooks/useFavorites'
13
+ import { useResourceCounts } from '../../hooks/useResourceCounts'
14
+ import { useCNPGSidebarWorkspace } from '../cnpg/useCNPGSidebarWorkspace'
13
15
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
14
16
  import {
15
17
  canBulkRestartKind,
@@ -26,13 +28,6 @@ import { apiVersionToGroup, kindToPluralWithGroup, type NavigateToResource } fro
26
28
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
27
29
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
28
30
 
29
- interface ResourceCountsResponse {
30
- counts: Record<string, number>
31
- forbidden?: string[]
32
- reasons?: Record<string, string>
33
- unavailable?: string[]
34
- }
35
-
36
31
  interface ResourcesViewProps {
37
32
  namespaces: string[]
38
33
  selectedResource?: SelectedResource | null
@@ -114,6 +109,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
114
109
  if (apiResources) initNavigationMap(apiResources)
115
110
  }, [apiResources])
116
111
 
112
+ const cnpgSidebarWorkspace = useCNPGSidebarWorkspace({ apiResources, namespaces })
113
+
117
114
  // Track the selected kind from the k8s-ui component
118
115
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
119
116
  const workloadWrites = namespaces.length === 0
@@ -126,37 +123,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
126
123
 
127
124
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
128
125
  const namespacesParam = namespaces.join(',')
129
- const { data: countsData, isError: countsIsError } = useQuery({
130
- queryKey: ['resource-counts', namespacesParam],
131
- queryFn: async () => {
132
- const params = new URLSearchParams()
133
- if (namespaces.length > 0) params.set('namespaces', namespacesParam)
134
- const startedAt = performance.now()
135
- debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
136
- try {
137
- return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
138
- } finally {
139
- debugNamespaceLog('resources:counts-fetch-end', {
140
- namespaces,
141
- params: params.toString(),
142
- durationMs: Math.round(performance.now() - startedAt),
143
- })
144
- }
145
- },
146
- staleTime: 10000,
147
- // SSE invalidation isn't running while connecting, and mid-sync counts
148
- // are what unlatch guarded kinds as their informers finish — poll fast
149
- // during the shell, settle to the safety net once connected.
150
- refetchInterval: connection.state === 'connecting' ? 3000 : 60000,
151
- // During the first seconds of the progressive shell the endpoint 503s
152
- // (cluster_connecting) until the mid-sync cache handle exists; keep the
153
- // query pending rather than parking it in error state, which would
154
- // unlatch the large-list guard at the connected flip.
155
- retry: (failureCount: number, error: Error) =>
156
- isStillLoadingError(error) ? true : failureCount < 3,
157
- retryDelay: (failureCount: number, error: Error) =>
158
- isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
159
- })
126
+ const { data: countsData, isError: countsIsError } = useResourceCounts(namespaces)
160
127
 
161
128
  // Determine if selected kind is a CRD (only CRDs should send ?group= to backend)
162
129
  const isSelectedCrd = useMemo(() => {
@@ -440,6 +407,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
440
407
  connectionState={connection.state === 'connecting' && connection.syncStatus ? 'syncing' : connection.state}
441
408
  largeListGuard={largeListGuard}
442
409
  onSelectedKindChange={setSelectedKind}
410
+ sidebarCategoryWorkspaces={cnpgSidebarWorkspace}
443
411
  topPodMetrics={topPodMetrics}
444
412
  topNodeMetrics={topNodeMetrics}
445
413
  certExpiry={certExpiry}