@skyhook-io/radar-app 1.13.0 → 1.13.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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +55 -9
  3. package/src/RadarApp.tsx +3 -3
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +117 -14
  6. package/src/components/diagnose/AISettings.tsx +10 -5
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +228 -65
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
  11. package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +307 -108
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1911 -338
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1340 -536
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  40. package/src/components/execution/BatchExecutionView.tsx +2 -3
  41. package/src/components/execution/execution-definition.test.ts +19 -0
  42. package/src/components/execution/execution-definition.ts +2 -0
  43. package/src/components/gitops/GitOpsView.tsx +25 -3
  44. package/src/components/home/MCPSetupDialog.tsx +2 -2
  45. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  46. package/src/components/home/mcpToolCatalog.ts +3 -2
  47. package/src/components/issues/IssuesPane.tsx +5 -1
  48. package/src/components/nav/navigation.test.ts +26 -0
  49. package/src/components/nav/navigation.ts +10 -0
  50. package/src/components/settings/SettingsDialog.tsx +11 -13
  51. package/src/components/workload/WorkloadView.tsx +1 -1
  52. package/src/context/DiagnoseCustomization.tsx +11 -8
  53. package/src/index.css +63 -79
  54. package/src/index.ts +1 -1
@@ -1,7 +1,7 @@
1
- // Client for the local AI-diagnose engine (OSS BYO-agent). The agent CLI runs
1
+ // Client for local AI investigations (OSS BYO-agent). The agent CLI runs
2
2
  // on the user's own machine/subscription against Radar's MCP; this just starts
3
3
  // the investigation and consumes its SSE event stream.
4
- import { getApiBase, getCredentialsMode } from "./config";
4
+ import { getApiBase, getAuthHeaders, getCredentialsMode } from "./config";
5
5
 
6
6
  export interface AgentInfo {
7
7
  name: string;
@@ -18,7 +18,7 @@ export interface AgentInfo {
18
18
  export interface AgentsResponse {
19
19
  agents: AgentInfo[];
20
20
  enabled: boolean;
21
- // eligible: this run mode supports local BYO-agent diagnosis (no proxy/OIDC
21
+ // eligible: this run mode supports local BYO-agent investigations (no proxy/OIDC
22
22
  // auth, /mcp mounted) — true even when no agent is installed. Lets the UI tell
23
23
  // "install an agent to enable this" (eligible && !enabled) apart from "not
24
24
  // available here" (auth/cloud/--no-mcp). Absent on older servers / embed hosts.
@@ -37,13 +37,24 @@ export interface DiagnoseStep {
37
37
  ms?: number;
38
38
  summary?: string; // input args (on running)
39
39
  result?: string; // result text (on done), capped
40
+ evidenceRef?: string; // server-issued reference used to bind a root cause to this exact result
41
+ // Server validated Radar's uncapped producer result and this retained result
42
+ // as its exact capped derivative.
43
+ radarEvidence?: boolean;
44
+ isError?: boolean; // authoritative agent-host result; absent means unknown
40
45
  truncated?: boolean; // result was capped — payload shown/copied is partial
41
46
  }
42
47
 
48
+ export interface RootCauseEvidence {
49
+ status: "linked" | "missing" | "invalid";
50
+ refs?: string[];
51
+ }
52
+
43
53
  export interface Diagnosis {
44
54
  healthy?: boolean;
45
55
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
46
56
  rootCause: string;
57
+ rootCauseEvidence?: RootCauseEvidence;
47
58
  report: string;
48
59
  remediation: string[];
49
60
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -72,8 +83,19 @@ export interface ResourceHealthSignal {
72
83
  auditFindings?: HealthLine[];
73
84
  }
74
85
 
86
+ export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
87
+
75
88
  export interface DiagnoseStreamEvent {
76
- type: "turn" | "phase" | "step" | "thinking" | "done" | "error" | "closed";
89
+ type:
90
+ | "turn"
91
+ | "phase"
92
+ | "step"
93
+ | "thinking"
94
+ | "done"
95
+ | "error"
96
+ | "closed"
97
+ | "history_unavailable"
98
+ | "replay_complete";
77
99
  phase?: string;
78
100
  step?: DiagnoseStep;
79
101
  token?: string;
@@ -81,6 +103,17 @@ export interface DiagnoseStreamEvent {
81
103
  error?: string;
82
104
  question?: string; // on "turn"
83
105
  apply?: boolean; // on "turn"
106
+ verify?: boolean; // on "turn": explicit post-change re-check
107
+ explainAssessment?: number; // on "turn": originating assessment done-event sequence
108
+ // Evidence-backed mutation truth on an apply turn's terminal event. Only
109
+ // "confirmed" means a Radar write tool authoritatively reported success.
110
+ applyOutcome?: ApplyMutationOutcome;
111
+ // On an apply terminal event, the server durably queued the adjacent
112
+ // read-only verification turn. This prevents an idle control flash between
113
+ // the two SSE events without inferring lifecycle from copy.
114
+ verificationScheduled?: boolean;
115
+ retryable?: boolean; // on "history_unavailable": keep native EventSource reconnect alive
116
+ actor?: string; // human author on shared hosted transcripts
84
117
  }
85
118
 
86
119
  // A run is a durable, server-owned investigation. Its lifetime is independent of
@@ -89,6 +122,8 @@ export interface DiagnoseStreamEvent {
89
122
  export interface RunSummary {
90
123
  id: string;
91
124
  kind: string;
125
+ /** Kubernetes API group; empty means the core API group. */
126
+ group: string;
92
127
  namespace: string;
93
128
  name: string;
94
129
  /** The issue this session is for, on hosts that key sessions by issue. Always
@@ -101,11 +136,17 @@ export interface RunSummary {
101
136
  effort?: string;
102
137
  managedBy?: string; // GitOps/Helm owner of the target ("Argo CD"/"Flux"/"Helm"), for the Apply warning
103
138
  health?: ResourceHealthSignal;
104
- status: "running" | "done" | "error" | "stopped" | "stale";
139
+ status: "running" | "stopping" | "done" | "error" | "stopped" | "stale";
105
140
  sessionId?: string;
106
141
  preview?: string;
107
142
  createdAt: string;
108
143
  updatedAt: string;
144
+ visibility?: "private" | "organization";
145
+ ownedByMe?: boolean;
146
+ canManageVisibility?: boolean;
147
+ canContinue?: boolean;
148
+ trigger?: "interactive" | "background";
149
+ radarUrl?: string;
109
150
  }
110
151
 
111
152
  export async function fetchAgents(
@@ -113,6 +154,7 @@ export async function fetchAgents(
113
154
  ): Promise<AgentsResponse> {
114
155
  const res = await fetch(`${getApiBase()}/agents`, {
115
156
  credentials: getCredentialsMode(),
157
+ headers: getAuthHeaders(),
116
158
  signal,
117
159
  });
118
160
  if (!res.ok) throw new Error(`agents: ${res.status}`);
@@ -145,6 +187,8 @@ const RUNS = () => `${getApiBase()}/diagnose/runs`;
145
187
  export async function createRun(
146
188
  target: {
147
189
  kind: string;
190
+ /** Kubernetes API group; empty means the core API group. */
191
+ group: string;
148
192
  namespace: string;
149
193
  name: string;
150
194
  // Associates the session with the issue it was started from, for hosts that
@@ -167,7 +211,7 @@ export async function createRun(
167
211
  const res = await fetch(RUNS(), {
168
212
  method: "POST",
169
213
  credentials: getCredentialsMode(),
170
- headers: { "Content-Type": "application/json" },
214
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
171
215
  body: JSON.stringify({ ...target, ...opts }),
172
216
  });
173
217
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -186,6 +230,7 @@ export interface RunsResponse {
186
230
  export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
187
231
  const res = await fetch(RUNS(), {
188
232
  credentials: getCredentialsMode(),
233
+ headers: getAuthHeaders(),
189
234
  signal,
190
235
  });
191
236
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -193,12 +238,41 @@ export async function listRuns(signal?: AbortSignal): Promise<RunsResponse> {
193
238
  return { runs: d.runs ?? [], historyDegraded: !!d.historyDegraded };
194
239
  }
195
240
 
241
+ // getRun resolves a stable run id directly. Deep links use this rather than
242
+ // relying on a bounded history page to happen to contain the target.
243
+ export async function getRun(
244
+ id: string,
245
+ signal?: AbortSignal,
246
+ ): Promise<RunSummary> {
247
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
248
+ credentials: getCredentialsMode(),
249
+ headers: getAuthHeaders(),
250
+ signal,
251
+ });
252
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
253
+ return res.json();
254
+ }
255
+
256
+ export async function updateRunVisibility(
257
+ id: string,
258
+ visibility: "private" | "organization",
259
+ ): Promise<RunSummary> {
260
+ const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
261
+ method: "PATCH",
262
+ credentials: getCredentialsMode(),
263
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
264
+ body: JSON.stringify({ visibility }),
265
+ });
266
+ if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
267
+ return res.json();
268
+ }
269
+
196
270
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
197
271
  export async function recordConsent(surface: string): Promise<void> {
198
272
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
199
273
  method: "POST",
200
274
  credentials: getCredentialsMode(),
201
- headers: { "Content-Type": "application/json" },
275
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
202
276
  body: JSON.stringify({ surface }),
203
277
  });
204
278
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -210,6 +284,7 @@ export async function clearHistory(): Promise<void> {
210
284
  const res = await fetch(`${getApiBase()}/diagnose/history/clear`, {
211
285
  method: "POST",
212
286
  credentials: getCredentialsMode(),
287
+ headers: getAuthHeaders(),
213
288
  });
214
289
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
215
290
  }
@@ -217,12 +292,18 @@ export async function clearHistory(): Promise<void> {
217
292
  // addTurn appends a follow-up (question) or an apply turn (apply + confirmed fix).
218
293
  export async function addTurn(
219
294
  id: string,
220
- body: { question?: string; apply?: boolean; fix?: string },
295
+ body: {
296
+ question?: string;
297
+ apply?: boolean;
298
+ fix?: string;
299
+ verify?: boolean;
300
+ explainAssessment?: number;
301
+ },
221
302
  ): Promise<void> {
222
303
  const res = await fetch(`${RUNS()}/${id}/turns`, {
223
304
  method: "POST",
224
305
  credentials: getCredentialsMode(),
225
- headers: { "Content-Type": "application/json" },
306
+ headers: { "Content-Type": "application/json", ...getAuthHeaders() },
226
307
  body: JSON.stringify(body),
227
308
  });
228
309
  if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
@@ -233,12 +314,16 @@ export async function stopRun(id: string): Promise<void> {
233
314
  await fetch(`${RUNS()}/${id}/stop`, {
234
315
  method: "POST",
235
316
  credentials: getCredentialsMode(),
317
+ headers: getAuthHeaders(),
236
318
  }).catch(() => {});
237
319
  }
238
320
 
239
321
  export interface SubscribeHandlers {
240
- onEvent: (ev: DiagnoseStreamEvent) => void;
241
- onClosed?: () => void; // the run can no longer produce events (stale/evicted)
322
+ onEvent: (ev: DiagnoseStreamEvent, sequence?: number) => void;
323
+ // EventSource fires open before each initial/reconnect replay. Together with
324
+ // replay_complete this brackets history so consumers can rebuild silently.
325
+ onReplayStart?: () => void;
326
+ onClosed?: (reason: "run_closed" | "unavailable") => void;
242
327
  }
243
328
 
244
329
  /**
@@ -262,6 +347,11 @@ export function subscribeRun(
262
347
  es.close();
263
348
  };
264
349
  const dispatch = (e: MessageEvent) => {
350
+ // close() can run while an earlier SSE callback is being dispatched. Ignore
351
+ // any already-queued trailing frame (notably the server's `closed` sentinel
352
+ // after a permanent history_unavailable) so it cannot replace the specific
353
+ // failure explanation with a generic eviction state.
354
+ if (closed) return;
265
355
  let ev: DiagnoseStreamEvent;
266
356
  try {
267
357
  ev = JSON.parse(e.data);
@@ -270,11 +360,21 @@ export function subscribeRun(
270
360
  }
271
361
  if (ev.type === "closed") {
272
362
  close();
273
- handlers.onClosed?.();
363
+ handlers.onClosed?.("run_closed");
274
364
  return;
275
365
  }
276
- handlers.onEvent(ev);
366
+ const sequence = Number(e.lastEventId);
367
+ handlers.onEvent(
368
+ ev,
369
+ Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined,
370
+ );
371
+ // Hydration failures stay inside the SSE protocol. Retryable failures end
372
+ // this response and let native EventSource reconnect without advancing its
373
+ // cursor; a permanent failure must stop that reconnect loop after the UI
374
+ // receives the explanatory event.
375
+ if (ev.type === "history_unavailable" && ev.retryable !== true) close();
277
376
  };
377
+ es.onopen = () => handlers.onReplayStart?.();
278
378
  for (const t of [
279
379
  "turn",
280
380
  "phase",
@@ -283,10 +383,13 @@ export function subscribeRun(
283
383
  "done",
284
384
  "error",
285
385
  "closed",
386
+ "history_unavailable",
387
+ "replay_complete",
286
388
  ] as const) {
287
389
  es.addEventListener(t, dispatch);
288
390
  }
289
391
  es.onerror = () => {
392
+ if (closed) return;
290
393
  // A permanent failure (readyState CLOSED) means the run is gone — a 404 because
291
394
  // it was evicted (retention cap) or lost on a server restart. Surface it as
292
395
  // closed so the view shows a "no longer available" state instead of a silent
@@ -294,7 +397,7 @@ export function subscribeRun(
294
397
  // replays only what we missed), so leave those to EventSource.
295
398
  if (es.readyState === EventSource.CLOSED) {
296
399
  close();
297
- handlers.onClosed?.();
400
+ handlers.onClosed?.("unavailable");
298
401
  }
299
402
  };
300
403
  return close;
@@ -1,5 +1,5 @@
1
1
  // The agent/model/effort controls + a clear-history action for the Settings
2
- // "AI diagnose" tab. Controlled by the dialog: it edits a STAGED draft committed
2
+ // "AI investigations" tab. Controlled by the dialog: it edits a STAGED draft committed
3
3
  // on Save (like the rest of Settings), not on every keystroke. The heading,
4
4
  // description, and Save button live in the dialog so this tab matches the other
5
5
  // Settings tabs' layout — this renders only the controls (no card, no heading).
@@ -49,7 +49,7 @@ function ClearHistoryRow({
49
49
  <div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
50
50
  <p className="text-[11px] leading-snug text-theme-text-tertiary">
51
51
  {hosted ? (
52
- `Investigation transcripts are stored by ${agentLabel} so history survives restarts.`
52
+ `${agentLabel} stores private, organization-shared, and automatic investigation transcripts so history survives restarts.`
53
53
  ) : (
54
54
  <>
55
55
  Investigation transcripts are kept on this machine (
@@ -67,6 +67,12 @@ function ClearHistoryRow({
67
67
  Couldn&apos;t clear history.
68
68
  </span>
69
69
  )}
70
+ {hosted && confirming && state === "idle" && (
71
+ <span className="ml-1 font-medium text-red-400">
72
+ This permanently deletes every member&apos;s investigations for this
73
+ cluster — private, organization-shared, and automatic.
74
+ </span>
75
+ )}
70
76
  </p>
71
77
  {confirming ? (
72
78
  <div className="flex shrink-0 items-center gap-1.5">
@@ -133,9 +139,8 @@ export function AISettingsSection({
133
139
  selectedAgent={draft.agent}
134
140
  // Model + effort are agent-specific; reset them when the agent changes.
135
141
  onSelectAgent={(a) => {
136
- const nextProfile = agents.find(
137
- (agent) => agent.name === a,
138
- )?.profiles?.[0];
142
+ const nextProfile = agents.find((agent) => agent.name === a)
143
+ ?.profiles?.[0];
139
144
  onChange({
140
145
  agent: a,
141
146
  profile: nextProfile ?? draft.profile,
@@ -52,7 +52,7 @@ function AgentRow({ agent }: { agent: AgentInstall }) {
52
52
  );
53
53
  }
54
54
 
55
- // Shown in the AI surface's Home when diagnosis is eligible in this deployment
55
+ // Shown in the AI surface's Home when investigations are eligible in this deployment
56
56
  // but not runnable yet — either no agent CLI is installed ("needs-install") or a
57
57
  // supported one appeared after Radar booted ("needs-restart"). Radar decides the
58
58
  // engine once at startup, so a fresh install needs a restart to take effect.
@@ -69,8 +69,8 @@ export function AgentSetupNotice({
69
69
  </div>
70
70
  <h3 className="text-base font-semibold text-theme-text-primary">
71
71
  {needsRestart
72
- ? "Restart Radar to enable AI diagnosis"
73
- : "Set up AI diagnosis"}
72
+ ? "Restart Radar to enable AI investigations"
73
+ : "Set up AI investigations"}
74
74
  </h3>
75
75
  <p className="mt-1 text-sm text-theme-text-secondary">
76
76
  {needsRestart ? (
@@ -109,8 +109,8 @@ export function AgentSetupNotice({
109
109
  </button>
110
110
 
111
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.
112
+ Your agent runs locally. Resource details and logs are sent to its model
113
+ provider under your account, not to Radar.
114
114
  </p>
115
115
  </div>
116
116
  );
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { ApplyDialog } from "./parts";
5
+ import { AgentSetupNotice } from "./AgentSetupNotice";
6
+
7
+ vi.mock("@skyhook-io/k8s-ui/components/ui/DialogPortal", () => ({
8
+ DialogPortal: ({ open, children }: { open: boolean; children: ReactNode }) =>
9
+ open ? children : null,
10
+ }));
11
+
12
+ describe("Apply confirmation context", () => {
13
+ it.each([
14
+ "gke_project-a_us-east1-b_nonprod",
15
+ "gke_project-b_us-east1-b_nonprod",
16
+ ])(
17
+ "identifies the exact destination %s even when display names collide",
18
+ (context) => {
19
+ const html = renderToStaticMarkup(
20
+ <ApplyDialog
21
+ open
22
+ onClose={() => {}}
23
+ onConfirm={() => {}}
24
+ agentLabel="Local agent"
25
+ resourceLabel="Deployment dev/api"
26
+ context={context}
27
+ fix="Only after verifying credentials, update the Secret reference."
28
+ />,
29
+ );
30
+ expect(html).toContain("Cluster:");
31
+ expect(html).toContain("nonprod");
32
+ expect(html).toContain(context);
33
+ expect(html).toContain("Deployment dev/api");
34
+ expect(html).toContain("Proposed change");
35
+ expect(html).not.toContain("What will happen");
36
+ expect(html).not.toContain("max-h-48");
37
+ expect(html).toContain("Only after verifying credentials");
38
+ },
39
+ );
40
+
41
+ it("keeps managed-resource acknowledgement and low-confidence warning", () => {
42
+ const html = renderToStaticMarkup(
43
+ <ApplyDialog
44
+ open
45
+ onClose={() => {}}
46
+ onConfirm={() => {}}
47
+ agentLabel="Local agent"
48
+ resourceLabel="Deployment dev/api"
49
+ context="kind-dev"
50
+ fix="Update its configuration"
51
+ managedBy="Argo CD"
52
+ confidence={0.3}
53
+ />,
54
+ );
55
+ expect(html).toContain("I understand Argo CD may revert this");
56
+ const applyButton = html
57
+ .match(/<button\b[^>]*>[\s\S]*?<\/button>/g)
58
+ ?.find((button) => button.includes("Apply fix"));
59
+ expect(applyButton).toContain('disabled=""');
60
+ expect(html).toContain("low confidence");
61
+ expect(html.match(/kubeconfig/g)).toHaveLength(1);
62
+ });
63
+ });
64
+
65
+ it("distinguishes local execution from sending resource data to the model provider", () => {
66
+ const html = renderToStaticMarkup(
67
+ <AgentSetupNotice setupState="needs-install" />,
68
+ );
69
+ expect(html).toContain("Your agent runs locally");
70
+ expect(html).toContain("model provider under your account, not to Radar");
71
+ expect(html).not.toContain("never leaves your machine");
72
+ });