@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.
- package/package.json +1 -1
- package/src/App.tsx +42 -7
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2884 -2075
- package/src/api/diagnose.ts +5 -0
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +51 -10
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/utils/shell-safe.test.ts +55 -0
- 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
|
+
}
|