@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,5 +1,5 @@
1
1
  // The single controller for the AI assistant surface. One instance app-wide:
2
- // the per-resource "Diagnose" button and the global top-bar entry both dispatch
2
+ // the per-resource "Investigate" button and the global top-bar entry both dispatch
3
3
  // here. Investigations are durable, server-side jobs (see internal/ai RunManager);
4
4
  // this provider lists them, tracks which one is focused, and owns the push-content
5
5
  // layout. The run lifetime is the server's, so closing/navigating never kills one.
@@ -17,19 +17,24 @@ import {
17
17
  } from "react";
18
18
  import {
19
19
  fetchAgents,
20
+ getRun,
20
21
  listRuns,
21
22
  createRun,
22
23
  recordConsent,
23
24
  DiagnoseError,
24
25
  } from "../../api/diagnose";
26
+ import { getApiBase } from "../../api/config";
25
27
  import {
26
28
  type RunSummary,
27
29
  type AgentInfo,
28
30
  type ExecutionProfile,
29
31
  } from "../../api/diagnose";
32
+ import { runTargetKey } from "./target";
30
33
 
31
34
  export interface Target {
32
35
  kind: string;
36
+ /** Kubernetes API group; empty means core. */
37
+ group: string;
33
38
  namespace: string;
34
39
  name: string;
35
40
  /** The issue this investigation is for, when it came from an issue. Hosts
@@ -43,7 +48,7 @@ export interface Target {
43
48
  }
44
49
  export type DiagnoseView = "home" | "investigation";
45
50
 
46
- // Setup readiness of the local AI-diagnosis feature, derived from the agents API:
51
+ // Setup readiness of local AI investigations, derived from the agents API:
47
52
  // - "ready": an agent is installed and the engine is running (available)
48
53
  // - "needs-install": the feature is supported here but no agent CLI is installed
49
54
  // - "needs-restart": a supported agent is now on PATH but Radar booted before it
@@ -86,6 +91,7 @@ interface DiagnoseCtx {
86
91
  approveConsent: () => void;
87
92
  cancelConsent: () => void;
88
93
  refreshRuns: () => void;
94
+ updateRunSummary: (run: RunSummary) => void;
89
95
  dismissError: () => void;
90
96
  }
91
97
 
@@ -95,6 +101,7 @@ interface DiagnoseCtx {
95
101
  // churns the business context) doesn't re-render the whole shell.
96
102
  interface DiagnoseLayoutCtx {
97
103
  open: boolean;
104
+ close: () => void;
98
105
  contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
99
106
  maximized: boolean;
100
107
  setMaximized: Dispatch<SetStateAction<boolean>>;
@@ -106,16 +113,6 @@ interface DiagnoseLayoutCtx {
106
113
  runningKeys: ReadonlySet<string>; // resources with a live investigation (see runTargetKey)
107
114
  }
108
115
 
109
- // Stable key for "is THIS resource being investigated right now" — built the same way
110
- // from a run summary and from a button's target so the two always match.
111
- export function runTargetKey(
112
- kind: string,
113
- namespace: string,
114
- name: string,
115
- ): string {
116
- return `${kind} ${namespace} ${name}`;
117
- }
118
-
119
116
  const Ctx = createContext<DiagnoseCtx | null>(null);
120
117
  const LayoutCtx = createContext<DiagnoseLayoutCtx | null>(null);
121
118
 
@@ -164,7 +161,7 @@ export function agentLabelFor(name: string, fallbackLabel?: string): string {
164
161
  }
165
162
 
166
163
  // openDiagnoseSettings opens the Settings dialog (App.tsx listens for this DOM
167
- // event) — the canonical home for AI-diagnosis config.
164
+ // event) — the canonical home for AI investigation config.
168
165
  export function openDiagnoseSettings() {
169
166
  window.dispatchEvent(
170
167
  new CustomEvent("radar:open-settings", { detail: { section: "ai" } }),
@@ -186,7 +183,58 @@ function writeStored(key: string, value: string) {
186
183
  }
187
184
  }
188
185
 
189
- export function DiagnoseProvider({ children }: { children: ReactNode }) {
186
+ function runIDFromLocation(browserURLState: boolean): string | null {
187
+ if (!diagnoseURLStateEnabled(browserURLState)) return null;
188
+ try {
189
+ return new URLSearchParams(window.location.search).get("ai-run");
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ function writeRunIDToLocation(
196
+ id: string | null,
197
+ push: boolean,
198
+ browserURLState: boolean,
199
+ ) {
200
+ if (!diagnoseURLStateEnabled(browserURLState)) return;
201
+ try {
202
+ const url = new URL(window.location.href);
203
+ if (id) url.searchParams.set("ai-run", id);
204
+ else url.searchParams.delete("ai-run");
205
+ window.history[push ? "pushState" : "replaceState"](
206
+ window.history.state,
207
+ "",
208
+ `${url.pathname}${url.search}${url.hash}`,
209
+ );
210
+ // History API writes do not notify BrowserRouter. Replaying the new state as
211
+ // popstate keeps every later navigate/setSearchParams call on the same live
212
+ // query string instead of letting a stale router snapshot erase ai-run.
213
+ window.dispatchEvent(
214
+ new PopStateEvent("popstate", { state: window.history.state }),
215
+ );
216
+ } catch {
217
+ /* URL APIs unavailable — panel state still works for this session */
218
+ }
219
+ }
220
+
221
+ // Fleet pages host the panel against a cluster-scoped API while remaining on a
222
+ // hub route such as /issues. Writing ?ai-run there would create a non-reloadable
223
+ // pseudo-link. The embedded /c/:id Radar tree and local Radar own their route and
224
+ // therefore keep the query as navigation state; Fleet copies run.radarUrl instead.
225
+ function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
226
+ if (!browserURLState) return false;
227
+ const clusterScopedAPI = /\/c\/[^/]+\/api\/?$/.test(getApiBase());
228
+ return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
229
+ }
230
+
231
+ export function DiagnoseProvider({
232
+ children,
233
+ browserURLState = true,
234
+ }: {
235
+ children: ReactNode;
236
+ browserURLState?: boolean;
237
+ }) {
190
238
  const [available, setAvailable] = useState(false);
191
239
  const [eligible, setEligible] = useState(false);
192
240
  const [agents, setAgents] = useState<AgentInfo[]>([]);
@@ -206,6 +254,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
206
254
  const [open, setOpen] = useState(false);
207
255
  const [view, setView] = useState<DiagnoseView>("home");
208
256
  const [activeRunId, setActiveRunId] = useState<string | null>(null);
257
+ const activeRunIdRef = useRef(activeRunId);
258
+ activeRunIdRef.current = activeRunId;
259
+ // A missing/revoked durable link is terminal until the user explicitly
260
+ // navigates to it again. Keep polling the bounded history list (so a newly
261
+ // shared run can reappear), but do not hammer the exact 404 endpoint every
262
+ // four seconds while the unavailable state is already on screen.
263
+ const unavailableRunIDsRef = useRef(new Set<string>());
264
+ // Tracks whether the panel focus belongs to browser history. Fleet opens the
265
+ // same panel on /issues, where URL state is deliberately disabled; a generic
266
+ // panel launch must never be closed by the deep-link synchronization effect.
267
+ const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
268
+ const writeFocusedRunID = useCallback(
269
+ (id: string | null, push: boolean) => {
270
+ // Set this before writeRunIDToLocation's synthetic popstate so our own
271
+ // listener can distinguish a programmatic close/home write from Back.
272
+ if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
273
+ writeRunIDToLocation(id, push, browserURLState);
274
+ },
275
+ [browserURLState],
276
+ );
209
277
  const [runs, setRuns] = useState<RunSummary[]>([]);
210
278
  const [runsLoaded, setRunsLoaded] = useState(false);
211
279
  const [runsLoadFailed, setRunsLoadFailed] = useState(false);
@@ -332,33 +400,86 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
332
400
  return () => window.removeEventListener("resize", onResize);
333
401
  }, []);
334
402
 
335
- const refreshRuns = useCallback(() => {
403
+ const refreshRuns = useCallback(async () => {
336
404
  if (!available) return;
337
- listRuns()
338
- .then((r) => {
339
- setRuns(r.runs);
340
- setRunsLoaded(true);
341
- setRunsLoadFailed(false);
342
- setHistoryDegraded(!!r.historyDegraded);
343
- })
344
- .catch(() => {
345
- // Leave runsLoaded false (a missing-run verdict needs a real list) but
346
- // record the failure so the panel can say "retrying" instead of
347
- // pretending nothing happened. The 4s poll keeps retrying while open.
348
- setRunsLoadFailed(true);
405
+ try {
406
+ const r = await listRuns();
407
+ const focusedID = activeRunIdRef.current;
408
+ let focusedRun: RunSummary | null = null;
409
+ let retainFocusedSnapshot = false;
410
+ const listedFocusedRun = focusedID
411
+ ? r.runs.find((run) => run.id === focusedID)
412
+ : undefined;
413
+ if (focusedID && listedFocusedRun) {
414
+ unavailableRunIDsRef.current.delete(focusedID);
415
+ } else if (focusedID && !unavailableRunIDsRef.current.has(focusedID)) {
416
+ try {
417
+ // A stable deep link can target a retained run older than the bounded
418
+ // history page. Refresh it directly too: its status and capabilities
419
+ // must not freeze at the first snapshot while the panel stays open.
420
+ focusedRun = await getRun(focusedID);
421
+ } catch (error) {
422
+ // The bounded list is still authoritative for everything else. A
423
+ // missing/revoked focused run falls out and renders unavailable;
424
+ // transient direct-fetch failures keep the last useful snapshot.
425
+ const unavailable =
426
+ error instanceof DiagnoseError && error.status === 404;
427
+ if (unavailable) unavailableRunIDsRef.current.add(focusedID);
428
+ retainFocusedSnapshot = !unavailable;
429
+ }
430
+ }
431
+ setRuns((prev) => {
432
+ let nextRuns = r.runs;
433
+ if (focusedRun) nextRuns = [focusedRun, ...nextRuns];
434
+ if (focusedID && retainFocusedSnapshot) {
435
+ const previous = prev.find((run) => run.id === focusedID);
436
+ if (previous) nextRuns = [previous, ...nextRuns];
437
+ }
438
+
439
+ // openRun/start can focus and insert a run while the direct fetch above
440
+ // is in flight. Preserve that newer focus instead of replacing it with
441
+ // the snapshot for the run that was active when this refresh started.
442
+ const currentFocusedID = activeRunIdRef.current;
443
+ if (
444
+ currentFocusedID &&
445
+ currentFocusedID !== focusedID &&
446
+ !nextRuns.some((run) => run.id === currentFocusedID)
447
+ ) {
448
+ const current = prev.find((run) => run.id === currentFocusedID);
449
+ if (current) nextRuns = [current, ...nextRuns];
450
+ }
451
+ return nextRuns;
349
452
  });
453
+ setRunsLoaded(true);
454
+ setRunsLoadFailed(false);
455
+ setHistoryDegraded(!!r.historyDegraded);
456
+ } catch {
457
+ // Leave runsLoaded false (a missing-run verdict needs a real list) but
458
+ // record the failure so the panel can say "retrying" instead of
459
+ // pretending nothing happened. The 4s poll keeps retrying while open.
460
+ setRunsLoadFailed(true);
461
+ }
350
462
  }, [available]);
463
+ const updateRunSummary = useCallback((run: RunSummary) => {
464
+ setRuns((prev) =>
465
+ prev.some((item) => item.id === run.id)
466
+ ? prev.map((item) => (item.id === run.id ? run : item))
467
+ : [run, ...prev],
468
+ );
469
+ }, []);
351
470
 
352
- // A content-stable signature of the resources with a live (running) investigation,
353
- // so the per-resource Diagnose buttons can show a "running" indicator even with the
471
+ // A content-stable signature of the resources with a live investigation,
472
+ // so the per-resource Investigate buttons can show a "running" indicator even with the
354
473
  // panel closed — and only re-render when the set actually changes, not every poll.
355
474
  const runningSig = runs
356
- .filter((r) => r.status === "running")
357
- .map((r) => runTargetKey(r.kind, r.namespace, r.name))
475
+ .filter((r) => r.status === "running" || r.status === "stopping")
476
+ .map((r) => runTargetKey(r.kind, r.namespace, r.name, r.group))
358
477
  .sort()
359
- .join("|");
478
+ // resourceKey itself is pipe-delimited; newlines cannot occur in a
479
+ // Kubernetes group, Kind, namespace, or name.
480
+ .join("\n");
360
481
  const runningKeys = useMemo(
361
- () => new Set(runningSig ? runningSig.split("|") : []),
482
+ () => new Set(runningSig ? runningSig.split("\n") : []),
362
483
  [runningSig],
363
484
  );
364
485
  const hasRunning = runningSig.length > 0;
@@ -397,6 +518,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
397
518
  if (seq !== startSeqRef.current) return;
398
519
  setActiveRunId(run.id);
399
520
  setView("investigation");
521
+ writeFocusedRunID(run.id, true);
400
522
  })
401
523
  .catch((e) => {
402
524
  if (seq !== startSeqRef.current) return;
@@ -431,52 +553,90 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
431
553
  },
432
554
  [consentSurface, hosted],
433
555
  );
434
- const openRun = useCallback((id: string) => {
435
- setStartError(null);
436
- setActiveRunId(id);
437
- setView("investigation");
438
- setOpen(true);
439
- }, []);
556
+ const openRun = useCallback(
557
+ (id: string) => {
558
+ unavailableRunIDsRef.current.delete(id);
559
+ setStartError(null);
560
+ setActiveRunId(id);
561
+ setView("investigation");
562
+ setOpen(true);
563
+ writeFocusedRunID(id, true);
564
+ // A Fleet issue can point at a retained automatic run older than the
565
+ // bounded history page. Resolve the exact id instead of waiting for list.
566
+ getRun(id)
567
+ .then(updateRunSummary)
568
+ .catch((error) => {
569
+ if (error instanceof DiagnoseError && error.status === 404) {
570
+ unavailableRunIDsRef.current.add(id);
571
+ }
572
+ // The loaded-list state renders the durable unavailable message.
573
+ });
574
+ },
575
+ [updateRunSummary, writeFocusedRunID],
576
+ );
440
577
 
441
- // Deep link: ?ai-run=<id> opens the panel focused on that investigation —
442
- // the URL `radar diagnose --open` prints/opens. Consumed once (then stripped)
443
- // so back/forward and copied URLs don't keep re-opening the panel.
578
+ // The run id is durable navigation state: direct loads and popstate focus the
579
+ // exact run, and the query remains in place so the address bar is copyable.
580
+ // Fetch by id rather than assuming the bounded recent list contains it.
444
581
  useEffect(() => {
445
- if (!available) return;
446
- let id: string | null = null;
447
- try {
448
- const params = new URLSearchParams(window.location.search);
449
- id = params.get("ai-run");
450
- if (id) {
451
- params.delete("ai-run");
452
- const qs = params.toString();
453
- window.history.replaceState(
454
- null,
455
- "",
456
- window.location.pathname +
457
- (qs ? `?${qs}` : "") +
458
- window.location.hash,
459
- );
582
+ if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
583
+ const focusFromLocation = (fromPopState: boolean) => {
584
+ const id = runIDFromLocation(browserURLState);
585
+ if (!id) {
586
+ // A missing id on first mount is ordinary. On popstate it means "back
587
+ // out of this investigation" only when the focused run was itself
588
+ // installed by URL history; unrelated synthetic popstate events must
589
+ // not tear down a panel opened by an in-app action.
590
+ if (fromPopState && urlRunIdRef.current) {
591
+ setActiveRunId(null);
592
+ setView("home");
593
+ setOpen(false);
594
+ }
595
+ urlRunIdRef.current = null;
596
+ return;
460
597
  }
461
- } catch {
462
- /* URL APIs unavailable — skip the deep link */
463
- }
464
- if (id) openRun(id);
465
- }, [available, openRun]);
598
+ urlRunIdRef.current = id;
599
+ unavailableRunIDsRef.current.delete(id);
600
+ setStartError(null);
601
+ setActiveRunId(id);
602
+ setView("investigation");
603
+ setOpen(true);
604
+ getRun(id)
605
+ .then(updateRunSummary)
606
+ .catch((error) => {
607
+ if (error instanceof DiagnoseError && error.status === 404) {
608
+ unavailableRunIDsRef.current.add(id);
609
+ }
610
+ // The list load owns the final missing/degraded state and keeps retrying.
611
+ });
612
+ };
613
+ focusFromLocation(false);
614
+ const onPopState = () => focusFromLocation(true);
615
+ window.addEventListener("popstate", onPopState);
616
+ return () => window.removeEventListener("popstate", onPopState);
617
+ }, [available, browserURLState, updateRunSummary]);
466
618
  // Leaving the detail pane drops the failure that belonged to it. startError
467
619
  // renders as the entire pane (maximized home still shows `detail`), where a
468
620
  // message about a resource you just navigated away from has nothing to attach
469
621
  // to and no way to be dismissed.
470
622
  const openHome = useCallback(() => {
623
+ unavailableRunIDsRef.current.clear();
471
624
  setView("home");
472
625
  setStartError(null);
473
626
  setOpen(true);
474
- }, []);
627
+ writeFocusedRunID(null, false);
628
+ }, [writeFocusedRunID]);
475
629
  const goHome = useCallback(() => {
630
+ unavailableRunIDsRef.current.clear();
476
631
  setView("home");
477
632
  setStartError(null);
478
- }, []);
479
- const close = useCallback(() => setOpen(false), []);
633
+ writeFocusedRunID(null, false);
634
+ }, [writeFocusedRunID]);
635
+ const close = useCallback(() => {
636
+ unavailableRunIDsRef.current.clear();
637
+ setOpen(false);
638
+ writeFocusedRunID(null, false);
639
+ }, [writeFocusedRunID]);
480
640
  const consentBusyRef = useRef(false);
481
641
  const approveConsent = useCallback(() => {
482
642
  if (consentBusyRef.current) return;
@@ -557,6 +717,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
557
717
  approveConsent,
558
718
  cancelConsent,
559
719
  refreshRuns,
720
+ updateRunSummary,
560
721
  dismissError,
561
722
  };
562
723
 
@@ -567,6 +728,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
567
728
  const layout = useMemo<DiagnoseLayoutCtx>(
568
729
  () => ({
569
730
  open,
731
+ close,
570
732
  contentGutter,
571
733
  maximized,
572
734
  setMaximized,
@@ -579,6 +741,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
579
741
  }),
580
742
  [
581
743
  open,
744
+ close,
582
745
  contentGutter,
583
746
  maximized,
584
747
  width,