@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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Server, Settings, Shield, Cpu, Tag, BarChart3 } from 'lucide-react'
|
|
2
2
|
import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
3
3
|
import { kindToPlural } from '../../../utils/navigation'
|
|
4
|
+
import { formatCPUString, formatMemoryString } from '../../../utils/format'
|
|
5
|
+
import { Badge } from '../../ui/Badge'
|
|
4
6
|
import {
|
|
5
7
|
getNodePoolStatus,
|
|
6
8
|
getNodePoolNodeClassRef,
|
|
@@ -9,22 +11,14 @@ import {
|
|
|
9
11
|
getNodePoolWeight,
|
|
10
12
|
} from '../resource-utils-karpenter'
|
|
11
13
|
|
|
12
|
-
function formatCpuCores(value: unknown): string {
|
|
13
|
-
const quantity = String(value)
|
|
14
|
-
if (quantity.endsWith('m')) {
|
|
15
|
-
const millis = parseInt(quantity, 10)
|
|
16
|
-
if (!isNaN(millis)) return String(millis / 1000)
|
|
17
|
-
}
|
|
18
|
-
return quantity
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
14
|
interface KarpenterNodePoolRendererProps {
|
|
23
15
|
data: any
|
|
24
16
|
onNavigate?: (ref: { kind: string; namespace: string; name: string; group?: string }) => void
|
|
17
|
+
/** Host-wired: opens this pool in the Capacity view (only hosts that ship it pass this). */
|
|
18
|
+
onOpenCapacity?: () => void
|
|
25
19
|
}
|
|
26
20
|
|
|
27
|
-
export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoolRendererProps) {
|
|
21
|
+
export function KarpenterNodePoolRenderer({ data, onNavigate, onOpenCapacity }: KarpenterNodePoolRendererProps) {
|
|
28
22
|
const status = data.status || {}
|
|
29
23
|
const spec = data.spec || {}
|
|
30
24
|
const conditions = status.conditions || []
|
|
@@ -38,6 +32,7 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
38
32
|
const disruption = spec.disruption || {}
|
|
39
33
|
const templateLabels = spec.template?.metadata?.labels || {}
|
|
40
34
|
const templateExpireAfter = spec.template?.spec?.expireAfter
|
|
35
|
+
const terminationGracePeriod = spec.template?.spec?.terminationGracePeriod
|
|
41
36
|
const nodeClassRef = spec.template?.spec?.nodeClassRef
|
|
42
37
|
const templateTaints = spec.template?.spec?.taints || []
|
|
43
38
|
const templateStartupTaints = spec.template?.spec?.startupTaints || []
|
|
@@ -45,6 +40,18 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
45
40
|
|
|
46
41
|
return (
|
|
47
42
|
<>
|
|
43
|
+
{onOpenCapacity && (
|
|
44
|
+
<div className="mb-3">
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={onOpenCapacity}
|
|
48
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
49
|
+
>
|
|
50
|
+
Open in Capacity — ledger, workloads, demand →
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
|
|
48
55
|
{/* Problem alert */}
|
|
49
56
|
{isNotReady && !operationalIssuesShown && (
|
|
50
57
|
<AlertBanner
|
|
@@ -91,20 +98,23 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
91
98
|
</PropertyList>
|
|
92
99
|
</Section>
|
|
93
100
|
|
|
94
|
-
{/*
|
|
101
|
+
{/* Karpenter reports provisioned resources here, not workload utilization. */}
|
|
95
102
|
{(statusResources.cpu || statusResources.memory) && (
|
|
96
|
-
<Section title="
|
|
103
|
+
<Section title="Provisioned Capacity" icon={BarChart3} defaultExpanded>
|
|
104
|
+
<p className="mb-2 text-xs text-theme-text-tertiary">
|
|
105
|
+
Resources provisioned by this NodePool versus its configured ceiling. This is not live workload usage.
|
|
106
|
+
</p>
|
|
97
107
|
<PropertyList>
|
|
98
108
|
{statusResources.cpu && (
|
|
99
109
|
<Property
|
|
100
110
|
label="CPU"
|
|
101
|
-
value={`${
|
|
111
|
+
value={`${formatCPUString(String(statusResources.cpu))} provisioned${spec.limits?.cpu ? ` / ${formatCPUString(String(spec.limits.cpu))} limit` : ''}`}
|
|
102
112
|
/>
|
|
103
113
|
)}
|
|
104
114
|
{statusResources.memory && (
|
|
105
115
|
<Property
|
|
106
116
|
label="Memory"
|
|
107
|
-
value={`${statusResources.memory}${spec.limits?.memory ? ` / ${spec.limits.memory}` : ''}`}
|
|
117
|
+
value={`${formatMemoryString(String(statusResources.memory))} provisioned${spec.limits?.memory ? ` / ${formatMemoryString(String(spec.limits.memory))} limit` : ''}`}
|
|
108
118
|
/>
|
|
109
119
|
)}
|
|
110
120
|
</PropertyList>
|
|
@@ -121,6 +131,9 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
121
131
|
{(disruption.expireAfter || templateExpireAfter) && (
|
|
122
132
|
<Property label="Expire After" value={disruption.expireAfter || templateExpireAfter} />
|
|
123
133
|
)}
|
|
134
|
+
{terminationGracePeriod && (
|
|
135
|
+
<Property label="Termination Grace Period" value={terminationGracePeriod} />
|
|
136
|
+
)}
|
|
124
137
|
</PropertyList>
|
|
125
138
|
{disruption.budgets && disruption.budgets.length > 0 && (
|
|
126
139
|
<div className="mt-2 space-y-1">
|
|
@@ -130,6 +143,11 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
130
143
|
{budget.nodes && <span>Nodes: {budget.nodes}</span>}
|
|
131
144
|
{budget.schedule && <span className="ml-2">Schedule: {budget.schedule}</span>}
|
|
132
145
|
{budget.duration && <span className="ml-2">Duration: {budget.duration}</span>}
|
|
146
|
+
{budget.reasons?.length > 0 && (
|
|
147
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
148
|
+
{budget.reasons.map((reason: string) => <Badge key={reason} tone="structural" size="sm">{reason}</Badge>)}
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
133
151
|
</div>
|
|
134
152
|
))}
|
|
135
153
|
</div>
|
|
@@ -193,6 +211,7 @@ export function KarpenterNodePoolRenderer({ data, onNavigate }: KarpenterNodePoo
|
|
|
193
211
|
<div className="flex items-center gap-2 text-sm">
|
|
194
212
|
<span className="text-theme-text-primary font-medium">{req.key}</span>
|
|
195
213
|
<span className="text-theme-text-tertiary">{req.operator}</span>
|
|
214
|
+
{req.minValues !== undefined && <Badge tone="note" size="sm">min {req.minValues}</Badge>}
|
|
196
215
|
</div>
|
|
197
216
|
{req.values && req.values.length > 0 && (
|
|
198
217
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { PodRenderer } from './PodRenderer'
|
|
4
|
+
import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
|
|
4
5
|
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
5
6
|
import type { ResolvedEnvFrom } from '../../../types'
|
|
7
|
+
import type { PodEnvironmentResponse } from '../../../types'
|
|
6
8
|
|
|
7
9
|
const pod = {
|
|
8
10
|
metadata: { name: 'api', namespace: 'default' },
|
|
@@ -52,6 +54,231 @@ describe('PodRenderer envFrom expansion', () => {
|
|
|
52
54
|
})
|
|
53
55
|
})
|
|
54
56
|
|
|
57
|
+
describe('PodRenderer resolved environment', () => {
|
|
58
|
+
it('explains Secret coverage without placing Secret values in the initial markup', () => {
|
|
59
|
+
const environment: PodEnvironmentResponse = {
|
|
60
|
+
containers: [{
|
|
61
|
+
name: 'api',
|
|
62
|
+
role: 'container',
|
|
63
|
+
rows: [
|
|
64
|
+
{ name: 'PUBLIC_URL', value: 'https://example.com', state: 'resolved', source: { kind: 'ConfigMap', name: 'shared', key: 'PUBLIC_URL' } },
|
|
65
|
+
{ name: 'API_TOKEN', state: 'masked', sensitive: true, source: { kind: 'Secret', name: 'shared', key: 'API_TOKEN' } },
|
|
66
|
+
],
|
|
67
|
+
}],
|
|
68
|
+
coverage: { observedSince: '2026-08-02T00:00:00Z' },
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
<PodRenderer
|
|
73
|
+
data={pod}
|
|
74
|
+
onCopy={() => undefined}
|
|
75
|
+
copied={null}
|
|
76
|
+
environment={environment}
|
|
77
|
+
onRevealEnvironment={async () => ({ value: 'sentinel-secret-value', encoding: 'utf8' })}
|
|
78
|
+
/>,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
expect(html).toContain('2 variables')
|
|
82
|
+
expect(html).toContain('1 from Secrets')
|
|
83
|
+
expect(html).toContain('Secret values stay hidden until you reveal them.')
|
|
84
|
+
expect(html).not.toContain('Value if restarted now')
|
|
85
|
+
expect(html).toContain('Only variables declared on the Pod are shown.')
|
|
86
|
+
expect(html).not.toContain('Changes observed')
|
|
87
|
+
expect(html).toContain('Secret<!-- -->/<!-- -->shared')
|
|
88
|
+
expect(html).toContain('flex-wrap items-center gap-x-1.5 gap-y-0.5')
|
|
89
|
+
expect(html).toContain('@container/env')
|
|
90
|
+
expect(html).toContain('table-fixed')
|
|
91
|
+
expect(html).toContain('w-[46%]')
|
|
92
|
+
expect(html).toContain('table-divide-subtle')
|
|
93
|
+
expect(html).not.toContain('overflow-x-auto')
|
|
94
|
+
expect(html).not.toContain('space-y-2.5 px-3 py-3 text-xs')
|
|
95
|
+
expect(html).not.toContain('title=')
|
|
96
|
+
expect(html).not.toContain('sentinel-secret-value')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('only allocates a status column when a row needs attention', () => {
|
|
100
|
+
const environment: PodEnvironmentResponse = {
|
|
101
|
+
containers: [{
|
|
102
|
+
name: 'api',
|
|
103
|
+
role: 'container',
|
|
104
|
+
rows: [{ name: 'PUBLIC_URL', value: 'https://example.com', state: 'resolved', source: { kind: 'Direct' } }],
|
|
105
|
+
}],
|
|
106
|
+
coverage: {},
|
|
107
|
+
}
|
|
108
|
+
const render = (value: PodEnvironmentResponse) => renderToString(
|
|
109
|
+
<ContainerEnvironmentSection
|
|
110
|
+
environment={value}
|
|
111
|
+
namespace="default"
|
|
112
|
+
onCopy={() => undefined}
|
|
113
|
+
copied={null}
|
|
114
|
+
/>,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const normal = render(environment)
|
|
118
|
+
expect(normal).toContain('w-[46%]')
|
|
119
|
+
expect(normal).not.toContain('>Status</span>')
|
|
120
|
+
expect(normal).toContain('lucide-equal')
|
|
121
|
+
expect(normal).not.toContain('lucide-pencil')
|
|
122
|
+
|
|
123
|
+
const optional = render({
|
|
124
|
+
...environment,
|
|
125
|
+
containers: [{
|
|
126
|
+
...environment.containers[0],
|
|
127
|
+
rows: [{ name: 'FEATURE', state: 'missing', optional: true, source: { kind: 'ConfigMap', name: 'settings', key: 'feature' }, message: 'Optional key feature is absent.' }],
|
|
128
|
+
}],
|
|
129
|
+
})
|
|
130
|
+
expect(optional).toContain('Not set')
|
|
131
|
+
expect(optional).toContain('w-[46%]')
|
|
132
|
+
expect(optional).not.toContain('>Status</span>')
|
|
133
|
+
expect(optional).toContain('!cursor-help')
|
|
134
|
+
expect(optional).not.toContain('<button class="badge-sm')
|
|
135
|
+
|
|
136
|
+
const navigable = renderToString(
|
|
137
|
+
<ContainerEnvironmentSection
|
|
138
|
+
environment={{
|
|
139
|
+
...environment,
|
|
140
|
+
containers: [{
|
|
141
|
+
...environment.containers[0],
|
|
142
|
+
rows: [{ name: 'PUBLIC_URL', value: 'https://example.com', state: 'resolved', source: { kind: 'ConfigMap', name: 'settings', key: 'url' } }],
|
|
143
|
+
}],
|
|
144
|
+
}}
|
|
145
|
+
namespace="default"
|
|
146
|
+
onNavigate={() => undefined}
|
|
147
|
+
onCopy={() => undefined}
|
|
148
|
+
copied={null}
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(navigable).toContain('cursor-pointer')
|
|
152
|
+
expect(navigable).toContain('<button class="badge-sm')
|
|
153
|
+
|
|
154
|
+
const runtime = render({
|
|
155
|
+
...environment,
|
|
156
|
+
containers: [{
|
|
157
|
+
...environment.containers[0],
|
|
158
|
+
rows: [{ name: 'RUNTIME_VALUE', value: '$(SERVICE_HOST)', state: 'unavailable', runtimeDependent: true, source: { kind: 'Direct' } }],
|
|
159
|
+
}],
|
|
160
|
+
})
|
|
161
|
+
expect(runtime).toContain('Set at startup')
|
|
162
|
+
expect(runtime).toContain('w-[46%]')
|
|
163
|
+
expect(runtime).not.toContain('>Status</span>')
|
|
164
|
+
|
|
165
|
+
const restartBlocked = render({
|
|
166
|
+
...environment,
|
|
167
|
+
containers: [{
|
|
168
|
+
...environment.containers[0],
|
|
169
|
+
rows: [{
|
|
170
|
+
name: 'REQUIRED',
|
|
171
|
+
state: 'missing',
|
|
172
|
+
missingImpact: 'restartBlocked',
|
|
173
|
+
source: { kind: 'ConfigMap', name: 'settings', key: 'required' },
|
|
174
|
+
message: 'The running container cannot restart while this is missing.',
|
|
175
|
+
evidence: { kind: 'removed', changedAt: '2026-08-02T00:00:00Z', message: 'Removed after the container started.' },
|
|
176
|
+
}],
|
|
177
|
+
}],
|
|
178
|
+
})
|
|
179
|
+
expect(restartBlocked).toContain('Restart blocked')
|
|
180
|
+
expect(restartBlocked).not.toContain('>Removed after start</span>')
|
|
181
|
+
expect(restartBlocked).toContain('>Status</span>')
|
|
182
|
+
|
|
183
|
+
const startupBlocked = render({
|
|
184
|
+
...environment,
|
|
185
|
+
containers: [{
|
|
186
|
+
...environment.containers[0],
|
|
187
|
+
rows: [{ name: 'REQUIRED', state: 'missing', missingImpact: 'startupBlocked', source: { kind: 'Secret', name: 'credentials', key: 'required' } }],
|
|
188
|
+
}],
|
|
189
|
+
})
|
|
190
|
+
expect(startupBlocked).toContain('Prevents start')
|
|
191
|
+
expect(startupBlocked).toContain('>Status</span>')
|
|
192
|
+
|
|
193
|
+
const limitedHistory = render({
|
|
194
|
+
...environment,
|
|
195
|
+
coverage: { degraded: true, degradedReason: 'Change history is limited to this Radar session.', saturated: true },
|
|
196
|
+
})
|
|
197
|
+
expect(limitedHistory).toContain('Change history is limited to this Radar session.')
|
|
198
|
+
expect(limitedHistory).toContain('Some recent changes may not be shown.')
|
|
199
|
+
|
|
200
|
+
const changed = render({
|
|
201
|
+
...environment,
|
|
202
|
+
containers: [{
|
|
203
|
+
...environment.containers[0],
|
|
204
|
+
rows: [{
|
|
205
|
+
...environment.containers[0].rows[0],
|
|
206
|
+
evidence: { kind: 'modified', changedAt: '2026-08-02T00:00:00Z', message: 'Changed after the container started.' },
|
|
207
|
+
}],
|
|
208
|
+
}],
|
|
209
|
+
})
|
|
210
|
+
expect(changed).toContain('w-[42%]')
|
|
211
|
+
expect(changed).toContain('>Status</span>')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('selects the first regular container instead of an init container', () => {
|
|
215
|
+
const html = renderToString(
|
|
216
|
+
<ContainerEnvironmentSection
|
|
217
|
+
environment={{
|
|
218
|
+
containers: [
|
|
219
|
+
{ name: 'migrate', role: 'init', rows: [{ name: 'INIT_ONLY', value: 'yes', state: 'resolved', source: { kind: 'Direct' } }] },
|
|
220
|
+
{ name: 'api', role: 'container', rows: [{ name: 'API_ONLY', value: 'yes', state: 'resolved', source: { kind: 'Direct' } }] },
|
|
221
|
+
],
|
|
222
|
+
coverage: {},
|
|
223
|
+
}}
|
|
224
|
+
namespace="default"
|
|
225
|
+
onCopy={() => undefined}
|
|
226
|
+
copied={null}
|
|
227
|
+
/>,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
expect(html).toContain('API_ONLY')
|
|
231
|
+
expect(html).not.toContain('INIT_ONLY')
|
|
232
|
+
expect(html).toContain('aria-selected="false"')
|
|
233
|
+
expect(html).toContain('aria-selected="true"')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('offers copy for concrete values and confirms success', () => {
|
|
237
|
+
const environment: PodEnvironmentResponse = {
|
|
238
|
+
containers: [{
|
|
239
|
+
name: 'api',
|
|
240
|
+
role: 'container',
|
|
241
|
+
rows: [{ name: 'PUBLIC_URL', value: 'https://example.com', state: 'resolved', source: { kind: 'Direct' } }],
|
|
242
|
+
}],
|
|
243
|
+
coverage: {},
|
|
244
|
+
}
|
|
245
|
+
const render = (copied: string | null) => renderToString(
|
|
246
|
+
<ContainerEnvironmentSection
|
|
247
|
+
environment={environment}
|
|
248
|
+
namespace="default"
|
|
249
|
+
onCopy={() => undefined}
|
|
250
|
+
copied={copied}
|
|
251
|
+
/>,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
const ready = render(null)
|
|
255
|
+
expect(ready).toContain('group-hover/value:opacity-100')
|
|
256
|
+
expect(ready).toContain('aria-label="Copy value"')
|
|
257
|
+
expect(ready).toContain('lucide-copy')
|
|
258
|
+
|
|
259
|
+
const confirmed = render('api\u0000PUBLIC_URL')
|
|
260
|
+
expect(confirmed).toContain('aria-label="Value copied"')
|
|
261
|
+
expect(confirmed).toContain('lucide-check')
|
|
262
|
+
expect(confirmed).toContain('text-green-400')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('keeps the declaration view when resolved sources contain no usable variables', () => {
|
|
266
|
+
const html = renderToString(
|
|
267
|
+
<PodRenderer
|
|
268
|
+
data={pod}
|
|
269
|
+
onCopy={() => undefined}
|
|
270
|
+
copied={null}
|
|
271
|
+
environment={{ containers: [{ name: 'api', role: 'container', rows: [] }], coverage: {} }}
|
|
272
|
+
/>,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(html).toContain('Environment Variables')
|
|
276
|
+
expect(html).toContain('ConfigMap')
|
|
277
|
+
expect(html).toContain('Secret')
|
|
278
|
+
expect(html).toContain('(all keys)')
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
55
282
|
describe('PodRenderer issues banner', () => {
|
|
56
283
|
it('renders pod status messages for evicted pods', () => {
|
|
57
284
|
const html = renderToString(
|
|
@@ -12,10 +12,17 @@ import {
|
|
|
12
12
|
import { resolvedEnvFromKey } from '../../../utils/env-from'
|
|
13
13
|
import { detectBlastRadius, rulePermissivenessScore } from '../../../utils/rbac-blast-radius'
|
|
14
14
|
import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
PodEnvironmentResponse,
|
|
17
|
+
PodEnvironmentRevealResponse,
|
|
18
|
+
ResolvedEnvFrom,
|
|
19
|
+
RBACSubjectResponse,
|
|
20
|
+
RBACPolicyRule,
|
|
21
|
+
} from '../../../types'
|
|
16
22
|
import { Tooltip } from '../../ui/Tooltip'
|
|
17
23
|
import { MetricsChart } from '../../ui/MetricsChart'
|
|
18
24
|
import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
|
|
25
|
+
import { ContainerEnvironmentSection } from './ContainerEnvironmentSection'
|
|
19
26
|
|
|
20
27
|
function parseValidDate(dateStr: string): Date | null {
|
|
21
28
|
const d = new Date(dateStr)
|
|
@@ -63,6 +70,10 @@ interface PodRendererProps {
|
|
|
63
70
|
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
64
71
|
/** When provided, container-level Logs buttons call this instead of onOpenLogsPanel */
|
|
65
72
|
onOpenLogs?: (podName: string, containerName: string) => void
|
|
73
|
+
/** Host-wired for pending pods on Karpenter clusters: opens the Capacity
|
|
74
|
+
* Demand view, which groups pending pods by scheduling signature and
|
|
75
|
+
* evaluates every NodePool against them. */
|
|
76
|
+
onEvaluateCapacity?: () => void
|
|
66
77
|
// Platform capabilities
|
|
67
78
|
canExec?: boolean
|
|
68
79
|
canViewLogs?: boolean
|
|
@@ -85,6 +96,10 @@ interface PodRendererProps {
|
|
|
85
96
|
* When provided, expands ConfigMap/Secret keys inline instead of showing "(all keys)".
|
|
86
97
|
*/
|
|
87
98
|
resolvedEnvFrom?: ResolvedEnvFrom
|
|
99
|
+
environment?: PodEnvironmentResponse
|
|
100
|
+
environmentLoading?: boolean
|
|
101
|
+
environmentError?: Error | null
|
|
102
|
+
onRevealEnvironment?: (container: string, variable: string) => Promise<PodEnvironmentRevealResponse>
|
|
88
103
|
/**
|
|
89
104
|
* RBAC reverse-lookup for the Pod's ServiceAccount. Undefined means the host
|
|
90
105
|
* didn't wire the fetch (Permissions section is omitted). Null means the
|
|
@@ -251,6 +266,7 @@ export function PodRenderer({
|
|
|
251
266
|
copied,
|
|
252
267
|
onNavigate,
|
|
253
268
|
onOpenLogs: onOpenLogsOverride,
|
|
269
|
+
onEvaluateCapacity,
|
|
254
270
|
canExec,
|
|
255
271
|
canViewLogs,
|
|
256
272
|
canPortForward,
|
|
@@ -264,6 +280,10 @@ export function PodRenderer({
|
|
|
264
280
|
renderImageBrowser,
|
|
265
281
|
renderPodBrowser,
|
|
266
282
|
resolvedEnvFrom,
|
|
283
|
+
environment,
|
|
284
|
+
environmentLoading,
|
|
285
|
+
environmentError,
|
|
286
|
+
onRevealEnvironment,
|
|
267
287
|
rbacData,
|
|
268
288
|
rbacLoading,
|
|
269
289
|
rbacError,
|
|
@@ -272,6 +292,12 @@ export function PodRenderer({
|
|
|
272
292
|
const containers = data.spec?.containers || []
|
|
273
293
|
const initContainers = data.spec?.initContainers || []
|
|
274
294
|
const initContainerStatuses = data.status?.initContainerStatuses || []
|
|
295
|
+
const hasEnvironmentDeclarations = [...initContainers, ...containers].some(
|
|
296
|
+
(container: any) => container.env?.length > 0 || container.envFrom?.length > 0,
|
|
297
|
+
)
|
|
298
|
+
const hasResolvedEnvironmentRows = environment?.containers.some(
|
|
299
|
+
container => container.rows.length > 0 || container.truncated,
|
|
300
|
+
) ?? false
|
|
275
301
|
|
|
276
302
|
const namespace = data.metadata?.namespace
|
|
277
303
|
const podName = data.metadata?.name
|
|
@@ -366,6 +392,18 @@ export function PodRenderer({
|
|
|
366
392
|
</AlertBanner>
|
|
367
393
|
)}
|
|
368
394
|
|
|
395
|
+
{onEvaluateCapacity && (
|
|
396
|
+
<div className="mb-3">
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
onClick={onEvaluateCapacity}
|
|
400
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
401
|
+
>
|
|
402
|
+
Evaluate against Karpenter NodePools →
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
|
|
369
407
|
{/* Status section */}
|
|
370
408
|
<Section title="Status" icon={Server}>
|
|
371
409
|
<PropertyList>
|
|
@@ -715,7 +753,30 @@ export function PodRenderer({
|
|
|
715
753
|
</Section>
|
|
716
754
|
|
|
717
755
|
{/* Environment Variables */}
|
|
718
|
-
{
|
|
756
|
+
{environment && hasResolvedEnvironmentRows && (
|
|
757
|
+
<ContainerEnvironmentSection
|
|
758
|
+
environment={environment}
|
|
759
|
+
namespace={namespace}
|
|
760
|
+
onNavigate={onNavigate}
|
|
761
|
+
onReveal={onRevealEnvironment}
|
|
762
|
+
onCopy={onCopy}
|
|
763
|
+
copied={copied}
|
|
764
|
+
/>
|
|
765
|
+
)}
|
|
766
|
+
{hasEnvironmentDeclarations && environmentLoading && !environment && (
|
|
767
|
+
<Section title="Environment Variables" icon={List} defaultExpanded={false}>
|
|
768
|
+
<div className="text-xs text-theme-text-tertiary">Loading variable sources…</div>
|
|
769
|
+
</Section>
|
|
770
|
+
)}
|
|
771
|
+
{hasEnvironmentDeclarations && environmentError && !environment && (
|
|
772
|
+
<Section title="Environment Variables" icon={List} defaultExpanded={false}>
|
|
773
|
+
<div className="space-y-2 text-xs">
|
|
774
|
+
<p className="text-theme-text-secondary">Variable sources could not be loaded.</p>
|
|
775
|
+
<p className="text-theme-text-tertiary">{environmentError.message}</p>
|
|
776
|
+
</div>
|
|
777
|
+
</Section>
|
|
778
|
+
)}
|
|
779
|
+
{(!environment || !hasResolvedEnvironmentRows) && !environmentLoading && !environmentError && hasEnvironmentDeclarations && (
|
|
719
780
|
<EnvVarsSection
|
|
720
781
|
initContainers={initContainers}
|
|
721
782
|
containers={containers}
|
|
@@ -80,6 +80,7 @@ export * from './OrderRenderer'
|
|
|
80
80
|
export * from './PersistentVolumeRenderer'
|
|
81
81
|
export * from './PodDisruptionBudgetRenderer'
|
|
82
82
|
export * from './PodRenderer'
|
|
83
|
+
export * from './ContainerEnvironmentSection'
|
|
83
84
|
export * from './PodMonitorRenderer'
|
|
84
85
|
export * from './PriorityClassRenderer'
|
|
85
86
|
export * from './prometheus-cells'
|
|
@@ -256,6 +256,10 @@ export interface RendererOverrides {
|
|
|
256
256
|
NodeRenderer?: React.ComponentType<{
|
|
257
257
|
data: any; relationships?: Relationships
|
|
258
258
|
}>
|
|
259
|
+
KarpenterNodePoolRenderer?: React.ComponentType<{
|
|
260
|
+
data: any
|
|
261
|
+
onNavigate?: (ref: ResourceRef) => void
|
|
262
|
+
}>
|
|
259
263
|
ServiceRenderer?: React.ComponentType<{
|
|
260
264
|
data: any; onCopy: CopyHandler; copied: string | null
|
|
261
265
|
onNavigate?: (ref: ResourceRef) => void
|
|
@@ -487,6 +491,7 @@ export function ResourceRendererDispatch({
|
|
|
487
491
|
const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
|
|
488
492
|
|
|
489
493
|
const PodComp = rendererOverrides?.PodRenderer ?? PodRenderer
|
|
494
|
+
const KarpenterNodePoolComp = rendererOverrides?.KarpenterNodePoolRenderer ?? KarpenterNodePoolRenderer
|
|
490
495
|
const WorkloadComp = rendererOverrides?.WorkloadRenderer ?? WorkloadRenderer
|
|
491
496
|
const NodeComp = rendererOverrides?.NodeRenderer ?? NodeRenderer
|
|
492
497
|
const ServiceComp = rendererOverrides?.ServiceRenderer ?? ServiceRenderer
|
|
@@ -570,7 +575,7 @@ export function ResourceRendererDispatch({
|
|
|
570
575
|
{kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
|
|
571
576
|
{kind === 'alerts' && <AlertRenderer data={data} />}
|
|
572
577
|
{kind === 'applications' && <ArgoApplicationRenderer data={data} />}
|
|
573
|
-
{kind === 'nodepools' && <
|
|
578
|
+
{kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
|
|
574
579
|
{kind === 'nodeclaims' && <KarpenterNodeClaimRenderer data={data} onNavigate={onNavigate} />}
|
|
575
580
|
{kind === 'ec2nodeclasses' && <KarpenterEC2NodeClassRenderer data={data} />}
|
|
576
581
|
{kind === 'scaledobjects' && <KedaScaledObjectRenderer data={data} onNavigate={onNavigate} />}
|
|
@@ -81,9 +81,10 @@ interface SectionProps {
|
|
|
81
81
|
icon?: React.ComponentType<{ className?: string }>
|
|
82
82
|
children: React.ReactNode
|
|
83
83
|
defaultExpanded?: boolean
|
|
84
|
+
contentClassName?: string
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
export function Section({ title, icon: Icon, children, defaultExpanded = true }: SectionProps) {
|
|
87
|
+
export function Section({ title, icon: Icon, children, defaultExpanded = true, contentClassName }: SectionProps) {
|
|
87
88
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
|
88
89
|
|
|
89
90
|
return (
|
|
@@ -101,7 +102,7 @@ export function Section({ title, icon: Icon, children, defaultExpanded = true }:
|
|
|
101
102
|
style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
|
|
102
103
|
>
|
|
103
104
|
<div className="overflow-hidden">
|
|
104
|
-
<div className=
|
|
105
|
+
<div className={contentClassName ?? 'pl-6'}>{children}</div>
|
|
105
106
|
</div>
|
|
106
107
|
</div>
|
|
107
108
|
</div>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export { Tooltip } from './Tooltip'
|
|
1
|
+
export { Tooltip, WithTooltip } from './Tooltip'
|
|
2
|
+
export { Badge } from './Badge'
|
|
3
|
+
export type { BadgeSeverity, BadgeSize, BadgeTone } from './Badge'
|
|
2
4
|
export { FreshnessControl } from './FreshnessControl'
|
|
3
5
|
export type { FreshnessMode, FreshnessConnection } from './FreshnessControl'
|
|
4
6
|
export { PaneLoader } from './PaneLoader'
|
|
@@ -156,6 +156,9 @@ interface WorkloadViewProps {
|
|
|
156
156
|
workloadPods?: WorkloadPodInfo[]
|
|
157
157
|
workloadPodsLoading?: boolean
|
|
158
158
|
workloadPodsError?: Error | null
|
|
159
|
+
/** Wired by the host only while this workload has pods awaiting scheduling
|
|
160
|
+
* and Karpenter is available — absent otherwise, so no dead affordance. */
|
|
161
|
+
onEvaluateCapacity?: () => void
|
|
159
162
|
/** Full objects for service/route refs related to this workload. Optional;
|
|
160
163
|
* overview falls back to relationship refs when hosts do not fetch them. */
|
|
161
164
|
servingResources?: ServingResourceDetail[]
|
|
@@ -349,6 +352,7 @@ export function WorkloadView({
|
|
|
349
352
|
workloadPods,
|
|
350
353
|
workloadPodsLoading = false,
|
|
351
354
|
workloadPodsError = null,
|
|
355
|
+
onEvaluateCapacity,
|
|
352
356
|
servingResources,
|
|
353
357
|
renderServicePortAction,
|
|
354
358
|
renderServicePortPanel,
|
|
@@ -1027,6 +1031,7 @@ export function WorkloadView({
|
|
|
1027
1031
|
extraContent={renderOverviewExtra && renderOverviewExtra({ kind, namespace, name })}
|
|
1028
1032
|
introContent={overviewIntro}
|
|
1029
1033
|
leadContent={hasOperationalIssues && renderOverviewLead ? renderOverviewLead({ kind, namespace, name }) : undefined}
|
|
1034
|
+
onEvaluateCapacity={onEvaluateCapacity}
|
|
1030
1035
|
/>
|
|
1031
1036
|
)}
|
|
1032
1037
|
{effectiveTab === 'topology' && (
|
|
@@ -1471,6 +1476,7 @@ function InfoTab({
|
|
|
1471
1476
|
extraContent,
|
|
1472
1477
|
introContent,
|
|
1473
1478
|
leadContent,
|
|
1479
|
+
onEvaluateCapacity,
|
|
1474
1480
|
}: {
|
|
1475
1481
|
resource: any
|
|
1476
1482
|
selectedResource: SelectedResource
|
|
@@ -1481,6 +1487,7 @@ function InfoTab({
|
|
|
1481
1487
|
workloadPods?: WorkloadPodInfo[]
|
|
1482
1488
|
workloadPodsLoading?: boolean
|
|
1483
1489
|
workloadPodsError?: Error | null
|
|
1490
|
+
onEvaluateCapacity?: () => void
|
|
1484
1491
|
servingResources?: ServingResourceDetail[]
|
|
1485
1492
|
renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
1486
1493
|
renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
@@ -1591,6 +1598,7 @@ function InfoTab({
|
|
|
1591
1598
|
extraContent={extraContent}
|
|
1592
1599
|
introContent={introContent}
|
|
1593
1600
|
leadContent={leadContent}
|
|
1601
|
+
onEvaluateCapacity={onEvaluateCapacity}
|
|
1594
1602
|
/>
|
|
1595
1603
|
)
|
|
1596
1604
|
}
|
|
@@ -1646,6 +1654,7 @@ function WorkloadOverviewTab({
|
|
|
1646
1654
|
extraContent,
|
|
1647
1655
|
introContent,
|
|
1648
1656
|
leadContent,
|
|
1657
|
+
onEvaluateCapacity,
|
|
1649
1658
|
}: {
|
|
1650
1659
|
resource: any
|
|
1651
1660
|
selectedResource: SelectedResource
|
|
@@ -1655,6 +1664,7 @@ function WorkloadOverviewTab({
|
|
|
1655
1664
|
workloadPods?: WorkloadPodInfo[]
|
|
1656
1665
|
workloadPodsLoading?: boolean
|
|
1657
1666
|
workloadPodsError?: Error | null
|
|
1667
|
+
onEvaluateCapacity?: () => void
|
|
1658
1668
|
servingResources?: ServingResourceDetail[]
|
|
1659
1669
|
renderServicePortAction?: (props: ServicePortRenderProps) => ReactNode
|
|
1660
1670
|
renderServicePortPanel?: (props: ServicePortRenderProps) => ReactNode
|
|
@@ -1707,6 +1717,18 @@ function WorkloadOverviewTab({
|
|
|
1707
1717
|
servingSummary={showServingPath ? buildServingStripSummary(servingRelationshipGroups, servingResources) : undefined}
|
|
1708
1718
|
/>
|
|
1709
1719
|
|
|
1720
|
+
{onEvaluateCapacity && (
|
|
1721
|
+
<div>
|
|
1722
|
+
<button
|
|
1723
|
+
type="button"
|
|
1724
|
+
onClick={onEvaluateCapacity}
|
|
1725
|
+
className="text-xs font-medium text-accent-text hover:underline"
|
|
1726
|
+
>
|
|
1727
|
+
Evaluate pending pods against Karpenter NodePools →
|
|
1728
|
+
</button>
|
|
1729
|
+
</div>
|
|
1730
|
+
)}
|
|
1731
|
+
|
|
1710
1732
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
1711
1733
|
<div className="space-y-4">
|
|
1712
1734
|
<div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|