@skyhook-io/radar-app 1.9.0 → 1.9.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.
Files changed (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. package/src/utils/shell-safe.ts +21 -0
@@ -0,0 +1,1626 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { CircleHelp, Layers3 } from "lucide-react";
3
+ import {
4
+ WithTooltip,
5
+ type CapacityLedger,
6
+ type CapacityMemberListResponse,
7
+ type CapacityMemberType,
8
+ type CapacityPoolMember,
9
+ type CapacityPoolObservation,
10
+ type CapacityQuantityObservation,
11
+ type CapacitySourceCoverage,
12
+ } from "@skyhook-io/k8s-ui";
13
+ import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
14
+ import {
15
+ isCapacityCursorInvalidError,
16
+ isNotFoundError,
17
+ useCapacityPoolDetail,
18
+ useCapacityPoolMembers,
19
+ } from "../../api/client";
20
+ import type { SelectedResource } from "../../types";
21
+ import { refToSelectedResource } from "../../utils/navigation";
22
+ import {
23
+ ActualUsageDetail,
24
+ ActualUsageInline,
25
+ CapacityFreshness,
26
+ CapacityIssueEvidence,
27
+ CertaintyGlyph,
28
+ ClaimStageBadge,
29
+ conditionSummary,
30
+ ConditionBadge,
31
+ coverageHasObservations,
32
+ coverageIsDenied,
33
+ coverageIsLowerBound,
34
+ coverageMessage,
35
+ DeniedBadge,
36
+ EmptyState,
37
+ errorMessage,
38
+ formatQuantity,
39
+ formatTaint,
40
+ humanizeCode,
41
+ identityKey,
42
+ identityToSelectedResource,
43
+ InlineEmpty,
44
+ integrationBlock,
45
+ KeyValueRows,
46
+ LinkButton,
47
+ memberCoverageSource,
48
+ MiniCount,
49
+ NodeReadyBadge,
50
+ Notice,
51
+ observationTitle,
52
+ PoolReadyBadge,
53
+ POOL_SECTIONS,
54
+ PressureDetail,
55
+ quantityResourceRank,
56
+ QuantityInline,
57
+ RefreshError,
58
+ resourceLabel,
59
+ ResourceLink,
60
+ ROW_HOVER,
61
+ ScopeBadges,
62
+ ScrollableContent,
63
+ SectionCard,
64
+ TABLE_HEAD,
65
+ TABLE_WRAP,
66
+ TBODY,
67
+ TD,
68
+ TH,
69
+ TokenGroup,
70
+ useCapacityCursorRecovery,
71
+ useCapacityPagination,
72
+ type CapacityConnectionState,
73
+ type PoolSection,
74
+ } from "./shared";
75
+
76
+ export function CapacityPoolDetail({
77
+ name,
78
+ section,
79
+ connectionState,
80
+ onNavigate,
81
+ onOpenResource,
82
+ }: {
83
+ name: string;
84
+ section: PoolSection;
85
+ connectionState: CapacityConnectionState;
86
+ onNavigate: (path: string) => void;
87
+ onOpenResource: (resource: SelectedResource) => void;
88
+ }) {
89
+ const query = useCapacityPoolDetail(name);
90
+ const poolPath = `/capacity/pools/${encodeURIComponent(name)}`;
91
+
92
+ if (isNotFoundError(query.error)) {
93
+ return (
94
+ <ScrollableContent>
95
+ <div className="mx-auto max-w-xl">
96
+ <SectionCard title="Not found" bodyClassName="px-4 py-4">
97
+ <p className="text-sm text-theme-text-secondary">
98
+ NodePool “<span className="font-mono">{name}</span>” no longer
99
+ exists. It may have been deleted since this link was created.
100
+ Claims it owned appear as orphans in the Overview until they
101
+ drain.
102
+ </p>
103
+ <LinkButton
104
+ className="mt-3"
105
+ onClick={() => onNavigate("/capacity")}
106
+ >
107
+ ← Back to Capacity overview
108
+ </LinkButton>
109
+ </SectionCard>
110
+ </div>
111
+ </ScrollableContent>
112
+ );
113
+ }
114
+
115
+ const blocked = integrationBlock(
116
+ query.data,
117
+ query.error,
118
+ query.isLoading,
119
+ `Loading ${name}…`,
120
+ );
121
+ if (blocked) return blocked;
122
+
123
+ const response = query.data;
124
+ if (response && !coverageHasObservations(response.coverage.nodePools)) {
125
+ return (
126
+ <EmptyState
127
+ icon={Layers3}
128
+ title="NodePool inventory unavailable"
129
+ detail={coverageMessage(
130
+ response.coverage.nodePools,
131
+ "NodePool inventory",
132
+ )}
133
+ />
134
+ );
135
+ }
136
+ const pool = response?.pool;
137
+ if (!response || !pool) {
138
+ return (
139
+ <EmptyState
140
+ icon={CircleHelp}
141
+ title="NodePool unavailable"
142
+ detail="The Capacity API did not return an observation for this NodePool."
143
+ />
144
+ );
145
+ }
146
+
147
+ const staleGeneration =
148
+ pool.observedGeneration !== undefined &&
149
+ pool.observedGeneration !== pool.generation;
150
+
151
+ return (
152
+ <ScrollableContent>
153
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
154
+ <LinkButton onClick={() => onNavigate("/capacity")}>
155
+ ← Overview
156
+ </LinkButton>
157
+ <Badge tone="structural" size="sm">
158
+ NodePool
159
+ </Badge>
160
+ <h1 className="font-mono text-base font-semibold text-theme-text-primary">
161
+ {pool.resource.ref.name}
162
+ </h1>
163
+ <PoolReadyBadge ready={pool.ready} />
164
+ {pool.mode !== "unknown" && (
165
+ <Badge tone="structural" size="sm">
166
+ {pool.mode}
167
+ </Badge>
168
+ )}
169
+ <WithTooltip tip="metadata.generation vs status.observedGeneration">
170
+ <span className="font-mono text-xs text-theme-text-tertiary">
171
+ generation {pool.generation}
172
+ {pool.observedGeneration !== undefined
173
+ ? ` · observed ${pool.observedGeneration}`
174
+ : ""}
175
+ </span>
176
+ </WithTooltip>
177
+ {staleGeneration && (
178
+ <WithTooltip tip="The controller has not yet observed the latest spec. Status below may describe the previous generation.">
179
+ <Badge severity="warning" size="sm">
180
+ Stale generation
181
+ </Badge>
182
+ </WithTooltip>
183
+ )}
184
+ <div className="flex-1" />
185
+ <ScopeBadges coverage={response.coverage} />
186
+ <LinkButton
187
+ onClick={() =>
188
+ onNavigate(`/capacity/activity?pool=${encodeURIComponent(name)}`)
189
+ }
190
+ >
191
+ Pool activity →
192
+ </LinkButton>
193
+ <LinkButton
194
+ title="Raw resource, YAML, events"
195
+ onClick={() =>
196
+ onOpenResource(identityToSelectedResource(pool.resource))
197
+ }
198
+ >
199
+ Resource drawer →
200
+ </LinkButton>
201
+ <CapacityFreshness
202
+ meta={response}
203
+ query={query}
204
+ connectionState={connectionState}
205
+ />
206
+ </div>
207
+
208
+ {pool.nodeClass && (
209
+ <div className="flex items-center gap-2 text-xs text-theme-text-secondary">
210
+ <span>NodeClass</span>
211
+ <LinkButton
212
+ className="font-mono text-xs font-medium text-accent-text hover:underline"
213
+ onClick={() =>
214
+ onOpenResource(refToSelectedResource(pool.nodeClass!.reference))
215
+ }
216
+ >
217
+ {pool.nodeClass.reference.kind}/{pool.nodeClass.reference.name}
218
+ </LinkButton>
219
+ <PoolReadyBadge ready={pool.nodeClass.ready} />
220
+ </div>
221
+ )}
222
+
223
+ {query.error && <RefreshError message={errorMessage(query.error)} />}
224
+
225
+ <div className="flex gap-1 border-b border-theme-border">
226
+ {POOL_SECTIONS.map((tab) => (
227
+ <button
228
+ key={tab.id}
229
+ type="button"
230
+ aria-selected={section === tab.id}
231
+ onClick={() =>
232
+ onNavigate(
233
+ tab.id === "summary" ? poolPath : `${poolPath}/${tab.id}`,
234
+ )
235
+ }
236
+ className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${
237
+ section === tab.id
238
+ ? "border-skyhook-500 text-theme-text-primary"
239
+ : "border-transparent text-theme-text-secondary hover:text-theme-text-primary"
240
+ }`}
241
+ >
242
+ {tab.label}
243
+ </button>
244
+ ))}
245
+ </div>
246
+
247
+ {section === "summary" && (
248
+ <PoolSummaryTab
249
+ pool={pool}
250
+ onNavigate={onNavigate}
251
+ onOpenResource={onOpenResource}
252
+ />
253
+ )}
254
+ {section === "workloads" && (
255
+ <PoolWorkloadsTab name={name} onOpenResource={onOpenResource} />
256
+ )}
257
+ {section === "members" && (
258
+ <PoolMembersTab name={name} onOpenResource={onOpenResource} />
259
+ )}
260
+ {section === "configuration" && (
261
+ <PoolConfigurationTab pool={pool} onOpenResource={onOpenResource} />
262
+ )}
263
+ </ScrollableContent>
264
+ );
265
+ }
266
+
267
+ // ---------------------------------------------------------------- ledger
268
+
269
+ const LEDGER_COLUMNS: {
270
+ key: keyof CapacityLedger | "usage";
271
+ header: string;
272
+ tooltip: string;
273
+ }[] = [
274
+ {
275
+ key: "configuredLimit",
276
+ header: "Configured limit",
277
+ tooltip: "Declared ceiling on total provisioned capacity (spec.limits)",
278
+ },
279
+ {
280
+ key: "provisioned",
281
+ header: "Provisioned",
282
+ tooltip:
283
+ "Capacity Karpenter has committed for this pool (status.resources) — includes claims still launching",
284
+ },
285
+ {
286
+ key: "inFlightCapacity",
287
+ header: "In flight",
288
+ tooltip:
289
+ "Capacity of claims launched but not yet registered. Already counted inside Provisioned — shown separately to expose the launching share, not to be added on top",
290
+ },
291
+ {
292
+ key: "limitHeadroom",
293
+ header: "Limit headroom",
294
+ tooltip:
295
+ "Configured limit minus provisioned — Karpenter's actual provisioning gate (in-flight claims are already inside provisioned)",
296
+ },
297
+ {
298
+ key: "allocatable",
299
+ header: "Node allocatable",
300
+ tooltip: "What kubelets offer the scheduler after reservations",
301
+ },
302
+ {
303
+ key: "scheduledRequests",
304
+ header: "Scheduled requests",
305
+ tooltip: "Sum of scheduled pod requests on this pool's nodes",
306
+ },
307
+ {
308
+ key: "aggregateUnallocatedRequests",
309
+ header: "Unallocated ⚠",
310
+ tooltip: "Allocatable minus requests, summed across nodes. NOT bin-packed.",
311
+ },
312
+ {
313
+ key: "usage",
314
+ header: "Actual usage",
315
+ tooltip:
316
+ "Point-in-time usage from metrics — efficiency signal, never scheduler headroom",
317
+ },
318
+ ];
319
+
320
+ function ledgerResourceKeys(ledger: CapacityLedger): string[] {
321
+ const keys = new Set<string>();
322
+ const collect = (obs?: CapacityQuantityObservation) => {
323
+ if (obs) Object.keys(obs.resources).forEach((key) => keys.add(key));
324
+ };
325
+ collect(ledger.allocatable);
326
+ collect(ledger.provisioned);
327
+ collect(ledger.configuredLimit);
328
+ collect(ledger.scheduledRequests);
329
+ collect(ledger.aggregateUnallocatedRequests);
330
+ collect(ledger.inFlightCapacity);
331
+ ledger.actualUsage?.utilization.forEach((entry) => keys.add(entry.resource));
332
+ return [...keys].sort(
333
+ (left, right) =>
334
+ quantityResourceRank(left) - quantityResourceRank(right) ||
335
+ left.localeCompare(right),
336
+ );
337
+ }
338
+
339
+ function LedgerValue({
340
+ observation,
341
+ resource,
342
+ prefix,
343
+ }: {
344
+ observation?: CapacityQuantityObservation;
345
+ resource: string;
346
+ prefix?: string;
347
+ }) {
348
+ const raw = observation?.resources[resource];
349
+ if (!observation || raw === undefined)
350
+ return <span className="text-theme-text-tertiary">—</span>;
351
+ return (
352
+ <WithTooltip tip={observationTitle(observation)}>
353
+ <span>
354
+ {prefix}
355
+ {formatQuantity(resource, raw)}
356
+ {observation.certainty !== "exact" && (
357
+ <>
358
+ {" "}
359
+ <CertaintyGlyph certainty={observation.certainty} />
360
+ </>
361
+ )}
362
+ </span>
363
+ </WithTooltip>
364
+ );
365
+ }
366
+
367
+ function LedgerCell({
368
+ column,
369
+ ledger,
370
+ resource,
371
+ denied,
372
+ coverage,
373
+ }: {
374
+ column: (typeof LEDGER_COLUMNS)[number];
375
+ ledger: CapacityLedger;
376
+ resource: string;
377
+ denied: boolean;
378
+ coverage: CapacityPoolObservation["coverage"];
379
+ }) {
380
+ if (column.key === "usage") {
381
+ const usage = ledger.actualUsage;
382
+ const entry = usage?.utilization.find((u) => u.resource === resource);
383
+ if (!usage || !entry) {
384
+ // Distinguish "metrics ran, this pool had no sample" (a real zero-ish
385
+ // state, e.g. scaled to zero) from "metrics were never observed" — the
386
+ // latter must not read as a benign No samples.
387
+ if (coverageIsDenied(coverage.nodeMetrics))
388
+ return (
389
+ <WithTooltip tip="Metrics access denied — usage cannot be computed. This is not zero.">
390
+ <span className="text-theme-text-tertiary">Unavailable</span>
391
+ </WithTooltip>
392
+ );
393
+ if (!coverageHasObservations(coverage.nodeMetrics))
394
+ return <span className="text-theme-text-tertiary">Not observed</span>;
395
+ return <span className="text-theme-text-tertiary">No samples</span>;
396
+ }
397
+ const sampledSubset = usage.coveredNodes < usage.totalNodes;
398
+ return (
399
+ <WithTooltip
400
+ tip={
401
+ sampledSubset
402
+ ? `${observationTitle(usage.quantity)} — only ${usage.coveredNodes} of ${usage.totalNodes} nodes had samples; the percentage covers the sampled nodes' allocatable, not the whole pool.`
403
+ : observationTitle(usage.quantity)
404
+ }
405
+ >
406
+ <span>
407
+ {formatQuantity(resource, entry.usage)}
408
+ {usage.quantity.certainty !== "exact" && (
409
+ <>
410
+ {" "}
411
+ <CertaintyGlyph certainty={usage.quantity.certainty} />
412
+ </>
413
+ )}{" "}
414
+ <span className="text-theme-text-tertiary">
415
+ ({Math.round(entry.percent)}%
416
+ {sampledSubset
417
+ ? ` of ${usage.coveredNodes}/${usage.totalNodes} sampled`
418
+ : ""}
419
+ )
420
+ </span>
421
+ </span>
422
+ </WithTooltip>
423
+ );
424
+ }
425
+
426
+ if (column.key === "configuredLimit") {
427
+ const obs = ledger.configuredLimit;
428
+ if (!obs || obs.resources[resource] === undefined)
429
+ return (
430
+ <WithTooltip tip="No configured ceiling on this dimension. Not unlimited — provider quota and availability remain external constraints.">
431
+ <span className="text-theme-text-tertiary">No limit</span>
432
+ </WithTooltip>
433
+ );
434
+ return <LedgerValue observation={obs} resource={resource} />;
435
+ }
436
+
437
+ if (column.key === "inFlightCapacity") {
438
+ const obs = ledger.inFlightCapacity;
439
+ if (!obs || obs.resources[resource] === undefined) {
440
+ // No in-flight figure. If NodeClaims weren't observed we can't claim
441
+ // "none in flight" — that would read a missing source as a healthy zero.
442
+ if (!coverageHasObservations(coverage.nodeClaims))
443
+ return (
444
+ <WithTooltip tip="NodeClaims were not observed — in-flight capacity is unknown, not zero.">
445
+ <span className="text-theme-text-tertiary">Unknown</span>
446
+ </WithTooltip>
447
+ );
448
+ if (obs && obs.certainty === "unknown")
449
+ return (
450
+ <WithTooltip tip="Claims are launching but none report capacity yet — the in-flight quantity is unknown, not zero.">
451
+ <span className="text-theme-text-tertiary">
452
+ In flight — unknown
453
+ </span>
454
+ </WithTooltip>
455
+ );
456
+ return (
457
+ <WithTooltip tip="No capacity currently in flight">
458
+ <span className="text-theme-text-tertiary">—</span>
459
+ </WithTooltip>
460
+ );
461
+ }
462
+ return <LedgerValue observation={obs} resource={resource} prefix="+" />;
463
+ }
464
+
465
+ if (column.key === "limitHeadroom") {
466
+ const obs = ledger.limitHeadroom;
467
+ if (!obs || obs.resources[resource] === undefined)
468
+ return (
469
+ <WithTooltip tip="Headroom is undefined without a configured limit">
470
+ <span className="text-theme-text-tertiary">—</span>
471
+ </WithTooltip>
472
+ );
473
+ return <LedgerValue observation={obs} resource={resource} />;
474
+ }
475
+
476
+ if (
477
+ (column.key === "scheduledRequests" ||
478
+ column.key === "aggregateUnallocatedRequests") &&
479
+ denied
480
+ ) {
481
+ return (
482
+ <WithTooltip tip="Pod access denied — cannot be computed. This is not zero.">
483
+ <span className="text-theme-text-tertiary">Unavailable</span>
484
+ </WithTooltip>
485
+ );
486
+ }
487
+
488
+ if (
489
+ (column.key === "scheduledRequests" ||
490
+ column.key === "aggregateUnallocatedRequests") &&
491
+ !coverageHasObservations(coverage.pods)
492
+ ) {
493
+ return (
494
+ <WithTooltip tip="Pods were not observed — this value cannot be computed. This is not zero.">
495
+ <span className="text-theme-text-tertiary">Not observed</span>
496
+ </WithTooltip>
497
+ );
498
+ }
499
+
500
+ if (
501
+ (column.key === "allocatable" ||
502
+ column.key === "aggregateUnallocatedRequests") &&
503
+ !coverageHasObservations(coverage.nodes)
504
+ ) {
505
+ return (
506
+ <WithTooltip tip="Nodes were not observed — this value cannot be computed. This is not zero.">
507
+ <span className="text-theme-text-tertiary">Unavailable</span>
508
+ </WithTooltip>
509
+ );
510
+ }
511
+
512
+ const observation = ledger[column.key] as
513
+ CapacityQuantityObservation | undefined;
514
+ return <LedgerValue observation={observation} resource={resource} />;
515
+ }
516
+
517
+ function PoolSummaryTab({
518
+ pool,
519
+ onNavigate,
520
+ onOpenResource,
521
+ }: {
522
+ pool: CapacityPoolObservation;
523
+ onNavigate: (path: string) => void;
524
+ onOpenResource: (resource: SelectedResource) => void;
525
+ }) {
526
+ const denied = coverageIsDenied(pool.coverage.pods);
527
+ const resources = ledgerResourceKeys(pool.ledger);
528
+ // Overview signals label their links "Diagnose pool", so a broken pool's
529
+ // dominant arrival is diagnostic: lead with the diagnosis instead of a
530
+ // ledger of true zeros. Issues are warning-or-worse by construction;
531
+ // informational posture (facts on a healthy scale-to-zero pool) deliberately
532
+ // keeps the healthy ordering — reordering on a normal state feels unstable.
533
+ const diagnosisFirst = pool.issues.length > 0;
534
+ const scaledToZero = pool.nodes?.total === 0;
535
+ const issuesCard = (
536
+ <SectionCard title="Issues & conditions" bodyClassName="px-4 py-4">
537
+ <PostureView
538
+ pool={pool}
539
+ onOpenResource={onOpenResource}
540
+ onNavigate={onNavigate}
541
+ onOpenMembers={() =>
542
+ onNavigate(
543
+ `/capacity/pools/${encodeURIComponent(pool.resource.ref.name)}/members`,
544
+ )
545
+ }
546
+ />
547
+ </SectionCard>
548
+ );
549
+
550
+ return (
551
+ <div className="space-y-4">
552
+ {diagnosisFirst && issuesCard}
553
+ <SectionCard
554
+ title="Capacity ledger"
555
+ subtitle="every column is a different concept — they are never combined into one number"
556
+ >
557
+ {scaledToZero && (
558
+ <p className="px-4 pt-3 text-xs text-theme-text-secondary">
559
+ No nodes exist in this pool right now — the observed columns are
560
+ true zeros, and Configured limit is the ceiling provisioning would
561
+ run against.
562
+ </p>
563
+ )}
564
+ <div className={TABLE_WRAP}>
565
+ <table className="w-full min-w-[1180px] text-left">
566
+ <thead className={TABLE_HEAD}>
567
+ <tr>
568
+ <th className={TH}>Resource</th>
569
+ {LEDGER_COLUMNS.map((column) => (
570
+ <th key={column.key} className={TH}>
571
+ <WithTooltip tip={column.tooltip}>
572
+ <span>{column.header}</span>
573
+ </WithTooltip>
574
+ </th>
575
+ ))}
576
+ </tr>
577
+ </thead>
578
+ <tbody className={TBODY}>
579
+ {resources.length === 0 ? (
580
+ <tr>
581
+ <td
582
+ className={`${TD} text-theme-text-tertiary`}
583
+ colSpan={LEDGER_COLUMNS.length + 1}
584
+ >
585
+ No ledger dimensions were reported for this pool.
586
+ </td>
587
+ </tr>
588
+ ) : (
589
+ resources.map((resource) => (
590
+ <tr key={resource}>
591
+ <td className={`${TD} whitespace-nowrap font-medium`}>
592
+ {resourceLabel(resource)}
593
+ </td>
594
+ {LEDGER_COLUMNS.map((column) => (
595
+ <td
596
+ key={column.key}
597
+ className={`${TD} whitespace-nowrap font-mono text-xs`}
598
+ >
599
+ <LedgerCell
600
+ column={column}
601
+ ledger={pool.ledger}
602
+ resource={resource}
603
+ denied={denied}
604
+ coverage={pool.coverage}
605
+ />
606
+ </td>
607
+ ))}
608
+ </tr>
609
+ ))
610
+ )}
611
+ </tbody>
612
+ </table>
613
+ </div>
614
+ {pool.ledger.limitPressure.length > 0 && (
615
+ <div className="grid gap-4 border-t border-theme-border-subtle px-4 py-3 sm:grid-cols-2 lg:grid-cols-3">
616
+ {pool.ledger.limitPressure.map((pressure) => (
617
+ <PressureDetail key={pressure.resource} pressure={pressure} />
618
+ ))}
619
+ </div>
620
+ )}
621
+ <p className="border-t border-theme-border-subtle px-4 py-3 text-[11px] leading-relaxed text-theme-text-tertiary">
622
+ Sources: nodepool spec/status · node allocatable · pod list requests ·
623
+ metrics API. Granularity: aggregate per pool; unallocated is aggregate
624
+ subtraction, <b>not bin-packed</b>, and does not prove another pod can
625
+ schedule. Certainty glyphs: <span className="font-mono">=</span> exact
626
+ · <span className="font-mono">≥</span> lower bound ·{" "}
627
+ <span className="font-mono">≤</span> upper bound (a difference
628
+ computed from a lower-bound input) ·{" "}
629
+ <span className="font-mono">?</span> unknown. Hover any value for its
630
+ definition, certainty and source.
631
+ </p>
632
+ </SectionCard>
633
+
634
+ <div className="grid gap-4 lg:grid-cols-2">
635
+ <SectionCard title="Lifecycle" bodyClassName="space-y-4 px-4 py-4">
636
+ <LifecycleGrid pool={pool} denied={denied} />
637
+ </SectionCard>
638
+
639
+ <SectionCard title="Composition" bodyClassName="px-4 py-4">
640
+ <CompositionView pool={pool} />
641
+ </SectionCard>
642
+
643
+ <SectionCard title="Workload attribution" bodyClassName="px-4 py-4">
644
+ <WorkloadAttribution
645
+ pool={pool}
646
+ denied={denied}
647
+ onNavigate={onNavigate}
648
+ onOpenResource={onOpenResource}
649
+ />
650
+ </SectionCard>
651
+
652
+ <SectionCard title="Actual usage" bodyClassName="px-4 py-4">
653
+ <ActualUsageDetail
654
+ usage={pool.ledger.actualUsage}
655
+ coverage={pool.coverage.nodeMetrics}
656
+ />
657
+ </SectionCard>
658
+ </div>
659
+
660
+ {!diagnosisFirst && issuesCard}
661
+ </div>
662
+ );
663
+ }
664
+
665
+ function LifecycleGrid({
666
+ pool,
667
+ denied,
668
+ }: {
669
+ pool: CapacityPoolObservation;
670
+ denied: boolean;
671
+ }) {
672
+ return (
673
+ <>
674
+ <div>
675
+ <div className="mb-2 text-xs font-medium text-theme-text-tertiary">
676
+ Nodes
677
+ </div>
678
+ {pool.nodes ? (
679
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
680
+ <MiniCount label="Ready" value={pool.nodes.ready} />
681
+ <MiniCount label="Not ready" value={pool.nodes.notReady} />
682
+ <MiniCount label="Cordoned" value={pool.nodes.cordoned} />
683
+ <MiniCount label="Terminating" value={pool.nodes.terminating} />
684
+ </div>
685
+ ) : (
686
+ <p className="text-sm text-theme-text-secondary">
687
+ {coverageMessage(pool.coverage.nodes, "Node lifecycle")}
688
+ </p>
689
+ )}
690
+ </div>
691
+ <div>
692
+ <div className="mb-2 text-xs font-medium text-theme-text-tertiary">
693
+ Claims
694
+ </div>
695
+ {pool.claims ? (
696
+ <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
697
+ <MiniCount label="Pending" value={pool.claims.pending} />
698
+ <MiniCount label="Launched" value={pool.claims.launched} />
699
+ <MiniCount label="Registered" value={pool.claims.registered} />
700
+ <MiniCount label="Initialized" value={pool.claims.initialized} />
701
+ <MiniCount label="Ready" value={pool.claims.ready} />
702
+ <MiniCount label="Failed" value={pool.claims.failed} />
703
+ <MiniCount label="Terminating" value={pool.claims.terminating} />
704
+ </div>
705
+ ) : (
706
+ <p className="text-sm text-theme-text-secondary">
707
+ {coverageMessage(pool.coverage.nodeClaims, "Claim lifecycle")}
708
+ </p>
709
+ )}
710
+ </div>
711
+ <div>
712
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">
713
+ Scheduled pods on this pool
714
+ </div>
715
+ <div className="font-mono text-sm text-theme-text-primary">
716
+ <ScheduledPodCount
717
+ count={pool.workloads?.scheduledPodCount}
718
+ denied={denied}
719
+ coverage={pool.coverage.pods}
720
+ />
721
+ </div>
722
+ </div>
723
+ </>
724
+ );
725
+ }
726
+
727
+ /** Pod-derived count: namespace-scoped pod coverage makes it a lower bound, so
728
+ * it never renders bare. */
729
+ function ScheduledPodCount({
730
+ count,
731
+ denied,
732
+ coverage,
733
+ }: {
734
+ count?: number;
735
+ denied: boolean;
736
+ coverage?: CapacitySourceCoverage;
737
+ }) {
738
+ if (denied) return <>Unknown — Pod access denied</>;
739
+ if (count === undefined) return <>—</>;
740
+ if (!coverageIsLowerBound(coverage)) return <>{count}</>;
741
+ return (
742
+ <WithTooltip tip={coverageMessage(coverage, "Scheduled pod count")}>
743
+ <span>≥ {count}</span>
744
+ </WithTooltip>
745
+ );
746
+ }
747
+
748
+ function CompositionView({ pool }: { pool: CapacityPoolObservation }) {
749
+ const composition = pool.composition;
750
+ if (!pool.nodes || !composition)
751
+ return (
752
+ <p className="text-sm text-theme-text-secondary">
753
+ {coverageMessage(pool.coverage.nodes, "Fleet composition")}
754
+ </p>
755
+ );
756
+ const bucketValues = (
757
+ buckets: { value: string; count: number }[],
758
+ ): string[] => buckets.map((bucket) => `${bucket.value} · ${bucket.count}`);
759
+ return (
760
+ <div className="space-y-3">
761
+ <TokenGroup
762
+ title="Capacity type"
763
+ values={bucketValues(composition.capacityTypes)}
764
+ empty="None reported"
765
+ />
766
+ <TokenGroup
767
+ title="Zones"
768
+ values={composition.zones.map((bucket) => bucket.value)}
769
+ empty="None reported"
770
+ />
771
+ <TokenGroup
772
+ title="Instance types"
773
+ values={composition.instanceTypes.map((bucket) => bucket.value)}
774
+ empty="None — scaled to zero"
775
+ />
776
+ <TokenGroup
777
+ title="Architectures"
778
+ values={composition.architectures.map((bucket) => bucket.value)}
779
+ empty="None reported"
780
+ />
781
+ <TokenGroup
782
+ title="Images"
783
+ values={composition.images.map((bucket) => bucket.value)}
784
+ empty="None reported"
785
+ />
786
+ </div>
787
+ );
788
+ }
789
+
790
+ function WorkloadAttribution({
791
+ pool,
792
+ denied,
793
+ onNavigate,
794
+ onOpenResource,
795
+ }: {
796
+ pool: CapacityPoolObservation;
797
+ denied: boolean;
798
+ onNavigate: (path: string) => void;
799
+ onOpenResource: (resource: SelectedResource) => void;
800
+ }) {
801
+ if (denied) return <DeniedBadge />;
802
+ const workloads = pool.workloads;
803
+ if (!coverageHasObservations(pool.coverage.workloads) || !workloads)
804
+ return (
805
+ <p className="text-sm text-theme-text-secondary">
806
+ {coverageMessage(pool.coverage.workloads, "Workload attribution")}
807
+ </p>
808
+ );
809
+ const poolName = pool.resource.ref.name;
810
+ return (
811
+ <div className="space-y-2">
812
+ {workloads.topScheduled.length === 0 ? (
813
+ <p className="text-sm text-theme-text-secondary">
814
+ No workloads were attributed to this pool.
815
+ </p>
816
+ ) : (
817
+ workloads.topScheduled.map((workload) => (
818
+ <button
819
+ key={identityKey({ ref: workload.owner })}
820
+ type="button"
821
+ className="flex w-full items-baseline justify-between gap-2 border-b border-theme-border-subtle py-1.5 text-left last:border-b-0 hover:text-accent-text"
822
+ onClick={() =>
823
+ onOpenResource(refToSelectedResource(workload.owner))
824
+ }
825
+ >
826
+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-theme-text-primary">
827
+ {workload.owner.kind}/{workload.owner.name}
828
+ </span>
829
+ <span className="shrink-0 text-xs text-theme-text-tertiary">
830
+ {workload.owner.namespace}
831
+ </span>
832
+ <span className="shrink-0 font-mono text-xs">
833
+ {workload.podCount} pods
834
+ </span>
835
+ <span className="shrink-0">
836
+ <QuantityInline observation={workload.requests} empty="—" />
837
+ </span>
838
+ </button>
839
+ ))
840
+ )}
841
+ {workloads.workloadCount > workloads.topScheduled.length && (
842
+ <LinkButton
843
+ onClick={() =>
844
+ onNavigate(
845
+ `/capacity/pools/${encodeURIComponent(poolName)}/workloads`,
846
+ )
847
+ }
848
+ >
849
+ All {workloads.workloadCount} workloads →
850
+ </LinkButton>
851
+ )}
852
+ <div className="mt-2 border-t border-theme-border-subtle pt-2">
853
+ <div className="mb-0.5 text-xs text-theme-text-tertiary">
854
+ Pending demand
855
+ </div>
856
+ {workloads.pendingEligibleGroupCount > 0 ? (
857
+ <>
858
+ <div className="text-xs text-theme-text-secondary">
859
+ {workloads.pendingEligibleGroupCount}{" "}
860
+ {workloads.pendingEligibleGroupCount === 1
861
+ ? "group awaiting capacity is"
862
+ : "groups awaiting capacity are"}{" "}
863
+ declared eligible for this pool.
864
+ </div>
865
+ <LinkButton
866
+ className="mt-0.5"
867
+ onClick={() =>
868
+ onNavigate(
869
+ `/capacity/demand?pool=${encodeURIComponent(poolName)}`,
870
+ )
871
+ }
872
+ >
873
+ Evaluate in Demand →
874
+ </LinkButton>
875
+ </>
876
+ ) : (
877
+ <div className="text-xs text-theme-text-secondary">
878
+ No pending pods are declared eligible for this pool.
879
+ </div>
880
+ )}
881
+ </div>
882
+ </div>
883
+ );
884
+ }
885
+
886
+ // A NodePool-not-ready row whose stated reason is the NodeClass issue shown
887
+ // beside it adds nothing: the header chip already carries the state, and the
888
+ // root cause's Inspect link is exactly its "Next" step — the cascade collapses
889
+ // to its root. A standalone pool-unready (any NodeClass-independent reason)
890
+ // keeps its row; this narrows to the one derivable echo.
891
+ function isNodeClassCascadeEcho(
892
+ issue: { reason: string; message?: string },
893
+ all: { reason: string }[],
894
+ ): boolean {
895
+ return (
896
+ issue.reason === "NodePoolNotReady" &&
897
+ (issue.message ?? "").includes("NodeClassReady") &&
898
+ all.some((other) => other.reason === "NodeClassNotReady")
899
+ );
900
+ }
901
+
902
+ // Issue reasons whose durable evidence lives in the pool's provision episodes
903
+ // (failed and ended launches). Their cards deep-link into Activity pre-filtered
904
+ // — the whole diagnosis journey stays one click wide.
905
+ const PROVISION_EPISODE_REASONS = new Set([
906
+ "NodePoolNodeRegistrationUnhealthy",
907
+ "NodeClaimProvisioningFailed",
908
+ ]);
909
+
910
+ function PostureView({
911
+ pool,
912
+ onOpenResource,
913
+ onNavigate,
914
+ onOpenMembers,
915
+ }: {
916
+ pool: CapacityPoolObservation;
917
+ onOpenResource: (resource: SelectedResource) => void;
918
+ onNavigate: (path: string) => void;
919
+ onOpenMembers: () => void;
920
+ }) {
921
+ const visibleIssues = pool.issues.filter(
922
+ (issue) => !isNodeClassCascadeEcho(issue, pool.issues),
923
+ );
924
+ // Raw conditions are the receipts. While issues cover them they collapse
925
+ // behind a toggle (the Activity provenance pattern); on a healthy pool they
926
+ // ARE the content and stay visible.
927
+ const conditionsCollapsible = visibleIssues.length > 0;
928
+ const [showConditions, setShowConditions] = useState(false);
929
+ const hasSignals =
930
+ pool.facts.length > 0 ||
931
+ visibleIssues.length > 0 ||
932
+ pool.conditions.length > 0;
933
+ if (!hasSignals)
934
+ return (
935
+ <p className="text-sm text-theme-text-secondary">
936
+ No capacity-specific posture facts or controller conditions were
937
+ reported.
938
+ </p>
939
+ );
940
+ return (
941
+ <div className="space-y-4">
942
+ {(pool.facts.length > 0 || visibleIssues.length > 0) && (
943
+ <div className="space-y-1.5">
944
+ {visibleIssues.map((issue) => {
945
+ const subject = {
946
+ group: issue.group,
947
+ kind: issue.kind,
948
+ namespace: issue.namespace,
949
+ name: issue.name,
950
+ };
951
+ const subjectIsPool =
952
+ subject.group === pool.resource.ref.group &&
953
+ subject.kind === pool.resource.ref.kind &&
954
+ (subject.namespace ?? "") ===
955
+ (pool.resource.ref.namespace ?? "") &&
956
+ subject.name === pool.resource.ref.name;
957
+ return (
958
+ <div key={issue.id} className="flex items-start gap-2 text-sm">
959
+ <Badge
960
+ severity={issue.severity === "critical" ? "error" : "warning"}
961
+ size="sm"
962
+ >
963
+ {issue.severity}
964
+ </Badge>
965
+ <div className="text-theme-text-secondary">
966
+ <span className="font-medium text-theme-text-primary">
967
+ {humanizeCode(issue.reason)}
968
+ </span>
969
+ {issue.message ? ` — ${issue.message}` : ""}
970
+ {!subjectIsPool && (
971
+ <div className="mt-1">
972
+ <LinkButton
973
+ onClick={() =>
974
+ onOpenResource(refToSelectedResource(subject))
975
+ }
976
+ >
977
+ Inspect {subject.kind}/{subject.name} →
978
+ </LinkButton>
979
+ </div>
980
+ )}
981
+ {PROVISION_EPISODE_REASONS.has(issue.reason) && (
982
+ <div className="mt-1">
983
+ <LinkButton
984
+ onClick={() =>
985
+ onNavigate(
986
+ `/capacity/activity?pool=${encodeURIComponent(pool.resource.ref.name)}&type=provision`,
987
+ )
988
+ }
989
+ >
990
+ View provisioning episodes →
991
+ </LinkButton>
992
+ </div>
993
+ )}
994
+ {issue.cause && (
995
+ <p className="mt-1 text-xs">
996
+ <span className="font-medium text-theme-text-primary">
997
+ Cause:
998
+ </span>{" "}
999
+ {issue.cause}
1000
+ </p>
1001
+ )}
1002
+ {issue.action && (
1003
+ <p className="mt-1 text-xs">
1004
+ <span className="font-medium text-theme-text-primary">
1005
+ Next:
1006
+ </span>{" "}
1007
+ {issue.action}
1008
+ </p>
1009
+ )}
1010
+ <CapacityIssueEvidence
1011
+ issue={issue}
1012
+ onOpenResource={onOpenResource}
1013
+ onOpenMembers={onOpenMembers}
1014
+ />
1015
+ </div>
1016
+ </div>
1017
+ );
1018
+ })}
1019
+ {pool.facts.map((fact) => (
1020
+ <div
1021
+ key={`${fact.code}-${fact.summary}`}
1022
+ className="flex items-start gap-2 text-sm"
1023
+ >
1024
+ <span className="mt-0.5 text-theme-text-tertiary">·</span>
1025
+ <span className="text-theme-text-secondary">
1026
+ <span className="font-medium text-theme-text-primary">
1027
+ {fact.summary}
1028
+ </span>
1029
+ {fact.detail ? ` — ${fact.detail}` : ""}
1030
+ </span>
1031
+ </div>
1032
+ ))}
1033
+ </div>
1034
+ )}
1035
+ {pool.conditions.length > 0 && (
1036
+ <div>
1037
+ {conditionsCollapsible ? (
1038
+ <LinkButton
1039
+ className="mb-1.5 text-xs"
1040
+ onClick={() => setShowConditions((current) => !current)}
1041
+ >
1042
+ {showConditions ? "Hide" : "Show"} controller conditions (
1043
+ {pool.conditions.length})
1044
+ </LinkButton>
1045
+ ) : (
1046
+ <div className="mb-1.5 text-xs font-medium text-theme-text-tertiary">
1047
+ Controller conditions
1048
+ </div>
1049
+ )}
1050
+ {(!conditionsCollapsible || showConditions) && (
1051
+ <div className="space-y-1.5">
1052
+ {pool.conditions.map((condition) => (
1053
+ <div
1054
+ key={condition.type}
1055
+ className="flex flex-wrap items-baseline gap-2 text-xs"
1056
+ >
1057
+ <ConditionBadge status={condition.status} />
1058
+ <span className="font-medium text-theme-text-primary">
1059
+ {condition.type}
1060
+ </span>
1061
+ {condition.reason && (
1062
+ <span className="text-theme-text-secondary">
1063
+ {condition.reason}
1064
+ </span>
1065
+ )}
1066
+ </div>
1067
+ ))}
1068
+ </div>
1069
+ )}
1070
+ </div>
1071
+ )}
1072
+ </div>
1073
+ );
1074
+ }
1075
+
1076
+ // ---------------------------------------------------------------- members
1077
+
1078
+ function MemberPage({
1079
+ name,
1080
+ type,
1081
+ title,
1082
+ empty,
1083
+ minWidth,
1084
+ head,
1085
+ renderRow,
1086
+ }: {
1087
+ name: string;
1088
+ type: CapacityMemberType;
1089
+ title: string;
1090
+ empty: string;
1091
+ minWidth: number;
1092
+ head: ReactNode;
1093
+ renderRow: (
1094
+ item: CapacityPoolMember,
1095
+ coverage: CapacityMemberListResponse["coverage"],
1096
+ ) => ReactNode;
1097
+ }) {
1098
+ const pagination = useCapacityPagination<CapacityMemberListResponse>(
1099
+ `${name}\u0000${type}`,
1100
+ );
1101
+ const query = useCapacityPoolMembers(name, type, {
1102
+ limit: 50,
1103
+ cursor: pagination.cursor,
1104
+ });
1105
+ const recoveringCursor = useCapacityCursorRecovery(
1106
+ query.error,
1107
+ pagination.cursor,
1108
+ pagination.recover,
1109
+ );
1110
+ const recoveredCursor = pagination.recovered || recoveringCursor;
1111
+ const response =
1112
+ query.data ?? (recoveredCursor ? pagination.retainedPage : undefined);
1113
+ const sourceCoverage = response?.coverage[memberCoverageSource(type)];
1114
+
1115
+ const lowerBound =
1116
+ response &&
1117
+ coverageIsLowerBound(sourceCoverage) &&
1118
+ response.items.length > 0;
1119
+
1120
+ return (
1121
+ <SectionCard
1122
+ title={title}
1123
+ subtitle={
1124
+ // "Page 1" is noise unless pagination is actually happening.
1125
+ response && (pagination.history.length > 0 || response.page.hasMore)
1126
+ ? `Page ${pagination.history.length + 1}`
1127
+ : undefined
1128
+ }
1129
+ >
1130
+ {recoveredCursor && (
1131
+ <div className="border-b border-theme-border-subtle px-4 py-2">
1132
+ <Notice>Capacity data changed; showing the latest results.</Notice>
1133
+ </div>
1134
+ )}
1135
+ {query.error &&
1136
+ response &&
1137
+ !isCapacityCursorInvalidError(query.error) && (
1138
+ <div className="border-b border-theme-border-subtle px-4 py-2">
1139
+ <RefreshError message={errorMessage(query.error)} />
1140
+ </div>
1141
+ )}
1142
+ {lowerBound && (
1143
+ <div className="border-b border-theme-border-subtle px-4 py-2">
1144
+ <Notice>{coverageMessage(sourceCoverage, title)}.</Notice>
1145
+ </div>
1146
+ )}
1147
+ {!response && query.isLoading && (
1148
+ <div className="px-4 py-6 text-sm text-theme-text-tertiary">
1149
+ Loading {title}…
1150
+ </div>
1151
+ )}
1152
+ {!response && query.error && (
1153
+ <InlineEmpty
1154
+ title={`${title} unavailable`}
1155
+ detail={errorMessage(query.error)}
1156
+ />
1157
+ )}
1158
+ {response && response.state === "syncing" && (
1159
+ <InlineEmpty
1160
+ title="Inventory is syncing"
1161
+ detail="Counts below will fill in as the watch catches up."
1162
+ />
1163
+ )}
1164
+ {response && response.state === "denied" && (
1165
+ <InlineEmpty
1166
+ title="Inventory denied"
1167
+ detail={coverageMessage(sourceCoverage, title)}
1168
+ />
1169
+ )}
1170
+ {response &&
1171
+ (response.state === "available" || response.state === "not_detected") &&
1172
+ (response.items.length === 0 ? (
1173
+ <InlineEmpty
1174
+ title={
1175
+ coverageHasObservations(sourceCoverage)
1176
+ ? `No ${title}`
1177
+ : `${title} unavailable`
1178
+ }
1179
+ detail={
1180
+ coverageHasObservations(sourceCoverage)
1181
+ ? empty
1182
+ : coverageMessage(sourceCoverage, title)
1183
+ }
1184
+ />
1185
+ ) : (
1186
+ <>
1187
+ <div className={TABLE_WRAP}>
1188
+ <table
1189
+ className="w-full text-left"
1190
+ style={{ minWidth: `${minWidth}px` }}
1191
+ >
1192
+ <thead className={TABLE_HEAD}>{head}</thead>
1193
+ <tbody className={TBODY}>
1194
+ {response.items.map((item) =>
1195
+ renderRow(item, response.coverage),
1196
+ )}
1197
+ </tbody>
1198
+ </table>
1199
+ </div>
1200
+ {(pagination.history.length > 0 || response.page.hasMore) && (
1201
+ <div className="flex items-center justify-end gap-2 border-t border-theme-border-subtle px-4 py-2.5">
1202
+ <button
1203
+ type="button"
1204
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover disabled:opacity-50"
1205
+ disabled={pagination.history.length === 0 || query.isFetching}
1206
+ onClick={() => pagination.goBack(response)}
1207
+ >
1208
+ Previous
1209
+ </button>
1210
+ <button
1211
+ type="button"
1212
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover disabled:opacity-50"
1213
+ disabled={
1214
+ !response.page.hasMore ||
1215
+ !response.page.nextCursor ||
1216
+ query.isFetching
1217
+ }
1218
+ onClick={() =>
1219
+ response.page.nextCursor &&
1220
+ pagination.goNext(response.page.nextCursor, response)
1221
+ }
1222
+ >
1223
+ Next
1224
+ </button>
1225
+ </div>
1226
+ )}
1227
+ </>
1228
+ ))}
1229
+ </SectionCard>
1230
+ );
1231
+ }
1232
+
1233
+ function PoolWorkloadsTab({
1234
+ name,
1235
+ onOpenResource,
1236
+ }: {
1237
+ name: string;
1238
+ onOpenResource: (resource: SelectedResource) => void;
1239
+ }) {
1240
+ return (
1241
+ <MemberPage
1242
+ name={name}
1243
+ type="workload"
1244
+ title="Workloads on this pool"
1245
+ empty="No workloads were observed on this NodePool."
1246
+ minWidth={860}
1247
+ head={
1248
+ <tr>
1249
+ <th className={TH}>Kind</th>
1250
+ <th className={TH}>Workload</th>
1251
+ <th className={TH}>Namespace</th>
1252
+ <th className={TH}>Pods</th>
1253
+ <th className={TH}>Aggregate requests</th>
1254
+ <th className={TH}>
1255
+ <WithTooltip tip="Distinct nodes this workload's scheduled pods run on">
1256
+ <span>Nodes used</span>
1257
+ </WithTooltip>
1258
+ </th>
1259
+ </tr>
1260
+ }
1261
+ renderRow={(item) => {
1262
+ const workload = item.workload;
1263
+ if (!workload) return null;
1264
+ const owner = workload.owner;
1265
+ return (
1266
+ <tr key={identityKey(item.resource)} className={ROW_HOVER}>
1267
+ <td className={TD}>
1268
+ {owner.kind ? (
1269
+ <Badge tone="structural" size="sm">
1270
+ {owner.kind}
1271
+ </Badge>
1272
+ ) : (
1273
+ <Badge severity="neutral" size="sm">
1274
+ Unowned
1275
+ </Badge>
1276
+ )}
1277
+ </td>
1278
+ <td className={`${TD} max-w-[320px]`}>
1279
+ <WithTooltip tip={owner.name}>
1280
+ <button
1281
+ type="button"
1282
+ className="block max-w-full truncate font-mono text-xs font-medium text-accent-text hover:underline"
1283
+ onClick={() => onOpenResource(refToSelectedResource(owner))}
1284
+ >
1285
+ {owner.name}
1286
+ </button>
1287
+ </WithTooltip>
1288
+ </td>
1289
+ <td className={`${TD} font-mono text-xs`}>
1290
+ {owner.namespace ?? item.resource.ref.namespace ?? "—"}
1291
+ </td>
1292
+ <td className={`${TD} font-mono`}>{workload.podCount}</td>
1293
+ <td className={`${TD} whitespace-nowrap`}>
1294
+ <QuantityInline observation={workload.requests} empty="—" />
1295
+ </td>
1296
+ <td className={`${TD} font-mono`}>
1297
+ <WithTooltip tip="Count of distinct nodes running this workload's scheduled pods">
1298
+ <span>{workload.nodesMeta.total}</span>
1299
+ </WithTooltip>
1300
+ </td>
1301
+ </tr>
1302
+ );
1303
+ }}
1304
+ />
1305
+ );
1306
+ }
1307
+
1308
+ function PoolMembersTab({
1309
+ name,
1310
+ onOpenResource,
1311
+ }: {
1312
+ name: string;
1313
+ onOpenResource: (resource: SelectedResource) => void;
1314
+ }) {
1315
+ return (
1316
+ <div className="space-y-4">
1317
+ <MemberPage
1318
+ name={name}
1319
+ type="node"
1320
+ title="Nodes"
1321
+ empty="No nodes — this pool may be scaled to zero. For a dynamic pool this is a valid state."
1322
+ minWidth={1240}
1323
+ head={
1324
+ <tr>
1325
+ <th className={TH}>Node</th>
1326
+ <th className={TH}>Readiness</th>
1327
+ <th className={TH}>Instance</th>
1328
+ <th className={TH}>Type</th>
1329
+ <th className={TH}>Zone</th>
1330
+ <th className={TH}>Arch</th>
1331
+ <th className={TH}>Allocatable</th>
1332
+ <th className={TH}>Requests</th>
1333
+ <th className={TH}>Usage</th>
1334
+ <th className={TH}>
1335
+ <WithTooltip tip="Shown only when Pods were actually observed">
1336
+ <span>Pods</span>
1337
+ </WithTooltip>
1338
+ </th>
1339
+ </tr>
1340
+ }
1341
+ renderRow={(item, coverage) => {
1342
+ const node = item.node;
1343
+ if (!node) return null;
1344
+ const podsDeniedNode = coverageIsDenied(coverage.pods);
1345
+ const podsLowerBound = coverageIsLowerBound(coverage.pods);
1346
+ return (
1347
+ <tr key={identityKey(item.resource)} className={ROW_HOVER}>
1348
+ <td className={`${TD} max-w-[220px]`}>
1349
+ <ResourceLink
1350
+ identity={item.resource}
1351
+ className="block max-w-full truncate font-mono text-xs font-medium text-accent-text hover:underline"
1352
+ onOpenResource={onOpenResource}
1353
+ />
1354
+ </td>
1355
+ <td className={TD}>
1356
+ <NodeReadyBadge ready={node.ready} cordoned={node.cordoned} />
1357
+ </td>
1358
+ <td className={`${TD} font-mono text-xs`}>
1359
+ {node.instanceType ?? "—"}
1360
+ </td>
1361
+ <td className={`${TD} font-mono text-xs`}>
1362
+ {node.capacityType ?? "—"}
1363
+ </td>
1364
+ <td className={`${TD} font-mono text-xs`}>{node.zone ?? "—"}</td>
1365
+ <td className={`${TD} font-mono text-xs`}>
1366
+ {node.architecture ?? "—"}
1367
+ </td>
1368
+ <td className={`${TD} whitespace-nowrap`}>
1369
+ <QuantityInline observation={node.allocatable} empty="—" />
1370
+ </td>
1371
+ <td className={`${TD} whitespace-nowrap`}>
1372
+ {podsDeniedNode ? (
1373
+ <WithTooltip tip="Pod access denied — not zero">
1374
+ <span className="text-theme-text-tertiary">
1375
+ Unavailable
1376
+ </span>
1377
+ </WithTooltip>
1378
+ ) : (
1379
+ <QuantityInline
1380
+ observation={node.scheduledRequests}
1381
+ empty="—"
1382
+ />
1383
+ )}
1384
+ </td>
1385
+ <td className={`${TD} whitespace-nowrap`}>
1386
+ <ActualUsageInline
1387
+ usage={node.actualUsage}
1388
+ coverage={coverage.nodeMetrics}
1389
+ />
1390
+ </td>
1391
+ <td className={`${TD} font-mono`}>
1392
+ <WithTooltip
1393
+ tip={
1394
+ podsDeniedNode
1395
+ ? "Pod access denied — the count is unknown, not zero"
1396
+ : "Observed via pod list"
1397
+ }
1398
+ >
1399
+ <span>
1400
+ {podsDeniedNode
1401
+ ? "Unknown"
1402
+ : node.podCount === undefined
1403
+ ? "—"
1404
+ : `${podsLowerBound ? "≥ " : ""}${node.podCount}`}
1405
+ </span>
1406
+ </WithTooltip>
1407
+ </td>
1408
+ </tr>
1409
+ );
1410
+ }}
1411
+ />
1412
+ <MemberPage
1413
+ name={name}
1414
+ type="claim"
1415
+ title="NodeClaims"
1416
+ empty="No claims. For a dynamic pool at zero this is a valid state."
1417
+ minWidth={1080}
1418
+ head={
1419
+ <tr>
1420
+ <th className={TH}>Claim</th>
1421
+ <th className={TH}>Lifecycle</th>
1422
+ <th className={TH}>
1423
+ <WithTooltip tip="The controller-reported node name is preserved even when the Node object is not readable">
1424
+ <span>Node</span>
1425
+ </WithTooltip>
1426
+ </th>
1427
+ <th className={TH}>Status capacity</th>
1428
+ <th className={TH}>Conditions</th>
1429
+ </tr>
1430
+ }
1431
+ renderRow={(item, coverage) => {
1432
+ const claim = item.claim;
1433
+ if (!claim) return null;
1434
+ return (
1435
+ <tr key={identityKey(item.resource)} className={ROW_HOVER}>
1436
+ <td className={TD}>
1437
+ <ResourceLink
1438
+ identity={item.resource}
1439
+ onOpenResource={onOpenResource}
1440
+ />
1441
+ </td>
1442
+ <td className={TD}>
1443
+ <ClaimStageBadge stage={claim.stage} />
1444
+ </td>
1445
+ <td className={`${TD} max-w-[220px]`}>
1446
+ {claim.node ? (
1447
+ <ResourceLink
1448
+ identity={claim.node}
1449
+ className="block max-w-full truncate font-mono text-xs font-medium text-accent-text hover:underline"
1450
+ onOpenResource={onOpenResource}
1451
+ />
1452
+ ) : claim.nodeName ? (
1453
+ <WithTooltip
1454
+ tip={coverageMessage(coverage.nodes, "Node details")}
1455
+ >
1456
+ <span className="font-mono text-xs text-theme-text-secondary">
1457
+ {claim.nodeName}
1458
+ </span>
1459
+ </WithTooltip>
1460
+ ) : (
1461
+ <span className="text-theme-text-tertiary">—</span>
1462
+ )}
1463
+ </td>
1464
+ <td className={`${TD} whitespace-nowrap`}>
1465
+ <QuantityInline observation={claim.capacity} empty="—" />
1466
+ </td>
1467
+ <td className={`${TD} text-xs text-theme-text-secondary`}>
1468
+ {conditionSummary(claim.conditions)}
1469
+ </td>
1470
+ </tr>
1471
+ );
1472
+ }}
1473
+ />
1474
+ </div>
1475
+ );
1476
+ }
1477
+
1478
+ // ---------------------------------------------------------------- configuration
1479
+
1480
+ function PoolConfigurationTab({
1481
+ pool,
1482
+ onOpenResource,
1483
+ }: {
1484
+ pool: CapacityPoolObservation;
1485
+ onOpenResource: (resource: SelectedResource) => void;
1486
+ }) {
1487
+ const config = pool.configuration;
1488
+ const disruption = pool.disruption;
1489
+ return (
1490
+ <div className="grid gap-4 lg:grid-cols-2">
1491
+ <SectionCard title="Effective configuration" bodyClassName="px-4 py-4">
1492
+ <KeyValueRows
1493
+ rows={[
1494
+ ["Mode", pool.mode],
1495
+ ["Weight", config.weight?.toString() ?? "—"],
1496
+ [
1497
+ "Static replicas",
1498
+ config.replicas !== undefined
1499
+ ? config.replicas.toString()
1500
+ : "— (dynamic pool)",
1501
+ ],
1502
+ ["Expiry", config.expireAfter ?? "—"],
1503
+ ["Termination grace", config.terminationGracePeriod ?? "—"],
1504
+ ]}
1505
+ />
1506
+ {pool.nodeClass && (
1507
+ <div className="mt-3 border-t border-theme-border-subtle pt-3">
1508
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">
1509
+ NodeClass
1510
+ </div>
1511
+ <div className="flex items-center gap-2">
1512
+ <LinkButton
1513
+ className="font-mono text-xs font-medium text-accent-text hover:underline"
1514
+ onClick={() =>
1515
+ onOpenResource(
1516
+ refToSelectedResource(pool.nodeClass!.reference),
1517
+ )
1518
+ }
1519
+ >
1520
+ {pool.nodeClass.reference.kind}/{pool.nodeClass.reference.name}
1521
+ </LinkButton>
1522
+ <PoolReadyBadge ready={pool.nodeClass.ready} />
1523
+ {pool.nodeClass.ready === undefined && (
1524
+ <span className="text-xs text-theme-text-tertiary">
1525
+ {coverageMessage(
1526
+ pool.coverage.nodeClasses,
1527
+ "NodeClass readiness",
1528
+ )}
1529
+ </span>
1530
+ )}
1531
+ </div>
1532
+ </div>
1533
+ )}
1534
+ </SectionCard>
1535
+
1536
+ <SectionCard title="Requirements" bodyClassName="px-0 py-0">
1537
+ {config.requirements.length === 0 ? (
1538
+ <InlineEmpty
1539
+ title="No requirements"
1540
+ detail="This NodePool declares no scheduling requirements."
1541
+ />
1542
+ ) : (
1543
+ <div className={TABLE_WRAP}>
1544
+ <table className="w-full min-w-[520px] text-left">
1545
+ <thead className={TABLE_HEAD}>
1546
+ <tr>
1547
+ <th className={TH}>Key</th>
1548
+ <th className={TH}>Op</th>
1549
+ <th className={TH}>Values</th>
1550
+ <th className={TH}>minValues</th>
1551
+ </tr>
1552
+ </thead>
1553
+ <tbody className={TBODY}>
1554
+ {config.requirements.map((requirement) => (
1555
+ <tr key={`${requirement.key}-${requirement.operator}`}>
1556
+ <td className={`${TD} font-mono text-xs`}>
1557
+ {requirement.key}
1558
+ </td>
1559
+ <td className={`${TD} text-xs`}>{requirement.operator}</td>
1560
+ <td className={`${TD} font-mono text-xs`}>
1561
+ {requirement.values.join(", ") || "—"}
1562
+ </td>
1563
+ <td className={`${TD} font-mono text-xs`}>
1564
+ {requirement.minValues ?? "—"}
1565
+ </td>
1566
+ </tr>
1567
+ ))}
1568
+ </tbody>
1569
+ </table>
1570
+ </div>
1571
+ )}
1572
+ </SectionCard>
1573
+
1574
+ <SectionCard title="Disruption" bodyClassName="px-4 py-4">
1575
+ <KeyValueRows
1576
+ rows={[
1577
+ ["Consolidation policy", disruption.consolidationPolicy ?? "—"],
1578
+ ["Consolidate after", disruption.consolidateAfter ?? "—"],
1579
+ ]}
1580
+ />
1581
+ {disruption.budgets.length > 0 && (
1582
+ <div className="mt-3 border-t border-theme-border-subtle pt-3">
1583
+ <div className="mb-1.5 text-xs font-medium text-theme-text-tertiary">
1584
+ Budgets
1585
+ </div>
1586
+ <div className="space-y-1">
1587
+ {disruption.budgets.map((budget, index) => (
1588
+ <div
1589
+ key={index}
1590
+ className="font-mono text-xs text-theme-text-secondary"
1591
+ >
1592
+ {budget.nodes}
1593
+ {budget.reasons.length > 0
1594
+ ? ` · ${budget.reasons.join(", ")}`
1595
+ : ""}
1596
+ {budget.schedule ? ` · ${budget.schedule}` : ""}
1597
+ {budget.duration ? ` · ${budget.duration}` : ""}
1598
+ </div>
1599
+ ))}
1600
+ </div>
1601
+ </div>
1602
+ )}
1603
+ </SectionCard>
1604
+
1605
+ <SectionCard title="Labels & taints" bodyClassName="space-y-3 px-4 py-4">
1606
+ <TokenGroup
1607
+ title="Labels"
1608
+ values={Object.entries(config.labels).map(
1609
+ ([key, value]) => `${key}=${value}`,
1610
+ )}
1611
+ empty="None"
1612
+ />
1613
+ <TokenGroup
1614
+ title="Taints"
1615
+ values={config.taints.map(formatTaint)}
1616
+ empty="None"
1617
+ />
1618
+ <TokenGroup
1619
+ title="Startup taints"
1620
+ values={config.startupTaints.map(formatTaint)}
1621
+ empty="None"
1622
+ />
1623
+ </SectionCard>
1624
+ </div>
1625
+ );
1626
+ }