@skyhook-io/radar-app 1.12.3 → 1.13.1

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 (38) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +37 -8
  3. package/src/RadarApp.tsx +1 -1
  4. package/src/api/client.ts +38 -6
  5. package/src/api/diagnose.ts +45 -5
  6. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  7. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  8. package/src/components/cost/CostTrendChart.test.ts +65 -0
  9. package/src/components/cost/CostTrendChart.tsx +107 -17
  10. package/src/components/cost/CostView.test.ts +36 -1
  11. package/src/components/cost/CostView.tsx +133 -51
  12. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  13. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  14. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  15. package/src/components/cost/source.test.ts +33 -0
  16. package/src/components/cost/source.ts +100 -0
  17. package/src/components/diagnose/AISettings.tsx +9 -4
  18. package/src/components/diagnose/DiagnoseContext.tsx +216 -48
  19. package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
  20. package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
  21. package/src/components/diagnose/Home.tsx +93 -64
  22. package/src/components/diagnose/InvestigationView.tsx +190 -124
  23. package/src/components/diagnose/parts.test.tsx +12 -4
  24. package/src/components/diagnose/parts.tsx +29 -17
  25. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  26. package/src/components/execution/BatchExecutionView.tsx +2 -3
  27. package/src/components/execution/execution-definition.test.ts +19 -0
  28. package/src/components/execution/execution-definition.ts +2 -0
  29. package/src/components/gitops/GitOpsView.tsx +25 -3
  30. package/src/components/home/CostCard.tsx +3 -2
  31. package/src/components/nav/navigation.test.ts +26 -0
  32. package/src/components/nav/navigation.ts +10 -0
  33. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  34. package/src/components/rightsizing/copy.test.ts +19 -0
  35. package/src/components/settings/SettingsDialog.tsx +666 -103
  36. package/src/components/settings/settings-state.test.ts +42 -0
  37. package/src/components/settings/settings-state.ts +39 -0
  38. package/src/index.css +11 -1
@@ -17,11 +17,13 @@ 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,
@@ -86,6 +88,7 @@ interface DiagnoseCtx {
86
88
  approveConsent: () => void;
87
89
  cancelConsent: () => void;
88
90
  refreshRuns: () => void;
91
+ updateRunSummary: (run: RunSummary) => void;
89
92
  dismissError: () => void;
90
93
  }
91
94
 
@@ -95,6 +98,7 @@ interface DiagnoseCtx {
95
98
  // churns the business context) doesn't re-render the whole shell.
96
99
  interface DiagnoseLayoutCtx {
97
100
  open: boolean;
101
+ close: () => void;
98
102
  contentGutter: number; // px right-gutter for the content area when docked (0 = overlay/closed)
99
103
  maximized: boolean;
100
104
  setMaximized: Dispatch<SetStateAction<boolean>>;
@@ -186,7 +190,58 @@ function writeStored(key: string, value: string) {
186
190
  }
187
191
  }
188
192
 
189
- export function DiagnoseProvider({ children }: { children: ReactNode }) {
193
+ function runIDFromLocation(browserURLState: boolean): string | null {
194
+ if (!diagnoseURLStateEnabled(browserURLState)) return null;
195
+ try {
196
+ return new URLSearchParams(window.location.search).get("ai-run");
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function writeRunIDToLocation(
203
+ id: string | null,
204
+ push: boolean,
205
+ browserURLState: boolean,
206
+ ) {
207
+ if (!diagnoseURLStateEnabled(browserURLState)) return;
208
+ try {
209
+ const url = new URL(window.location.href);
210
+ if (id) url.searchParams.set("ai-run", id);
211
+ else url.searchParams.delete("ai-run");
212
+ window.history[push ? "pushState" : "replaceState"](
213
+ window.history.state,
214
+ "",
215
+ `${url.pathname}${url.search}${url.hash}`,
216
+ );
217
+ // History API writes do not notify BrowserRouter. Replaying the new state as
218
+ // popstate keeps every later navigate/setSearchParams call on the same live
219
+ // query string instead of letting a stale router snapshot erase ai-run.
220
+ window.dispatchEvent(
221
+ new PopStateEvent("popstate", { state: window.history.state }),
222
+ );
223
+ } catch {
224
+ /* URL APIs unavailable — panel state still works for this session */
225
+ }
226
+ }
227
+
228
+ // Fleet pages host the panel against a cluster-scoped API while remaining on a
229
+ // hub route such as /issues. Writing ?ai-run there would create a non-reloadable
230
+ // pseudo-link. The embedded /c/:id Radar tree and local Radar own their route and
231
+ // therefore keep the query as navigation state; Fleet copies run.radarUrl instead.
232
+ function diagnoseURLStateEnabled(browserURLState: boolean): boolean {
233
+ if (!browserURLState) return false;
234
+ const clusterScopedAPI = /\/c\/[^/]+\/api\/?$/.test(getApiBase());
235
+ return !clusterScopedAPI || /^\/c\/[^/]+/.test(window.location.pathname);
236
+ }
237
+
238
+ export function DiagnoseProvider({
239
+ children,
240
+ browserURLState = true,
241
+ }: {
242
+ children: ReactNode;
243
+ browserURLState?: boolean;
244
+ }) {
190
245
  const [available, setAvailable] = useState(false);
191
246
  const [eligible, setEligible] = useState(false);
192
247
  const [agents, setAgents] = useState<AgentInfo[]>([]);
@@ -206,6 +261,26 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
206
261
  const [open, setOpen] = useState(false);
207
262
  const [view, setView] = useState<DiagnoseView>("home");
208
263
  const [activeRunId, setActiveRunId] = useState<string | null>(null);
264
+ const activeRunIdRef = useRef(activeRunId);
265
+ activeRunIdRef.current = activeRunId;
266
+ // A missing/revoked durable link is terminal until the user explicitly
267
+ // navigates to it again. Keep polling the bounded history list (so a newly
268
+ // shared run can reappear), but do not hammer the exact 404 endpoint every
269
+ // four seconds while the unavailable state is already on screen.
270
+ const unavailableRunIDsRef = useRef(new Set<string>());
271
+ // Tracks whether the panel focus belongs to browser history. Fleet opens the
272
+ // same panel on /issues, where URL state is deliberately disabled; a generic
273
+ // panel launch must never be closed by the deep-link synchronization effect.
274
+ const urlRunIdRef = useRef(runIDFromLocation(browserURLState));
275
+ const writeFocusedRunID = useCallback(
276
+ (id: string | null, push: boolean) => {
277
+ // Set this before writeRunIDToLocation's synthetic popstate so our own
278
+ // listener can distinguish a programmatic close/home write from Back.
279
+ if (diagnoseURLStateEnabled(browserURLState)) urlRunIdRef.current = id;
280
+ writeRunIDToLocation(id, push, browserURLState);
281
+ },
282
+ [browserURLState],
283
+ );
209
284
  const [runs, setRuns] = useState<RunSummary[]>([]);
210
285
  const [runsLoaded, setRunsLoaded] = useState(false);
211
286
  const [runsLoadFailed, setRunsLoadFailed] = useState(false);
@@ -332,28 +407,79 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
332
407
  return () => window.removeEventListener("resize", onResize);
333
408
  }, []);
334
409
 
335
- const refreshRuns = useCallback(() => {
410
+ const refreshRuns = useCallback(async () => {
336
411
  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);
412
+ try {
413
+ const r = await listRuns();
414
+ const focusedID = activeRunIdRef.current;
415
+ let focusedRun: RunSummary | null = null;
416
+ let retainFocusedSnapshot = false;
417
+ const listedFocusedRun = focusedID
418
+ ? r.runs.find((run) => run.id === focusedID)
419
+ : undefined;
420
+ if (focusedID && listedFocusedRun) {
421
+ unavailableRunIDsRef.current.delete(focusedID);
422
+ } else if (focusedID && !unavailableRunIDsRef.current.has(focusedID)) {
423
+ try {
424
+ // A stable deep link can target a retained run older than the bounded
425
+ // history page. Refresh it directly too: its status and capabilities
426
+ // must not freeze at the first snapshot while the panel stays open.
427
+ focusedRun = await getRun(focusedID);
428
+ } catch (error) {
429
+ // The bounded list is still authoritative for everything else. A
430
+ // missing/revoked focused run falls out and renders unavailable;
431
+ // transient direct-fetch failures keep the last useful snapshot.
432
+ const unavailable =
433
+ error instanceof DiagnoseError && error.status === 404;
434
+ if (unavailable) unavailableRunIDsRef.current.add(focusedID);
435
+ retainFocusedSnapshot = !unavailable;
436
+ }
437
+ }
438
+ setRuns((prev) => {
439
+ let nextRuns = r.runs;
440
+ if (focusedRun) nextRuns = [focusedRun, ...nextRuns];
441
+ if (focusedID && retainFocusedSnapshot) {
442
+ const previous = prev.find((run) => run.id === focusedID);
443
+ if (previous) nextRuns = [previous, ...nextRuns];
444
+ }
445
+
446
+ // openRun/start can focus and insert a run while the direct fetch above
447
+ // is in flight. Preserve that newer focus instead of replacing it with
448
+ // the snapshot for the run that was active when this refresh started.
449
+ const currentFocusedID = activeRunIdRef.current;
450
+ if (
451
+ currentFocusedID &&
452
+ currentFocusedID !== focusedID &&
453
+ !nextRuns.some((run) => run.id === currentFocusedID)
454
+ ) {
455
+ const current = prev.find((run) => run.id === currentFocusedID);
456
+ if (current) nextRuns = [current, ...nextRuns];
457
+ }
458
+ return nextRuns;
349
459
  });
460
+ setRunsLoaded(true);
461
+ setRunsLoadFailed(false);
462
+ setHistoryDegraded(!!r.historyDegraded);
463
+ } catch {
464
+ // Leave runsLoaded false (a missing-run verdict needs a real list) but
465
+ // record the failure so the panel can say "retrying" instead of
466
+ // pretending nothing happened. The 4s poll keeps retrying while open.
467
+ setRunsLoadFailed(true);
468
+ }
350
469
  }, [available]);
470
+ const updateRunSummary = useCallback((run: RunSummary) => {
471
+ setRuns((prev) =>
472
+ prev.some((item) => item.id === run.id)
473
+ ? prev.map((item) => (item.id === run.id ? run : item))
474
+ : [run, ...prev],
475
+ );
476
+ }, []);
351
477
 
352
- // A content-stable signature of the resources with a live (running) investigation,
478
+ // A content-stable signature of the resources with a live investigation,
353
479
  // so the per-resource Diagnose buttons can show a "running" indicator even with the
354
480
  // panel closed — and only re-render when the set actually changes, not every poll.
355
481
  const runningSig = runs
356
- .filter((r) => r.status === "running")
482
+ .filter((r) => r.status === "running" || r.status === "stopping")
357
483
  .map((r) => runTargetKey(r.kind, r.namespace, r.name))
358
484
  .sort()
359
485
  .join("|");
@@ -397,6 +523,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
397
523
  if (seq !== startSeqRef.current) return;
398
524
  setActiveRunId(run.id);
399
525
  setView("investigation");
526
+ writeFocusedRunID(run.id, true);
400
527
  })
401
528
  .catch((e) => {
402
529
  if (seq !== startSeqRef.current) return;
@@ -431,52 +558,90 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
431
558
  },
432
559
  [consentSurface, hosted],
433
560
  );
434
- const openRun = useCallback((id: string) => {
435
- setStartError(null);
436
- setActiveRunId(id);
437
- setView("investigation");
438
- setOpen(true);
439
- }, []);
561
+ const openRun = useCallback(
562
+ (id: string) => {
563
+ unavailableRunIDsRef.current.delete(id);
564
+ setStartError(null);
565
+ setActiveRunId(id);
566
+ setView("investigation");
567
+ setOpen(true);
568
+ writeFocusedRunID(id, true);
569
+ // A Fleet issue can point at a retained automatic run older than the
570
+ // bounded history page. Resolve the exact id instead of waiting for list.
571
+ getRun(id)
572
+ .then(updateRunSummary)
573
+ .catch((error) => {
574
+ if (error instanceof DiagnoseError && error.status === 404) {
575
+ unavailableRunIDsRef.current.add(id);
576
+ }
577
+ // The loaded-list state renders the durable unavailable message.
578
+ });
579
+ },
580
+ [updateRunSummary, writeFocusedRunID],
581
+ );
440
582
 
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.
583
+ // The run id is durable navigation state: direct loads and popstate focus the
584
+ // exact run, and the query remains in place so the address bar is copyable.
585
+ // Fetch by id rather than assuming the bounded recent list contains it.
444
586
  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
- );
587
+ if (!available || !diagnoseURLStateEnabled(browserURLState)) return;
588
+ const focusFromLocation = (fromPopState: boolean) => {
589
+ const id = runIDFromLocation(browserURLState);
590
+ if (!id) {
591
+ // A missing id on first mount is ordinary. On popstate it means "back
592
+ // out of this investigation" only when the focused run was itself
593
+ // installed by URL history; unrelated synthetic popstate events must
594
+ // not tear down a panel opened by an in-app action.
595
+ if (fromPopState && urlRunIdRef.current) {
596
+ setActiveRunId(null);
597
+ setView("home");
598
+ setOpen(false);
599
+ }
600
+ urlRunIdRef.current = null;
601
+ return;
460
602
  }
461
- } catch {
462
- /* URL APIs unavailable — skip the deep link */
463
- }
464
- if (id) openRun(id);
465
- }, [available, openRun]);
603
+ urlRunIdRef.current = id;
604
+ unavailableRunIDsRef.current.delete(id);
605
+ setStartError(null);
606
+ setActiveRunId(id);
607
+ setView("investigation");
608
+ setOpen(true);
609
+ getRun(id)
610
+ .then(updateRunSummary)
611
+ .catch((error) => {
612
+ if (error instanceof DiagnoseError && error.status === 404) {
613
+ unavailableRunIDsRef.current.add(id);
614
+ }
615
+ // The list load owns the final missing/degraded state and keeps retrying.
616
+ });
617
+ };
618
+ focusFromLocation(false);
619
+ const onPopState = () => focusFromLocation(true);
620
+ window.addEventListener("popstate", onPopState);
621
+ return () => window.removeEventListener("popstate", onPopState);
622
+ }, [available, browserURLState, updateRunSummary]);
466
623
  // Leaving the detail pane drops the failure that belonged to it. startError
467
624
  // renders as the entire pane (maximized home still shows `detail`), where a
468
625
  // message about a resource you just navigated away from has nothing to attach
469
626
  // to and no way to be dismissed.
470
627
  const openHome = useCallback(() => {
628
+ unavailableRunIDsRef.current.clear();
471
629
  setView("home");
472
630
  setStartError(null);
473
631
  setOpen(true);
474
- }, []);
632
+ writeFocusedRunID(null, false);
633
+ }, [writeFocusedRunID]);
475
634
  const goHome = useCallback(() => {
635
+ unavailableRunIDsRef.current.clear();
476
636
  setView("home");
477
637
  setStartError(null);
478
- }, []);
479
- const close = useCallback(() => setOpen(false), []);
638
+ writeFocusedRunID(null, false);
639
+ }, [writeFocusedRunID]);
640
+ const close = useCallback(() => {
641
+ unavailableRunIDsRef.current.clear();
642
+ setOpen(false);
643
+ writeFocusedRunID(null, false);
644
+ }, [writeFocusedRunID]);
480
645
  const consentBusyRef = useRef(false);
481
646
  const approveConsent = useCallback(() => {
482
647
  if (consentBusyRef.current) return;
@@ -557,6 +722,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
557
722
  approveConsent,
558
723
  cancelConsent,
559
724
  refreshRuns,
725
+ updateRunSummary,
560
726
  dismissError,
561
727
  };
562
728
 
@@ -567,6 +733,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
567
733
  const layout = useMemo<DiagnoseLayoutCtx>(
568
734
  () => ({
569
735
  open,
736
+ close,
570
737
  contentGutter,
571
738
  maximized,
572
739
  setMaximized,
@@ -579,6 +746,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
579
746
  }),
580
747
  [
581
748
  open,
749
+ close,
582
750
  contentGutter,
583
751
  maximized,
584
752
  width,
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { canStartNewInvestigation } from "./DiagnoseSurface";
2
+ import { canCopyRunLink, canStartNewInvestigation } from "./DiagnoseSurface";
3
+ import {
4
+ canContinueInvestigation,
5
+ canStopInvestigation,
6
+ } from "./InvestigationView";
3
7
  import type { RunSummary } from "../../api/diagnose";
4
8
 
5
9
  // The "new investigation" button dispatches an agent and spends the user's own
@@ -34,9 +38,25 @@ describe("canStartNewInvestigation", () => {
34
38
 
35
39
  it("stays hidden while a turn is in flight", () => {
36
40
  // A start would be handed back the live run, so the button does nothing.
37
- expect(canStartNewInvestigation("investigation", run("running"), false)).toBe(
38
- false,
39
- );
41
+ expect(
42
+ canStartNewInvestigation("investigation", run("running"), false),
43
+ ).toBe(false);
44
+ });
45
+
46
+ it("stays hidden while a stopped turn is draining", () => {
47
+ expect(
48
+ canStartNewInvestigation("investigation", run("stopping"), false),
49
+ ).toBe(false);
50
+ });
51
+
52
+ it("offers a separate human investigation while an automatic run is in flight", () => {
53
+ expect(
54
+ canStartNewInvestigation(
55
+ "investigation",
56
+ { ...run("running"), trigger: "background" },
57
+ false,
58
+ ),
59
+ ).toBe(true);
40
60
  });
41
61
 
42
62
  it("stays hidden on a stale run", () => {
@@ -63,8 +83,101 @@ describe("canStartNewInvestigation", () => {
63
83
  expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
64
84
  true,
65
85
  );
66
- expect(canStartNewInvestigation("investigation", run("stopped"), false)).toBe(
86
+ expect(
87
+ canStartNewInvestigation("investigation", run("stopped"), false),
88
+ ).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("canStopInvestigation", () => {
93
+ it("lets the terminal transcript outrank a lagging running summary", () => {
94
+ expect(canStopInvestigation(run("running"), false, false, "done")).toBe(
95
+ false,
96
+ );
97
+ expect(canStopInvestigation(run("running"), false, false, "error")).toBe(
98
+ false,
99
+ );
100
+ });
101
+
102
+ it("keeps Stop available for an active human turn", () => {
103
+ expect(canStopInvestigation(run("running"), true, false, "running")).toBe(
67
104
  true,
68
105
  );
69
106
  });
107
+
108
+ it("does not offer another Stop while the server drains the turn", () => {
109
+ expect(canStopInvestigation(run("stopping"), true, false, "running")).toBe(
110
+ false,
111
+ );
112
+ });
113
+
114
+ it("never lets a missing or automatic run expose Stop", () => {
115
+ expect(canStopInvestigation(run("running"), true, true, "running")).toBe(
116
+ false,
117
+ );
118
+ expect(
119
+ canStopInvestigation(
120
+ { ...run("running"), trigger: "background" },
121
+ true,
122
+ false,
123
+ "running",
124
+ ),
125
+ ).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe("canContinueInvestigation", () => {
130
+ it("lets a terminal transcript outrank only a lagging running human summary", () => {
131
+ expect(
132
+ canContinueInvestigation(
133
+ { ...run("running"), canContinue: false },
134
+ "done",
135
+ ),
136
+ ).toBe(true);
137
+ });
138
+
139
+ it("keeps genuinely read-only and sessionless investigations read-only", () => {
140
+ expect(
141
+ canContinueInvestigation(
142
+ {
143
+ ...run("running"),
144
+ trigger: "background",
145
+ canContinue: false,
146
+ },
147
+ "done",
148
+ ),
149
+ ).toBe(false);
150
+ expect(
151
+ canContinueInvestigation({ ...run("done"), canContinue: false }, "done"),
152
+ ).toBe(false);
153
+ });
154
+
155
+ it("does not offer a follow-up after the retained run disappears", () => {
156
+ expect(
157
+ canContinueInvestigation(
158
+ { ...run("running"), canContinue: false },
159
+ "error",
160
+ true,
161
+ ),
162
+ ).toBe(false);
163
+ });
164
+ });
165
+
166
+ describe("canCopyRunLink", () => {
167
+ it("does not expose collaboration UI for an OSS run", () => {
168
+ expect(canCopyRunLink(run("done"))).toBe(false);
169
+ });
170
+
171
+ it("exposes the copy action only for a canonical hosted URL", () => {
172
+ expect(
173
+ canCopyRunLink({
174
+ ...run("done"),
175
+ radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
176
+ }),
177
+ ).toBe(true);
178
+ });
179
+
180
+ it("treats an empty hosted URL as unavailable", () => {
181
+ expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
182
+ });
70
183
  });