@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 +1 -1
- package/src/components/issues/types.ts +6 -0
- package/src/components/resources/renderers/ContainerEnvironmentSection.tsx +594 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +26 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +34 -15
- package/src/components/resources/renderers/PodRenderer.test.tsx +227 -0
- package/src/components/resources/renderers/PodRenderer.tsx +63 -2
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +6 -1
- package/src/components/ui/drawer-components.tsx +3 -2
- package/src/components/ui/index.ts +3 -1
- package/src/components/workload/WorkloadView.tsx +22 -0
- package/src/types/capacity.ts +702 -0
- package/src/types/core.ts +72 -2
- package/src/types/index.ts +1 -0
- package/src/utils/format.test.ts +35 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/navigation.test.ts +9 -0
- package/src/utils/navigation.ts +4 -2
package/package.json
CHANGED
|
@@ -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
|
})
|