@skyhook-io/radar-app 1.9.1 → 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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +42 -7
  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 +2884 -2075
  7. package/src/api/diagnose.ts +5 -0
  8. package/src/components/ConnectionErrorView.test.tsx +88 -0
  9. package/src/components/ConnectionErrorView.tsx +128 -22
  10. package/src/components/capacity/CapacityActivity.tsx +787 -0
  11. package/src/components/capacity/CapacityDemand.tsx +961 -0
  12. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  14. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  15. package/src/components/capacity/CapacityView.tsx +85 -0
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  17. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  18. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  19. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  20. package/src/components/capacity/podDemandGate.test.ts +47 -0
  21. package/src/components/capacity/podDemandGate.ts +22 -0
  22. package/src/components/capacity/schedulingBar.test.ts +244 -0
  23. package/src/components/capacity/shared.tsx +1841 -0
  24. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +51 -10
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
  27. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  28. package/src/components/diagnose/agentCatalog.ts +30 -0
  29. package/src/components/home/CapacityCard.test.tsx +150 -0
  30. package/src/components/home/CapacityCard.tsx +125 -0
  31. package/src/components/home/HomeView.tsx +15 -1
  32. package/src/components/issues/IssuesPane.test.ts +142 -0
  33. package/src/components/issues/IssuesPane.tsx +142 -38
  34. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  35. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  36. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  37. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  38. package/src/components/ui/command-items.ts +222 -98
  39. package/src/components/workload/WorkloadView.tsx +16 -83
  40. package/src/context/ConnectionContext.test.ts +39 -0
  41. package/src/context/ConnectionContext.tsx +155 -51
  42. package/src/utils/shell-safe.test.ts +55 -0
  43. package/src/utils/shell-safe.ts +21 -0
@@ -0,0 +1,1529 @@
1
+ import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
2
+ import { Layers3, X } from "lucide-react";
3
+ import {
4
+ Collapse,
5
+ CollapseChevron,
6
+ StatusDot,
7
+ WithTooltip,
8
+ mapHealthToTone,
9
+ type CapacityAutoscalerBackoff,
10
+ type CapacityAutoscalerChildObservation,
11
+ type CapacityBoundedResultMeta,
12
+ type CapacityCertainty,
13
+ type CapacityClaimLifecycleSummary,
14
+ type CapacityCoverageBySource,
15
+ type CapacityDemandState,
16
+ type CapacityGroupSummary,
17
+ type CapacityIntegrationState,
18
+ type CapacityManagerSummary,
19
+ type CapacityOverviewResponse,
20
+ type CapacityOverviewSummary,
21
+ type CapacityPoolSummary,
22
+ type CapacitySourceCoverage,
23
+ } from "@skyhook-io/k8s-ui";
24
+ import { ClusterSchedulingCard } from "./ClusterSchedulingCard";
25
+ import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
26
+ import type { useCapacityOverview } from "../../api/client";
27
+ import type { SelectedResource } from "../../types";
28
+ import {
29
+ actionSeverity,
30
+ CapacityFreshness,
31
+ capacityManagerLabel,
32
+ coverageHasObservations,
33
+ coverageIsDenied,
34
+ coverageIsLowerBound,
35
+ CertaintyGlyph,
36
+ formatQuantity,
37
+ coverageMessage,
38
+ DeniedBadge,
39
+ EmptyState,
40
+ errorMessage,
41
+ formatTimestamp,
42
+ humanizeCode,
43
+ identityToSelectedResource,
44
+ InlineEmpty,
45
+ integrationBlock,
46
+ InventoryQuantityCell,
47
+ KpiTile,
48
+ LinkButton,
49
+ managerStatusTone,
50
+ Notice,
51
+ pickWorstPressure,
52
+ PoolReadyBadge,
53
+ poolReadinessDetail,
54
+ RefreshError,
55
+ relativeTime,
56
+ ROW_HOVER,
57
+ ScopeBadges,
58
+ ScrollableContent,
59
+ SectionCard,
60
+ shortResourceLabel,
61
+ sortedResourceEntries,
62
+ TABLE_HEAD,
63
+ TABLE_WRAP,
64
+ TBODY,
65
+ TD,
66
+ TH,
67
+ worstManagerStatus,
68
+ type CapacityConnectionState,
69
+ } from "./shared";
70
+
71
+ const EXPLAIN_CARDS: { term: string; body: string }[] = [
72
+ {
73
+ term: "Capacity managers",
74
+ body: 'The controllers that add and remove nodes — Karpenter, the GKE or AKS cluster autoscaler, or the standalone Cluster Autoscaler. Detection is coverage-gated: whenever the autoscaler status cannot be read — permissions, watch scope, or an unparseable payload — Radar says detection is unavailable rather than reporting none. "None detected" means the status ConfigMap was looked for in kube-system and is not there.',
75
+ },
76
+ {
77
+ term: "Node groups",
78
+ body: "Logical groups an operator recognizes — a Karpenter NodePool, a GKE node pool, an EKS managed node group, an AKS agent pool. Identity comes from node labels or CRD evidence, never from parsing provider group names. Nodes with no group identity are counted apart — never called static.",
79
+ },
80
+ {
81
+ term: "Cluster scheduling capacity",
82
+ body: "Scheduled requests against node allocatable across every observed node, not just Karpenter's. Requests are what the scheduler consumes, never live usage, and the bar never takes a health color. In-flight capacity stays Karpenter-only — no other manager reports claims before they register.",
83
+ },
84
+ {
85
+ term: "Configured limit",
86
+ body: "A ceiling declared on the NodePool. Says nothing about what exists right now. A missing limit is not unlimited capacity — provider quota still applies.",
87
+ },
88
+ {
89
+ term: "Provisioned / allocatable",
90
+ body: "Capacity of nodes that exist. Allocatable is what the kubelet offers to the scheduler after reservations.",
91
+ },
92
+ {
93
+ term: "Scheduled requests",
94
+ body: "What the scheduler has placed. This is what consumes scheduling capacity — not live usage.",
95
+ },
96
+ {
97
+ term: "Negative-priority requests",
98
+ body: "The subset of scheduled requests coming from pods with a priority below zero — potential preemption victims. Default-priority workloads can reclaim their capacity, subject to preemption policy, placement, and disruption constraints. A measured priority fact, never added to scheduled requests: Radar does not claim these are an overprovisioning buffer.",
99
+ },
100
+ {
101
+ term: "Actual usage",
102
+ body: "Point-in-time consumption from metrics. An efficiency signal. Never scheduler headroom.",
103
+ },
104
+ {
105
+ term: "Unallocated",
106
+ body: "Allocatable minus requests, summed across nodes. Not bin-packed: it does not prove another pod can schedule.",
107
+ },
108
+ {
109
+ term: "In flight",
110
+ body: "Capacity of NodeClaims Karpenter has requested that have not registered as nodes yet. Drawn beyond the allocatable edge — the scheduler cannot use it until registration.",
111
+ },
112
+ {
113
+ term: "Pending demand",
114
+ body: "Requests of unscheduled pods. Shown as a count, never to scale — demand can exceed the whole fleet. Demand is what Karpenter still has to answer for, not usage.",
115
+ },
116
+ ];
117
+
118
+ const SEVERITY_RANK: Record<string, number> = {
119
+ error: 0,
120
+ warning: 1,
121
+ info: 2,
122
+ neutral: 3,
123
+ };
124
+
125
+ export function coverageCertainty(
126
+ coverage?: CapacitySourceCoverage,
127
+ ): CapacityCertainty {
128
+ if (coverageIsDenied(coverage)) return "unknown";
129
+ // Only an actually-observed source can be exact or a lower bound. Syncing,
130
+ // unavailable, error, or absent coverage is unknown — never a false "=".
131
+ if (!coverageHasObservations(coverage)) return "unknown";
132
+ if (coverageIsLowerBound(coverage)) return "lower_bound";
133
+ return "exact";
134
+ }
135
+
136
+ /**
137
+ * Certainty of the node-groups count. A group with no nodes is only visible
138
+ * through its NodePool, so unreadable NodePools hide scale-to-zero groups
139
+ * entirely and make the count a lower bound even when every node was observed.
140
+ */
141
+ export function nodeGroupsCertainty(
142
+ coverage: CapacityCoverageBySource,
143
+ state: CapacityIntegrationState,
144
+ ): {
145
+ certainty: CapacityCertainty;
146
+ title: string;
147
+ } {
148
+ const fromNodes = coverageCertainty(coverage.nodes);
149
+ const nodePools = coverage.nodePools;
150
+ // With Karpenter absent there are no NodePools to hide behind, so nothing is
151
+ // invisible and the count keeps the nodes' own certainty.
152
+ const nodePoolsHidden =
153
+ state !== "not_detected" &&
154
+ nodePools !== undefined &&
155
+ !coverageHasObservations(nodePools);
156
+ if (fromNodes === "unknown" || !nodePoolsHidden)
157
+ return {
158
+ certainty: fromNodes,
159
+ title: coverageMessage(coverage.nodes, "Node groups"),
160
+ };
161
+ return {
162
+ certainty: "lower_bound",
163
+ title: `${coverageMessage(nodePools, "NodePool inventory")} — NodePool groups holding no nodes are invisible, so this count is a lower bound.`,
164
+ };
165
+ }
166
+
167
+ /** Honest one-liner for an autoscaler-status source that could not be read.
168
+ * Null when the source WAS observed — including the true "the autoscaler
169
+ * publishes nothing here" observation, which is the only reading that may
170
+ * render as "None detected". */
171
+ export function autoscalerDetectionFailure(
172
+ coverage?: CapacitySourceCoverage,
173
+ ): string | null {
174
+ if (!coverage) return null;
175
+ if (coverage.status === "denied")
176
+ return "Autoscaler status is hidden by permissions, so managers could not be detected.";
177
+ if (coverage.status !== "unavailable" && coverage.status !== "error")
178
+ return null;
179
+ switch (coverage.reasonCode) {
180
+ case "autoscaler_status_not_published":
181
+ return null;
182
+ case "autoscaler_status_cache_scope":
183
+ return "The autoscaler status ConfigMap is outside the namespaces Radar watches.";
184
+ case "autoscaler_status_cache_unavailable":
185
+ return "The autoscaler status ConfigMap could not be read.";
186
+ case "autoscaler_status_read_failed":
187
+ return "Reading the autoscaler status ConfigMap failed.";
188
+ case "autoscaler_status_parse_failed":
189
+ return "The autoscaler status ConfigMap could not be parsed.";
190
+ default:
191
+ return coverage.reason ?? "The autoscaler status could not be observed.";
192
+ }
193
+ }
194
+
195
+ // The autoscaler publishes to one fixed object; the server reports the
196
+ // namespace it probed on the not-published path.
197
+ const AUTOSCALER_STATUS_NAMESPACE = "kube-system";
198
+
199
+ /** Scope for the "None detected" reading. Absence of the status ConfigMap is
200
+ * only evidence about the one namespace it can live in — unscoped, the tile
201
+ * would read as a cluster-wide claim that no autoscaler exists. */
202
+ export function autoscalerNotPublishedDetail(
203
+ coverage?: CapacitySourceCoverage,
204
+ ): string | null {
205
+ if (coverage?.reasonCode !== "autoscaler_status_not_published") return null;
206
+ const namespaces = coverage.namespaces?.length
207
+ ? coverage.namespaces.join(", ")
208
+ : AUTOSCALER_STATUS_NAMESPACE;
209
+ return `No cluster-autoscaler-status ConfigMap in ${namespaces}.`;
210
+ }
211
+
212
+ /** One-manager breakdown for the Node groups subline when no Karpenter pools
213
+ * own the groups (e.g. a GKE/EKS-managed cluster). Null when no manager was
214
+ * attributed. */
215
+ function managerBreakdown(managers: CapacityManagerSummary[]): string | null {
216
+ if (managers.length === 0) return null;
217
+ if (managers.length === 1)
218
+ return `${managers[0].groupCount} ${capacityManagerLabel(managers[0].manager)}`;
219
+ return `across ${managers.length} managers`;
220
+ }
221
+
222
+ /** Node groups tile subline. Headline is the unified group count; the subline
223
+ * keeps the Karpenter readiness signal when pools exist, else describes the
224
+ * managers that own the groups, else says none/unavailable — never a
225
+ * fabricated zero. */
226
+ function nodeGroupsSubline(
227
+ summary: CapacityOverviewSummary,
228
+ data: CapacityOverviewResponse,
229
+ coverage: CapacityCoverageBySource,
230
+ ): string {
231
+ if ((summary.poolCount ?? 0) > 0)
232
+ return `${summary.poolCount} Karpenter · ${poolReadinessDetail(
233
+ data.pools,
234
+ data.poolsTruncated,
235
+ )}`;
236
+ if (data.groups.length === 0) {
237
+ if (!coverageHasObservations(coverage.nodes))
238
+ return coverageMessage(coverage.nodes, "Node inventory");
239
+ // Karpenter simply is not installed. That is a true observation, so it must
240
+ // not borrow the "unavailable" phrasing that means we tried and could not
241
+ // see — the server leaves this coverage reasonless precisely because there
242
+ // was nothing to fail at.
243
+ if (data.state === "not_detected") return "Karpenter not detected";
244
+ // Unreadable NodePools cannot be summarized as "none identified" — the
245
+ // pools may exist and simply be invisible to this identity.
246
+ return summary.poolCount === undefined
247
+ ? coverageMessage(coverage.nodePools, "NodePool inventory")
248
+ : "none identified";
249
+ }
250
+ return managerBreakdown(summary.managers) ?? "logical groups across managers";
251
+ }
252
+
253
+ /** Nodes tile subline. Decomposes the total: nodes inside Karpenter pools, plus
254
+ * nodes with no group identity. Falls back to the coverage message when the
255
+ * total is unavailable or nothing decomposes (unavailable ≠ zero). */
256
+ function nodesSubline(
257
+ summary: CapacityOverviewSummary,
258
+ coverage: CapacityCoverageBySource,
259
+ ): string {
260
+ if (summary.nodeCount === undefined)
261
+ return coverageMessage(coverage.nodes, "Node inventory");
262
+ const parts: string[] = [];
263
+ if ((summary.poolCount ?? 0) > 0)
264
+ parts.push(
265
+ `${summary.nodeCount - (summary.unpooledNodeCount ?? 0)} in Karpenter pools`,
266
+ );
267
+ if ((summary.unattributedNodeCount ?? 0) > 0)
268
+ parts.push(`${summary.unattributedNodeCount} unattributed`);
269
+ return parts.length > 0
270
+ ? parts.join(" · ")
271
+ : coverageMessage(coverage.nodes, "Node inventory");
272
+ }
273
+
274
+ interface OverviewSignal {
275
+ key: string;
276
+ severity: "error" | "warning" | "info" | "neutral";
277
+ title: string;
278
+ detail: string;
279
+ /** Named subjects (pools) rendered as individual links into their evidence. */
280
+ subjects?: { label: string; open: () => void }[];
281
+ action?: { label: string; run: () => void };
282
+ }
283
+
284
+ export function CapacityOverview({
285
+ query,
286
+ connectionState,
287
+ onOpenPool,
288
+ onOpenResource,
289
+ onNavigate,
290
+ }: {
291
+ query: ReturnType<typeof useCapacityOverview>;
292
+ connectionState: CapacityConnectionState;
293
+ onOpenPool: (name: string) => void;
294
+ onOpenResource: (resource: SelectedResource) => void;
295
+ onNavigate: (path: string) => void;
296
+ }) {
297
+ const [showExplain, setShowExplain] = useState(false);
298
+ const inventoryRef = useRef<HTMLDivElement>(null);
299
+ const openExplainer = () => setShowExplain(true);
300
+
301
+ // A Karpenter-less cluster — or one whose NodePools this identity cannot read
302
+ // — still has capacity worth showing: its nodes, node groups owned by other
303
+ // managers, the autoscaler ConfigMap. Render the overview whenever there is an
304
+ // observable surface; only fall through to the "not detected" / "access
305
+ // denied" block when there is genuinely nothing to show. Loading / error /
306
+ // syncing still short-circuit through integrationBlock unchanged. Node
307
+ // visibility is the page gate on the server, so a truly node-blind caller
308
+ // never reaches here with a surface — the fetch 403s and integrationBlock
309
+ // renders the denial.
310
+ const response = query.data;
311
+ const hasObservableSurface =
312
+ !!response &&
313
+ (coverageHasObservations(response.coverage.nodes) ||
314
+ response.groups.length > 0 ||
315
+ response.orphanAutoscalerGroups.length > 0 ||
316
+ response.summary.managers.length > 0);
317
+ const stateWithSurface =
318
+ (response?.state === "not_detected" || response?.state === "denied") &&
319
+ hasObservableSurface;
320
+
321
+ if (!stateWithSurface) {
322
+ const blocked = integrationBlock(
323
+ query.data,
324
+ query.error,
325
+ query.isLoading,
326
+ "Loading capacity overview…",
327
+ );
328
+ if (blocked) return blocked;
329
+ }
330
+
331
+ const data = query.data as CapacityOverviewResponse;
332
+ // Karpenter-specific tiles/sections key off this, not off the presence of any
333
+ // capacity data — a not-detected cluster with observable nodes still renders.
334
+ const karpenterActive = data.state === "available";
335
+ // The NodePool-inventory guard is a Karpenter-detected concern (Karpenter is
336
+ // present but its NodePools can't be read). A not-detected cluster has no
337
+ // NodePool coverage by definition — that is not an error to block on.
338
+ if (karpenterActive && !coverageHasObservations(data.coverage.nodePools)) {
339
+ return (
340
+ <EmptyState
341
+ icon={Layers3}
342
+ title="NodePool inventory unavailable"
343
+ detail={coverageMessage(data.coverage.nodePools, "NodePool inventory")}
344
+ />
345
+ );
346
+ }
347
+
348
+ const summary = data.summary;
349
+ const coverage = data.coverage;
350
+ const podsDeniedFlag = coverageIsDenied(coverage.pods);
351
+ const unavailableRefresh = query.error ? errorMessage(query.error) : null;
352
+ // Cluster scope is the honest headline once the server measures every node;
353
+ // the Karpenter-only ledger is the fallback until it does — and only while
354
+ // Karpenter is actually a manager here (never a fabricated Karpenter bar on a
355
+ // cluster that has none).
356
+ const clusterScheduling = summary.clusterScheduling;
357
+ const schedulingForBar =
358
+ clusterScheduling ?? (karpenterActive ? summary.scheduling : undefined);
359
+ const schedulingScope = clusterScheduling ? "cluster" : "karpenter";
360
+ const groupsCertainty = nodeGroupsCertainty(coverage, data.state);
361
+
362
+ const signals = buildSignals(summary, { onOpenPool, onNavigate });
363
+
364
+ return (
365
+ <ScrollableContent>
366
+ <div>
367
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
368
+ <div>
369
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
370
+ <h1 className="text-lg font-semibold text-theme-text-primary">
371
+ Capacity overview
372
+ </h1>
373
+ <span className="text-xs text-theme-text-tertiary">
374
+ cluster capacity posture · read-only diagnosis
375
+ </span>
376
+ </div>
377
+ <button
378
+ type="button"
379
+ onClick={() => setShowExplain(true)}
380
+ className="mt-1 text-xs font-medium text-accent-text hover:underline"
381
+ >
382
+ Scheduling capacity ≠ actual usage — how to read these numbers
383
+ </button>
384
+ </div>
385
+ <div className="flex items-center gap-2">
386
+ <ScopeBadges coverage={coverage} />
387
+ <CapacityFreshness
388
+ meta={data}
389
+ query={query}
390
+ connectionState={connectionState}
391
+ />
392
+ </div>
393
+ </div>
394
+ </div>
395
+ <ExplainDialog open={showExplain} onClose={() => setShowExplain(false)} />
396
+
397
+ {unavailableRefresh && <RefreshError message={unavailableRefresh} />}
398
+
399
+ {coverageIsDenied(coverage.nodePools) && (
400
+ <Notice>
401
+ Karpenter view unavailable — your identity cannot list NodePools. The
402
+ cluster capacity below is derived from nodes and pods only; the
403
+ NodePool detail, Demand, and Activity screens stay unavailable.
404
+ </Notice>
405
+ )}
406
+
407
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
408
+ <ManagersTile
409
+ managers={summary.managers}
410
+ groups={data.groups}
411
+ autoscalerStatus={coverage.autoscalerStatus}
412
+ nodesObserved={coverageHasObservations(coverage.nodes)}
413
+ karpenterMode={data.provider.controllerMode}
414
+ />
415
+ <KpiTile
416
+ label="Node groups"
417
+ value={data.groups.length}
418
+ sub={nodeGroupsSubline(summary, data, coverage)}
419
+ certainty={groupsCertainty.certainty}
420
+ certaintyTitle={groupsCertainty.title}
421
+ attention={data.pools.some((pool) => pool.ready === false)}
422
+ linkLabel="View inventory ↓"
423
+ onClick={() =>
424
+ inventoryRef.current?.scrollIntoView({
425
+ behavior: "smooth",
426
+ block: "start",
427
+ })
428
+ }
429
+ />
430
+ <KpiTile
431
+ label="Nodes"
432
+ value={summary.nodeCount ?? "—"}
433
+ sub={nodesSubline(summary, coverage)}
434
+ certainty={coverageCertainty(coverage.nodes)}
435
+ certaintyTitle={coverageMessage(coverage.nodes, "Node inventory")}
436
+ />
437
+ {karpenterActive && (
438
+ <KpiTile
439
+ label="NodeClaims"
440
+ value={summary.claimCount ?? "—"}
441
+ sub={
442
+ claimStagesDetail(
443
+ summary.claimStages,
444
+ summary.orphanedClaimCount,
445
+ ) ?? coverageMessage(coverage.nodeClaims, "Claim inventory")
446
+ }
447
+ certainty={coverageCertainty(coverage.nodeClaims)}
448
+ certaintyTitle={coverageMessage(
449
+ coverage.nodeClaims,
450
+ "Claim inventory",
451
+ )}
452
+ attention={(summary.claimStages?.failed ?? 0) > 0}
453
+ />
454
+ )}
455
+ {podsDeniedFlag ? (
456
+ <KpiTile
457
+ label="Pending pods"
458
+ value="Unavailable"
459
+ sub="Pod access denied — not zero"
460
+ certainty="unknown"
461
+ certaintyTitle="Unknown — the current identity cannot list Pods. Absence of data is not zero."
462
+ />
463
+ ) : (
464
+ <KpiTile
465
+ label="Pending pods"
466
+ value={summary.pendingPodCount ?? "—"}
467
+ sub="scheduling demand, not usage"
468
+ certainty={coverageCertainty(coverage.pods)}
469
+ certaintyTitle={coverageMessage(coverage.pods, "Pending demand")}
470
+ attention={(summary.pendingPodCount ?? 0) > 0}
471
+ // Without Karpenter there is no Demand screen, but a pending count
472
+ // must never dead-end: Issues carries the scheduler verdicts on
473
+ // every cluster.
474
+ linkLabel={karpenterActive ? "Open Demand →" : "View issues →"}
475
+ onClick={
476
+ karpenterActive
477
+ ? () => onNavigate("/capacity/demand")
478
+ : () => onNavigate("/issues")
479
+ }
480
+ />
481
+ )}
482
+ </div>
483
+
484
+ <ClusterSchedulingCard
485
+ scheduling={schedulingForBar}
486
+ pending={podsDeniedFlag ? undefined : summary.aggregateDemand}
487
+ onExplain={openExplainer}
488
+ scope={schedulingScope}
489
+ />
490
+
491
+ <PendingDemandChips
492
+ summary={summary}
493
+ podsDenied={podsDeniedFlag}
494
+ podsObserved={coverageHasObservations(coverage.pods)}
495
+ podsPartial={coverageIsLowerBound(coverage.pods)}
496
+ />
497
+
498
+ {/* Operational signals are Karpenter-scoped: the actions come from the
499
+ Karpenter action ledger and every link lands on a Karpenter-gated
500
+ screen (Demand / Activity / pool diagnosis). On a Karpenter-less
501
+ cluster the section — and its dead-end links — is omitted rather than
502
+ shown empty. */}
503
+ {karpenterActive && (
504
+ <SectionCard
505
+ title="Operational signals"
506
+ subtitle="prioritized · each links to its diagnosis"
507
+ actions={
508
+ <div className="flex items-center gap-4">
509
+ <LinkButton onClick={() => onNavigate("/capacity/demand")}>
510
+ {summary.pendingPodCount !== undefined && !podsDeniedFlag
511
+ ? `All demand · ${summary.pendingPodCount} →`
512
+ : "All demand →"}
513
+ </LinkButton>
514
+ <LinkButton onClick={() => onNavigate("/capacity/activity")}>
515
+ Activity timeline →
516
+ </LinkButton>
517
+ </div>
518
+ }
519
+ bodyClassName="divide-y divide-theme-border-subtle"
520
+ >
521
+ {signals.length === 0 ? (
522
+ <InlineEmpty
523
+ title="No operational signals"
524
+ detail="Radar reported no capacity-affecting actions for this cluster."
525
+ />
526
+ ) : (
527
+ signals.map((signal) => (
528
+ <div
529
+ key={signal.key}
530
+ className="flex items-start gap-3 px-4 py-2.5 hover:bg-theme-hover/40"
531
+ >
532
+ <Badge
533
+ severity={signal.severity}
534
+ size="sm"
535
+ className="min-w-[3rem] justify-center"
536
+ >
537
+ {signalSeverityLabel(signal.severity)}
538
+ </Badge>
539
+ <div className="min-w-0 flex-1">
540
+ <div className="text-sm font-medium text-theme-text-primary">
541
+ {signal.title}
542
+ </div>
543
+ <div className="flex flex-wrap items-baseline gap-x-2 text-xs text-theme-text-secondary">
544
+ {signal.subjects?.map((subject) => (
545
+ <button
546
+ key={subject.label}
547
+ type="button"
548
+ onClick={subject.open}
549
+ className="font-mono text-accent-text hover:underline"
550
+ >
551
+ {subject.label}
552
+ </button>
553
+ ))}
554
+ {signal.detail && <span>{signal.detail}</span>}
555
+ </div>
556
+ </div>
557
+ {signal.action && (
558
+ <LinkButton
559
+ onClick={signal.action.run}
560
+ className="shrink-0 self-center"
561
+ >
562
+ {signal.action.label} →
563
+ </LinkButton>
564
+ )}
565
+ </div>
566
+ ))
567
+ )}
568
+ </SectionCard>
569
+ )}
570
+
571
+ <NodeGroupsSection
572
+ sectionRef={inventoryRef}
573
+ groups={data.groups}
574
+ pools={data.pools}
575
+ coverage={coverage}
576
+ poolCount={summary.poolCount}
577
+ unattributedNodeCount={summary.unattributedNodeCount ?? 0}
578
+ onOpenPool={onOpenPool}
579
+ onOpenResource={onOpenResource}
580
+ />
581
+
582
+ <OrphanAutoscalerSection
583
+ orphans={data.orphanAutoscalerGroups}
584
+ meta={data.orphanAutoscalerGroupsMeta}
585
+ />
586
+ </ScrollableContent>
587
+ );
588
+ }
589
+
590
+ // ============================================================================
591
+ // Capacity managers KPI tile
592
+ // ============================================================================
593
+
594
+ const GROUP_PLATFORM_LABELS: Record<string, string> = {
595
+ karpenter: "Karpenter NodePool",
596
+ gke: "GKE node pool",
597
+ eks: "EKS managed node group",
598
+ aks: "AKS agent pool",
599
+ kops: "kOps instance group",
600
+ doks: "DOKS node pool",
601
+ ack: "ACK node pool",
602
+ lke: "LKE node pool",
603
+ scaleway: "Scaleway pool",
604
+ };
605
+
606
+ export function groupPlatformLabel(platform: string): string {
607
+ return GROUP_PLATFORM_LABELS[platform] ?? humanizeCode(platform);
608
+ }
609
+
610
+ function ManagersTile({
611
+ managers,
612
+ groups,
613
+ autoscalerStatus,
614
+ nodesObserved,
615
+ karpenterMode,
616
+ }: {
617
+ managers: CapacityManagerSummary[];
618
+ groups: CapacityGroupSummary[];
619
+ autoscalerStatus?: CapacitySourceCoverage;
620
+ nodesObserved: boolean;
621
+ karpenterMode?: string;
622
+ }) {
623
+ const worst = worstManagerStatus(managers);
624
+ // Denied, cache-scoped, unreadable and unparseable all mean the same thing to
625
+ // an operator: detection did not run. Only a source that WAS read may report
626
+ // "none detected".
627
+ const detectionFailure = autoscalerDetectionFailure(autoscalerStatus);
628
+ const notPublishedDetail = autoscalerNotPublishedDetail(autoscalerStatus);
629
+ const unvalidated = new Set(
630
+ groups
631
+ .filter((group) => group.manager && !group.managerValidated)
632
+ .map((group) => group.manager),
633
+ );
634
+ const observedAt = coverageHasObservations(autoscalerStatus)
635
+ ? autoscalerStatus?.observedAt
636
+ : undefined;
637
+ return (
638
+ <div className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
639
+ <div className="flex items-center gap-1.5">
640
+ <span className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
641
+ Capacity managers
642
+ </span>
643
+ {worst === "degraded" && (
644
+ <Badge severity="warning" size="sm">
645
+ Attention
646
+ </Badge>
647
+ )}
648
+ </div>
649
+ <div className="mt-1.5">
650
+ {managers.length === 0 ? (
651
+ detectionFailure ? (
652
+ <>
653
+ <div className="text-sm font-medium text-theme-text-primary">
654
+ Detection unavailable
655
+ </div>
656
+ <div className="mt-0.5 text-xs text-theme-text-tertiary">
657
+ {detectionFailure}
658
+ </div>
659
+ </>
660
+ ) : nodesObserved ? (
661
+ <>
662
+ <div className="text-sm text-theme-text-secondary">
663
+ None detected
664
+ </div>
665
+ {notPublishedDetail && (
666
+ <div className="mt-0.5 text-xs text-theme-text-tertiary">
667
+ {notPublishedDetail}
668
+ </div>
669
+ )}
670
+ </>
671
+ ) : (
672
+ <div className="text-xs text-theme-text-tertiary">
673
+ Not yet observed
674
+ </div>
675
+ )
676
+ ) : (
677
+ <div className="flex flex-col gap-1">
678
+ {managers.map((manager) => (
679
+ <ManagerLine
680
+ key={manager.manager}
681
+ manager={manager}
682
+ karpenterMode={karpenterMode}
683
+ validated={!unvalidated.has(manager.manager)}
684
+ />
685
+ ))}
686
+ {detectionFailure && (
687
+ <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
688
+ {`Autoscaler detection is partially unavailable. ${detectionFailure}`}
689
+ </div>
690
+ )}
691
+ {observedAt && (
692
+ <WithTooltip
693
+ tip={`The autoscaler publishes its own timestamp; a quiet cluster republishes rarely, so an old reading is context, not breakage. Published ${formatTimestamp(observedAt)}.`}
694
+ >
695
+ <div className="mt-0.5 text-[11px] text-theme-text-tertiary">
696
+ as of {relativeTime(observedAt)}
697
+ </div>
698
+ </WithTooltip>
699
+ )}
700
+ </div>
701
+ )}
702
+ </div>
703
+ </div>
704
+ );
705
+ }
706
+
707
+ /** Quiet marker for a manager whose node→group attribution Radar has not yet
708
+ * validated against a live cluster of that platform. */
709
+ function UnvalidatedHint() {
710
+ return (
711
+ <WithTooltip tip="Attribution for this platform is not yet validated on live clusters — group membership here is best effort.">
712
+ <span className="cursor-help text-[10px] text-theme-text-tertiary underline decoration-dotted underline-offset-2">
713
+ unvalidated
714
+ </span>
715
+ </WithTooltip>
716
+ );
717
+ }
718
+
719
+ function ManagerLine({
720
+ manager,
721
+ karpenterMode,
722
+ validated,
723
+ }: {
724
+ manager: CapacityManagerSummary;
725
+ karpenterMode?: string;
726
+ validated: boolean;
727
+ }) {
728
+ const tone = managerStatusTone(manager.status);
729
+ const showDetail = manager.status === "degraded" && manager.detail;
730
+ // Auto Mode is the notable case (AWS runs the controller — its logs and
731
+ // knobs are out of reach); self-managed is the default and earns no ink.
732
+ const modeSuffix =
733
+ manager.manager === "karpenter" && karpenterMode === "eks_auto"
734
+ ? " · EKS Auto Mode"
735
+ : "";
736
+ return (
737
+ <div>
738
+ <div className="flex items-baseline justify-between gap-2">
739
+ <span className="flex min-w-0 items-center gap-1.5">
740
+ <StatusDot tone={tone} />
741
+ <span className="truncate text-sm text-theme-text-primary">
742
+ {capacityManagerLabel(manager.manager)}
743
+ {modeSuffix}
744
+ </span>
745
+ {!validated && <UnvalidatedHint />}
746
+ </span>
747
+ <span className="shrink-0 font-mono text-xs text-theme-text-tertiary">
748
+ {manager.groupCount} {manager.groupCount === 1 ? "group" : "groups"}
749
+ </span>
750
+ </div>
751
+ {showDetail && (
752
+ <div className="mt-0.5 pl-3 text-[11px] text-theme-text-tertiary">
753
+ {manager.detail}
754
+ </div>
755
+ )}
756
+ </div>
757
+ );
758
+ }
759
+
760
+ // ============================================================================
761
+ // Node groups inventory (every manager) + autoscaler-child sub-tables
762
+ // ============================================================================
763
+
764
+ function NodeGroupsSection({
765
+ sectionRef,
766
+ groups,
767
+ pools,
768
+ coverage,
769
+ poolCount,
770
+ unattributedNodeCount,
771
+ onOpenPool,
772
+ onOpenResource,
773
+ }: {
774
+ sectionRef: RefObject<HTMLDivElement | null>;
775
+ groups: CapacityGroupSummary[];
776
+ pools: CapacityPoolSummary[];
777
+ coverage: CapacityCoverageBySource;
778
+ poolCount?: number;
779
+ unattributedNodeCount: number;
780
+ onOpenPool: (name: string) => void;
781
+ onOpenResource: (resource: SelectedResource) => void;
782
+ }) {
783
+ const nodesObserved = coverageHasObservations(coverage.nodes);
784
+ // A group with no attributed manager means one of two very different things,
785
+ // and only the detection source can tell them apart.
786
+ const detectionFailure = autoscalerDetectionFailure(
787
+ coverage.autoscalerStatus,
788
+ );
789
+ const poolByName = useMemo(() => {
790
+ const map = new Map<string, CapacityPoolSummary>();
791
+ for (const pool of pools) map.set(pool.resource.ref.name, pool);
792
+ return map;
793
+ }, [pools]);
794
+
795
+ return (
796
+ <div ref={sectionRef} className="scroll-mt-4">
797
+ <SectionCard
798
+ title="Node groups"
799
+ subtitle="logical groups across every capacity manager"
800
+ bodyClassName=""
801
+ >
802
+ {groups.length === 0 ? (
803
+ nodesObserved ? (
804
+ <InlineEmpty
805
+ title="No node groups identified"
806
+ detail={
807
+ (poolCount ?? 0) > 0
808
+ ? "Nodes carry no group-identity labels and no Karpenter NodePool owns them yet."
809
+ : "Nodes carry no group-identity labels."
810
+ }
811
+ />
812
+ ) : (
813
+ <InlineEmpty
814
+ title="Node groups unavailable"
815
+ detail={coverageMessage(coverage.nodes, "Node inventory")}
816
+ />
817
+ )
818
+ ) : (
819
+ <>
820
+ <div className={TABLE_WRAP}>
821
+ <table className="w-full min-w-[980px] text-left">
822
+ <thead className={TABLE_HEAD}>
823
+ <tr>
824
+ <th className={TH}>Group</th>
825
+ <th className={TH}>Status</th>
826
+ <th className={TH}>Manager</th>
827
+ <th className={TH}>Nodes</th>
828
+ <th className={TH}>
829
+ <WithTooltip tip="Allocatable the kubelet offers the scheduler across this group's nodes">
830
+ <span>Allocatable</span>
831
+ </WithTooltip>
832
+ </th>
833
+ <th className={TH}>
834
+ <WithTooltip tip="Sum of scheduled pod requests on this group's nodes">
835
+ <span>Scheduled requests</span>
836
+ </WithTooltip>
837
+ </th>
838
+ <th className={TH}>Scaling</th>
839
+ </tr>
840
+ </thead>
841
+ <tbody className={TBODY}>
842
+ {groups.map((group) => (
843
+ <GroupRow
844
+ key={group.id}
845
+ group={group}
846
+ pool={
847
+ group.manager === "karpenter"
848
+ ? poolByName.get(group.name)
849
+ : undefined
850
+ }
851
+ detectionFailure={detectionFailure}
852
+ onOpenPool={onOpenPool}
853
+ onOpenResource={onOpenResource}
854
+ />
855
+ ))}
856
+ </tbody>
857
+ </table>
858
+ </div>
859
+ {unattributedNodeCount > 0 && (
860
+ <div className="border-t border-theme-border-subtle px-4 py-2.5">
861
+ <WithTooltip tip="No group-identity label and no Karpenter ownership — Radar does not guess group membership from node names.">
862
+ <span className="text-xs text-theme-text-tertiary">
863
+ {`${unattributedNodeCount} ${
864
+ unattributedNodeCount === 1 ? "node" : "nodes"
865
+ } without group identity`}
866
+ </span>
867
+ </WithTooltip>
868
+ </div>
869
+ )}
870
+ </>
871
+ )}
872
+ </SectionCard>
873
+ </div>
874
+ );
875
+ }
876
+
877
+ function ExplainDialog({
878
+ open,
879
+ onClose,
880
+ }: {
881
+ open: boolean;
882
+ onClose: () => void;
883
+ }) {
884
+ const dialogRef = useRef<HTMLDivElement>(null);
885
+ useEffect(() => {
886
+ if (!open) return;
887
+ const onKeyDown = (event: KeyboardEvent) => {
888
+ if (event.key === "Escape") onClose();
889
+ };
890
+ window.addEventListener("keydown", onKeyDown);
891
+ dialogRef.current?.focus();
892
+ return () => window.removeEventListener("keydown", onKeyDown);
893
+ }, [open, onClose]);
894
+ if (!open) return null;
895
+ return (
896
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
897
+ <div
898
+ className="absolute inset-0 bg-black/60 backdrop-blur-sm"
899
+ onClick={onClose}
900
+ />
901
+ <div
902
+ ref={dialogRef}
903
+ tabIndex={-1}
904
+ role="dialog"
905
+ aria-label="How to read capacity numbers"
906
+ className="dialog relative mx-4 flex max-h-[85vh] w-full max-w-3xl flex-col outline-none"
907
+ >
908
+ <div className="flex shrink-0 items-center justify-between border-b border-theme-border/50 px-6 py-4">
909
+ <div className="flex items-center gap-3">
910
+ <Layers3 className="h-5 w-5 text-accent-text" />
911
+ <h3 className="text-lg font-semibold text-theme-text-primary">
912
+ How to read these numbers
913
+ </h3>
914
+ </div>
915
+ <button
916
+ onClick={onClose}
917
+ aria-label="Close"
918
+ className="rounded-md p-1.5 transition-colors hover:bg-theme-elevated"
919
+ >
920
+ <X className="h-5 w-5 text-theme-text-tertiary" />
921
+ </button>
922
+ </div>
923
+ <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
924
+ <p className="text-sm text-theme-text-secondary">
925
+ Scheduling capacity ≠ actual usage. Requests are what the scheduler
926
+ consumes; usage is an efficiency signal — the two never mix, and no
927
+ bar here is a health score.
928
+ </p>
929
+ <div className="mt-4 grid gap-3 sm:grid-cols-2">
930
+ {EXPLAIN_CARDS.map((card) => (
931
+ <div key={card.term} className="text-xs leading-relaxed">
932
+ <div className="font-semibold text-theme-text-primary">
933
+ {card.term}
934
+ </div>
935
+ <div className="mt-0.5 text-theme-text-tertiary">
936
+ {card.body}
937
+ </div>
938
+ </div>
939
+ ))}
940
+ </div>
941
+ <p className="mt-4 border-t border-theme-border-subtle pt-3 text-[11px] text-theme-text-tertiary">
942
+ Certainty on each value — <span className="font-mono">=</span> exact
943
+ · <span className="font-mono">≥</span> lower bound ·{" "}
944
+ <span className="font-mono">≤</span> upper bound (a difference
945
+ computed from a lower-bound input) ·{" "}
946
+ <span className="font-mono">?</span> unknown. Hover a glyph for its
947
+ coverage detail.
948
+ </p>
949
+ </div>
950
+ </div>
951
+ </div>
952
+ );
953
+ }
954
+
955
+ /** Worst-of rollup of the autoscaler's own per-child health so the collapsed
956
+ * row carries the "expand me" signal: backoff outranks unhealthy outranks
957
+ * healthy; health strings pass through verbatim. Null when no child reports
958
+ * anything — never a fabricated "Healthy". */
959
+ function worstChildHealth(
960
+ children: CapacityAutoscalerChildObservation[],
961
+ ): { label: string; tone: "healthy" | "alert"; tip?: string } | null {
962
+ const backoff = children.find((child) => child.backoff);
963
+ if (backoff) {
964
+ const message = backoff.backoff?.errorMessage;
965
+ return {
966
+ label: "Backoff",
967
+ tone: "alert",
968
+ tip: `${backoff.id}${message ? `: ${message}` : ""}`,
969
+ };
970
+ }
971
+ const unhealthy = children.find(
972
+ (child) => child.health && child.health.toLowerCase() !== "healthy",
973
+ );
974
+ if (unhealthy) {
975
+ return { label: unhealthy.health ?? "", tone: "alert", tip: unhealthy.id };
976
+ }
977
+ if (!children.some((child) => child.health)) return null;
978
+ return { label: "Healthy", tone: "healthy" };
979
+ }
980
+
981
+ function GroupRow({
982
+ group,
983
+ pool,
984
+ detectionFailure,
985
+ onOpenPool,
986
+ onOpenResource,
987
+ }: {
988
+ group: CapacityGroupSummary;
989
+ pool?: CapacityPoolSummary;
990
+ /** Why manager detection could not run, when it could not. Non-null forbids
991
+ * the "none detected" conclusion for a group with no attributed manager. */
992
+ detectionFailure: string | null;
993
+ onOpenPool: (name: string) => void;
994
+ onOpenResource: (resource: SelectedResource) => void;
995
+ }) {
996
+ const [expanded, setExpanded] = useState(false);
997
+ const hasChildren = group.children.length > 0;
998
+ const isKarpenter = group.manager === "karpenter";
999
+ const worstPressure = pool
1000
+ ? pickWorstPressure(pool.ledger.limitPressure)
1001
+ : undefined;
1002
+ const childHealth = worstChildHealth(group.children);
1003
+ // Server-rendered prose — joined, never sorted or parsed.
1004
+ const scalingText = group.scaling.map((fact) => fact.summary).join(" · ");
1005
+
1006
+ return (
1007
+ <>
1008
+ <tr
1009
+ className={`${ROW_HOVER}${hasChildren ? " cursor-pointer" : ""}`}
1010
+ onClick={
1011
+ hasChildren
1012
+ ? (event) => {
1013
+ // The whole row toggles the children, but clicks on inner
1014
+ // links/buttons (pool link, Inspect, tooltips) keep their own
1015
+ // behavior.
1016
+ const target = event.target as HTMLElement;
1017
+ if (target.closest("button, a, [role='link']")) return;
1018
+ setExpanded((value) => !value);
1019
+ }
1020
+ : undefined
1021
+ }
1022
+ >
1023
+ <td className={TD}>
1024
+ <div className="flex items-center gap-1.5">
1025
+ {hasChildren ? (
1026
+ <button
1027
+ type="button"
1028
+ aria-expanded={expanded}
1029
+ aria-label={expanded ? "Collapse children" : "Expand children"}
1030
+ onClick={() => setExpanded((value) => !value)}
1031
+ className="rounded p-0.5 hover:bg-theme-hover"
1032
+ >
1033
+ <CollapseChevron open={expanded} className="h-3.5 w-3.5" />
1034
+ </button>
1035
+ ) : (
1036
+ // Names align across rows with and without children.
1037
+ <span aria-hidden="true" className="w-[23px] shrink-0" />
1038
+ )}
1039
+ {isKarpenter ? (
1040
+ <LinkButton
1041
+ onClick={() => onOpenPool(group.name)}
1042
+ className="font-mono text-xs font-medium text-accent-text hover:underline"
1043
+ >
1044
+ {group.name}
1045
+ </LinkButton>
1046
+ ) : (
1047
+ <span className="font-mono text-xs text-theme-text-primary">
1048
+ {group.name}
1049
+ </span>
1050
+ )}
1051
+ </div>
1052
+ </td>
1053
+ <td className={TD}>
1054
+ <div className="flex flex-wrap items-center gap-1.5">
1055
+ {pool && <PoolReadyBadge ready={pool.ready} />}
1056
+ {worstPressure?.overLimit && (
1057
+ <Badge severity="warning" size="sm">
1058
+ Over limit
1059
+ </Badge>
1060
+ )}
1061
+ {childHealth && (
1062
+ <WithTooltip
1063
+ tip={
1064
+ childHealth.tip ??
1065
+ "Worst of the autoscaler's own per-group health reports."
1066
+ }
1067
+ >
1068
+ <span className="flex items-center gap-1 text-xs text-theme-text-secondary">
1069
+ <StatusDot tone={childHealth.tone} />
1070
+ {childHealth.label}
1071
+ </span>
1072
+ </WithTooltip>
1073
+ )}
1074
+ {pool && pool.issueCount > 0 && (
1075
+ <Badge severity="warning" size="sm">
1076
+ {pool.issueCount} {pool.issueCount === 1 ? "issue" : "issues"}
1077
+ </Badge>
1078
+ )}
1079
+ {isKarpenter && pool && (
1080
+ <LinkButton
1081
+ onClick={() =>
1082
+ onOpenResource(identityToSelectedResource(pool.resource))
1083
+ }
1084
+ title="Open the raw NodePool in the resource drawer"
1085
+ className="text-theme-text-tertiary"
1086
+ >
1087
+ Inspect
1088
+ </LinkButton>
1089
+ )}
1090
+ {!pool && !childHealth && !worstPressure?.overLimit && (
1091
+ <span className="text-xs text-theme-text-tertiary">—</span>
1092
+ )}
1093
+ </div>
1094
+ </td>
1095
+ <td className={TD}>
1096
+ {group.manager ? (
1097
+ <span className="flex flex-wrap items-center gap-1.5">
1098
+ <Badge tone="structural" size="sm">
1099
+ {capacityManagerLabel(group.manager)}
1100
+ </Badge>
1101
+ {!group.managerValidated && <UnvalidatedHint />}
1102
+ </span>
1103
+ ) : detectionFailure ? (
1104
+ <WithTooltip tip={detectionFailure}>
1105
+ <span className="text-xs text-theme-text-tertiary">
1106
+ detection unavailable
1107
+ </span>
1108
+ </WithTooltip>
1109
+ ) : (
1110
+ <WithTooltip tip="No autoscaler was observed for this group — the platform identity comes from its node labels.">
1111
+ <span className="text-xs text-theme-text-tertiary">
1112
+ {groupPlatformLabel(group.platform)}
1113
+ </span>
1114
+ </WithTooltip>
1115
+ )}
1116
+ </td>
1117
+ <td
1118
+ className={`${TD} whitespace-nowrap font-mono text-xs text-theme-text-secondary`}
1119
+ >
1120
+ {`${group.readyNodeCount}/${group.nodeCount}`}
1121
+ </td>
1122
+ <td className={TD}>
1123
+ <InventoryQuantityCell
1124
+ observation={group.allocatable}
1125
+ empty="Not observed"
1126
+ />
1127
+ </td>
1128
+ <td className={TD}>
1129
+ <InventoryQuantityCell
1130
+ observation={group.scheduledRequests}
1131
+ empty="Not observed"
1132
+ />
1133
+ </td>
1134
+ <td className={`${TD} text-xs text-theme-text-secondary`}>
1135
+ {scalingText || <span className="text-theme-text-tertiary">—</span>}
1136
+ </td>
1137
+ </tr>
1138
+ {hasChildren && (
1139
+ <tr>
1140
+ <td colSpan={7} className="p-0">
1141
+ <Collapse open={expanded}>
1142
+ <ChildSubTable group={group} />
1143
+ </Collapse>
1144
+ </td>
1145
+ </tr>
1146
+ )}
1147
+ </>
1148
+ );
1149
+ }
1150
+
1151
+ const CHILD_TH = "px-3 py-1.5 text-left font-medium whitespace-nowrap";
1152
+ const CHILD_TD = "px-3 py-1.5 align-top text-xs text-theme-text-primary";
1153
+
1154
+ function ChildSubTable({ group }: { group: CapacityGroupSummary }) {
1155
+ return (
1156
+ <div className="border-t border-theme-border-subtle bg-theme-base/40 px-4 py-3">
1157
+ <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
1158
+ As the autoscaler reports it
1159
+ </div>
1160
+ <div className="overflow-x-auto">
1161
+ <table className="w-full min-w-[720px] text-left">
1162
+ <thead className="text-[11px] uppercase tracking-wide text-theme-text-tertiary">
1163
+ <tr>
1164
+ <th className={CHILD_TH}>Group</th>
1165
+ <th className={CHILD_TH}>Min–max</th>
1166
+ <th className={CHILD_TH}>Target</th>
1167
+ <th className={CHILD_TH}>Ready / total</th>
1168
+ <th className={CHILD_TH}>Health</th>
1169
+ <th className={CHILD_TH}>Backoff</th>
1170
+ <th className={CHILD_TH}>Observed</th>
1171
+ </tr>
1172
+ </thead>
1173
+ <tbody className={TBODY}>
1174
+ {group.children.map((child) => (
1175
+ <tr key={child.id} className={ROW_HOVER}>
1176
+ <td className={`${CHILD_TD} font-mono`}>
1177
+ <WithTooltip tip={child.name}>
1178
+ <span>{child.id}</span>
1179
+ </WithTooltip>
1180
+ </td>
1181
+ <td
1182
+ className={`${CHILD_TD} font-mono text-theme-text-secondary`}
1183
+ >
1184
+ {childMinMax(child)}
1185
+ </td>
1186
+ <td className={CHILD_TD}>
1187
+ <ChildTarget target={child.target} />
1188
+ </td>
1189
+ <td
1190
+ className={`${CHILD_TD} font-mono text-theme-text-secondary`}
1191
+ >
1192
+ {childReadyTotal(child)}
1193
+ </td>
1194
+ <td className={CHILD_TD}>
1195
+ <ChildHealth health={child.health} />
1196
+ </td>
1197
+ <td className={CHILD_TD}>
1198
+ <ChildBackoff backoff={child.backoff} />
1199
+ </td>
1200
+ <td className={CHILD_TD}>
1201
+ <ChildObserved asOf={child.asOf} />
1202
+ </td>
1203
+ </tr>
1204
+ ))}
1205
+ </tbody>
1206
+ </table>
1207
+ </div>
1208
+ {group.childrenMeta.truncated && (
1209
+ <p className="mt-2 text-[11px] text-theme-text-tertiary">
1210
+ {`Showing ${group.childrenMeta.returned} of ${group.childrenMeta.total} child groups.`}
1211
+ </p>
1212
+ )}
1213
+ </div>
1214
+ );
1215
+ }
1216
+
1217
+ // ============================================================================
1218
+ // Autoscaler-known groups with no joinable node (scale-to-zero included)
1219
+ // ============================================================================
1220
+
1221
+ function OrphanAutoscalerSection({
1222
+ orphans,
1223
+ meta,
1224
+ }: {
1225
+ orphans: CapacityAutoscalerChildObservation[];
1226
+ meta: CapacityBoundedResultMeta;
1227
+ }) {
1228
+ if (orphans.length === 0) return null;
1229
+ return (
1230
+ <SectionCard
1231
+ title="Known to the autoscaler, unattributed"
1232
+ subtitle="Groups the autoscaler reports that no observed node joins — scale-to-zero groups live here until nodes appear."
1233
+ bodyClassName=""
1234
+ >
1235
+ <div className={TABLE_WRAP}>
1236
+ <table className="w-full min-w-[720px] text-left">
1237
+ <thead className={TABLE_HEAD}>
1238
+ <tr>
1239
+ <th className={TH}>Group</th>
1240
+ <th className={TH}>Min–max</th>
1241
+ <th className={TH}>Target</th>
1242
+ <th className={TH}>Health</th>
1243
+ <th className={TH}>Observed</th>
1244
+ </tr>
1245
+ </thead>
1246
+ <tbody className={TBODY}>
1247
+ {orphans.map((child) => (
1248
+ <tr key={child.id} className={ROW_HOVER}>
1249
+ <td className={`${TD} font-mono text-xs`}>
1250
+ <WithTooltip tip={child.name}>
1251
+ <span>{child.id}</span>
1252
+ </WithTooltip>
1253
+ </td>
1254
+ <td
1255
+ className={`${TD} whitespace-nowrap font-mono text-xs text-theme-text-secondary`}
1256
+ >
1257
+ {childMinMax(child)}
1258
+ </td>
1259
+ <td className={TD}>
1260
+ <ChildTarget target={child.target} />
1261
+ </td>
1262
+ <td className={TD}>
1263
+ <ChildHealth health={child.health} />
1264
+ </td>
1265
+ <td className={TD}>
1266
+ <ChildObserved asOf={child.asOf} />
1267
+ </td>
1268
+ </tr>
1269
+ ))}
1270
+ </tbody>
1271
+ </table>
1272
+ </div>
1273
+ {meta.truncated && (
1274
+ <div className="border-t border-theme-border-subtle px-4 py-2.5 text-[11px] text-theme-text-tertiary">
1275
+ {`Showing ${meta.returned} of ${meta.total} groups.`}
1276
+ </div>
1277
+ )}
1278
+ </SectionCard>
1279
+ );
1280
+ }
1281
+
1282
+ // ---------------------------------------------------------------------------
1283
+ // Autoscaler-child cell renderers (shared by group children + orphan groups)
1284
+ // ---------------------------------------------------------------------------
1285
+
1286
+ function childMinMax(child: CapacityAutoscalerChildObservation): string {
1287
+ if (child.minSize === undefined && child.maxSize === undefined) return "—";
1288
+ return `${child.minSize ?? "?"}–${child.maxSize ?? "?"}`;
1289
+ }
1290
+
1291
+ function childReadyTotal(child: CapacityAutoscalerChildObservation): string {
1292
+ if (child.readyNodes === undefined && child.totalNodes === undefined)
1293
+ return "—";
1294
+ return `${child.readyNodes ?? "?"}/${child.totalNodes ?? "?"}`;
1295
+ }
1296
+
1297
+ function ChildTarget({ target }: { target?: number }) {
1298
+ // A published 0 is a real target (scale-to-zero), not an absence — render it.
1299
+ if (target === undefined)
1300
+ return <span className="text-theme-text-tertiary">—</span>;
1301
+ return (
1302
+ <span className="font-mono text-theme-text-primary tabular-nums">
1303
+ {target}
1304
+ </span>
1305
+ );
1306
+ }
1307
+
1308
+ function ChildHealth({ health }: { health?: string }) {
1309
+ if (!health) return <span className="text-theme-text-tertiary">—</span>;
1310
+ return (
1311
+ <span className="flex items-center gap-1.5">
1312
+ <StatusDot tone={mapHealthToTone(health)} />
1313
+ <span className="text-theme-text-secondary">{health}</span>
1314
+ </span>
1315
+ );
1316
+ }
1317
+
1318
+ function ChildBackoff({ backoff }: { backoff?: CapacityAutoscalerBackoff }) {
1319
+ // The legacy status format reports a group as backing off without any error
1320
+ // class, code or message. A dash there would read as "not backing off" — the
1321
+ // detail is missing, the backoff is not.
1322
+ if (!backoff) return <span className="text-theme-text-tertiary">—</span>;
1323
+ const message = backoff.errorMessage;
1324
+ const label = backoff.errorCode ?? backoff.errorClass;
1325
+ return (
1326
+ <WithTooltip
1327
+ tip={
1328
+ message ??
1329
+ label ??
1330
+ "The autoscaler reports this group in backoff without a reason."
1331
+ }
1332
+ >
1333
+ <Badge severity="alert" size="sm">
1334
+ {label ?? "Backoff"}
1335
+ </Badge>
1336
+ </WithTooltip>
1337
+ );
1338
+ }
1339
+
1340
+ /** Relative freshness; an absent `asOf` is "not probed" — never a zero time
1341
+ * and never "now" (the payload published null, meaning we never checked). */
1342
+ function ChildObserved({ asOf }: { asOf?: string }) {
1343
+ if (!asOf)
1344
+ return <span className="text-theme-text-tertiary">not probed</span>;
1345
+ return (
1346
+ <span className="text-theme-text-secondary">{relativeTime(asOf)}</span>
1347
+ );
1348
+ }
1349
+
1350
+ function PendingDemandChips({
1351
+ summary,
1352
+ podsDenied,
1353
+ podsObserved,
1354
+ podsPartial,
1355
+ }: {
1356
+ summary: CapacityOverviewSummary;
1357
+ podsDenied: boolean;
1358
+ podsObserved: boolean;
1359
+ /** Pod coverage is a lower bound (namespace-scoped or partial) — an empty
1360
+ * result then means "none observed in scope", never a global "none". */
1361
+ podsPartial: boolean;
1362
+ }) {
1363
+ // The "pods" resource duplicates the dedicated "Pod slots" chip below.
1364
+ const entries = summary.aggregateDemand
1365
+ ? sortedResourceEntries(summary.aggregateDemand.resources).filter(
1366
+ ([resource]) => resource !== "pods",
1367
+ )
1368
+ : [];
1369
+ const demandCertainty = summary.aggregateDemand?.certainty;
1370
+ return (
1371
+ <div className="flex flex-wrap items-center gap-2">
1372
+ <span className="text-xs text-theme-text-tertiary">
1373
+ Active pending requests (scheduling demand, not usage):
1374
+ </span>
1375
+ {podsDenied ? (
1376
+ <DeniedBadge />
1377
+ ) : !podsObserved ? (
1378
+ // Pods not yet observed (syncing/unavailable) — absence is not zero.
1379
+ <span className="text-xs text-theme-text-tertiary">Not observed</span>
1380
+ ) : entries.length === 0 ? (
1381
+ <span className="text-xs text-theme-text-tertiary">
1382
+ {podsPartial ? "None observed in scope" : "None pending"}
1383
+ </span>
1384
+ ) : (
1385
+ <>
1386
+ {entries.map(([resource, value]) => (
1387
+ <Badge key={resource} tone="structural" size="sm">
1388
+ <span className="font-mono">
1389
+ {`${shortResourceLabel(resource)} ${formatQuantity(resource, value)}`}
1390
+ </span>
1391
+ </Badge>
1392
+ ))}
1393
+ {summary.pendingPodCount !== undefined && (
1394
+ <Badge tone="structural" size="sm">
1395
+ <span className="font-mono">{`Pod slots ${summary.pendingPodCount}`}</span>
1396
+ </Badge>
1397
+ )}
1398
+ {demandCertainty && demandCertainty !== "exact" && (
1399
+ <CertaintyGlyph certainty={demandCertainty} />
1400
+ )}
1401
+ </>
1402
+ )}
1403
+ </div>
1404
+ );
1405
+ }
1406
+
1407
+ /** "8 ready · 1 launched · 1 failed · +1 orphaned" — nonzero stages in
1408
+ * lifecycle order, failed kept late for adjacency with the orphan note.
1409
+ * Null when the stage rollup is absent so the tile can fall back to its
1410
+ * coverage message (unavailable ≠ zero claims). */
1411
+ export function claimStagesDetail(
1412
+ stages: CapacityClaimLifecycleSummary | undefined,
1413
+ orphaned: number | undefined,
1414
+ ): string | null {
1415
+ if (!stages) return null;
1416
+ const parts: string[] = [];
1417
+ const push = (count: number, label: string) => {
1418
+ if (count > 0) parts.push(`${count} ${label}`);
1419
+ };
1420
+ push(stages.ready, "ready");
1421
+ push(stages.initialized, "initialized");
1422
+ push(stages.registered, "registered");
1423
+ push(stages.launched, "launched");
1424
+ push(stages.pending, "pending");
1425
+ push(stages.failed, "failed");
1426
+ push(stages.terminating, "terminating");
1427
+ if (orphaned) parts.push(`+${orphaned} orphaned`);
1428
+ if (parts.length === 0) return stages.total === 0 ? "none yet" : null;
1429
+ return parts.join(" · ");
1430
+ }
1431
+
1432
+ function signalSeverityLabel(severity: OverviewSignal["severity"]): string {
1433
+ if (severity === "error") return "Alert";
1434
+ if (severity === "warning") return "Warn";
1435
+ if (severity === "info") return "Info";
1436
+ return "Note";
1437
+ }
1438
+
1439
+ function buildSignals(
1440
+ summary: CapacityOverviewSummary,
1441
+ handlers: {
1442
+ onOpenPool: (name: string) => void;
1443
+ onNavigate: (path: string) => void;
1444
+ },
1445
+ ): OverviewSignal[] {
1446
+ const signals: OverviewSignal[] = summary.actions.map((action) => {
1447
+ const demandState: CapacityDemandState | undefined =
1448
+ action.code === "pending_demand_blocked"
1449
+ ? "blocked"
1450
+ : action.code === "pending_demand_resource_pressure"
1451
+ ? "awaiting_capacity"
1452
+ : action.code === "pending_demand_unclassified"
1453
+ ? "unknown"
1454
+ : undefined;
1455
+ const subjects = action.pools.slice(0, 3).map((pool) => ({
1456
+ label: pool.ref.name,
1457
+ open: () => handlers.onOpenPool(pool.ref.name),
1458
+ }));
1459
+ const detailParts: string[] = [];
1460
+ // The wire caps subjects at 25 (`truncated`), so the visible array length
1461
+ // must never masquerade as the total — an action over 50 pools is
1462
+ // "+22+ more", not "+22 more".
1463
+ const remainder = action.pools.length - subjects.length;
1464
+ if (remainder > 0)
1465
+ detailParts.push(`+${remainder}${action.truncated ? "+" : ""} more`);
1466
+ if (action.count > 0)
1467
+ detailParts.push(
1468
+ `${action.count} ${action.count === 1 ? "occurrence" : "occurrences"}`,
1469
+ );
1470
+ const firstPool = action.pools[0];
1471
+ const action_: OverviewSignal["action"] = demandState
1472
+ ? {
1473
+ label: "View demand",
1474
+ run: () =>
1475
+ handlers.onNavigate(
1476
+ `/capacity/demand?state=${encodeURIComponent(demandState)}`,
1477
+ ),
1478
+ }
1479
+ : firstPool
1480
+ ? {
1481
+ label: "Diagnose pool",
1482
+ run: () => handlers.onOpenPool(firstPool.ref.name),
1483
+ }
1484
+ : undefined;
1485
+ return {
1486
+ key: `action:${action.code}`,
1487
+ severity: actionSeverity(action.highestSeverity),
1488
+ title: humanizeCode(action.code),
1489
+ detail:
1490
+ detailParts.join(" · ") ||
1491
+ (subjects.length > 0
1492
+ ? ""
1493
+ : "Reported by the Capacity API for this cluster."),
1494
+ subjects: subjects.length > 0 ? subjects : undefined,
1495
+ action: action_,
1496
+ };
1497
+ });
1498
+
1499
+ const orphaned = summary.orphanedClaimCount ?? 0;
1500
+ const unpooled = summary.unpooledNodeCount ?? 0;
1501
+ if (orphaned > 0 || unpooled > 0) {
1502
+ const parts: string[] = [];
1503
+ if (orphaned > 0)
1504
+ parts.push(
1505
+ `${orphaned} ${orphaned === 1 ? "claim references" : "claims reference"} a deleted NodePool`,
1506
+ );
1507
+ if (unpooled > 0)
1508
+ parts.push(
1509
+ `${unpooled} ${unpooled === 1 ? "node is" : "nodes are"} outside Karpenter pools`,
1510
+ );
1511
+ signals.push({
1512
+ key: "inventory:outside-pools",
1513
+ severity: "neutral",
1514
+ title: "Inventory outside live pools",
1515
+ // "Both" only when two facts are actually listed — one orphaned-claim or
1516
+ // one unpooled-node fact reads as a miscount otherwise.
1517
+ detail: `${parts.join("; ")}. ${
1518
+ parts.length > 1
1519
+ ? "Both may be intentional."
1520
+ : "This may be intentional."
1521
+ }`,
1522
+ });
1523
+ }
1524
+
1525
+ return signals.sort(
1526
+ (left, right) =>
1527
+ SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity],
1528
+ );
1529
+ }