@skyhook-io/k8s-ui 1.10.0 → 1.10.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -191,6 +191,12 @@ export interface Issue {
191
191
  * self-recover. */
192
192
  operation_retry_count?: number;
193
193
  stuck?: boolean;
194
+ /** True for an unschedulable pod that explicitly requires a Karpenter NodePool.
195
+ * Set server-side from a structural pod-spec check (nodeSelector / required
196
+ * nodeAffinity on karpenter.sh/nodepool), NOT by parsing the scheduler
197
+ * message. The Issues view uses it to link these — and only these — to the
198
+ * Capacity / Demand view, so a generic scheduling failure never links. */
199
+ capacity_relevant?: boolean;
194
200
  first_seen?: string;
195
201
  last_seen?: string;
196
202
  /** Affected-resource fan-out, EXCLUDING the subject (the row header).
@@ -0,0 +1,594 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+ import {
3
+ Box,
4
+ Check,
5
+ CircleAlert,
6
+ CircleOff,
7
+ Copy,
8
+ Cpu,
9
+ Equal,
10
+ Eye,
11
+ EyeOff,
12
+ FileCode2,
13
+ History,
14
+ KeyRound,
15
+ List,
16
+ LockKeyhole,
17
+ Minus,
18
+ Variable,
19
+ } from 'lucide-react'
20
+ import { clsx } from 'clsx'
21
+ import type {
22
+ PodEnvironmentResponse,
23
+ PodEnvironmentRevealResponse,
24
+ PodEnvironmentRow,
25
+ PodEnvironmentSource,
26
+ } from '../../../types'
27
+ import { Badge, type BadgeSeverity } from '../../ui/Badge'
28
+ import { Section, type CopyHandler } from '../../ui/drawer-components'
29
+ import { Tooltip } from '../../ui/Tooltip'
30
+
31
+ interface ContainerEnvironmentSectionProps {
32
+ environment: PodEnvironmentResponse
33
+ namespace: string
34
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
35
+ onReveal?: (container: string, variable: string) => Promise<PodEnvironmentRevealResponse>
36
+ onCopy: CopyHandler
37
+ copied: string | null
38
+ }
39
+
40
+ export function ContainerEnvironmentSection({
41
+ environment,
42
+ namespace,
43
+ onNavigate,
44
+ onReveal,
45
+ onCopy,
46
+ copied,
47
+ }: ContainerEnvironmentSectionProps) {
48
+ const visibleContainers = environment.containers.filter(container => container.rows.length > 0 || container.truncated)
49
+ const defaultContainerName = (visibleContainers.find(container => container.role === 'container') ?? visibleContainers[0])?.name ?? ''
50
+ const [selected, setSelected] = useState(defaultContainerName)
51
+ const [revealed, setRevealed] = useState<Record<string, PodEnvironmentRevealResponse>>({})
52
+ const [revealing, setRevealing] = useState<Record<string, boolean>>({})
53
+ const [revealErrors, setRevealErrors] = useState<Record<string, string>>({})
54
+
55
+ useEffect(() => {
56
+ if (!visibleContainers.some(container => container.name === selected))
57
+ setSelected(defaultContainerName)
58
+ }, [defaultContainerName, selected, visibleContainers])
59
+
60
+ useEffect(() => {
61
+ setRevealed({})
62
+ setRevealErrors({})
63
+ }, [environment])
64
+
65
+ const active = visibleContainers.find(container => container.name === selected)
66
+ ?? visibleContainers.find(container => container.name === defaultContainerName)
67
+ const total = visibleContainers.reduce(
68
+ (sum, container) => sum + container.rows.filter(row => !row.placeholder).length,
69
+ 0,
70
+ )
71
+ const unavailableSources = visibleContainers.reduce(
72
+ (sum, container) => sum + container.rows.filter(row => row.placeholder).length,
73
+ 0,
74
+ )
75
+ const secretBacked = visibleContainers.reduce(
76
+ (sum, container) => sum + container.rows.filter(row => row.sensitive && !row.placeholder).length,
77
+ 0,
78
+ )
79
+ const subtitle = total + ' variable' + (total === 1 ? '' : 's')
80
+ + (secretBacked ? ' · ' + secretBacked + ' from Secrets' : '')
81
+ + (unavailableSources ? ' · ' + unavailableSources + ' source' + (unavailableSources === 1 ? '' : 's') + ' unavailable' : '')
82
+
83
+ if (!active) return null
84
+ const showStatus = active.rows.some(hasEnvironmentStatus)
85
+
86
+ const reveal = async (row: PodEnvironmentRow) => {
87
+ const key = rowKey(active.name, row.name)
88
+ if (revealed[key]) {
89
+ setRevealed(current => withoutKey(current, key))
90
+ return
91
+ }
92
+ if (!onReveal) return
93
+ setRevealing(current => ({ ...current, [key]: true }))
94
+ setRevealErrors(current => withoutKey(current, key))
95
+ try {
96
+ const value = await onReveal(active.name, row.name)
97
+ setRevealed(current => ({ ...current, [key]: value }))
98
+ } catch (error) {
99
+ setRevealErrors(current => ({ ...current, [key]: error instanceof Error ? error.message : 'Reveal failed' }))
100
+ } finally {
101
+ setRevealing(current => withoutKey(current, key))
102
+ }
103
+ }
104
+
105
+ return (
106
+ <Section title={'Environment Variables · ' + subtitle} icon={List} defaultExpanded={false} contentClassName="pl-2">
107
+ <div className="@container/env min-w-0 space-y-3">
108
+ <p className="text-xs text-theme-text-tertiary">
109
+ Shows values from the Pod's current configuration and where they come from. Secret values stay hidden until you reveal them.
110
+ </p>
111
+ <p className="text-xs text-theme-text-tertiary">
112
+ <Tooltip content="The cluster can add networking-related variables when the container starts. They are not stored on the Pod, so Radar cannot show them." position="right">
113
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary" tabIndex={0}>Only variables declared on the Pod are shown.</span>
114
+ </Tooltip>
115
+ </p>
116
+
117
+ {visibleContainers.length > 1 && (
118
+ <div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Container">
119
+ {visibleContainers.map(container => (
120
+ <button
121
+ key={container.name}
122
+ type="button"
123
+ role="tab"
124
+ aria-selected={container.name === active.name}
125
+ onClick={() => {
126
+ setSelected(container.name)
127
+ setRevealed({})
128
+ setRevealErrors({})
129
+ }}
130
+ className={clsx(
131
+ 'rounded-md border px-2.5 py-1 text-xs transition-colors',
132
+ container.name === active.name
133
+ ? 'selection selection-text selection-ring border-transparent'
134
+ : 'border-theme-border text-theme-text-secondary hover:bg-theme-hover',
135
+ )}
136
+ >
137
+ {container.name}
138
+ {container.role !== 'container' && ' · ' + container.role}
139
+ </button>
140
+ ))}
141
+ </div>
142
+ )}
143
+
144
+ <div className="overflow-hidden rounded-lg border border-theme-border">
145
+ <table className="w-full table-fixed text-left text-xs">
146
+ <colgroup>
147
+ <col className={showStatus ? 'w-[42%] @min-[760px]/env:w-[26%]' : 'w-[46%] @min-[760px]/env:w-[30%]'} />
148
+ <col className={showStatus ? 'w-[36%] @min-[760px]/env:w-[30%]' : 'w-[42%] @min-[760px]/env:w-[38%]'} />
149
+ <col className={showStatus ? 'w-[11%] @min-[760px]/env:w-[24%]' : 'w-[12%] @min-[760px]/env:w-[32%]'} />
150
+ {showStatus && <col className="w-[11%] @min-[760px]/env:w-[20%]" />}
151
+ </colgroup>
152
+ <thead className="bg-theme-elevated text-theme-text-tertiary">
153
+ <tr>
154
+ <th className="px-2 py-1.5 font-medium @min-[760px]/env:px-3 @min-[760px]/env:py-2">Variable</th>
155
+ <th className="px-2 py-1.5 font-medium @min-[760px]/env:px-3 @min-[760px]/env:py-2">
156
+ <ExplainedLabel label="Value" explanation="The value in the Pod's current configuration. A running container may still have an older value if its ConfigMap or Secret changed after it started." />
157
+ </th>
158
+ <th className="px-1 py-1.5 text-center font-medium @min-[760px]/env:px-3 @min-[760px]/env:py-2 @min-[760px]/env:text-left">
159
+ <ExplainedLabel label="Source" explanation="Where this variable gets its value." />
160
+ </th>
161
+ {showStatus && (
162
+ <th className="px-1 py-1.5 text-center font-medium @min-[760px]/env:px-3 @min-[760px]/env:py-2 @min-[760px]/env:text-left">
163
+ <ExplainedLabel label="Status" explanation="A change since the container started, or a problem reading this value." />
164
+ </th>
165
+ )}
166
+ </tr>
167
+ </thead>
168
+ <tbody className="table-divide-subtle">
169
+ {active.rows.map(row => {
170
+ const key = rowKey(active.name, row.name)
171
+ const revealedValue = revealed[key]
172
+ return (
173
+ <tr key={row.name} className="align-top">
174
+ <td className="min-w-0 break-all px-2 py-1.5 font-mono text-theme-text-primary @min-[760px]/env:px-3 @min-[760px]/env:py-2">
175
+ {row.placeholder ? 'All variables from this source' : row.name}
176
+ </td>
177
+ <td className="min-w-0 px-2 py-1.5 font-mono text-theme-text-primary @min-[760px]/env:px-3 @min-[760px]/env:py-2">
178
+ <ValueCell
179
+ row={row}
180
+ revealed={revealedValue}
181
+ revealing={revealing[key] === true}
182
+ revealError={revealErrors[key]}
183
+ onReveal={() => reveal(row)}
184
+ onCopy={() => onCopy(revealedValue?.value ?? row.value ?? '', key)}
185
+ copied={copied === key}
186
+ revealEnabled={Boolean(onReveal)}
187
+ />
188
+ </td>
189
+ <td className="min-w-0 px-1 py-1.5 @min-[760px]/env:px-3 @min-[760px]/env:py-2">
190
+ <div className="flex justify-center @min-[760px]/env:hidden">
191
+ <CompactSourceCell row={row} namespace={namespace} onNavigate={onNavigate} />
192
+ </div>
193
+ <div className="hidden @min-[760px]/env:block">
194
+ <SourceCell row={row} namespace={namespace} onNavigate={onNavigate} />
195
+ </div>
196
+ </td>
197
+ {showStatus && (
198
+ <td className="min-w-0 px-1 py-1.5 @min-[760px]/env:px-3 @min-[760px]/env:py-2">
199
+ <div className="flex justify-center @min-[760px]/env:hidden">
200
+ <CompactStatusCell row={row} />
201
+ </div>
202
+ <div className="hidden @min-[760px]/env:block">
203
+ <StatusCell row={row} />
204
+ </div>
205
+ </td>
206
+ )}
207
+ </tr>
208
+ )
209
+ })}
210
+ </tbody>
211
+ </table>
212
+ </div>
213
+
214
+ {active.truncated && (
215
+ <p className="text-xs text-warning-text">Some variables in this container were omitted.</p>
216
+ )}
217
+
218
+ {environment.partial && <p className="text-xs text-warning-text">Some sources could not be read.</p>}
219
+ {environment.truncated && <p className="text-xs text-warning-text">This Pod has more sources or variables than Radar can show at once.</p>}
220
+ {environment.coverage.degraded && (
221
+ <p className="text-xs text-warning-text">{environment.coverage.degradedReason || 'Recent changes could not be checked.'}</p>
222
+ )}
223
+ {environment.coverage.saturated && <p className="text-xs text-warning-text">Some recent changes may not be shown.</p>}
224
+ </div>
225
+ </Section>
226
+ )
227
+ }
228
+
229
+ function ValueCell({
230
+ row,
231
+ revealed,
232
+ revealing,
233
+ revealError,
234
+ onReveal,
235
+ onCopy,
236
+ copied,
237
+ revealEnabled,
238
+ }: {
239
+ row: PodEnvironmentRow
240
+ revealed?: PodEnvironmentRevealResponse
241
+ revealing: boolean
242
+ revealError?: string
243
+ onReveal: () => void
244
+ onCopy: () => void
245
+ copied: boolean
246
+ revealEnabled: boolean
247
+ }) {
248
+ if (row.runtimeDependent) {
249
+ const explanation = row.message || 'The final value is decided when the container starts.'
250
+ return (
251
+ <Tooltip content={row.value ? `${explanation} Pod configuration: ${row.value}` : explanation} position="top">
252
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary text-theme-text-tertiary" tabIndex={0}>Set at startup</span>
253
+ </Tooltip>
254
+ )
255
+ }
256
+ if (row.state === 'missing') {
257
+ if (row.optional) {
258
+ return (
259
+ <Tooltip content={row.message || 'This optional variable is not set.'} position="top">
260
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary text-theme-text-tertiary" tabIndex={0}>Not set</span>
261
+ </Tooltip>
262
+ )
263
+ }
264
+ return <span className="text-theme-text-tertiary">—</span>
265
+ }
266
+ if (row.sensitive) {
267
+ return (
268
+ <div className="space-y-1">
269
+ <div className="group/value flex items-center gap-1.5">
270
+ <span className="break-all">{revealed ? revealed.value : '••••••••'}</span>
271
+ {row.state === 'masked' && !row.runtimeDependent && !row.placeholder && row.evidence?.kind !== 'removed' && (
272
+ <button
273
+ type="button"
274
+ onClick={onReveal}
275
+ disabled={!revealEnabled || revealing}
276
+ className="text-theme-text-tertiary transition-colors hover:text-theme-text-primary disabled:cursor-not-allowed disabled:opacity-40"
277
+ aria-label={revealed ? 'Hide value' : 'Reveal value'}
278
+ >
279
+ <Tooltip content={revealed ? 'Hide Secret value' : 'Reveal this Secret value'} delay={150}>
280
+ {revealed ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
281
+ </Tooltip>
282
+ </button>
283
+ )}
284
+ {revealed && (
285
+ <ValueCopyButton onCopy={onCopy} copied={copied} />
286
+ )}
287
+ {revealing && <span className="text-theme-text-tertiary">Loading…</span>}
288
+ </div>
289
+ {revealed?.encoding === 'base64' && <div className="text-theme-text-tertiary">Base64-encoded binary value</div>}
290
+ {revealError && <div className="text-theme-text-secondary">{revealError}</div>}
291
+ </div>
292
+ )
293
+ }
294
+ if (row.state !== 'resolved' && !row.value) return <span className="text-theme-text-tertiary">—</span>
295
+ return (
296
+ <div className="group/value flex min-w-0 items-center gap-1.5">
297
+ <span className="min-w-0 break-all">{row.value ?? ''}</span>
298
+ <ValueCopyButton onCopy={onCopy} copied={copied} />
299
+ </div>
300
+ )
301
+ }
302
+
303
+ function ValueCopyButton({ onCopy, copied }: { onCopy: () => void; copied: boolean }) {
304
+ return (
305
+ <button
306
+ type="button"
307
+ onClick={onCopy}
308
+ className={clsx(
309
+ 'shrink-0 text-theme-text-tertiary transition-[color,opacity] hover:text-theme-text-primary focus-visible:opacity-100',
310
+ copied ? 'opacity-100' : 'opacity-0 group-hover/value:opacity-100 group-focus-within/value:opacity-100',
311
+ )}
312
+ aria-label={copied ? 'Value copied' : 'Copy value'}
313
+ >
314
+ <Tooltip content={copied ? 'Copied' : 'Copy value'} delay={150}>
315
+ {copied
316
+ ? <Check className="h-3.5 w-3.5 text-green-400" aria-hidden />
317
+ : <Copy className="h-3.5 w-3.5" aria-hidden />}
318
+ </Tooltip>
319
+ </button>
320
+ )
321
+ }
322
+
323
+ function CompactSourceCell({
324
+ row,
325
+ namespace,
326
+ onNavigate,
327
+ }: {
328
+ row: PodEnvironmentRow
329
+ namespace: string
330
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
331
+ }) {
332
+ const source = row.source
333
+ const navigate = (source.kind === 'Secret' || source.kind === 'ConfigMap') && source.name && onNavigate
334
+ ? () => onNavigate({ kind: source.kind, namespace, name: source.name! })
335
+ : undefined
336
+ const kind = source.kind === 'Secret' || source.kind === 'ConfigMap' || source.kind === 'Pod'
337
+ ? source.kind
338
+ : undefined
339
+ const badge = (
340
+ <Badge
341
+ kind={kind}
342
+ tone={kind ? undefined : 'structural'}
343
+ size="sm"
344
+ className="!px-1"
345
+ onClick={navigate}
346
+ >
347
+ {compactSourceIcon(source.kind)}
348
+ <span className="sr-only">{navigate ? `View ${source.kind}/${source.name}` : source.kind}</span>
349
+ </Badge>
350
+ )
351
+
352
+ return (
353
+ <Tooltip content={compactSourceExplanation(row, Boolean(navigate))} delay={150} position="top">
354
+ <span className={clsx('inline-flex', navigate ? 'cursor-pointer' : '!cursor-help')} tabIndex={navigate ? undefined : 0}>{badge}</span>
355
+ </Tooltip>
356
+ )
357
+ }
358
+
359
+ function compactSourceIcon(kind: string) {
360
+ const className = 'h-3.5 w-3.5'
361
+ if (kind === 'Secret') return <KeyRound className={className} aria-hidden />
362
+ if (kind === 'ConfigMap') return <FileCode2 className={className} aria-hidden />
363
+ if (kind === 'Pod') return <Box className={className} aria-hidden />
364
+ if (kind === 'Container resources') return <Cpu className={className} aria-hidden />
365
+ if (kind === 'Variable') return <Variable className={className} aria-hidden />
366
+ return <Equal className={className} aria-hidden />
367
+ }
368
+
369
+ function compactSourceExplanation(row: PodEnvironmentRow, canNavigate: boolean) {
370
+ let explanation = describeSource(row.source)
371
+ if ((row.dependencies?.length ?? 0) > 0) {
372
+ explanation += ' It also uses ' + formatSources(row.dependencies!) + '.'
373
+ }
374
+ if ((row.shadowedSources?.length ?? 0) > 0) {
375
+ explanation += ' Overrides earlier ' + formatSources(row.shadowedSources!) + '.'
376
+ }
377
+ if (canNavigate) {
378
+ explanation += ` Click to view the ${row.source.kind}.`
379
+ }
380
+ return explanation
381
+ }
382
+
383
+ function SourceCell({
384
+ row,
385
+ namespace,
386
+ onNavigate,
387
+ }: {
388
+ row: PodEnvironmentRow
389
+ namespace: string
390
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
391
+ }) {
392
+ const dependencies = row.dependencies ?? []
393
+ return (
394
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5">
395
+ <SourceLabel source={row.source} namespace={namespace} onNavigate={onNavigate} />
396
+ {dependencies.length > 0 && <span className="text-theme-text-tertiary">using</span>}
397
+ {dependencies.map((dependency, index) => (
398
+ <SourceLabel
399
+ key={dependency.kind + '\u0000' + dependency.name + '\u0000' + dependency.key + '\u0000' + index}
400
+ source={dependency}
401
+ namespace={namespace}
402
+ onNavigate={onNavigate}
403
+ />
404
+ ))}
405
+ {(row.shadowedSources?.length ?? 0) > 0 && (
406
+ <Tooltip content={'Earlier source' + (row.shadowedSources!.length === 1 ? ': ' : 's: ') + formatSources(row.shadowedSources!)}>
407
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary text-theme-text-tertiary" tabIndex={0}>
408
+ overrides {row.shadowedSources!.length} earlier source{row.shadowedSources!.length === 1 ? '' : 's'}
409
+ </span>
410
+ </Tooltip>
411
+ )}
412
+ </div>
413
+ )
414
+ }
415
+
416
+ function SourceLabel({
417
+ source,
418
+ namespace,
419
+ onNavigate,
420
+ }: {
421
+ source: PodEnvironmentSource
422
+ namespace: string
423
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
424
+ }) {
425
+ if ((source.kind === 'Secret' || source.kind === 'ConfigMap') && source.name) {
426
+ const explanation = describeSource(source)
427
+ const navigationHint = onNavigate ? ` Click to view the ${source.kind}.` : ''
428
+ return (
429
+ <span className="inline-flex min-w-0 max-w-full flex-wrap items-center gap-x-1.5 gap-y-0.5">
430
+ <Tooltip content={explanation + navigationHint} position="top" wrapperClassName="min-w-0">
431
+ <span className={clsx('inline-flex min-w-0 max-w-full', onNavigate ? 'cursor-pointer' : '!cursor-help')} tabIndex={onNavigate ? undefined : 0}>
432
+ <Badge
433
+ kind={source.kind}
434
+ size="sm"
435
+ className="max-w-full"
436
+ onClick={onNavigate ? () => onNavigate({ kind: source.kind, namespace, name: source.name! }) : undefined}
437
+ >
438
+ <span className="min-w-0 truncate">{source.kind}/{source.name}</span>
439
+ </Badge>
440
+ </span>
441
+ </Tooltip>
442
+ {source.key && <span className="min-w-0 max-w-full break-all font-mono text-theme-text-tertiary">{source.key}</span>}
443
+ </span>
444
+ )
445
+ }
446
+ if (source.kind === 'Variable' && source.variable) {
447
+ return (
448
+ <Tooltip content={`This value uses the earlier ${source.variable} environment variable.`}>
449
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary font-mono text-theme-text-secondary" tabIndex={0}>{source.variable}</span>
450
+ </Tooltip>
451
+ )
452
+ }
453
+ const label = source.kind === 'Direct'
454
+ ? 'Set directly'
455
+ : source.kind === 'Pod'
456
+ ? 'Pod'
457
+ : source.kind === 'Container resources'
458
+ ? 'Container resources'
459
+ : source.kind
460
+ const explanation = describeSource(source)
461
+ return (
462
+ <Tooltip content={explanation}>
463
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary text-theme-text-secondary" tabIndex={0}>{label}</span>
464
+ </Tooltip>
465
+ )
466
+ }
467
+
468
+ function describeSource(source: PodEnvironmentSource) {
469
+ if (source.kind === 'Direct') return 'Set directly in this container\'s configuration.'
470
+ if (source.kind === 'Secret') {
471
+ return `Secret/${source.name} stores sensitive configuration${source.key ? `; this value uses its ${source.key} key` : ''}. The value stays hidden until you reveal it.`
472
+ }
473
+ if (source.kind === 'ConfigMap') {
474
+ return `ConfigMap/${source.name} stores non-sensitive configuration${source.key ? `; this value uses its ${source.key} key` : ''}.`
475
+ }
476
+ if (source.kind === 'Pod') {
477
+ return `Comes from the Pod itself${source.key ? ` (${source.key})` : ''}, such as its name, namespace, IP, or assigned node. The running container read this value when it started.`
478
+ }
479
+ if (source.kind === 'Container resources') {
480
+ return `Calculated from a container's CPU or memory request or limit${source.key ? ` (${source.key})` : ''}. The running container read this value when it started.`
481
+ }
482
+ if (source.kind === 'Variable') return `Uses the earlier ${source.variable} environment variable.`
483
+ return `Value source: ${source.kind}.`
484
+ }
485
+
486
+ function formatSources(sources: PodEnvironmentSource[]) {
487
+ return sources.map(source => source.kind
488
+ + (source.name ? '/' + source.name : source.variable ? '/' + source.variable : '')
489
+ + (source.key ? '[' + source.key + ']' : '')).join(', ')
490
+ }
491
+
492
+ function CompactStatusCell({ row }: { row: PodEnvironmentRow }) {
493
+ if (row.state === 'missing' && !row.optional) {
494
+ const { label, explanation } = missingStatus(row)
495
+ const severity = row.missingImpact === 'restartBlocked' ? 'warning' : 'error'
496
+ return <CompactStatusBadge label={label} explanation={explanation} severity={severity} icon={<CircleAlert className="h-3.5 w-3.5" aria-hidden />} />
497
+ }
498
+ if (row.evidence) {
499
+ const label = row.evidence.kind === 'added'
500
+ ? 'Added after start'
501
+ : row.evidence.kind === 'removed'
502
+ ? 'Removed after start'
503
+ : 'Changed after start'
504
+ return <CompactStatusBadge label={label} explanation={row.evidence.message} severity="warning" icon={<History className="h-3.5 w-3.5" aria-hidden />} />
505
+ }
506
+ if (row.state === 'denied') return <CompactStatusBadge label="Access needed" explanation={row.message} severity="info" icon={<LockKeyhole className="h-3.5 w-3.5" aria-hidden />} />
507
+ if (row.runtimeDependent) return <CompactStatusBadge label="At startup" explanation={row.message} severity="neutral" icon={<Minus className="h-3.5 w-3.5" aria-hidden />} />
508
+ if (row.state === 'missing' && row.optional) return <CompactStatusBadge label="Not set" explanation={row.message} severity="neutral" icon={<Minus className="h-3.5 w-3.5" aria-hidden />} />
509
+ if (row.state === 'unavailable') return <CompactStatusBadge label="Unavailable" explanation={row.message} severity="neutral" icon={<CircleOff className="h-3.5 w-3.5" aria-hidden />} />
510
+ return null
511
+ }
512
+
513
+ function CompactStatusBadge({
514
+ label,
515
+ explanation,
516
+ severity,
517
+ icon,
518
+ }: {
519
+ label: string
520
+ explanation?: string
521
+ severity?: BadgeSeverity
522
+ icon: ReactNode
523
+ }) {
524
+ return (
525
+ <Tooltip content={explanation || label} delay={150} position="left">
526
+ <span className="inline-flex cursor-help" tabIndex={0}>
527
+ <Badge severity={severity} size="sm" className="!px-1">
528
+ {icon}
529
+ <span className="sr-only">{label}</span>
530
+ </Badge>
531
+ </span>
532
+ </Tooltip>
533
+ )
534
+ }
535
+
536
+ function StatusCell({ row }: { row: PodEnvironmentRow }) {
537
+ if (row.state === 'missing' && !row.optional) {
538
+ const { label, explanation } = missingStatus(row)
539
+ const severity = row.missingImpact === 'restartBlocked' ? 'warning' : 'error'
540
+ return <StatusBadge explanation={explanation}><Badge severity={severity} size="sm">{label}</Badge></StatusBadge>
541
+ }
542
+ if (row.evidence) {
543
+ const label = row.evidence.kind === 'added'
544
+ ? 'Added after start'
545
+ : row.evidence.kind === 'removed'
546
+ ? 'Removed after start'
547
+ : 'Changed after start'
548
+ return <StatusBadge explanation={row.evidence.message}><Badge severity="warning" size="sm">{label}</Badge></StatusBadge>
549
+ }
550
+ if (row.state === 'denied') return <StatusBadge explanation={row.message}><Badge severity="info" size="sm">Access needed</Badge></StatusBadge>
551
+ if (row.runtimeDependent) return <StatusBadge explanation={row.message}><Badge severity="neutral" size="sm">At startup</Badge></StatusBadge>
552
+ if (row.state === 'missing' && row.optional) return <StatusBadge explanation={row.message}><Badge severity="neutral" size="sm">Not set</Badge></StatusBadge>
553
+ if (row.state === 'unavailable') return <StatusBadge explanation={row.message}><Badge severity="neutral" size="sm">Unavailable</Badge></StatusBadge>
554
+ return null
555
+ }
556
+
557
+ function missingStatus(row: PodEnvironmentRow) {
558
+ const label = row.missingImpact === 'restartBlocked' ? 'Restart blocked' : 'Prevents start'
559
+ const explanation = [row.message, row.evidence?.message].filter(Boolean).join(' ')
560
+ return { label, explanation }
561
+ }
562
+
563
+ function hasEnvironmentStatus(row: PodEnvironmentRow) {
564
+ return Boolean(row.evidence)
565
+ || row.state === 'denied'
566
+ || (row.state === 'missing' && !row.optional)
567
+ || (row.state === 'unavailable' && !row.runtimeDependent)
568
+ }
569
+
570
+ function StatusBadge({ explanation, children }: { explanation?: string; children: ReactNode }) {
571
+ return (
572
+ <Tooltip content={explanation} position="left" disabled={!explanation}>
573
+ <span className={clsx(explanation && 'cursor-help')} tabIndex={explanation ? 0 : undefined}>{children}</span>
574
+ </Tooltip>
575
+ )
576
+ }
577
+
578
+ function ExplainedLabel({ label, explanation }: { label: string; explanation: string }) {
579
+ return (
580
+ <Tooltip content={explanation}>
581
+ <span className="cursor-help border-b border-dotted border-theme-text-tertiary" tabIndex={0}>{label}</span>
582
+ </Tooltip>
583
+ )
584
+ }
585
+
586
+ function rowKey(container: string, variable: string) {
587
+ return container + '\u0000' + variable
588
+ }
589
+
590
+ function withoutKey<T>(record: Record<string, T>, key: string) {
591
+ const next = { ...record }
592
+ delete next[key]
593
+ return next
594
+ }
@@ -13,7 +13,8 @@ describe('KarpenterNodePoolRenderer', () => {
13
13
  />,
14
14
  )
15
15
 
16
- expect(html).toContain('6 / 12')
16
+ expect(html).toContain('6 cores provisioned / 12 cores limit')
17
+ expect(html).toContain('This is not live workload usage')
17
18
  })
18
19
 
19
20
  it('renders non-string CPU quantities without throwing', () => {
@@ -28,4 +29,28 @@ describe('KarpenterNodePoolRenderer', () => {
28
29
  ),
29
30
  ).not.toThrow()
30
31
  })
32
+
33
+ it('renders disruption reasons and lifecycle safety settings', () => {
34
+ const html = renderToString(
35
+ <KarpenterNodePoolRenderer
36
+ data={{
37
+ spec: {
38
+ template: {
39
+ spec: {
40
+ terminationGracePeriod: '24h',
41
+ requirements: [{ key: 'node.kubernetes.io/instance-family', operator: 'In', values: ['m6i'], minValues: 2 }],
42
+ },
43
+ },
44
+ disruption: { budgets: [{ nodes: '10%', reasons: ['Drifted', 'Underutilized'] }] },
45
+ },
46
+ }}
47
+ />,
48
+ )
49
+
50
+ expect(html).toContain('Termination Grace Period')
51
+ expect(html).toContain('24h')
52
+ expect(html).toContain('Drifted')
53
+ expect(html).toContain('Underutilized')
54
+ expect(html).toContain('min <!-- -->2')
55
+ })
31
56
  })