@skyhook-io/radar-app 1.16.1 → 1.16.3

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 (73) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +51 -9
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +61 -49
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.ts +26 -0
  8. package/src/api/httpErrors.ts +12 -0
  9. package/src/api/timelineSource.ts +1 -1
  10. package/src/components/ConnectionErrorView.test.tsx +2 -0
  11. package/src/components/ConnectionErrorView.tsx +2 -1
  12. package/src/components/NoClusterAccessBanner.tsx +68 -0
  13. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  14. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  15. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  16. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  17. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  18. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  19. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  20. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  21. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  22. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  23. package/src/components/cnpg/CNPGView.tsx +156 -0
  24. package/src/components/cnpg/paths.ts +13 -0
  25. package/src/components/cnpg/routes.test.ts +60 -0
  26. package/src/components/cnpg/routes.ts +112 -0
  27. package/src/components/cnpg/shared.tsx +290 -0
  28. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  29. package/src/components/diagnose/AgentControls.tsx +1 -1
  30. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  31. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  32. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  33. package/src/components/diagnose/Home.tsx +1 -1
  34. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  35. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  36. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  37. package/src/components/diagnose/InvestigationView.tsx +199 -65
  38. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  39. package/src/components/diagnose/investigationState.test.ts +83 -1
  40. package/src/components/diagnose/investigationState.ts +43 -0
  41. package/src/components/diagnose/launch.ts +2 -2
  42. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  43. package/src/components/dock/BottomDock.tsx +1 -1
  44. package/src/components/dock/DockContext.tsx +27 -2
  45. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  46. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  47. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  48. package/src/components/helm/ChartBrowser.tsx +6 -7
  49. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  50. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  51. package/src/components/helm/InstallWizard.tsx +5 -2
  52. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  53. package/src/components/helm/ValuesViewer.tsx +1 -1
  54. package/src/components/logs/LogsViewer.tsx +3 -0
  55. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  56. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  57. package/src/components/resources/ResourcesView.tsx +6 -38
  58. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  59. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  60. package/src/components/timeline/TimelineList.test.ts +47 -29
  61. package/src/components/timeline/TimelineList.tsx +57 -11
  62. package/src/components/timeline/TimelineView.tsx +9 -2
  63. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  64. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  65. package/src/components/whats-new/WhatsNew.tsx +146 -86
  66. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  67. package/src/components/whats-new/releaseNotes.ts +156 -25
  68. package/src/components/workload/WorkloadView.tsx +30 -4
  69. package/src/context/DiagnoseCustomization.tsx +13 -3
  70. package/src/contexts/CapabilitiesContext.tsx +9 -1
  71. package/src/hooks/useResourceCounts.ts +49 -0
  72. package/src/index.ts +4 -2
  73. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -28,9 +28,11 @@ import {
28
28
  createRun,
29
29
  recordConsent,
30
30
  DiagnoseError,
31
+ investigationRefusal,
31
32
  } from "../../api/diagnose";
32
33
  import { getApiBase } from "../../api/config";
33
34
  import {
35
+ type InvestigationRefusal,
34
36
  type RunSummary,
35
37
  type AgentInfo,
36
38
  type ExecutionProfile,
@@ -86,6 +88,9 @@ interface DiagnoseCtx {
86
88
  historyDegraded: boolean; // persistence broke — history won't survive a restart
87
89
  needsConsent: boolean; // a start is pending the one-time consent
88
90
  startError: string | null;
91
+ // The host's refusal behind startError, when it sent one, so the surface can
92
+ // offer the host's action beside the message.
93
+ startRefusal: InvestigationRefusal | null;
89
94
  // Kept apart from startError: the consent card renders this as "why your
90
95
  // approval was refused", and a run-start failure landing in the same slot
91
96
  // would be read as exactly that — the two paths don't share a lifecycle.
@@ -426,7 +431,10 @@ function RoutedDiagnoseProvider({
426
431
  const [runsLoadFailed, setRunsLoadFailed] = useState(false);
427
432
  const [historyDegraded, setHistoryDegraded] = useState(false);
428
433
  const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
429
- const [startError, setStartError] = useState<string | null>(null);
434
+ const [startFailure, setStartFailure] = useState<{
435
+ message: string;
436
+ refusal: InvestigationRefusal | null;
437
+ } | null>(null);
430
438
  const [consentError, setConsentError] = useState<string | null>(null);
431
439
  const [width, setWidth] = useState<number>(() => {
432
440
  try {
@@ -694,24 +702,28 @@ function RoutedDiagnoseProvider({
694
702
  })
695
703
  .catch((e) => {
696
704
  if (seq !== startSeqRef.current) return;
697
- setStartError(
698
- e instanceof DiagnoseError
699
- ? e.message
700
- : "Couldn't start the investigation.",
701
- );
705
+ setStartFailure({
706
+ message:
707
+ e instanceof DiagnoseError
708
+ ? e.message
709
+ : "Couldn't start the investigation.",
710
+ refusal: investigationRefusal(e),
711
+ });
702
712
  });
703
713
  };
704
714
 
705
715
  const openInvestigation = useCallback(
706
716
  (t: Target) => {
707
- setStartError(null);
717
+ setStartFailure(null);
708
718
  setConsentError(null);
709
719
  setOpen(true);
710
720
  if (!hosted && !consentSurface) {
711
721
  setPendingTarget(null);
712
- setStartError(
713
- "Radar can’t run this agent with a verified execution profile.",
714
- );
722
+ setStartFailure({
723
+ message:
724
+ "Radar can’t run this agent with a verified execution profile.",
725
+ refusal: null,
726
+ });
715
727
  setView("investigation");
716
728
  return;
717
729
  }
@@ -728,7 +740,7 @@ function RoutedDiagnoseProvider({
728
740
  const openRun = useCallback(
729
741
  (id: string) => {
730
742
  unavailableRunIDsRef.current.delete(id);
731
- setStartError(null);
743
+ setStartFailure(null);
732
744
  setActiveRunId(id);
733
745
  setView("investigation");
734
746
  setOpen(true);
@@ -748,7 +760,7 @@ function RoutedDiagnoseProvider({
748
760
  );
749
761
 
750
762
  // Browser navigation is the source of truth for presentation: a dedicated
751
- // /investigations route is the full workspace, while ?ai-run keeps the
763
+ // /investigations route is the full investigations page, while ?ai-run keeps the
752
764
  // contextual drawer on its underlying page. Fetch exact ids rather than
753
765
  // assuming the bounded recent list contains them.
754
766
  useEffect(() => {
@@ -756,7 +768,7 @@ function RoutedDiagnoseProvider({
756
768
  return;
757
769
  const workspace = isInvestigationWorkspacePath(location.pathname);
758
770
  // Eligibility is unresolved until the agent probe returns. Once it has
759
- // definitively resolved to off, workspace routes cannot render anything
771
+ // definitively resolved to off, investigations-page routes cannot render anything
760
772
  // useful; return to the app instead of leaving an eternal loading panel.
761
773
  if (agentEligibilityResolved && !eligible) {
762
774
  setActiveRunId(null);
@@ -823,7 +835,7 @@ function RoutedDiagnoseProvider({
823
835
 
824
836
  urlRunIdRef.current = id;
825
837
  unavailableRunIDsRef.current.delete(id);
826
- setStartError(null);
838
+ setStartFailure(null);
827
839
  setActiveRunId(id);
828
840
  setView("investigation");
829
841
  setOpen(true);
@@ -856,14 +868,14 @@ function RoutedDiagnoseProvider({
856
868
  unavailableRunIDsRef.current.clear();
857
869
  setActiveRunId(null);
858
870
  setView("home");
859
- setStartError(null);
871
+ setStartFailure(null);
860
872
  setOpen(true);
861
873
  writeFocusedRunID(null, false);
862
874
  }, [writeFocusedRunID]);
863
875
  const openWorkspace = useCallback(
864
876
  (preferredRunID?: string | null) => {
865
877
  setOpen(true);
866
- setStartError(null);
878
+ setStartFailure(null);
867
879
  if (!forceRouterURLState && !diagnoseURLStateEnabled(browserURLState)) {
868
880
  if (preferredRunID) {
869
881
  setActiveRunId(preferredRunID);
@@ -876,8 +888,8 @@ function RoutedDiagnoseProvider({
876
888
  return;
877
889
  }
878
890
  const current = locationRef.current;
879
- // The global entry remains visible in the workspace. Treat clicking it
880
- // there as an idempotent reveal rather than pushing another workspace
891
+ // The global entry remains visible on the investigations page. Treat clicking it
892
+ // there as an idempotent reveal rather than pushing another page
881
893
  // entry and replacing the page that Close should return to.
882
894
  if (isInvestigationWorkspacePath(current.pathname)) {
883
895
  setMaximized(true);
@@ -927,14 +939,14 @@ function RoutedDiagnoseProvider({
927
939
  unavailableRunIDsRef.current.clear();
928
940
  setActiveRunId(null);
929
941
  setView("home");
930
- setStartError(null);
942
+ setStartFailure(null);
931
943
  writeFocusedRunID(null, false);
932
944
  }, [writeFocusedRunID]);
933
945
  const dismissForNavigation = useCallback(() => {
934
946
  unavailableRunIDsRef.current.clear();
935
947
  setOpen(false);
936
948
  setView("home");
937
- setStartError(null);
949
+ setStartFailure(null);
938
950
  }, []);
939
951
  const close = useCallback(() => {
940
952
  unavailableRunIDsRef.current.clear();
@@ -999,7 +1011,7 @@ function RoutedDiagnoseProvider({
999
1011
  setOpen(true);
1000
1012
  if (!activeRunIdRef.current) setView("home");
1001
1013
  }, []);
1002
- const dismissError = useCallback(() => setStartError(null), []);
1014
+ const dismissError = useCallback(() => setStartFailure(null), []);
1003
1015
 
1004
1016
  // Reserve a right gutter on the CONTENT area (not the navbar/rail — those stay
1005
1017
  // global and static) so docked content reflows beside the panel. Wide viewports
@@ -1029,7 +1041,8 @@ function RoutedDiagnoseProvider({
1029
1041
  // pendingTarget is set ONLY when the current agent's consent is missing, and
1030
1042
  // cleared on approve/cancel — so its presence is exactly "consent needed now".
1031
1043
  needsConsent: !!pendingTarget,
1032
- startError,
1044
+ startError: startFailure?.message ?? null,
1045
+ startRefusal: startFailure?.refusal ?? null,
1033
1046
  consentError,
1034
1047
  openInvestigation,
1035
1048
  openRun,
@@ -1,6 +1,6 @@
1
1
  // The right-docked shell of the AI surface. Two layouts:
2
2
  // - docked: a single-pane right column (app reflows left via the provider's push)
3
- // - expanded: a master-detail workspace that fills ONLY the content area (does
3
+ // - expanded: the investigations page, a master-detail layout that fills ONLY the content area (does
4
4
  // not cover the left nav rail or top bar) — recent list on the left, the
5
5
  // selected investigation/report on the right.
6
6
  import {
@@ -38,6 +38,7 @@ import {
38
38
  type DiagnoseView,
39
39
  } from "./DiagnoseContext";
40
40
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
41
+ import { InvestigationRefusalAction } from "./InvestigationRefusalAction";
41
42
  import { useNavCustomization } from "../../context/NavCustomization";
42
43
  import { InvestigationView } from "./InvestigationView";
43
44
  import type { InvestigationTimelineScope } from "./InvestigationEvidencePane";
@@ -419,7 +420,7 @@ export function DiagnoseSurface({
419
420
  panelWidthKey: widthKey,
420
421
  } = useDiagnoseLayout();
421
422
  // Home has no detail pane competing for width, so its history is always part
422
- // of the workspace. Only a focused run collapses history into a drawer when
423
+ // of the page. Only a focused run collapses history into a drawer when
423
424
  // the two-pane layout would become cramped.
424
425
  const persistentHistory = investigationHistoryIsPersistent({
425
426
  maximized,
@@ -604,12 +605,15 @@ export function DiagnoseSurface({
604
605
  ) : d.startError ? (
605
606
  <div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
606
607
  <p className="text-sm text-theme-text-secondary">{d.startError}</p>
607
- <button
608
- onClick={d.dismissError}
609
- className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
610
- >
611
- Dismiss
612
- </button>
608
+ <div className="flex flex-wrap items-center justify-center gap-2">
609
+ <InvestigationRefusalAction refusal={d.startRefusal} />
610
+ <button
611
+ onClick={d.dismissError}
612
+ className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
613
+ >
614
+ Dismiss
615
+ </button>
616
+ </div>
613
617
  </div>
614
618
  ) : setupPending ? (
615
619
  <div className="flex-1 overflow-y-auto">
@@ -1,5 +1,5 @@
1
1
  // Server-side runs keep background and running investigations visible in both
2
- // the docked Home view and the maximized workspace's master pane.
2
+ // the docked Home view and the investigations page's list pane.
3
3
  import {
4
4
  ArrowRight,
5
5
  CircleAlert,
@@ -204,7 +204,7 @@ export function InvestigationEvidencePane({
204
204
  : undefined;
205
205
 
206
206
  // A source link is a navigation request, not a disclosure preference. Open
207
- // whichever collection owns the source first, then tell the workspace that
207
+ // whichever collection owns the source first, then tell the investigation view that
208
208
  // its double-rAF focus/scroll can safely run outside an inert subtree.
209
209
  useLayoutEffect(() => {
210
210
  if (
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import { DiagnoseError, investigationRefusal } from "../../api/diagnose";
4
+ import { DiagnoseCustomizationProvider } from "../../context/DiagnoseCustomization";
5
+ import {
6
+ InvestigationFailureNotice,
7
+ InvestigationStartErrorAlert,
8
+ InvestigationStatusCheckNotice,
9
+ } from "./InvestigationView";
10
+
11
+ const quota = new DiagnoseError(
12
+ 402,
13
+ "Your organization has used this month's investigations.",
14
+ {
15
+ code: "ai_quota_exhausted",
16
+ reason: "allowance_used_free",
17
+ action: "upgrade",
18
+ },
19
+ );
20
+
21
+ describe("investigationRefusal", () => {
22
+ it("carries a host refusal's code, reason and action with its sentence", () => {
23
+ expect(investigationRefusal(quota)).toEqual({
24
+ status: 402,
25
+ message: "Your organization has used this month's investigations.",
26
+ code: "ai_quota_exhausted",
27
+ reason: "allowance_used_free",
28
+ action: "upgrade",
29
+ });
30
+ });
31
+
32
+ it("is null for Radar's own errors and for anything that isn't a DiagnoseError", () => {
33
+ expect(
34
+ investigationRefusal(
35
+ new DiagnoseError(409, "Too many investigations running."),
36
+ ),
37
+ ).toBeNull();
38
+ expect(investigationRefusal(new Error("network down"))).toBeNull();
39
+ expect(investigationRefusal(undefined)).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe("InvestigationStartErrorAlert", () => {
44
+ const alert = (refusal = investigationRefusal(quota)) => (
45
+ <InvestigationStartErrorAlert
46
+ error={quota.message}
47
+ refusal={refusal}
48
+ onDismiss={() => {}}
49
+ />
50
+ );
51
+
52
+ it("shows the host's action beside the refusal", () => {
53
+ const html = renderToString(
54
+ <DiagnoseCustomizationProvider
55
+ value={undefined}
56
+ renderRefusalAction={(r) => (
57
+ <a href="/plans">{`Upgrade (${r.reason})`}</a>
58
+ )}
59
+ >
60
+ {alert()}
61
+ </DiagnoseCustomizationProvider>,
62
+ );
63
+ expect(html).toContain("this month&#x27;s investigations");
64
+ expect(html).toContain("Upgrade (allowance_used_free)");
65
+ });
66
+
67
+ it("shows only the sentence when the host offers no action", () => {
68
+ const html = renderToString(alert());
69
+ expect(html).toContain("this month&#x27;s investigations");
70
+ expect(html).not.toContain("Upgrade");
71
+ });
72
+
73
+ it("asks the host for nothing when the error carries no refusal", () => {
74
+ let asked = false;
75
+ renderToString(
76
+ <DiagnoseCustomizationProvider
77
+ value={undefined}
78
+ renderRefusalAction={() => {
79
+ asked = true;
80
+ return null;
81
+ }}
82
+ >
83
+ {alert(null)}
84
+ </DiagnoseCustomizationProvider>,
85
+ );
86
+ expect(asked).toBe(false);
87
+ });
88
+ });
89
+
90
+ const withHost = (node: React.ReactNode) =>
91
+ renderToString(
92
+ <DiagnoseCustomizationProvider
93
+ value={undefined}
94
+ renderRefusalAction={(r) => (
95
+ <a href="/plans">{`Upgrade (${r.reason})`}</a>
96
+ )}
97
+ >
98
+ {node}
99
+ </DiagnoseCustomizationProvider>,
100
+ );
101
+
102
+ describe("InvestigationStatusCheckNotice (Findings)", () => {
103
+ it("says a refused status check couldn't run, with the host's action", () => {
104
+ const html = withHost(
105
+ <InvestigationStatusCheckNotice
106
+ message={quota.message}
107
+ uncertainOnly={false}
108
+ refusal={investigationRefusal(quota)}
109
+ onCheck={() => {}}
110
+ disabled={false}
111
+ />,
112
+ );
113
+ expect(html).toContain("Couldn&#x27;t check status:");
114
+ expect(html).not.toContain("Verification did not complete");
115
+ expect(html).toContain("Upgrade (allowance_used_free)");
116
+ });
117
+
118
+ it("keeps the verification wording, and no action, for a check that ran and failed", () => {
119
+ const html = withHost(
120
+ <InvestigationStatusCheckNotice
121
+ message="The agent stopped before checking."
122
+ uncertainOnly={false}
123
+ refusal={null}
124
+ onCheck={() => {}}
125
+ disabled={false}
126
+ />,
127
+ );
128
+ expect(html).toContain("Verification did not complete:");
129
+ expect(html).not.toContain("Upgrade");
130
+ });
131
+ });
132
+
133
+ describe("InvestigationFailureNotice (Activity)", () => {
134
+ it("keeps a status warning first and shows a newer refused follow-up, with its action, beside it", () => {
135
+ const html = withHost(
136
+ <InvestigationFailureNotice
137
+ message="Radar couldn't confirm whether the apply request completed."
138
+ refusal={null}
139
+ statusCheck={{
140
+ label: "Check current status",
141
+ onCheck: () => {},
142
+ disabled: false,
143
+ }}
144
+ followUp={{
145
+ message: quota.message,
146
+ refusal: investigationRefusal(quota),
147
+ }}
148
+ />,
149
+ );
150
+ expect(
151
+ html.indexOf("couldn&#x27;t confirm whether the apply"),
152
+ ).toBeLessThan(html.indexOf("this month&#x27;s investigations"));
153
+ expect(html).toContain("Check current status");
154
+ expect(html).toContain("Upgrade (allowance_used_free)");
155
+ });
156
+ });
@@ -0,0 +1,14 @@
1
+ import type { InvestigationRefusal } from "../../api/diagnose";
2
+ import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
3
+
4
+ /** The host's action for a refused start or follow-up, or nothing when the
5
+ * refusal carries no code, reason or action, or the host offers none. */
6
+ export function InvestigationRefusalAction({
7
+ refusal,
8
+ }: {
9
+ refusal: InvestigationRefusal | null;
10
+ }) {
11
+ const { renderRefusalAction } = useDiagnoseCustomization();
12
+ if (!refusal || !renderRefusalAction) return null;
13
+ return <>{renderRefusalAction(refusal)}</>;
14
+ }