@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,961 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useLocation, useNavigate } from "react-router-dom";
|
|
3
|
+
import { AlertTriangle } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
Collapse,
|
|
6
|
+
CollapseChevron,
|
|
7
|
+
WithTooltip,
|
|
8
|
+
type CapacityDemandGroup,
|
|
9
|
+
type CapacityDemandResponse,
|
|
10
|
+
type CapacityDemandState,
|
|
11
|
+
type CapacityEvaluationEvidence,
|
|
12
|
+
type CapacityPoolEvaluation,
|
|
13
|
+
type CapacitySchedulingSignature,
|
|
14
|
+
} from "@skyhook-io/k8s-ui";
|
|
15
|
+
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
16
|
+
import { FilterPill } from "@skyhook-io/k8s-ui";
|
|
17
|
+
import {
|
|
18
|
+
isCapacityCursorInvalidError,
|
|
19
|
+
isNotFoundError,
|
|
20
|
+
useCapacityDemand,
|
|
21
|
+
} from "../../api/client";
|
|
22
|
+
import type { SelectedResource } from "../../types";
|
|
23
|
+
import { refToSelectedResource } from "../../utils/navigation";
|
|
24
|
+
import {
|
|
25
|
+
CapacityFreshness,
|
|
26
|
+
CapacityIssueEvidence,
|
|
27
|
+
DemandStateBadge,
|
|
28
|
+
EmptyState,
|
|
29
|
+
InlineEmpty,
|
|
30
|
+
LinkButton,
|
|
31
|
+
Notice,
|
|
32
|
+
PageControls,
|
|
33
|
+
PoolEvaluationBadge,
|
|
34
|
+
PoolSelector,
|
|
35
|
+
QuantityInline,
|
|
36
|
+
ResourceLink,
|
|
37
|
+
ROW_HOVER,
|
|
38
|
+
ScopeBadges,
|
|
39
|
+
ScrollableContent,
|
|
40
|
+
TABLE_HEAD,
|
|
41
|
+
TABLE_WRAP,
|
|
42
|
+
TBODY,
|
|
43
|
+
TD,
|
|
44
|
+
TH,
|
|
45
|
+
coverageHasObservations,
|
|
46
|
+
coverageIsLowerBound,
|
|
47
|
+
coverageMessage,
|
|
48
|
+
demandStateLabel,
|
|
49
|
+
errorMessage,
|
|
50
|
+
formatTimestamp,
|
|
51
|
+
humanizeCode,
|
|
52
|
+
identityKey,
|
|
53
|
+
integrationBlock,
|
|
54
|
+
namespaceCoverageDescription,
|
|
55
|
+
quantityText,
|
|
56
|
+
useCapacityCursorRecovery,
|
|
57
|
+
useCapacityPagination,
|
|
58
|
+
} from "./shared";
|
|
59
|
+
|
|
60
|
+
const STATE_PILLS: [CapacityDemandState | undefined, string][] = [
|
|
61
|
+
[undefined, "All states"],
|
|
62
|
+
["waiting_for_scheduler", "Waiting for scheduler"],
|
|
63
|
+
["held", "Held by scheduling gate"],
|
|
64
|
+
["awaiting_capacity", "Awaiting capacity"],
|
|
65
|
+
["blocked", "Blocked"],
|
|
66
|
+
["unknown", "Unknown"],
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export function updateDemandSearchParam(
|
|
70
|
+
search: string,
|
|
71
|
+
key: "pool" | "state" | "owner" | "pod",
|
|
72
|
+
value: string | undefined,
|
|
73
|
+
): string {
|
|
74
|
+
const params = new URLSearchParams(search);
|
|
75
|
+
if (value) params.set(key, value);
|
|
76
|
+
else params.delete(key);
|
|
77
|
+
const next = params.toString();
|
|
78
|
+
return next ? `?${next}` : "";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// demandGroupVerdict synthesizes the one-line answer for the archetypal case:
|
|
82
|
+
// every evaluated pool ruled the group out. The dominant leading reason across
|
|
83
|
+
// incompatible pools headlines; the predicate tables below stay the receipts.
|
|
84
|
+
// Anything short of all-pools-incompatible keeps the neutral counts line —
|
|
85
|
+
// declared compatibility is not a scheduling guarantee, and a synthesized
|
|
86
|
+
// verdict must not overclaim past what the evidence supports.
|
|
87
|
+
export function demandGroupVerdict(group: CapacityDemandGroup): {
|
|
88
|
+
explanation: string;
|
|
89
|
+
predicateCount: number;
|
|
90
|
+
poolCount: number;
|
|
91
|
+
} | null {
|
|
92
|
+
const counts = group.poolEvaluationCounts;
|
|
93
|
+
if (counts.declaredCompatible > 0 || counts.incompatible === 0) return null;
|
|
94
|
+
const leading = group.poolEvaluations
|
|
95
|
+
.filter((e) => e.result === "incompatible" && e.evidence.length > 0)
|
|
96
|
+
.map((e) => e.evidence[0]);
|
|
97
|
+
if (leading.length === 0) return null;
|
|
98
|
+
const byPredicate = new Map<string, { count: number; explanation: string }>();
|
|
99
|
+
for (const item of leading) {
|
|
100
|
+
const entry = byPredicate.get(item.predicate);
|
|
101
|
+
if (entry) entry.count += 1;
|
|
102
|
+
else byPredicate.set(item.predicate, { count: 1, explanation: item.explanation });
|
|
103
|
+
}
|
|
104
|
+
let best: { count: number; explanation: string } | undefined;
|
|
105
|
+
for (const entry of byPredicate.values()) {
|
|
106
|
+
if (!best || entry.count > best.count) best = entry;
|
|
107
|
+
}
|
|
108
|
+
if (!best?.explanation) return null;
|
|
109
|
+
return {
|
|
110
|
+
explanation: best.explanation,
|
|
111
|
+
predicateCount: best.count,
|
|
112
|
+
poolCount: counts.incompatible,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function CapacityDemand({
|
|
117
|
+
connectionState,
|
|
118
|
+
onOpenPool,
|
|
119
|
+
onOpenResource,
|
|
120
|
+
onNavigate,
|
|
121
|
+
}: {
|
|
122
|
+
connectionState: "connected" | "disconnected" | "connecting";
|
|
123
|
+
onOpenPool: (name: string) => void;
|
|
124
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
125
|
+
onNavigate: (path: string) => void;
|
|
126
|
+
}) {
|
|
127
|
+
const location = useLocation();
|
|
128
|
+
const navigate = useNavigate();
|
|
129
|
+
const search = new URLSearchParams(location.search);
|
|
130
|
+
const stateValue = search.get("state");
|
|
131
|
+
const stateFilter =
|
|
132
|
+
stateValue &&
|
|
133
|
+
[
|
|
134
|
+
"waiting_for_scheduler",
|
|
135
|
+
"held",
|
|
136
|
+
"awaiting_capacity",
|
|
137
|
+
"blocked",
|
|
138
|
+
"unknown",
|
|
139
|
+
].includes(stateValue)
|
|
140
|
+
? (stateValue as CapacityDemandState)
|
|
141
|
+
: undefined;
|
|
142
|
+
const poolFilter = search.get("pool") || undefined;
|
|
143
|
+
const ownerFilter = search.get("owner") || undefined;
|
|
144
|
+
const ownerFilterName = ownerFilter?.split("/").slice(1).join(" ");
|
|
145
|
+
const podFilter = search.get("pod") || undefined;
|
|
146
|
+
const podFilterLabel = podFilter
|
|
147
|
+
? `pod ${podFilter.split("/")[1] ?? podFilter}'s workload`
|
|
148
|
+
: undefined;
|
|
149
|
+
const subjectFilterLabel = ownerFilterName ?? podFilterLabel;
|
|
150
|
+
const subjectScoped = Boolean(ownerFilter || podFilter);
|
|
151
|
+
const pagination = useCapacityPagination<CapacityDemandResponse>(
|
|
152
|
+
`${location.search}`,
|
|
153
|
+
);
|
|
154
|
+
const query = useCapacityDemand({
|
|
155
|
+
limit: 25,
|
|
156
|
+
cursor: pagination.cursor,
|
|
157
|
+
state: stateFilter,
|
|
158
|
+
pool: poolFilter,
|
|
159
|
+
owner: ownerFilter,
|
|
160
|
+
pod: podFilter,
|
|
161
|
+
});
|
|
162
|
+
const recoveringCursor = useCapacityCursorRecovery(
|
|
163
|
+
query.error,
|
|
164
|
+
pagination.cursor,
|
|
165
|
+
pagination.recover,
|
|
166
|
+
);
|
|
167
|
+
const recoveredCursor = pagination.recovered || recoveringCursor;
|
|
168
|
+
const responseData =
|
|
169
|
+
query.data ?? (recoveredCursor ? pagination.retainedPage : undefined);
|
|
170
|
+
const updateSearchParam = (
|
|
171
|
+
key: "pool" | "state" | "owner" | "pod",
|
|
172
|
+
value: string | undefined,
|
|
173
|
+
) => {
|
|
174
|
+
navigate(
|
|
175
|
+
{
|
|
176
|
+
pathname: location.pathname,
|
|
177
|
+
search: updateDemandSearchParam(location.search, key, value),
|
|
178
|
+
},
|
|
179
|
+
{ replace: true },
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
const clearPoolFilter = () => updateSearchParam("pool", undefined);
|
|
183
|
+
const clearOwnerFilter = () => updateSearchParam("owner", undefined);
|
|
184
|
+
const clearPodFilter = () => updateSearchParam("pod", undefined);
|
|
185
|
+
if (poolFilter && !responseData && isNotFoundError(query.error)) {
|
|
186
|
+
return (
|
|
187
|
+
<EmptyState
|
|
188
|
+
icon={AlertTriangle}
|
|
189
|
+
title="NodePool not found"
|
|
190
|
+
detail={`This link evaluates demand against NodePool “${poolFilter}”, which no longer exists. It may have been removed or belongs to another cluster context.`}
|
|
191
|
+
action={
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
className="btn-brand mt-4 rounded-lg px-3 py-1.5 text-sm"
|
|
195
|
+
onClick={clearPoolFilter}
|
|
196
|
+
>
|
|
197
|
+
Show all pending demand
|
|
198
|
+
</button>
|
|
199
|
+
}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const blocked = integrationBlock(
|
|
204
|
+
responseData,
|
|
205
|
+
query.error,
|
|
206
|
+
query.isLoading,
|
|
207
|
+
"Loading pending capacity demand…",
|
|
208
|
+
);
|
|
209
|
+
if (blocked) return blocked;
|
|
210
|
+
const response = responseData as CapacityDemandResponse;
|
|
211
|
+
|
|
212
|
+
const changeFilter = (next: CapacityDemandState | undefined) =>
|
|
213
|
+
updateSearchParam("state", next);
|
|
214
|
+
|
|
215
|
+
// Page-local counts (numerator of "showing X of N").
|
|
216
|
+
const pagePods = response.items.reduce(
|
|
217
|
+
(total, group) => total + group.podCount,
|
|
218
|
+
0,
|
|
219
|
+
);
|
|
220
|
+
const pageGroups = response.items.length;
|
|
221
|
+
// Authoritative denominator from the server rollup — the full observed
|
|
222
|
+
// population, computed after RBAC/namespace scoping + Karpenter classification
|
|
223
|
+
// but BEFORE the state filter and pagination. Never the current page. Absent
|
|
224
|
+
// while Pod data isn't observed yet — then we show no total rather than a
|
|
225
|
+
// misleading zero.
|
|
226
|
+
const rollup = response.summary;
|
|
227
|
+
// Namespace-scoped pod coverage hides whole groups, so every rollup count is
|
|
228
|
+
// a floor — the pills must say so rather than read as totals.
|
|
229
|
+
const countsAreLowerBound = coverageIsLowerBound(response.coverage.pods);
|
|
230
|
+
const denom = rollup
|
|
231
|
+
? stateFilter
|
|
232
|
+
? rollup.byState[stateFilter]
|
|
233
|
+
: rollup.total
|
|
234
|
+
: undefined;
|
|
235
|
+
const noun = stateFilter
|
|
236
|
+
? `${demandStateLabel(stateFilter).toLowerCase()} pods`
|
|
237
|
+
: "pending pods";
|
|
238
|
+
const nounFor = (count: number) =>
|
|
239
|
+
count === 1 ? noun.replace(/pods$/, "pod") : noun;
|
|
240
|
+
// Under an owner filter the rollup denominator describes ALL observed
|
|
241
|
+
// demand, not this workload — "showing X of Y" would imply Y pods exist for
|
|
242
|
+
// this owner. Owner-scoped views get honest page-local counts instead.
|
|
243
|
+
const headerSummary = subjectScoped
|
|
244
|
+
? pagePods > 0 || pageGroups > 0
|
|
245
|
+
? `${pagePods}${response.page.hasMore ? "+" : ""} ${nounFor(pagePods)} in ${pageGroups}${response.page.hasMore ? "+" : ""} ${pageGroups === 1 ? "group" : "groups"} for this workload`
|
|
246
|
+
: null
|
|
247
|
+
: denom
|
|
248
|
+
? denom.podCount === pagePods && denom.groupCount === pageGroups
|
|
249
|
+
? `${denom.podCount} ${nounFor(denom.podCount)} in ${denom.groupCount} ${denom.groupCount === 1 ? "group" : "groups"}`
|
|
250
|
+
: `showing ${pagePods} of ${denom.podCount} ${nounFor(denom.podCount)} in ${pageGroups} of ${denom.groupCount} groups`
|
|
251
|
+
: null;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<ScrollableContent>
|
|
255
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
256
|
+
<div className="min-w-0">
|
|
257
|
+
<div className="flex flex-wrap items-baseline gap-3">
|
|
258
|
+
<LinkButton onClick={() => onNavigate("/capacity")}>
|
|
259
|
+
← Capacity
|
|
260
|
+
</LinkButton>
|
|
261
|
+
<h1 className="text-lg font-semibold text-theme-text-primary">
|
|
262
|
+
Demand
|
|
263
|
+
</h1>
|
|
264
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
265
|
+
{headerSummary
|
|
266
|
+
? `Pending pods grouped by scheduling signature · ${headerSummary}`
|
|
267
|
+
: "Pending pods grouped by scheduling signature"}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
<div className="mt-2">
|
|
271
|
+
<ScopeBadges coverage={response.coverage} />
|
|
272
|
+
</div>
|
|
273
|
+
<p className="mt-1.5 text-xs text-theme-text-tertiary">
|
|
274
|
+
Evaluations cover Karpenter NodePools only — “blocked” means no
|
|
275
|
+
NodePool can take it, not that no node can.
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="flex items-center gap-3">
|
|
279
|
+
<LinkButton
|
|
280
|
+
onClick={() =>
|
|
281
|
+
onNavigate(
|
|
282
|
+
poolFilter
|
|
283
|
+
? `/capacity/activity?pool=${encodeURIComponent(poolFilter)}`
|
|
284
|
+
: "/capacity/activity",
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
>
|
|
288
|
+
Activity timeline →
|
|
289
|
+
</LinkButton>
|
|
290
|
+
<CapacityFreshness
|
|
291
|
+
meta={response}
|
|
292
|
+
query={query}
|
|
293
|
+
connectionState={connectionState}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{recoveredCursor && (
|
|
299
|
+
<Notice>Capacity data changed; showing the latest results.</Notice>
|
|
300
|
+
)}
|
|
301
|
+
{query.error && !isCapacityCursorInvalidError(query.error) && (
|
|
302
|
+
<Notice>
|
|
303
|
+
Refresh failed; showing the last successful capacity snapshot.{" "}
|
|
304
|
+
{errorMessage(query.error)}
|
|
305
|
+
</Notice>
|
|
306
|
+
)}
|
|
307
|
+
{coverageIsLowerBound(response.coverage.pods) && (
|
|
308
|
+
<Notice>
|
|
309
|
+
≥ {coverageMessage(response.coverage.pods, "Pending pod demand")}.
|
|
310
|
+
Groups outside {namespaceCoverageDescription(response.coverage.pods)}{" "}
|
|
311
|
+
are invisible — every count on this page is a lower bound, not a
|
|
312
|
+
total.
|
|
313
|
+
</Notice>
|
|
314
|
+
)}
|
|
315
|
+
{ownerFilter && (
|
|
316
|
+
<Notice>
|
|
317
|
+
Showing pending demand for{" "}
|
|
318
|
+
<span className="font-medium text-theme-text-primary">
|
|
319
|
+
{ownerFilterName}
|
|
320
|
+
</span>{" "}
|
|
321
|
+
only — the state counts below describe all observed demand, not just
|
|
322
|
+
this workload.{" "}
|
|
323
|
+
<LinkButton className="inline" onClick={clearOwnerFilter}>
|
|
324
|
+
Show all demand
|
|
325
|
+
</LinkButton>
|
|
326
|
+
</Notice>
|
|
327
|
+
)}
|
|
328
|
+
{podFilter && (
|
|
329
|
+
<Notice>
|
|
330
|
+
Showing pending demand for{" "}
|
|
331
|
+
<span className="font-medium text-theme-text-primary">
|
|
332
|
+
{podFilterLabel}
|
|
333
|
+
</span>{" "}
|
|
334
|
+
only — the state counts below describe all observed demand, not just
|
|
335
|
+
this workload.{" "}
|
|
336
|
+
<LinkButton className="inline" onClick={clearPodFilter}>
|
|
337
|
+
Show all demand
|
|
338
|
+
</LinkButton>
|
|
339
|
+
</Notice>
|
|
340
|
+
)}
|
|
341
|
+
{poolFilter && (
|
|
342
|
+
<Notice>
|
|
343
|
+
Evaluated against NodePool{" "}
|
|
344
|
+
<span className="font-medium text-theme-text-primary">
|
|
345
|
+
{poolFilter}
|
|
346
|
+
</span>{" "}
|
|
347
|
+
— each group's evaluation shows this pool's perspective; states and
|
|
348
|
+
counts stay fleet-wide.{" "}
|
|
349
|
+
<LinkButton className="inline" onClick={clearPoolFilter}>
|
|
350
|
+
Evaluate against all NodePools
|
|
351
|
+
</LinkButton>
|
|
352
|
+
</Notice>
|
|
353
|
+
)}
|
|
354
|
+
|
|
355
|
+
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
356
|
+
<div
|
|
357
|
+
className="flex flex-wrap gap-1.5"
|
|
358
|
+
aria-label="Filter demand by state"
|
|
359
|
+
>
|
|
360
|
+
{STATE_PILLS.map(([state, label]) => {
|
|
361
|
+
// Counts come from the server rollup (stable across the active filter),
|
|
362
|
+
// not the current page. Omitted entirely when summary is absent — a
|
|
363
|
+
// pill never shows a fabricated "· 0".
|
|
364
|
+
const counts = rollup
|
|
365
|
+
? state === undefined
|
|
366
|
+
? rollup.total
|
|
367
|
+
: rollup.byState[state]
|
|
368
|
+
: undefined;
|
|
369
|
+
return (
|
|
370
|
+
<FilterPill
|
|
371
|
+
key={state ?? "all"}
|
|
372
|
+
label={
|
|
373
|
+
counts
|
|
374
|
+
? `${label} · ${countsAreLowerBound ? "≥" : ""}${counts.podCount}`
|
|
375
|
+
: label
|
|
376
|
+
}
|
|
377
|
+
active={stateFilter === state}
|
|
378
|
+
onClick={() => changeFilter(state)}
|
|
379
|
+
/>
|
|
380
|
+
);
|
|
381
|
+
})}
|
|
382
|
+
</div>
|
|
383
|
+
<PoolSelector
|
|
384
|
+
key={`${response.clusterContext.contextName}`}
|
|
385
|
+
pool={poolFilter}
|
|
386
|
+
onChange={(pool) => updateSearchParam("pool", pool)}
|
|
387
|
+
label="Evaluate against"
|
|
388
|
+
emptyLabel="All NodePools"
|
|
389
|
+
unavailableLabel="NodePool options unavailable; demand remains available."
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
{response.items.length > 0 ? (
|
|
394
|
+
<div className="space-y-3">
|
|
395
|
+
{response.items.map((group, index) => (
|
|
396
|
+
<DemandGroupCard
|
|
397
|
+
key={group.id}
|
|
398
|
+
group={group}
|
|
399
|
+
defaultExpanded={index === 0}
|
|
400
|
+
onOpenPool={onOpenPool}
|
|
401
|
+
onOpenResource={onOpenResource}
|
|
402
|
+
/>
|
|
403
|
+
))}
|
|
404
|
+
</div>
|
|
405
|
+
) : coverageHasObservations(response.coverage.pods) ? (
|
|
406
|
+
subjectScoped ? (
|
|
407
|
+
<InlineEmpty
|
|
408
|
+
title={`No pending demand for ${subjectFilterLabel}`}
|
|
409
|
+
detail="Its pods may have scheduled since this link was created — this is a true zero across all pending demand, not a paging artifact."
|
|
410
|
+
/>
|
|
411
|
+
) : (
|
|
412
|
+
stateFilter ? (
|
|
413
|
+
<InlineEmpty
|
|
414
|
+
title={`No groups in “${demandStateLabel(stateFilter)}”`}
|
|
415
|
+
detail="Pending demand, if any, is in other states — the pills above carry the counts. A true zero, not a paging artifact."
|
|
416
|
+
/>
|
|
417
|
+
) : countsAreLowerBound ? (
|
|
418
|
+
<InlineEmpty
|
|
419
|
+
title="No pending demand observed"
|
|
420
|
+
detail="Nothing is pending in the namespaces you can read — pods outside them are not visible here."
|
|
421
|
+
/>
|
|
422
|
+
) : (
|
|
423
|
+
<InlineEmpty
|
|
424
|
+
title="Nothing is pending"
|
|
425
|
+
detail="Every pod the scheduler knows about is placed — a true zero, not a paging artifact."
|
|
426
|
+
/>
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
) : (
|
|
430
|
+
<InlineEmpty
|
|
431
|
+
title="Demand unavailable"
|
|
432
|
+
detail={coverageMessage(response.coverage.pods, "Pending pod demand")}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{(pagination.history.length > 0 || response.page.hasMore) && (
|
|
437
|
+
<PageControls
|
|
438
|
+
page={pagination.history.length + 1}
|
|
439
|
+
hasPrevious={pagination.history.length > 0}
|
|
440
|
+
hasNext={response.page.hasMore && Boolean(response.page.nextCursor)}
|
|
441
|
+
busy={query.isFetching}
|
|
442
|
+
onPrevious={() => pagination.goBack(response)}
|
|
443
|
+
onNext={() =>
|
|
444
|
+
response.page.nextCursor &&
|
|
445
|
+
pagination.goNext(response.page.nextCursor, response)
|
|
446
|
+
}
|
|
447
|
+
/>
|
|
448
|
+
)}
|
|
449
|
+
</ScrollableContent>
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** What actually binds these pods into one group, in words. The fingerprint
|
|
454
|
+
* hash identifies the group but tells an operator nothing. */
|
|
455
|
+
function signatureSummary(signature: CapacitySchedulingSignature): string {
|
|
456
|
+
const parts: string[] = ["identical per-pod requests"];
|
|
457
|
+
const constraints = signature.constraintsMeta.total;
|
|
458
|
+
const tolerations = signature.tolerationsMeta.total;
|
|
459
|
+
if (constraints > 0)
|
|
460
|
+
parts.push(
|
|
461
|
+
`${constraints} ${constraints === 1 ? "selector" : "selectors"}`,
|
|
462
|
+
);
|
|
463
|
+
if (tolerations > 0)
|
|
464
|
+
parts.push(
|
|
465
|
+
`${tolerations} ${tolerations === 1 ? "toleration" : "tolerations"}`,
|
|
466
|
+
);
|
|
467
|
+
if (constraints === 0 && tolerations === 0)
|
|
468
|
+
parts.push("no selectors or tolerations");
|
|
469
|
+
return `Grouped by ${parts.join(" · ")}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function DemandGroupCard({
|
|
473
|
+
group,
|
|
474
|
+
defaultExpanded,
|
|
475
|
+
onOpenPool,
|
|
476
|
+
onOpenResource,
|
|
477
|
+
}: {
|
|
478
|
+
group: CapacityDemandGroup;
|
|
479
|
+
defaultExpanded: boolean;
|
|
480
|
+
onOpenPool: (name: string) => void;
|
|
481
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
482
|
+
}) {
|
|
483
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
484
|
+
const counts = group.poolEvaluationCounts;
|
|
485
|
+
const compatSummary = `${counts.declaredCompatible} compatible · ${counts.incompatible} incompatible · ${counts.unknown} unknown`;
|
|
486
|
+
const verdict = demandGroupVerdict(group);
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<article className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
aria-expanded={expanded}
|
|
493
|
+
className={`flex w-full items-start gap-2 px-4 py-3 text-left ${ROW_HOVER}`}
|
|
494
|
+
onClick={() => setExpanded((current) => !current)}
|
|
495
|
+
>
|
|
496
|
+
<CollapseChevron open={expanded} className="mt-0.5 h-4 w-4" />
|
|
497
|
+
<div className="min-w-0 flex-1">
|
|
498
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
499
|
+
<span className="min-w-[90px]">
|
|
500
|
+
<DemandStateBadge state={group.state} />
|
|
501
|
+
</span>
|
|
502
|
+
{group.owner ? (
|
|
503
|
+
<>
|
|
504
|
+
<Badge tone="structural" size="sm">
|
|
505
|
+
{group.owner.kind}
|
|
506
|
+
</Badge>
|
|
507
|
+
<span
|
|
508
|
+
role="link"
|
|
509
|
+
tabIndex={0}
|
|
510
|
+
className="truncate font-medium text-accent-text hover:underline"
|
|
511
|
+
onClick={(event) => {
|
|
512
|
+
event.stopPropagation();
|
|
513
|
+
onOpenResource(refToSelectedResource(group.owner!));
|
|
514
|
+
}}
|
|
515
|
+
onKeyDown={(event) => {
|
|
516
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
event.stopPropagation();
|
|
519
|
+
onOpenResource(refToSelectedResource(group.owner!));
|
|
520
|
+
}
|
|
521
|
+
}}
|
|
522
|
+
>
|
|
523
|
+
{group.owner.name}
|
|
524
|
+
</span>
|
|
525
|
+
</>
|
|
526
|
+
) : (
|
|
527
|
+
<span className="font-medium text-theme-text-primary">
|
|
528
|
+
Pod without a workload owner
|
|
529
|
+
</span>
|
|
530
|
+
)}
|
|
531
|
+
<span className="font-mono text-xs text-theme-text-tertiary">
|
|
532
|
+
ns/{group.namespace}
|
|
533
|
+
</span>
|
|
534
|
+
<Badge tone="structural" size="sm">
|
|
535
|
+
{group.podCount} {group.podCount === 1 ? "pod" : "pods"}
|
|
536
|
+
</Badge>
|
|
537
|
+
{group.nominatedPodCount != null && group.nominatedPodCount > 0 && (
|
|
538
|
+
<WithTooltip
|
|
539
|
+
tip={`${group.nominatedPodCount} ${
|
|
540
|
+
group.nominatedPodCount === 1 ? "pod holds" : "pods hold"
|
|
541
|
+
} a scheduler node nomination — preemption in progress. Nomination is best-effort and can go stale; it does not change the group's state.`}
|
|
542
|
+
>
|
|
543
|
+
<Badge tone="note" size="sm" className="cursor-help">
|
|
544
|
+
{`${group.nominatedPodCount} nominated`}
|
|
545
|
+
</Badge>
|
|
546
|
+
</WithTooltip>
|
|
547
|
+
)}
|
|
548
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
549
|
+
{quantityText(group.aggregateRequests) ?? "No requests reported"}
|
|
550
|
+
</span>
|
|
551
|
+
</div>
|
|
552
|
+
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 text-xs text-theme-text-tertiary">
|
|
553
|
+
<span>
|
|
554
|
+
first {formatTimestamp(group.firstSeen)} · last{" "}
|
|
555
|
+
{formatTimestamp(group.lastSeen)}
|
|
556
|
+
</span>
|
|
557
|
+
{group.schedulerReasons.length > 0 && (
|
|
558
|
+
<span className="min-w-0 truncate text-theme-text-secondary">
|
|
559
|
+
e.g. {group.schedulerReasons[0].message}
|
|
560
|
+
</span>
|
|
561
|
+
)}
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
</button>
|
|
565
|
+
|
|
566
|
+
<Collapse open={expanded}>
|
|
567
|
+
<div className="border-t border-theme-border">
|
|
568
|
+
<div className="px-4 pt-3 text-xs text-theme-text-tertiary">
|
|
569
|
+
Pools:{" "}
|
|
570
|
+
<span className="text-theme-text-secondary">{compatSummary}</span>{" "}
|
|
571
|
+
<WithTooltip tip="Declared-compatible means the pool's declared requirements, taints and resources do not rule out these pods. It is not a scheduling, capacity or availability guarantee.">
|
|
572
|
+
<span className="cursor-help text-theme-text-tertiary">
|
|
573
|
+
— declared compatibility, not a scheduling guarantee
|
|
574
|
+
</span>
|
|
575
|
+
</WithTooltip>
|
|
576
|
+
</div>
|
|
577
|
+
|
|
578
|
+
{verdict && (
|
|
579
|
+
<div className="px-4 pt-2 text-sm">
|
|
580
|
+
<span className="font-medium text-warning-text">
|
|
581
|
+
No pool can take this group
|
|
582
|
+
</span>{" "}
|
|
583
|
+
<span className="text-theme-text-secondary">
|
|
584
|
+
— {verdict.explanation}
|
|
585
|
+
{verdict.poolCount > 1
|
|
586
|
+
? ` (${verdict.predicateCount} of ${verdict.poolCount} incompatible pools)`
|
|
587
|
+
: ""}
|
|
588
|
+
</span>
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
591
|
+
|
|
592
|
+
{group.issues.length > 0 && (
|
|
593
|
+
<div className="px-4 pt-3">
|
|
594
|
+
<div className="rounded-lg border border-theme-border bg-theme-base/50 px-3 py-3">
|
|
595
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
596
|
+
Capacity diagnosis
|
|
597
|
+
</h3>
|
|
598
|
+
<div className="mt-2 space-y-2">
|
|
599
|
+
{group.issues.map((issue) => (
|
|
600
|
+
<div
|
|
601
|
+
key={issue.id}
|
|
602
|
+
className="flex items-start gap-2 text-xs"
|
|
603
|
+
>
|
|
604
|
+
<Badge
|
|
605
|
+
severity={
|
|
606
|
+
issue.severity === "critical" ? "error" : "warning"
|
|
607
|
+
}
|
|
608
|
+
size="sm"
|
|
609
|
+
>
|
|
610
|
+
{issue.severity}
|
|
611
|
+
</Badge>
|
|
612
|
+
<div className="min-w-0 text-theme-text-secondary">
|
|
613
|
+
<p>
|
|
614
|
+
<span className="font-medium text-theme-text-primary">
|
|
615
|
+
{humanizeCode(issue.reason)}
|
|
616
|
+
</span>
|
|
617
|
+
{issue.message ? ` — ${issue.message}` : ""}
|
|
618
|
+
</p>
|
|
619
|
+
{issue.cause && (
|
|
620
|
+
<p className="mt-1">Cause: {issue.cause}</p>
|
|
621
|
+
)}
|
|
622
|
+
{issue.action && (
|
|
623
|
+
<p className="mt-1">Next: {issue.action}</p>
|
|
624
|
+
)}
|
|
625
|
+
<CapacityIssueEvidence
|
|
626
|
+
issue={issue}
|
|
627
|
+
onOpenResource={onOpenResource}
|
|
628
|
+
/>
|
|
629
|
+
</div>
|
|
630
|
+
</div>
|
|
631
|
+
))}
|
|
632
|
+
</div>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
)}
|
|
636
|
+
|
|
637
|
+
<div className="grid gap-x-6 gap-y-4 px-4 py-4 lg:grid-cols-2">
|
|
638
|
+
<div className="space-y-4">
|
|
639
|
+
<div>
|
|
640
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
641
|
+
Scheduling signature
|
|
642
|
+
</h3>
|
|
643
|
+
<WithTooltip tip={`Grouping fingerprint: ${group.fingerprint}`}>
|
|
644
|
+
<div className="mt-1.5 w-fit cursor-help text-[11px] text-theme-text-tertiary">
|
|
645
|
+
{signatureSummary(group.schedulingSignature)}
|
|
646
|
+
</div>
|
|
647
|
+
</WithTooltip>
|
|
648
|
+
<div className="mt-2 text-xs font-medium text-theme-text-tertiary">
|
|
649
|
+
Per pod
|
|
650
|
+
</div>
|
|
651
|
+
<div className="mt-1">
|
|
652
|
+
<QuantityInline
|
|
653
|
+
observation={group.perPodRequests}
|
|
654
|
+
empty="No requests reported"
|
|
655
|
+
/>
|
|
656
|
+
</div>
|
|
657
|
+
<div className="mt-3 text-xs font-medium text-theme-text-tertiary">
|
|
658
|
+
Selectors
|
|
659
|
+
</div>
|
|
660
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
661
|
+
{group.schedulingSignature.constraints.length > 0 ? (
|
|
662
|
+
group.schedulingSignature.constraints.map((constraint) => (
|
|
663
|
+
<Badge
|
|
664
|
+
key={`${constraint.predicate}-${constraint.sourcePath}`}
|
|
665
|
+
tone="structural"
|
|
666
|
+
size="sm"
|
|
667
|
+
>
|
|
668
|
+
{constraint.key || constraint.predicate}
|
|
669
|
+
{constraint.operator ? ` ${constraint.operator}` : ""}
|
|
670
|
+
{constraint.values.length > 0
|
|
671
|
+
? ` ${constraint.values.join(", ")}`
|
|
672
|
+
: ""}
|
|
673
|
+
</Badge>
|
|
674
|
+
))
|
|
675
|
+
) : (
|
|
676
|
+
<span className="text-sm text-theme-text-secondary">
|
|
677
|
+
None
|
|
678
|
+
</span>
|
|
679
|
+
)}
|
|
680
|
+
</div>
|
|
681
|
+
{group.schedulingSignature.constraintsMeta.truncated && (
|
|
682
|
+
<p className="mt-1.5 text-[11px] text-theme-text-tertiary">
|
|
683
|
+
Showing {group.schedulingSignature.constraintsMeta.returned}{" "}
|
|
684
|
+
of {group.schedulingSignature.constraintsMeta.total}{" "}
|
|
685
|
+
constraints.
|
|
686
|
+
</p>
|
|
687
|
+
)}
|
|
688
|
+
<div className="mt-3 text-xs font-medium text-theme-text-tertiary">
|
|
689
|
+
Tolerations
|
|
690
|
+
</div>
|
|
691
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
692
|
+
{group.schedulingSignature.tolerations.length > 0 ? (
|
|
693
|
+
group.schedulingSignature.tolerations.map(
|
|
694
|
+
(toleration, index) => (
|
|
695
|
+
<Badge
|
|
696
|
+
key={`${toleration.key ?? "*"}-${toleration.effect ?? "*"}-${index}`}
|
|
697
|
+
tone="structural"
|
|
698
|
+
size="sm"
|
|
699
|
+
>
|
|
700
|
+
{toleration.key || "*"}
|
|
701
|
+
{toleration.value ? `=${toleration.value}` : ""}
|
|
702
|
+
{toleration.effect ? ` · ${toleration.effect}` : ""}
|
|
703
|
+
</Badge>
|
|
704
|
+
),
|
|
705
|
+
)
|
|
706
|
+
) : (
|
|
707
|
+
<span className="text-sm text-theme-text-secondary">
|
|
708
|
+
None
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
711
|
+
</div>
|
|
712
|
+
{group.schedulingSignature.tolerationsMeta.truncated && (
|
|
713
|
+
<p className="mt-1.5 text-[11px] text-theme-text-tertiary">
|
|
714
|
+
Showing {group.schedulingSignature.tolerationsMeta.returned}{" "}
|
|
715
|
+
of {group.schedulingSignature.tolerationsMeta.total}{" "}
|
|
716
|
+
tolerations.
|
|
717
|
+
</p>
|
|
718
|
+
)}
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
|
|
722
|
+
<div className="space-y-4">
|
|
723
|
+
<div>
|
|
724
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
725
|
+
Evidence
|
|
726
|
+
</h3>
|
|
727
|
+
{group.schedulerReasons.length > 0 ? (
|
|
728
|
+
<div className="mt-2 space-y-2">
|
|
729
|
+
{group.schedulerReasons.map((reason) => (
|
|
730
|
+
<div
|
|
731
|
+
key={`${reason.source}-${reason.code}`}
|
|
732
|
+
className="rounded-lg border border-theme-border bg-theme-base/50 px-3 py-2"
|
|
733
|
+
>
|
|
734
|
+
<div className="flex items-center justify-between gap-2">
|
|
735
|
+
<span className="font-mono text-xs font-medium text-theme-text-primary">
|
|
736
|
+
{reason.code}
|
|
737
|
+
</span>
|
|
738
|
+
<Badge tone="structural" size="sm">
|
|
739
|
+
{reason.count}{" "}
|
|
740
|
+
{reason.count === 1 ? "event" : "events"}
|
|
741
|
+
</Badge>
|
|
742
|
+
</div>
|
|
743
|
+
{reason.message && (
|
|
744
|
+
<p className="mt-1 text-xs text-theme-text-secondary">
|
|
745
|
+
{reason.message}
|
|
746
|
+
</p>
|
|
747
|
+
)}
|
|
748
|
+
<p className="mt-1 text-[11px] text-theme-text-tertiary">
|
|
749
|
+
{reason.source} · latest{" "}
|
|
750
|
+
{formatTimestamp(reason.lastSeen)}
|
|
751
|
+
</p>
|
|
752
|
+
</div>
|
|
753
|
+
))}
|
|
754
|
+
</div>
|
|
755
|
+
) : (
|
|
756
|
+
<p className="mt-2 text-sm text-theme-text-secondary">
|
|
757
|
+
No scheduler reason was observed for this group.
|
|
758
|
+
</p>
|
|
759
|
+
)}
|
|
760
|
+
{group.schedulerReasonsMeta.truncated && (
|
|
761
|
+
<p className="mt-1.5 text-[11px] text-theme-text-tertiary">
|
|
762
|
+
Showing {group.schedulerReasonsMeta.returned} of{" "}
|
|
763
|
+
{group.schedulerReasonsMeta.total} scheduler reasons.
|
|
764
|
+
</p>
|
|
765
|
+
)}
|
|
766
|
+
</div>
|
|
767
|
+
|
|
768
|
+
{(group.pods.length > 0 || group.podsMeta.truncated) && (
|
|
769
|
+
<div>
|
|
770
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
771
|
+
Pods
|
|
772
|
+
</h3>
|
|
773
|
+
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
774
|
+
{group.pods.map((pod) => (
|
|
775
|
+
<ResourceLink
|
|
776
|
+
key={identityKey(pod)}
|
|
777
|
+
identity={pod}
|
|
778
|
+
onOpenResource={onOpenResource}
|
|
779
|
+
/>
|
|
780
|
+
))}
|
|
781
|
+
{group.podsMeta.truncated && (
|
|
782
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
783
|
+
showing {group.podsMeta.returned} of{" "}
|
|
784
|
+
{group.podsMeta.total}
|
|
785
|
+
</span>
|
|
786
|
+
)}
|
|
787
|
+
</div>
|
|
788
|
+
</div>
|
|
789
|
+
)}
|
|
790
|
+
</div>
|
|
791
|
+
</div>
|
|
792
|
+
|
|
793
|
+
<div className="border-t border-theme-border-subtle px-4 py-4">
|
|
794
|
+
<div className="flex items-center justify-between gap-3">
|
|
795
|
+
<h3 className="text-xs font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
796
|
+
Pool evaluations
|
|
797
|
+
</h3>
|
|
798
|
+
<span className="text-[11px] text-theme-text-tertiary">
|
|
799
|
+
— declared compatibility, with evidence
|
|
800
|
+
</span>
|
|
801
|
+
</div>
|
|
802
|
+
{group.poolEvaluations.length > 0 ? (
|
|
803
|
+
<div className="mt-2 space-y-2">
|
|
804
|
+
{group.poolEvaluations.map((evaluation) => (
|
|
805
|
+
<PoolEvaluationCard
|
|
806
|
+
key={`${evaluation.pool.group ?? ""}/${evaluation.pool.name}`}
|
|
807
|
+
evaluation={evaluation}
|
|
808
|
+
onOpenPool={onOpenPool}
|
|
809
|
+
/>
|
|
810
|
+
))}
|
|
811
|
+
{group.poolEvaluationsMeta.truncated && (
|
|
812
|
+
<p className="text-[11px] text-theme-text-tertiary">
|
|
813
|
+
Showing {group.poolEvaluationsMeta.returned} of{" "}
|
|
814
|
+
{group.poolEvaluationsMeta.total} evaluations.
|
|
815
|
+
</p>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
) : (
|
|
819
|
+
<p className="mt-2 text-sm text-theme-text-secondary">
|
|
820
|
+
No NodePools were available for evaluation.
|
|
821
|
+
</p>
|
|
822
|
+
)}
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
</Collapse>
|
|
826
|
+
</article>
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function PoolEvaluationCard({
|
|
831
|
+
evaluation,
|
|
832
|
+
onOpenPool,
|
|
833
|
+
}: {
|
|
834
|
+
evaluation: CapacityPoolEvaluation;
|
|
835
|
+
onOpenPool: (name: string) => void;
|
|
836
|
+
}) {
|
|
837
|
+
const [expanded, setExpanded] = useState(false);
|
|
838
|
+
const predicateCount =
|
|
839
|
+
evaluation.evidence.length + evaluation.unknownPredicates.length;
|
|
840
|
+
return (
|
|
841
|
+
<div className="overflow-hidden rounded-lg border border-theme-border bg-theme-base/40">
|
|
842
|
+
<button
|
|
843
|
+
type="button"
|
|
844
|
+
aria-expanded={expanded}
|
|
845
|
+
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left ${ROW_HOVER}`}
|
|
846
|
+
onClick={() => setExpanded((current) => !current)}
|
|
847
|
+
>
|
|
848
|
+
<CollapseChevron open={expanded} className="h-4 w-4" />
|
|
849
|
+
<span
|
|
850
|
+
role="link"
|
|
851
|
+
tabIndex={0}
|
|
852
|
+
className="font-mono text-sm font-medium text-accent-text hover:underline"
|
|
853
|
+
onClick={(event) => {
|
|
854
|
+
event.stopPropagation();
|
|
855
|
+
onOpenPool(evaluation.pool.name);
|
|
856
|
+
}}
|
|
857
|
+
onKeyDown={(event) => {
|
|
858
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
859
|
+
event.preventDefault();
|
|
860
|
+
event.stopPropagation();
|
|
861
|
+
onOpenPool(evaluation.pool.name);
|
|
862
|
+
}
|
|
863
|
+
}}
|
|
864
|
+
>
|
|
865
|
+
{evaluation.pool.name}
|
|
866
|
+
</span>
|
|
867
|
+
<PoolEvaluationBadge result={evaluation.result} />
|
|
868
|
+
<span className="ml-auto text-[11px] text-theme-text-tertiary">
|
|
869
|
+
{predicateCount} {predicateCount === 1 ? "predicate" : "predicates"}
|
|
870
|
+
{evaluation.evidenceMeta.truncated ? " · truncated" : ""}
|
|
871
|
+
</span>
|
|
872
|
+
</button>
|
|
873
|
+
<Collapse open={expanded}>
|
|
874
|
+
<div className="border-t border-theme-border">
|
|
875
|
+
{evaluation.evidence.length > 0 ||
|
|
876
|
+
evaluation.unknownPredicates.length > 0 ? (
|
|
877
|
+
<div className={TABLE_WRAP}>
|
|
878
|
+
<table className="w-full text-left">
|
|
879
|
+
<thead className={TABLE_HEAD}>
|
|
880
|
+
<tr>
|
|
881
|
+
<th className={TH}>Predicate</th>
|
|
882
|
+
<th className={TH}>Observed</th>
|
|
883
|
+
<th className={TH}>Expected</th>
|
|
884
|
+
<th className={TH}>Confidence</th>
|
|
885
|
+
<th className={TH}>Explanation</th>
|
|
886
|
+
<th className={TH}>Source</th>
|
|
887
|
+
</tr>
|
|
888
|
+
</thead>
|
|
889
|
+
<tbody className={TBODY}>
|
|
890
|
+
{evaluation.evidence.map((evidence, index) => (
|
|
891
|
+
<EvidenceRow
|
|
892
|
+
key={`${evidence.predicate}-${index}`}
|
|
893
|
+
evidence={evidence}
|
|
894
|
+
/>
|
|
895
|
+
))}
|
|
896
|
+
{evaluation.unknownPredicates.map((predicate) => (
|
|
897
|
+
<tr key={`unknown-${predicate}`} className={ROW_HOVER}>
|
|
898
|
+
<td className={`${TD} font-mono`}>{predicate}</td>
|
|
899
|
+
<td
|
|
900
|
+
className={`${TD} font-mono text-theme-text-tertiary`}
|
|
901
|
+
colSpan={4}
|
|
902
|
+
>
|
|
903
|
+
? unknown — not evaluated
|
|
904
|
+
</td>
|
|
905
|
+
<td className={`${TD} text-theme-text-tertiary`}>—</td>
|
|
906
|
+
</tr>
|
|
907
|
+
))}
|
|
908
|
+
</tbody>
|
|
909
|
+
</table>
|
|
910
|
+
</div>
|
|
911
|
+
) : (
|
|
912
|
+
<p className="px-3 py-2.5 text-sm text-theme-text-secondary">
|
|
913
|
+
No incompatibility evidence was recorded.
|
|
914
|
+
</p>
|
|
915
|
+
)}
|
|
916
|
+
{evaluation.evidenceMeta.truncated && (
|
|
917
|
+
<p className="px-3 py-2 text-[11px] text-theme-text-tertiary">
|
|
918
|
+
Showing {evaluation.evidenceMeta.returned} of{" "}
|
|
919
|
+
{evaluation.evidenceMeta.total} incompatibility checks.
|
|
920
|
+
</p>
|
|
921
|
+
)}
|
|
922
|
+
{evaluation.unknownPredicatesMeta.truncated && (
|
|
923
|
+
<p className="px-3 pb-2 text-[11px] text-theme-text-tertiary">
|
|
924
|
+
Showing {evaluation.unknownPredicatesMeta.returned} of{" "}
|
|
925
|
+
{evaluation.unknownPredicatesMeta.total} unknown predicates.
|
|
926
|
+
</p>
|
|
927
|
+
)}
|
|
928
|
+
</div>
|
|
929
|
+
</Collapse>
|
|
930
|
+
</div>
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function EvidenceRow({ evidence }: { evidence: CapacityEvaluationEvidence }) {
|
|
935
|
+
return (
|
|
936
|
+
<tr className={ROW_HOVER}>
|
|
937
|
+
<td className={`${TD} font-mono`}>{evidence.predicate}</td>
|
|
938
|
+
<td className={`${TD} font-mono text-theme-text-secondary`}>
|
|
939
|
+
{evidence.observedValues.length > 0
|
|
940
|
+
? evidence.observedValues.join(", ")
|
|
941
|
+
: "—"}
|
|
942
|
+
</td>
|
|
943
|
+
<td className={`${TD} font-mono text-theme-text-secondary`}>
|
|
944
|
+
{evidence.expectedValues.length > 0
|
|
945
|
+
? evidence.expectedValues.join(", ")
|
|
946
|
+
: "—"}
|
|
947
|
+
</td>
|
|
948
|
+
<td className={TD}>
|
|
949
|
+
<Badge tone="structural" size="sm">
|
|
950
|
+
{evidence.confidence}
|
|
951
|
+
</Badge>
|
|
952
|
+
</td>
|
|
953
|
+
<td className={`${TD} text-theme-text-secondary`}>
|
|
954
|
+
{evidence.explanation}
|
|
955
|
+
</td>
|
|
956
|
+
<td className={`${TD} font-mono text-theme-text-tertiary`}>
|
|
957
|
+
{evidence.sourcePath || "—"}
|
|
958
|
+
</td>
|
|
959
|
+
</tr>
|
|
960
|
+
);
|
|
961
|
+
}
|