@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.13.4",
44
+ "@skyhook-io/k8s-ui": ">=1.14.2",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
package/src/App.tsx CHANGED
@@ -37,6 +37,7 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
37
37
  import { useNavCustomization } from './context/NavCustomization'
38
38
  import type { FleetTakeoverTarget } from './context/NavCustomization'
39
39
  import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
40
+ import { navigateFromPrimaryRail } from './components/nav/navigation'
40
41
  import { useNavRailPinned } from './hooks/useNavRailPinned'
41
42
  import { useMediaQuery } from './hooks/useMediaQuery'
42
43
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
@@ -322,7 +323,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
322
323
  // The AI panel is an absolute slot in the body frame (the column under the header):
323
324
  // it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
324
325
  // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
325
- const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
326
+ const {
327
+ open: diagnoseOpen,
328
+ close: closeDiagnose,
329
+ contentGutter,
330
+ maximized: diagnoseMaximized,
331
+ } = useDiagnoseLayout()
326
332
  // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
327
333
  // cross-tree swap) navigates same-document so the chrome morphs instead of
328
334
  // cold-booting; without it we fall back to a hard `window.location` nav.
@@ -468,12 +474,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
468
474
 
469
475
  const path = view === 'home' ? '/' : `/${view}`
470
476
 
471
- // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
477
+ // Start fresh — keep only cross-view params, discard view-specific ones.
478
+ // Read the live location because Diagnose owns `ai-run` through the History
479
+ // API; React Router's searchParams snapshot does not update for that write.
472
480
  const newParams = new URLSearchParams()
473
- const globalNamespaces = searchParams.get('namespaces')
481
+ const currentParams = new URLSearchParams(window.location.search)
482
+ const globalNamespaces = currentParams.get('namespaces')
474
483
  if (globalNamespaces) {
475
484
  newParams.set('namespaces', globalNamespaces)
476
485
  }
486
+ const diagnoseRun = currentParams.get('ai-run')
487
+ if (diagnoseRun) {
488
+ newParams.set('ai-run', diagnoseRun)
489
+ }
477
490
 
478
491
  // Add any new params
479
492
  if (params) {
@@ -483,7 +496,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
483
496
  }
484
497
 
485
498
  navigate({ pathname: path, search: newParams.toString() })
486
- }, [navigate, searchParams, takeover, goHost])
499
+ }, [navigate, takeover, goHost])
500
+
501
+ // The standalone rail expresses intent to leave the full-width investigation
502
+ // workspace. Close it before routing so the destination is immediately visible;
503
+ // docked investigations stay open across views as a persistent side panel.
504
+ const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
505
+ navigateFromPrimaryRail(
506
+ diagnoseOpen && diagnoseMaximized,
507
+ closeDiagnose,
508
+ () => setMainView(view),
509
+ )
510
+ }, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
487
511
 
488
512
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
489
513
  // own fleet pages scoped to this cluster — owned by the host's left rail — so
@@ -1135,9 +1159,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1135
1159
  setSelectedResource(null)
1136
1160
  setSelectedHelmRelease(null)
1137
1161
 
1138
- // Reset URL to current view with no resource-specific params.
1139
- // Old cluster's selected pod/resource/kind don't exist on the new cluster.
1140
- navigate({ pathname: location.pathname, search: '' }, { replace: true })
1162
+ // Reset resource-specific params while retaining the durable investigation
1163
+ // focus. Diagnose resolves runs by id and owns whether the focused run is
1164
+ // still readable after the context switch.
1165
+ const nextParams = new URLSearchParams()
1166
+ const diagnoseRun = new URLSearchParams(window.location.search).get('ai-run')
1167
+ if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1168
+ navigate({ pathname: location.pathname, search: nextParams.toString() }, { replace: true })
1141
1169
 
1142
1170
  // Auto-unpause so the new cluster's topology loads immediately
1143
1171
  setTopologyPaused(false)
@@ -1658,7 +1686,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1658
1686
  {showNavRail && (
1659
1687
  <PrimaryNavRail
1660
1688
  activeView={navActiveView}
1661
- onNavigate={setMainView}
1689
+ onNavigate={handlePrimaryNavigate}
1662
1690
  pinned={navRailEffectivePinned}
1663
1691
  onTogglePinned={toggleNavRailPinned}
1664
1692
  showPinToggle={!railForcedSlim}
@@ -2436,7 +2464,25 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2436
2464
  below the header and right of the nav rail, sharing the frame with the
2437
2465
  drawers above. Docked = right slot (pushes content via contentGutter);
2438
2466
  maximized = fills the frame. */}
2439
- {diagnoseOpen && <DiagnoseSurface topInset={chromeless ? 0 : APP_HEADER_HEIGHT} />}
2467
+ {diagnoseOpen && (
2468
+ <DiagnoseSurface
2469
+ topInset={chromeless ? 0 : APP_HEADER_HEIGHT}
2470
+ onOpenResource={(ref) => {
2471
+ const resource: SelectedResource = {
2472
+ kind: ref.kind,
2473
+ namespace: ref.namespace ?? '',
2474
+ name: ref.name,
2475
+ group: ref.group,
2476
+ }
2477
+ const path = relatedResourcePath(resource)
2478
+ if (path.startsWith('/workload/')) {
2479
+ navigate(path)
2480
+ return
2481
+ }
2482
+ navigateFromIssue(resource)
2483
+ }}
2484
+ />
2485
+ )}
2440
2486
 
2441
2487
  {/* Port Forward floating panel (indicator lives in header) */}
2442
2488
  <PortForwardPanel />
package/src/RadarApp.tsx CHANGED
@@ -106,10 +106,10 @@ export interface RadarAppProps {
106
106
  */
107
107
  documentTitleSuffix?: string;
108
108
  /**
109
- * Injects a resource-level "Diagnose" action (e.g. a "Diagnose with AI"
109
+ * Injects a resource-level "Investigate" action (e.g. an "Investigate with AI"
110
110
  * button) into every resource detail action bar's right-aligned universal
111
111
  * actions. The host returns the node to render given the resource context.
112
- * Standalone Radar omits this and renders no Diagnose button — OSS stays
112
+ * Standalone Radar omits this and renders no Investigate button — OSS stays
113
113
  * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
114
114
  */
115
115
  renderDiagnoseAction?: RenderDiagnoseAction;
@@ -228,7 +228,7 @@ export function RadarApp({
228
228
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
229
  consentCopy={diagnoseConsent}
230
230
  >
231
- <DiagnoseProvider>
231
+ <DiagnoseProvider browserURLState={router !== "memory"}>
232
232
  <App
233
233
  manageDocumentTitle={manageDocumentTitle}
234
234
  documentTitleSuffix={documentTitleSuffix}
@@ -0,0 +1,268 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createRun, subscribeRun, type DiagnoseStreamEvent } from "./diagnose";
3
+
4
+ type SSEListener = (event: { data: string; lastEventId: string }) => void;
5
+
6
+ class FakeEventSource {
7
+ static CLOSED = 2;
8
+ static instances: FakeEventSource[] = [];
9
+
10
+ readyState = 1;
11
+ onopen: (() => void) | null = null;
12
+ onerror: (() => void) | null = null;
13
+ closed = false;
14
+ private listeners = new Map<string, SSEListener>();
15
+
16
+ constructor(
17
+ readonly url: string,
18
+ readonly options?: EventSourceInit,
19
+ ) {
20
+ FakeEventSource.instances.push(this);
21
+ }
22
+
23
+ addEventListener(type: string, listener: SSEListener) {
24
+ this.listeners.set(type, listener);
25
+ }
26
+
27
+ emit(type: string, event: DiagnoseStreamEvent, lastEventId = "") {
28
+ this.listeners.get(type)?.({ data: JSON.stringify(event), lastEventId });
29
+ }
30
+
31
+ close() {
32
+ this.closed = true;
33
+ }
34
+ }
35
+
36
+ describe("createRun", () => {
37
+ afterEach(() => vi.unstubAllGlobals());
38
+
39
+ it.each(["argoproj.io", ""])(
40
+ "sends the target API group %j in the request body",
41
+ async (group) => {
42
+ const fetchMock = vi.fn().mockResolvedValue({
43
+ ok: true,
44
+ json: async () => ({
45
+ id: "run-1",
46
+ kind: "Rollout",
47
+ group,
48
+ namespace: "prod",
49
+ name: "checkout",
50
+ }),
51
+ });
52
+ vi.stubGlobal("fetch", fetchMock);
53
+
54
+ await createRun(
55
+ {
56
+ kind: "Rollout",
57
+ group,
58
+ namespace: "prod",
59
+ name: "checkout",
60
+ },
61
+ { agent: "codex" },
62
+ );
63
+
64
+ const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
65
+ expect(JSON.parse(String(init.body))).toEqual({
66
+ kind: "Rollout",
67
+ group,
68
+ namespace: "prod",
69
+ name: "checkout",
70
+ agent: "codex",
71
+ });
72
+ },
73
+ );
74
+ });
75
+
76
+ it("passes durable SSE sequence IDs and explanation origin through replay", () => {
77
+ vi.stubGlobal("EventSource", FakeEventSource);
78
+ const onEvent = vi.fn();
79
+ const close = subscribeRun("sequence-test", { onEvent });
80
+ const source = FakeEventSource.instances.at(-1)!;
81
+ source.emit("done", { type: "done" }, "42");
82
+ source.emit("turn", { type: "turn", explainAssessment: 42 }, "43");
83
+ expect(onEvent).toHaveBeenNthCalledWith(1, { type: "done" }, 42);
84
+ expect(onEvent).toHaveBeenNthCalledWith(
85
+ 2,
86
+ { type: "turn", explainAssessment: 42 },
87
+ 43,
88
+ );
89
+ close();
90
+ vi.unstubAllGlobals();
91
+ });
92
+
93
+ describe("subscribeRun replay boundaries", () => {
94
+ beforeEach(() => {
95
+ FakeEventSource.instances = [];
96
+ vi.stubGlobal("EventSource", FakeEventSource);
97
+ });
98
+
99
+ afterEach(() => vi.unstubAllGlobals());
100
+
101
+ it("brackets every connection replay and forwards the completion marker", () => {
102
+ const starts = vi.fn();
103
+ const events: DiagnoseStreamEvent[] = [];
104
+ const cancel = subscribeRun("run-1", {
105
+ onReplayStart: starts,
106
+ onEvent: (event) => events.push(event),
107
+ });
108
+ const source = FakeEventSource.instances[0]!;
109
+
110
+ source.onopen?.();
111
+ source.emit("turn", { type: "turn" });
112
+ source.emit("replay_complete", { type: "replay_complete" });
113
+ source.onopen?.(); // EventSource reconnects through the same instance.
114
+
115
+ expect(starts).toHaveBeenCalledTimes(2);
116
+ expect(events.map((event) => event.type)).toEqual([
117
+ "turn",
118
+ "replay_complete",
119
+ ]);
120
+ cancel();
121
+ expect(source.closed).toBe(true);
122
+ });
123
+
124
+ it("distinguishes a durable closed sentinel from an unavailable stream", () => {
125
+ const durableClose = vi.fn();
126
+ subscribeRun("run-1", {
127
+ onEvent: vi.fn(),
128
+ onClosed: durableClose,
129
+ });
130
+ const closedSource = FakeEventSource.instances[0]!;
131
+ closedSource.emit("closed", { type: "closed" });
132
+ closedSource.readyState = FakeEventSource.CLOSED;
133
+ closedSource.onerror?.();
134
+ expect(durableClose).toHaveBeenCalledWith("run_closed");
135
+ expect(durableClose).toHaveBeenCalledTimes(1);
136
+
137
+ const unavailable = vi.fn();
138
+ subscribeRun("run-2", {
139
+ onEvent: vi.fn(),
140
+ onClosed: unavailable,
141
+ });
142
+ const source = FakeEventSource.instances[1]!;
143
+ source.readyState = FakeEventSource.CLOSED;
144
+ source.onerror?.();
145
+ expect(unavailable).toHaveBeenCalledWith("unavailable");
146
+ });
147
+
148
+ it("forwards hydration failures while preserving only retryable reconnects", () => {
149
+ const retryEvents: DiagnoseStreamEvent[] = [];
150
+ subscribeRun("run-retry", {
151
+ onEvent: (event) => retryEvents.push(event),
152
+ });
153
+ const retrySource = FakeEventSource.instances[0]!;
154
+ retrySource.emit("history_unavailable", {
155
+ type: "history_unavailable",
156
+ error: "history store is busy",
157
+ retryable: true,
158
+ });
159
+ expect(retryEvents).toEqual([
160
+ {
161
+ type: "history_unavailable",
162
+ error: "history store is busy",
163
+ retryable: true,
164
+ },
165
+ ]);
166
+ expect(retrySource.closed).toBe(false);
167
+
168
+ const permanentEvents: DiagnoseStreamEvent[] = [];
169
+ subscribeRun("run-permanent", {
170
+ onEvent: (event) => permanentEvents.push(event),
171
+ });
172
+ const permanentSource = FakeEventSource.instances[1]!;
173
+ permanentSource.emit("history_unavailable", {
174
+ type: "history_unavailable",
175
+ error: "history cannot be decoded",
176
+ retryable: false,
177
+ });
178
+ expect(permanentEvents.map((event) => event.type)).toEqual([
179
+ "history_unavailable",
180
+ ]);
181
+ expect(permanentSource.closed).toBe(true);
182
+ });
183
+
184
+ it("ignores a trailing closed frame after a permanent history failure", () => {
185
+ const events: DiagnoseStreamEvent[] = [];
186
+ const onClosed = vi.fn();
187
+ subscribeRun("run-corrupt-history", {
188
+ onEvent: (event) => events.push(event),
189
+ onClosed,
190
+ });
191
+ const source = FakeEventSource.instances[0]!;
192
+
193
+ source.emit("history_unavailable", {
194
+ type: "history_unavailable",
195
+ error: "history cannot be decoded",
196
+ retryable: false,
197
+ });
198
+ // This frame can already be queued when history_unavailable closes the
199
+ // EventSource. It must not relabel the run as evicted in InvestigationView.
200
+ source.emit("closed", { type: "closed" });
201
+
202
+ expect(events).toEqual([
203
+ {
204
+ type: "history_unavailable",
205
+ error: "history cannot be decoded",
206
+ retryable: false,
207
+ },
208
+ ]);
209
+ expect(onClosed).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("forwards evidence-backed apply outcomes on terminal events", () => {
213
+ const events: DiagnoseStreamEvent[] = [];
214
+ subscribeRun("run-apply", {
215
+ onEvent: (event) => events.push(event),
216
+ });
217
+ const source = FakeEventSource.instances[0]!;
218
+ source.emit("done", {
219
+ type: "done",
220
+ applyOutcome: "confirmed",
221
+ diagnosis: {
222
+ rootCause: "",
223
+ report: "Deployment updated.",
224
+ remediation: [],
225
+ },
226
+ });
227
+ source.emit("error", {
228
+ type: "error",
229
+ applyOutcome: "unknown",
230
+ error: "The write result was incomplete.",
231
+ });
232
+
233
+ expect(events).toEqual([
234
+ expect.objectContaining({ type: "done", applyOutcome: "confirmed" }),
235
+ expect.objectContaining({ type: "error", applyOutcome: "unknown" }),
236
+ ]);
237
+ });
238
+ });
239
+
240
+ describe("investigation start requests", () => {
241
+ afterEach(() => vi.unstubAllGlobals());
242
+
243
+ const target = {
244
+ kind: "Deployment",
245
+ group: "apps",
246
+ namespace: "prod",
247
+ name: "payments",
248
+ issueId: "issue-1",
249
+ };
250
+
251
+ it.each([
252
+ ["investigate further", target],
253
+ ["start fresh", { ...target, fresh: true }],
254
+ ])("preserves the %s intent on the wire", async (_name, request) => {
255
+ const fetch = vi
256
+ .fn()
257
+ .mockResolvedValue(new Response(JSON.stringify({ id: "new-run" })));
258
+ vi.stubGlobal("fetch", fetch);
259
+ await createRun(request, { agent: "hub" });
260
+ const init = fetch.mock.calls[0][1] as RequestInit;
261
+ expect(init.method).toBe("POST");
262
+ expect(JSON.parse(init.body as string)).toEqual({
263
+ ...request,
264
+ agent: "hub",
265
+ });
266
+ expect(JSON.parse(init.body as string).issueId).toBe("issue-1");
267
+ });
268
+ });