@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.
Files changed (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. 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
+ }