@skyhook-io/radar-app 1.9.1 → 1.9.3
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 +2308 -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 +1853 -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,2308 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { MemoryRouter } from "react-router-dom";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import type {
|
|
6
|
+
CapacityActivityEpisode,
|
|
7
|
+
CapacityActivityResponse,
|
|
8
|
+
CapacityAutoscalerChildObservation,
|
|
9
|
+
CapacityBoundedResultMeta,
|
|
10
|
+
CapacityDemandGroup,
|
|
11
|
+
CapacityDemandResponse,
|
|
12
|
+
CapacityDemandSummary,
|
|
13
|
+
CapacityGroupSummary,
|
|
14
|
+
CapacityManagerSummary,
|
|
15
|
+
CapacityMemberListResponse,
|
|
16
|
+
CapacityOverviewResponse,
|
|
17
|
+
CapacityPoolDetailResponse,
|
|
18
|
+
CapacityPoolListResponse,
|
|
19
|
+
CapacityPoolMember,
|
|
20
|
+
CapacityPoolObservation,
|
|
21
|
+
CapacityQuantityObservation,
|
|
22
|
+
CapacityResponseMeta,
|
|
23
|
+
CapacitySourceCoverage,
|
|
24
|
+
} from "@skyhook-io/k8s-ui";
|
|
25
|
+
import { CapacityView } from "./CapacityView";
|
|
26
|
+
import { updateDemandSearchParam } from "./CapacityDemand";
|
|
27
|
+
import { integrationBlock } from "./shared";
|
|
28
|
+
import { ApiError } from "../../api/client";
|
|
29
|
+
|
|
30
|
+
vi.mock("../../context/ConnectionContext", () => ({
|
|
31
|
+
useConnection: () => ({ connection: { state: "connected" } }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const generatedAt = "2026-07-13T08:00:00Z";
|
|
35
|
+
|
|
36
|
+
function sourceCoverage(
|
|
37
|
+
status: CapacitySourceCoverage["status"] = "available",
|
|
38
|
+
reason?: string,
|
|
39
|
+
scope: CapacitySourceCoverage["scope"] = "cluster",
|
|
40
|
+
): CapacitySourceCoverage {
|
|
41
|
+
return {
|
|
42
|
+
status,
|
|
43
|
+
reason,
|
|
44
|
+
scope,
|
|
45
|
+
observedAt: generatedAt,
|
|
46
|
+
impactFields: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const meta: CapacityResponseMeta = {
|
|
51
|
+
schemaVersion: "v1alpha1",
|
|
52
|
+
generatedAt,
|
|
53
|
+
clusterContext: { contextName: "radar-test-eks", clusterName: "radar-test" },
|
|
54
|
+
provider: {
|
|
55
|
+
type: "karpenter",
|
|
56
|
+
controllerMode: "self_managed",
|
|
57
|
+
apiVersionsByKind: { NodePool: ["v1"], NodeClaim: ["v1"] },
|
|
58
|
+
nodeClassKinds: [
|
|
59
|
+
{
|
|
60
|
+
group: "karpenter.k8s.aws",
|
|
61
|
+
kind: "EC2NodeClass",
|
|
62
|
+
resource: "ec2nodeclasses",
|
|
63
|
+
versions: ["v1"],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
features: { staticCapacity: true, metrics: true },
|
|
67
|
+
},
|
|
68
|
+
coverage: {
|
|
69
|
+
nodePools: sourceCoverage(),
|
|
70
|
+
nodeClaims: sourceCoverage(),
|
|
71
|
+
nodeClasses: sourceCoverage(),
|
|
72
|
+
nodes: sourceCoverage(),
|
|
73
|
+
pods: sourceCoverage(),
|
|
74
|
+
workloads: sourceCoverage(),
|
|
75
|
+
nodeMetrics: sourceCoverage(),
|
|
76
|
+
karpenterObjectEvents: sourceCoverage(),
|
|
77
|
+
timeline: sourceCoverage(),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function quantity(
|
|
82
|
+
resources: Record<string, string>,
|
|
83
|
+
certainty: CapacityQuantityObservation["certainty"] = "exact",
|
|
84
|
+
): CapacityQuantityObservation {
|
|
85
|
+
return {
|
|
86
|
+
resources,
|
|
87
|
+
certainty,
|
|
88
|
+
sources: ["karpenter.status"],
|
|
89
|
+
asOf: generatedAt,
|
|
90
|
+
granularity: "aggregate",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function boundedMeta(count: number): CapacityBoundedResultMeta {
|
|
95
|
+
return { total: count, returned: count, truncated: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const poolSummary: CapacityOverviewResponse["pools"][number] = {
|
|
99
|
+
resource: {
|
|
100
|
+
ref: { group: "karpenter.sh", kind: "NodePool", name: "default" },
|
|
101
|
+
apiVersion: "karpenter.sh/v1",
|
|
102
|
+
},
|
|
103
|
+
mode: "dynamic",
|
|
104
|
+
ready: true,
|
|
105
|
+
nodeClass: {
|
|
106
|
+
group: "karpenter.k8s.aws",
|
|
107
|
+
kind: "EC2NodeClass",
|
|
108
|
+
name: "general",
|
|
109
|
+
},
|
|
110
|
+
ledger: {
|
|
111
|
+
configuredLimit: quantity({ cpu: "20", memory: "80Gi" }),
|
|
112
|
+
provisioned: quantity({ cpu: "10", memory: "40Gi" }),
|
|
113
|
+
scheduledRequests: quantity({ cpu: "6500m", memory: "28Gi" }),
|
|
114
|
+
actualUsage: {
|
|
115
|
+
quantity: quantity({ cpu: "4", memory: "16Gi" }),
|
|
116
|
+
coveredNodes: 4,
|
|
117
|
+
totalNodes: 4,
|
|
118
|
+
coveredAllocatable: quantity({ cpu: "10", memory: "40Gi" }),
|
|
119
|
+
utilization: [
|
|
120
|
+
{ resource: "cpu", usage: "4", allocatable: "10", percent: 40 },
|
|
121
|
+
{ resource: "memory", usage: "16Gi", allocatable: "40Gi", percent: 40 },
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
limitPressure: [
|
|
125
|
+
{
|
|
126
|
+
resource: "cpu",
|
|
127
|
+
provisioned: "10",
|
|
128
|
+
limit: "20",
|
|
129
|
+
percent: 50,
|
|
130
|
+
overLimit: false,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
claims: {
|
|
135
|
+
total: 4,
|
|
136
|
+
pending: 0,
|
|
137
|
+
launched: 4,
|
|
138
|
+
registered: 4,
|
|
139
|
+
initialized: 4,
|
|
140
|
+
ready: 4,
|
|
141
|
+
failed: 0,
|
|
142
|
+
terminating: 0,
|
|
143
|
+
},
|
|
144
|
+
nodes: { total: 4, ready: 4, notReady: 0, cordoned: 0, terminating: 0 },
|
|
145
|
+
issueCount: 1,
|
|
146
|
+
factCount: 2,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function overview(
|
|
150
|
+
overrides: Partial<CapacityOverviewResponse> = {},
|
|
151
|
+
): CapacityOverviewResponse {
|
|
152
|
+
return {
|
|
153
|
+
...meta,
|
|
154
|
+
state: "available",
|
|
155
|
+
summary: {
|
|
156
|
+
actions: [
|
|
157
|
+
{
|
|
158
|
+
code: "configured_limit_pressure",
|
|
159
|
+
count: 1,
|
|
160
|
+
highestSeverity: "warning",
|
|
161
|
+
pools: [poolSummary.resource],
|
|
162
|
+
truncated: false,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
aggregateDemand: quantity({ cpu: "6", memory: "24Gi" }),
|
|
166
|
+
scheduling: {
|
|
167
|
+
scheduledRequests: quantity({
|
|
168
|
+
cpu: "21900m",
|
|
169
|
+
memory: "79Gi",
|
|
170
|
+
pods: "27",
|
|
171
|
+
}),
|
|
172
|
+
allocatable: quantity({ cpu: "31200m", memory: "102Gi", pods: "220" }),
|
|
173
|
+
inFlightCapacity: quantity({ cpu: "4" }),
|
|
174
|
+
},
|
|
175
|
+
claimStages: {
|
|
176
|
+
total: 4,
|
|
177
|
+
pending: 0,
|
|
178
|
+
launched: 0,
|
|
179
|
+
registered: 0,
|
|
180
|
+
initialized: 0,
|
|
181
|
+
ready: 3,
|
|
182
|
+
failed: 1,
|
|
183
|
+
terminating: 0,
|
|
184
|
+
},
|
|
185
|
+
poolCount: 1,
|
|
186
|
+
claimCount: 4,
|
|
187
|
+
nodeCount: 4,
|
|
188
|
+
pendingPodCount: 2,
|
|
189
|
+
managers: [],
|
|
190
|
+
},
|
|
191
|
+
pools: [poolSummary],
|
|
192
|
+
poolsTruncated: false,
|
|
193
|
+
groups: [],
|
|
194
|
+
orphanAutoscalerGroups: [],
|
|
195
|
+
orphanAutoscalerGroupsMeta: boundedMeta(0),
|
|
196
|
+
...overrides,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Comprehensive-capacity fixtures: multi-manager groups, autoscaler children,
|
|
202
|
+
// orphan (scale-to-zero) groups, managers rollup, cluster scheduling.
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
const managers: CapacityManagerSummary[] = [
|
|
206
|
+
{ manager: "karpenter", groupCount: 1, status: "healthy" },
|
|
207
|
+
{
|
|
208
|
+
manager: "gke_autoscaler",
|
|
209
|
+
groupCount: 1,
|
|
210
|
+
status: "degraded",
|
|
211
|
+
detail: "1 group backing off on a stockout",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const gkeChild: CapacityAutoscalerChildObservation = {
|
|
216
|
+
id: "gke-mig-us-central1-a",
|
|
217
|
+
name: "https://www.googleapis.com/.../gke-mig-us-central1-a",
|
|
218
|
+
minSize: 1,
|
|
219
|
+
maxSize: 10,
|
|
220
|
+
target: 3,
|
|
221
|
+
health: "healthy",
|
|
222
|
+
readyNodes: 3,
|
|
223
|
+
totalNodes: 3,
|
|
224
|
+
asOf: generatedAt,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const gkeChildBackoff: CapacityAutoscalerChildObservation = {
|
|
228
|
+
id: "gke-mig-us-central1-b",
|
|
229
|
+
name: "https://www.googleapis.com/.../gke-mig-us-central1-b",
|
|
230
|
+
minSize: 0,
|
|
231
|
+
maxSize: 5,
|
|
232
|
+
target: 2,
|
|
233
|
+
health: "degraded",
|
|
234
|
+
readyNodes: 1,
|
|
235
|
+
totalNodes: 2,
|
|
236
|
+
backoff: {
|
|
237
|
+
errorClass: "OutOfResource",
|
|
238
|
+
errorCode: "stockout",
|
|
239
|
+
errorMessage: "Instances unavailable in zone us-central1-b",
|
|
240
|
+
},
|
|
241
|
+
asOf: generatedAt,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const karpenterGroup: CapacityGroupSummary = {
|
|
245
|
+
id: "grp-karpenter-default",
|
|
246
|
+
platform: "karpenter",
|
|
247
|
+
// Matches poolSummary.resource.ref.name so the karpenter row can join pools.
|
|
248
|
+
name: "default",
|
|
249
|
+
manager: "karpenter",
|
|
250
|
+
managerValidated: true,
|
|
251
|
+
nodeCount: 4,
|
|
252
|
+
readyNodeCount: 4,
|
|
253
|
+
allocatable: quantity({ cpu: "10", memory: "40Gi" }),
|
|
254
|
+
scheduledRequests: quantity({ cpu: "6500m", memory: "28Gi" }),
|
|
255
|
+
scaling: [{ code: "dynamic", summary: "provisions on demand" }],
|
|
256
|
+
children: [],
|
|
257
|
+
childrenMeta: boundedMeta(0),
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const gkeGroup: CapacityGroupSummary = {
|
|
261
|
+
id: "grp-gke-default",
|
|
262
|
+
platform: "gke",
|
|
263
|
+
name: "gke-default-pool",
|
|
264
|
+
manager: "gke_autoscaler",
|
|
265
|
+
managerValidated: true,
|
|
266
|
+
nodeCount: 5,
|
|
267
|
+
readyNodeCount: 4,
|
|
268
|
+
allocatable: quantity({ cpu: "20", memory: "80Gi" }),
|
|
269
|
+
scheduledRequests: quantity({ cpu: "12", memory: "40Gi" }),
|
|
270
|
+
scaling: [
|
|
271
|
+
{ code: "min_max", summary: "1–10 nodes across 2 zones" },
|
|
272
|
+
{ code: "surge", summary: "surge upgrade" },
|
|
273
|
+
],
|
|
274
|
+
children: [gkeChild, gkeChildBackoff],
|
|
275
|
+
childrenMeta: { total: 3, returned: 2, truncated: true },
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// A group whose allocatable vector carries the "unlabeled soup" the cell must
|
|
279
|
+
// tame: cpu + memory lead, a GPU is promoted, pods + ephemeral-storage collapse.
|
|
280
|
+
const richGroup: CapacityGroupSummary = {
|
|
281
|
+
id: "grp-rich",
|
|
282
|
+
platform: "karpenter",
|
|
283
|
+
name: "rich-pool",
|
|
284
|
+
manager: "karpenter",
|
|
285
|
+
managerValidated: true,
|
|
286
|
+
nodeCount: 3,
|
|
287
|
+
readyNodeCount: 3,
|
|
288
|
+
allocatable: quantity({
|
|
289
|
+
cpu: "16",
|
|
290
|
+
memory: "64Gi",
|
|
291
|
+
pods: "990",
|
|
292
|
+
"ephemeral-storage": "200Gi",
|
|
293
|
+
"nvidia.com/gpu": "8",
|
|
294
|
+
}),
|
|
295
|
+
scheduledRequests: quantity({ cpu: "8", memory: "32Gi" }),
|
|
296
|
+
scaling: [{ code: "dynamic", summary: "provisions on demand" }],
|
|
297
|
+
children: [],
|
|
298
|
+
childrenMeta: boundedMeta(0),
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const orphanScaleToZero: CapacityAutoscalerChildObservation = {
|
|
302
|
+
id: "gke-scale-to-zero",
|
|
303
|
+
name: "https://www.googleapis.com/.../gke-scale-to-zero",
|
|
304
|
+
minSize: 0,
|
|
305
|
+
maxSize: 8,
|
|
306
|
+
// A real published zero — scale-to-zero, not an absent value.
|
|
307
|
+
target: 0,
|
|
308
|
+
health: "healthy",
|
|
309
|
+
// asOf deliberately omitted → "not probed".
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
function comprehensiveOverview(): CapacityOverviewResponse {
|
|
313
|
+
const base = overview();
|
|
314
|
+
return {
|
|
315
|
+
...base,
|
|
316
|
+
summary: {
|
|
317
|
+
...base.summary,
|
|
318
|
+
managers,
|
|
319
|
+
clusterScheduling: {
|
|
320
|
+
scheduledRequests: quantity({ cpu: "30", memory: "120Gi" }),
|
|
321
|
+
allocatable: quantity({ cpu: "60", memory: "240Gi" }),
|
|
322
|
+
},
|
|
323
|
+
unattributedNodeCount: 2,
|
|
324
|
+
},
|
|
325
|
+
groups: [karpenterGroup, gkeGroup],
|
|
326
|
+
orphanAutoscalerGroups: [orphanScaleToZero],
|
|
327
|
+
orphanAutoscalerGroupsMeta: boundedMeta(1),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const poolDetail: CapacityPoolObservation = {
|
|
332
|
+
resource: poolSummary.resource,
|
|
333
|
+
generation: 3,
|
|
334
|
+
observedGeneration: 3,
|
|
335
|
+
createdAt: generatedAt,
|
|
336
|
+
updatedAt: generatedAt,
|
|
337
|
+
mode: "dynamic",
|
|
338
|
+
ready: true,
|
|
339
|
+
conditions: [{ type: "Ready", status: "True" }],
|
|
340
|
+
nodeClass: {
|
|
341
|
+
reference: {
|
|
342
|
+
group: "karpenter.k8s.aws",
|
|
343
|
+
kind: "EC2NodeClass",
|
|
344
|
+
name: "general",
|
|
345
|
+
},
|
|
346
|
+
ready: true,
|
|
347
|
+
conditions: [{ type: "Ready", status: "True" }],
|
|
348
|
+
},
|
|
349
|
+
configuration: {
|
|
350
|
+
weight: 10,
|
|
351
|
+
labels: { team: "platform" },
|
|
352
|
+
requirements: [
|
|
353
|
+
{
|
|
354
|
+
key: "karpenter.sh/capacity-type",
|
|
355
|
+
operator: "In",
|
|
356
|
+
values: ["spot", "on-demand"],
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
taints: [],
|
|
360
|
+
startupTaints: [],
|
|
361
|
+
expireAfter: "720h",
|
|
362
|
+
},
|
|
363
|
+
ledger: poolSummary.ledger,
|
|
364
|
+
disruption: {
|
|
365
|
+
consolidationPolicy: "WhenEmptyOrUnderutilized",
|
|
366
|
+
consolidateAfter: "30s",
|
|
367
|
+
budgets: [],
|
|
368
|
+
},
|
|
369
|
+
claims: poolSummary.claims,
|
|
370
|
+
nodes: poolSummary.nodes,
|
|
371
|
+
issues: [],
|
|
372
|
+
facts: [],
|
|
373
|
+
coverage: {
|
|
374
|
+
...meta.coverage,
|
|
375
|
+
nodes: sourceCoverage("denied", "Node inventory hidden by permissions"),
|
|
376
|
+
pods: sourceCoverage("denied", "Pod inventory hidden by permissions"),
|
|
377
|
+
workloads: sourceCoverage(
|
|
378
|
+
"denied",
|
|
379
|
+
"Workload attribution hidden by permissions",
|
|
380
|
+
),
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function poolDetailResponse(
|
|
385
|
+
pool: CapacityPoolObservation | undefined,
|
|
386
|
+
): CapacityPoolDetailResponse {
|
|
387
|
+
return { ...meta, state: "available", pool };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const cleanPoolDetail: CapacityPoolObservation = {
|
|
391
|
+
...poolDetail,
|
|
392
|
+
workloads: {
|
|
393
|
+
scheduledPodCount: 12,
|
|
394
|
+
workloadCount: 3,
|
|
395
|
+
topScheduled: [
|
|
396
|
+
{
|
|
397
|
+
owner: { kind: "Deployment", namespace: "payments", name: "checkout" },
|
|
398
|
+
podCount: 6,
|
|
399
|
+
requests: quantity({ cpu: "6", memory: "24Gi" }),
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
topScheduledMeta: boundedMeta(1),
|
|
403
|
+
pendingEligibleGroupCount: 0,
|
|
404
|
+
},
|
|
405
|
+
coverage: meta.coverage,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const nodeMember: CapacityPoolMember = {
|
|
409
|
+
type: "node",
|
|
410
|
+
resource: { ref: { kind: "Node", name: "ip-10-0-1-1" } },
|
|
411
|
+
node: {
|
|
412
|
+
ready: true,
|
|
413
|
+
cordoned: false,
|
|
414
|
+
conditions: [],
|
|
415
|
+
instanceType: "m6i.large",
|
|
416
|
+
capacityType: "on-demand",
|
|
417
|
+
zone: "us-east-1a",
|
|
418
|
+
architecture: "amd64",
|
|
419
|
+
allocatable: quantity({ cpu: "2", memory: "8Gi" }),
|
|
420
|
+
scheduledRequests: quantity({ cpu: "1", memory: "4Gi" }),
|
|
421
|
+
podCount: 7,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const claimMember: CapacityPoolMember = {
|
|
426
|
+
type: "claim",
|
|
427
|
+
resource: { ref: { kind: "NodeClaim", name: "default-xyz9k" } },
|
|
428
|
+
claim: {
|
|
429
|
+
stage: "ready",
|
|
430
|
+
conditions: [{ type: "Ready", status: "True" }],
|
|
431
|
+
nodeName: "ip-10-0-1-1",
|
|
432
|
+
capacity: quantity({ cpu: "2", memory: "8Gi" }),
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
function memberResponse(
|
|
437
|
+
type: CapacityMemberListResponse["type"],
|
|
438
|
+
items: CapacityPoolMember[],
|
|
439
|
+
coverage: CapacityMemberListResponse["coverage"] = meta.coverage,
|
|
440
|
+
): CapacityMemberListResponse {
|
|
441
|
+
return {
|
|
442
|
+
...meta,
|
|
443
|
+
coverage,
|
|
444
|
+
state: "available",
|
|
445
|
+
pool: poolSummary.resource,
|
|
446
|
+
type,
|
|
447
|
+
items,
|
|
448
|
+
page: { hasMore: false },
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const demandGroup: CapacityDemandGroup = {
|
|
453
|
+
id: "grp-1",
|
|
454
|
+
fingerprint: "sched-fp:abc123",
|
|
455
|
+
owner: { kind: "Deployment", namespace: "payments", name: "checkout" },
|
|
456
|
+
pods: [{ ref: { kind: "Pod", namespace: "payments", name: "checkout-abc" } }],
|
|
457
|
+
podsMeta: boundedMeta(1),
|
|
458
|
+
namespace: "payments",
|
|
459
|
+
firstSeen: generatedAt,
|
|
460
|
+
lastSeen: generatedAt,
|
|
461
|
+
podCount: 3,
|
|
462
|
+
perPodRequests: quantity({ cpu: "2", memory: "4Gi" }),
|
|
463
|
+
aggregateRequests: quantity({ cpu: "6", memory: "12Gi" }),
|
|
464
|
+
schedulingSignature: {
|
|
465
|
+
fingerprint: "sched-fp:abc123",
|
|
466
|
+
constraints: [
|
|
467
|
+
{
|
|
468
|
+
predicate: "nodeSelector",
|
|
469
|
+
key: "kubernetes.io/arch",
|
|
470
|
+
operator: "In",
|
|
471
|
+
values: ["amd64"],
|
|
472
|
+
sourcePath: "spec.nodeSelector",
|
|
473
|
+
},
|
|
474
|
+
],
|
|
475
|
+
constraintsMeta: boundedMeta(1),
|
|
476
|
+
tolerations: [],
|
|
477
|
+
tolerationsMeta: boundedMeta(0),
|
|
478
|
+
},
|
|
479
|
+
state: "awaiting_capacity",
|
|
480
|
+
schedulerReasons: [
|
|
481
|
+
{
|
|
482
|
+
code: "Unschedulable",
|
|
483
|
+
source: "scheduler",
|
|
484
|
+
message: "0/4 nodes are available",
|
|
485
|
+
count: 3,
|
|
486
|
+
firstSeen: generatedAt,
|
|
487
|
+
lastSeen: generatedAt,
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
schedulerReasonsMeta: boundedMeta(1),
|
|
491
|
+
poolEvaluations: [
|
|
492
|
+
{
|
|
493
|
+
pool: { kind: "NodePool", name: "default" },
|
|
494
|
+
result: "declared_compatible",
|
|
495
|
+
evidence: [
|
|
496
|
+
{
|
|
497
|
+
predicate: "requirements",
|
|
498
|
+
sourcePath: "spec.requirements",
|
|
499
|
+
observedValues: ["amd64"],
|
|
500
|
+
expectedValues: ["amd64"],
|
|
501
|
+
confidence: "high",
|
|
502
|
+
explanation: "architecture requirement matches",
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
evidenceMeta: boundedMeta(1),
|
|
506
|
+
unknownPredicates: [],
|
|
507
|
+
unknownPredicatesMeta: boundedMeta(0),
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
poolEvaluationsMeta: boundedMeta(1),
|
|
511
|
+
poolEvaluationCounts: { declaredCompatible: 1, incompatible: 0, unknown: 0 },
|
|
512
|
+
issues: [],
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Rollup deliberately differs from the single-group page (which has 3 pods in
|
|
516
|
+
// 1 group) so tests prove counts come from the server summary, not the page.
|
|
517
|
+
const demandSummary: CapacityDemandSummary = {
|
|
518
|
+
total: { podCount: 27, groupCount: 9 },
|
|
519
|
+
byState: {
|
|
520
|
+
waiting_for_scheduler: { podCount: 3, groupCount: 1 },
|
|
521
|
+
held: { podCount: 2, groupCount: 2 },
|
|
522
|
+
awaiting_capacity: { podCount: 12, groupCount: 3 },
|
|
523
|
+
blocked: { podCount: 10, groupCount: 3 },
|
|
524
|
+
unknown: { podCount: 0, groupCount: 0 },
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
function demandResponse(
|
|
529
|
+
overrides: Partial<CapacityDemandResponse> = {},
|
|
530
|
+
): CapacityDemandResponse {
|
|
531
|
+
return {
|
|
532
|
+
...meta,
|
|
533
|
+
state: "available",
|
|
534
|
+
summary: demandSummary,
|
|
535
|
+
items: [demandGroup],
|
|
536
|
+
page: { hasMore: false },
|
|
537
|
+
...overrides,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function poolListResponse(
|
|
542
|
+
names: string[],
|
|
543
|
+
page: CapacityPoolListResponse["page"] = { hasMore: false },
|
|
544
|
+
): CapacityPoolListResponse {
|
|
545
|
+
return {
|
|
546
|
+
...meta,
|
|
547
|
+
state: "available",
|
|
548
|
+
items: names.map((name) => ({
|
|
549
|
+
...poolSummary,
|
|
550
|
+
resource: {
|
|
551
|
+
...poolSummary.resource,
|
|
552
|
+
ref: { ...poolSummary.resource.ref, name },
|
|
553
|
+
},
|
|
554
|
+
})),
|
|
555
|
+
page,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const activityEpisode: CapacityActivityEpisode = {
|
|
560
|
+
id: "ep-1",
|
|
561
|
+
type: "provision",
|
|
562
|
+
state: "completed",
|
|
563
|
+
summary: "Provisioned a node for pending pods",
|
|
564
|
+
primaryReasonCode: "Launched",
|
|
565
|
+
startedAt: generatedAt,
|
|
566
|
+
endedAt: generatedAt,
|
|
567
|
+
durationSeconds: 120,
|
|
568
|
+
pool: { ref: { kind: "NodePool", name: "default" } },
|
|
569
|
+
evidence: [
|
|
570
|
+
{
|
|
571
|
+
at: generatedAt,
|
|
572
|
+
source: "k8s_event",
|
|
573
|
+
reasonCode: "Launched",
|
|
574
|
+
rawReason: "Launched",
|
|
575
|
+
rawMessage: "created a NodeClaim",
|
|
576
|
+
relationship: "direct",
|
|
577
|
+
confidence: "high",
|
|
578
|
+
refs: [],
|
|
579
|
+
},
|
|
580
|
+
],
|
|
581
|
+
evidenceMeta: boundedMeta(1),
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
function activityResponse(): CapacityActivityResponse {
|
|
585
|
+
return {
|
|
586
|
+
...meta,
|
|
587
|
+
state: "available",
|
|
588
|
+
items: [activityEpisode],
|
|
589
|
+
page: { hasMore: false },
|
|
590
|
+
cursorStatus: "valid",
|
|
591
|
+
observation: {
|
|
592
|
+
startedAt: generatedAt,
|
|
593
|
+
endedAt: generatedAt,
|
|
594
|
+
sources: ["k8s_event"],
|
|
595
|
+
retention: {
|
|
596
|
+
mode: "bounded_local_timeline",
|
|
597
|
+
maxEvents: 2000,
|
|
598
|
+
maxAgeSeconds: 86400,
|
|
599
|
+
},
|
|
600
|
+
gaps: [],
|
|
601
|
+
},
|
|
602
|
+
aggregate: {
|
|
603
|
+
total: 3,
|
|
604
|
+
byType: {
|
|
605
|
+
provision: { total: 2, byState: { completed: 1, failed: 1 } },
|
|
606
|
+
disruption: { total: 1, byState: { blocked: 1 } },
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function renderCapacity(
|
|
613
|
+
path: string,
|
|
614
|
+
seed: (client: QueryClient) => void,
|
|
615
|
+
): string {
|
|
616
|
+
const client = new QueryClient({
|
|
617
|
+
defaultOptions: { queries: { retry: false, retryOnMount: false } },
|
|
618
|
+
});
|
|
619
|
+
seed(client);
|
|
620
|
+
return renderToString(
|
|
621
|
+
<MemoryRouter initialEntries={[path]}>
|
|
622
|
+
<QueryClientProvider client={client}>
|
|
623
|
+
<CapacityView onOpenResource={() => {}} />
|
|
624
|
+
</QueryClientProvider>
|
|
625
|
+
</MemoryRouter>,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
describe("CapacityView overview", () => {
|
|
630
|
+
it("renders KPI tiles, the node-groups inventory, and the Karpenter chip", () => {
|
|
631
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
632
|
+
client.setQueryData(["capacity", "overview"], overview()),
|
|
633
|
+
);
|
|
634
|
+
expect(html).toContain("Capacity overview");
|
|
635
|
+
expect(html).toContain("Karpenter");
|
|
636
|
+
expect(html).toContain("NodeClaims");
|
|
637
|
+
expect(html).toContain("Pending pods");
|
|
638
|
+
// The former "NodePools" tile is now the unified "Node groups" tile.
|
|
639
|
+
expect(html).toContain("Node groups");
|
|
640
|
+
expect(html).toContain("Operational signals");
|
|
641
|
+
expect(html).toContain("default");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("draws cluster scheduling capacity with honest scale semantics", () => {
|
|
645
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
646
|
+
client.setQueryData(["capacity", "overview"], overview()),
|
|
647
|
+
);
|
|
648
|
+
expect(html).toContain("Karpenter scheduling capacity");
|
|
649
|
+
expect(html).toContain("not usage, not a health score");
|
|
650
|
+
expect(html).toContain("in flight (beyond the edge)");
|
|
651
|
+
expect(html).toContain("pending demand — count, not to scale");
|
|
652
|
+
expect(html).toContain("3 ready · 1 failed");
|
|
653
|
+
expect(html).toContain("View inventory ↓");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("frames the aggregate demand as scheduling demand, not usage", () => {
|
|
657
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
658
|
+
client.setQueryData(["capacity", "overview"], overview()),
|
|
659
|
+
);
|
|
660
|
+
expect(html).toContain(
|
|
661
|
+
"Active pending requests (scheduling demand, not usage):",
|
|
662
|
+
);
|
|
663
|
+
expect(html).toContain("Pod slots 2");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("draws the negative-priority sub-band and legend under exact certainty", () => {
|
|
667
|
+
const base = overview();
|
|
668
|
+
const data: CapacityOverviewResponse = {
|
|
669
|
+
...base,
|
|
670
|
+
summary: {
|
|
671
|
+
...base.summary,
|
|
672
|
+
scheduling: {
|
|
673
|
+
scheduledRequests: quantity({ cpu: "6", memory: "24Gi", pods: "12" }),
|
|
674
|
+
allocatable: quantity({ cpu: "12", memory: "48Gi", pods: "110" }),
|
|
675
|
+
negativePriorityRequests: quantity({ cpu: "3" }),
|
|
676
|
+
},
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
680
|
+
client.setQueryData(["capacity", "overview"], data),
|
|
681
|
+
);
|
|
682
|
+
// The legend entry only appears when a proportional band is actually drawn.
|
|
683
|
+
expect(html).toContain("negative-priority (potential preemption victims)");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("annotates negative-priority as a lower bound instead of a band under non-exact certainty", () => {
|
|
687
|
+
const base = overview();
|
|
688
|
+
const data: CapacityOverviewResponse = {
|
|
689
|
+
...base,
|
|
690
|
+
summary: {
|
|
691
|
+
...base.summary,
|
|
692
|
+
scheduling: {
|
|
693
|
+
scheduledRequests: quantity(
|
|
694
|
+
{ cpu: "6", memory: "24Gi", pods: "12" },
|
|
695
|
+
"lower_bound",
|
|
696
|
+
),
|
|
697
|
+
allocatable: quantity(
|
|
698
|
+
{ cpu: "12", memory: "48Gi", pods: "110" },
|
|
699
|
+
"lower_bound",
|
|
700
|
+
),
|
|
701
|
+
negativePriorityRequests: quantity({ cpu: "3" }, "lower_bound"),
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
706
|
+
client.setQueryData(["capacity", "overview"], data),
|
|
707
|
+
);
|
|
708
|
+
// No proportional split under a lower bound — a ≥ annotation instead, and
|
|
709
|
+
// no band means no legend swatch describing one.
|
|
710
|
+
expect(html).toContain("≥3 cores");
|
|
711
|
+
expect(html).toContain("negative-priority");
|
|
712
|
+
expect(html).not.toContain("(potential preemption victims)");
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("derives operational signals from server actions", () => {
|
|
716
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
717
|
+
client.setQueryData(["capacity", "overview"], overview()),
|
|
718
|
+
);
|
|
719
|
+
expect(html).toContain("Configured limit pressure");
|
|
720
|
+
expect(html).toContain("Warn");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("makes the outside-pools note agree with how many facts it lists", () => {
|
|
724
|
+
const base = overview();
|
|
725
|
+
const single = renderCapacity("/capacity", (client) =>
|
|
726
|
+
client.setQueryData(["capacity", "overview"], {
|
|
727
|
+
...base,
|
|
728
|
+
summary: {
|
|
729
|
+
...base.summary,
|
|
730
|
+
actions: [],
|
|
731
|
+
orphanedClaimCount: 0,
|
|
732
|
+
unpooledNodeCount: 9,
|
|
733
|
+
},
|
|
734
|
+
}),
|
|
735
|
+
);
|
|
736
|
+
expect(single).toContain("9 nodes are outside Karpenter pools");
|
|
737
|
+
expect(single).toContain("This may be intentional.");
|
|
738
|
+
expect(single).not.toContain("Both may be intentional.");
|
|
739
|
+
|
|
740
|
+
const both = renderCapacity("/capacity", (client) =>
|
|
741
|
+
client.setQueryData(["capacity", "overview"], {
|
|
742
|
+
...base,
|
|
743
|
+
summary: {
|
|
744
|
+
...base.summary,
|
|
745
|
+
actions: [],
|
|
746
|
+
orphanedClaimCount: 2,
|
|
747
|
+
unpooledNodeCount: 9,
|
|
748
|
+
},
|
|
749
|
+
}),
|
|
750
|
+
);
|
|
751
|
+
expect(both).toContain("Both may be intentional.");
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it("marks pending pods unavailable — never zero — when pod access is denied", () => {
|
|
755
|
+
const denied = overview({
|
|
756
|
+
summary: { ...overview().summary, pendingPodCount: undefined },
|
|
757
|
+
coverage: {
|
|
758
|
+
...meta.coverage,
|
|
759
|
+
pods: sourceCoverage("denied", "Pod inventory hidden by permissions"),
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
763
|
+
client.setQueryData(["capacity", "overview"], denied),
|
|
764
|
+
);
|
|
765
|
+
expect(html).toContain("Pod access denied — not zero");
|
|
766
|
+
expect(html).toContain("Unavailable — Pod access denied");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("labels namespace-scoped data as a lower bound", () => {
|
|
770
|
+
const scoped = overview({
|
|
771
|
+
coverage: {
|
|
772
|
+
...meta.coverage,
|
|
773
|
+
pods: {
|
|
774
|
+
...sourceCoverage("available", undefined, "explicit_namespaces"),
|
|
775
|
+
namespaces: ["payments", "media"],
|
|
776
|
+
},
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
780
|
+
client.setQueryData(["capacity", "overview"], scoped),
|
|
781
|
+
);
|
|
782
|
+
expect(html).toContain("Explicit namespaces (2)");
|
|
783
|
+
expect(html).toContain("Namespaced data is a lower bound");
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("shows the integration state returned by the server", () => {
|
|
787
|
+
// not_detected only shows the block when there is no observable surface —
|
|
788
|
+
// strip node coverage and managers so nothing is left to render.
|
|
789
|
+
const notDetected = renderCapacity("/capacity", (client) =>
|
|
790
|
+
client.setQueryData(
|
|
791
|
+
["capacity", "overview"],
|
|
792
|
+
overview({
|
|
793
|
+
state: "not_detected",
|
|
794
|
+
summary: { ...overview().summary, managers: [] },
|
|
795
|
+
coverage: {
|
|
796
|
+
...meta.coverage,
|
|
797
|
+
nodes: sourceCoverage("unavailable"),
|
|
798
|
+
nodePools: sourceCoverage("unavailable"),
|
|
799
|
+
},
|
|
800
|
+
}),
|
|
801
|
+
),
|
|
802
|
+
);
|
|
803
|
+
expect(notDetected).toContain("Karpenter not detected");
|
|
804
|
+
|
|
805
|
+
// denied likewise only blocks when the node fleet is invisible too — with
|
|
806
|
+
// no observable surface, the access-denied block stands.
|
|
807
|
+
const deniedState = renderCapacity("/capacity", (client) =>
|
|
808
|
+
client.setQueryData(
|
|
809
|
+
["capacity", "overview"],
|
|
810
|
+
overview({
|
|
811
|
+
state: "denied",
|
|
812
|
+
summary: { ...overview().summary, managers: [] },
|
|
813
|
+
groups: [],
|
|
814
|
+
coverage: {
|
|
815
|
+
...meta.coverage,
|
|
816
|
+
nodes: sourceCoverage("denied"),
|
|
817
|
+
nodePools: sourceCoverage("denied"),
|
|
818
|
+
},
|
|
819
|
+
}),
|
|
820
|
+
),
|
|
821
|
+
);
|
|
822
|
+
expect(deniedState).toContain("Capacity access denied");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("renders the cluster-only overview when NodePools are denied but nodes are visible", () => {
|
|
826
|
+
// Karpenter denied, node fleet visible: the Overview softens the denial into
|
|
827
|
+
// the cluster-only shape (inventory + managers from nodes/labels) with an
|
|
828
|
+
// honest notice, never the access-denied block and never Karpenter sections.
|
|
829
|
+
const denied = overview({
|
|
830
|
+
state: "denied",
|
|
831
|
+
summary: {
|
|
832
|
+
...overview().summary,
|
|
833
|
+
managers: [
|
|
834
|
+
{ manager: "gke_autoscaler", groupCount: 1, status: "healthy" },
|
|
835
|
+
],
|
|
836
|
+
poolCount: 0,
|
|
837
|
+
},
|
|
838
|
+
pools: [],
|
|
839
|
+
groups: [gkeGroup],
|
|
840
|
+
coverage: {
|
|
841
|
+
...meta.coverage,
|
|
842
|
+
nodePools: sourceCoverage("denied", "NodePools hidden by permissions"),
|
|
843
|
+
},
|
|
844
|
+
});
|
|
845
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
846
|
+
client.setQueryData(["capacity", "overview"], denied),
|
|
847
|
+
);
|
|
848
|
+
expect(html).not.toContain("Capacity access denied");
|
|
849
|
+
expect(html).toContain("Karpenter view unavailable");
|
|
850
|
+
expect(html).toContain("Node groups");
|
|
851
|
+
expect(html).toContain("gke-default-pool");
|
|
852
|
+
expect(html).toContain("GKE autoscaler");
|
|
853
|
+
// Karpenter-specific surfaces stay hidden under denial.
|
|
854
|
+
expect(html).not.toContain("Open Demand");
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it("explains why Karpenter data is still syncing", () => {
|
|
858
|
+
const syncing = renderCapacity("/capacity", (client) =>
|
|
859
|
+
client.setQueryData(
|
|
860
|
+
["capacity", "overview"],
|
|
861
|
+
overview({
|
|
862
|
+
state: "syncing",
|
|
863
|
+
coverage: {
|
|
864
|
+
...meta.coverage,
|
|
865
|
+
nodePools: {
|
|
866
|
+
...sourceCoverage("syncing"),
|
|
867
|
+
reasonCode: "karpenter_discovery_partial",
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
}),
|
|
871
|
+
),
|
|
872
|
+
);
|
|
873
|
+
expect(syncing).toContain(
|
|
874
|
+
"Karpenter API discovery is incomplete; retrying…",
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("renders the capacity managers tile with labels, counts, and degraded detail", () => {
|
|
879
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
880
|
+
client.setQueryData(["capacity", "overview"], comprehensiveOverview()),
|
|
881
|
+
);
|
|
882
|
+
expect(html).toContain("Capacity managers");
|
|
883
|
+
expect(html).toContain("Karpenter");
|
|
884
|
+
expect(html).toContain("GKE autoscaler");
|
|
885
|
+
expect(html).toContain("1 group");
|
|
886
|
+
// Degraded rollup surfaces its reason as a visible line (SSR-safe).
|
|
887
|
+
expect(html).toContain("1 group backing off on a stockout");
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("shows autoscaler detection unavailable — never 'none' — when the status ConfigMap is denied", () => {
|
|
891
|
+
const denied = overview({
|
|
892
|
+
summary: { ...overview().summary, managers: [] },
|
|
893
|
+
coverage: {
|
|
894
|
+
...meta.coverage,
|
|
895
|
+
autoscalerStatus: sourceCoverage(
|
|
896
|
+
"denied",
|
|
897
|
+
"Autoscaler status hidden by permissions",
|
|
898
|
+
),
|
|
899
|
+
},
|
|
900
|
+
});
|
|
901
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
902
|
+
client.setQueryData(["capacity", "overview"], denied),
|
|
903
|
+
);
|
|
904
|
+
expect(html).toContain("Detection unavailable");
|
|
905
|
+
expect(html).not.toContain("None detected");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("renders a unified node-groups table across managers with karpenter pool affordances", () => {
|
|
909
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
910
|
+
client.setQueryData(["capacity", "overview"], comprehensiveOverview()),
|
|
911
|
+
);
|
|
912
|
+
expect(html).toContain("Node groups");
|
|
913
|
+
// GKE group: name, manager badge, and server-rendered scaling prose.
|
|
914
|
+
expect(html).toContain("gke-default-pool");
|
|
915
|
+
expect(html).toContain("GKE autoscaler");
|
|
916
|
+
expect(html).toContain("1–10 nodes across 2 zones");
|
|
917
|
+
expect(html).toContain("provisions on demand");
|
|
918
|
+
// Ready/total node counts render as "ready/total".
|
|
919
|
+
expect(html).toContain("4/5");
|
|
920
|
+
// Karpenter row keeps its pool-detail + raw-inspect affordances.
|
|
921
|
+
expect(html).toContain("default");
|
|
922
|
+
expect(html).toContain("Inspect");
|
|
923
|
+
// Unattributed footer — a bucket, never called "static".
|
|
924
|
+
expect(html).toContain("2 nodes without group identity");
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it("expands autoscaler child sub-tables with min–max, backoff, and truncation", () => {
|
|
928
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
929
|
+
client.setQueryData(["capacity", "overview"], comprehensiveOverview()),
|
|
930
|
+
);
|
|
931
|
+
// Collapse keeps children mounted, so SSR renders the sub-table content.
|
|
932
|
+
expect(html).toContain("gke-mig-us-central1-a");
|
|
933
|
+
expect(html).toContain("gke-mig-us-central1-b");
|
|
934
|
+
expect(html).toContain("1–10");
|
|
935
|
+
// Backoff badge label is visible (its message rides a hover tooltip).
|
|
936
|
+
expect(html).toContain("stockout");
|
|
937
|
+
expect(html).toContain("Showing 2 of 3 child groups.");
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it("renders scale-to-zero orphan groups with a real 0 target and 'not probed' freshness", () => {
|
|
941
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
942
|
+
client.setQueryData(["capacity", "overview"], comprehensiveOverview()),
|
|
943
|
+
);
|
|
944
|
+
expect(html).toContain("Known to the autoscaler, unattributed");
|
|
945
|
+
expect(html).toContain("gke-scale-to-zero");
|
|
946
|
+
// A nil asOf reads "not probed", never a zero time or "now".
|
|
947
|
+
expect(html).toContain("not probed");
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("titles the scheduling bar cluster-wide when clusterScheduling is present", () => {
|
|
951
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
952
|
+
client.setQueryData(["capacity", "overview"], comprehensiveOverview()),
|
|
953
|
+
);
|
|
954
|
+
expect(html).toContain("Cluster scheduling capacity");
|
|
955
|
+
expect(html).toContain(
|
|
956
|
+
"scheduled requests vs node allocatable, all observed nodes",
|
|
957
|
+
);
|
|
958
|
+
expect(html).toContain("in flight (Karpenter, beyond the edge)");
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("keeps the scheduling bar Karpenter-scoped when clusterScheduling is absent", () => {
|
|
962
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
963
|
+
client.setQueryData(["capacity", "overview"], overview()),
|
|
964
|
+
);
|
|
965
|
+
expect(html).toContain("Karpenter scheduling capacity");
|
|
966
|
+
expect(html).toContain("in flight (beyond the edge)");
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("renders the overview on a Karpenter-less cluster that still has observable capacity", () => {
|
|
970
|
+
const notDetected = comprehensiveOverview();
|
|
971
|
+
notDetected.state = "not_detected";
|
|
972
|
+
// Karpenter-less: no NodePools, no Karpenter pool ledger, no claims — only
|
|
973
|
+
// node/manager evidence remains.
|
|
974
|
+
notDetected.pools = [];
|
|
975
|
+
notDetected.poolsTruncated = false;
|
|
976
|
+
notDetected.summary = {
|
|
977
|
+
...notDetected.summary,
|
|
978
|
+
poolCount: 0,
|
|
979
|
+
claimCount: undefined,
|
|
980
|
+
claimStages: undefined,
|
|
981
|
+
scheduling: undefined,
|
|
982
|
+
managers: [
|
|
983
|
+
{ manager: "gke_autoscaler", groupCount: 1, status: "healthy" },
|
|
984
|
+
],
|
|
985
|
+
nodeCount: 9,
|
|
986
|
+
};
|
|
987
|
+
notDetected.groups = [gkeGroup];
|
|
988
|
+
|
|
989
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
990
|
+
client.setQueryData(["capacity", "overview"], notDetected),
|
|
991
|
+
);
|
|
992
|
+
// The page renders instead of the not-detected block.
|
|
993
|
+
expect(html).not.toContain("Karpenter not detected");
|
|
994
|
+
expect(html).toContain("Capacity overview");
|
|
995
|
+
// Node-groups inventory + managers tile both render from node/CM evidence.
|
|
996
|
+
expect(html).toContain("Node groups");
|
|
997
|
+
expect(html).toContain("gke-default-pool");
|
|
998
|
+
expect(html).toContain("Capacity managers");
|
|
999
|
+
expect(html).toContain("GKE autoscaler");
|
|
1000
|
+
// Cluster scheduling bar renders from clusterScheduling.
|
|
1001
|
+
expect(html).toContain("Cluster scheduling capacity");
|
|
1002
|
+
// Karpenter-only signals section is omitted (no dead-end Demand/Activity links).
|
|
1003
|
+
expect(html).not.toContain("Operational signals");
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("says Karpenter is not detected rather than calling its inventory unavailable", () => {
|
|
1007
|
+
// A cluster with no Karpenter CRDs at all: nodes are fully observed and
|
|
1008
|
+
// carry no group-identity labels, so there is nothing we failed to read.
|
|
1009
|
+
const notDetected = overview({
|
|
1010
|
+
state: "not_detected",
|
|
1011
|
+
pools: [],
|
|
1012
|
+
groups: [],
|
|
1013
|
+
coverage: {
|
|
1014
|
+
...meta.coverage,
|
|
1015
|
+
nodePools: sourceCoverage("unavailable"),
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
notDetected.summary = {
|
|
1019
|
+
...notDetected.summary,
|
|
1020
|
+
poolCount: undefined,
|
|
1021
|
+
claimCount: undefined,
|
|
1022
|
+
claimStages: undefined,
|
|
1023
|
+
scheduling: undefined,
|
|
1024
|
+
nodeCount: 9,
|
|
1025
|
+
};
|
|
1026
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1027
|
+
client.setQueryData(["capacity", "overview"], notDetected),
|
|
1028
|
+
);
|
|
1029
|
+
expect(html).toContain("Karpenter not detected");
|
|
1030
|
+
// "Unavailable" would claim we tried to look and could not.
|
|
1031
|
+
expect(html).not.toContain("NodePool inventory is unavailable");
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("keeps the not-detected block when the cluster exposes no observable capacity surface", () => {
|
|
1035
|
+
const blank = overview({
|
|
1036
|
+
state: "not_detected",
|
|
1037
|
+
summary: { ...overview().summary, managers: [], nodeCount: undefined },
|
|
1038
|
+
groups: [],
|
|
1039
|
+
orphanAutoscalerGroups: [],
|
|
1040
|
+
coverage: {
|
|
1041
|
+
...meta.coverage,
|
|
1042
|
+
nodes: sourceCoverage("unavailable"),
|
|
1043
|
+
nodePools: sourceCoverage("unavailable"),
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1047
|
+
client.setQueryData(["capacity", "overview"], blank),
|
|
1048
|
+
);
|
|
1049
|
+
expect(html).toContain("Karpenter not detected");
|
|
1050
|
+
// The block replaces the page — no tiles, no managers surface.
|
|
1051
|
+
expect(html).not.toContain("Capacity managers");
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("headlines the Nodes tile with the total and decomposes pooled vs unattributed", () => {
|
|
1055
|
+
const decomposed = overview({
|
|
1056
|
+
summary: {
|
|
1057
|
+
...overview().summary,
|
|
1058
|
+
nodeCount: 17,
|
|
1059
|
+
poolCount: 3,
|
|
1060
|
+
unpooledNodeCount: 5,
|
|
1061
|
+
unattributedNodeCount: 2,
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1065
|
+
client.setQueryData(["capacity", "overview"], decomposed),
|
|
1066
|
+
);
|
|
1067
|
+
// Headline is the total observed, not the pooled subset (the old bug showed
|
|
1068
|
+
// pooled = 12 as the headline on a 17-node cluster).
|
|
1069
|
+
expect(html).toContain(">17</div>");
|
|
1070
|
+
expect(html).not.toContain(">12</div>");
|
|
1071
|
+
// Subline decomposes: 17 − 5 unpooled = 12 in Karpenter pools · 2 unattributed.
|
|
1072
|
+
expect(html).toContain("12 in Karpenter pools");
|
|
1073
|
+
expect(html).toContain("2 unattributed");
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it("promotes GPUs into the inventory cell and collapses other resources into +N more", () => {
|
|
1077
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1078
|
+
client.setQueryData(
|
|
1079
|
+
["capacity", "overview"],
|
|
1080
|
+
overview({ groups: [richGroup] }),
|
|
1081
|
+
),
|
|
1082
|
+
);
|
|
1083
|
+
// CPU + memory lead; the GPU is promoted into the visible cell.
|
|
1084
|
+
expect(html).toContain("16 cores");
|
|
1085
|
+
expect(html).toContain("64.0 GiB");
|
|
1086
|
+
expect(html).toContain("gpu 8");
|
|
1087
|
+
// pods + ephemeral-storage collapse behind a labeled "+N more" tooltip.
|
|
1088
|
+
expect(html).toContain("+2 more");
|
|
1089
|
+
// The unlabeled "990" (pods) is no longer a bare number in the cell — it
|
|
1090
|
+
// lives inside the tooltip, which SSR does not paint.
|
|
1091
|
+
expect(html).not.toContain("990");
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("calls autoscaler detection unavailable for every unreadable source, not just denied", () => {
|
|
1095
|
+
for (const reasonCode of [
|
|
1096
|
+
"autoscaler_status_cache_scope",
|
|
1097
|
+
"autoscaler_status_cache_unavailable",
|
|
1098
|
+
"autoscaler_status_read_failed",
|
|
1099
|
+
"autoscaler_status_parse_failed",
|
|
1100
|
+
]) {
|
|
1101
|
+
const base = overview();
|
|
1102
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1103
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1104
|
+
...base,
|
|
1105
|
+
summary: { ...base.summary, managers: [] },
|
|
1106
|
+
coverage: {
|
|
1107
|
+
...meta.coverage,
|
|
1108
|
+
autoscalerStatus: {
|
|
1109
|
+
...sourceCoverage("unavailable"),
|
|
1110
|
+
reasonCode,
|
|
1111
|
+
},
|
|
1112
|
+
},
|
|
1113
|
+
}),
|
|
1114
|
+
);
|
|
1115
|
+
expect(html).toContain("Detection unavailable");
|
|
1116
|
+
expect(html).not.toContain("None detected");
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it("never concludes 'none detected' anywhere on the page while detection is unavailable", () => {
|
|
1121
|
+
// The tile was fixed first; the group row's Manager column reached the same
|
|
1122
|
+
// conclusion in lowercase. Assert over the WHOLE page, both capitalizations.
|
|
1123
|
+
const managerlessGroup: CapacityGroupSummary = {
|
|
1124
|
+
...gkeGroup,
|
|
1125
|
+
manager: undefined,
|
|
1126
|
+
children: [],
|
|
1127
|
+
childrenMeta: boundedMeta(0),
|
|
1128
|
+
scaling: [
|
|
1129
|
+
{
|
|
1130
|
+
code: "manager_detection_unavailable",
|
|
1131
|
+
summary: "manager detection unavailable",
|
|
1132
|
+
},
|
|
1133
|
+
],
|
|
1134
|
+
};
|
|
1135
|
+
for (const coverage of [
|
|
1136
|
+
sourceCoverage("denied", "Autoscaler status hidden by permissions"),
|
|
1137
|
+
{
|
|
1138
|
+
...sourceCoverage("unavailable"),
|
|
1139
|
+
reasonCode: "autoscaler_status_cache_scope",
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
...sourceCoverage("unavailable"),
|
|
1143
|
+
reasonCode: "autoscaler_status_cache_unavailable",
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
...sourceCoverage("error"),
|
|
1147
|
+
reasonCode: "autoscaler_status_read_failed",
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
...sourceCoverage("unavailable"),
|
|
1151
|
+
reasonCode: "autoscaler_status_parse_failed",
|
|
1152
|
+
},
|
|
1153
|
+
]) {
|
|
1154
|
+
const base = overview();
|
|
1155
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1156
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1157
|
+
...base,
|
|
1158
|
+
summary: { ...base.summary, managers: [] },
|
|
1159
|
+
groups: [managerlessGroup],
|
|
1160
|
+
coverage: { ...meta.coverage, autoscalerStatus: coverage },
|
|
1161
|
+
}),
|
|
1162
|
+
);
|
|
1163
|
+
expect(html).not.toMatch(/none detected/i);
|
|
1164
|
+
expect(html).toContain("Detection unavailable");
|
|
1165
|
+
expect(html).toContain("detection unavailable");
|
|
1166
|
+
// The Scaling column's server-authored fact must not contradict the
|
|
1167
|
+
// Manager column beside it.
|
|
1168
|
+
expect(html).toContain("manager detection unavailable");
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("shows certainty glyphs only on deviation — exact is the quiet default", () => {
|
|
1173
|
+
const base = overview();
|
|
1174
|
+
const clean = renderCapacity("/capacity", (client) =>
|
|
1175
|
+
client.setQueryData(["capacity", "overview"], base),
|
|
1176
|
+
);
|
|
1177
|
+
// Fully-observed tiles carry no = chip; the aria notes disappear with them.
|
|
1178
|
+
expect(clean).not.toContain("Node inventory observed");
|
|
1179
|
+
|
|
1180
|
+
const bounded = renderCapacity("/capacity", (client) =>
|
|
1181
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1182
|
+
...base,
|
|
1183
|
+
coverage: {
|
|
1184
|
+
...meta.coverage,
|
|
1185
|
+
pods: sourceCoverage("available", undefined, "all_authorized_namespaces"),
|
|
1186
|
+
},
|
|
1187
|
+
}),
|
|
1188
|
+
);
|
|
1189
|
+
// Deviation still announces itself.
|
|
1190
|
+
expect(bounded).toContain("≥");
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it("the pending tile never dead-ends on a Karpenter-less cluster", () => {
|
|
1194
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1195
|
+
client.setQueryData(["capacity", "overview"], (() => {
|
|
1196
|
+
const base = overview({ state: "not_detected", pools: [] });
|
|
1197
|
+
return {
|
|
1198
|
+
...base,
|
|
1199
|
+
summary: {
|
|
1200
|
+
...base.summary,
|
|
1201
|
+
poolCount: undefined,
|
|
1202
|
+
claimCount: undefined,
|
|
1203
|
+
managers: [],
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
})()),
|
|
1207
|
+
);
|
|
1208
|
+
expect(html).toContain("View issues");
|
|
1209
|
+
expect(html).not.toContain("Open Demand");
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("splits platform identity from autoscaler observation when the source was read", () => {
|
|
1213
|
+
const managerlessGroup: CapacityGroupSummary = {
|
|
1214
|
+
...gkeGroup,
|
|
1215
|
+
manager: undefined,
|
|
1216
|
+
children: [],
|
|
1217
|
+
childrenMeta: boundedMeta(0),
|
|
1218
|
+
};
|
|
1219
|
+
const base = overview();
|
|
1220
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1221
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1222
|
+
...base,
|
|
1223
|
+
summary: { ...base.summary, managers: [] },
|
|
1224
|
+
groups: [managerlessGroup],
|
|
1225
|
+
coverage: {
|
|
1226
|
+
...meta.coverage,
|
|
1227
|
+
autoscalerStatus: {
|
|
1228
|
+
...sourceCoverage("unavailable"),
|
|
1229
|
+
reasonCode: "autoscaler_status_not_published",
|
|
1230
|
+
namespaces: ["kube-system"],
|
|
1231
|
+
},
|
|
1232
|
+
},
|
|
1233
|
+
}),
|
|
1234
|
+
);
|
|
1235
|
+
// Tile: the autoscaler was read and published nothing — an honest "none".
|
|
1236
|
+
expect(html).toContain("None detected");
|
|
1237
|
+
// Row: the group's identity CAME from a platform label; concluding "none
|
|
1238
|
+
// detected" there would read as failing to recognize the fleet's most
|
|
1239
|
+
// basic fact. Platform identity renders instead.
|
|
1240
|
+
expect(html).toContain("GKE node pool");
|
|
1241
|
+
expect(html).not.toContain("none detected");
|
|
1242
|
+
expect(html).not.toContain("detection unavailable");
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("says none detected only when the autoscaler was read and published nothing", () => {
|
|
1246
|
+
const base = overview();
|
|
1247
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1248
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1249
|
+
...base,
|
|
1250
|
+
summary: { ...base.summary, managers: [] },
|
|
1251
|
+
coverage: {
|
|
1252
|
+
...meta.coverage,
|
|
1253
|
+
autoscalerStatus: {
|
|
1254
|
+
...sourceCoverage("unavailable"),
|
|
1255
|
+
reasonCode: "autoscaler_status_not_published",
|
|
1256
|
+
// The server reports the namespace it probed on this path.
|
|
1257
|
+
namespaces: ["kube-system"],
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1260
|
+
}),
|
|
1261
|
+
);
|
|
1262
|
+
expect(html).toContain("None detected");
|
|
1263
|
+
expect(html).not.toContain("Detection unavailable");
|
|
1264
|
+
// "None detected" is only evidence about the namespace probed.
|
|
1265
|
+
expect(html).toContain("No cluster-autoscaler-status ConfigMap in");
|
|
1266
|
+
expect(html).toContain("kube-system");
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("dates the managers tile with the autoscaler payload's own timestamp", () => {
|
|
1270
|
+
const base = comprehensiveOverview();
|
|
1271
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1272
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1273
|
+
...base,
|
|
1274
|
+
coverage: { ...meta.coverage, autoscalerStatus: sourceCoverage() },
|
|
1275
|
+
}),
|
|
1276
|
+
);
|
|
1277
|
+
expect(html).toContain("as of ");
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it("hedges the node-groups count when NodePools are unreadable", () => {
|
|
1281
|
+
const base = overview();
|
|
1282
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1283
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1284
|
+
...base,
|
|
1285
|
+
// A denied NodePool source hides scale-to-zero groups entirely, so the
|
|
1286
|
+
// count is a floor even with every node observed.
|
|
1287
|
+
summary: { ...base.summary, poolCount: undefined },
|
|
1288
|
+
state: "denied",
|
|
1289
|
+
pools: [],
|
|
1290
|
+
groups: [gkeGroup],
|
|
1291
|
+
coverage: {
|
|
1292
|
+
...meta.coverage,
|
|
1293
|
+
nodePools: sourceCoverage(
|
|
1294
|
+
"denied",
|
|
1295
|
+
"NodePools hidden by permissions",
|
|
1296
|
+
),
|
|
1297
|
+
},
|
|
1298
|
+
}),
|
|
1299
|
+
);
|
|
1300
|
+
expect(html).toContain("≥");
|
|
1301
|
+
expect(html).toContain("NodePool groups holding no nodes are invisible");
|
|
1302
|
+
// An omitted poolCount is never rendered as zero Karpenter pools.
|
|
1303
|
+
expect(html).not.toContain("0 Karpenter");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it("keeps a detail-less backoff visible instead of dashing it out", () => {
|
|
1307
|
+
// The legacy status format reports backoff with no error class, code or
|
|
1308
|
+
// message; a dash there would read as "not backing off".
|
|
1309
|
+
const base = comprehensiveOverview();
|
|
1310
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1311
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1312
|
+
...base,
|
|
1313
|
+
groups: [
|
|
1314
|
+
{
|
|
1315
|
+
...gkeGroup,
|
|
1316
|
+
children: [{ ...gkeChild, backoff: {} }],
|
|
1317
|
+
childrenMeta: boundedMeta(1),
|
|
1318
|
+
},
|
|
1319
|
+
],
|
|
1320
|
+
}),
|
|
1321
|
+
);
|
|
1322
|
+
// Match the badge itself — "Backoff" also appears as the column header and
|
|
1323
|
+
// the group-row rollup, neither of which proves the cell kept the signal.
|
|
1324
|
+
expect(html).toMatch(/badge-sm[^"]*">Backoff</);
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("marks a group whose manager attribution is unvalidated", () => {
|
|
1328
|
+
const base = comprehensiveOverview();
|
|
1329
|
+
const html = renderCapacity("/capacity", (client) =>
|
|
1330
|
+
client.setQueryData(["capacity", "overview"], {
|
|
1331
|
+
...base,
|
|
1332
|
+
groups: [{ ...gkeGroup, managerValidated: false }],
|
|
1333
|
+
}),
|
|
1334
|
+
);
|
|
1335
|
+
expect(html).toContain("unvalidated");
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
describe("CapacityView pool detail", () => {
|
|
1340
|
+
it("renders the capacity ledger with headers and a certainty legend", () => {
|
|
1341
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1342
|
+
client.setQueryData(
|
|
1343
|
+
["capacity", "pool", "default"],
|
|
1344
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1345
|
+
),
|
|
1346
|
+
);
|
|
1347
|
+
expect(html).toContain("Capacity ledger");
|
|
1348
|
+
expect(html).toContain("Configured limit");
|
|
1349
|
+
expect(html).toContain("Node allocatable");
|
|
1350
|
+
expect(html).toContain("Unallocated");
|
|
1351
|
+
expect(html).toContain("CPU");
|
|
1352
|
+
expect(html).toContain("Memory");
|
|
1353
|
+
expect(html).toContain("Certainty glyphs:");
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
it("keeps pod-derived ledger and attribution unavailable under denied pod access", () => {
|
|
1357
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1358
|
+
client.setQueryData(
|
|
1359
|
+
["capacity", "pool", "default"],
|
|
1360
|
+
poolDetailResponse(poolDetail),
|
|
1361
|
+
),
|
|
1362
|
+
);
|
|
1363
|
+
expect(html).toContain("Unavailable");
|
|
1364
|
+
expect(html).toContain("Unknown — Pod access denied");
|
|
1365
|
+
expect(html).toContain("Unavailable — Pod access denied");
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it("explains a missing pool observation without implying zero", () => {
|
|
1369
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1370
|
+
client.setQueryData(
|
|
1371
|
+
["capacity", "pool", "default"],
|
|
1372
|
+
poolDetailResponse(undefined),
|
|
1373
|
+
),
|
|
1374
|
+
);
|
|
1375
|
+
expect(html).toContain("NodePool unavailable");
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("collapses the NodeClass cascade to its root and tucks raw conditions behind a toggle", () => {
|
|
1379
|
+
const issue = (reason: string, message: string) => ({
|
|
1380
|
+
id: reason,
|
|
1381
|
+
severity: "warning" as const,
|
|
1382
|
+
source: "condition" as const,
|
|
1383
|
+
category: "node_provisioning_failure",
|
|
1384
|
+
category_group: "capacity",
|
|
1385
|
+
grouping_scope: "unknown" as const,
|
|
1386
|
+
group: "karpenter.sh",
|
|
1387
|
+
kind: "NodePool",
|
|
1388
|
+
name: "default",
|
|
1389
|
+
reason,
|
|
1390
|
+
message,
|
|
1391
|
+
});
|
|
1392
|
+
const brokenRaw = renderCapacity("/capacity/pools/default", (client) =>
|
|
1393
|
+
client.setQueryData(
|
|
1394
|
+
["capacity", "pool", "default"],
|
|
1395
|
+
poolDetailResponse({
|
|
1396
|
+
...cleanPoolDetail,
|
|
1397
|
+
issues: [
|
|
1398
|
+
issue("NodeClassNotReady", "ValidationSucceeded=False"),
|
|
1399
|
+
issue("NodePoolNotReady", "NodeClassReady=False"),
|
|
1400
|
+
],
|
|
1401
|
+
conditions: [
|
|
1402
|
+
{ type: "Ready", status: "False", reason: "UnhealthyDependents" },
|
|
1403
|
+
{ type: "ValidationSucceeded", status: "True" },
|
|
1404
|
+
],
|
|
1405
|
+
}),
|
|
1406
|
+
),
|
|
1407
|
+
);
|
|
1408
|
+
const broken = brokenRaw.replace(/<!-- -->/g, "");
|
|
1409
|
+
// The pool-not-ready row is a derivable echo of the NodeClass issue beside
|
|
1410
|
+
// it — the header chip already carries the state, so the row disappears.
|
|
1411
|
+
expect(broken).not.toContain("NodeClassReady=False");
|
|
1412
|
+
expect(broken).toContain("ValidationSucceeded=False");
|
|
1413
|
+
// Raw conditions collapse behind the toggle while issues cover them.
|
|
1414
|
+
expect(broken).toContain("Show controller conditions (2)");
|
|
1415
|
+
expect(broken).not.toContain("UnhealthyDependents");
|
|
1416
|
+
|
|
1417
|
+
const standaloneRaw = renderCapacity("/capacity/pools/default", (client) =>
|
|
1418
|
+
client.setQueryData(
|
|
1419
|
+
["capacity", "pool", "default"],
|
|
1420
|
+
poolDetailResponse({
|
|
1421
|
+
...cleanPoolDetail,
|
|
1422
|
+
issues: [issue("NodePoolNotReady", "NodeClassReady=False")],
|
|
1423
|
+
}),
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
const standalone = standaloneRaw.replace(/<!-- -->/g, "");
|
|
1427
|
+
// Without the NodeClass issue the pool-unready row is novel — it stays.
|
|
1428
|
+
expect(standalone).toContain("NodeClassReady=False");
|
|
1429
|
+
|
|
1430
|
+
const healthyRaw = renderCapacity("/capacity/pools/default", (client) =>
|
|
1431
|
+
client.setQueryData(
|
|
1432
|
+
["capacity", "pool", "default"],
|
|
1433
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1434
|
+
),
|
|
1435
|
+
);
|
|
1436
|
+
const healthy = healthyRaw.replace(/<!-- -->/g, "");
|
|
1437
|
+
// No issues: the raw conditions ARE the content and stay visible.
|
|
1438
|
+
expect(healthy).toContain("Controller conditions");
|
|
1439
|
+
expect(healthy).not.toContain("Show controller conditions");
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it("deep-links a provisioning-health issue into the pool's provision episodes", () => {
|
|
1443
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1444
|
+
client.setQueryData(
|
|
1445
|
+
["capacity", "pool", "default"],
|
|
1446
|
+
poolDetailResponse({
|
|
1447
|
+
...cleanPoolDetail,
|
|
1448
|
+
issues: [
|
|
1449
|
+
{
|
|
1450
|
+
id: "reg-unhealthy",
|
|
1451
|
+
severity: "warning",
|
|
1452
|
+
source: "condition",
|
|
1453
|
+
category: "node_provisioning_failure",
|
|
1454
|
+
category_group: "capacity",
|
|
1455
|
+
grouping_scope: "unknown",
|
|
1456
|
+
group: "karpenter.sh",
|
|
1457
|
+
kind: "NodePool",
|
|
1458
|
+
name: "default",
|
|
1459
|
+
reason: "NodePoolNodeRegistrationUnhealthy",
|
|
1460
|
+
message: "NodeClass is in Ready=Unknown",
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
id: "nodeclass-not-ready",
|
|
1464
|
+
severity: "warning",
|
|
1465
|
+
source: "condition",
|
|
1466
|
+
category: "node_provisioning_failure",
|
|
1467
|
+
category_group: "capacity",
|
|
1468
|
+
grouping_scope: "unknown",
|
|
1469
|
+
group: "karpenter.k8s.aws",
|
|
1470
|
+
kind: "EC2NodeClass",
|
|
1471
|
+
name: "gpu",
|
|
1472
|
+
reason: "NodeClassNotReady",
|
|
1473
|
+
message: "Validation failed",
|
|
1474
|
+
},
|
|
1475
|
+
],
|
|
1476
|
+
}),
|
|
1477
|
+
),
|
|
1478
|
+
);
|
|
1479
|
+
const links = html.split("View provisioning episodes").length - 1;
|
|
1480
|
+
expect(links).toBe(1);
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it("leads with the diagnosis when the pool has issues, ledger when clean", () => {
|
|
1484
|
+
const broken = renderCapacity("/capacity/pools/default", (client) =>
|
|
1485
|
+
client.setQueryData(
|
|
1486
|
+
["capacity", "pool", "default"],
|
|
1487
|
+
poolDetailResponse({
|
|
1488
|
+
...cleanPoolDetail,
|
|
1489
|
+
issues: [
|
|
1490
|
+
{
|
|
1491
|
+
id: "nodepool-not-ready",
|
|
1492
|
+
severity: "warning",
|
|
1493
|
+
source: "condition",
|
|
1494
|
+
category: "node_provisioning_failure",
|
|
1495
|
+
category_group: "capacity",
|
|
1496
|
+
grouping_scope: "unknown",
|
|
1497
|
+
group: "karpenter.sh",
|
|
1498
|
+
kind: "NodePool",
|
|
1499
|
+
name: "default",
|
|
1500
|
+
reason: "NodePoolNotReady",
|
|
1501
|
+
message: "Validation failed",
|
|
1502
|
+
},
|
|
1503
|
+
],
|
|
1504
|
+
}),
|
|
1505
|
+
),
|
|
1506
|
+
);
|
|
1507
|
+
expect(broken.indexOf("Issues & conditions")).toBeGreaterThan(-1);
|
|
1508
|
+
expect(broken.indexOf("Issues & conditions")).toBeLessThan(
|
|
1509
|
+
broken.indexOf("Capacity ledger"),
|
|
1510
|
+
);
|
|
1511
|
+
|
|
1512
|
+
const clean = renderCapacity("/capacity/pools/default", (client) =>
|
|
1513
|
+
client.setQueryData(
|
|
1514
|
+
["capacity", "pool", "default"],
|
|
1515
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1516
|
+
),
|
|
1517
|
+
);
|
|
1518
|
+
expect(clean.indexOf("Capacity ledger")).toBeLessThan(
|
|
1519
|
+
clean.indexOf("Issues & conditions"),
|
|
1520
|
+
);
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
it("narrates the zero-node ledger instead of presenting a wall of zeros", () => {
|
|
1524
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1525
|
+
client.setQueryData(
|
|
1526
|
+
["capacity", "pool", "default"],
|
|
1527
|
+
poolDetailResponse({
|
|
1528
|
+
...cleanPoolDetail,
|
|
1529
|
+
nodes: { total: 0, ready: 0, notReady: 0, cordoned: 0, terminating: 0 },
|
|
1530
|
+
}),
|
|
1531
|
+
),
|
|
1532
|
+
);
|
|
1533
|
+
expect(html).toContain("No nodes exist in this pool right now");
|
|
1534
|
+
const populated = renderCapacity("/capacity/pools/default", (client) =>
|
|
1535
|
+
client.setQueryData(
|
|
1536
|
+
["capacity", "pool", "default"],
|
|
1537
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1538
|
+
),
|
|
1539
|
+
);
|
|
1540
|
+
expect(populated).not.toContain("No nodes exist in this pool right now");
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it("renders structured issue diagnosis in pool posture", () => {
|
|
1544
|
+
const html = renderCapacity("/capacity/pools/default/posture", (client) =>
|
|
1545
|
+
client.setQueryData(
|
|
1546
|
+
["capacity", "pool", "default"],
|
|
1547
|
+
poolDetailResponse({
|
|
1548
|
+
...cleanPoolDetail,
|
|
1549
|
+
issues: [
|
|
1550
|
+
{
|
|
1551
|
+
id: "nodepool-not-ready",
|
|
1552
|
+
severity: "warning",
|
|
1553
|
+
source: "condition",
|
|
1554
|
+
category: "node_provisioning_failure",
|
|
1555
|
+
category_group: "capacity",
|
|
1556
|
+
grouping_scope: "unknown",
|
|
1557
|
+
group: "karpenter.sh",
|
|
1558
|
+
kind: "NodePool",
|
|
1559
|
+
name: "default",
|
|
1560
|
+
reason: "NodePoolNotReady",
|
|
1561
|
+
message: "Validation failed",
|
|
1562
|
+
cause: "The NodePool Ready condition is false.",
|
|
1563
|
+
action:
|
|
1564
|
+
"Inspect the Ready condition and correct the referenced configuration.",
|
|
1565
|
+
count: 3,
|
|
1566
|
+
members: [
|
|
1567
|
+
{ kind: "NodeClaim", name: "default-failed-a" },
|
|
1568
|
+
{ kind: "NodeClaim", name: "default-failed-b" },
|
|
1569
|
+
],
|
|
1570
|
+
members_truncated: true,
|
|
1571
|
+
},
|
|
1572
|
+
{
|
|
1573
|
+
id: "nodeclass-not-ready",
|
|
1574
|
+
severity: "warning",
|
|
1575
|
+
source: "condition",
|
|
1576
|
+
category: "node_provisioning_failure",
|
|
1577
|
+
category_group: "capacity",
|
|
1578
|
+
grouping_scope: "unknown",
|
|
1579
|
+
group: "karpenter.k8s.aws",
|
|
1580
|
+
kind: "EC2NodeClass",
|
|
1581
|
+
name: "loadtest",
|
|
1582
|
+
reason: "NodeClassNotReady",
|
|
1583
|
+
message: "Subnets could not be resolved",
|
|
1584
|
+
cause: "The EC2NodeClass Ready condition is false.",
|
|
1585
|
+
action: "Inspect the EC2NodeClass selector terms.",
|
|
1586
|
+
count: 0,
|
|
1587
|
+
members: [],
|
|
1588
|
+
},
|
|
1589
|
+
],
|
|
1590
|
+
}),
|
|
1591
|
+
),
|
|
1592
|
+
);
|
|
1593
|
+
expect(html).toContain("NodePool Not Ready");
|
|
1594
|
+
expect(html).toContain("Cause:");
|
|
1595
|
+
expect(html).toContain("The NodePool Ready condition is false.");
|
|
1596
|
+
expect(html).toContain("Next:");
|
|
1597
|
+
expect(html).toContain("Inspect the Ready condition");
|
|
1598
|
+
expect(html).toMatch(/3(?:<!-- -->)? affected/);
|
|
1599
|
+
expect(html).toMatch(
|
|
1600
|
+
/NodeClaim(?:<!-- -->)?\/(?:<!-- -->)?default-failed-a/,
|
|
1601
|
+
);
|
|
1602
|
+
expect(html).toContain("Showing 2 of 3 affected resources.");
|
|
1603
|
+
expect(html).toContain("Inspect nodes & claims →");
|
|
1604
|
+
expect(html).toMatch(
|
|
1605
|
+
/Inspect (?:<!-- -->)?EC2NodeClass(?:<!-- -->)?\/(?:<!-- -->)?loadtest/,
|
|
1606
|
+
);
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
it("loads node and claim members from the URL-backed members tab", () => {
|
|
1610
|
+
const html = renderCapacity("/capacity/pools/default/members", (client) => {
|
|
1611
|
+
client.setQueryData(
|
|
1612
|
+
["capacity", "pool", "default"],
|
|
1613
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1614
|
+
);
|
|
1615
|
+
client.setQueryData(
|
|
1616
|
+
["capacity", "pool", "default", "members", "node", 50, undefined],
|
|
1617
|
+
memberResponse("node", [nodeMember]),
|
|
1618
|
+
);
|
|
1619
|
+
client.setQueryData(
|
|
1620
|
+
["capacity", "pool", "default", "members", "claim", 50, undefined],
|
|
1621
|
+
memberResponse("claim", [claimMember]),
|
|
1622
|
+
);
|
|
1623
|
+
});
|
|
1624
|
+
expect(html).toContain("Nodes");
|
|
1625
|
+
expect(html).toContain("NodeClaims");
|
|
1626
|
+
expect(html).toContain("ip-10-0-1-1");
|
|
1627
|
+
expect(html).toContain("default-xyz9k");
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("keeps member pod counts unknown under partial RBAC", () => {
|
|
1631
|
+
const html = renderCapacity("/capacity/pools/default/members", (client) => {
|
|
1632
|
+
client.setQueryData(
|
|
1633
|
+
["capacity", "pool", "default"],
|
|
1634
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1635
|
+
);
|
|
1636
|
+
client.setQueryData(
|
|
1637
|
+
["capacity", "pool", "default", "members", "node", 50, undefined],
|
|
1638
|
+
memberResponse("node", [nodeMember], {
|
|
1639
|
+
...meta.coverage,
|
|
1640
|
+
pods: sourceCoverage("denied", "Pod inventory hidden by permissions"),
|
|
1641
|
+
}),
|
|
1642
|
+
);
|
|
1643
|
+
client.setQueryData(
|
|
1644
|
+
["capacity", "pool", "default", "members", "claim", 50, undefined],
|
|
1645
|
+
memberResponse("claim", [claimMember]),
|
|
1646
|
+
);
|
|
1647
|
+
});
|
|
1648
|
+
expect(html).toContain("Unknown");
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1651
|
+
it("hedges a member's pod-derived requests when pod coverage is a lower bound", () => {
|
|
1652
|
+
const html = renderCapacity("/capacity/pools/default/members", (client) => {
|
|
1653
|
+
client.setQueryData(
|
|
1654
|
+
["capacity", "pool", "default"],
|
|
1655
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1656
|
+
);
|
|
1657
|
+
client.setQueryData(
|
|
1658
|
+
["capacity", "pool", "default", "members", "node", 50, undefined],
|
|
1659
|
+
memberResponse(
|
|
1660
|
+
"node",
|
|
1661
|
+
[
|
|
1662
|
+
{
|
|
1663
|
+
...nodeMember,
|
|
1664
|
+
node: {
|
|
1665
|
+
...nodeMember.node!,
|
|
1666
|
+
scheduledRequests: quantity(
|
|
1667
|
+
{ cpu: "1", memory: "4Gi" },
|
|
1668
|
+
"lower_bound",
|
|
1669
|
+
),
|
|
1670
|
+
},
|
|
1671
|
+
},
|
|
1672
|
+
],
|
|
1673
|
+
{
|
|
1674
|
+
...meta.coverage,
|
|
1675
|
+
pods: sourceCoverage(
|
|
1676
|
+
"available",
|
|
1677
|
+
undefined,
|
|
1678
|
+
"all_authorized_namespaces",
|
|
1679
|
+
),
|
|
1680
|
+
},
|
|
1681
|
+
),
|
|
1682
|
+
);
|
|
1683
|
+
client.setQueryData(
|
|
1684
|
+
["capacity", "pool", "default", "members", "claim", 50, undefined],
|
|
1685
|
+
memberResponse("claim", [claimMember]),
|
|
1686
|
+
);
|
|
1687
|
+
});
|
|
1688
|
+
// Namespace-limited pod coverage cannot produce a bare exact-looking value.
|
|
1689
|
+
expect(html).toContain("≥");
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
it("hedges the scheduled pod count under namespace-scoped pod coverage", () => {
|
|
1693
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1694
|
+
client.setQueryData(
|
|
1695
|
+
["capacity", "pool", "default"],
|
|
1696
|
+
poolDetailResponse({
|
|
1697
|
+
...cleanPoolDetail,
|
|
1698
|
+
coverage: {
|
|
1699
|
+
...meta.coverage,
|
|
1700
|
+
pods: sourceCoverage(
|
|
1701
|
+
"available",
|
|
1702
|
+
undefined,
|
|
1703
|
+
"all_authorized_namespaces",
|
|
1704
|
+
),
|
|
1705
|
+
},
|
|
1706
|
+
}),
|
|
1707
|
+
),
|
|
1708
|
+
);
|
|
1709
|
+
expect(html).toContain("Scheduled pods on this pool");
|
|
1710
|
+
expect(html).toMatch(/≥ (?:<!-- -->)?12/);
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
it("names the upper bound in the ledger certainty legend", () => {
|
|
1714
|
+
const html = renderCapacity("/capacity/pools/default", (client) =>
|
|
1715
|
+
client.setQueryData(
|
|
1716
|
+
["capacity", "pool", "default"],
|
|
1717
|
+
poolDetailResponse(cleanPoolDetail),
|
|
1718
|
+
),
|
|
1719
|
+
);
|
|
1720
|
+
expect(html).toContain("≤");
|
|
1721
|
+
expect(html).toContain("upper bound");
|
|
1722
|
+
});
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
describe("CapacityView demand", () => {
|
|
1726
|
+
it("never mixes the global denominator into an owner-scoped header", () => {
|
|
1727
|
+
const html = renderCapacity(
|
|
1728
|
+
"/capacity/demand?owner=payments%2FDeployment%2Fcheckout",
|
|
1729
|
+
(client) =>
|
|
1730
|
+
client.setQueryData(
|
|
1731
|
+
[
|
|
1732
|
+
"capacity",
|
|
1733
|
+
"demand",
|
|
1734
|
+
25,
|
|
1735
|
+
undefined,
|
|
1736
|
+
undefined,
|
|
1737
|
+
undefined,
|
|
1738
|
+
"payments/Deployment/checkout",
|
|
1739
|
+
undefined,
|
|
1740
|
+
],
|
|
1741
|
+
demandResponse(),
|
|
1742
|
+
),
|
|
1743
|
+
);
|
|
1744
|
+
expect(html).toContain("for this workload");
|
|
1745
|
+
expect(html).not.toContain("showing");
|
|
1746
|
+
expect(html).toContain(
|
|
1747
|
+
"the state counts below describe all observed demand",
|
|
1748
|
+
);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
it("headlines the dominant reason when every pool rules the group out", () => {
|
|
1752
|
+
const blockedGroup = {
|
|
1753
|
+
...demandGroup,
|
|
1754
|
+
poolEvaluationCounts: { declaredCompatible: 0, incompatible: 2, unknown: 0 },
|
|
1755
|
+
poolEvaluations: [
|
|
1756
|
+
{
|
|
1757
|
+
pool: { kind: "NodePool", name: "general" },
|
|
1758
|
+
result: "incompatible" as const,
|
|
1759
|
+
evidence: [
|
|
1760
|
+
{
|
|
1761
|
+
predicate: "selector.gpu",
|
|
1762
|
+
sourcePath: "spec.nodeSelector",
|
|
1763
|
+
observedValues: ["gpu=true"],
|
|
1764
|
+
expectedValues: [],
|
|
1765
|
+
confidence: "high" as const,
|
|
1766
|
+
explanation: "requires label gpu=true, which no pool requirement or template declares",
|
|
1767
|
+
},
|
|
1768
|
+
],
|
|
1769
|
+
evidenceMeta: boundedMeta(1),
|
|
1770
|
+
unknownPredicates: [],
|
|
1771
|
+
unknownPredicatesMeta: boundedMeta(0),
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
pool: { kind: "NodePool", name: "spot" },
|
|
1775
|
+
result: "incompatible" as const,
|
|
1776
|
+
evidence: [
|
|
1777
|
+
{
|
|
1778
|
+
predicate: "selector.gpu",
|
|
1779
|
+
sourcePath: "spec.nodeSelector",
|
|
1780
|
+
observedValues: ["gpu=true"],
|
|
1781
|
+
expectedValues: [],
|
|
1782
|
+
confidence: "high" as const,
|
|
1783
|
+
explanation: "requires label gpu=true, which no pool requirement or template declares",
|
|
1784
|
+
},
|
|
1785
|
+
],
|
|
1786
|
+
evidenceMeta: boundedMeta(1),
|
|
1787
|
+
unknownPredicates: [],
|
|
1788
|
+
unknownPredicatesMeta: boundedMeta(0),
|
|
1789
|
+
},
|
|
1790
|
+
],
|
|
1791
|
+
};
|
|
1792
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1793
|
+
client.setQueryData(
|
|
1794
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1795
|
+
demandResponse({ items: [blockedGroup] }),
|
|
1796
|
+
),
|
|
1797
|
+
);
|
|
1798
|
+
expect(html).toContain("No pool can take this group");
|
|
1799
|
+
expect(html).toContain("requires label gpu=true");
|
|
1800
|
+
expect(html).toContain("(2 of 2 incompatible pools)");
|
|
1801
|
+
|
|
1802
|
+
const mixed = renderCapacity("/capacity/demand", (client) =>
|
|
1803
|
+
client.setQueryData(
|
|
1804
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1805
|
+
demandResponse({
|
|
1806
|
+
items: [
|
|
1807
|
+
{
|
|
1808
|
+
...blockedGroup,
|
|
1809
|
+
poolEvaluationCounts: { declaredCompatible: 1, incompatible: 1, unknown: 0 },
|
|
1810
|
+
},
|
|
1811
|
+
],
|
|
1812
|
+
}),
|
|
1813
|
+
),
|
|
1814
|
+
);
|
|
1815
|
+
expect(mixed).not.toContain("No pool can take this group");
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it("celebrates the unfiltered empty state instead of blaming inactive filters", () => {
|
|
1819
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1820
|
+
client.setQueryData(
|
|
1821
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1822
|
+
demandResponse({ items: [] }),
|
|
1823
|
+
),
|
|
1824
|
+
);
|
|
1825
|
+
expect(html).toContain("Nothing is pending");
|
|
1826
|
+
expect(html).toContain("Every pod the scheduler knows about is placed");
|
|
1827
|
+
expect(html).not.toContain("match these filters");
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
it("claims only what it can see when pod coverage is namespace-bounded", () => {
|
|
1831
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1832
|
+
client.setQueryData(
|
|
1833
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1834
|
+
demandResponse({
|
|
1835
|
+
items: [],
|
|
1836
|
+
coverage: {
|
|
1837
|
+
...meta.coverage,
|
|
1838
|
+
pods: sourceCoverage("available", undefined, "all_authorized_namespaces"),
|
|
1839
|
+
},
|
|
1840
|
+
}),
|
|
1841
|
+
),
|
|
1842
|
+
);
|
|
1843
|
+
expect(html).toContain("No pending demand observed");
|
|
1844
|
+
expect(html).not.toContain("Every pod the scheduler knows about is placed");
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
it("names the state when a state filter empties the list, and success copy under pool-only", () => {
|
|
1848
|
+
const stateFiltered = renderCapacity("/capacity/demand?state=blocked", (client) =>
|
|
1849
|
+
client.setQueryData(
|
|
1850
|
+
["capacity", "demand", 25, undefined, "blocked", undefined, undefined, undefined],
|
|
1851
|
+
demandResponse({ items: [] }),
|
|
1852
|
+
),
|
|
1853
|
+
);
|
|
1854
|
+
expect(stateFiltered).toContain("No groups in");
|
|
1855
|
+
expect(stateFiltered).toContain("the pills above carry the counts");
|
|
1856
|
+
|
|
1857
|
+
const poolOnly = renderCapacity("/capacity/demand?pool=general", (client) =>
|
|
1858
|
+
client.setQueryData(
|
|
1859
|
+
["capacity", "demand", 25, undefined, undefined, "general", undefined, undefined],
|
|
1860
|
+
demandResponse({ items: [] }),
|
|
1861
|
+
),
|
|
1862
|
+
);
|
|
1863
|
+
expect(poolOnly).toContain("Nothing is pending");
|
|
1864
|
+
expect(poolOnly).not.toContain("match these filters");
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
it("lands the pod-drawer bridge on the pod's workload, filtered and labeled", () => {
|
|
1868
|
+
const html = renderCapacity("/capacity/demand?pod=payments%2Fcheckout-abc12", (client) =>
|
|
1869
|
+
client.setQueryData(
|
|
1870
|
+
[
|
|
1871
|
+
"capacity",
|
|
1872
|
+
"demand",
|
|
1873
|
+
25,
|
|
1874
|
+
undefined,
|
|
1875
|
+
undefined,
|
|
1876
|
+
undefined,
|
|
1877
|
+
undefined,
|
|
1878
|
+
"payments/checkout-abc12",
|
|
1879
|
+
],
|
|
1880
|
+
demandResponse(),
|
|
1881
|
+
),
|
|
1882
|
+
);
|
|
1883
|
+
expect(html).toContain("pod checkout-abc12's workload");
|
|
1884
|
+
expect(html).toContain("Show all demand");
|
|
1885
|
+
expect(html).toContain("for this workload");
|
|
1886
|
+
expect(html).not.toContain("showing");
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
it("reports a true zero when the bridged pod has no pending demand", () => {
|
|
1890
|
+
const html = renderCapacity("/capacity/demand?pod=payments%2Fcheckout-abc12", (client) =>
|
|
1891
|
+
client.setQueryData(
|
|
1892
|
+
[
|
|
1893
|
+
"capacity",
|
|
1894
|
+
"demand",
|
|
1895
|
+
25,
|
|
1896
|
+
undefined,
|
|
1897
|
+
undefined,
|
|
1898
|
+
undefined,
|
|
1899
|
+
undefined,
|
|
1900
|
+
"payments/checkout-abc12",
|
|
1901
|
+
],
|
|
1902
|
+
{ ...demandResponse(), items: [] },
|
|
1903
|
+
),
|
|
1904
|
+
);
|
|
1905
|
+
expect(html).toContain("No pending demand for pod checkout-abc12's workload");
|
|
1906
|
+
expect(html).toContain("true zero");
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it("groups pending pods by scheduling signature with pool evaluations", () => {
|
|
1910
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1911
|
+
client.setQueryData(
|
|
1912
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1913
|
+
demandResponse(),
|
|
1914
|
+
),
|
|
1915
|
+
);
|
|
1916
|
+
expect(html).toContain("Demand");
|
|
1917
|
+
expect(html).toContain("Pending pods grouped by scheduling signature");
|
|
1918
|
+
expect(html).toContain("checkout");
|
|
1919
|
+
expect(html).toContain("Pool evaluations");
|
|
1920
|
+
expect(html).toContain(
|
|
1921
|
+
"declared compatibility, not a scheduling guarantee",
|
|
1922
|
+
);
|
|
1923
|
+
});
|
|
1924
|
+
|
|
1925
|
+
it("shows structured capacity-linked issues on the demand group", () => {
|
|
1926
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1927
|
+
client.setQueryData(
|
|
1928
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1929
|
+
demandResponse({
|
|
1930
|
+
items: [
|
|
1931
|
+
{
|
|
1932
|
+
...demandGroup,
|
|
1933
|
+
issues: [
|
|
1934
|
+
{
|
|
1935
|
+
id: "capacity-unschedulable",
|
|
1936
|
+
severity: "warning",
|
|
1937
|
+
source: "scheduling",
|
|
1938
|
+
category: "unschedulable",
|
|
1939
|
+
category_group: "scheduling",
|
|
1940
|
+
grouping_scope: "workload",
|
|
1941
|
+
group: "apps",
|
|
1942
|
+
kind: "Deployment",
|
|
1943
|
+
namespace: "payments",
|
|
1944
|
+
name: "checkout",
|
|
1945
|
+
reason: "NodePoolConstraintMismatch",
|
|
1946
|
+
cause:
|
|
1947
|
+
"The pending pods require a NodePool label no compatible pool declares.",
|
|
1948
|
+
action:
|
|
1949
|
+
"Review the workload selector or add a compatible NodePool.",
|
|
1950
|
+
capacity_relevant: true,
|
|
1951
|
+
count: 2,
|
|
1952
|
+
members: [
|
|
1953
|
+
{
|
|
1954
|
+
kind: "Pod",
|
|
1955
|
+
namespace: "payments",
|
|
1956
|
+
name: "checkout-abc",
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
kind: "Pod",
|
|
1960
|
+
namespace: "payments",
|
|
1961
|
+
name: "checkout-def",
|
|
1962
|
+
},
|
|
1963
|
+
],
|
|
1964
|
+
},
|
|
1965
|
+
],
|
|
1966
|
+
},
|
|
1967
|
+
],
|
|
1968
|
+
}),
|
|
1969
|
+
),
|
|
1970
|
+
);
|
|
1971
|
+
expect(html).toContain("Capacity diagnosis");
|
|
1972
|
+
expect(html).toContain("NodePool Constraint Mismatch");
|
|
1973
|
+
expect(html).toContain("Cause:");
|
|
1974
|
+
expect(html).toContain("Review the workload selector");
|
|
1975
|
+
expect(html).toMatch(/2(?:<!-- -->)? affected/);
|
|
1976
|
+
expect(html).toMatch(/Pod(?:<!-- -->)?\/(?:<!-- -->)?checkout-def/);
|
|
1977
|
+
});
|
|
1978
|
+
|
|
1979
|
+
it("takes pill and header counts from the server summary, not the page", () => {
|
|
1980
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1981
|
+
client.setQueryData(
|
|
1982
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1983
|
+
demandResponse(),
|
|
1984
|
+
),
|
|
1985
|
+
);
|
|
1986
|
+
// Page holds 1 group / 3 pods; summary says 27 total, 12 awaiting, 10 blocked.
|
|
1987
|
+
expect(html).toContain("All states · 27");
|
|
1988
|
+
expect(html).toContain("Awaiting capacity · 12");
|
|
1989
|
+
expect(html).toContain("Blocked · 10");
|
|
1990
|
+
// Reconcile header uses the summary total as the denominator, not the page.
|
|
1991
|
+
expect(html).toContain("showing 3 of 27 pending pods in 1 of 9 groups");
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it("never fabricates zeros when the server omits the summary", () => {
|
|
1995
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
1996
|
+
client.setQueryData(
|
|
1997
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
1998
|
+
demandResponse({ summary: undefined }),
|
|
1999
|
+
),
|
|
2000
|
+
);
|
|
2001
|
+
// Pills render as plain labels — no "· N" count, never a fabricated "· 0".
|
|
2002
|
+
expect(html).toContain("All states");
|
|
2003
|
+
expect(html).not.toContain("All states ·");
|
|
2004
|
+
expect(html).not.toContain("Blocked ·");
|
|
2005
|
+
// No authoritative total is claimed from page-local counts.
|
|
2006
|
+
expect(html).not.toContain("showing");
|
|
2007
|
+
expect(html).toContain("Pending pods grouped by scheduling signature");
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it("offers a URL-backed NodePool evaluation perspective", () => {
|
|
2011
|
+
const html = renderCapacity(
|
|
2012
|
+
"/capacity/demand?state=blocked&pool=spot",
|
|
2013
|
+
(client) => {
|
|
2014
|
+
client.setQueryData(
|
|
2015
|
+
["capacity", "demand", 25, undefined, "blocked", "spot", undefined, undefined],
|
|
2016
|
+
demandResponse(),
|
|
2017
|
+
);
|
|
2018
|
+
client.setQueryData(
|
|
2019
|
+
["capacity", "pools", 100, undefined],
|
|
2020
|
+
poolListResponse(["default", "spot"]),
|
|
2021
|
+
);
|
|
2022
|
+
},
|
|
2023
|
+
);
|
|
2024
|
+
expect(html).toContain("Evaluate against");
|
|
2025
|
+
expect(html).toContain("All NodePools");
|
|
2026
|
+
expect(html).toContain('<option value="spot" selected="">spot</option>');
|
|
2027
|
+
expect(html).toContain("Evaluated against NodePool");
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
it("keeps paginated NodePool discovery available beyond the first 100 pools", () => {
|
|
2031
|
+
const names = Array.from(
|
|
2032
|
+
{ length: 100 },
|
|
2033
|
+
(_, index) => `pool-${String(index).padStart(3, "0")}`,
|
|
2034
|
+
);
|
|
2035
|
+
const html = renderCapacity("/capacity/demand", (client) => {
|
|
2036
|
+
client.setQueryData(
|
|
2037
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
2038
|
+
demandResponse(),
|
|
2039
|
+
);
|
|
2040
|
+
client.setQueryData(
|
|
2041
|
+
["capacity", "pools", 100, undefined],
|
|
2042
|
+
poolListResponse(names, { hasMore: true, nextCursor: "pool-099" }),
|
|
2043
|
+
);
|
|
2044
|
+
});
|
|
2045
|
+
expect(html).toContain("pool-000");
|
|
2046
|
+
expect(html).toContain("pool-099");
|
|
2047
|
+
expect(html).toContain("Load more NodePools…");
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
it("does not block Demand when NodePool option discovery fails", () => {
|
|
2051
|
+
const html = renderCapacity("/capacity/demand", (client) => {
|
|
2052
|
+
client.setQueryData(
|
|
2053
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
2054
|
+
demandResponse(),
|
|
2055
|
+
);
|
|
2056
|
+
const poolQuery = client.getQueryCache().build(client, {
|
|
2057
|
+
queryKey: ["capacity", "pools", 100, undefined],
|
|
2058
|
+
queryFn: async () => poolListResponse([]),
|
|
2059
|
+
});
|
|
2060
|
+
poolQuery.setState({
|
|
2061
|
+
...poolQuery.state,
|
|
2062
|
+
error: new Error("pool list unavailable"),
|
|
2063
|
+
errorUpdateCount: 1,
|
|
2064
|
+
errorUpdatedAt: Date.now(),
|
|
2065
|
+
fetchStatus: "idle",
|
|
2066
|
+
status: "error",
|
|
2067
|
+
});
|
|
2068
|
+
});
|
|
2069
|
+
expect(html).toContain("checkout");
|
|
2070
|
+
expect(html).toContain("All NodePools");
|
|
2071
|
+
expect(html).toContain(
|
|
2072
|
+
"NodePool options unavailable; demand remains available.",
|
|
2073
|
+
);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
it("hedges the state pills when pod coverage is a lower bound", () => {
|
|
2077
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
2078
|
+
client.setQueryData(
|
|
2079
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
2080
|
+
demandResponse({
|
|
2081
|
+
coverage: {
|
|
2082
|
+
...meta.coverage,
|
|
2083
|
+
pods: sourceCoverage(
|
|
2084
|
+
"available",
|
|
2085
|
+
undefined,
|
|
2086
|
+
"all_authorized_namespaces",
|
|
2087
|
+
),
|
|
2088
|
+
},
|
|
2089
|
+
}),
|
|
2090
|
+
),
|
|
2091
|
+
);
|
|
2092
|
+
// Groups outside the authorized namespaces are invisible, so the rollup
|
|
2093
|
+
// counts are floors, not totals.
|
|
2094
|
+
expect(html).toContain("Blocked · ≥10");
|
|
2095
|
+
expect(html).toContain("All states · ≥27");
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
it("leads the signature card with the grouping basis, not the hash", () => {
|
|
2099
|
+
const html = renderCapacity("/capacity/demand", (client) =>
|
|
2100
|
+
client.setQueryData(
|
|
2101
|
+
["capacity", "demand", 25, undefined, undefined, undefined, undefined, undefined],
|
|
2102
|
+
demandResponse(),
|
|
2103
|
+
),
|
|
2104
|
+
);
|
|
2105
|
+
expect(html).toContain(
|
|
2106
|
+
"Grouped by identical per-pod requests · 1 selector",
|
|
2107
|
+
);
|
|
2108
|
+
// The fingerprint hash moved into the tooltip, which SSR does not paint.
|
|
2109
|
+
expect(html).not.toContain("sched-fp:abc123");
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
it("preserves state while changing or clearing pool perspective", () => {
|
|
2113
|
+
const selected = updateDemandSearchParam("?state=blocked", "pool", "spot");
|
|
2114
|
+
expect(new URLSearchParams(selected).get("state")).toBe("blocked");
|
|
2115
|
+
expect(new URLSearchParams(selected).get("pool")).toBe("spot");
|
|
2116
|
+
|
|
2117
|
+
const cleared = updateDemandSearchParam(selected, "pool", undefined);
|
|
2118
|
+
expect(new URLSearchParams(cleared).get("state")).toBe("blocked");
|
|
2119
|
+
expect(new URLSearchParams(cleared).has("pool")).toBe(false);
|
|
2120
|
+
});
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
describe("CapacityView activity", () => {
|
|
2124
|
+
it("renders the window line, type rollup pills, and demoted provenance", () => {
|
|
2125
|
+
const html = renderCapacity("/capacity/activity", (client) =>
|
|
2126
|
+
client.setQueryData(
|
|
2127
|
+
[
|
|
2128
|
+
"capacity",
|
|
2129
|
+
"activity",
|
|
2130
|
+
50,
|
|
2131
|
+
undefined,
|
|
2132
|
+
undefined,
|
|
2133
|
+
undefined,
|
|
2134
|
+
undefined,
|
|
2135
|
+
undefined,
|
|
2136
|
+
undefined,
|
|
2137
|
+
],
|
|
2138
|
+
activityResponse(),
|
|
2139
|
+
),
|
|
2140
|
+
);
|
|
2141
|
+
expect(html).toContain("Activity");
|
|
2142
|
+
expect(html).toContain("Recent Karpenter capacity events");
|
|
2143
|
+
// The observation-window card collapsed into a single header line.
|
|
2144
|
+
expect(html).not.toContain("Observation window");
|
|
2145
|
+
expect(html).toContain("Window ");
|
|
2146
|
+
expect(html).toContain("Provisioned a node for pending pods");
|
|
2147
|
+
// Type pills carry whole-window rollup counts, failures called out.
|
|
2148
|
+
expect(html).toContain("All · 3");
|
|
2149
|
+
expect(html).toContain("Provision · 2 · 1 failed");
|
|
2150
|
+
expect(html).toContain("Disruption · 1");
|
|
2151
|
+
// Provenance columns hide behind the toggle; the raw evidence leads.
|
|
2152
|
+
expect(html).toContain("Show provenance");
|
|
2153
|
+
expect(html).not.toContain("Relationship");
|
|
2154
|
+
expect(html).toContain("Raw");
|
|
2155
|
+
});
|
|
2156
|
+
|
|
2157
|
+
it("filters live through the shared SearchBox and pool selector — no Apply submit", () => {
|
|
2158
|
+
const html = renderCapacity("/capacity/activity", (client) =>
|
|
2159
|
+
client.setQueryData(
|
|
2160
|
+
[
|
|
2161
|
+
"capacity",
|
|
2162
|
+
"activity",
|
|
2163
|
+
50,
|
|
2164
|
+
undefined,
|
|
2165
|
+
undefined,
|
|
2166
|
+
undefined,
|
|
2167
|
+
undefined,
|
|
2168
|
+
undefined,
|
|
2169
|
+
undefined,
|
|
2170
|
+
],
|
|
2171
|
+
activityResponse(),
|
|
2172
|
+
),
|
|
2173
|
+
);
|
|
2174
|
+
// The search is the shared SearchBox with an example placeholder — not a
|
|
2175
|
+
// submit-gated <input>.
|
|
2176
|
+
expect(html).toContain("LaunchFailed, node name");
|
|
2177
|
+
// NodePool filter is the shared PoolSelector with an "Any pool" empty option.
|
|
2178
|
+
expect(html).toContain("Any pool");
|
|
2179
|
+
// The Apply-to-commit flow is gone.
|
|
2180
|
+
expect(html).not.toContain("Apply");
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("search filters the loaded episodes client-side — never a server fetch", () => {
|
|
2184
|
+
// The seeded query key carries no search term: `q` narrows the already
|
|
2185
|
+
// loaded page in the client, so typing can never unmount the view or
|
|
2186
|
+
// trigger a refetch.
|
|
2187
|
+
const matched = renderCapacity("/capacity/activity?q=nodeclaim", (client) =>
|
|
2188
|
+
client.setQueryData(
|
|
2189
|
+
[
|
|
2190
|
+
"capacity",
|
|
2191
|
+
"activity",
|
|
2192
|
+
50,
|
|
2193
|
+
undefined,
|
|
2194
|
+
undefined,
|
|
2195
|
+
undefined,
|
|
2196
|
+
undefined,
|
|
2197
|
+
undefined,
|
|
2198
|
+
undefined,
|
|
2199
|
+
],
|
|
2200
|
+
activityResponse(),
|
|
2201
|
+
),
|
|
2202
|
+
);
|
|
2203
|
+
// Matches evidence text ("created a NodeClaim"), case-insensitively.
|
|
2204
|
+
expect(matched).toContain("Provisioned a node for pending pods");
|
|
2205
|
+
|
|
2206
|
+
const unmatched = renderCapacity(
|
|
2207
|
+
"/capacity/activity?q=no-such-episode",
|
|
2208
|
+
(client) =>
|
|
2209
|
+
client.setQueryData(
|
|
2210
|
+
[
|
|
2211
|
+
"capacity",
|
|
2212
|
+
"activity",
|
|
2213
|
+
50,
|
|
2214
|
+
undefined,
|
|
2215
|
+
undefined,
|
|
2216
|
+
undefined,
|
|
2217
|
+
undefined,
|
|
2218
|
+
undefined,
|
|
2219
|
+
undefined,
|
|
2220
|
+
],
|
|
2221
|
+
activityResponse(),
|
|
2222
|
+
),
|
|
2223
|
+
);
|
|
2224
|
+
expect(unmatched).not.toContain("Provisioned a node for pending pods");
|
|
2225
|
+
expect(unmatched).toContain("No loaded episodes match this search");
|
|
2226
|
+
// Type pills keep the whole-window rollup — search narrows the page, not
|
|
2227
|
+
// the window.
|
|
2228
|
+
expect(unmatched).toContain("All · 3");
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
it("hedges the rollup pills when either evidence source is partial", () => {
|
|
2232
|
+
const partial: Partial<CapacityActivityResponse["coverage"]>[] = [
|
|
2233
|
+
// Episodes are correlated from Karpenter's object events as well as the
|
|
2234
|
+
// retained timeline; a bound on either bounds every rollup count.
|
|
2235
|
+
{
|
|
2236
|
+
karpenterObjectEvents: sourceCoverage(
|
|
2237
|
+
"available",
|
|
2238
|
+
undefined,
|
|
2239
|
+
"all_authorized_namespaces",
|
|
2240
|
+
),
|
|
2241
|
+
},
|
|
2242
|
+
{ timeline: sourceCoverage("partial") },
|
|
2243
|
+
];
|
|
2244
|
+
for (const coverage of partial) {
|
|
2245
|
+
const html = renderCapacity("/capacity/activity", (client) =>
|
|
2246
|
+
client.setQueryData(
|
|
2247
|
+
[
|
|
2248
|
+
"capacity",
|
|
2249
|
+
"activity",
|
|
2250
|
+
50,
|
|
2251
|
+
undefined,
|
|
2252
|
+
undefined,
|
|
2253
|
+
undefined,
|
|
2254
|
+
undefined,
|
|
2255
|
+
undefined,
|
|
2256
|
+
undefined,
|
|
2257
|
+
],
|
|
2258
|
+
{
|
|
2259
|
+
...activityResponse(),
|
|
2260
|
+
coverage: { ...meta.coverage, ...coverage },
|
|
2261
|
+
},
|
|
2262
|
+
),
|
|
2263
|
+
);
|
|
2264
|
+
expect(html).toContain("All · ≥3");
|
|
2265
|
+
expect(html).toContain("Provision · ≥2 · ≥1 failed");
|
|
2266
|
+
}
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
it("leaves the rollup pills exact when both evidence sources are clean", () => {
|
|
2270
|
+
const html = renderCapacity("/capacity/activity", (client) =>
|
|
2271
|
+
client.setQueryData(
|
|
2272
|
+
[
|
|
2273
|
+
"capacity",
|
|
2274
|
+
"activity",
|
|
2275
|
+
50,
|
|
2276
|
+
undefined,
|
|
2277
|
+
undefined,
|
|
2278
|
+
undefined,
|
|
2279
|
+
undefined,
|
|
2280
|
+
undefined,
|
|
2281
|
+
undefined,
|
|
2282
|
+
],
|
|
2283
|
+
activityResponse(),
|
|
2284
|
+
),
|
|
2285
|
+
);
|
|
2286
|
+
expect(html).toContain("All · 3");
|
|
2287
|
+
expect(html).not.toContain("All · ≥3");
|
|
2288
|
+
});
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
describe("integrationBlock version skew", () => {
|
|
2292
|
+
it("maps a top-level 404 to the pre-v1.9.0 upgrade message, never 'Unknown error'", () => {
|
|
2293
|
+
const html = renderToString(
|
|
2294
|
+
<>{integrationBlock(undefined, new ApiError("404 page not found", 404), false, "Loading")}</>,
|
|
2295
|
+
);
|
|
2296
|
+
expect(html).toContain("Capacity needs a newer Radar");
|
|
2297
|
+
expect(html).toContain("v1.9.0");
|
|
2298
|
+
expect(html).not.toContain("Capacity unavailable");
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
it("keeps ordinary failures on the generic error state", () => {
|
|
2302
|
+
const html = renderToString(
|
|
2303
|
+
<>{integrationBlock(undefined, new ApiError("boom", 500), false, "Loading")}</>,
|
|
2304
|
+
);
|
|
2305
|
+
expect(html).toContain("Capacity unavailable");
|
|
2306
|
+
expect(html).not.toContain("needs a newer Radar");
|
|
2307
|
+
});
|
|
2308
|
+
});
|