@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,1841 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useState,
|
|
5
|
+
type ComponentType,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import {
|
|
9
|
+
AlertTriangle,
|
|
10
|
+
ChevronLeft,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
Gauge,
|
|
13
|
+
Shield,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import {
|
|
16
|
+
FreshnessControl,
|
|
17
|
+
formatDuration,
|
|
18
|
+
memberRef,
|
|
19
|
+
PaneLoader,
|
|
20
|
+
ResourceBar,
|
|
21
|
+
Tooltip,
|
|
22
|
+
WithTooltip,
|
|
23
|
+
type CapacityActivityEpisode,
|
|
24
|
+
type CapacityCertainty,
|
|
25
|
+
type CapacityClaimStage,
|
|
26
|
+
type CapacityCondition,
|
|
27
|
+
type CapacityCoverageBySource,
|
|
28
|
+
type CapacityCoverageSource,
|
|
29
|
+
type CapacityDemandGroup,
|
|
30
|
+
type CapacityDemandState,
|
|
31
|
+
type CapacityIntegrationState,
|
|
32
|
+
type CapacityLimitPressure,
|
|
33
|
+
type CapacityManager,
|
|
34
|
+
type CapacityManagerRollupStatus,
|
|
35
|
+
type CapacityManagerSummary,
|
|
36
|
+
type CapacityMemberType,
|
|
37
|
+
type CapacityPoolSummary,
|
|
38
|
+
type CapacityQuantityObservation,
|
|
39
|
+
type CapacityResourceIdentity,
|
|
40
|
+
type CapacityResponseMeta,
|
|
41
|
+
type CapacitySourceCoverage,
|
|
42
|
+
type CapacityUsageObservation,
|
|
43
|
+
type Issue,
|
|
44
|
+
type StatusTone,
|
|
45
|
+
} from "@skyhook-io/k8s-ui";
|
|
46
|
+
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
47
|
+
import {
|
|
48
|
+
formatCPUString,
|
|
49
|
+
formatMemoryString,
|
|
50
|
+
} from "@skyhook-io/k8s-ui/utils/format";
|
|
51
|
+
import {
|
|
52
|
+
isCapacityCursorInvalidError,
|
|
53
|
+
isForbiddenError,
|
|
54
|
+
useCapacityPools,
|
|
55
|
+
} from "../../api/client";
|
|
56
|
+
import type { SelectedResource } from "../../types";
|
|
57
|
+
import { refToSelectedResource } from "../../utils/navigation";
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Route model + navigation
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
export type CapacityTopTab = "overview" | "demand" | "activity";
|
|
64
|
+
export type PoolSection = "summary" | "workloads" | "members" | "configuration";
|
|
65
|
+
export type CapacityConnectionState =
|
|
66
|
+
"connected" | "disconnected" | "connecting";
|
|
67
|
+
|
|
68
|
+
export interface CapacityRoute {
|
|
69
|
+
topTab: CapacityTopTab;
|
|
70
|
+
poolName?: string;
|
|
71
|
+
poolSection: PoolSection;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const POOL_SECTIONS: { id: PoolSection; label: string }[] = [
|
|
75
|
+
{ id: "summary", label: "Summary" },
|
|
76
|
+
{ id: "workloads", label: "Workloads" },
|
|
77
|
+
{ id: "members", label: "Nodes & claims" },
|
|
78
|
+
{ id: "configuration", label: "Configuration" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export function parseCapacityRoute(pathname: string): CapacityRoute {
|
|
82
|
+
const segments = pathname.replace(/^\/+|\/+$/g, "").split("/");
|
|
83
|
+
if (segments[0] !== "capacity")
|
|
84
|
+
return { topTab: "overview", poolSection: "summary" };
|
|
85
|
+
if (segments[1] === "pools" && segments[2]) {
|
|
86
|
+
const section = segments[3];
|
|
87
|
+
return {
|
|
88
|
+
topTab: "overview",
|
|
89
|
+
poolName: decodePathSegment(segments[2]),
|
|
90
|
+
poolSection:
|
|
91
|
+
section === "workloads" ||
|
|
92
|
+
section === "members" ||
|
|
93
|
+
section === "configuration"
|
|
94
|
+
? section
|
|
95
|
+
: "summary",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (segments[1] === "demand" || segments[1] === "activity")
|
|
99
|
+
return { topTab: segments[1], poolSection: "summary" };
|
|
100
|
+
return { topTab: "overview", poolSection: "summary" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decodePathSegment(value: string): string {
|
|
104
|
+
try {
|
|
105
|
+
return decodeURIComponent(value);
|
|
106
|
+
} catch {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function capacityPoolPath(name: string): string {
|
|
112
|
+
return `/capacity/pools/${encodeURIComponent(name)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function identityToSelectedResource(
|
|
116
|
+
identity: CapacityResourceIdentity,
|
|
117
|
+
): SelectedResource {
|
|
118
|
+
return refToSelectedResource(identity.ref);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function identityKey(identity: CapacityResourceIdentity): string {
|
|
122
|
+
return `${identity.ref.group ?? ""}/${identity.ref.kind}/${identity.ref.namespace ?? ""}/${identity.ref.name}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function CapacityIssueEvidence({
|
|
126
|
+
issue,
|
|
127
|
+
onOpenResource,
|
|
128
|
+
onOpenMembers,
|
|
129
|
+
}: {
|
|
130
|
+
issue: Issue;
|
|
131
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
132
|
+
onOpenMembers?: () => void;
|
|
133
|
+
}) {
|
|
134
|
+
const members = issue.members ?? [];
|
|
135
|
+
const total = issue.count ?? members.length;
|
|
136
|
+
if (total === 0 && members.length === 0) return null;
|
|
137
|
+
const linksToPoolMembers = members.some(
|
|
138
|
+
(member) => member.kind === "Node" || member.kind === "NodeClaim",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="mt-2 rounded-lg border border-theme-border-subtle bg-theme-base/50 px-3 py-2">
|
|
143
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
144
|
+
<span className="text-xs font-medium text-theme-text-secondary">
|
|
145
|
+
{total} affected {total === 1 ? "resource" : "resources"}
|
|
146
|
+
</span>
|
|
147
|
+
{onOpenMembers && linksToPoolMembers && (
|
|
148
|
+
<LinkButton onClick={onOpenMembers}>
|
|
149
|
+
Inspect nodes & claims →
|
|
150
|
+
</LinkButton>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
{members.length > 0 && (
|
|
154
|
+
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
|
|
155
|
+
{members.map((member, index) => {
|
|
156
|
+
const ref = memberRef(issue, member);
|
|
157
|
+
return (
|
|
158
|
+
<button
|
|
159
|
+
key={`${ref.group}/${ref.kind}/${ref.namespace}/${ref.name}#${index}`}
|
|
160
|
+
type="button"
|
|
161
|
+
className="font-mono text-xs font-medium text-accent-text hover:underline"
|
|
162
|
+
onClick={() => onOpenResource(refToSelectedResource(ref))}
|
|
163
|
+
>
|
|
164
|
+
{ref.kind}/{ref.name}
|
|
165
|
+
</button>
|
|
166
|
+
);
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
{issue.members_truncated && (
|
|
171
|
+
<p className="mt-1.5 text-[11px] text-theme-text-tertiary">
|
|
172
|
+
{total > members.length
|
|
173
|
+
? `Showing ${members.length} of ${total} affected resources.`
|
|
174
|
+
: `Additional affected resources are omitted beyond the ${members.length} shown.`}
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================================
|
|
182
|
+
// Coverage semantics ("unavailable ≠ zero", lower-bound, staleness)
|
|
183
|
+
// ============================================================================
|
|
184
|
+
|
|
185
|
+
export function coverageHasObservations(
|
|
186
|
+
coverage?: CapacitySourceCoverage,
|
|
187
|
+
): boolean {
|
|
188
|
+
return coverage?.status === "available" || coverage?.status === "partial";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function coverageIsLowerBound(
|
|
192
|
+
coverage?: CapacitySourceCoverage,
|
|
193
|
+
): boolean {
|
|
194
|
+
return Boolean(
|
|
195
|
+
coverageHasObservations(coverage) &&
|
|
196
|
+
((coverage?.status === "partial" && !nodeMetricsAreStale(coverage)) ||
|
|
197
|
+
coverage?.scope !== "cluster"),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function coverageIsDenied(coverage?: CapacitySourceCoverage): boolean {
|
|
202
|
+
return coverage?.status === "denied";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function nodeMetricsAreStale(coverage?: CapacitySourceCoverage): boolean {
|
|
206
|
+
return coverage?.reasonCode === "node_metrics_stale";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function namespaceCoverageDescription(
|
|
210
|
+
coverage: CapacitySourceCoverage | undefined,
|
|
211
|
+
): string {
|
|
212
|
+
if (coverage?.scope === "explicit_namespaces") {
|
|
213
|
+
const count = coverage.namespaces?.length ?? 0;
|
|
214
|
+
return count > 0
|
|
215
|
+
? `${count} selected ${count === 1 ? "namespace" : "namespaces"}`
|
|
216
|
+
: "the selected namespaces";
|
|
217
|
+
}
|
|
218
|
+
if (coverage?.scope === "all_authorized_namespaces")
|
|
219
|
+
return "all namespaces authorized for this user";
|
|
220
|
+
return "the observed inventory";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function memberCoverageSource(
|
|
224
|
+
type: CapacityMemberType,
|
|
225
|
+
): CapacityCoverageSource {
|
|
226
|
+
if (type === "node") return "nodes";
|
|
227
|
+
if (type === "claim") return "nodeClaims";
|
|
228
|
+
return "workloads";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function coverageMessage(
|
|
232
|
+
coverage: CapacitySourceCoverage | undefined,
|
|
233
|
+
subject: string,
|
|
234
|
+
): string {
|
|
235
|
+
if (!coverage) return `${subject} coverage was not reported`;
|
|
236
|
+
if (coverage.reason) return coverage.reason;
|
|
237
|
+
if (coverageIsLowerBound(coverage))
|
|
238
|
+
return `${subject} is a lower-bound view of ${namespaceCoverageDescription(coverage)}`;
|
|
239
|
+
if (coverage.status === "available") return `${subject} observed`;
|
|
240
|
+
if (coverage.status === "partial") return `${subject} has partial coverage`;
|
|
241
|
+
if (coverage.status === "denied")
|
|
242
|
+
return `${subject} is hidden by permissions`;
|
|
243
|
+
if (coverage.status === "syncing") return `${subject} is still syncing`;
|
|
244
|
+
if (coverage.status === "error") return `${subject} could not be observed`;
|
|
245
|
+
return `${subject} is unavailable`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function missingUsageMessage(coverage?: CapacitySourceCoverage): string {
|
|
249
|
+
if (nodeMetricsAreStale(coverage))
|
|
250
|
+
return "The retained node metrics are stale and this pool has no sample";
|
|
251
|
+
if (coverage?.status === "available")
|
|
252
|
+
return "No live node samples were reported for this pool";
|
|
253
|
+
if (coverage?.status === "partial" && !coverage.reason)
|
|
254
|
+
return "No live sample was present in the partially observed nodes";
|
|
255
|
+
return coverageMessage(coverage, "Live usage");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ============================================================================
|
|
259
|
+
// Certainty glyphs — the design's per-value trust vocabulary
|
|
260
|
+
// = exact · ≥ lower bound · ≤ upper bound · ? unknown
|
|
261
|
+
// ============================================================================
|
|
262
|
+
|
|
263
|
+
export function certaintyGlyph(certainty: CapacityCertainty): string {
|
|
264
|
+
if (certainty === "exact") return "=";
|
|
265
|
+
if (certainty === "lower_bound") return "≥";
|
|
266
|
+
if (certainty === "upper_bound") return "≤";
|
|
267
|
+
return "?";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function certaintyValueLabel(certainty: CapacityCertainty): string {
|
|
271
|
+
if (certainty === "exact") return "Exact";
|
|
272
|
+
if (certainty === "lower_bound") return "Lower bound";
|
|
273
|
+
if (certainty === "upper_bound") return "Upper bound";
|
|
274
|
+
return "Unknown certainty";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function observationTitle(
|
|
278
|
+
observation: CapacityQuantityObservation,
|
|
279
|
+
definition?: string,
|
|
280
|
+
): string {
|
|
281
|
+
const parts = [
|
|
282
|
+
definition,
|
|
283
|
+
`${certaintyValueLabel(observation.certainty)}.`,
|
|
284
|
+
observation.sources.length > 0
|
|
285
|
+
? `Source: ${sourceLabel(observation.sources)}.`
|
|
286
|
+
: undefined,
|
|
287
|
+
`Observed ${formatTimestamp(observation.asOf)}.`,
|
|
288
|
+
].filter(Boolean);
|
|
289
|
+
return parts.join(" ");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Small bordered mono glyph chip; hover reveals certainty/source/asOf. */
|
|
293
|
+
export function CertaintyGlyph({
|
|
294
|
+
certainty,
|
|
295
|
+
title,
|
|
296
|
+
}: {
|
|
297
|
+
certainty: CapacityCertainty;
|
|
298
|
+
title?: string;
|
|
299
|
+
}) {
|
|
300
|
+
return (
|
|
301
|
+
<WithTooltip tip={title ?? certaintyValueLabel(certainty)}>
|
|
302
|
+
<span
|
|
303
|
+
tabIndex={0}
|
|
304
|
+
role="note"
|
|
305
|
+
aria-label={title ?? certaintyValueLabel(certainty)}
|
|
306
|
+
className="cursor-help rounded border border-theme-border-light px-1 font-mono text-[10px] leading-tight text-theme-text-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:outline-skyhook-500"
|
|
307
|
+
>
|
|
308
|
+
{certaintyGlyph(certainty)}
|
|
309
|
+
</span>
|
|
310
|
+
</WithTooltip>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// Quantity / resource formatting
|
|
316
|
+
// ============================================================================
|
|
317
|
+
|
|
318
|
+
export function formatQuantity(resource: string, quantity: string): string {
|
|
319
|
+
if (resource === "cpu") return formatCPUString(quantity);
|
|
320
|
+
if (resource === "memory" || resource === "ephemeral-storage")
|
|
321
|
+
return formatMemoryString(quantity);
|
|
322
|
+
return quantity;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function resourceLabel(resource: string): string {
|
|
326
|
+
if (resource === "cpu") return "CPU";
|
|
327
|
+
if (resource === "memory") return "Memory";
|
|
328
|
+
if (resource === "ephemeral-storage") return "Ephemeral storage";
|
|
329
|
+
if (resource === "nvidia.com/gpu") return "GPU";
|
|
330
|
+
if (resource === "pods") return "Pods";
|
|
331
|
+
if (resource === "nodes") return "Nodes";
|
|
332
|
+
return resource;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function quantityResourceRank(resource: string): number {
|
|
336
|
+
if (resource === "cpu") return 0;
|
|
337
|
+
if (resource === "memory") return 1;
|
|
338
|
+
if (resource.includes("gpu") || resource.includes("accelerator")) return 2;
|
|
339
|
+
if (resource.includes("/")) return 3;
|
|
340
|
+
if (resource === "pods" || resource === "nodes") return 4;
|
|
341
|
+
if (resource === "ephemeral-storage") return 5;
|
|
342
|
+
if (resource.startsWith("hugepages-")) return 7;
|
|
343
|
+
return 6;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function shortResourceLabel(resource: string): string {
|
|
347
|
+
if (resource === "memory") return "MEM";
|
|
348
|
+
if (resource === "ephemeral-storage") return "DISK";
|
|
349
|
+
if (resource === "nvidia.com/gpu") return "GPU";
|
|
350
|
+
if (resource.includes("/")) {
|
|
351
|
+
const suffix = resource.split("/").at(-1);
|
|
352
|
+
if (!suffix) return "EXT";
|
|
353
|
+
return (suffix.startsWith("pod-") ? suffix.slice(4) : suffix)
|
|
354
|
+
.slice(0, 4)
|
|
355
|
+
.toUpperCase();
|
|
356
|
+
}
|
|
357
|
+
return resource.slice(0, 4).toUpperCase();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function sortedResourceEntries(
|
|
361
|
+
resources: Record<string, string>,
|
|
362
|
+
): [string, string][] {
|
|
363
|
+
return Object.entries(resources).sort(
|
|
364
|
+
([left], [right]) =>
|
|
365
|
+
quantityResourceRank(left) - quantityResourceRank(right) ||
|
|
366
|
+
left.localeCompare(right),
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** "8 CPU · 32Gi" style compact one-liner for a quantity observation. */
|
|
371
|
+
export function quantityText(
|
|
372
|
+
observation: CapacityQuantityObservation | undefined,
|
|
373
|
+
options?: { max?: number },
|
|
374
|
+
): string | null {
|
|
375
|
+
if (!observation) return null;
|
|
376
|
+
const entries = sortedResourceEntries(observation.resources);
|
|
377
|
+
if (entries.length === 0) return null;
|
|
378
|
+
const max = options?.max ?? 4;
|
|
379
|
+
const shown = entries
|
|
380
|
+
.slice(0, max)
|
|
381
|
+
.map(([resource, value]) => formatQuantity(resource, value));
|
|
382
|
+
if (entries.length > max) shown.push(`+${entries.length - max}`);
|
|
383
|
+
return shown.join(" · ");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function sourceLabel(sources: string[]): string {
|
|
387
|
+
return sources.length > 0 ? sources.join(" + ") : "source not reported";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function formatTimestamp(value: string): string {
|
|
391
|
+
const timestamp = Date.parse(value);
|
|
392
|
+
return Number.isFinite(timestamp)
|
|
393
|
+
? new Date(timestamp).toLocaleString()
|
|
394
|
+
: value;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function relativeTime(value?: string, now = Date.now()): string {
|
|
398
|
+
if (!value) return "—";
|
|
399
|
+
const timestamp = Date.parse(value);
|
|
400
|
+
if (!Number.isFinite(timestamp)) return value;
|
|
401
|
+
const seconds = Math.max(0, Math.round((now - timestamp) / 1000));
|
|
402
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
403
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
404
|
+
if (seconds < 86400) {
|
|
405
|
+
const hours = Math.floor(seconds / 3600);
|
|
406
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
407
|
+
return minutes > 0 ? `${hours}h ${minutes}m ago` : `${hours}h ago`;
|
|
408
|
+
}
|
|
409
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function formatTaint(taint: {
|
|
413
|
+
key: string;
|
|
414
|
+
value?: string;
|
|
415
|
+
effect: string;
|
|
416
|
+
}): string {
|
|
417
|
+
return `${taint.key}${taint.value ? `=${taint.value}` : ""}:${taint.effect}`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function generatedAtMillis(generatedAt: string, fallback: number): number {
|
|
421
|
+
const value = Date.parse(generatedAt);
|
|
422
|
+
return Number.isFinite(value) ? value : fallback;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function errorMessage(error: unknown): string {
|
|
426
|
+
return error instanceof Error ? error.message : "An unknown error occurred.";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ============================================================================
|
|
430
|
+
// Label helpers
|
|
431
|
+
// ============================================================================
|
|
432
|
+
|
|
433
|
+
export function poolReadinessDetail(
|
|
434
|
+
pools: CapacityPoolSummary[],
|
|
435
|
+
truncated: boolean,
|
|
436
|
+
): string {
|
|
437
|
+
const ready = pools.filter((pool) => pool.ready === true).length;
|
|
438
|
+
const notReady = pools.filter((pool) => pool.ready === false).length;
|
|
439
|
+
const unknown = pools.filter((pool) => pool.ready === undefined).length;
|
|
440
|
+
const parts: string[] = [];
|
|
441
|
+
if (ready > 0) parts.push(`${ready} ready`);
|
|
442
|
+
if (notReady > 0) parts.push(`${notReady} unready`);
|
|
443
|
+
if (unknown > 0) parts.push(`${unknown} unknown`);
|
|
444
|
+
if (parts.length === 0)
|
|
445
|
+
return pools.length === 0 ? "No pools visible" : "Readiness unknown";
|
|
446
|
+
return truncated ? `${parts.join(" · ")} (first page)` : parts.join(" · ");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Human label for a detected capacity manager. */
|
|
450
|
+
export function capacityManagerLabel(manager: CapacityManager): string {
|
|
451
|
+
if (manager === "karpenter") return "Karpenter";
|
|
452
|
+
if (manager === "gke_autoscaler") return "GKE autoscaler";
|
|
453
|
+
if (manager === "cluster_autoscaler") return "Cluster Autoscaler";
|
|
454
|
+
if (manager === "aks_autoscaler") return "AKS autoscaler";
|
|
455
|
+
return manager;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Manager rollup status → status tone. `unknown` maps to neutral (an absence
|
|
459
|
+
* of health signal), never to the alarming `unknown` slate — the capacity
|
|
460
|
+
* contract treats "we haven't determined health" as informational, not bad. */
|
|
461
|
+
export function managerStatusTone(
|
|
462
|
+
status: CapacityManagerRollupStatus,
|
|
463
|
+
): StatusTone {
|
|
464
|
+
if (status === "healthy") return "healthy";
|
|
465
|
+
if (status === "degraded") return "degraded";
|
|
466
|
+
return "neutral";
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const MANAGER_STATUS_RANK: Record<string, number> = {
|
|
470
|
+
degraded: 0,
|
|
471
|
+
unknown: 1,
|
|
472
|
+
healthy: 2,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/** Severity rank of a manager rollup status (lower is worse). A status this
|
|
476
|
+
* build does not know ranks as `unknown` — never as an accidental best-of that
|
|
477
|
+
* would hide a real degradation behind a newer wire value. */
|
|
478
|
+
export function managerStatusRank(status: string): number {
|
|
479
|
+
return MANAGER_STATUS_RANK[status] ?? MANAGER_STATUS_RANK.unknown;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Worst-of status across managers (degraded worst), driving the tile accent.
|
|
483
|
+
* Undefined when no managers were detected. */
|
|
484
|
+
export function worstManagerStatus(
|
|
485
|
+
managers: CapacityManagerSummary[],
|
|
486
|
+
): CapacityManagerRollupStatus | undefined {
|
|
487
|
+
if (managers.length === 0) return undefined;
|
|
488
|
+
return managers.reduce<CapacityManagerRollupStatus>(
|
|
489
|
+
(worst, manager) =>
|
|
490
|
+
managerStatusRank(manager.status) < managerStatusRank(worst)
|
|
491
|
+
? manager.status
|
|
492
|
+
: worst,
|
|
493
|
+
managers[0].status,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function demandStateLabel(state: CapacityDemandState): string {
|
|
498
|
+
if (state === "waiting_for_scheduler") return "Waiting for scheduler";
|
|
499
|
+
if (state === "held") return "Held by scheduling gate";
|
|
500
|
+
if (state === "awaiting_capacity") return "Awaiting capacity";
|
|
501
|
+
if (state === "blocked") return "Blocked";
|
|
502
|
+
return "Unknown";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export function activityTypeLabel(
|
|
506
|
+
type: CapacityActivityEpisode["type"],
|
|
507
|
+
): string {
|
|
508
|
+
if (type === "launch_failure") return "Launch failure";
|
|
509
|
+
if (type === "registration_failure") return "Registration failure";
|
|
510
|
+
if (type === "initialization_failure") return "Initialization failure";
|
|
511
|
+
if (type === "config_change") return "Configuration change";
|
|
512
|
+
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function actionSeverity(
|
|
516
|
+
severity?: string,
|
|
517
|
+
): "error" | "warning" | "info" | "neutral" {
|
|
518
|
+
if (severity === "critical" || severity === "error") return "error";
|
|
519
|
+
if (severity === "warning" || severity === "alert") return "warning";
|
|
520
|
+
if (severity === "info") return "info";
|
|
521
|
+
return "neutral";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function humanizeCode(code: string): string {
|
|
525
|
+
const words = code
|
|
526
|
+
.replaceAll("_", " ")
|
|
527
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
528
|
+
if (!words) return "Capacity signal";
|
|
529
|
+
return `${words.charAt(0).toUpperCase()}${words.slice(1)}`
|
|
530
|
+
.replaceAll("Nodeclass", "NodeClass")
|
|
531
|
+
.replaceAll("Node Class", "NodeClass")
|
|
532
|
+
.replaceAll("Node Pool", "NodePool");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function conditionSummary(
|
|
536
|
+
conditions: { type: string; status: string; reason?: string }[] | undefined,
|
|
537
|
+
): string {
|
|
538
|
+
if (!conditions || conditions.length === 0) return "No conditions reported";
|
|
539
|
+
const failing = conditions.find((condition) => condition.status === "False");
|
|
540
|
+
const unknown = conditions.find(
|
|
541
|
+
(condition) => condition.status === "Unknown",
|
|
542
|
+
);
|
|
543
|
+
const selected =
|
|
544
|
+
failing ??
|
|
545
|
+
unknown ??
|
|
546
|
+
conditions.find((condition) => condition.status === "True") ??
|
|
547
|
+
conditions[0];
|
|
548
|
+
return `${selected.type}: ${selected.reason || selected.status}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function retentionLabel(retention: {
|
|
552
|
+
mode: string;
|
|
553
|
+
maxAgeSeconds?: number;
|
|
554
|
+
maxEvents?: number;
|
|
555
|
+
}): string {
|
|
556
|
+
const details: string[] = [retention.mode.replaceAll("_", " ")];
|
|
557
|
+
if (retention.maxAgeSeconds !== undefined)
|
|
558
|
+
details.push(`up to ${formatDuration(retention.maxAgeSeconds * 1000)}`);
|
|
559
|
+
if (retention.maxEvents !== undefined)
|
|
560
|
+
details.push(`${retention.maxEvents} events max`);
|
|
561
|
+
return details.join(" · ");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function activityWindowPreset(
|
|
565
|
+
since: string | undefined,
|
|
566
|
+
now = Date.now(),
|
|
567
|
+
): number | "custom" | undefined {
|
|
568
|
+
if (!since) return undefined;
|
|
569
|
+
const timestamp = Date.parse(since);
|
|
570
|
+
if (!Number.isFinite(timestamp)) return "custom";
|
|
571
|
+
const age = now - timestamp;
|
|
572
|
+
const tolerance = 10 * 60 * 1000;
|
|
573
|
+
const preset = [1, 6, 24].find(
|
|
574
|
+
(hours) => Math.abs(age - hours * 60 * 60 * 1000) <= tolerance,
|
|
575
|
+
);
|
|
576
|
+
return preset ?? "custom";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ============================================================================
|
|
580
|
+
// Badges
|
|
581
|
+
// ============================================================================
|
|
582
|
+
|
|
583
|
+
export function PoolReadyBadge({ ready }: { ready?: boolean }) {
|
|
584
|
+
return ready === true ? (
|
|
585
|
+
<Badge severity="success" size="sm">
|
|
586
|
+
Ready
|
|
587
|
+
</Badge>
|
|
588
|
+
) : ready === false ? (
|
|
589
|
+
<Badge severity="error" size="sm">
|
|
590
|
+
Unready
|
|
591
|
+
</Badge>
|
|
592
|
+
) : (
|
|
593
|
+
<Badge severity="neutral" size="sm">
|
|
594
|
+
Status unknown
|
|
595
|
+
</Badge>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function NodeReadyBadge({
|
|
600
|
+
ready,
|
|
601
|
+
cordoned,
|
|
602
|
+
}: {
|
|
603
|
+
ready?: boolean;
|
|
604
|
+
cordoned: boolean;
|
|
605
|
+
}) {
|
|
606
|
+
if (ready === false)
|
|
607
|
+
return (
|
|
608
|
+
<Badge severity="error" size="sm">
|
|
609
|
+
NotReady
|
|
610
|
+
</Badge>
|
|
611
|
+
);
|
|
612
|
+
if (ready === undefined)
|
|
613
|
+
return (
|
|
614
|
+
<Badge severity="neutral" size="sm">
|
|
615
|
+
Unknown
|
|
616
|
+
</Badge>
|
|
617
|
+
);
|
|
618
|
+
if (cordoned)
|
|
619
|
+
return (
|
|
620
|
+
<Badge severity="warning" size="sm">
|
|
621
|
+
Cordoned
|
|
622
|
+
</Badge>
|
|
623
|
+
);
|
|
624
|
+
return (
|
|
625
|
+
<Badge severity="success" size="sm">
|
|
626
|
+
Ready
|
|
627
|
+
</Badge>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function ClaimStageBadge({ stage }: { stage?: CapacityClaimStage }) {
|
|
632
|
+
if (!stage || stage === "unknown")
|
|
633
|
+
return (
|
|
634
|
+
<Badge severity="neutral" size="sm">
|
|
635
|
+
Unknown
|
|
636
|
+
</Badge>
|
|
637
|
+
);
|
|
638
|
+
const severity =
|
|
639
|
+
stage === "failed"
|
|
640
|
+
? "error"
|
|
641
|
+
: stage === "terminating" || stage === "pending"
|
|
642
|
+
? "warning"
|
|
643
|
+
: stage === "ready"
|
|
644
|
+
? "success"
|
|
645
|
+
: "info";
|
|
646
|
+
return (
|
|
647
|
+
<Badge severity={severity} size="sm">
|
|
648
|
+
{stage}
|
|
649
|
+
</Badge>
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function DemandStateBadge({ state }: { state: CapacityDemandState }) {
|
|
654
|
+
const severity =
|
|
655
|
+
state === "blocked"
|
|
656
|
+
? "error"
|
|
657
|
+
: state === "held"
|
|
658
|
+
? "info"
|
|
659
|
+
: state === "awaiting_capacity"
|
|
660
|
+
? "warning"
|
|
661
|
+
: "neutral";
|
|
662
|
+
return (
|
|
663
|
+
<Badge severity={severity} size="sm">
|
|
664
|
+
{demandStateLabel(state)}
|
|
665
|
+
</Badge>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function PoolEvaluationBadge({
|
|
670
|
+
result,
|
|
671
|
+
}: {
|
|
672
|
+
result: CapacityDemandGroup["poolEvaluations"][number]["result"];
|
|
673
|
+
}) {
|
|
674
|
+
// Declared-compatible is deliberately NOT success-styled: the pod is still
|
|
675
|
+
// pending, and the claim is about declared constraints only (no instance
|
|
676
|
+
// shape / bin-packing simulation) — green would overpromise placement.
|
|
677
|
+
const severity = result === "incompatible" ? "warning" : "neutral";
|
|
678
|
+
return (
|
|
679
|
+
<Badge severity={severity} size="sm">
|
|
680
|
+
{result === "declared_compatible"
|
|
681
|
+
? "Declared compatible"
|
|
682
|
+
: result === "incompatible"
|
|
683
|
+
? "Declared incompatible"
|
|
684
|
+
: "Unknown"}
|
|
685
|
+
</Badge>
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const ACTIVITY_STATE_SEVERITY: Record<
|
|
690
|
+
string,
|
|
691
|
+
"error" | "warning" | "info" | "success" | "neutral"
|
|
692
|
+
> = {
|
|
693
|
+
failed: "error",
|
|
694
|
+
blocked: "warning",
|
|
695
|
+
open: "info",
|
|
696
|
+
completed: "success",
|
|
697
|
+
// `ended` is deliberately toneless. The episode terminated with no evidence
|
|
698
|
+
// either way, so green would claim a success and red a failure that Radar
|
|
699
|
+
// never observed. An unrecognized future state lands on the same default.
|
|
700
|
+
ended: "neutral",
|
|
701
|
+
observed: "neutral",
|
|
702
|
+
unknown: "neutral",
|
|
703
|
+
};
|
|
704
|
+
|
|
705
|
+
export function ActivityStateBadge({
|
|
706
|
+
state,
|
|
707
|
+
}: {
|
|
708
|
+
state: CapacityActivityEpisode["state"];
|
|
709
|
+
}) {
|
|
710
|
+
return (
|
|
711
|
+
<WithTooltip tip={activityStateTip(state)}>
|
|
712
|
+
<Badge severity={ACTIVITY_STATE_SEVERITY[state] ?? "neutral"} size="sm">
|
|
713
|
+
{state}
|
|
714
|
+
</Badge>
|
|
715
|
+
</WithTooltip>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function activityStateTip(
|
|
720
|
+
state: CapacityActivityEpisode["state"],
|
|
721
|
+
): string | undefined {
|
|
722
|
+
if (state === "ended")
|
|
723
|
+
return "Ended without a recorded cause — the subject went away before reaching its goal. Radar does not claim this failed or succeeded.";
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function ConditionBadge({
|
|
728
|
+
status,
|
|
729
|
+
}: {
|
|
730
|
+
status: CapacityCondition["status"];
|
|
731
|
+
}) {
|
|
732
|
+
const severity =
|
|
733
|
+
status === "True" ? "success" : status === "False" ? "warning" : "neutral";
|
|
734
|
+
return (
|
|
735
|
+
<Badge severity={severity} size="sm">
|
|
736
|
+
{status}
|
|
737
|
+
</Badge>
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function CoverageBadge({ coverage }: { coverage?: CapacitySourceCoverage }) {
|
|
742
|
+
if (!coverage)
|
|
743
|
+
return (
|
|
744
|
+
<Badge severity="neutral" size="sm">
|
|
745
|
+
Not reported
|
|
746
|
+
</Badge>
|
|
747
|
+
);
|
|
748
|
+
const lowerBound = coverageIsLowerBound(coverage);
|
|
749
|
+
const severity = lowerBound
|
|
750
|
+
? "warning"
|
|
751
|
+
: coverage.status === "available"
|
|
752
|
+
? "success"
|
|
753
|
+
: coverage.status === "partial"
|
|
754
|
+
? "warning"
|
|
755
|
+
: coverage.status === "error" || coverage.status === "denied"
|
|
756
|
+
? "error"
|
|
757
|
+
: coverage.status === "syncing"
|
|
758
|
+
? "info"
|
|
759
|
+
: "neutral";
|
|
760
|
+
return (
|
|
761
|
+
<Badge severity={severity} size="sm">
|
|
762
|
+
{lowerBound ? "lower bound" : coverage.status.replace("_", " ")}
|
|
763
|
+
</Badge>
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function UsageCoverageBadge({
|
|
768
|
+
coverage,
|
|
769
|
+
}: {
|
|
770
|
+
coverage?: CapacitySourceCoverage;
|
|
771
|
+
}) {
|
|
772
|
+
if (nodeMetricsAreStale(coverage))
|
|
773
|
+
return (
|
|
774
|
+
<Badge severity="warning" size="sm">
|
|
775
|
+
Stale
|
|
776
|
+
</Badge>
|
|
777
|
+
);
|
|
778
|
+
if (coverage?.status === "available")
|
|
779
|
+
return (
|
|
780
|
+
<Badge severity="neutral" size="sm">
|
|
781
|
+
No sample
|
|
782
|
+
</Badge>
|
|
783
|
+
);
|
|
784
|
+
return <CoverageBadge coverage={coverage} />;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Alert-toned "Unavailable — Pod access denied" chip. Never rendered as zero. */
|
|
788
|
+
export function DeniedBadge({ label = "Unavailable" }: { label?: string }) {
|
|
789
|
+
return (
|
|
790
|
+
<WithTooltip tip="Pod access is denied. This value cannot be computed — it is unavailable, not zero.">
|
|
791
|
+
<Badge severity="warning" size="sm">
|
|
792
|
+
{`${label} — Pod access denied`}
|
|
793
|
+
</Badge>
|
|
794
|
+
</WithTooltip>
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
// ============================================================================
|
|
798
|
+
// Quantity presentation
|
|
799
|
+
// ============================================================================
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* A quantity observation that carries no resource entries. Only certainty can
|
|
803
|
+
* tell a measured zero (a pool scaled to zero) apart from a source that was
|
|
804
|
+
* never read — `empty` is this page's "not observed" wording, so an exact
|
|
805
|
+
* observation must never land there, and an unobserved source must never land
|
|
806
|
+
* on a zero.
|
|
807
|
+
*/
|
|
808
|
+
function EmptyQuantity({
|
|
809
|
+
observation,
|
|
810
|
+
empty,
|
|
811
|
+
}: {
|
|
812
|
+
observation?: CapacityQuantityObservation;
|
|
813
|
+
empty: string;
|
|
814
|
+
}) {
|
|
815
|
+
if (!observation)
|
|
816
|
+
return <span className="text-xs text-theme-text-tertiary">{empty}</span>;
|
|
817
|
+
if (observation.certainty === "unknown")
|
|
818
|
+
return (
|
|
819
|
+
<WithTooltip tip={observationTitle(observation)}>
|
|
820
|
+
<span className="text-xs text-theme-text-tertiary">Unknown</span>
|
|
821
|
+
</WithTooltip>
|
|
822
|
+
);
|
|
823
|
+
if (observation.certainty === "exact")
|
|
824
|
+
return (
|
|
825
|
+
<WithTooltip tip={observationTitle(observation)}>
|
|
826
|
+
<span className="font-mono text-xs text-theme-text-secondary">0</span>
|
|
827
|
+
</WithTooltip>
|
|
828
|
+
);
|
|
829
|
+
return (
|
|
830
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
831
|
+
{empty} <CertaintyGlyph certainty={observation.certainty} />
|
|
832
|
+
</span>
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function QuantityInline({
|
|
837
|
+
observation,
|
|
838
|
+
empty,
|
|
839
|
+
}: {
|
|
840
|
+
observation?: CapacityQuantityObservation;
|
|
841
|
+
empty: string;
|
|
842
|
+
}) {
|
|
843
|
+
const text = quantityText(observation);
|
|
844
|
+
if (!text) return <EmptyQuantity observation={observation} empty={empty} />;
|
|
845
|
+
return (
|
|
846
|
+
<WithTooltip tip={observation ? observationTitle(observation) : text}>
|
|
847
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
848
|
+
{text}
|
|
849
|
+
{observation && observation.certainty !== "exact" && (
|
|
850
|
+
<>
|
|
851
|
+
{" "}
|
|
852
|
+
<CertaintyGlyph certainty={observation.certainty} />
|
|
853
|
+
</>
|
|
854
|
+
)}
|
|
855
|
+
</span>
|
|
856
|
+
</WithTooltip>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Inventory-table quantity cell. CPU ("N cores") and memory ("N GiB") lead; a
|
|
862
|
+
* GPU resource is promoted into the visible cell ("gpu N") because it changes
|
|
863
|
+
* what can schedule; every other resource collapses into a "+N more" chip whose
|
|
864
|
+
* tooltip lists each one labeled — so pods and ephemeral-storage never sit as
|
|
865
|
+
* bare unlabeled numbers next to CPU/memory. Empty/unknown handling mirrors
|
|
866
|
+
* QuantityInline (unavailable ≠ zero, a lower bound keeps its glyph).
|
|
867
|
+
*/
|
|
868
|
+
export function InventoryQuantityCell({
|
|
869
|
+
observation,
|
|
870
|
+
empty,
|
|
871
|
+
}: {
|
|
872
|
+
observation?: CapacityQuantityObservation;
|
|
873
|
+
empty: string;
|
|
874
|
+
}) {
|
|
875
|
+
const entries = observation
|
|
876
|
+
? sortedResourceEntries(observation.resources)
|
|
877
|
+
: [];
|
|
878
|
+
if (entries.length === 0)
|
|
879
|
+
return <EmptyQuantity observation={observation} empty={empty} />;
|
|
880
|
+
|
|
881
|
+
const visible: string[] = [];
|
|
882
|
+
const cpu = observation?.resources.cpu;
|
|
883
|
+
const memory = observation?.resources.memory;
|
|
884
|
+
if (cpu !== undefined) visible.push(formatQuantity("cpu", cpu));
|
|
885
|
+
if (memory !== undefined) visible.push(formatQuantity("memory", memory));
|
|
886
|
+
const hidden: [string, string][] = [];
|
|
887
|
+
for (const [resource, value] of entries) {
|
|
888
|
+
if (resource === "cpu" || resource === "memory") continue;
|
|
889
|
+
if (/gpu/i.test(resource)) {
|
|
890
|
+
visible.push(`gpu ${formatQuantity(resource, value)}`);
|
|
891
|
+
} else {
|
|
892
|
+
hidden.push([resource, value]);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
const hiddenTip = hidden
|
|
896
|
+
.map(
|
|
897
|
+
([resource, value]) => `${resource} ${formatQuantity(resource, value)}`,
|
|
898
|
+
)
|
|
899
|
+
.join(" · ");
|
|
900
|
+
|
|
901
|
+
return (
|
|
902
|
+
<span className="font-mono text-xs text-theme-text-secondary">
|
|
903
|
+
<WithTooltip
|
|
904
|
+
tip={observation ? observationTitle(observation) : undefined}
|
|
905
|
+
>
|
|
906
|
+
<span>{visible.join(" · ")}</span>
|
|
907
|
+
</WithTooltip>
|
|
908
|
+
{hidden.length > 0 && (
|
|
909
|
+
<>
|
|
910
|
+
{visible.length > 0 ? " · " : ""}
|
|
911
|
+
<WithTooltip tip={hiddenTip}>
|
|
912
|
+
<span className="cursor-help text-theme-text-tertiary underline decoration-dotted underline-offset-2">
|
|
913
|
+
{`+${hidden.length} more`}
|
|
914
|
+
</span>
|
|
915
|
+
</WithTooltip>
|
|
916
|
+
</>
|
|
917
|
+
)}
|
|
918
|
+
{observation && observation.certainty !== "exact" && (
|
|
919
|
+
<>
|
|
920
|
+
{" "}
|
|
921
|
+
<CertaintyGlyph certainty={observation.certainty} />
|
|
922
|
+
</>
|
|
923
|
+
)}
|
|
924
|
+
</span>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function ActualUsageInline({
|
|
929
|
+
usage,
|
|
930
|
+
coverage,
|
|
931
|
+
}: {
|
|
932
|
+
usage?: CapacityUsageObservation;
|
|
933
|
+
coverage?: CapacitySourceCoverage;
|
|
934
|
+
}) {
|
|
935
|
+
if (!usage)
|
|
936
|
+
return (
|
|
937
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
938
|
+
{missingUsageMessage(coverage)}
|
|
939
|
+
</span>
|
|
940
|
+
);
|
|
941
|
+
if (usage.utilization.length === 0)
|
|
942
|
+
return (
|
|
943
|
+
<div className="space-y-1">
|
|
944
|
+
{nodeMetricsAreStale(coverage) && (
|
|
945
|
+
<Badge severity="warning" size="sm">
|
|
946
|
+
Stale sample
|
|
947
|
+
</Badge>
|
|
948
|
+
)}
|
|
949
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
950
|
+
No utilization values reported
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
);
|
|
954
|
+
const primary = usage.utilization.filter(
|
|
955
|
+
(entry) => entry.resource === "cpu" || entry.resource === "memory",
|
|
956
|
+
);
|
|
957
|
+
const visible = primary.length > 0 ? primary : usage.utilization.slice(0, 2);
|
|
958
|
+
return (
|
|
959
|
+
<div className="space-y-1">
|
|
960
|
+
{nodeMetricsAreStale(coverage) && (
|
|
961
|
+
<div className="flex items-center gap-1.5">
|
|
962
|
+
<Badge severity="warning" size="sm">
|
|
963
|
+
Stale sample
|
|
964
|
+
</Badge>
|
|
965
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
966
|
+
{formatTimestamp(usage.quantity.asOf)}
|
|
967
|
+
</span>
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
{visible.map((entry) => (
|
|
971
|
+
<ResourceBar
|
|
972
|
+
key={entry.resource}
|
|
973
|
+
used={formatQuantity(entry.resource, entry.usage)}
|
|
974
|
+
total={formatQuantity(entry.resource, entry.allocatable)}
|
|
975
|
+
percent={entry.percent}
|
|
976
|
+
colorScheme="quiet"
|
|
977
|
+
layout="inline"
|
|
978
|
+
label={shortResourceLabel(entry.resource)}
|
|
979
|
+
tooltip={`Actual ${resourceLabel(entry.resource).toLowerCase()} usage across ${usage.coveredNodes} of ${usage.totalNodes} nodes with samples`}
|
|
980
|
+
/>
|
|
981
|
+
))}
|
|
982
|
+
</div>
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function pickWorstPressure(
|
|
987
|
+
pressure: CapacityLimitPressure[],
|
|
988
|
+
): CapacityLimitPressure | undefined {
|
|
989
|
+
return [...pressure].sort((left, right) => {
|
|
990
|
+
if (left.overLimit !== right.overLimit) return left.overLimit ? -1 : 1;
|
|
991
|
+
return (right.percent ?? -1) - (left.percent ?? -1);
|
|
992
|
+
})[0];
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export function PressureDetail({
|
|
996
|
+
pressure,
|
|
997
|
+
}: {
|
|
998
|
+
pressure: CapacityLimitPressure;
|
|
999
|
+
}) {
|
|
1000
|
+
return (
|
|
1001
|
+
<div>
|
|
1002
|
+
<div className="mb-1 flex items-center justify-between gap-2 text-xs font-medium text-theme-text-secondary">
|
|
1003
|
+
<span>{resourceLabel(pressure.resource)}</span>
|
|
1004
|
+
{pressure.overLimit && (
|
|
1005
|
+
<Badge severity="warning" size="sm">
|
|
1006
|
+
Over limit
|
|
1007
|
+
</Badge>
|
|
1008
|
+
)}
|
|
1009
|
+
</div>
|
|
1010
|
+
{pressure.percent === undefined ? (
|
|
1011
|
+
<div className="font-mono text-xs text-theme-text-primary">
|
|
1012
|
+
{formatQuantity(pressure.resource, pressure.provisioned)} /{" "}
|
|
1013
|
+
{formatQuantity(pressure.resource, pressure.limit)} · ratio
|
|
1014
|
+
unavailable
|
|
1015
|
+
</div>
|
|
1016
|
+
) : (
|
|
1017
|
+
<ResourceBar
|
|
1018
|
+
used={formatQuantity(pressure.resource, pressure.provisioned)}
|
|
1019
|
+
total={formatQuantity(pressure.resource, pressure.limit)}
|
|
1020
|
+
percent={pressure.percent}
|
|
1021
|
+
colorScheme="quiet"
|
|
1022
|
+
/>
|
|
1023
|
+
)}
|
|
1024
|
+
</div>
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export function ActualUsageDetail({
|
|
1029
|
+
usage,
|
|
1030
|
+
coverage,
|
|
1031
|
+
}: {
|
|
1032
|
+
usage?: CapacityUsageObservation;
|
|
1033
|
+
coverage?: CapacitySourceCoverage;
|
|
1034
|
+
}) {
|
|
1035
|
+
if (!usage)
|
|
1036
|
+
return (
|
|
1037
|
+
<div className="flex items-start gap-2">
|
|
1038
|
+
<UsageCoverageBadge coverage={coverage} />
|
|
1039
|
+
<p className="text-sm text-theme-text-secondary">
|
|
1040
|
+
{missingUsageMessage(coverage)}
|
|
1041
|
+
</p>
|
|
1042
|
+
</div>
|
|
1043
|
+
);
|
|
1044
|
+
return (
|
|
1045
|
+
<div>
|
|
1046
|
+
{nodeMetricsAreStale(coverage) && (
|
|
1047
|
+
<div className="mb-3">
|
|
1048
|
+
<Notice>
|
|
1049
|
+
The latest retained node metrics are stale. Values below were last
|
|
1050
|
+
sampled {formatTimestamp(usage.quantity.asOf)}.
|
|
1051
|
+
</Notice>
|
|
1052
|
+
</div>
|
|
1053
|
+
)}
|
|
1054
|
+
{usage.utilization.length > 0 ? (
|
|
1055
|
+
<div className="space-y-3">
|
|
1056
|
+
{usage.utilization.map((entry) => (
|
|
1057
|
+
<div key={entry.resource}>
|
|
1058
|
+
<div className="mb-1 text-xs font-medium text-theme-text-secondary">
|
|
1059
|
+
{resourceLabel(entry.resource)}
|
|
1060
|
+
</div>
|
|
1061
|
+
<ResourceBar
|
|
1062
|
+
used={formatQuantity(entry.resource, entry.usage)}
|
|
1063
|
+
total={formatQuantity(entry.resource, entry.allocatable)}
|
|
1064
|
+
percent={entry.percent}
|
|
1065
|
+
colorScheme="quiet"
|
|
1066
|
+
/>
|
|
1067
|
+
</div>
|
|
1068
|
+
))}
|
|
1069
|
+
</div>
|
|
1070
|
+
) : (
|
|
1071
|
+
<p className="text-sm text-theme-text-secondary">
|
|
1072
|
+
The usage source returned no comparable utilization values.
|
|
1073
|
+
</p>
|
|
1074
|
+
)}
|
|
1075
|
+
<p className="mt-3 text-xs text-theme-text-tertiary">
|
|
1076
|
+
Coverage: {usage.coveredNodes} of {usage.totalNodes} nodes · sampled{" "}
|
|
1077
|
+
{formatTimestamp(usage.quantity.asOf)}. Percentages include only nodes
|
|
1078
|
+
with samples.
|
|
1079
|
+
</p>
|
|
1080
|
+
</div>
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ============================================================================
|
|
1085
|
+
// Small layout primitives
|
|
1086
|
+
// ============================================================================
|
|
1087
|
+
|
|
1088
|
+
export function SectionCard({
|
|
1089
|
+
title,
|
|
1090
|
+
subtitle,
|
|
1091
|
+
actions,
|
|
1092
|
+
bodyClassName,
|
|
1093
|
+
children,
|
|
1094
|
+
}: {
|
|
1095
|
+
title: ReactNode;
|
|
1096
|
+
subtitle?: ReactNode;
|
|
1097
|
+
actions?: ReactNode;
|
|
1098
|
+
bodyClassName?: string;
|
|
1099
|
+
children: ReactNode;
|
|
1100
|
+
}) {
|
|
1101
|
+
return (
|
|
1102
|
+
<section className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
1103
|
+
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1 border-b border-theme-border px-4 py-3">
|
|
1104
|
+
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
|
1105
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">
|
|
1106
|
+
{title}
|
|
1107
|
+
</h2>
|
|
1108
|
+
{subtitle && (
|
|
1109
|
+
<span className="text-xs text-theme-text-tertiary">{subtitle}</span>
|
|
1110
|
+
)}
|
|
1111
|
+
</div>
|
|
1112
|
+
{actions}
|
|
1113
|
+
</div>
|
|
1114
|
+
<div className={bodyClassName}>{children}</div>
|
|
1115
|
+
</section>
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function KpiTile({
|
|
1120
|
+
label,
|
|
1121
|
+
value,
|
|
1122
|
+
sub,
|
|
1123
|
+
certainty,
|
|
1124
|
+
certaintyTitle,
|
|
1125
|
+
attention = false,
|
|
1126
|
+
linkLabel,
|
|
1127
|
+
onClick,
|
|
1128
|
+
}: {
|
|
1129
|
+
label: string;
|
|
1130
|
+
value: ReactNode;
|
|
1131
|
+
sub?: ReactNode;
|
|
1132
|
+
certainty?: CapacityCertainty;
|
|
1133
|
+
certaintyTitle?: string;
|
|
1134
|
+
attention?: boolean;
|
|
1135
|
+
linkLabel?: string;
|
|
1136
|
+
onClick?: () => void;
|
|
1137
|
+
}) {
|
|
1138
|
+
const clickable = Boolean(onClick);
|
|
1139
|
+
return (
|
|
1140
|
+
<div
|
|
1141
|
+
role={clickable ? "button" : undefined}
|
|
1142
|
+
tabIndex={clickable ? 0 : undefined}
|
|
1143
|
+
onClick={onClick}
|
|
1144
|
+
onKeyDown={
|
|
1145
|
+
clickable
|
|
1146
|
+
? (event) => {
|
|
1147
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1148
|
+
event.preventDefault();
|
|
1149
|
+
onClick?.();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
: undefined
|
|
1153
|
+
}
|
|
1154
|
+
className={`rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm ${
|
|
1155
|
+
clickable
|
|
1156
|
+
? "cursor-pointer transition-colors hover:border-theme-border-light hover:bg-theme-hover/40"
|
|
1157
|
+
: ""
|
|
1158
|
+
}`}
|
|
1159
|
+
>
|
|
1160
|
+
<div className="flex items-center gap-1.5">
|
|
1161
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
1162
|
+
{label}
|
|
1163
|
+
</span>
|
|
1164
|
+
{certainty && certainty !== "exact" && (
|
|
1165
|
+
// Deviation-only: an = on every tile trains the eye to skip the one
|
|
1166
|
+
// glyph that matters. Exact is the quiet default; ≥ ≤ ? announce
|
|
1167
|
+
// themselves.
|
|
1168
|
+
<CertaintyGlyph certainty={certainty} title={certaintyTitle} />
|
|
1169
|
+
)}
|
|
1170
|
+
{attention && (
|
|
1171
|
+
<Badge severity="warning" size="sm">
|
|
1172
|
+
Attention
|
|
1173
|
+
</Badge>
|
|
1174
|
+
)}
|
|
1175
|
+
</div>
|
|
1176
|
+
<div className="mt-1 font-mono text-2xl font-medium tabular-nums text-theme-text-primary">
|
|
1177
|
+
{value}
|
|
1178
|
+
</div>
|
|
1179
|
+
{sub && (
|
|
1180
|
+
<div className="mt-0.5 text-xs text-theme-text-secondary">{sub}</div>
|
|
1181
|
+
)}
|
|
1182
|
+
{linkLabel && (
|
|
1183
|
+
<div className="mt-1.5 text-xs font-medium text-accent-text">
|
|
1184
|
+
{linkLabel}
|
|
1185
|
+
</div>
|
|
1186
|
+
)}
|
|
1187
|
+
</div>
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
export function MiniCount({ label, value }: { label: string; value: number }) {
|
|
1192
|
+
return (
|
|
1193
|
+
<div className="rounded-lg border border-theme-border bg-theme-base/50 px-2.5 py-2">
|
|
1194
|
+
<div className="font-mono text-base font-semibold tabular-nums text-theme-text-primary">
|
|
1195
|
+
{value}
|
|
1196
|
+
</div>
|
|
1197
|
+
<div className="text-[11px] text-theme-text-tertiary">{label}</div>
|
|
1198
|
+
</div>
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export function KeyValueRows({ rows }: { rows: [string, ReactNode][] }) {
|
|
1203
|
+
return (
|
|
1204
|
+
<div className="divide-y divide-theme-border">
|
|
1205
|
+
{rows.map(([label, value], index) => (
|
|
1206
|
+
<div
|
|
1207
|
+
key={`${label}-${index}`}
|
|
1208
|
+
className="flex items-center justify-between gap-4 py-2 first:pt-0 last:pb-0"
|
|
1209
|
+
>
|
|
1210
|
+
<span className="text-sm text-theme-text-secondary">{label}</span>
|
|
1211
|
+
<span className="text-right font-mono text-sm text-theme-text-primary">
|
|
1212
|
+
{value}
|
|
1213
|
+
</span>
|
|
1214
|
+
</div>
|
|
1215
|
+
))}
|
|
1216
|
+
</div>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
export function TokenGroup({
|
|
1221
|
+
title,
|
|
1222
|
+
values,
|
|
1223
|
+
empty,
|
|
1224
|
+
}: {
|
|
1225
|
+
title: string;
|
|
1226
|
+
values: string[];
|
|
1227
|
+
empty: string;
|
|
1228
|
+
}) {
|
|
1229
|
+
return (
|
|
1230
|
+
<div>
|
|
1231
|
+
<div className="mb-1.5 text-xs font-medium text-theme-text-tertiary">
|
|
1232
|
+
{title}
|
|
1233
|
+
</div>
|
|
1234
|
+
<div className="flex flex-wrap gap-1">
|
|
1235
|
+
{values.length > 0 ? (
|
|
1236
|
+
values.map((value) => (
|
|
1237
|
+
<Badge key={value} tone="structural" size="sm">
|
|
1238
|
+
{value}
|
|
1239
|
+
</Badge>
|
|
1240
|
+
))
|
|
1241
|
+
) : (
|
|
1242
|
+
<span className="text-sm text-theme-text-secondary">{empty}</span>
|
|
1243
|
+
)}
|
|
1244
|
+
</div>
|
|
1245
|
+
</div>
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
export function Notice({ children }: { children: ReactNode }) {
|
|
1250
|
+
return (
|
|
1251
|
+
<div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text-secondary">
|
|
1252
|
+
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
|
|
1253
|
+
<div>{children}</div>
|
|
1254
|
+
</div>
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export function RefreshError({ message }: { message: string }) {
|
|
1259
|
+
return (
|
|
1260
|
+
<Notice>
|
|
1261
|
+
Refresh failed; showing the last successful capacity snapshot. {message}
|
|
1262
|
+
</Notice>
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export function ResourceLink({
|
|
1267
|
+
identity,
|
|
1268
|
+
label,
|
|
1269
|
+
className,
|
|
1270
|
+
onOpenResource,
|
|
1271
|
+
}: {
|
|
1272
|
+
identity: CapacityResourceIdentity;
|
|
1273
|
+
label?: string;
|
|
1274
|
+
className?: string;
|
|
1275
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
1276
|
+
}) {
|
|
1277
|
+
return (
|
|
1278
|
+
<Tooltip
|
|
1279
|
+
content={label ?? identity.ref.name}
|
|
1280
|
+
wrapperClassName="min-w-0 max-w-full"
|
|
1281
|
+
>
|
|
1282
|
+
<button
|
|
1283
|
+
type="button"
|
|
1284
|
+
className={
|
|
1285
|
+
className ??
|
|
1286
|
+
"font-mono text-xs font-medium text-accent-text hover:underline"
|
|
1287
|
+
}
|
|
1288
|
+
onClick={() => onOpenResource(identityToSelectedResource(identity))}
|
|
1289
|
+
>
|
|
1290
|
+
{label ?? identity.ref.name}
|
|
1291
|
+
</button>
|
|
1292
|
+
</Tooltip>
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
export function LinkButton({
|
|
1297
|
+
children,
|
|
1298
|
+
onClick,
|
|
1299
|
+
title,
|
|
1300
|
+
className,
|
|
1301
|
+
}: {
|
|
1302
|
+
children: ReactNode;
|
|
1303
|
+
onClick: () => void;
|
|
1304
|
+
title?: string;
|
|
1305
|
+
className?: string;
|
|
1306
|
+
}) {
|
|
1307
|
+
return (
|
|
1308
|
+
<WithTooltip tip={title}>
|
|
1309
|
+
<button
|
|
1310
|
+
type="button"
|
|
1311
|
+
onClick={onClick}
|
|
1312
|
+
className={`text-left text-xs font-medium text-accent-text hover:underline ${className ?? ""}`}
|
|
1313
|
+
>
|
|
1314
|
+
{children}
|
|
1315
|
+
</button>
|
|
1316
|
+
</WithTooltip>
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export function InlineEmpty({
|
|
1321
|
+
title,
|
|
1322
|
+
detail,
|
|
1323
|
+
}: {
|
|
1324
|
+
title: string;
|
|
1325
|
+
detail: string;
|
|
1326
|
+
}) {
|
|
1327
|
+
return (
|
|
1328
|
+
<div className="px-4 py-8 text-center">
|
|
1329
|
+
<div className="text-sm font-medium text-theme-text-primary">{title}</div>
|
|
1330
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{detail}</p>
|
|
1331
|
+
</div>
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
export function EmptyState({
|
|
1336
|
+
icon: Icon,
|
|
1337
|
+
title,
|
|
1338
|
+
detail,
|
|
1339
|
+
action,
|
|
1340
|
+
}: {
|
|
1341
|
+
icon: ComponentType<{ className?: string }>;
|
|
1342
|
+
title: string;
|
|
1343
|
+
detail: string;
|
|
1344
|
+
action?: ReactNode;
|
|
1345
|
+
}) {
|
|
1346
|
+
return (
|
|
1347
|
+
<div className="flex min-h-0 flex-1 items-center justify-center bg-theme-base p-6">
|
|
1348
|
+
<div className="max-w-md text-center">
|
|
1349
|
+
<Icon className="mx-auto h-9 w-9 text-theme-text-tertiary/50" />
|
|
1350
|
+
<h2 className="mt-3 text-lg font-medium text-theme-text-primary">
|
|
1351
|
+
{title}
|
|
1352
|
+
</h2>
|
|
1353
|
+
<p className="mt-1 text-sm text-theme-text-secondary">{detail}</p>
|
|
1354
|
+
{action}
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
export function ScrollableContent({ children }: { children: ReactNode }) {
|
|
1361
|
+
// Expanding/collapsing cards can toggle the scrollbar; a stable gutter
|
|
1362
|
+
// keeps the centered column from shifting sideways when that happens.
|
|
1363
|
+
// The margin (not padding — scrolled content paints over scrollport
|
|
1364
|
+
// padding) keeps a constant breathing gap under the top bar while
|
|
1365
|
+
// scrolled; the inner pt shrinks so the unscrolled rhythm is unchanged.
|
|
1366
|
+
return (
|
|
1367
|
+
<div
|
|
1368
|
+
className="mt-2 min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
|
1369
|
+
id="rk-scroll"
|
|
1370
|
+
>
|
|
1371
|
+
<div className="mx-auto max-w-[1760px] space-y-5 px-5 pb-5 pt-3 xl:px-7">
|
|
1372
|
+
{children}
|
|
1373
|
+
</div>
|
|
1374
|
+
</div>
|
|
1375
|
+
);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export function CapacityFreshness({
|
|
1379
|
+
meta,
|
|
1380
|
+
query,
|
|
1381
|
+
connectionState,
|
|
1382
|
+
}: {
|
|
1383
|
+
meta: Pick<CapacityResponseMeta, "generatedAt">;
|
|
1384
|
+
query: {
|
|
1385
|
+
dataUpdatedAt: number;
|
|
1386
|
+
isFetching: boolean;
|
|
1387
|
+
refetch: () => unknown;
|
|
1388
|
+
};
|
|
1389
|
+
connectionState: CapacityConnectionState;
|
|
1390
|
+
}) {
|
|
1391
|
+
return (
|
|
1392
|
+
<FreshnessControl
|
|
1393
|
+
mode="auto"
|
|
1394
|
+
dataUpdatedAt={generatedAtMillis(meta.generatedAt, query.dataUpdatedAt)}
|
|
1395
|
+
isFetching={query.isFetching}
|
|
1396
|
+
onRefresh={() => {
|
|
1397
|
+
query.refetch();
|
|
1398
|
+
}}
|
|
1399
|
+
connectionState={connectionState}
|
|
1400
|
+
/>
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** Header scope badge + optional lower-bound warning, from the pods coverage. */
|
|
1405
|
+
export function ScopeBadges({
|
|
1406
|
+
coverage,
|
|
1407
|
+
source = "pods",
|
|
1408
|
+
}: {
|
|
1409
|
+
coverage: CapacityCoverageBySource;
|
|
1410
|
+
// Which coverage entry carries this screen's namespace scope — Activity's
|
|
1411
|
+
// namespaced data flows through karpenterObjectEvents, not pods; defaulting
|
|
1412
|
+
// it to pods would show "Cluster-wide" for a namespace-scoped view.
|
|
1413
|
+
source?: keyof CapacityCoverageBySource;
|
|
1414
|
+
}) {
|
|
1415
|
+
const pods = coverage[source];
|
|
1416
|
+
const scope = pods?.scope;
|
|
1417
|
+
const explicit = scope === "explicit_namespaces";
|
|
1418
|
+
const scopeLabel = explicit
|
|
1419
|
+
? `Explicit namespaces${pods?.namespaces?.length ? ` (${pods.namespaces.length})` : ""}`
|
|
1420
|
+
: scope === "all_authorized_namespaces"
|
|
1421
|
+
? "Authorized namespaces"
|
|
1422
|
+
: "Cluster-wide";
|
|
1423
|
+
return (
|
|
1424
|
+
<span className="flex items-center gap-2">
|
|
1425
|
+
<WithTooltip tip="Pool, node and claim data is cluster-scoped. Pod and workload data covers authorized namespaces.">
|
|
1426
|
+
<Badge tone="structural" size="sm">
|
|
1427
|
+
{scopeLabel}
|
|
1428
|
+
</Badge>
|
|
1429
|
+
</WithTooltip>
|
|
1430
|
+
{coverageIsLowerBound(pods) && (
|
|
1431
|
+
<WithTooltip tip="Pod and workload data is restricted to authorized namespaces. Namespaced counts and demand are lower bounds.">
|
|
1432
|
+
<Badge severity="warning" size="sm">
|
|
1433
|
+
≥ Namespaced data is a lower bound
|
|
1434
|
+
</Badge>
|
|
1435
|
+
</WithTooltip>
|
|
1436
|
+
)}
|
|
1437
|
+
</span>
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// ============================================================================
|
|
1442
|
+
// Integration state gating (loading / syncing / not_detected / denied)
|
|
1443
|
+
// ============================================================================
|
|
1444
|
+
|
|
1445
|
+
// The RBAC a cluster admin must grant for Capacity, ready to paste. The two
|
|
1446
|
+
// rules mirror the two gates: nodes opens the page, Karpenter resources add
|
|
1447
|
+
// its screens. Kept minimal on purpose — optional depth (pods, metrics,
|
|
1448
|
+
// NodeClasses) degrades to labeled partial coverage rather than a 403.
|
|
1449
|
+
export const CAPACITY_VIEWER_CLUSTERROLE = `apiVersion: rbac.authorization.k8s.io/v1
|
|
1450
|
+
kind: ClusterRole
|
|
1451
|
+
metadata:
|
|
1452
|
+
name: radar-capacity-viewer
|
|
1453
|
+
rules:
|
|
1454
|
+
- apiGroups: [""]
|
|
1455
|
+
resources: ["nodes"]
|
|
1456
|
+
verbs: ["list", "watch"] # opens the Capacity page
|
|
1457
|
+
- apiGroups: ["karpenter.sh"]
|
|
1458
|
+
resources: ["nodepools", "nodeclaims"]
|
|
1459
|
+
verbs: ["list", "watch"] # adds the Karpenter screens
|
|
1460
|
+
`;
|
|
1461
|
+
|
|
1462
|
+
// DeniedCapacityState is the 403 an operator can act on: what the denial
|
|
1463
|
+
// means, what they can still see without these permissions, and the exact
|
|
1464
|
+
// grant to request — a dead end converted into a next step.
|
|
1465
|
+
export function DeniedCapacityState({ detail }: { detail: string }) {
|
|
1466
|
+
const [copied, setCopied] = useState(false);
|
|
1467
|
+
return (
|
|
1468
|
+
<EmptyState
|
|
1469
|
+
icon={Shield}
|
|
1470
|
+
title="Capacity access denied"
|
|
1471
|
+
detail={detail}
|
|
1472
|
+
action={
|
|
1473
|
+
<div className="mt-4 text-left">
|
|
1474
|
+
<p className="text-xs text-theme-text-secondary">
|
|
1475
|
+
Your readable pods still carry their scheduler verdicts — the Pod
|
|
1476
|
+
drawer and Issues answer “why is this pod pending” at pod grain
|
|
1477
|
+
without these permissions.
|
|
1478
|
+
</p>
|
|
1479
|
+
<div className="mt-3 rounded-lg border border-theme-border bg-theme-surface">
|
|
1480
|
+
<div className="flex items-center justify-between border-b border-theme-border-subtle px-3 py-1.5">
|
|
1481
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
|
|
1482
|
+
Permissions to request
|
|
1483
|
+
</span>
|
|
1484
|
+
<LinkButton
|
|
1485
|
+
onClick={() => {
|
|
1486
|
+
void navigator.clipboard?.writeText(
|
|
1487
|
+
CAPACITY_VIEWER_CLUSTERROLE,
|
|
1488
|
+
);
|
|
1489
|
+
setCopied(true);
|
|
1490
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
1491
|
+
}}
|
|
1492
|
+
>
|
|
1493
|
+
{copied ? "Copied" : "Copy"}
|
|
1494
|
+
</LinkButton>
|
|
1495
|
+
</div>
|
|
1496
|
+
<pre className="overflow-x-auto px-3 py-2 font-mono text-[11px] leading-relaxed text-theme-text-secondary">
|
|
1497
|
+
{CAPACITY_VIEWER_CLUSTERROLE}
|
|
1498
|
+
</pre>
|
|
1499
|
+
</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
}
|
|
1502
|
+
/>
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export function integrationBlock(
|
|
1507
|
+
response:
|
|
1508
|
+
| {
|
|
1509
|
+
state: CapacityIntegrationState;
|
|
1510
|
+
coverage?: CapacityCoverageBySource;
|
|
1511
|
+
}
|
|
1512
|
+
| undefined,
|
|
1513
|
+
error: Error | null,
|
|
1514
|
+
isLoading: boolean,
|
|
1515
|
+
loadingLabel: string,
|
|
1516
|
+
): ReactNode | null {
|
|
1517
|
+
if (!response && isLoading)
|
|
1518
|
+
return <PaneLoader label={loadingLabel} className="min-h-0 flex-1" />;
|
|
1519
|
+
if (!response && error) {
|
|
1520
|
+
return isForbiddenError(error) ? (
|
|
1521
|
+
<DeniedCapacityState detail={errorMessage(error)} />
|
|
1522
|
+
) : (
|
|
1523
|
+
<EmptyState
|
|
1524
|
+
icon={AlertTriangle}
|
|
1525
|
+
title="Capacity unavailable"
|
|
1526
|
+
detail={errorMessage(error)}
|
|
1527
|
+
/>
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
if (!response)
|
|
1531
|
+
return <PaneLoader label={loadingLabel} className="min-h-0 flex-1" />;
|
|
1532
|
+
if (response.state === "syncing")
|
|
1533
|
+
return (
|
|
1534
|
+
<PaneLoader
|
|
1535
|
+
label={capacitySyncingLabel(response.coverage?.nodePools?.reasonCode)}
|
|
1536
|
+
className="min-h-0 flex-1"
|
|
1537
|
+
/>
|
|
1538
|
+
);
|
|
1539
|
+
if (response.state === "not_detected")
|
|
1540
|
+
return (
|
|
1541
|
+
<EmptyState
|
|
1542
|
+
icon={Gauge}
|
|
1543
|
+
title="Karpenter not detected"
|
|
1544
|
+
detail="Capacity appears automatically when this cluster exposes Karpenter NodePools."
|
|
1545
|
+
/>
|
|
1546
|
+
);
|
|
1547
|
+
if (response.state === "denied")
|
|
1548
|
+
return (
|
|
1549
|
+
<DeniedCapacityState detail="This user cannot read the Karpenter resources Capacity needs to diagnose this cluster." />
|
|
1550
|
+
);
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function capacitySyncingLabel(reasonCode?: string): string {
|
|
1555
|
+
switch (reasonCode) {
|
|
1556
|
+
case "cluster_connecting":
|
|
1557
|
+
return "Connecting to the cluster before checking Karpenter…";
|
|
1558
|
+
case "cluster_disconnected":
|
|
1559
|
+
return "Cluster disconnected; waiting to reconnect…";
|
|
1560
|
+
case "cluster_client_unavailable":
|
|
1561
|
+
return "Waiting for the Kubernetes client…";
|
|
1562
|
+
case "discovery_unavailable":
|
|
1563
|
+
return "Waiting for Kubernetes API discovery…";
|
|
1564
|
+
case "karpenter_discovery_partial":
|
|
1565
|
+
return "Karpenter API discovery is incomplete; retrying…";
|
|
1566
|
+
case "dynamic_cache_unavailable":
|
|
1567
|
+
return "Waiting for the Karpenter resource cache…";
|
|
1568
|
+
case "nodepools_syncing":
|
|
1569
|
+
return "Syncing Karpenter NodePools…";
|
|
1570
|
+
default:
|
|
1571
|
+
return "Capacity data is syncing…";
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// ============================================================================
|
|
1576
|
+
// Keyset pagination + cursor recovery
|
|
1577
|
+
// ============================================================================
|
|
1578
|
+
|
|
1579
|
+
interface CapacityPaginationState<T> {
|
|
1580
|
+
key: string;
|
|
1581
|
+
cursor?: string;
|
|
1582
|
+
history: (string | undefined)[];
|
|
1583
|
+
retainedPage?: T;
|
|
1584
|
+
recovered: boolean;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
export function useCapacityPagination<T>(key: string) {
|
|
1588
|
+
const [state, setState] = useState<CapacityPaginationState<T>>(() => ({
|
|
1589
|
+
key,
|
|
1590
|
+
history: [],
|
|
1591
|
+
recovered: false,
|
|
1592
|
+
}));
|
|
1593
|
+
const emptyPage = (): CapacityPaginationState<T> => ({
|
|
1594
|
+
key,
|
|
1595
|
+
history: [],
|
|
1596
|
+
recovered: false,
|
|
1597
|
+
});
|
|
1598
|
+
const active = state.key === key ? state : emptyPage();
|
|
1599
|
+
const reset = useCallback(
|
|
1600
|
+
() => setState({ key, history: [], recovered: false }),
|
|
1601
|
+
[key],
|
|
1602
|
+
);
|
|
1603
|
+
const recover = useCallback(
|
|
1604
|
+
() =>
|
|
1605
|
+
setState((current) => ({
|
|
1606
|
+
key,
|
|
1607
|
+
history: [],
|
|
1608
|
+
retainedPage: current.key === key ? current.retainedPage : undefined,
|
|
1609
|
+
recovered: true,
|
|
1610
|
+
})),
|
|
1611
|
+
[key],
|
|
1612
|
+
);
|
|
1613
|
+
const goNext = useCallback(
|
|
1614
|
+
(nextCursor: string, retainedPage?: T) => {
|
|
1615
|
+
setState((current) => {
|
|
1616
|
+
const page =
|
|
1617
|
+
current.key === key
|
|
1618
|
+
? current
|
|
1619
|
+
: {
|
|
1620
|
+
key,
|
|
1621
|
+
history: [] as (string | undefined)[],
|
|
1622
|
+
recovered: false,
|
|
1623
|
+
};
|
|
1624
|
+
return {
|
|
1625
|
+
key,
|
|
1626
|
+
cursor: nextCursor,
|
|
1627
|
+
history: [...page.history, page.cursor],
|
|
1628
|
+
retainedPage,
|
|
1629
|
+
recovered: false,
|
|
1630
|
+
};
|
|
1631
|
+
});
|
|
1632
|
+
},
|
|
1633
|
+
[key],
|
|
1634
|
+
);
|
|
1635
|
+
const goBack = useCallback(
|
|
1636
|
+
(retainedPage?: T) => {
|
|
1637
|
+
setState((current) => {
|
|
1638
|
+
const page =
|
|
1639
|
+
current.key === key
|
|
1640
|
+
? current
|
|
1641
|
+
: {
|
|
1642
|
+
key,
|
|
1643
|
+
history: [] as (string | undefined)[],
|
|
1644
|
+
recovered: false,
|
|
1645
|
+
};
|
|
1646
|
+
if (page.history.length === 0) return page;
|
|
1647
|
+
return {
|
|
1648
|
+
key,
|
|
1649
|
+
cursor: page.history[page.history.length - 1],
|
|
1650
|
+
history: page.history.slice(0, -1),
|
|
1651
|
+
retainedPage,
|
|
1652
|
+
recovered: false,
|
|
1653
|
+
};
|
|
1654
|
+
});
|
|
1655
|
+
},
|
|
1656
|
+
[key],
|
|
1657
|
+
);
|
|
1658
|
+
|
|
1659
|
+
return {
|
|
1660
|
+
cursor: active.cursor,
|
|
1661
|
+
history: active.history,
|
|
1662
|
+
retainedPage: active.retainedPage,
|
|
1663
|
+
recovered: active.recovered,
|
|
1664
|
+
reset,
|
|
1665
|
+
recover,
|
|
1666
|
+
goNext,
|
|
1667
|
+
goBack,
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
export function useCapacityCursorRecovery(
|
|
1672
|
+
error: unknown,
|
|
1673
|
+
cursor: string | undefined,
|
|
1674
|
+
recover: () => void,
|
|
1675
|
+
): boolean {
|
|
1676
|
+
const invalid = Boolean(cursor && isCapacityCursorInvalidError(error));
|
|
1677
|
+
|
|
1678
|
+
useEffect(() => {
|
|
1679
|
+
if (!invalid) return;
|
|
1680
|
+
let cancelled = false;
|
|
1681
|
+
queueMicrotask(() => {
|
|
1682
|
+
if (!cancelled) recover();
|
|
1683
|
+
});
|
|
1684
|
+
return () => {
|
|
1685
|
+
cancelled = true;
|
|
1686
|
+
};
|
|
1687
|
+
}, [invalid, recover]);
|
|
1688
|
+
|
|
1689
|
+
return invalid;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
export function PageControls({
|
|
1693
|
+
page,
|
|
1694
|
+
hasPrevious,
|
|
1695
|
+
hasNext,
|
|
1696
|
+
busy,
|
|
1697
|
+
onPrevious,
|
|
1698
|
+
onNext,
|
|
1699
|
+
}: {
|
|
1700
|
+
page: number;
|
|
1701
|
+
hasPrevious: boolean;
|
|
1702
|
+
hasNext: boolean;
|
|
1703
|
+
busy: boolean;
|
|
1704
|
+
onPrevious: () => void;
|
|
1705
|
+
onNext: () => void;
|
|
1706
|
+
}) {
|
|
1707
|
+
return (
|
|
1708
|
+
<div className="flex items-center justify-end gap-2">
|
|
1709
|
+
<span className="mr-1 text-xs text-theme-text-tertiary">Page {page}</span>
|
|
1710
|
+
<button
|
|
1711
|
+
type="button"
|
|
1712
|
+
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"
|
|
1713
|
+
disabled={!hasPrevious || busy}
|
|
1714
|
+
onClick={onPrevious}
|
|
1715
|
+
>
|
|
1716
|
+
<ChevronLeft className="mr-1 inline h-3.5 w-3.5" />
|
|
1717
|
+
Previous
|
|
1718
|
+
</button>
|
|
1719
|
+
<button
|
|
1720
|
+
type="button"
|
|
1721
|
+
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"
|
|
1722
|
+
disabled={!hasNext || busy}
|
|
1723
|
+
onClick={onNext}
|
|
1724
|
+
>
|
|
1725
|
+
Next
|
|
1726
|
+
<ChevronRight className="ml-1 inline h-3.5 w-3.5" />
|
|
1727
|
+
</button>
|
|
1728
|
+
</div>
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// ============================================================================
|
|
1733
|
+
// NodePool selector — a paginated <select> shared by Demand ("evaluate against")
|
|
1734
|
+
// and Activity ("filter by pool"). The label/empty/error copy differs per caller;
|
|
1735
|
+
// the paging behaviour (load-more cursor, invalid-cursor recovery) does not.
|
|
1736
|
+
// ============================================================================
|
|
1737
|
+
|
|
1738
|
+
const POOL_PAGE_LIMIT = 100;
|
|
1739
|
+
const LOAD_MORE_POOLS = "__load_more_nodepools__";
|
|
1740
|
+
|
|
1741
|
+
export function PoolSelector({
|
|
1742
|
+
pool,
|
|
1743
|
+
onChange,
|
|
1744
|
+
label,
|
|
1745
|
+
emptyLabel,
|
|
1746
|
+
unavailableLabel,
|
|
1747
|
+
}: {
|
|
1748
|
+
pool: string | undefined;
|
|
1749
|
+
onChange: (pool: string | undefined) => void;
|
|
1750
|
+
label: string;
|
|
1751
|
+
emptyLabel: string;
|
|
1752
|
+
unavailableLabel: string;
|
|
1753
|
+
}) {
|
|
1754
|
+
const [cursor, setCursor] = useState<string>();
|
|
1755
|
+
const [loadedPools, setLoadedPools] = useState<string[]>([]);
|
|
1756
|
+
const query = useCapacityPools({
|
|
1757
|
+
limit: POOL_PAGE_LIMIT,
|
|
1758
|
+
cursor,
|
|
1759
|
+
refetchInterval: false,
|
|
1760
|
+
});
|
|
1761
|
+
const recoverCursor = useCallback(() => {
|
|
1762
|
+
setCursor(undefined);
|
|
1763
|
+
setLoadedPools([]);
|
|
1764
|
+
}, []);
|
|
1765
|
+
const recoveringCursor = useCapacityCursorRecovery(
|
|
1766
|
+
query.error,
|
|
1767
|
+
cursor,
|
|
1768
|
+
recoverCursor,
|
|
1769
|
+
);
|
|
1770
|
+
const page = query.isPlaceholderData ? undefined : query.data;
|
|
1771
|
+
const pageNames = page?.items.map((item) => item.resource.ref.name) ?? [];
|
|
1772
|
+
const poolNames = Array.from(new Set([...loadedPools, ...pageNames])).sort();
|
|
1773
|
+
if (pool && !poolNames.includes(pool)) poolNames.unshift(pool);
|
|
1774
|
+
|
|
1775
|
+
const loadingMore = Boolean(
|
|
1776
|
+
cursor && query.isFetching && query.isPlaceholderData,
|
|
1777
|
+
);
|
|
1778
|
+
const hasMore = Boolean(page?.page.hasMore && page.page.nextCursor);
|
|
1779
|
+
const statusId = "pool-selector-options-status";
|
|
1780
|
+
|
|
1781
|
+
return (
|
|
1782
|
+
<div className="flex min-w-56 flex-col gap-1">
|
|
1783
|
+
<label className="flex items-center gap-2 text-xs text-theme-text-secondary">
|
|
1784
|
+
<span className="shrink-0 font-medium">{label}</span>
|
|
1785
|
+
<select
|
|
1786
|
+
value={pool ?? ""}
|
|
1787
|
+
aria-describedby={query.error ? statusId : undefined}
|
|
1788
|
+
aria-busy={query.isFetching}
|
|
1789
|
+
onChange={(event) => {
|
|
1790
|
+
if (event.target.value === LOAD_MORE_POOLS) {
|
|
1791
|
+
if (page?.page.nextCursor) {
|
|
1792
|
+
setLoadedPools((current) =>
|
|
1793
|
+
Array.from(new Set([...current, ...pageNames])).sort(),
|
|
1794
|
+
);
|
|
1795
|
+
setCursor(page.page.nextCursor);
|
|
1796
|
+
}
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
onChange(event.target.value || undefined);
|
|
1800
|
+
}}
|
|
1801
|
+
className="min-w-0 flex-1 rounded-md border border-theme-border bg-theme-elevated px-2 py-1.5 text-xs text-theme-text-primary outline-none focus:border-skyhook-500"
|
|
1802
|
+
>
|
|
1803
|
+
<option value="">{emptyLabel}</option>
|
|
1804
|
+
{poolNames.map((name) => (
|
|
1805
|
+
<option key={name} value={name}>
|
|
1806
|
+
{name}
|
|
1807
|
+
</option>
|
|
1808
|
+
))}
|
|
1809
|
+
{query.isLoading && <option disabled>Loading NodePools…</option>}
|
|
1810
|
+
{loadingMore && <option disabled>Loading more NodePools…</option>}
|
|
1811
|
+
{!loadingMore && hasMore && (
|
|
1812
|
+
<option value={LOAD_MORE_POOLS}>Load more NodePools…</option>
|
|
1813
|
+
)}
|
|
1814
|
+
</select>
|
|
1815
|
+
</label>
|
|
1816
|
+
{query.error && (
|
|
1817
|
+
<span
|
|
1818
|
+
id={statusId}
|
|
1819
|
+
role="status"
|
|
1820
|
+
className="text-right text-xs text-theme-text-tertiary"
|
|
1821
|
+
>
|
|
1822
|
+
{recoveringCursor
|
|
1823
|
+
? "NodePool list changed; reloading options."
|
|
1824
|
+
: unavailableLabel}
|
|
1825
|
+
</span>
|
|
1826
|
+
)}
|
|
1827
|
+
</div>
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ============================================================================
|
|
1832
|
+
// Shared table cell classes (keeps every capacity table visually identical)
|
|
1833
|
+
// ============================================================================
|
|
1834
|
+
|
|
1835
|
+
export const TABLE_WRAP = "overflow-x-auto";
|
|
1836
|
+
export const TABLE_HEAD =
|
|
1837
|
+
"border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary";
|
|
1838
|
+
export const TH = "px-3 py-2.5 text-left font-medium whitespace-nowrap";
|
|
1839
|
+
export const TD = "px-3 py-2.5 align-top text-sm text-theme-text-primary";
|
|
1840
|
+
export const TBODY = "table-divide-subtle";
|
|
1841
|
+
export const ROW_HOVER = "transition-colors hover:bg-theme-hover/50";
|