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