@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,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 &amp; 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";