@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
@@ -14,25 +14,35 @@ interface ResourceDetailDrawerProps {
14
14
  expanded?: boolean
15
15
  /** Called when user clicks collapse in expanded mode */
16
16
  onCollapse?: () => void
17
- /** Called when user clicks expand button */
18
- onExpand?: (resource: SelectedResource) => void
17
+ /** Called when user clicks expand button (opts.yaml = expanding from YAML view) */
18
+ onExpand?: (resource: SelectedResource, opts?: { yaml?: boolean }) => void
19
+ /** Hide the collapse-to-drawer control (mobile: no drawer to collapse to). Default true. */
20
+ canCollapseToDrawer?: boolean
19
21
  /** Navigate to another resource within expanded WorkloadView */
20
22
  onNavigateToResource?: (resource: SelectedResource) => void
23
+ /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
24
+ * pass 0 in chromeless embeds where there's no Radar header above it. */
25
+ headerHeight?: number
26
+ /** Right inset in px so the drawer sits beside a docked side panel (AI), not under it. */
27
+ rightInset?: number
21
28
  }
22
29
 
23
30
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
24
31
  return (
25
32
  <BaseResourceDetailDrawer {...props}>
26
- {({ resource, expanded, initialTab, onClose, onExpand, onBack, onNavigateToResource, onCollapseToDrawer }) => (
33
+ {({ resource, expanded, active, initialTab, onClose, onExpand, onExpandIntent, onCancelExpandIntent, onBack, onNavigateToResource, onCollapseToDrawer }) => (
27
34
  <WorkloadView
28
35
  kind={resource.kind}
29
36
  namespace={resource.namespace}
30
37
  name={resource.name}
31
38
  group={resource.group}
32
39
  expanded={expanded}
40
+ active={active}
33
41
  initialTab={initialTab}
34
42
  onClose={onClose}
35
43
  onExpand={onExpand}
44
+ onExpandIntent={onExpandIntent}
45
+ onCancelExpandIntent={onCancelExpandIntent}
36
46
  onBack={onBack ?? (() => {})}
37
47
  onNavigateToResource={onNavigateToResource}
38
48
  onCollapseToDrawer={onCollapseToDrawer}
@@ -1,25 +1,33 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, fetchJSON, isForbiddenError, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
5
+ import { isBadgeWorthy } from '../../utils/auditBadges'
6
+ import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
5
7
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
8
  import { useAPIResources } from '../../api/apiResources'
9
+ import { useConnection } from '../../context/ConnectionContext'
7
10
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
8
11
  import { usePinnedKinds } from '../../hooks/useFavorites'
9
12
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
10
13
  import {
14
+ canBulkRestartKind,
15
+ canBulkScaleKind,
11
16
  ResourcesView as BaseResourcesView,
12
17
  CORE_RESOURCES,
18
+ intersectWorkloadWrites,
13
19
  } from '@skyhook-io/k8s-ui'
14
- import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
20
+ import type { Capabilities, ResourceQueryResult, WorkloadWritePermissions } from '@skyhook-io/k8s-ui'
15
21
  import type { SelectedResource } from '../../types'
16
- import type { NavigateToResource } from '../../utils/navigation'
22
+ import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
17
23
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
18
24
  import { getSkeletonYaml } from '../../utils/skeleton-yaml'
19
25
 
20
26
  interface ResourceCountsResponse {
21
27
  counts: Record<string, number>
22
28
  forbidden?: string[]
29
+ reasons?: Record<string, string>
30
+ unavailable?: string[]
23
31
  }
24
32
 
25
33
  interface ResourcesViewProps {
@@ -28,11 +36,65 @@ interface ResourcesViewProps {
28
36
  onResourceClick?: (resource: SelectedResource | null) => void
29
37
  onResourceClickYaml?: NavigateToResource
30
38
  onKindChange?: () => void
39
+ onClearNamespaces?: () => void
31
40
  }
32
41
 
33
- export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange }: ResourcesViewProps) {
42
+ type SelectedKindInfo = { name: string; kind: string; group: string } | null
43
+
44
+ const EMPTY_RESOURCE_COUNTS: Record<string, number> = {}
45
+ const LARGE_RESOURCE_LIST_LIMIT = 25000
46
+ const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
47
+ 'Pod',
48
+ 'Event',
49
+ 'apps/ReplicaSet',
50
+ 'discovery.k8s.io/EndpointSlice',
51
+ ])
52
+
53
+ const deniedWorkloadWrites: WorkloadWritePermissions = {
54
+ deployments: false,
55
+ daemonSets: false,
56
+ statefulSets: false,
57
+ rollouts: false,
58
+ }
59
+
60
+ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
61
+ return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
62
+ }
63
+
64
+ function hasResourceCount(counts: Record<string, number> | undefined, key: string): boolean {
65
+ return Object.prototype.hasOwnProperty.call(counts ?? {}, key)
66
+ }
67
+
68
+ export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
34
69
  const location = useLocation()
35
70
  const navigate = useNavigate()
71
+ const { connection } = useConnection()
72
+
73
+ const { data: capabilities } = useCapabilities()
74
+ const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
75
+ const { data: namespaceCapabilities } = useNamespaceCapabilities(namespaceForCapabilities, capabilities)
76
+ const namespaceCapabilityNames = useMemo(() => namespaces.length > 1 ? [...namespaces].sort() : [], [namespaces])
77
+ const { data: namespaceCapabilitiesList } = useQuery<Array<Pick<Capabilities, 'workloadWrites'>>>({
78
+ queryKey: ['capabilities', 'namespaces', namespaceCapabilityNames],
79
+ queryFn: async () => {
80
+ const results = await Promise.allSettled(
81
+ namespaceCapabilityNames.map(async ns => ({
82
+ namespace: ns,
83
+ capabilities: await fetchJSON<Capabilities>(`/capabilities?namespace=${encodeURIComponent(ns)}`),
84
+ }))
85
+ )
86
+ return results.map((result, index) => {
87
+ if (result.status === 'fulfilled') {
88
+ return { workloadWrites: result.value.capabilities.workloadWrites }
89
+ }
90
+ console.warn(`Failed to fetch namespace capabilities for ${namespaceCapabilityNames[index]}, withholding workload writes:`, result.reason)
91
+ return { workloadWrites: deniedWorkloadWrites }
92
+ })
93
+ },
94
+ enabled: namespaceCapabilityNames.length > 1 && capabilities != null,
95
+ staleTime: 60000,
96
+ })
97
+ const multiNamespaceWorkloadWrites = useMemo(() => intersectWorkloadWrites(namespaceCapabilitiesList), [namespaceCapabilitiesList])
36
98
 
37
99
  // API resources discovery
38
100
  const { data: apiResources } = useAPIResources()
@@ -43,16 +105,33 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
43
105
  }, [apiResources])
44
106
 
45
107
  // Track the selected kind from the k8s-ui component
46
- const [selectedKind, setSelectedKind] = useState<{ name: string; kind: string; group: string } | null>(null)
108
+ const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
109
+ const workloadWrites = namespaces.length === 0
110
+ ? capabilities?.workloadWrites
111
+ : namespaces.length === 1
112
+ ? namespaceCapabilities?.workloadWrites
113
+ : multiNamespaceWorkloadWrites
114
+ const canBulkRestartSelectedKind = useMemo(() => canBulkRestartKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
115
+ const canBulkScaleSelectedKind = useMemo(() => canBulkScaleKind(selectedKind, workloadWrites), [selectedKind, workloadWrites])
47
116
 
48
117
  // Lightweight resource counts for sidebar badges (~2KB instead of ~608MB)
49
118
  const namespacesParam = namespaces.join(',')
50
- const { data: countsData } = useQuery({
119
+ const { data: countsData, isError: countsIsError } = useQuery({
51
120
  queryKey: ['resource-counts', namespacesParam],
52
121
  queryFn: async () => {
53
122
  const params = new URLSearchParams()
54
123
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
55
- return fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
124
+ const startedAt = performance.now()
125
+ debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
126
+ try {
127
+ return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
128
+ } finally {
129
+ debugNamespaceLog('resources:counts-fetch-end', {
130
+ namespaces,
131
+ params: params.toString(),
132
+ durationMs: Math.round(performance.now() - startedAt),
133
+ })
134
+ }
56
135
  },
57
136
  staleTime: 10000,
58
137
  refetchInterval: 60000, // Safety net — SSE k8s_event drives near-real-time invalidation
@@ -67,6 +146,69 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
67
146
  return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
68
147
  }, [selectedKind, apiResources])
69
148
 
149
+ // The canonical Kind for the selected resource. selectedKind.kind is the plural
150
+ // URL segment for CRDs/grouped kinds (e.g. "ingressroutes", "ingresses") — only
151
+ // core no-group kinds resolve to the real Kind there — so resolve it via
152
+ // discovery to match audit findings, which key by the real Kind ("IngressRoute").
153
+ const selectedKindCanonical = useMemo(() => {
154
+ if (!selectedKind) return undefined
155
+ const match = apiResources?.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
156
+ ?? CORE_RESOURCES.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
157
+ return match?.kind ?? selectedKind.kind
158
+ }, [selectedKind, apiResources])
159
+
160
+ // Cluster Audit findings for the selected kind, keyed by "namespace/name" for
161
+ // the resource list. The list shows ONE kind at a time, so ns/name is enough;
162
+ // we still match the finding's group (built-ins → real group, CRDs → "") so a
163
+ // kind shared across groups doesn't bleed findings across the two lists. Only
164
+ // "badge-worthy" findings count (reference-integrity / lifecycle) — posture
165
+ // and best-practice nags fire near-universally and would just be noise.
166
+ const audit = useAudit(namespaces)
167
+ const auditBadges = useMemo(() => {
168
+ if (!selectedKind || !audit.data?.findings) return undefined
169
+ const wantGroup = isSelectedCrd ? '' : selectedKind.group
170
+ const map: Record<string, { danger: number; warning: number; messages: AuditBadgeMessage[] }> = {}
171
+ for (const f of audit.data.findings) {
172
+ if (f.kind !== selectedKindCanonical || (f.group ?? '') !== wantGroup) continue
173
+ if (!isBadgeWorthy(f, audit.data.checks)) continue
174
+ const k = `${f.namespace || ''}/${f.name}`
175
+ const cur = map[k] ?? { danger: 0, warning: 0, messages: [] }
176
+ if (f.severity === 'danger') cur.danger++
177
+ else if (f.severity === 'warning') cur.warning++
178
+ cur.messages.push({ severity: f.severity, message: f.message })
179
+ map[k] = cur
180
+ }
181
+ for (const cur of Object.values(map)) {
182
+ cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
183
+ }
184
+ return map
185
+ }, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical, isSelectedCrd])
186
+
187
+ const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
188
+ const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
189
+ const selectedCountKnown = selectedCountKey ? hasResourceCount(countsData?.counts, selectedCountKey) : false
190
+ const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
191
+ const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
192
+ const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
193
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT))
194
+ const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
195
+ const podCount = countsData?.counts.Pod
196
+ const podCountKnown = hasResourceCount(countsData?.counts, 'Pod')
197
+ const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
198
+ const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
199
+ const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
200
+ const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
201
+ const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
202
+ const largeListGuard = selectedKind && largeListBlocked
203
+ ? {
204
+ kind: selectedKind.name,
205
+ count: selectedCountUnavailable ? undefined : selectedCount,
206
+ reason: selectedCountUnavailable ? 'count-unavailable' as const : 'too-many' as const,
207
+ limit: LARGE_RESOURCE_LIST_LIMIT,
208
+ namespaces,
209
+ }
210
+ : null
211
+
70
212
  // Fetch full data only for the selected kind
71
213
  const selectedKindQuery = useQuery({
72
214
  queryKey: ['resources', selectedKind?.name, isSelectedCrd ? selectedKind?.group : '', namespaces],
@@ -75,17 +217,32 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
75
217
  const params = new URLSearchParams()
76
218
  if (namespaces.length > 0) params.set('namespaces', namespacesParam)
77
219
  if (isSelectedCrd && selectedKind.group) params.set('group', selectedKind.group)
220
+ const startedAt = performance.now()
221
+ debugNamespaceLog('resources:selected-kind-fetch-start', {
222
+ kind: selectedKind.name,
223
+ group: isSelectedCrd ? selectedKind.group : '',
224
+ namespaces,
225
+ params: params.toString(),
226
+ })
78
227
  const res = await fetch(apiUrl(`/resources/${selectedKind.name}?${params}`), {
79
228
  credentials: getCredentialsMode(),
80
229
  headers: getAuthHeaders(),
81
230
  })
231
+ debugNamespaceLog('resources:selected-kind-fetch-response', {
232
+ kind: selectedKind.name,
233
+ group: isSelectedCrd ? selectedKind.group : '',
234
+ namespaces,
235
+ params: params.toString(),
236
+ status: res.status,
237
+ durationMs: Math.round(performance.now() - startedAt),
238
+ })
82
239
  if (!res.ok) {
83
240
  const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
84
241
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
85
242
  }
86
243
  return res.json()
87
244
  },
88
- enabled: !!selectedKind,
245
+ enabled: !!selectedKind && !selectedKindQueryBlocked,
89
246
  staleTime: 30000,
90
247
  refetchInterval: 120000, // Safety net — SSE k8s_event drives near-real-time invalidation
91
248
  retry: (failureCount: number, error: Error) => {
@@ -98,17 +255,19 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
98
255
  const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
99
256
  if (!selectedKind) return undefined
100
257
  return {
101
- data: selectedKindQuery.data as any[] | undefined,
102
- isLoading: selectedKindQuery.isLoading,
103
- error: selectedKindQuery.error,
258
+ resourceName: selectedKind.name,
259
+ group: selectedKind.group,
260
+ data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
261
+ isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
262
+ error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
104
263
  refetch: selectedKindQuery.refetch,
105
264
  dataUpdatedAt: selectedKindQuery.dataUpdatedAt,
106
265
  }
107
- }, [selectedKind, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
266
+ }, [selectedKind, selectedKindQueryBlocked, waitingForGuardCount, selectedKindQuery.data, selectedKindQuery.isLoading, selectedKindQuery.error, selectedKindQuery.refetch, selectedKindQuery.dataUpdatedAt])
108
267
 
109
268
  // Metrics
110
- const { data: topPodMetrics } = useTopPodMetrics()
111
- const { data: topNodeMetrics } = useTopNodeMetrics()
269
+ const { data: topPodMetrics } = useTopPodMetrics({ enabled: topPodMetricsEnabled, namespaces })
270
+ const { data: topNodeMetrics } = useTopNodeMetrics({ enabled: topNodeMetricsEnabled })
112
271
 
113
272
  // Certificate expiry
114
273
  const { data: certExpiry, isError: certExpiryError } = useSecretCertExpiry()
@@ -120,6 +279,11 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
120
279
  const openLogs = useOpenLogs()
121
280
  const openWorkloadLogs = useOpenWorkloadLogs()
122
281
 
282
+ // Bulk delete
283
+ const bulkDeleteMutation = useBulkDeleteResources()
284
+ const bulkRestartMutation = useBulkRestartWorkloads()
285
+ const bulkScaleMutation = useBulkScaleWorkloads()
286
+
123
287
  // Navigation adapter. k8s-ui constructs paths from `basePath` (which
124
288
  // includes the router basename so they line up with window.location.pathname
125
289
  // for path-equality checks) and from `window.location.pathname` directly.
@@ -160,23 +324,29 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
160
324
  return (
161
325
  <>
162
326
  <BaseResourcesView
163
- key={location.pathname}
327
+ key={connection.context || 'default'}
164
328
  namespaces={namespaces}
165
329
  selectedResource={selectedResource}
166
330
  onResourceClick={onResourceClick}
167
331
  onResourceClickYaml={onResourceClickYaml}
168
332
  onKindChange={onKindChange}
333
+ onClearNamespaces={onClearNamespaces}
169
334
  // Injected data
170
335
  apiResources={apiResources}
171
336
  // Lightweight counts for sidebar (replaces 233 parallel queries)
172
- resourceCounts={countsData?.counts}
337
+ resourceCounts={countsData?.counts ?? EMPTY_RESOURCE_COUNTS}
173
338
  resourceForbidden={countsData?.forbidden}
339
+ resourceReasons={countsData?.reasons}
340
+ resourceUnavailable={countsData?.unavailable}
174
341
  selectedKindQuery={selectedKindQueryResult}
342
+ connectionState={connection.state}
343
+ largeListGuard={largeListGuard}
175
344
  onSelectedKindChange={setSelectedKind}
176
345
  topPodMetrics={topPodMetrics}
177
346
  topNodeMetrics={topNodeMetrics}
178
347
  certExpiry={certExpiry}
179
348
  certExpiryError={certExpiryError}
349
+ auditBadges={auditBadges}
180
350
  // Pinned kinds
181
351
  pinned={pinned}
182
352
  togglePin={togglePin}
@@ -197,6 +367,13 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
197
367
  onOpenWorkloadLogs={openWorkloadLogs}
198
368
  // Create resource
199
369
  onCreateResource={handleCreateResource}
370
+ // Bulk operations
371
+ onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
372
+ isBulkDeleting={bulkDeleteMutation.isPending}
373
+ onBulkRestart={canBulkRestartSelectedKind ? (items, options) => bulkRestartMutation.mutate({ items }, { onSuccess: options?.onSuccess }) : undefined}
374
+ isBulkRestarting={canBulkRestartSelectedKind && bulkRestartMutation.isPending}
375
+ onBulkScale={canBulkScaleSelectedKind ? (items, replicas, options) => bulkScaleMutation.mutate({ items, replicas }, { onSuccess: options?.onSuccess }) : undefined}
376
+ isBulkScaling={canBulkScaleSelectedKind && bulkScaleMutation.isPending}
200
377
  />
201
378
  <CreateResourceDialog
202
379
  open={createDialogOpen}
@@ -204,7 +381,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
204
381
  initialYaml={createDialogYaml}
205
382
  title={createDialogTitle}
206
383
  onCreated={(result) => {
207
- onResourceClick?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
384
+ onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
208
385
  }}
209
386
  />
210
387
  </>
@@ -0,0 +1 @@
1
+ export * from '@skyhook-io/k8s-ui/components/resources/renderers/CronWorkflowRenderer'
@@ -1 +1,20 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/HPARenderer'
1
+ import { HPARenderer as BaseHPARenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/HPARenderer'
2
+ import { HPACharts } from '../../resource/HPACharts'
3
+ import type { HPADiagnosis } from '@skyhook-io/k8s-ui'
4
+
5
+ interface HPARendererProps {
6
+ data: any
7
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
8
+ hpaDiagnosis?: HPADiagnosis
9
+ }
10
+
11
+ export function HPARenderer({ data, onNavigate, hpaDiagnosis }: HPARendererProps) {
12
+ return (
13
+ <BaseHPARenderer
14
+ data={data}
15
+ onNavigate={onNavigate}
16
+ hpaDiagnosis={hpaDiagnosis}
17
+ extraSections={<HPACharts data={data} />}
18
+ />
19
+ )
20
+ }
@@ -0,0 +1,31 @@
1
+ import { NamespaceRenderer as BaseNamespaceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NamespaceRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useRBACNamespace } from '../../../api/rbac'
4
+ import { useNamespaceQuotas } from '../../../api/quotas'
5
+ import { isForbiddenError } from '../../../api/client'
6
+
7
+ interface NamespaceRendererProps {
8
+ data: any
9
+ onNavigate?: (ref: ResourceRef) => void
10
+ }
11
+
12
+ export function NamespaceRenderer({ data, onNavigate }: NamespaceRendererProps) {
13
+ const name = data?.metadata?.name ?? ''
14
+ const { data: rbacData, isLoading, error } = useRBACNamespace(name, !!name)
15
+ const { data: quotaData, error: quotaError } = useNamespaceQuotas(name, !!name)
16
+ // 403 → the user can't see quotas; hide the section (same posture as the
17
+ // RBAC sections). Surface other errors (500/503) so a quota-constrained
18
+ // namespace doesn't silently render as quota-free.
19
+ const quotaErr = quotaError && !isForbiddenError(quotaError) ? (quotaError as Error) : null
20
+ return (
21
+ <BaseNamespaceRenderer
22
+ data={data}
23
+ rbacData={rbacData ?? null}
24
+ rbacLoading={isLoading}
25
+ rbacError={error as Error | null}
26
+ quotaData={quotaData}
27
+ quotaError={quotaErr}
28
+ onNavigate={onNavigate}
29
+ />
30
+ )
31
+ }
@@ -1,6 +1,6 @@
1
1
  import { NodeRenderer as BaseNodeRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NodeRenderer'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
3
+ import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, useNodeMetrics, useNodeMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
4
4
  import { serializeColumnFilters } from '../resource-utils'
5
5
 
6
6
  interface NodeRendererProps {
@@ -13,8 +13,13 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
13
13
  const nodeName = data.metadata?.name
14
14
 
15
15
  // Fetch node metrics
16
- const { data: metrics } = useNodeMetrics(nodeName)
17
- const { data: metricsHistory } = useNodeMetricsHistory(nodeName)
16
+ const metricsHistoryQuery = useNodeMetricsHistory(nodeName)
17
+ const { data: metricsHistory } = metricsHistoryQuery
18
+ const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
19
+ const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
20
+ const { data: metrics } = useNodeMetrics(nodeName, { enabled: liveMetricsEnabled })
21
+ const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
22
+ const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
18
23
 
19
24
  // Determine whether to hide metrics-server section (Prometheus has data)
20
25
  const { data: prometheusStatus } = usePrometheusStatus()
@@ -36,8 +41,9 @@ export function NodeRenderer({ data, relationships }: NodeRendererProps) {
36
41
  params.set('filters', serializeColumnFilters({ node: [nodeName] }))
37
42
  navigate(`/resources/pods?${params.toString()}`)
38
43
  } : undefined}
39
- metrics={metrics}
44
+ metrics={visibleMetrics}
40
45
  metricsHistory={metricsHistory}
46
+ metricsUnavailable={metricsUnavailable}
41
47
  hideMetricsServer={hideMetricsServer}
42
48
  />
43
49
  )
@@ -1 +1,19 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/PVCRenderer'
1
+ import { PVCRenderer as BasePVCRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/PVCRenderer'
2
+ import { PVCUsageBar } from '../../resource/PVCUsageBar'
3
+
4
+ interface PVCRendererProps {
5
+ data: any
6
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ }
8
+
9
+ export function PVCRenderer({ data, onNavigate }: PVCRendererProps) {
10
+ const namespace = data?.metadata?.namespace ?? ''
11
+ const name = data?.metadata?.name ?? ''
12
+ return (
13
+ <BasePVCRenderer
14
+ data={data}
15
+ onNavigate={onNavigate}
16
+ extraSections={namespace && name ? <PVCUsageBar namespace={namespace} name={name} /> : undefined}
17
+ />
18
+ )
19
+ }
@@ -2,8 +2,9 @@ import { PodRenderer as BasePodRenderer } from '@skyhook-io/k8s-ui/components/re
2
2
  import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
3
3
  import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
4
4
  import { useOpenTerminal, useOpenLogs } from '../../dock'
5
- import { useNamespacedCapabilities } from '../../../contexts/CapabilitiesContext'
6
- import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
5
+ import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
6
+ import { getVisibleLiveMetrics, isLiveMetricsUnavailable, shouldFetchLiveMetrics, usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
7
+ import { useRBACSubject } from '../../../api/rbac'
7
8
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
8
9
  import { ImageFilesystemModal } from '../ImageFilesystemModal'
9
10
  import { PodFilesystemModal } from '../PodFilesystemModal'
@@ -26,10 +27,20 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
26
27
 
27
28
  // Capabilities (namespace-scoped: re-checks RBAC if globally denied)
28
29
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
30
+ // Show the port-forward affordance for a live forward (local + RBAC) OR when
31
+ // not local — in-cluster/Cloud surfaces a copy-paste kubectl command instead.
32
+ // The button itself picks live vs. copy-command based on deployment mode.
33
+ const isLocal = useIsLocalDeployment()
34
+ const showPortForward = canPortForward || !isLocal
29
35
 
30
36
  // Metrics
31
- const { data: metrics } = usePodMetrics(namespace, podName)
32
- const { data: metricsHistory } = usePodMetricsHistory(namespace, podName)
37
+ const metricsHistoryQuery = usePodMetricsHistory(namespace, podName)
38
+ const { data: metricsHistory } = metricsHistoryQuery
39
+ const historyMetricsUnavailable = metricsHistory?.metricsUnavailable === true
40
+ const liveMetricsEnabled = shouldFetchLiveMetrics(metricsHistoryQuery.isFetched || metricsHistoryQuery.isError, historyMetricsUnavailable)
41
+ const { data: metrics } = usePodMetrics(namespace, podName, { enabled: liveMetricsEnabled })
42
+ const metricsUnavailable = historyMetricsUnavailable || isLiveMetricsUnavailable(liveMetricsEnabled, metrics)
43
+ const visibleMetrics = getVisibleLiveMetrics(liveMetricsEnabled, metricsUnavailable, metrics)
33
44
 
34
45
  // Hide metrics-server section when Prometheus has CPU data
35
46
  const { data: prometheusStatus } = usePrometheusStatus()
@@ -42,6 +53,15 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
42
53
  ) ?? false)
43
54
  const hideMetricsServer = prometheusHasCPU || (prometheusConnected && prometheusCPULoading)
44
55
 
56
+ // RBAC reverse-lookup for the Pod's ServiceAccount. Defaults to "default" —
57
+ // that's the SA every Pod uses when spec.serviceAccountName is unset, which
58
+ // is itself a useful signal (operators often don't realize "default" still
59
+ // has whatever permissions the namespace's defaults grant).
60
+ const saName = data.spec?.serviceAccountName || 'default'
61
+ const { data: rbacData, isLoading: rbacLoading, error: rbacError } = useRBACSubject(
62
+ 'ServiceAccount', namespace ?? '', saName, !!namespace,
63
+ )
64
+
45
65
  return (
46
66
  <BasePodRenderer
47
67
  data={data}
@@ -50,9 +70,12 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
50
70
  onNavigate={onNavigate}
51
71
  onOpenLogs={onOpenLogs}
52
72
  resolvedEnvFrom={resolvedEnvFrom}
73
+ rbacData={rbacData ?? null}
74
+ rbacLoading={rbacLoading}
75
+ rbacError={rbacError as Error | null}
53
76
  canExec={canExec}
54
77
  canViewLogs={canViewLogs}
55
- canPortForward={canPortForward}
78
+ canPortForward={showPortForward}
56
79
  onOpenTerminal={(params) => openTerminal(params)}
57
80
  onOpenLogsPanel={(params) => openLogsPanel(params)}
58
81
  renderPortAction={({ namespace: ns, podName: pod, port, protocol, disabled }) => (
@@ -64,8 +87,9 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
64
87
  disabled={disabled}
65
88
  />
66
89
  )}
67
- metrics={metrics}
90
+ metrics={visibleMetrics}
68
91
  metricsHistory={metricsHistory}
92
+ metricsUnavailable={metricsUnavailable}
69
93
  hideMetricsServer={hideMetricsServer}
70
94
  renderImageBrowser={({ image, namespace: ns, podName: pod, pullSecrets, onClose, onSwitchToPodFiles }) => (
71
95
  <ImageFilesystemModal
@@ -1 +1,45 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RoleBindingRenderer'
1
+ import { RoleBindingRenderer as BaseRoleBindingRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RoleBindingRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useResource } from '../../../api/client'
4
+
5
+ interface RoleBindingRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ // Reuses the generic resource endpoint to fetch the referenced Role/ClusterRole
11
+ // so the base renderer can show an inline rules preview. A dedicated /api/rbac
12
+ // "rules-only" endpoint would be tighter, but the generic one is already
13
+ // cached and respects per-user RBAC the same way.
14
+ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererProps) {
15
+ const roleRef = data?.roleRef ?? {}
16
+ const isClusterRole = roleRef.kind === 'ClusterRole'
17
+ const kind = isClusterRole ? 'clusterroles' : 'roles'
18
+ // For ClusterRole the namespace is cluster-scoped (empty); for Role the
19
+ // binding's namespace is the role's namespace (Roles are namespaced and
20
+ // RoleBindings can only reference Roles in their own namespace).
21
+ const namespace = isClusterRole ? '' : (data?.metadata?.namespace ?? '')
22
+ const name = roleRef.name ?? ''
23
+
24
+ const { data: role, isLoading, error } = useResource<any>(kind, namespace, name)
25
+ // `role` is undefined while loading, then the resource, then potentially
26
+ // null on 404 / 403. Pass [] for "loaded but no rules" so the base
27
+ // renderer's `rules === null` branch fires only on outright fetch failure
28
+ // (orphan or forbidden); `roleRulesError` then disambiguates the two.
29
+ const rules =
30
+ isLoading
31
+ ? undefined
32
+ : role
33
+ ? (role.rules ?? [])
34
+ : null
35
+
36
+ return (
37
+ <BaseRoleBindingRenderer
38
+ data={data}
39
+ onNavigate={onNavigate}
40
+ roleRules={rules ?? null}
41
+ roleRulesLoading={isLoading}
42
+ roleRulesError={error}
43
+ />
44
+ )
45
+ }
@@ -1 +1,27 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RoleRenderer'
1
+ import { RoleRenderer as BaseRoleRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RoleRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useRBACRole } from '../../../api/rbac'
4
+
5
+ interface RoleRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ export function RoleRenderer({ data, onNavigate }: RoleRendererProps) {
11
+ // ClusterRole has no namespace; Role does. The kind in the manifest is
12
+ // authoritative — RoleRenderer is shared between both kinds via the
13
+ // dispatch, so checking it here is the only way to know which we are.
14
+ const kind: 'Role' | 'ClusterRole' = data?.kind === 'ClusterRole' ? 'ClusterRole' : 'Role'
15
+ const namespace = data?.metadata?.namespace ?? ''
16
+ const name = data?.metadata?.name ?? ''
17
+ const { data: rbacRoleData, isLoading, error } = useRBACRole(kind, namespace, name, !!name)
18
+ return (
19
+ <BaseRoleRenderer
20
+ data={data}
21
+ rbacRoleData={rbacRoleData ?? null}
22
+ rbacRoleLoading={isLoading}
23
+ rbacRoleError={error as Error | null}
24
+ onNavigate={onNavigate}
25
+ />
26
+ )
27
+ }