@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.
@@ -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
- {/* Resource Usage from status.resources vs spec.limits */}
101
+ {/* Karpenter reports provisioned resources here, not workload utilization. */}
95
102
  {(statusResources.cpu || statusResources.memory) && (
96
- <Section title="Resource Usage" icon={BarChart3} defaultExpanded>
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={`${formatCpuCores(statusResources.cpu)}${spec.limits?.cpu ? ` / ${formatCpuCores(spec.limits.cpu)}` : ''}`}
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 { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
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
- {[...initContainers, ...containers].some((c: any) => c.env?.length > 0 || c.envFrom?.length > 0) && (
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' && <KarpenterNodePoolRenderer data={data} onNavigate={onNavigate} />}
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="pl-6">{children}</div>
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)]">