@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.
- package/package.json +1 -1
- package/src/App.tsx +42 -7
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2884 -2075
- package/src/api/diagnose.ts +5 -0
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +51 -10
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/utils/shell-safe.test.ts +55 -0
- 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(
|
|
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)
|
|
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)
|
|
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
|
-
|
|
279
|
-
|
|
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(
|
|
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 +
|
|
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
|
-
[
|
|
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
|
-
<
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
34
|
-
?
|
|
35
|
-
:
|
|
36
|
-
?
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
:
|
|
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={() =>
|
|
58
|
+
onClick={() =>
|
|
59
|
+
ready ? d.openInvestigation({ kind, namespace, name }) : d.openHome()
|
|
60
|
+
}
|
|
49
61
|
aria-label={
|
|
50
|
-
|
|
51
|
-
? "
|
|
52
|
-
:
|
|
53
|
-
? "
|
|
54
|
-
:
|
|
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 (
|
|
114
|
+
if (d.setupState === "off") return null;
|
|
115
|
+
const ready = d.available;
|
|
101
116
|
return (
|
|
102
117
|
<Tooltip
|
|
103
118
|
content={
|
|
104
|
-
d.
|
|
105
|
-
?
|
|
106
|
-
:
|
|
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 (
|
|
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
|
-
|
|
138
|
-
?
|
|
139
|
-
:
|
|
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
|
+
});
|