@skyhook-io/k8s-ui 1.5.10 → 1.5.12

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/components/audit/AuditFindingsTable.tsx +66 -30
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
  4. package/src/components/dock/BottomDock.tsx +23 -12
  5. package/src/components/gitops/ManagedResourcesList.tsx +4 -2
  6. package/src/components/logs/LogCore.tsx +109 -14
  7. package/src/components/resources/ResourcesView.tsx +19 -2
  8. package/src/components/resources/get-pod-phase-display.test.ts +254 -0
  9. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
  10. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
  11. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
  12. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
  13. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  14. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
  15. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  16. package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
  18. package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
  19. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
  21. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
  22. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
  24. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
  25. package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
  26. package/src/components/resources/renderers/PodRenderer.tsx +23 -2
  27. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
  28. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
  29. package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
  30. package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
  31. package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
  32. package/src/components/resources/renderers/istio-cells.tsx +3 -2
  33. package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
  34. package/src/components/resources/resource-utils-karpenter.ts +0 -6
  35. package/src/components/resources/resource-utils.ts +110 -0
  36. package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
  37. package/src/components/timeline/shared.tsx +5 -6
  38. package/src/components/topology/TopologySearch.tsx +49 -16
  39. package/src/components/topology/topology-search-match.test.ts +112 -0
  40. package/src/components/ui/Tooltip.tsx +73 -40
  41. package/src/components/ui/tooltip-position.test.ts +132 -0
  42. package/src/components/ui/tooltip-position.ts +86 -0
  43. package/src/components/workload/ResourceDetailDrawer.tsx +3 -3
  44. package/src/components/workload/WorkloadView.tsx +6 -6
  45. package/src/types/core.ts +13 -1
  46. package/src/utils/badge-colors.ts +19 -0
  47. package/src/utils/index.ts +2 -0
  48. package/src/utils/validators.test.ts +207 -0
  49. package/src/utils/validators.ts +178 -0
  50. package/src/utils/view-transition.test.ts +81 -0
  51. package/src/utils/view-transition.ts +62 -0
@@ -156,7 +156,7 @@ const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
156
156
 
157
157
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
158
158
  const SKIP_FILTER_COLUMNS = new Set([
159
- 'name', 'namespace', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
159
+ 'name', 'age', 'keys', 'size', 'images', 'domains', 'hosts', 'rules',
160
160
  'ports', 'message', 'url', 'ref', 'revision', 'path', 'selector', 'ready', 'restarts',
161
161
  'completions', 'duration', 'schedule', 'lastRun', 'target', 'replicas', 'metrics',
162
162
  'capacity', 'accessModes', 'volume', 'step', 'progress', 'template', 'expires',
@@ -3471,7 +3471,7 @@ export function ResourcesView({
3471
3471
  {lastUpdated && (
3472
3472
  <div className="flex items-center gap-1.5 text-xs text-theme-text-tertiary">
3473
3473
  <Clock className="w-3.5 h-3.5" />
3474
- <span>Updated {formatAge(lastUpdated.toISOString())}</span>
3474
+ <span>Updated <span className="inline-block min-w-[4ch] tabular-nums">{formatAge(lastUpdated.toISOString())}</span></span>
3475
3475
  </div>
3476
3476
  )}
3477
3477
  {/* Column picker */}
@@ -3578,6 +3578,23 @@ export function ResourcesView({
3578
3578
  <X className="w-3.5 h-3.5" />
3579
3579
  </button>
3580
3580
  )}
3581
+ {/*
3582
+ The sidebar's count badge shows the cluster-wide
3583
+ total for the selected kind (from resourceCounts) and
3584
+ deliberately stays unfiltered. When a search yields
3585
+ zero results the user can think the badge is lying.
3586
+ Spell out that the badge is the cluster total, not
3587
+ the filtered count.
3588
+ */}
3589
+ {searchTerm && (() => {
3590
+ const totalForKind = counts[selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind] ?? 0
3591
+ if (totalForKind === 0) return null
3592
+ return (
3593
+ <p className="text-xs mt-1 text-theme-text-disabled">
3594
+ The sidebar shows {pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.
3595
+ </p>
3596
+ )
3597
+ })()}
3581
3598
  {namespaces.length > 0 && <p className="text-sm mt-1 text-theme-text-disabled">Searching in {namespaces.length === 1 ? `namespace: ${namespaces[0]}` : `${namespaces.length} namespaces`}</p>}
3582
3599
  {/* Show active filters as dismissible badges so user can clear them */}
3583
3600
  {(() => {
@@ -0,0 +1,254 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getPodPhaseDisplay } from './resource-utils'
3
+
4
+ const podRunningHealthy = {
5
+ status: {
6
+ phase: 'Running',
7
+ containerStatuses: [{ name: 'app', ready: true, restartCount: 0 }],
8
+ },
9
+ }
10
+
11
+ const podRunningNotReady = {
12
+ status: {
13
+ phase: 'Running',
14
+ containerStatuses: [{ name: 'app', ready: false, restartCount: 0 }],
15
+ },
16
+ }
17
+
18
+ const podRunningCycling = {
19
+ status: {
20
+ phase: 'Running',
21
+ containerStatuses: [{ name: 'app', ready: true, restartCount: 6301 }],
22
+ },
23
+ }
24
+
25
+ const podRunningNotReadyAndCycling = {
26
+ status: {
27
+ phase: 'Running',
28
+ containerStatuses: [{ name: 'app', ready: false, restartCount: 6301 }],
29
+ },
30
+ }
31
+
32
+ const podCrashLooping = {
33
+ status: {
34
+ phase: 'Running',
35
+ containerStatuses: [
36
+ {
37
+ name: 'app',
38
+ ready: false,
39
+ restartCount: 12,
40
+ state: { waiting: { reason: 'CrashLoopBackOff' } },
41
+ },
42
+ ],
43
+ },
44
+ }
45
+
46
+ const podImagePullBackOff = {
47
+ status: {
48
+ phase: 'Pending',
49
+ containerStatuses: [
50
+ {
51
+ name: 'app',
52
+ ready: false,
53
+ restartCount: 0,
54
+ state: { waiting: { reason: 'ImagePullBackOff' } },
55
+ },
56
+ ],
57
+ },
58
+ }
59
+
60
+ const podOOMKilled = {
61
+ status: {
62
+ phase: 'Running',
63
+ containerStatuses: [
64
+ {
65
+ name: 'app',
66
+ ready: false,
67
+ restartCount: 3,
68
+ state: { terminated: { reason: 'OOMKilled', exitCode: 137 } },
69
+ },
70
+ ],
71
+ },
72
+ }
73
+
74
+ const podTerminating = {
75
+ metadata: { deletionTimestamp: '2026-04-29T10:00:00Z' },
76
+ status: {
77
+ phase: 'Running',
78
+ containerStatuses: [{ name: 'app', ready: true, restartCount: 0 }],
79
+ },
80
+ }
81
+
82
+ const podErrImagePull = {
83
+ status: {
84
+ phase: 'Pending',
85
+ containerStatuses: [
86
+ {
87
+ name: 'app',
88
+ ready: false,
89
+ restartCount: 0,
90
+ state: { waiting: { reason: 'ErrImagePull' } },
91
+ },
92
+ ],
93
+ },
94
+ }
95
+
96
+ const podCreateContainerConfigError = {
97
+ status: {
98
+ phase: 'Pending',
99
+ containerStatuses: [
100
+ {
101
+ name: 'app',
102
+ ready: false,
103
+ restartCount: 0,
104
+ state: { waiting: { reason: 'CreateContainerConfigError' } },
105
+ },
106
+ ],
107
+ },
108
+ }
109
+
110
+ const podMultiContainerPartiallyReady = {
111
+ status: {
112
+ phase: 'Running',
113
+ containerStatuses: [
114
+ { name: 'app', ready: true, restartCount: 0 },
115
+ { name: 'sidecar', ready: false, restartCount: 0 },
116
+ { name: 'proxy', ready: true, restartCount: 0 },
117
+ ],
118
+ },
119
+ }
120
+
121
+ const podMultiContainerCyclingAcrossContainers = {
122
+ status: {
123
+ phase: 'Running',
124
+ containerStatuses: [
125
+ { name: 'app', ready: true, restartCount: 3 },
126
+ { name: 'sidecar', ready: true, restartCount: 4 },
127
+ ],
128
+ },
129
+ }
130
+
131
+ const podSucceededWithOOMKilledSidecar = {
132
+ status: {
133
+ phase: 'Succeeded',
134
+ containerStatuses: [
135
+ { name: 'main', ready: false, restartCount: 0, state: { terminated: { reason: 'Completed', exitCode: 0 } } },
136
+ { name: 'sidecar', ready: false, restartCount: 1, state: { terminated: { reason: 'OOMKilled', exitCode: 137 } } },
137
+ ],
138
+ },
139
+ }
140
+
141
+ describe('getPodPhaseDisplay', () => {
142
+ it('returns Running, healthy for a fully-ready pod with no restarts', () => {
143
+ const r = getPodPhaseDisplay(podRunningHealthy)
144
+ expect(r.phase).toBe('Running')
145
+ expect(r.text).toBe('Running')
146
+ expect(r.level).toBe('healthy')
147
+ expect(r.hint).toBeUndefined()
148
+ })
149
+
150
+ it('downgrades a Running pod that is not ready (the "0/1" case)', () => {
151
+ const r = getPodPhaseDisplay(podRunningNotReady)
152
+ expect(r.phase).toBe('Running')
153
+ expect(r.text).toBe('Running — Not Ready (0/1)')
154
+ expect(r.level).toBe('degraded')
155
+ expect(r.hint).toMatch(/not ready/i)
156
+ })
157
+
158
+ it('downgrades a Running pod with high restarts even if currently ready', () => {
159
+ const r = getPodPhaseDisplay(podRunningCycling)
160
+ expect(r.text).toBe('Running — Restarting (6301 restarts)')
161
+ expect(r.level).toBe('degraded')
162
+ })
163
+
164
+ it('marks a Running + Not Ready + cycling pod as unhealthy', () => {
165
+ const r = getPodPhaseDisplay(podRunningNotReadyAndCycling)
166
+ expect(r.text).toContain('Not Ready (0/1)')
167
+ expect(r.text).toContain('6301 restarts')
168
+ expect(r.level).toBe('unhealthy')
169
+ })
170
+
171
+ it('always preserves the raw phase field so kubectl users can correlate', () => {
172
+ expect(getPodPhaseDisplay(podRunningNotReady).phase).toBe('Running')
173
+ expect(getPodPhaseDisplay(podCrashLooping).phase).toBe('Running')
174
+ expect(getPodPhaseDisplay(podImagePullBackOff).phase).toBe('Pending')
175
+ })
176
+
177
+ it('flags CrashLoopBackOff as unhealthy with phase preserved', () => {
178
+ const r = getPodPhaseDisplay(podCrashLooping)
179
+ expect(r.text).toBe('Running — CrashLoopBackOff')
180
+ expect(r.level).toBe('unhealthy')
181
+ })
182
+
183
+ it('flags ImagePullBackOff as unhealthy on a Pending pod', () => {
184
+ const r = getPodPhaseDisplay(podImagePullBackOff)
185
+ expect(r.text).toBe('Pending — ImagePullBackOff')
186
+ expect(r.level).toBe('unhealthy')
187
+ })
188
+
189
+ it('flags OOMKilled even when phase is Running', () => {
190
+ const r = getPodPhaseDisplay(podOOMKilled)
191
+ expect(r.text).toBe('Running — OOMKilled')
192
+ expect(r.level).toBe('unhealthy')
193
+ })
194
+
195
+ it('marks pods with deletionTimestamp as Terminating regardless of phase', () => {
196
+ const r = getPodPhaseDisplay(podTerminating)
197
+ expect(r.text).toBe('Running — Terminating')
198
+ expect(r.level).toBe('degraded')
199
+ })
200
+
201
+ it('falls back to Unknown for missing/unknown phase', () => {
202
+ expect(getPodPhaseDisplay({}).level).toBe('unknown')
203
+ expect(getPodPhaseDisplay({}).phase).toBe('Unknown')
204
+ expect(getPodPhaseDisplay({ status: { phase: 'Banana' } }).level).toBe('unknown')
205
+ })
206
+
207
+ it('handles Succeeded/Pending/Failed phases', () => {
208
+ expect(getPodPhaseDisplay({ status: { phase: 'Succeeded' } }).level).toBe('neutral')
209
+ expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('degraded')
210
+ expect(getPodPhaseDisplay({ status: { phase: 'Failed' } }).level).toBe('unhealthy')
211
+ })
212
+
213
+ it('flags ErrImagePull as unhealthy', () => {
214
+ const r = getPodPhaseDisplay(podErrImagePull)
215
+ expect(r.text).toBe('Pending — ErrImagePull')
216
+ expect(r.level).toBe('unhealthy')
217
+ })
218
+
219
+ it('flags CreateContainerConfigError as unhealthy', () => {
220
+ const r = getPodPhaseDisplay(podCreateContainerConfigError)
221
+ expect(r.text).toBe('Pending — CreateContainerConfigError')
222
+ expect(r.level).toBe('unhealthy')
223
+ })
224
+
225
+ it('reports the correct readiness ratio for multi-container pods', () => {
226
+ const r = getPodPhaseDisplay(podMultiContainerPartiallyReady)
227
+ expect(r.text).toBe('Running — Not Ready (2/3)')
228
+ expect(r.level).toBe('degraded')
229
+ })
230
+
231
+ it('sums restartCount across containers when deciding cycling', () => {
232
+ const r = getPodPhaseDisplay(podMultiContainerCyclingAcrossContainers)
233
+ expect(r.text).toBe('Running — Restarting (7 restarts)')
234
+ expect(r.level).toBe('degraded')
235
+ })
236
+
237
+ it('does not flag a Succeeded Job pod as unhealthy when a sidecar OOMed', () => {
238
+ const r = getPodPhaseDisplay(podSucceededWithOOMKilledSidecar)
239
+ expect(r.phase).toBe('Succeeded')
240
+ expect(r.text).toBe('Completed')
241
+ expect(r.level).toBe('neutral')
242
+ })
243
+
244
+ it('does not mistake exactly-RESTART_CYCLING_THRESHOLD restarts for cycling (boundary)', () => {
245
+ const r = getPodPhaseDisplay({
246
+ status: {
247
+ phase: 'Running',
248
+ containerStatuses: [{ name: 'app', ready: true, restartCount: 5 }],
249
+ },
250
+ })
251
+ expect(r.text).toBe('Running')
252
+ expect(r.level).toBe('healthy')
253
+ })
254
+ })
@@ -3,6 +3,7 @@ import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
4
  import { getCAPIConditions } from '../resource-utils-capi'
5
5
  import { getAWSMMPStatus, getAWSMMPInstanceType, getAWSMMPCapacityType, getAWSMMPAMIType, getAWSMMPNodegroupName, getAWSMMPScaling } from '../resource-utils-aws-capi'
6
+ import { CAPACITY_TYPE_BADGE } from '../../../utils/badge-colors'
6
7
 
7
8
  interface Props {
8
9
  data: any
@@ -39,10 +40,7 @@ export function AWSManagedMachinePoolRenderer({ data }: Props) {
39
40
  <Property label="Instance Type" value={getAWSMMPInstanceType(data)} />
40
41
  <Property label="AMI Type" value={getAWSMMPAMIType(data)} />
41
42
  <Property label="Capacity Type" value={
42
- <span className={clsx('badge badge-sm', capacityType === 'spot'
43
- ? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40'
44
- : 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
45
- )}>{capacityType === 'onDemand' ? 'On-Demand' : capacityType}</span>
43
+ <span className={clsx('badge badge-sm', capacityType === 'spot' ? CAPACITY_TYPE_BADGE.spot : CAPACITY_TYPE_BADGE.onDemand)}>{capacityType === 'onDemand' ? 'On-Demand' : capacityType}</span>
46
44
  } />
47
45
  {spec.roleName && <Property label="IAM Role" value={spec.roleName} />}
48
46
  </PropertyList>
@@ -9,6 +9,7 @@ import {
9
9
  type ArgoAppStatus,
10
10
  type ArgoResource,
11
11
  } from '../../../types/gitops'
12
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
12
13
 
13
14
  interface ArgoApplicationRendererProps {
14
15
  data: any
@@ -192,7 +193,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
192
193
  <span
193
194
  className={clsx(
194
195
  'badge',
195
- syncPolicy.automated ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'
196
+ syncPolicy.automated ? 'bg-green-500/20 text-green-400' : BADGE_INACTIVE
196
197
  )}
197
198
  >
198
199
  {syncPolicy.automated ? 'Enabled' : 'Disabled'}
@@ -241,7 +242,7 @@ export function ArgoApplicationRenderer({ data, onTerminate, isTerminating }: Ar
241
242
  ? 'bg-blue-500/20 text-blue-400'
242
243
  : operationState.phase === 'Failed' || operationState.phase === 'Error'
243
244
  ? 'bg-red-500/20 text-red-400'
244
- : 'bg-gray-500/20 text-gray-400'
245
+ : BADGE_INACTIVE
245
246
  )}
246
247
  >
247
248
  {operationState.phase}
@@ -3,6 +3,7 @@ import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
4
  import { getCAPIConditions } from '../resource-utils-capi'
5
5
  import { getAzureMMPStatus, getAzureMMPSKU, getAzureMMPMode, getAzureMMPOSDiskInfo, getAzureMMPScaling, getAzureMMPScaleSetPriority, getAzureMMPOSType } from '../resource-utils-azure-capi'
6
+ import { CAPACITY_TYPE_BADGE, NODEPOOL_MODE_BADGE } from '../../../utils/badge-colors'
6
7
 
7
8
  interface Props {
8
9
  data: any
@@ -34,18 +35,12 @@ export function AzureManagedMachinePoolRenderer({ data }: Props) {
34
35
  {spec.name && <Property label="Pool Name" value={spec.name} />}
35
36
  <Property label="VM Size" value={getAzureMMPSKU(data)} />
36
37
  <Property label="Mode" value={
37
- <span className={clsx('badge badge-sm', mode === 'System'
38
- ? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40'
39
- : 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
40
- )}>{mode}</span>
38
+ <span className={clsx('badge badge-sm', NODEPOOL_MODE_BADGE[mode] || NODEPOOL_MODE_BADGE.User)}>{mode}</span>
41
39
  } />
42
40
  <Property label="OS" value={getAzureMMPOSType(data)} />
43
41
  <Property label="OS Disk" value={getAzureMMPOSDiskInfo(data)} />
44
42
  <Property label="Priority" value={
45
- <span className={clsx('badge badge-sm', priority === 'Spot'
46
- ? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40'
47
- : 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
48
- )}>{priority}</span>
43
+ <span className={clsx('badge badge-sm', priority === 'Spot' ? CAPACITY_TYPE_BADGE.spot : CAPACITY_TYPE_BADGE.regular)}>{priority}</span>
49
44
  } />
50
45
  {spec.maxPods != null && <Property label="Max Pods" value={String(spec.maxPods)} />}
51
46
  </PropertyList>
@@ -2,6 +2,7 @@ import { Shield, AlertTriangle, Globe, Key, FileText } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
4
4
  import { getSolverType } from './ClusterIssuerRenderer'
5
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
5
6
 
6
7
  function getChallengeStateBadge(state: string): { color: string; text: string } {
7
8
  switch (state?.toLowerCase()) {
@@ -20,7 +21,7 @@ function getChallengeStateBadge(state: string): { color: string; text: string }
20
21
  case 'errored':
21
22
  return { text: 'Errored', color: 'bg-red-500/20 text-red-400' }
22
23
  default:
23
- return { text: state || 'Unknown', color: 'bg-gray-500/20 text-gray-400' }
24
+ return { text: state || 'Unknown', color: BADGE_INACTIVE }
24
25
  }
25
26
  }
26
27
 
@@ -5,6 +5,7 @@ import { Section, PropertyList, Property, AlertBanner } from '../../ui/drawer-co
5
5
  import { formatAge } from '../resource-utils'
6
6
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER } from './trivy-shared'
7
7
  import { pluralize } from '../../../utils/pluralize'
8
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
8
9
 
9
10
  interface ClusterComplianceReportRendererProps {
10
11
  data: any
@@ -185,7 +186,7 @@ export function ClusterComplianceReportRenderer({ data }: ClusterComplianceRepor
185
186
  ) : (
186
187
  <CheckCircle2 className="w-3.5 h-3.5 text-green-400 shrink-0" />
187
188
  )}
188
- <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', SEVERITY_BADGE_COLORS[check.severity] || 'bg-gray-500/20 text-gray-400')}>
189
+ <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', SEVERITY_BADGE_COLORS[check.severity] || BADGE_INACTIVE)}>
189
190
  {check.severity || '-'}
190
191
  </span>
191
192
  <span className="text-xs text-theme-text-secondary truncate flex-1">{check.name || check.id}</span>
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { formatAge } from '../resource-utils'
6
6
  import { SEVERITY_BADGE_COLORS, SEVERITY_ORDER, TrivyAlertBanner } from './trivy-shared'
7
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
7
8
 
8
9
  interface ConfigAuditReportRendererProps {
9
10
  data: any
@@ -169,7 +170,7 @@ export function ConfigAuditReportRenderer({ data }: ConfigAuditReportRendererPro
169
170
  ) : (
170
171
  <XCircle className="w-3.5 h-3.5 text-red-400 shrink-0" />
171
172
  )}
172
- <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', SEVERITY_BADGE_COLORS[group.severity] || 'bg-gray-500/20 text-gray-400')}>
173
+ <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0', SEVERITY_BADGE_COLORS[group.severity] || BADGE_INACTIVE)}>
173
174
  {group.severity}
174
175
  </span>
175
176
  <span className="text-xs text-theme-text-secondary truncate flex-1">{group.title}</span>
@@ -5,6 +5,7 @@ import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { formatAge } from '../resource-utils'
6
6
  import { SEVERITY_BADGE_COLORS, TrivyAlertBanner, formatTrivyImage } from './trivy-shared'
7
7
  import { pluralize } from '../../../utils/pluralize'
8
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
8
9
 
9
10
  interface ExposedSecretReportRendererProps {
10
11
  data: any
@@ -92,7 +93,7 @@ export function ExposedSecretReportRenderer({ data }: ExposedSecretReportRendere
92
93
  <tr key={`${secret.ruleID || i}-${i}`} className="border-b border-theme-border/50 hover:bg-theme-hover/50">
93
94
  <td className="py-1.5 px-1 text-theme-text-secondary font-mono">{secret.ruleID || '-'}</td>
94
95
  <td className="py-1.5 px-1">
95
- <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium', SEVERITY_BADGE_COLORS[secret.severity] || 'bg-gray-500/20 text-gray-400')}>
96
+ <span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-medium', SEVERITY_BADGE_COLORS[secret.severity] || BADGE_INACTIVE)}>
96
97
  {secret.severity || '-'}
97
98
  </span>
98
99
  </td>
@@ -1,6 +1,7 @@
1
1
  import { Cpu } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
4
5
 
5
6
  interface GatewayClassRendererProps {
6
7
  data: any
@@ -40,7 +41,7 @@ export function GatewayClassRenderer({ data }: GatewayClassRendererProps) {
40
41
  ? 'bg-green-500/20 text-green-400'
41
42
  : isNotAccepted
42
43
  ? 'bg-red-500/20 text-red-400'
43
- : 'bg-gray-500/20 text-gray-400'
44
+ : BADGE_INACTIVE
44
45
  )}>
45
46
  {isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
46
47
  </span>
@@ -1,6 +1,7 @@
1
1
  import { Globe, Radio, Lock } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
4
5
 
5
6
  const protocolColors: Record<string, string> = {
6
7
  HTTP: 'bg-blue-500/20 text-blue-400',
@@ -73,7 +74,7 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
73
74
  ? 'bg-green-500/20 text-green-400'
74
75
  : isNotAccepted
75
76
  ? 'bg-red-500/20 text-red-400'
76
- : 'bg-gray-500/20 text-gray-400'
77
+ : BADGE_INACTIVE
77
78
  )}>
78
79
  {isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
79
80
  </span>
@@ -88,7 +89,7 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
88
89
  ? 'bg-green-500/20 text-green-400'
89
90
  : isNotProgrammed
90
91
  ? 'bg-red-500/20 text-red-400'
91
- : 'bg-gray-500/20 text-gray-400'
92
+ : BADGE_INACTIVE
92
93
  )}>
93
94
  {isProgrammed ? 'True' : isNotProgrammed ? 'False' : 'Unknown'}
94
95
  </span>
@@ -134,7 +135,7 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
134
135
  <span className="text-sm font-medium text-theme-text-primary">{listener.name}</span>
135
136
  <span className={clsx(
136
137
  'px-1.5 py-0.5 rounded text-[10px] font-medium',
137
- protocolColors[listener.protocol] || 'bg-gray-500/20 text-gray-400'
138
+ protocolColors[listener.protocol] || BADGE_INACTIVE
138
139
  )}>
139
140
  {listener.protocol}:{listener.port}
140
141
  </span>
@@ -157,7 +158,7 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
157
158
  ? 'bg-green-500/20 text-green-400'
158
159
  : isListenerNotAccepted
159
160
  ? 'bg-red-500/20 text-red-400'
160
- : 'bg-gray-500/20 text-gray-400'
161
+ : BADGE_INACTIVE
161
162
  )}>
162
163
  {isListenerAccepted ? '\u2713' : isListenerNotAccepted ? '\u2717' : '?'}
163
164
  </span>
@@ -1,6 +1,7 @@
1
1
  import { Globe } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
4
5
 
5
6
  interface IngressClassRendererProps {
6
7
  data: any
@@ -24,7 +25,7 @@ export function IngressClassRenderer({ data }: IngressClassRendererProps) {
24
25
  'badge',
25
26
  isDefault
26
27
  ? 'bg-green-500/20 text-green-400'
27
- : 'bg-gray-500/20 text-gray-400'
28
+ : BADGE_INACTIVE
28
29
  )}>
29
30
  {isDefault ? 'Yes' : 'No'}
30
31
  </span>
@@ -6,6 +6,7 @@ import {
6
6
  getAuthorizationPolicyRules,
7
7
  getAuthorizationPolicySelector,
8
8
  } from '../resource-utils-istio'
9
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
9
10
 
10
11
  const actionColors: Record<string, string> = {
11
12
  ALLOW: 'bg-green-500/20 text-green-400',
@@ -52,7 +53,7 @@ export function IstioAuthorizationPolicyRenderer({ data }: IstioAuthorizationPol
52
53
  <Property label="Action" value={
53
54
  <span className={clsx(
54
55
  'badge',
55
- actionColors[action] || 'bg-gray-500/20 text-gray-400'
56
+ actionColors[action] || BADGE_INACTIVE
56
57
  )}>
57
58
  {action}
58
59
  </span>
@@ -6,6 +6,7 @@ import {
6
6
  getIstioGatewayServers,
7
7
  getIstioGatewaySelector,
8
8
  } from '../resource-utils-istio'
9
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
9
10
 
10
11
  const protocolColors: Record<string, string> = {
11
12
  HTTP: 'bg-blue-500/20 text-blue-400',
@@ -73,7 +74,7 @@ export function IstioGatewayRenderer({ data }: IstioGatewayRendererProps) {
73
74
  </span>
74
75
  <span className={clsx(
75
76
  'px-1.5 py-0.5 rounded text-[10px] font-medium',
76
- protocolColors[protocol] || 'bg-gray-500/20 text-gray-400'
77
+ protocolColors[protocol] || BADGE_INACTIVE
77
78
  )}>
78
79
  {protocol}:{server.port.number}
79
80
  </span>
@@ -98,7 +99,7 @@ export function IstioGatewayRenderer({ data }: IstioGatewayRendererProps) {
98
99
  ? 'bg-green-500/20 text-green-400'
99
100
  : server.tls.mode === 'PASSTHROUGH'
100
101
  ? 'bg-blue-500/20 text-blue-400'
101
- : 'bg-gray-500/20 text-gray-400'
102
+ : BADGE_INACTIVE
102
103
  )}>
103
104
  {server.tls.mode || 'SIMPLE'}
104
105
  </span>
@@ -6,12 +6,13 @@ import {
6
6
  getPeerAuthenticationSelector,
7
7
  getPeerAuthenticationPortLevelMtls,
8
8
  } from '../resource-utils-istio'
9
+ import { BADGE_INACTIVE } from '../../../utils/badge-colors'
9
10
 
10
11
  const modeColors: Record<string, string> = {
11
12
  STRICT: 'bg-green-500/20 text-green-400',
12
13
  PERMISSIVE: 'bg-yellow-500/20 text-yellow-400',
13
14
  DISABLE: 'bg-red-500/20 text-red-400',
14
- UNSET: 'bg-gray-500/20 text-gray-400',
15
+ UNSET: BADGE_INACTIVE,
15
16
  }
16
17
 
17
18
  interface IstioPeerAuthenticationRendererProps {
@@ -2,8 +2,8 @@ import { Server, Cpu, Settings } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
4
  import { kindToPlural } from '../../../utils/navigation'
5
+ import { CAPACITY_TYPE_BADGE, BADGE_INACTIVE } from '../../../utils/badge-colors'
5
6
  import {
6
- CAPACITY_TYPE_BADGE,
7
7
  getNodeClaimStatus,
8
8
  getNodeClaimInstanceType,
9
9
  getNodeClaimNodeName,
@@ -188,7 +188,7 @@ export function KarpenterNodeClaimRenderer({ data, onNavigate }: KarpenterNodeCl
188
188
  isComplete && 'bg-green-500/20 text-green-400',
189
189
  isCurrent && 'bg-blue-500/20 text-blue-400',
190
190
  isFailed && 'bg-red-500/20 text-red-400',
191
- isPending && !isCurrent && 'bg-gray-500/20 text-gray-400'
191
+ isPending && !isCurrent && BADGE_INACTIVE
192
192
  )}
193
193
  >
194
194
  {isComplete ? '\u2713' : isFailed ? '\u2717' : isCurrent ? '\u25CF' : '\u25CB'}
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { KarpenterNodePoolRenderer } from './KarpenterNodePoolRenderer'
4
+
5
+ describe('KarpenterNodePoolRenderer', () => {
6
+ it('renders CPU quantities expressed as millicore strings', () => {
7
+ const html = renderToString(
8
+ <KarpenterNodePoolRenderer
9
+ data={{
10
+ spec: { limits: { cpu: '12000m' } },
11
+ status: { resources: { cpu: '6000m' } },
12
+ }}
13
+ />,
14
+ )
15
+
16
+ expect(html).toContain('6 / 12')
17
+ })
18
+
19
+ it('renders non-string CPU quantities without throwing', () => {
20
+ expect(() =>
21
+ renderToString(
22
+ <KarpenterNodePoolRenderer
23
+ data={{
24
+ spec: { limits: { cpu: 16 } },
25
+ status: { resources: { cpu: 12 } },
26
+ }}
27
+ />,
28
+ ),
29
+ ).not.toThrow()
30
+ })
31
+ })
@@ -9,13 +9,13 @@ import {
9
9
  getNodePoolWeight,
10
10
  } from '../resource-utils-karpenter'
11
11
 
12
- function formatCpuCores(value: string): string {
13
- // Karpenter status.resources CPU is typically in millicores (e.g. "12000m") or cores (e.g. "12")
14
- if (value.endsWith('m')) {
15
- const millis = parseInt(value, 10)
12
+ function formatCpuCores(value: unknown): string {
13
+ const quantity = String(value)
14
+ if (quantity.endsWith('m')) {
15
+ const millis = parseInt(quantity, 10)
16
16
  if (!isNaN(millis)) return String(millis / 1000)
17
17
  }
18
- return value
18
+ return quantity
19
19
  }
20
20
 
21
21