@skyhook-io/radar-app 1.9.1 → 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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +42 -7
  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 +2884 -2075
  7. package/src/api/diagnose.ts +5 -0
  8. package/src/components/ConnectionErrorView.test.tsx +88 -0
  9. package/src/components/ConnectionErrorView.tsx +128 -22
  10. package/src/components/capacity/CapacityActivity.tsx +787 -0
  11. package/src/components/capacity/CapacityDemand.tsx +961 -0
  12. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  13. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  14. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  15. package/src/components/capacity/CapacityView.tsx +85 -0
  16. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  17. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  18. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  19. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  20. package/src/components/capacity/podDemandGate.test.ts +47 -0
  21. package/src/components/capacity/podDemandGate.ts +22 -0
  22. package/src/components/capacity/schedulingBar.test.ts +244 -0
  23. package/src/components/capacity/shared.tsx +1841 -0
  24. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +51 -10
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
  27. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  28. package/src/components/diagnose/agentCatalog.ts +30 -0
  29. package/src/components/home/CapacityCard.test.tsx +150 -0
  30. package/src/components/home/CapacityCard.tsx +125 -0
  31. package/src/components/home/HomeView.tsx +15 -1
  32. package/src/components/issues/IssuesPane.test.ts +142 -0
  33. package/src/components/issues/IssuesPane.tsx +142 -38
  34. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  35. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  36. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  37. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  38. package/src/components/ui/command-items.ts +222 -98
  39. package/src/components/workload/WorkloadView.tsx +16 -83
  40. package/src/context/ConnectionContext.test.ts +39 -0
  41. package/src/context/ConnectionContext.tsx +155 -51
  42. package/src/utils/shell-safe.test.ts +55 -0
  43. package/src/utils/shell-safe.ts +21 -0
@@ -0,0 +1,117 @@
1
+ import { useState } from "react";
2
+ import { Sparkles, Copy, Check, ExternalLink, RotateCw } from "lucide-react";
3
+ import { SUPPORTED_AGENTS, type AgentInstall } from "./agentCatalog";
4
+ import { type DiagnoseSetup } from "./DiagnoseContext";
5
+
6
+ function CopyButton({ text }: { text: string }) {
7
+ const [copied, setCopied] = useState(false);
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={() => {
12
+ void navigator.clipboard?.writeText(text);
13
+ setCopied(true);
14
+ setTimeout(() => setCopied(false), 1100);
15
+ }}
16
+ className="shrink-0 rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
17
+ aria-label={copied ? "Copied" : "Copy install command"}
18
+ >
19
+ {copied ? (
20
+ <Check className="h-3.5 w-3.5 text-emerald-500" />
21
+ ) : (
22
+ <Copy className="h-3.5 w-3.5" />
23
+ )}
24
+ </button>
25
+ );
26
+ }
27
+
28
+ function AgentRow({ agent }: { agent: AgentInstall }) {
29
+ return (
30
+ <div className="rounded-lg border border-theme-border bg-theme-base p-3">
31
+ <div className="mb-2 flex items-center justify-between gap-2">
32
+ <span className="text-sm font-medium text-theme-text-primary">
33
+ {agent.label}
34
+ </span>
35
+ <a
36
+ href={agent.docs}
37
+ target="_blank"
38
+ rel="noreferrer"
39
+ className="inline-flex items-center gap-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
40
+ >
41
+ Docs
42
+ <ExternalLink className="h-3 w-3" />
43
+ </a>
44
+ </div>
45
+ <div className="flex items-center gap-1.5 rounded-md bg-theme-elevated px-2 py-1.5">
46
+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap font-mono text-xs text-theme-text-secondary">
47
+ {agent.install}
48
+ </code>
49
+ <CopyButton text={agent.install} />
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ // Shown in the AI surface's Home when diagnosis is eligible in this deployment
56
+ // but not runnable yet — either no agent CLI is installed ("needs-install") or a
57
+ // supported one appeared after Radar booted ("needs-restart"). Radar decides the
58
+ // engine once at startup, so a fresh install needs a restart to take effect.
59
+ export function AgentSetupNotice({
60
+ setupState,
61
+ }: {
62
+ setupState: DiagnoseSetup;
63
+ }) {
64
+ const needsRestart = setupState === "needs-restart";
65
+ return (
66
+ <div className="mx-auto max-w-md px-1 py-6">
67
+ <div className="mb-3 flex h-10 w-10 items-center justify-center rounded-xl border border-accent/30 bg-accent/5">
68
+ <Sparkles className="h-5 w-5 text-accent" />
69
+ </div>
70
+ <h3 className="text-base font-semibold text-theme-text-primary">
71
+ {needsRestart
72
+ ? "Restart Radar to enable AI diagnosis"
73
+ : "Set up AI diagnosis"}
74
+ </h3>
75
+ <p className="mt-1 text-sm text-theme-text-secondary">
76
+ {needsRestart ? (
77
+ <>
78
+ A supported agent CLI is now installed, but Radar started before it
79
+ was — restart Radar to pick it up. Investigations then run locally
80
+ on your machine, keyless, using your own agent.
81
+ </>
82
+ ) : (
83
+ <>
84
+ Radar runs investigations through a coding agent CLI on your own
85
+ machine — no Radar cloud, no API key. Install one of these, then
86
+ restart Radar:
87
+ </>
88
+ )}
89
+ </p>
90
+
91
+ {!needsRestart && (
92
+ <div className="mt-4 flex flex-col gap-2.5">
93
+ {SUPPORTED_AGENTS.map((a) => (
94
+ <AgentRow key={a.name} agent={a} />
95
+ ))}
96
+ </div>
97
+ )}
98
+
99
+ {/* The panel only re-reads /api/agents on load, so after installing or
100
+ restarting the user needs a way to refresh in place rather than
101
+ hunting for a manual browser reload. */}
102
+ <button
103
+ type="button"
104
+ onClick={() => window.location.reload()}
105
+ className="btn-brand mt-4 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm"
106
+ >
107
+ <RotateCw className="h-3.5 w-3.5" />
108
+ {needsRestart ? "Reload after restarting" : "Reload after installing"}
109
+ </button>
110
+
111
+ <p className="mt-4 text-xs text-theme-text-tertiary">
112
+ The agent reads this cluster through Radar and finds the root cause. It
113
+ never leaves your machine.
114
+ </p>
115
+ </div>
116
+ );
117
+ }
@@ -35,8 +35,18 @@ export interface Target {
35
35
  }
36
36
  export type DiagnoseView = "home" | "investigation";
37
37
 
38
+ // Setup readiness of the local AI-diagnosis feature, derived from the agents API:
39
+ // - "ready": an agent is installed and the engine is running (available)
40
+ // - "needs-install": the feature is supported here but no agent CLI is installed
41
+ // - "needs-restart": a supported agent is now on PATH but Radar booted before it
42
+ // existed (the engine is decided once, at startup)
43
+ // - "off": not available in this deployment (proxy/OIDC auth, --no-mcp,
44
+ // or an embed host) — no install nudge would help
45
+ export type DiagnoseSetup = "ready" | "needs-install" | "needs-restart" | "off";
46
+
38
47
  interface DiagnoseCtx {
39
48
  available: boolean; // an agent CLI is present (button/entry gate)
49
+ setupState: DiagnoseSetup; // readiness for the setup nudge (see DiagnoseSetup)
40
50
  agentLabel: string; // label of the selected agent, e.g. "Claude Code"
41
51
  hosted: boolean; // selected agent runs on the host's backend, not this machine
42
52
  agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
@@ -86,7 +96,11 @@ interface DiagnoseLayoutCtx {
86
96
 
87
97
  // Stable key for "is THIS resource being investigated right now" — built the same way
88
98
  // from a run summary and from a button's target so the two always match.
89
- export function runTargetKey(kind: string, namespace: string, name: string): string {
99
+ export function runTargetKey(
100
+ kind: string,
101
+ namespace: string,
102
+ name: string,
103
+ ): string {
90
104
  return `${kind} ${namespace} ${name}`;
91
105
  }
92
106
 
@@ -95,7 +109,8 @@ const LayoutCtx = createContext<DiagnoseLayoutCtx | null>(null);
95
109
 
96
110
  export function useDiagnoseLayout(): DiagnoseLayoutCtx {
97
111
  const c = useContext(LayoutCtx);
98
- if (!c) throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
112
+ if (!c)
113
+ throw new Error("useDiagnoseLayout must be used within DiagnoseProvider");
99
114
  return c;
100
115
  }
101
116
 
@@ -161,6 +176,7 @@ function writeStored(key: string, value: string) {
161
176
 
162
177
  export function DiagnoseProvider({ children }: { children: ReactNode }) {
163
178
  const [available, setAvailable] = useState(false);
179
+ const [eligible, setEligible] = useState(false);
164
180
  const [agents, setAgents] = useState<AgentInfo[]>([]);
165
181
  const [consented, setConsented] = useState<Record<string, boolean>>({});
166
182
  const [selectedAgent, setSelectedAgentState] = useState<string>(
@@ -205,6 +221,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
205
221
  .then((r) => {
206
222
  if (!live) return;
207
223
  setConsented(r.consented ?? {});
224
+ setEligible(!!r.eligible);
208
225
  const supported = r.agents.filter(
209
226
  (a) =>
210
227
  a.supported &&
@@ -256,7 +273,8 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
256
273
  (name: string) => {
257
274
  setSelectedAgentState(name);
258
275
  writeStored(AGENT_KEY, name);
259
- const nextProfile = agents.find((agent) => agent.name === name)?.profiles?.[0];
276
+ const nextProfile = agents.find((agent) => agent.name === name)
277
+ ?.profiles?.[0];
260
278
  if (nextProfile) {
261
279
  setProfileState(nextProfile);
262
280
  writeStored(PROFILE_KEY, nextProfile);
@@ -274,10 +292,9 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
274
292
  }, []);
275
293
 
276
294
  const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
277
- const effectiveProfile =
278
- selectedAgentInfo?.profiles?.includes(profile)
279
- ? profile
280
- : (selectedAgentInfo?.profiles?.[0] ?? profile);
295
+ const effectiveProfile = selectedAgentInfo?.profiles?.includes(profile)
296
+ ? profile
297
+ : (selectedAgentInfo?.profiles?.[0] ?? profile);
281
298
  const agentLabel = agentLabelFor(selectedAgent, selectedAgentInfo?.label);
282
299
  const hosted = !!selectedAgentInfo?.hosted;
283
300
  // Hosted Radar uses its existing per-user disclosure surface and supplies its
@@ -286,6 +303,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
286
303
  ? "standard"
287
304
  : (selectedAgentInfo?.consentSurfaces?.[effectiveProfile] ?? "");
288
305
 
306
+ // `agents` holds only supported CLIs (filtered on fetch), so a non-empty list
307
+ // while the engine is off means a drivable agent appeared on PATH after boot.
308
+ const setupState: DiagnoseSetup = available
309
+ ? "ready"
310
+ : !eligible
311
+ ? "off"
312
+ : agents.length > 0
313
+ ? "needs-restart"
314
+ : "needs-install";
315
+
289
316
  useEffect(() => {
290
317
  const onResize = () => setViewportW(window.innerWidth);
291
318
  window.addEventListener("resize", onResize);
@@ -374,7 +401,9 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
374
401
  setOpen(true);
375
402
  if (!hosted && !consentSurface) {
376
403
  setPendingTarget(null);
377
- setStartError("Radar can’t run this agent with a verified execution profile.");
404
+ setStartError(
405
+ "Radar can’t run this agent with a verified execution profile.",
406
+ );
378
407
  setView("investigation");
379
408
  return;
380
409
  }
@@ -410,7 +439,9 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
410
439
  window.history.replaceState(
411
440
  null,
412
441
  "",
413
- window.location.pathname + (qs ? `?${qs}` : "") + window.location.hash,
442
+ window.location.pathname +
443
+ (qs ? `?${qs}` : "") +
444
+ window.location.hash,
414
445
  );
415
446
  }
416
447
  } catch {
@@ -464,6 +495,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
464
495
 
465
496
  const value: DiagnoseCtx = {
466
497
  available,
498
+ setupState,
467
499
  agentLabel,
468
500
  hosted,
469
501
  agents,
@@ -513,7 +545,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
513
545
  panelWidthKey: WIDTH_KEY,
514
546
  runningKeys,
515
547
  }),
516
- [open, contentGutter, maximized, width, narrow, runningKeys, setMaximized, setWidth],
548
+ [
549
+ open,
550
+ contentGutter,
551
+ maximized,
552
+ width,
553
+ narrow,
554
+ runningKeys,
555
+ setMaximized,
556
+ setWidth,
557
+ ],
517
558
  );
518
559
 
519
560
  return (
@@ -26,6 +26,7 @@ import {
26
26
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
27
27
  import { InvestigationView } from "./InvestigationView";
28
28
  import { RecentList } from "./Home";
29
+ import { AgentSetupNotice } from "./AgentSetupNotice";
29
30
  import { ConsentCard } from "./parts";
30
31
  import { buildLaunchCommand, launchAgentLabel, openInTerminal } from "./launch";
31
32
  import { type RunSummary, type ExecutionProfile } from "../../api/diagnose";
@@ -179,6 +180,11 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
179
180
  document.addEventListener("mouseup", onUp);
180
181
  };
181
182
 
183
+ // Feature is eligible here but not runnable yet (no agent installed, or one
184
+ // appeared after boot) — Home leads with the setup notice instead of an empty list.
185
+ const setupPending =
186
+ d.setupState === "needs-install" || d.setupState === "needs-restart";
187
+
182
188
  const activeRun = d.runs.find((r) => r.id === d.activeRunId) ?? null;
183
189
  // A focused run shows the agent it actually ran with; Home reflects the current pick.
184
190
  const activeAgentLabel = activeRun?.agent
@@ -263,6 +269,10 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
263
269
  Dismiss
264
270
  </button>
265
271
  </div>
272
+ ) : setupPending ? (
273
+ <div className="flex-1 overflow-y-auto">
274
+ <AgentSetupNotice setupState={d.setupState} />
275
+ </div>
266
276
  ) : (
267
277
  <div className="flex flex-1 items-center justify-center px-6 text-center text-sm text-theme-text-tertiary">
268
278
  Select an investigation, or open a resource and click Diagnose.
@@ -362,7 +372,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
362
372
  only appears when expanded; keys keep the detail node identity-stable
363
373
  as it comes and goes. */}
364
374
  <div className="flex min-h-0 flex-1">
365
- {showHistory && (
375
+ {showHistory && (!setupPending || d.runs.length > 0) && (
366
376
  <aside
367
377
  key="recent"
368
378
  className="w-72 shrink-0 overflow-y-auto border-r border-theme-border px-3 py-3"
@@ -381,12 +391,15 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
381
391
  key="main"
382
392
  className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3"
383
393
  >
384
- <RecentList
385
- agentLabel={d.agentLabel}
386
- runs={d.runs}
387
- onSelect={d.openRun}
388
- historyDegraded={d.historyDegraded}
389
- />
394
+ {setupPending && <AgentSetupNotice setupState={d.setupState} />}
395
+ {(!setupPending || d.runs.length > 0) && (
396
+ <RecentList
397
+ agentLabel={d.agentLabel}
398
+ runs={d.runs}
399
+ onSelect={d.openRun}
400
+ historyDegraded={d.historyDegraded}
401
+ />
402
+ )}
390
403
  </div>
391
404
  ) : (
392
405
  <div key="main" className="flex min-h-0 min-w-0 flex-1 flex-col">
@@ -1,5 +1,9 @@
1
1
  import { Loader2, Sparkles } from "lucide-react";
2
- import { useDiagnose, useDiagnoseLayout, runTargetKey } from "./DiagnoseContext";
2
+ import {
3
+ useDiagnose,
4
+ useDiagnoseLayout,
5
+ runTargetKey,
6
+ } from "./DiagnoseContext";
3
7
  import { Tooltip } from "../ui/Tooltip";
4
8
  import type { RenderDiagnoseAction } from "../../context/DiagnoseCustomization";
5
9
 
@@ -27,31 +31,41 @@ function DiagnoseResourceButton({
27
31
  }) {
28
32
  const d = useDiagnose();
29
33
  const { runningKeys } = useDiagnoseLayout();
30
- if (!d.available) return null;
34
+ // Hidden only when the feature can't work here (auth/cloud/--no-mcp). When it's
35
+ // supported but no agent is installed we KEEP the button — clicking opens the
36
+ // setup notice so the feature is discoverable rather than silently absent.
37
+ if (d.setupState === "off") return null;
38
+ const ready = d.available;
31
39
  const problem = health === "problem";
32
- const running = runningKeys.has(runTargetKey(kind, namespace, name));
33
- const tooltip = running
34
- ? `${d.agentLabel} is investigating this resource click to watch it live.`
35
- : d.hosted
36
- ? problem
37
- ? `Diagnose with ${d.agentLabel} — reads this resource's spec, events & logs to find the root cause.`
38
- : `Ask ${d.agentLabel} about this resource — reads its spec, events & logs.`
39
- : problem
40
- ? `Diagnose with your own ${d.agentLabel} runs locally, reads this resource's spec, events & logs to find the root cause.`
41
- : `Ask your own ${d.agentLabel} about this resource — runs locally, reads its spec, events & logs.`;
40
+ const running = ready && runningKeys.has(runTargetKey(kind, namespace, name));
41
+ const tooltip = !ready
42
+ ? "Set up AI diagnosis to investigate this — runs your own agent locally."
43
+ : running
44
+ ? `${d.agentLabel} is investigating this resource — click to watch it live.`
45
+ : d.hosted
46
+ ? problem
47
+ ? `Diagnose with ${d.agentLabel} — reads this resource's spec, events & logs to find the root cause.`
48
+ : `Ask ${d.agentLabel} about this resource reads its spec, events & logs.`
49
+ : problem
50
+ ? `Diagnose with your own ${d.agentLabel} — runs locally, reads this resource's spec, events & logs to find the root cause.`
51
+ : `Ask your own ${d.agentLabel} about this resource — runs locally, reads its spec, events & logs.`;
42
52
  // While an investigation is live, the button advertises it (and clicking focuses
43
53
  // the existing run rather than starting a new one — openInvestigation dedups).
44
54
  const showLabel = problem || running;
45
55
  return (
46
56
  <Tooltip content={tooltip} position="bottom">
47
57
  <button
48
- onClick={() => d.openInvestigation({ kind, namespace, name })}
58
+ onClick={() =>
59
+ ready ? d.openInvestigation({ kind, namespace, name }) : d.openHome()
60
+ }
49
61
  aria-label={
50
- running
51
- ? "Investigation running click to view"
52
- : problem
53
- ? "Diagnose with AI"
54
- : "Ask AI about this resource"
62
+ !ready
63
+ ? "Set up AI diagnosis"
64
+ : running
65
+ ? "Investigation running — click to view"
66
+ : problem
67
+ ? "Diagnose with AI"
68
+ : "Ask AI about this resource"
55
69
  }
56
70
  className={
57
71
  showLabel
@@ -97,20 +111,26 @@ export function IssueDiagnoseButton({
97
111
  name: string;
98
112
  }) {
99
113
  const d = useDiagnose();
100
- if (!d.available) return null;
114
+ if (d.setupState === "off") return null;
115
+ const ready = d.available;
101
116
  return (
102
117
  <Tooltip
103
118
  content={
104
- d.hosted
105
- ? `Sends this resource's context to ${d.agentLabel} to find the root cause`
106
- : `Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`
119
+ d.setupState === "needs-restart"
120
+ ? "Restart Radar to enable AI diagnosis a supported agent is installed"
121
+ : !ready
122
+ ? "Set up AI diagnosis — install a local agent to investigate"
123
+ : d.hosted
124
+ ? `Sends this resource's context to ${d.agentLabel} to find the root cause`
125
+ : `Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`
107
126
  }
108
127
  position="left"
109
128
  >
110
129
  <button
111
130
  onClick={(e) => {
112
131
  e.stopPropagation();
113
- d.openInvestigation({ kind, namespace, name });
132
+ if (ready) d.openInvestigation({ kind, namespace, name });
133
+ else d.openHome();
114
134
  }}
115
135
  className="flex shrink-0 items-center gap-1 rounded-md border border-theme-border px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
116
136
  >
@@ -126,7 +146,8 @@ export function IssueDiagnoseButton({
126
146
  export function GlobalDiagnoseButton() {
127
147
  const d = useDiagnose();
128
148
  const { runningKeys } = useDiagnoseLayout();
129
- if (!d.available) return null;
149
+ if (d.setupState === "off") return null;
150
+ const ready = d.available;
130
151
  const runningCount = runningKeys.size;
131
152
  const agentSuffix = d.hosted
132
153
  ? `powered by ${d.agentLabel}`
@@ -134,9 +155,11 @@ export function GlobalDiagnoseButton() {
134
155
  return (
135
156
  <Tooltip
136
157
  content={
137
- runningCount > 0
138
- ? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — ${agentSuffix}`
139
- : `AI investigations — ${agentSuffix}`
158
+ !ready
159
+ ? "Set up AI investigations runs your own agent locally"
160
+ : runningCount > 0
161
+ ? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — ${agentSuffix}`
162
+ : `AI investigations — ${agentSuffix}`
140
163
  }
141
164
  position="bottom"
142
165
  >
@@ -0,0 +1,30 @@
1
+ // Install metadata for the agent CLIs Radar can drive. Used by the setup notice
2
+ // shown when AI diagnosis is eligible but no agent is installed. Names match the
3
+ // backend's supported set (see internal/ai/detect.go knownAgents + isSupportedAgent).
4
+ export interface AgentInstall {
5
+ name: string; // stable id, matches AgentInfo.name
6
+ label: string; // display name
7
+ install: string; // one-line install command
8
+ docs: string; // canonical setup/docs URL
9
+ }
10
+
11
+ export const SUPPORTED_AGENTS: AgentInstall[] = [
12
+ {
13
+ name: "claude",
14
+ label: "Claude Code",
15
+ install: "npm install -g @anthropic-ai/claude-code",
16
+ docs: "https://docs.claude.com/en/docs/claude-code/setup",
17
+ },
18
+ {
19
+ name: "codex",
20
+ label: "Codex",
21
+ install: "npm install -g @openai/codex",
22
+ docs: "https://github.com/openai/codex",
23
+ },
24
+ {
25
+ name: "cursor-agent",
26
+ label: "Cursor CLI",
27
+ install: "curl https://cursor.com/install -fsS | bash",
28
+ docs: "https://docs.cursor.com/en/cli/overview",
29
+ },
30
+ ];
@@ -0,0 +1,150 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import type {
5
+ CapacityOverviewResponse,
6
+ CapacityResponseMeta,
7
+ CapacitySourceCoverage,
8
+ } from "@skyhook-io/k8s-ui";
9
+ import { CapacityCard } from "./CapacityCard";
10
+
11
+ let mockKarpenterState = "available";
12
+ vi.mock("../../contexts/CapabilitiesContext", () => ({
13
+ useCapabilitiesContext: () => ({ karpenter: { state: mockKarpenterState } }),
14
+ }));
15
+ beforeEach(() => {
16
+ mockKarpenterState = "available";
17
+ });
18
+
19
+ const generatedAt = "2026-07-13T08:00:00Z";
20
+
21
+ function sourceCoverage(
22
+ scope: CapacitySourceCoverage["scope"] = "cluster",
23
+ ): CapacitySourceCoverage {
24
+ return {
25
+ status: "available",
26
+ scope,
27
+ observedAt: generatedAt,
28
+ impactFields: [],
29
+ };
30
+ }
31
+
32
+ const meta: CapacityResponseMeta = {
33
+ schemaVersion: "v1alpha1",
34
+ generatedAt,
35
+ clusterContext: { contextName: "radar-test-eks" },
36
+ provider: {
37
+ type: "karpenter",
38
+ controllerMode: "self_managed",
39
+ apiVersionsByKind: {},
40
+ nodeClassKinds: [],
41
+ features: {},
42
+ },
43
+ coverage: { nodes: sourceCoverage(), pods: sourceCoverage() },
44
+ };
45
+
46
+ function overview(
47
+ summary: Partial<CapacityOverviewResponse["summary"]> = {},
48
+ coverage: CapacityResponseMeta["coverage"] = meta.coverage,
49
+ ): CapacityOverviewResponse {
50
+ return {
51
+ ...meta,
52
+ coverage,
53
+ state: "available",
54
+ summary: {
55
+ actions: [],
56
+ poolCount: 2,
57
+ claimCount: 4,
58
+ nodeCount: 6,
59
+ pendingPodCount: 3,
60
+ managers: [],
61
+ ...summary,
62
+ },
63
+ pools: [],
64
+ poolsTruncated: false,
65
+ groups: [],
66
+ orphanAutoscalerGroups: [],
67
+ orphanAutoscalerGroupsMeta: { total: 0, returned: 0, truncated: false },
68
+ };
69
+ }
70
+
71
+ function renderCard(data: CapacityOverviewResponse): string {
72
+ const client = new QueryClient({
73
+ defaultOptions: { queries: { retry: false, retryOnMount: false } },
74
+ });
75
+ client.setQueryData(["capacity", "overview"], data);
76
+ return renderToString(
77
+ <QueryClientProvider client={client}>
78
+ <CapacityCard onNavigate={() => {}} />
79
+ </QueryClientProvider>,
80
+ );
81
+ }
82
+
83
+ describe("CapacityCard", () => {
84
+ it("shows exact counts under cluster-wide coverage", () => {
85
+ const html = renderCard(overview());
86
+ expect(html).toContain(">3<");
87
+ expect(html).not.toContain("≥");
88
+ });
89
+
90
+ it("hedges the pending-pod count when pod coverage is a lower bound", () => {
91
+ const html = renderCard(
92
+ overview(
93
+ {},
94
+ {
95
+ ...meta.coverage,
96
+ pods: sourceCoverage("all_authorized_namespaces"),
97
+ },
98
+ ),
99
+ );
100
+ expect(html).toContain("≥3");
101
+ });
102
+
103
+ it("renders the softened truth for a denied Karpenter instead of vanishing", () => {
104
+ mockKarpenterState = "denied";
105
+ const html = renderCard({
106
+ ...overview({ poolCount: undefined, claimCount: undefined }),
107
+ state: "denied",
108
+ });
109
+ expect(html).toContain("Karpenter view unavailable");
110
+ expect(html).not.toContain("No active signals");
111
+ expect(html).toContain("NodePools");
112
+ expect(html).toContain("—");
113
+ expect(html).toContain(">6<");
114
+ });
115
+
116
+ it("renders a cluster card on a Karpenter-less cluster with managers", () => {
117
+ mockKarpenterState = "not_detected";
118
+ const html = renderCard({
119
+ ...overview({
120
+ poolCount: undefined,
121
+ claimCount: undefined,
122
+ managers: [
123
+ { manager: "gke_autoscaler", groupCount: 2, status: "healthy" },
124
+ ],
125
+ }),
126
+ state: "not_detected",
127
+ });
128
+ expect(html).toContain("Node groups");
129
+ expect(html).toContain("Managers");
130
+ expect(html).not.toContain("NodePools");
131
+ expect(html).not.toContain("NodeClaims");
132
+ });
133
+
134
+ it("keeps a quiet Home on a bare cluster with no capacity story", () => {
135
+ mockKarpenterState = "not_detected";
136
+ const html = renderCard({
137
+ ...overview({ poolCount: undefined, claimCount: undefined, managers: [] }),
138
+ state: "not_detected",
139
+ });
140
+ expect(html).toBe("");
141
+ });
142
+
143
+ it("renders an omitted NodePool count as unavailable, never zero", () => {
144
+ // The server omits poolCount when NodePools were not observed.
145
+ const html = renderCard(overview({ poolCount: undefined }));
146
+ expect(html).toContain("NodePools");
147
+ expect(html).toContain("—");
148
+ expect(html).not.toContain(">0<");
149
+ });
150
+ });