@skyhook-io/k8s-ui 1.7.11 → 1.7.13

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/dock/TerminalTab.tsx +4 -2
  10. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  11. package/src/components/issues/IssuesView.tsx +64 -17
  12. package/src/components/issues/index.ts +1 -1
  13. package/src/components/issues/issues.test.ts +4 -4
  14. package/src/components/issues/severity.ts +5 -0
  15. package/src/components/issues/types.ts +43 -0
  16. package/src/components/logs/LogCore.tsx +13 -2
  17. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  18. package/src/components/logs/LogsViewer.tsx +66 -10
  19. package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
  20. package/src/components/logs/useLogStream.ts +41 -3
  21. package/src/components/resources/ResourcesView.tsx +550 -52
  22. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  23. package/src/components/resources/get-default-container-name.test.ts +31 -0
  24. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  25. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  26. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  27. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  28. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  30. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  31. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  32. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  33. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  34. package/src/components/resources/renderers/index.ts +8 -0
  35. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  36. package/src/components/resources/resource-utils-dra.ts +90 -0
  37. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  38. package/src/components/resources/resource-utils.ts +62 -4
  39. package/src/components/shared/DetailShell.tsx +14 -7
  40. package/src/components/shared/EditableYamlView.tsx +37 -17
  41. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  42. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  43. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  44. package/src/components/timeline/TimelineList.tsx +3 -32
  45. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  46. package/src/components/topology/K8sResourceNode.tsx +26 -5
  47. package/src/components/topology/TopologyGraph.tsx +102 -3
  48. package/src/components/topology/layout.ts +36 -11
  49. package/src/components/ui/CenteredEmpty.tsx +27 -0
  50. package/src/components/ui/ConfirmDialog.tsx +1 -1
  51. package/src/components/ui/SearchBox.tsx +85 -0
  52. package/src/components/ui/drawer-components.tsx +23 -1
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/workload/WorkloadView.tsx +167 -33
  55. package/src/components/workload/index.ts +1 -1
  56. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  57. package/src/index.ts +4 -0
  58. package/src/types/core.ts +1 -0
  59. package/src/utils/api-resources.ts +21 -0
  60. package/src/utils/applications.test.ts +207 -0
  61. package/src/utils/applications.ts +674 -0
  62. package/src/utils/custom-columns.test.ts +111 -0
  63. package/src/utils/custom-columns.ts +49 -0
  64. package/src/utils/extended-resources.test.ts +152 -0
  65. package/src/utils/extended-resources.ts +121 -0
  66. package/src/utils/format.ts +11 -0
  67. package/src/utils/index.ts +3 -0
  68. package/src/utils/topology-neighborhood.test.ts +185 -0
  69. package/src/utils/topology-neighborhood.ts +262 -0
  70. package/src/utils/workload-colors.ts +36 -0
@@ -0,0 +1,90 @@
1
+ // DRA (Dynamic Resource Allocation, resource.k8s.io) utility functions.
2
+ // Accessors tolerate all served shapes: v1/v1beta2 nest device-class refs
3
+ // under requests[].exactly (or firstAvailable subrequests); v1beta1 had
4
+ // deviceClassName directly on the request.
5
+
6
+ import type { StatusBadge } from './resource-utils'
7
+ import { healthColors } from './resource-utils'
8
+
9
+ function requestDeviceClasses(requests: any[]): string[] {
10
+ const seen = new Set<string>()
11
+ for (const req of requests || []) {
12
+ const direct = req?.exactly?.deviceClassName || req?.deviceClassName
13
+ if (direct) seen.add(direct)
14
+ for (const sub of req?.firstAvailable || []) {
15
+ if (sub?.deviceClassName) seen.add(sub.deviceClassName)
16
+ }
17
+ }
18
+ return [...seen]
19
+ }
20
+
21
+ export function getResourceClaimDeviceClasses(resource: any): string[] {
22
+ return requestDeviceClasses(resource?.spec?.devices?.requests)
23
+ }
24
+
25
+ export function getResourceClaimTemplateDeviceClasses(resource: any): string[] {
26
+ return requestDeviceClasses(resource?.spec?.spec?.devices?.requests)
27
+ }
28
+
29
+ export function getResourceClaimAllocation(resource: any): { driver: string; pool: string; device: string }[] {
30
+ const results = resource?.status?.allocation?.devices?.results || []
31
+ return results.map((r: any) => ({
32
+ driver: r?.driver || '',
33
+ pool: r?.pool || '',
34
+ device: r?.device || '',
35
+ }))
36
+ }
37
+
38
+ export function getResourceClaimReservedFor(resource: any): { resource: string; name: string }[] {
39
+ return (resource?.status?.reservedFor || []).map((r: any) => ({
40
+ resource: r?.resource || r?.apiGroup || '',
41
+ name: r?.name || '',
42
+ }))
43
+ }
44
+
45
+ export function getResourceClaimStatus(resource: any): StatusBadge {
46
+ // Allocated means devices were actually assigned — keyed to devices.results
47
+ // so the badge always agrees with the Allocation section.
48
+ const allocated = (resource?.status?.allocation?.devices?.results || []).length > 0
49
+ const reserved = (resource?.status?.reservedFor || []).length > 0
50
+ if (allocated && reserved) {
51
+ return { text: 'Allocated', color: healthColors.healthy, level: 'healthy' }
52
+ }
53
+ if (allocated) {
54
+ // Allocated-but-unreserved is normal transiently; long-lived it leaks a device
55
+ return { text: 'Unreserved', color: healthColors.degraded, level: 'degraded' }
56
+ }
57
+ return { text: 'Pending', color: healthColors.unknown, level: 'unknown' }
58
+ }
59
+
60
+ export function getResourceClaimTemplateStatus(_resource: any): StatusBadge {
61
+ return { text: 'Template', color: healthColors.neutral, level: 'neutral' }
62
+ }
63
+
64
+ export function getDeviceClassStatus(_resource: any): StatusBadge {
65
+ return { text: 'Available', color: healthColors.neutral, level: 'neutral' }
66
+ }
67
+
68
+ export function getDeviceClassSelectorCount(resource: any): number {
69
+ return (resource?.spec?.selectors || []).length
70
+ }
71
+
72
+ export function getResourceSliceStatus(_resource: any): StatusBadge {
73
+ return { text: 'Published', color: healthColors.neutral, level: 'neutral' }
74
+ }
75
+
76
+ export function getResourceSliceDriver(resource: any): string {
77
+ return resource?.spec?.driver || '-'
78
+ }
79
+
80
+ export function getResourceSlicePool(resource: any): string {
81
+ return resource?.spec?.pool?.name || '-'
82
+ }
83
+
84
+ export function getResourceSliceNode(resource: any): string {
85
+ return resource?.spec?.nodeName || ''
86
+ }
87
+
88
+ export function getResourceSliceDeviceCount(resource: any): number {
89
+ return (resource?.spec?.devices || []).length
90
+ }
@@ -0,0 +1,63 @@
1
+ // NVIDIA GPU Operator (nvidia.com) utility functions.
2
+ // ClusterPolicy here is nvidia.com's — distinct from Kyverno's ClusterPolicy;
3
+ // dispatch call sites guard on apiVersion before reaching these.
4
+
5
+ import type { StatusBadge } from './resource-utils'
6
+ import { healthColors } from './resource-utils'
7
+
8
+ function stateBadge(state: string | undefined): StatusBadge {
9
+ switch (state) {
10
+ case 'ready':
11
+ return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
12
+ case 'notReady':
13
+ return { text: 'Not Ready', color: healthColors.alert, level: 'alert' }
14
+ case 'disabled':
15
+ return { text: 'Disabled', color: healthColors.neutral, level: 'neutral' }
16
+ default:
17
+ return { text: state || 'Unknown', color: healthColors.unknown, level: 'unknown' }
18
+ }
19
+ }
20
+
21
+ export function getNvidiaClusterPolicyStatus(resource: any): StatusBadge {
22
+ return stateBadge(resource?.status?.state)
23
+ }
24
+
25
+ export const NVIDIA_CLUSTER_POLICY_COMPONENTS = [
26
+ { key: 'driver', label: 'Driver' },
27
+ { key: 'toolkit', label: 'Container Toolkit' },
28
+ { key: 'devicePlugin', label: 'Device Plugin' },
29
+ { key: 'dcgmExporter', label: 'DCGM Exporter' },
30
+ { key: 'dcgm', label: 'DCGM' },
31
+ { key: 'gfd', label: 'GPU Feature Discovery' },
32
+ { key: 'migManager', label: 'MIG Manager' },
33
+ { key: 'nodeStatusExporter', label: 'Node Status Exporter' },
34
+ { key: 'vgpuManager', label: 'vGPU Manager' },
35
+ { key: 'gds', label: 'GPUDirect Storage' },
36
+ ] as const
37
+
38
+ export function getNvidiaClusterPolicyEnabledComponents(resource: any): { label: string; enabled: boolean }[] {
39
+ const spec = resource?.spec || {}
40
+ return NVIDIA_CLUSTER_POLICY_COMPONENTS
41
+ .filter(({ key }) => spec[key]?.enabled !== undefined)
42
+ .map(({ key, label }) => ({ label, enabled: !!spec[key].enabled }))
43
+ }
44
+
45
+ export function getNvidiaClusterPolicyMigStrategy(resource: any): string {
46
+ return resource?.spec?.mig?.strategy || '-'
47
+ }
48
+
49
+ export function getNvidiaDriverStatus(resource: any): StatusBadge {
50
+ return stateBadge(resource?.status?.state)
51
+ }
52
+
53
+ export function getNvidiaDriverType(resource: any): string {
54
+ return resource?.spec?.driverType || '-'
55
+ }
56
+
57
+ export function getNvidiaDriverVersion(resource: any): string {
58
+ return resource?.spec?.version || '-'
59
+ }
60
+
61
+ export function isNvidiaResource(resource: any): boolean {
62
+ return !!resource?.apiVersion?.startsWith('nvidia.com/')
63
+ }
@@ -10,6 +10,8 @@ import { getScaledObjectStatus, getScaledJobStatus } from './resource-utils-keda
10
10
  import { getGitRepositoryStatus, getOCIRepositoryStatus, getHelmRepositoryStatus, getHelmRepositoryType, getKustomizationStatus, getFluxHelmReleaseStatus, getFluxAlertStatus } from './resource-utils-flux'
11
11
  import { getArgoApplicationStatus, getArgoApplicationSetStatus, getArgoApplicationSync, getArgoApplicationHealth, getArgoApplicationProject } from './resource-utils-argo'
12
12
  import { getPolicyReportStatus as _getPolicyReportStatus, getKyvernoPolicyStatus as _getKyvernoPolicyStatus } from './resource-utils-kyverno'
13
+ import { getResourceClaimStatus as _getResourceClaimStatus, getResourceClaimDeviceClasses as _getResourceClaimDeviceClasses, getResourceClaimTemplateDeviceClasses as _getResourceClaimTemplateDeviceClasses, getResourceClaimAllocation as _getResourceClaimAllocation, getResourceClaimReservedFor as _getResourceClaimReservedFor } from './resource-utils-dra'
14
+ import { getNvidiaClusterPolicyStatus as _getNvidiaClusterPolicyStatus, getNvidiaClusterPolicyEnabledComponents as _getNvidiaClusterPolicyEnabledComponents, getNvidiaDriverStatus as _getNvidiaDriverStatus } from './resource-utils-nvidia'
13
15
  import { getBackupStatus as _getBackupStatus, getRestoreStatus as _getRestoreStatus, getScheduleStatus as _getScheduleStatus, getBSLStatus as _getBSLStatus } from './resource-utils-velero'
14
16
  import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternalSecretStatus as _getClusterExternalSecretStatus, getSecretStoreStatus as _getSecretStoreStatus, getClusterSecretStoreStatus as _getClusterSecretStoreStatus, getSecretStoreProviderType as _getSecretStoreProviderType } from './resource-utils-eso'
15
17
 
@@ -418,6 +420,23 @@ export interface ContainerSquareState {
418
420
  }
419
421
  }
420
422
 
423
+ /**
424
+ * The container to default to for exec / logs on a multi-container pod. Honors
425
+ * the kubectl.kubernetes.io/default-container annotation (the convention
426
+ * kubectl, k9s, and Lens follow, and what service meshes like Istio set to
427
+ * point past their injected sidecar), falling back to the first container.
428
+ * Without this, mesh-injected pods default to their distroless sidecar — which
429
+ * has no shell — making the terminal appear broken.
430
+ */
431
+ export function getDefaultContainerName(pod: any): string | undefined {
432
+ const containers = pod?.spec?.containers || []
433
+ const annotated = pod?.metadata?.annotations?.['kubectl.kubernetes.io/default-container']
434
+ if (annotated && containers.some((c: any) => c.name === annotated)) {
435
+ return annotated
436
+ }
437
+ return containers[0]?.name
438
+ }
439
+
421
440
  export function getContainerSquareStates(pod: any): ContainerSquareState[] {
422
441
  const result: ContainerSquareState[] = []
423
442
  const initStatuses = pod.status?.initContainerStatuses || []
@@ -1610,6 +1629,10 @@ export function formatResources(resources: any): string {
1610
1629
  if (resources.memory) {
1611
1630
  parts.push(`Mem: ${formatMemoryString(resources.memory)}`)
1612
1631
  }
1632
+ for (const [key, value] of Object.entries(resources)) {
1633
+ if (key === 'cpu' || key === 'memory') continue
1634
+ parts.push(`${key}: ${value}`)
1635
+ }
1613
1636
  return parts.join(', ') || '-'
1614
1637
  }
1615
1638
 
@@ -1631,8 +1654,12 @@ export function parseColumnFilters(filtersParam: string | null): Record<string,
1631
1654
  for (const pair of filtersParam.split('|')) {
1632
1655
  const colonIdx = pair.indexOf(':')
1633
1656
  if (colonIdx > 0) {
1634
- const key = pair.slice(0, colonIdx).trim()
1657
+ const rawKey = pair.slice(0, colonIdx).trim()
1635
1658
  const valStr = pair.slice(colonIdx + 1).trim()
1659
+ // Keys are URI-encoded so a custom-column key's own colon (e.g.
1660
+ // "label:tier") doesn't collide with the key:value delimiter.
1661
+ let key: string
1662
+ try { key = decodeURIComponent(rawKey) } catch { key = rawKey }
1636
1663
  if (key && valStr) {
1637
1664
  filters[key] = valStr.split(',').map(v => {
1638
1665
  try { return decodeURIComponent(v.trim()) } catch { return v.trim() }
@@ -1643,12 +1670,13 @@ export function parseColumnFilters(filtersParam: string | null): Record<string,
1643
1670
  return filters
1644
1671
  }
1645
1672
 
1646
- // Serialize column filters to URL param format
1647
- // Values are URI-encoded so commas inside values (e.g. "Ready,SchedulingDisabled") survive the round-trip.
1673
+ // Serialize column filters to URL param format. Keys and values are both
1674
+ // URI-encoded so a colon inside a custom-column key (e.g. "label:tier") or a
1675
+ // comma inside a value (e.g. "Ready,SchedulingDisabled") survives the round-trip.
1648
1676
  export function serializeColumnFilters(filters: Record<string, string[]>): string {
1649
1677
  const result = Object.entries(filters)
1650
1678
  .filter(([, v]) => v.length > 0)
1651
- .map(([k, vals]) => `${k}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
1679
+ .map(([k, vals]) => `${encodeURIComponent(k)}:${vals.map(v => encodeURIComponent(v)).join(',')}`)
1652
1680
  .join('|')
1653
1681
  return result
1654
1682
  }
@@ -1706,6 +1734,9 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
1706
1734
  if (kindLower === 'scaledjobs') return getScaledJobStatus(resource).text
1707
1735
  if (kindLower === 'policyreports' || kindLower === 'clusterpolicyreports') return _getPolicyReportStatus(resource).text
1708
1736
  if (kindLower === 'kyvernopolicies' || kindLower === 'clusterpolicies') return _getKyvernoPolicyStatus(resource).text
1737
+ if (kindLower === 'nvidiaclusterpolicies') return _getNvidiaClusterPolicyStatus(resource).text
1738
+ if (kindLower === 'nvidiadrivers') return _getNvidiaDriverStatus(resource).text
1739
+ if (kindLower === 'resourceclaims') return _getResourceClaimStatus(resource).text
1709
1740
  if (kindLower === 'backups') return _getBackupStatus(resource).text
1710
1741
  if (kindLower === 'restores') return _getRestoreStatus(resource).text
1711
1742
  if (kindLower === 'schedules') return _getScheduleStatus(resource).text
@@ -1766,6 +1797,33 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
1766
1797
  case 'provider':
1767
1798
  if (kindLower === 'secretstores' || kindLower === 'clustersecretstores') return _getSecretStoreProviderType(resource)
1768
1799
  return resource.spec?.provider || ''
1800
+ // DRA + NVIDIA columns. Unmatched kinds break to the generic fallback —
1801
+ // these keys are shared (e.g. 'components' is also a Trivy SBOM column).
1802
+ case 'deviceClass':
1803
+ if (kindLower === 'resourceclaims') return _getResourceClaimDeviceClasses(resource).join(', ')
1804
+ if (kindLower === 'resourceclaimtemplates') return _getResourceClaimTemplateDeviceClasses(resource).join(', ')
1805
+ break
1806
+ case 'allocated':
1807
+ if (kindLower === 'resourceclaims') return _getResourceClaimAllocation(resource).map(r => r.driver).join(', ')
1808
+ break
1809
+ case 'reservedFor':
1810
+ if (kindLower === 'resourceclaims') return _getResourceClaimReservedFor(resource).map(r => r.name).join(', ')
1811
+ break
1812
+ case 'pool':
1813
+ if (kindLower === 'resourceslices') return resource.spec?.pool?.name || ''
1814
+ break
1815
+ case 'devices':
1816
+ if (kindLower === 'resourceslices') return String((resource.spec?.devices || []).length)
1817
+ break
1818
+ case 'selectors':
1819
+ if (kindLower === 'deviceclasses') return String((resource.spec?.selectors || []).length)
1820
+ break
1821
+ case 'components':
1822
+ if (kindLower === 'nvidiaclusterpolicies') return _getNvidiaClusterPolicyEnabledComponents(resource).filter(c => c.enabled).map(c => c.label).join(', ')
1823
+ break
1824
+ case 'mig':
1825
+ if (kindLower === 'nvidiaclusterpolicies') return resource.spec?.mig?.strategy || ''
1826
+ break
1769
1827
  }
1770
1828
 
1771
1829
  // Fallback: try common paths
@@ -30,6 +30,8 @@ export interface DetailShellProps<TId extends string = string> {
30
30
  onTabChange: (id: TId) => void
31
31
  tabStripEnd?: ReactNode
32
32
  overlay?: ReactNode
33
+ /** Hide breadcrumb/identity/header actions when a host page already owns that chrome. */
34
+ compactHeader?: boolean
33
35
  children: ReactNode
34
36
  }
35
37
 
@@ -44,6 +46,7 @@ export function DetailShell<TId extends string = string>({
44
46
  onTabChange,
45
47
  tabStripEnd,
46
48
  overlay,
49
+ compactHeader = false,
47
50
  children,
48
51
  }: DetailShellProps<TId>) {
49
52
  const visibleTabs = tabs.filter((t) => !t.hidden)
@@ -52,15 +55,19 @@ export function DetailShell<TId extends string = string>({
52
55
  <div className="flex flex-col h-full w-full bg-theme-surface">
53
56
  {/* Header */}
54
57
  <div className="shrink-0 border-b border-theme-border bg-theme-surface">
55
- {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
56
- <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
57
- {nav}
58
- <div className="flex-1 min-w-0">{identity}</div>
59
- {headerActions}
60
- </div>
58
+ {!compactHeader && (
59
+ <>
60
+ {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
61
+ <div className={clsx('px-6 flex items-start gap-4', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
62
+ {nav}
63
+ <div className="flex-1 min-w-0">{identity}</div>
64
+ {headerActions}
65
+ </div>
66
+ </>
67
+ )}
61
68
 
62
69
  {/* Tabs (left) + scope controls / actions (right) */}
63
- <div className="px-6 flex items-center border-t border-theme-border">
70
+ <div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
64
71
  <div className="flex gap-1" role="tablist">
65
72
  {visibleTabs.map((t) => (
66
73
  <DetailShellTabButton key={t.id} active={activeTab === t.id} onClick={() => onTabChange(t.id)}>
@@ -66,19 +66,27 @@ function formatSaveError(error: string): { summary: string; details?: string } {
66
66
  const errorPart = parts[1]?.trim() || ''
67
67
 
68
68
  if (errorPart.includes('Forbidden:')) {
69
- const forbiddenMatch = errorPart.match(/([^:]+):\s*Forbidden:\s*([^.{]+)/)
70
- if (forbiddenMatch) {
69
+ const forbiddenAt = errorPart.indexOf(': Forbidden:')
70
+ if (forbiddenAt > 0) {
71
+ const target = errorPart.slice(0, forbiddenAt).trim()
72
+ const messageStart = forbiddenAt + ': Forbidden:'.length
73
+ const dotAt = errorPart.indexOf('.', messageStart)
74
+ const braceAt = errorPart.indexOf('{', messageStart)
75
+ const endCandidates = [dotAt, braceAt].filter((i) => i >= 0)
76
+ const messageEnd = endCandidates.length ? Math.min(...endCandidates) : errorPart.length
77
+ const message = errorPart.slice(messageStart, messageEnd).trim()
71
78
  return {
72
- summary: `Cannot update ${forbiddenMatch[1]}: ${forbiddenMatch[2].trim()}`,
79
+ summary: `Cannot update ${target}: ${message}`,
73
80
  details: error.length > 200 ? error : undefined
74
81
  }
75
82
  }
76
83
  }
77
84
 
78
- const summaryMatch = errorPart.match(/^([^{]+)/)
79
- if (summaryMatch) {
85
+ const braceAt = errorPart.indexOf('{')
86
+ const summary = (braceAt >= 0 ? errorPart.slice(0, braceAt) : errorPart).trim()
87
+ if (summary) {
80
88
  return {
81
- summary: summaryMatch[1].trim(),
89
+ summary,
82
90
  details: error.length > 200 ? error : undefined
83
91
  }
84
92
  }
@@ -111,6 +119,8 @@ interface EditableYamlViewProps {
111
119
  data: any
112
120
  onCopy: (text: string) => void
113
121
  copied: boolean
122
+ /** Hide edit affordances when the host surface is read-only. */
123
+ readOnly?: boolean
114
124
  /** Called after a successful save so the parent can refetch */
115
125
  onSaved?: () => void
116
126
  /** Save handler — injected by the platform wrapper */
@@ -128,13 +138,13 @@ interface EditableYamlViewProps {
128
138
  onDownload?: (content: string, mime: string, filename: string) => void
129
139
  }
130
140
 
131
- export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
141
+ export function EditableYamlView({ resource, data, onCopy, copied, readOnly = false, onSaved, onSave, isSaving, saveError, onDuplicate, onDownload }: EditableYamlViewProps) {
132
142
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
133
143
 
134
144
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
135
145
  // All sessionStorage calls are wrapped in try-catch — storage can throw
136
146
  // QuotaExceededError or be blocked by browser security policies.
137
- const savedDraft = useRef(safeSessionGet(draftKey))
147
+ const savedDraft = useRef(readOnly ? null : safeSessionGet(draftKey))
138
148
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
139
149
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
140
150
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
@@ -153,12 +163,19 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
153
163
 
154
164
  // Autosave draft to sessionStorage while editing (best-effort)
155
165
  useEffect(() => {
166
+ if (readOnly) {
167
+ setIsEditing(false)
168
+ setEditedYaml('')
169
+ setYamlErrors([])
170
+ safeSessionRemove(draftKey)
171
+ return
172
+ }
156
173
  if (isEditing && editedYaml) {
157
174
  safeSessionSet(draftKey, editedYaml)
158
175
  } else {
159
176
  safeSessionRemove(draftKey)
160
177
  }
161
- }, [isEditing, editedYaml, draftKey])
178
+ }, [isEditing, editedYaml, draftKey, readOnly])
162
179
 
163
180
  const handleDownload = useCallback(() => {
164
181
  const yaml = resourceToYaml(data)
@@ -170,10 +187,11 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
170
187
  }, [data, resource.kind, resource.name, onDownload])
171
188
 
172
189
  const handleStartEdit = useCallback(() => {
190
+ if (readOnly) return
173
191
  setEditedYaml(resourceToYaml(data))
174
192
  setYamlErrors([])
175
193
  setIsEditing(true)
176
- }, [data])
194
+ }, [data, readOnly])
177
195
 
178
196
  const handleCancelEdit = useCallback(() => {
179
197
  setIsEditing(false)
@@ -345,13 +363,15 @@ export function EditableYamlView({ resource, data, onCopy, copied, onSaved, onSa
345
363
  <div className="flex items-center justify-between mb-2">
346
364
  <span className="text-sm font-medium text-theme-text-secondary">YAML</span>
347
365
  <div className="flex items-center gap-2">
348
- <button
349
- onClick={handleStartEdit}
350
- className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
351
- >
352
- <Pencil className="w-3.5 h-3.5" />
353
- Edit
354
- </button>
366
+ {!readOnly && (
367
+ <button
368
+ onClick={handleStartEdit}
369
+ className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:text-blue-300 hover:bg-theme-elevated rounded"
370
+ >
371
+ <Pencil className="w-3.5 h-3.5" />
372
+ Edit
373
+ </button>
374
+ )}
355
375
  <button
356
376
  onClick={() => onCopy(yamlContent)}
357
377
  className="flex items-center gap-1 px-2 py-1 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
@@ -24,6 +24,7 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
24
24
  import { DialogPortal } from '../ui/DialogPortal'
25
25
  import type { SelectedResource, WorkloadRevision } from '../../types'
26
26
  import { formatKindName } from '../ui/drawer-components'
27
+ import { getDefaultContainerName } from '../resources/resource-utils'
27
28
 
28
29
  // ============================================================================
29
30
  // ACTIONS BAR - Interactive buttons that change based on resource kind
@@ -64,7 +65,7 @@ interface ResourceActionsBarProps {
64
65
  renderPortForward?: (props: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => React.ReactNode
65
66
 
66
67
  // Delete
67
- onDelete?: (params: { kind: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
68
+ onDelete?: (params: { kind: string; group?: string; namespace: string; name: string; force: boolean }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
68
69
  isDeleting?: boolean
69
70
  cascadeDependents?: CascadeDependent[]
70
71
  cascadeLoading?: boolean
@@ -165,7 +166,7 @@ export function ResourceActionsBar({
165
166
 
166
167
  function handleDeleteConfirm(force: boolean) {
167
168
  onDelete?.(
168
- { kind: resource.kind, namespace: resource.namespace, name: resource.name, force },
169
+ { kind: resource.kind, group: resource.group, namespace: resource.namespace, name: resource.name, force },
169
170
  {
170
171
  onSuccess: () => {
171
172
  setShowDeleteConfirm(false)
@@ -183,7 +184,7 @@ export function ResourceActionsBar({
183
184
  onOpenTerminal?.({
184
185
  namespace: resource.namespace,
185
186
  podName: resource.name,
186
- containerName: containers[0],
187
+ containerName: getDefaultContainerName(data) || containers[0],
187
188
  containers,
188
189
  })
189
190
  }
@@ -195,7 +196,7 @@ export function ResourceActionsBar({
195
196
  namespace: resource.namespace,
196
197
  podName: resource.name,
197
198
  containers,
198
- containerName,
199
+ containerName: containerName || getDefaultContainerName(data),
199
200
  })
200
201
  }
201
202
  }
@@ -43,3 +43,106 @@ describe('ResourceRendererDispatch', () => {
43
43
  expect(html).toContain('none')
44
44
  })
45
45
  })
46
+
47
+ function renderKind(kind: string, data: any, namespace = ''): string {
48
+ return renderToString(
49
+ <ResourceRendererDispatch
50
+ resource={{ kind, namespace, name: data?.metadata?.name || 'x' }}
51
+ data={data}
52
+ onCopy={() => {}}
53
+ copied={null}
54
+ showCommonSections={false}
55
+ />,
56
+ )
57
+ }
58
+
59
+ describe('ClusterPolicy kind collision (nvidia.com vs kyverno.io)', () => {
60
+ it('routes nvidia.com ClusterPolicy to the GPU Operator renderer', () => {
61
+ const html = renderKind('clusterpolicies', {
62
+ apiVersion: 'nvidia.com/v1',
63
+ kind: 'ClusterPolicy',
64
+ metadata: { name: 'cluster-policy' },
65
+ spec: { driver: { enabled: true }, devicePlugin: { enabled: true } },
66
+ status: { state: 'ready' },
67
+ })
68
+ expect(html).toContain('Operator Status')
69
+ expect(html).not.toContain('Rules')
70
+ })
71
+
72
+ it('routes kyverno.io ClusterPolicy to the Kyverno renderer', () => {
73
+ const html = renderKind('clusterpolicies', {
74
+ apiVersion: 'kyverno.io/v1',
75
+ kind: 'ClusterPolicy',
76
+ metadata: { name: 'require-labels' },
77
+ spec: { rules: [{ name: 'check-labels' }] },
78
+ })
79
+ expect(html).not.toContain('Operator Status')
80
+ })
81
+ })
82
+
83
+ describe('DRA renderers dispatch', () => {
84
+ it('renders ResourceClaim with allocation sections (not GenericRenderer)', () => {
85
+ const html = renderKind('resourceclaims', {
86
+ apiVersion: 'resource.k8s.io/v1',
87
+ kind: 'ResourceClaim',
88
+ metadata: { name: 'gpu-claim', namespace: 'ml' },
89
+ spec: { devices: { requests: [{ name: 'gpu', exactly: { deviceClassName: 'gpu.nvidia.com', count: 1 } }] } },
90
+ status: {
91
+ allocation: { devices: { results: [{ request: 'gpu', driver: 'gpu.nvidia.com', pool: 'node-1', device: 'gpu-0' }] } },
92
+ reservedFor: [{ resource: 'pods', name: 'train-1' }],
93
+ },
94
+ }, 'ml')
95
+ expect(html).toContain('Device Requests')
96
+ expect(html).toContain('gpu.nvidia.com')
97
+ expect(html).toContain('Reserved For')
98
+ })
99
+
100
+ it('does not treat an empty allocation block as allocated', () => {
101
+ const html = renderKind('resourceclaims', {
102
+ apiVersion: 'resource.k8s.io/v1',
103
+ kind: 'ResourceClaim',
104
+ metadata: { name: 'partial-claim', namespace: 'ml' },
105
+ spec: { devices: { requests: [{ name: 'gpu', exactly: { deviceClassName: 'gpu.example.com' } }] } },
106
+ status: { allocation: {} },
107
+ }, 'ml')
108
+ expect(html).toContain('Not allocated')
109
+ expect(html).not.toContain('Allocated but unreserved')
110
+ })
111
+
112
+ it('reads v1beta1 request shape (deviceClassName at request level)', () => {
113
+ const html = renderKind('resourceclaims', {
114
+ apiVersion: 'resource.k8s.io/v1beta1',
115
+ kind: 'ResourceClaim',
116
+ metadata: { name: 'old-claim', namespace: 'ml' },
117
+ spec: { devices: { requests: [{ name: 'gpu', deviceClassName: 'gpu.example.com' }] } },
118
+ }, 'ml')
119
+ expect(html).toContain('gpu.example.com')
120
+ })
121
+
122
+ it('renders ResourceSlice with device inventory', () => {
123
+ const html = renderKind('resourceslices', {
124
+ apiVersion: 'resource.k8s.io/v1',
125
+ kind: 'ResourceSlice',
126
+ metadata: { name: 'node-1-gpus' },
127
+ spec: {
128
+ driver: 'gpu.nvidia.com',
129
+ pool: { name: 'node-1' },
130
+ nodeName: 'node-1',
131
+ devices: [{ name: 'gpu-0', attributes: { productName: { string: 'H100' } } }],
132
+ },
133
+ })
134
+ expect(html).toContain('Slice Info')
135
+ expect(html).toContain('H100')
136
+ })
137
+
138
+ it('renders DeviceClass selectors', () => {
139
+ const html = renderKind('deviceclasses', {
140
+ apiVersion: 'resource.k8s.io/v1',
141
+ kind: 'DeviceClass',
142
+ metadata: { name: 'gpu.nvidia.com' },
143
+ spec: { selectors: [{ cel: { expression: "device.driver == 'gpu.nvidia.com'" } }] },
144
+ })
145
+ expect(html).toContain('Selectors (1)')
146
+ expect(html).toContain('device.driver')
147
+ })
148
+ })
@@ -50,6 +50,8 @@ import { getNodePoolStatus, getNodeClaimStatus, getEC2NodeClassStatus } from '..
50
50
  import { getScaledObjectStatus, getScaledJobStatus } from '../resources/resource-utils-keda'
51
51
  import { getServiceMonitorStatus, getPrometheusRuleStatus, getPodMonitorStatus } from '../resources/resource-utils-prometheus'
52
52
  import { getPolicyReportStatus, getKyvernoPolicyStatus } from '../resources/resource-utils-kyverno'
53
+ import { getResourceClaimStatus, getResourceClaimTemplateStatus, getDeviceClassStatus, getResourceSliceStatus } from '../resources/resource-utils-dra'
54
+ import { getNvidiaClusterPolicyStatus, getNvidiaDriverStatus } from '../resources/resource-utils-nvidia'
53
55
  import { getBackupStatus, getRestoreStatus, getScheduleStatus, getBSLStatus } from '../resources/resource-utils-velero'
54
56
  import {
55
57
  getVirtualServiceStatus,
@@ -208,6 +210,12 @@ import {
208
210
  CompositionRenderer,
209
211
  CompositionRevisionRenderer,
210
212
  XRDRenderer,
213
+ ResourceClaimRenderer,
214
+ ResourceClaimTemplateRenderer,
215
+ DeviceClassRenderer,
216
+ ResourceSliceRenderer,
217
+ NvidiaClusterPolicyRenderer,
218
+ NvidiaDriverRenderer,
211
219
  } from '../resources/renderers'
212
220
  import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer'
213
221
  import {
@@ -318,6 +326,8 @@ const KNOWN_KINDS = new Set([
318
326
  'triggerauthentications', 'clustertriggerauthentications',
319
327
  'servicemonitors', 'prometheusrules', 'podmonitors',
320
328
  'policyreports', 'clusterpolicyreports', 'kyvernopolicies', 'clusterpolicies',
329
+ 'resourceclaims', 'resourceclaimtemplates', 'deviceclasses', 'resourceslices',
330
+ 'nvidiadrivers',
321
331
  'vulnerabilityreports', 'configauditreports', 'exposedsecretreports',
322
332
  'rbacassessmentreports', 'clusterrbacassessmentreports',
323
333
  'clustercompliancereports', 'sbomreports', 'clustersbomreports',
@@ -557,7 +567,14 @@ export function ResourceRendererDispatch({
557
567
  {kind === 'prometheusrules' && <PrometheusRuleRenderer data={data} />}
558
568
  {kind === 'podmonitors' && <PodMonitorRenderer data={data} />}
559
569
  {(kind === 'policyreports' || kind === 'clusterpolicyreports') && <PolicyReportRenderer data={data} />}
560
- {(kind === 'kyvernopolicies' || kind === 'clusterpolicies') && <KyvernoPolicyRenderer data={data} />}
570
+ {(kind === 'kyvernopolicies' || (kind === 'clusterpolicies' && !data?.apiVersion?.startsWith('nvidia.com/'))) && <KyvernoPolicyRenderer data={data} />}
571
+ {(kind === 'clusterpolicies' && data?.apiVersion?.startsWith('nvidia.com/')) && <NvidiaClusterPolicyRenderer data={data} />}
572
+ {kind === 'nvidiadrivers' && <NvidiaDriverRenderer data={data} />}
573
+ {/* DRA (resource.k8s.io) */}
574
+ {kind === 'resourceclaims' && <ResourceClaimRenderer data={data} onNavigate={onNavigate} />}
575
+ {kind === 'resourceclaimtemplates' && <ResourceClaimTemplateRenderer data={data} />}
576
+ {kind === 'deviceclasses' && <DeviceClassRenderer data={data} />}
577
+ {kind === 'resourceslices' && <ResourceSliceRenderer data={data} onNavigate={onNavigate} />}
561
578
  {kind === 'backups' && data.apiVersion?.includes('cnpg.io') && <CNPGBackupRenderer data={data} onNavigate={onNavigate} />}
562
579
  {kind === 'backups' && !data.apiVersion?.includes('cnpg.io') && <VeleroBackupRenderer data={data} />}
563
580
  {kind === 'restores' && <VeleroRestoreRenderer data={data} />}
@@ -760,7 +777,15 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
760
777
  if (k === 'clustercompliancereports') return getClusterComplianceReportStatus(data)
761
778
  if (k === 'sbomreports' || k === 'clustersbomreports') return getSbomReportStatus(data)
762
779
  if (k === 'policyreports' || k === 'clusterpolicyreports') return getPolicyReportStatus(data)
763
- if (k === 'kyvernopolicies' || k === 'clusterpolicies') return getKyvernoPolicyStatus(data)
780
+ if (k === 'kyvernopolicies' || k === 'clusterpolicies') {
781
+ if (data?.apiVersion?.startsWith('nvidia.com/')) return getNvidiaClusterPolicyStatus(data)
782
+ return getKyvernoPolicyStatus(data)
783
+ }
784
+ if (k === 'nvidiadrivers') return getNvidiaDriverStatus(data)
785
+ if (k === 'resourceclaims') return getResourceClaimStatus(data)
786
+ if (k === 'resourceclaimtemplates') return getResourceClaimTemplateStatus(data)
787
+ if (k === 'deviceclasses') return getDeviceClassStatus(data)
788
+ if (k === 'resourceslices') return getResourceSliceStatus(data)
764
789
  if (k === 'backups') {
765
790
  if (data.apiVersion?.includes('cnpg.io')) return getCNPGBackupStatus(data)
766
791
  return getBackupStatus(data)