@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -48,7 +48,9 @@ export function JobRenderer({ data }: JobRendererProps) {
48
48
  const conditions = status.conditions || []
49
49
 
50
50
  const startTime = status.startTime ? new Date(status.startTime) : null
51
- const completionTime = status.completionTime ? new Date(status.completionTime) : null
51
+ const terminalCondition = conditions.find((c: any) => (c.type === 'Complete' || c.type === 'Failed') && c.status === 'True')
52
+ const finishedAt = status.completionTime || terminalCondition?.lastTransitionTime
53
+ const completionTime = finishedAt ? new Date(finishedAt) : null
52
54
  const duration = startTime && completionTime
53
55
  ? formatDuration(completionTime.getTime() - startTime.getTime(), true)
54
56
  : startTime
@@ -0,0 +1,47 @@
1
+ import { Info } from 'lucide-react'
2
+ import { Tooltip } from '../../ui/Tooltip'
3
+
4
+ interface MetricsUnavailableNoticeProps {
5
+ rawError?: string
6
+ diagnosis?: string
7
+ }
8
+
9
+ export function MetricsUnavailableNotice({ rawError, diagnosis }: MetricsUnavailableNoticeProps) {
10
+ return (
11
+ <div className="card-inner-lg text-xs text-theme-text-tertiary">
12
+ <div className="flex items-center gap-1.5">
13
+ <span className="min-w-0 leading-5">
14
+ Metrics unavailable. Radar cannot read metrics.k8s.io.
15
+ </span>
16
+ {rawError && (
17
+ <Tooltip
18
+ content={(
19
+ <span className="space-y-1">
20
+ <span className="block">
21
+ This panel uses Kubernetes metrics.k8s.io for live CPU and memory; Prometheus data does not fill it.
22
+ </span>
23
+ <span className="block">
24
+ {diagnosis || 'Check that metrics-server is installed and healthy, and that the v1beta1.metrics.k8s.io APIService is Available.'}
25
+ </span>
26
+ <span className="block">
27
+ Raw error: <span className="font-mono break-words">{rawError}</span>
28
+ </span>
29
+ </span>
30
+ )}
31
+ delay={150}
32
+ position="left"
33
+ >
34
+ <button
35
+ type="button"
36
+ className="inline-flex shrink-0 cursor-help items-center gap-1 text-xs font-medium leading-5 text-theme-text-tertiary hover:text-theme-text-secondary"
37
+ aria-label="Metrics error details"
38
+ >
39
+ <Info className="h-3.5 w-3.5" />
40
+ <span>Details</span>
41
+ </button>
42
+ </Tooltip>
43
+ )}
44
+ </div>
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { NodeRenderer } from './NodeRenderer'
4
+
5
+ const node = {
6
+ metadata: { name: 'kind-worker', labels: {} },
7
+ spec: {},
8
+ status: {
9
+ nodeInfo: {
10
+ osImage: 'Container-Optimized OS',
11
+ architecture: 'amd64',
12
+ kernelVersion: '6.1.0',
13
+ containerRuntimeVersion: 'containerd://1.7',
14
+ kubeletVersion: 'v1.33.0',
15
+ kubeProxyVersion: 'v1.33.0',
16
+ },
17
+ capacity: { cpu: '4', memory: '8Gi', pods: '110' },
18
+ allocatable: { cpu: '4', memory: '7Gi', pods: '110' },
19
+ conditions: [],
20
+ },
21
+ }
22
+
23
+ describe('NodeRenderer metrics', () => {
24
+ it('renders a calm unavailable state when metrics-server is absent', () => {
25
+ const html = renderToString(
26
+ <NodeRenderer
27
+ data={node}
28
+ metricsUnavailable
29
+ metricsHistory={{
30
+ dataPoints: [],
31
+ metricsUnavailableReason: 'the server could not find the requested resource',
32
+ }}
33
+ />,
34
+ )
35
+
36
+ expect(html).toContain('Resource Usage')
37
+ expect(html).toContain('Metrics unavailable')
38
+ expect(html).toContain('Radar cannot read metrics.k8s.io')
39
+ expect(html).toContain('Metrics error details')
40
+ expect(html).not.toContain('Install or repair metrics-server and its APIService')
41
+ expect(html).not.toContain('Metrics collection error')
42
+ expect(html).not.toContain('Collecting metrics data')
43
+ })
44
+
45
+ it('does not let stale live metrics hide an unavailable state', () => {
46
+ const html = renderToString(
47
+ <NodeRenderer
48
+ data={node}
49
+ metricsUnavailable
50
+ metrics={{ usage: { cpu: '100m', memory: '256Mi' }, timestamp: '2026-06-30T00:00:00Z' }}
51
+ />,
52
+ )
53
+
54
+ expect(html).toContain('Metrics unavailable')
55
+ expect(html).not.toContain('100m')
56
+ expect(html).not.toContain('Last updated')
57
+ })
58
+
59
+ it('keeps buffered historical charts visible under an unavailable notice', () => {
60
+ const html = renderToString(
61
+ <NodeRenderer
62
+ data={node}
63
+ metricsUnavailable
64
+ metricsHistory={{
65
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
66
+ }}
67
+ />,
68
+ )
69
+
70
+ expect(html).toContain('Metrics unavailable')
71
+ expect(html).toContain('CPU')
72
+ expect(html).toContain('Memory')
73
+ expect(html).not.toContain('Last updated')
74
+ })
75
+
76
+ it('keeps non-absence collection errors visible', () => {
77
+ const html = renderToString(
78
+ <NodeRenderer
79
+ data={node}
80
+ metricsHistory={{ dataPoints: [], collectionError: 'forbidden: no access to nodes' }}
81
+ />,
82
+ )
83
+
84
+ expect(html).toContain('Metrics collection error')
85
+ expect(html).toContain('forbidden: no access to nodes')
86
+ })
87
+
88
+ it('warns about non-absence collection errors even when historical samples exist', () => {
89
+ const html = renderToString(
90
+ <NodeRenderer
91
+ data={node}
92
+ metricsHistory={{
93
+ collectionError: 'forbidden: no access to nodes',
94
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
95
+ }}
96
+ />,
97
+ )
98
+
99
+ expect(html).toContain('Metrics collection error')
100
+ expect(html).toContain('forbidden: no access to nodes')
101
+ expect(html).toContain('CPU')
102
+ })
103
+ })
@@ -5,13 +5,15 @@ import { MetricsChart } from '../../ui/MetricsChart'
5
5
  import { formatMemoryString } from '../../../utils/format'
6
6
  import { getExtendedCapacityRows } from '../../../utils/extended-resources'
7
7
  import type { MetricsDataPoint } from '../../../types/core'
8
+ import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
8
9
 
9
10
  interface NodeRendererProps {
10
11
  data: any
11
12
  relationships?: { pods?: any[] }
12
13
  onViewPods?: () => void
13
14
  metrics?: { usage?: { cpu: string; memory: string }; timestamp?: string }
14
- metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string }
15
+ metricsHistory?: { dataPoints?: MetricsDataPoint[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
16
+ metricsUnavailable?: boolean
15
17
  hideMetricsServer?: boolean
16
18
  }
17
19
 
@@ -59,7 +61,7 @@ function getNodeProblems(data: any): string[] {
59
61
  return problems
60
62
  }
61
63
 
62
- export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, hideMetricsServer }: NodeRendererProps) {
64
+ export function NodeRenderer({ data, relationships, onViewPods, metrics, metricsHistory, metricsUnavailable, hideMetricsServer }: NodeRendererProps) {
63
65
  const status = data.status || {}
64
66
  const spec = data.spec || {}
65
67
  const metadata = data.metadata || {}
@@ -74,6 +76,9 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
74
76
  const problems = getNodeProblems(data)
75
77
  const hasProblems = problems.length > 0
76
78
  const isCordoned = !!spec.unschedulable
79
+ const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
80
+ const hasMetricsHistory = !!metricsHistory?.dataPoints?.length
81
+ const currentMetrics = metricsUnavailable ? undefined : metrics
77
82
 
78
83
  // Extract platform info from labels
79
84
  const instanceType = labels['node.kubernetes.io/instance-type']
@@ -122,13 +127,13 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
122
127
  label: 'CPU',
123
128
  capacity: capacity.cpu,
124
129
  allocatable: allocatable.cpu,
125
- inUse: metrics?.usage?.cpu,
130
+ inUse: currentMetrics?.usage?.cpu,
126
131
  },
127
132
  {
128
133
  label: 'Memory',
129
134
  capacity: formatMemory(capacity.memory),
130
135
  allocatable: formatMemory(allocatable.memory),
131
- inUse: metrics?.usage?.memory ? formatMemory(metrics.usage.memory) : undefined,
136
+ inUse: currentMetrics?.usage?.memory ? formatMemory(currentMetrics.usage.memory) : undefined,
132
137
  },
133
138
  {
134
139
  label: 'Pods',
@@ -182,15 +187,18 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
182
187
  </Section>
183
188
 
184
189
  {/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
185
- {!hideMetricsServer && (metrics?.usage || metricsHistory?.dataPoints?.length || metricsHistory?.collectionError) && (
190
+ {!hideMetricsServer && (currentMetrics?.usage || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
186
191
  <Section title="Resource Usage" icon={Activity} defaultExpanded>
187
- {metricsHistory?.collectionError && !metricsHistory?.dataPoints?.length && (
192
+ {showMetricsUnavailable && (
193
+ <MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
194
+ )}
195
+ {metricsHistory?.collectionError && (
188
196
  <div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
189
197
  <span className="font-medium">Metrics collection error:</span>{' '}
190
198
  <span className="break-all">{metricsHistory.collectionError}</span>
191
199
  </div>
192
200
  )}
193
- {metricsHistory?.dataPoints && metricsHistory.dataPoints.length > 0 ? (
201
+ {hasMetricsHistory && metricsHistory?.dataPoints ? (
194
202
  <div className="space-y-4">
195
203
  {/* CPU Usage with Chart */}
196
204
  <div className="card-inner-lg">
@@ -224,7 +232,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
224
232
  />
225
233
  </div>
226
234
  </div>
227
- ) : metrics?.usage ? (
235
+ ) : showMetricsUnavailable ? null : currentMetrics?.usage ? (
228
236
  /* Fallback to simple display if no history yet */
229
237
  <div className="space-y-3">
230
238
  <div className="card-inner-lg">
@@ -232,7 +240,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
232
240
  <span className="text-sm font-medium text-theme-text-primary">CPU</span>
233
241
  </div>
234
242
  <div className="flex items-baseline gap-2">
235
- <span className="text-lg font-medium text-blue-400">{metrics.usage.cpu}</span>
243
+ <span className="text-lg font-medium text-blue-400">{currentMetrics.usage.cpu}</span>
236
244
  <span className="text-sm text-theme-text-tertiary">
237
245
  / {allocatable.cpu || capacity.cpu || '?'} allocatable
238
246
  </span>
@@ -243,7 +251,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
243
251
  <span className="text-sm font-medium text-theme-text-primary">Memory</span>
244
252
  </div>
245
253
  <div className="flex items-baseline gap-2">
246
- <span className="text-lg font-medium text-purple-400">{formatMemory(metrics.usage.memory)}</span>
254
+ <span className="text-lg font-medium text-purple-400">{formatMemory(currentMetrics.usage.memory)}</span>
247
255
  <span className="text-sm text-theme-text-tertiary">
248
256
  / {formatMemory(allocatable.memory) || formatMemory(capacity.memory) || '?'} allocatable
249
257
  </span>
@@ -253,9 +261,9 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
253
261
  ) : (
254
262
  <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
255
263
  )}
256
- {metrics?.timestamp && (
264
+ {!showMetricsUnavailable && currentMetrics?.timestamp && (
257
265
  <div className="mt-2 text-xs text-theme-text-tertiary">
258
- Last updated: {new Date(metrics.timestamp).toLocaleTimeString()}
266
+ Last updated: {new Date(currentMetrics.timestamp).toLocaleTimeString()}
259
267
  </div>
260
268
  )}
261
269
  </Section>
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { PVCRenderer } from './PVCRenderer'
4
+
5
+ describe('PVCRenderer', () => {
6
+ it('does not reassure indefinitely pending PVCs as normal', () => {
7
+ const html = renderToString(
8
+ <PVCRenderer
9
+ data={{
10
+ metadata: { name: 'data', namespace: 'demo' },
11
+ spec: { storageClassName: 'standard' },
12
+ status: { phase: 'Pending' },
13
+ }}
14
+ />,
15
+ )
16
+
17
+ expect(html).toContain('Pending')
18
+ expect(html).toContain('not yet bound')
19
+ expect(html).toContain('check the StorageClass')
20
+ expect(html).not.toContain('This is normal')
21
+ expect(html).not.toContain('expected indefinitely')
22
+ })
23
+ })
@@ -53,8 +53,8 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
53
53
  {isPending && (
54
54
  <AlertBanner
55
55
  variant="info"
56
- title="Pending — awaiting binding"
57
- message="Not yet bound to a volume. This is normal while provisioning, and expected indefinitely for a WaitForFirstConsumer StorageClass until a Pod that mounts this claim is scheduled."
56
+ title="Pending — not yet bound"
57
+ message="A PVC stays Pending while its volume is provisioned, or for a WaitForFirstConsumer StorageClass until a Pod that mounts it is scheduled. If it stays Pending, check the StorageClass, its provisioner, and storage quota."
58
58
  />
59
59
  )}
60
60
 
@@ -107,3 +107,104 @@ describe('PodRenderer issues banner', () => {
107
107
  expect(html).toContain('min-w-0 break-words')
108
108
  })
109
109
  })
110
+
111
+ describe('PodRenderer metrics', () => {
112
+ it('renders a calm unavailable state when metrics-server is absent', () => {
113
+ const html = renderToString(
114
+ <PodRenderer
115
+ data={pod}
116
+ onCopy={() => undefined}
117
+ copied={null}
118
+ metricsUnavailable
119
+ metricsHistory={{
120
+ containers: [],
121
+ metricsUnavailableReason: 'the server could not find the requested resource',
122
+ }}
123
+ />,
124
+ )
125
+
126
+ expect(html).toContain('Resource Usage')
127
+ expect(html).toContain('Metrics unavailable')
128
+ expect(html).toContain('Radar cannot read metrics.k8s.io')
129
+ expect(html).toContain('Metrics error details')
130
+ expect(html).not.toContain('Install or repair metrics-server and its APIService')
131
+ expect(html).not.toContain('Metrics collection error')
132
+ expect(html).not.toContain('Collecting metrics data')
133
+ })
134
+
135
+ it('does not let stale live metrics hide an unavailable state', () => {
136
+ const html = renderToString(
137
+ <PodRenderer
138
+ data={pod}
139
+ onCopy={() => undefined}
140
+ copied={null}
141
+ metricsUnavailable
142
+ metrics={{
143
+ containers: [{ name: 'api', usage: { cpu: '100m', memory: '256Mi' } }],
144
+ timestamp: '2026-06-30T00:00:00Z',
145
+ }}
146
+ />,
147
+ )
148
+
149
+ expect(html).toContain('Metrics unavailable')
150
+ expect(html).not.toContain('100m')
151
+ expect(html).not.toContain('Last updated')
152
+ })
153
+
154
+ it('keeps buffered historical charts visible under an unavailable notice', () => {
155
+ const html = renderToString(
156
+ <PodRenderer
157
+ data={pod}
158
+ onCopy={() => undefined}
159
+ copied={null}
160
+ metricsUnavailable
161
+ metricsHistory={{
162
+ containers: [{
163
+ name: 'api',
164
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
165
+ }],
166
+ }}
167
+ />,
168
+ )
169
+
170
+ expect(html).toContain('Metrics unavailable')
171
+ expect(html).toContain('CPU')
172
+ expect(html).toContain('Memory')
173
+ expect(html).not.toContain('Last updated')
174
+ })
175
+
176
+ it('keeps non-absence collection errors visible', () => {
177
+ const html = renderToString(
178
+ <PodRenderer
179
+ data={pod}
180
+ onCopy={() => undefined}
181
+ copied={null}
182
+ metricsHistory={{ containers: [], collectionError: 'forbidden: no access to pods.metrics.k8s.io' }}
183
+ />,
184
+ )
185
+
186
+ expect(html).toContain('Metrics collection error')
187
+ expect(html).toContain('forbidden: no access to pods.metrics.k8s.io')
188
+ })
189
+
190
+ it('warns about non-absence collection errors even when historical samples exist', () => {
191
+ const html = renderToString(
192
+ <PodRenderer
193
+ data={pod}
194
+ onCopy={() => undefined}
195
+ copied={null}
196
+ metricsHistory={{
197
+ collectionError: 'forbidden: no access to pods.metrics.k8s.io',
198
+ containers: [{
199
+ name: 'api',
200
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
201
+ }],
202
+ }}
203
+ />,
204
+ )
205
+
206
+ expect(html).toContain('Metrics collection error')
207
+ expect(html).toContain('forbidden: no access to pods.metrics.k8s.io')
208
+ expect(html).toContain('CPU')
209
+ })
210
+ })
@@ -15,6 +15,7 @@ import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
15
15
  import type { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
16
16
  import { Tooltip } from '../../ui/Tooltip'
17
17
  import { MetricsChart } from '../../ui/MetricsChart'
18
+ import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
18
19
 
19
20
  function parseValidDate(dateStr: string): Date | null {
20
21
  const d = new Date(dateStr)
@@ -73,7 +74,8 @@ interface PodRendererProps {
73
74
  renderPortAction?: (props: { namespace: string; podName: string; port: number; protocol: string; disabled?: boolean }) => ReactNode
74
75
  // Metrics data injection
75
76
  metrics?: { containers?: any[]; timestamp?: string }
76
- metricsHistory?: { containers?: any[]; collectionError?: string }
77
+ metricsHistory?: { containers?: any[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
78
+ metricsUnavailable?: boolean
77
79
  hideMetricsServer?: boolean
78
80
  // Filesystem browser render props
79
81
  renderImageBrowser?: (props: { image: string; namespace: string; podName: string; pullSecrets: string[]; onClose: () => void; onSwitchToPodFiles?: () => void }) => ReactNode
@@ -257,6 +259,7 @@ export function PodRenderer({
257
259
  renderPortAction,
258
260
  metrics,
259
261
  metricsHistory,
262
+ metricsUnavailable,
260
263
  hideMetricsServer,
261
264
  renderImageBrowser,
262
265
  renderPodBrowser,
@@ -280,6 +283,9 @@ export function PodRenderer({
280
283
  const operationalIssuesShown = useOperationalIssuesShown()
281
284
  const podProblems = getPodProblems(data)
282
285
  const hasProblems = podProblems.length > 0 && !operationalIssuesShown
286
+ const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
287
+ const hasMetricsHistory = !!metricsHistory?.containers?.length
288
+ const currentMetrics = metricsUnavailable ? undefined : metrics
283
289
 
284
290
  // Image filesystem modal state
285
291
  const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -718,89 +724,94 @@ export function PodRenderer({
718
724
  )}
719
725
 
720
726
  {/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
721
- {!hideMetricsServer && !!(metrics?.containers?.length || metricsHistory?.containers?.length || metricsHistory?.collectionError) && (
727
+ {!hideMetricsServer && !!(currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
722
728
  <Section title="Resource Usage" icon={Activity} defaultExpanded>
723
- {metricsHistory?.collectionError && !metricsHistory?.containers?.length && (
729
+ {showMetricsUnavailable && (
730
+ <MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
731
+ )}
732
+ {metricsHistory?.collectionError && (
724
733
  <div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
725
734
  <span className="font-medium">Metrics collection error:</span>{' '}
726
735
  <span className="break-all">{metricsHistory.collectionError}</span>
727
736
  </div>
728
737
  )}
729
- <div className="space-y-4">
730
- {(metricsHistory?.containers || metrics?.containers || []).map((historyContainer) => {
731
- // Find current metrics for this container
732
- const currentMetrics = metrics?.containers?.find(c => c.name === historyContainer.name)
733
- // Find the container spec to compare against limits
734
- const containerSpec = containers.find((c: any) => c.name === historyContainer.name)
735
- const limits = containerSpec?.resources?.limits
736
- const requests = containerSpec?.resources?.requests
737
-
738
- // Get historical data points (from history or empty)
739
- const dataPoints = 'dataPoints' in historyContainer ? historyContainer.dataPoints : []
738
+ {(hasMetricsHistory || !showMetricsUnavailable) && (
739
+ <div className="space-y-4">
740
+ {(metricsHistory?.containers || currentMetrics?.containers || []).map((historyContainer) => {
741
+ // Find current metrics for this container
742
+ const currentContainerMetrics = currentMetrics?.containers?.find(c => c.name === historyContainer.name)
743
+ // Find the container spec to compare against limits
744
+ const containerSpec = containers.find((c: any) => c.name === historyContainer.name)
745
+ const limits = containerSpec?.resources?.limits
746
+ const requests = containerSpec?.resources?.requests
740
747
 
741
- return (
742
- <div key={historyContainer.name} className="card-inner-lg">
743
- <div className="flex items-center justify-between mb-3">
744
- <span className="text-sm font-medium text-theme-text-primary">{historyContainer.name}</span>
745
- </div>
748
+ // Get historical data points (from history or empty)
749
+ const dataPoints = 'dataPoints' in historyContainer ? historyContainer.dataPoints : []
746
750
 
747
- {dataPoints && dataPoints.length > 0 ? (
748
- <div className="grid grid-cols-2 gap-6">
749
- <div>
750
- <div className="text-xs text-theme-text-tertiary mb-2">CPU</div>
751
- <MetricsChart
752
- dataPoints={dataPoints}
753
- type="cpu"
754
- height={80}
755
- showAxis={true}
756
- limit={limits?.cpu}
757
- request={requests?.cpu}
758
- />
759
- </div>
760
- <div>
761
- <div className="text-xs text-theme-text-tertiary mb-2">Memory</div>
762
- <MetricsChart
763
- dataPoints={dataPoints}
764
- type="memory"
765
- height={80}
766
- showAxis={true}
767
- limit={limits?.memory}
768
- request={requests?.memory}
769
- />
770
- </div>
751
+ return (
752
+ <div key={historyContainer.name} className="card-inner-lg">
753
+ <div className="flex items-center justify-between mb-3">
754
+ <span className="text-sm font-medium text-theme-text-primary">{historyContainer.name}</span>
771
755
  </div>
772
- ) : currentMetrics ? (
773
- /* Fallback to simple display if no history yet */
774
- <div className="grid grid-cols-2 gap-4 text-xs">
775
- <div>
776
- <div className="text-theme-text-tertiary mb-1">CPU</div>
777
- <div className="flex items-baseline gap-1">
778
- <span className="text-sm font-medium text-blue-400">{currentMetrics.usage.cpu}</span>
779
- {limits?.cpu && (
780
- <span className="text-theme-text-tertiary">/ {limits.cpu} limit</span>
781
- )}
756
+
757
+ {dataPoints && dataPoints.length > 0 ? (
758
+ <div className="grid grid-cols-2 gap-6">
759
+ <div>
760
+ <div className="text-xs text-theme-text-tertiary mb-2">CPU</div>
761
+ <MetricsChart
762
+ dataPoints={dataPoints}
763
+ type="cpu"
764
+ height={80}
765
+ showAxis={true}
766
+ limit={limits?.cpu}
767
+ request={requests?.cpu}
768
+ />
769
+ </div>
770
+ <div>
771
+ <div className="text-xs text-theme-text-tertiary mb-2">Memory</div>
772
+ <MetricsChart
773
+ dataPoints={dataPoints}
774
+ type="memory"
775
+ height={80}
776
+ showAxis={true}
777
+ limit={limits?.memory}
778
+ request={requests?.memory}
779
+ />
782
780
  </div>
783
781
  </div>
784
- <div>
785
- <div className="text-theme-text-tertiary mb-1">Memory</div>
786
- <div className="flex items-baseline gap-1">
787
- <span className="text-sm font-medium text-purple-400">{currentMetrics.usage.memory}</span>
788
- {limits?.memory && (
789
- <span className="text-theme-text-tertiary">/ {limits.memory} limit</span>
790
- )}
782
+ ) : currentContainerMetrics ? (
783
+ /* Fallback to simple display if no history yet */
784
+ <div className="grid grid-cols-2 gap-4 text-xs">
785
+ <div>
786
+ <div className="text-theme-text-tertiary mb-1">CPU</div>
787
+ <div className="flex items-baseline gap-1">
788
+ <span className="text-sm font-medium text-blue-400">{currentContainerMetrics.usage.cpu}</span>
789
+ {limits?.cpu && (
790
+ <span className="text-theme-text-tertiary">/ {limits.cpu} limit</span>
791
+ )}
792
+ </div>
793
+ </div>
794
+ <div>
795
+ <div className="text-theme-text-tertiary mb-1">Memory</div>
796
+ <div className="flex items-baseline gap-1">
797
+ <span className="text-sm font-medium text-purple-400">{currentContainerMetrics.usage.memory}</span>
798
+ {limits?.memory && (
799
+ <span className="text-theme-text-tertiary">/ {limits.memory} limit</span>
800
+ )}
801
+ </div>
791
802
  </div>
792
803
  </div>
793
- </div>
794
- ) : (
795
- <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
796
- )}
797
- </div>
798
- )
799
- })}
800
- </div>
801
- {metrics?.timestamp && (
804
+ ) : (
805
+ <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
806
+ )}
807
+ </div>
808
+ )
809
+ })}
810
+ </div>
811
+ )}
812
+ {!showMetricsUnavailable && currentMetrics?.timestamp && (
802
813
  <div className="mt-2 text-xs text-theme-text-tertiary">
803
- Last updated: {new Date(metrics.timestamp).toLocaleTimeString()}
814
+ Last updated: {new Date(currentMetrics.timestamp).toLocaleTimeString()}
804
815
  </div>
805
816
  )}
806
817
  </Section>
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react'
2
2
  import { Bell, Search, ChevronRight } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
+ import { Input } from '../../ui/Input'
5
6
  import { BADGE_SEVERITY_COLORS } from '../../ui/Badge'
6
7
  import {
7
8
  getPrometheusRuleGroups,
@@ -190,8 +191,7 @@ export function PrometheusRuleRenderer({ data }: PrometheusRuleRendererProps) {
190
191
  {totalRules > 5 && (
191
192
  <div className="relative mb-3">
192
193
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
193
- <input
194
- type="text"
194
+ <Input
195
195
  placeholder="Filter rules by name or expression..."
196
196
  value={searchTerm}
197
197
  onChange={(e) => setSearchTerm(e.target.value)}
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { Package, ChevronDown, ChevronRight, Search } from 'lucide-react'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import { Input } from '../../ui/Input'
4
5
  import { formatAge } from '../resource-utils'
5
6
  import { formatTrivyImage } from './trivy-shared'
6
7
 
@@ -74,8 +75,7 @@ export function SbomReportRenderer({ data }: SbomReportRendererProps) {
74
75
  <div className="flex items-center gap-2 mb-2 px-1">
75
76
  <div className="relative flex-1">
76
77
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-theme-text-tertiary" />
77
- <input
78
- type="text"
78
+ <Input
79
79
  placeholder="Filter by name, type, or purl..."
80
80
  value={searchTerm}
81
81
  onChange={(e) => { setSearchTerm(e.target.value); setShowAll(false) }}