@skyhook-io/radar-app 1.13.2 → 1.13.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.2",
3
+ "version": "1.13.4",
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",
@@ -35,7 +35,7 @@
35
35
  "diff": "^9.0.0",
36
36
  "monaco-editor": "0.55.1",
37
37
  "react-markdown": "^10.1.0",
38
- "react-virtuoso": "^4.18.12",
38
+ "react-virtuoso": "^4.18.13",
39
39
  "remark-gfm": "^4.0.1",
40
40
  "shiki": "^4.4.2",
41
41
  "yaml": "^2.9.0"
@@ -58,7 +58,7 @@
58
58
  "@skyhook-io/k8s-ui": "*",
59
59
  "@tailwindcss/typography": "^0.5.20",
60
60
  "@tailwindcss/vite": "^4.3.3",
61
- "@tanstack/react-query": "^5.101.4",
61
+ "@tanstack/react-query": "^5.102.8",
62
62
  "@types/node": "^26.2.0",
63
63
  "@types/react": "^19.2.18",
64
64
  "@types/react-dom": "^19.2.3",
@@ -66,16 +66,16 @@
66
66
  "@xyflow/react": "^12.11.2",
67
67
  "clsx": "^2.1.1",
68
68
  "elkjs": "^0.11.1",
69
- "eslint": "^10.8.0",
69
+ "eslint": "^10.10.0",
70
70
  "eslint-plugin-react-hooks": "^7.1.1",
71
71
  "eslint-plugin-react-refresh": "^0.5.3",
72
72
  "globals": "^17.11.0",
73
73
  "lucide-react": "^1.37.0",
74
- "postcss": "^8.5.26",
74
+ "postcss": "^8.5.28",
75
75
  "prettier": "^3.9.5",
76
76
  "react": "^19.2.8",
77
77
  "react-dom": "^19.2.8",
78
- "react-router-dom": "^7.18.2",
78
+ "react-router-dom": "^7.18.3",
79
79
  "tailwind-merge": "^3.6.0",
80
80
  "tailwindcss": "^4.3.3",
81
81
  "typescript": "^6.0.2",
package/src/App.tsx CHANGED
@@ -7,7 +7,7 @@ import { useNavigate, useLocation, useSearchParams, useNavigationType, Navigatio
7
7
  import { HomeView } from './components/home/HomeView'
8
8
  import { DebugOverlay } from './components/DebugOverlay'
9
9
  import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
10
- import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
10
+ import { investigationWorkspaceSearch, isInvestigationWorkspacePath, useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
11
11
  import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
12
12
  import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader, assetUrl } from '@skyhook-io/k8s-ui'
13
13
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
@@ -120,7 +120,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
120
120
 
121
121
  // Convert API resource name back to topology node ID prefix
122
122
  // Extended MainView type that includes traffic and cost
123
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
123
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
124
124
 
125
125
  // Extract view from URL path
126
126
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -140,6 +140,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
140
140
  if (path === 'applications') return 'applications'
141
141
  if (path === 'compare') return 'compare'
142
142
  if (path === 'issues') return 'issues'
143
+ if (path === 'investigations') return 'investigations'
143
144
  return 'home'
144
145
  }
145
146
 
@@ -325,7 +326,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
325
326
  // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
326
327
  const {
327
328
  open: diagnoseOpen,
328
- close: closeDiagnose,
329
+ dismissForNavigation: dismissDiagnoseForNavigation,
329
330
  contentGutter,
330
331
  maximized: diagnoseMaximized,
331
332
  } = useDiagnoseLayout()
@@ -475,10 +476,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
475
476
  const path = view === 'home' ? '/' : `/${view}`
476
477
 
477
478
  // 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.
479
+ // React Router owns navigation state. In embedded MemoryRouter mode the
480
+ // browser URL intentionally does not change, so window.location is stale.
480
481
  const newParams = new URLSearchParams()
481
- const currentParams = new URLSearchParams(window.location.search)
482
+ const currentParams = new URLSearchParams(location.search)
482
483
  const globalNamespaces = currentParams.get('namespaces')
483
484
  if (globalNamespaces) {
484
485
  newParams.set('namespaces', globalNamespaces)
@@ -496,7 +497,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
496
497
  }
497
498
 
498
499
  navigate({ pathname: path, search: newParams.toString() })
499
- }, [navigate, takeover, goHost])
500
+ }, [location.search, navigate, takeover, goHost])
500
501
 
501
502
  // The standalone rail expresses intent to leave the full-width investigation
502
503
  // workspace. Close it before routing so the destination is immediately visible;
@@ -504,10 +505,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
504
505
  const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
505
506
  navigateFromPrimaryRail(
506
507
  diagnoseOpen && diagnoseMaximized,
507
- closeDiagnose,
508
+ dismissDiagnoseForNavigation,
508
509
  () => setMainView(view),
509
510
  )
510
- }, [diagnoseOpen, diagnoseMaximized, closeDiagnose, setMainView])
511
+ }, [diagnoseOpen, diagnoseMaximized, dismissDiagnoseForNavigation, setMainView])
511
512
 
512
513
  // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
513
514
  // own fleet pages scoped to this cluster — owned by the host's left rail — so
@@ -730,7 +731,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
730
731
  // duplicated verbatim at each render site. Encodes the opened resource in the
731
732
  // URL (?resource=ns/name) — the same deep-link shape the resources view
732
733
  // round-trips — so refresh/share keeps the drawer open instead of dropping it.
733
- const navigateToResourceList = useCallback((resource: SelectedResource) => {
734
+ const navigateToResourceList = useCallback((resource: SelectedResource, investigationRunID?: string | null) => {
734
735
  const pluralKind = kindToPluralWithGroup(resource.kind, resource.group ?? '')
735
736
  setSelectedResource({ ...resource, kind: pluralKind })
736
737
  const newParams = new URLSearchParams(searchParams)
@@ -748,10 +749,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
748
749
  } else {
749
750
  newParams.delete('apiGroup')
750
751
  }
752
+ if (investigationRunID === null) newParams.delete('ai-run')
753
+ else if (investigationRunID) newParams.set('ai-run', investigationRunID)
751
754
  navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
752
755
  }, [searchParams, navigate])
753
756
 
754
- const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
757
+ const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string, investigationRunID?: string | null) => {
755
758
  const newParams = new URLSearchParams()
756
759
  const globalNamespaces = searchParams.get('namespaces')
757
760
  if (globalNamespaces) {
@@ -761,6 +764,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
761
764
  if (storageNamespace) {
762
765
  newParams.set('releaseStorage', storageNamespace)
763
766
  }
767
+ if (investigationRunID) newParams.set('ai-run', investigationRunID)
764
768
  setSelectedHelmRelease({ namespace, name, storageNamespace })
765
769
  if (mainView === 'helm') {
766
770
  setSearchParams(newParams, { replace: true })
@@ -772,9 +776,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
772
776
  // From the Issues queue: special controller/manager subjects route to their
773
777
  // rich detail pages, not the generic resource drawer that's a dead-end for
774
778
  // them. Member resources (Pods, Services, …) fall through to resources.
775
- const navigateFromIssue = useCallback((resource: SelectedResource) => {
779
+ const navigateFromIssue = useCallback((resource: SelectedResource, investigationRunID?: string | null) => {
776
780
  if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
777
- navigateToHelmRelease(resource.namespace, resource.name)
781
+ navigateToHelmRelease(resource.namespace, resource.name, undefined, investigationRunID)
778
782
  return
779
783
  }
780
784
  const gitOpsPath = gitOpsRouteForResource({
@@ -783,10 +787,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
783
787
  metadata: { namespace: resource.namespace ?? '', name: resource.name },
784
788
  })
785
789
  if (gitOpsPath) {
786
- navigate(gitOpsPath)
790
+ const destination = new URL(gitOpsPath, window.location.origin)
791
+ if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
792
+ navigate(`${destination.pathname}${destination.search}${destination.hash}`)
787
793
  return
788
794
  }
789
- navigateToResourceList(resource)
795
+ navigateToResourceList(resource, investigationRunID)
790
796
  }, [navigate, navigateToHelmRelease, navigateToResourceList])
791
797
 
792
798
  // Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
@@ -842,7 +848,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
842
848
  gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
843
849
  // Non-rail views (reachable via deep links / actions, not the rail) get no
844
850
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
845
- workload: '', compare: '', helmCompare: '',
851
+ workload: '', compare: '', helmCompare: '', investigations: '',
846
852
  }
847
853
  const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
848
854
  (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
@@ -1162,10 +1168,23 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
1162
1168
  // Reset resource-specific params while retaining the durable investigation
1163
1169
  // focus. Diagnose resolves runs by id and owns whether the focused run is
1164
1170
  // 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 })
1171
+ if (isInvestigationWorkspacePath(location.pathname)) {
1172
+ navigate(
1173
+ {
1174
+ pathname: location.pathname,
1175
+ search: investigationWorkspaceSearch(location.search),
1176
+ },
1177
+ { replace: true, state: location.state },
1178
+ )
1179
+ } else {
1180
+ const nextParams = new URLSearchParams()
1181
+ const diagnoseRun = new URLSearchParams(location.search).get('ai-run')
1182
+ if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
1183
+ navigate(
1184
+ { pathname: location.pathname, search: nextParams.toString() },
1185
+ { replace: true, state: location.state },
1186
+ )
1187
+ }
1169
1188
 
1170
1189
  // Auto-unpause so the new cluster's topology loads immediately
1171
1190
  setTopologyPaused(false)
@@ -2467,7 +2486,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2467
2486
  {diagnoseOpen && (
2468
2487
  <DiagnoseSurface
2469
2488
  topInset={chromeless ? 0 : APP_HEADER_HEIGHT}
2470
- onOpenResource={(ref) => {
2489
+ onBrowseIssues={() => setMainView('issues')}
2490
+ onOpenResource={(ref, investigationRunID) => {
2471
2491
  const resource: SelectedResource = {
2472
2492
  kind: ref.kind,
2473
2493
  namespace: ref.namespace ?? '',
@@ -2476,10 +2496,35 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
2476
2496
  }
2477
2497
  const path = relatedResourcePath(resource)
2478
2498
  if (path.startsWith('/workload/')) {
2479
- navigate(path)
2499
+ const destination = new URL(path, window.location.origin)
2500
+ if (investigationRunID) destination.searchParams.set('ai-run', investigationRunID)
2501
+ navigate(`${destination.pathname}${destination.search}${destination.hash}`)
2480
2502
  return
2481
2503
  }
2482
- navigateFromIssue(resource)
2504
+ navigateFromIssue(resource, investigationRunID)
2505
+ }}
2506
+ onOpenTimeline={({ namespace, name }) => {
2507
+ // Scope state and URL move together, as the Timeline's own
2508
+ // namespace prompt does: the URL-write effect would otherwise
2509
+ // restore the previous scope over the destination's `namespaces`
2510
+ // while the URL-read effect applies it, and the two would alternate.
2511
+ const params = new URLSearchParams({ q: name })
2512
+ if (namespace) {
2513
+ params.set('namespaces', namespace)
2514
+ setNamespaces([namespace])
2515
+ setActiveNamespace.mutate({ namespaces: [namespace] })
2516
+ } else {
2517
+ // A cluster-scoped subject changes nothing about scope, so the
2518
+ // destination keeps the current one. That means a namespace
2519
+ // filter can hide the very change that was clicked, because the
2520
+ // Timeline drops events whose namespace is outside it. Widening
2521
+ // instead has to move scope, URL and the server pick together —
2522
+ // three coupled effects with their own ordering rules — so it is
2523
+ // a change to make against that machinery, not here.
2524
+ const globalNamespaces = searchParams.get('namespaces')
2525
+ if (globalNamespaces) params.set('namespaces', globalNamespaces)
2526
+ }
2527
+ navigate({ pathname: '/timeline', search: params.toString() })
2483
2528
  }}
2484
2529
  />
2485
2530
  )}
package/src/RadarApp.tsx CHANGED
@@ -135,6 +135,12 @@ export interface RadarAppProps {
135
135
  * Radar runs with `navSlots.chrome: 'none'`.
136
136
  */
137
137
  onClusterLoadStateChange?: (state: ClusterLoadState) => void;
138
+ /**
139
+ * Called after a focused investigation has been resolved by the server.
140
+ * Embedders whose chrome lives outside RadarApp's router can use this as a
141
+ * navigation hint without treating an unverified URL id as durable state.
142
+ */
143
+ onInvestigationFocus?: (runID: string) => void;
138
144
  /**
139
145
  * Selects the store backing the event timeline. Omit for the local event
140
146
  * store the Radar binary keeps (default, standalone behavior). Set
@@ -199,6 +205,7 @@ export function RadarApp({
199
205
  diagnoseConsent,
200
206
  initialPath,
201
207
  onClusterLoadStateChange,
208
+ onInvestigationFocus,
202
209
  timelineSource,
203
210
  }: RadarAppProps): React.ReactElement {
204
211
  // Apply runtime config during render so module-level singletons are set
@@ -228,7 +235,11 @@ export function RadarApp({
228
235
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
236
  consentCopy={diagnoseConsent}
230
237
  >
231
- <DiagnoseProvider browserURLState={router !== "memory"}>
238
+ <DiagnoseProvider
239
+ browserURLState={router !== "memory"}
240
+ forceRouterURLState={router === "memory"}
241
+ onFocusedRun={onInvestigationFocus}
242
+ >
232
243
  <App
233
244
  manageDocumentTitle={manageDocumentTitle}
234
245
  documentTitleSuffix={documentTitleSuffix}
package/src/api/client.ts CHANGED
@@ -3516,21 +3516,35 @@ export function useRightsizingScan(namespaces: string[], context = "") {
3516
3516
  };
3517
3517
  }
3518
3518
 
3519
- // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
3520
- export function usePromQLRange(
3521
- query: string,
3519
+ // An HPA's replica history: the count it currently observes and the count it
3520
+ // has decided on. Curated server-side so reading the HPA is the only grant
3521
+ // the chart needs; raw PromQL requires cluster-wide access.
3522
+ export interface PrometheusHPAMetrics {
3523
+ namespace: string;
3524
+ name: string;
3525
+ range: string;
3526
+ current: PrometheusQueryResult;
3527
+ desired: PrometheusQueryResult;
3528
+ }
3529
+
3530
+ export function usePrometheusHPAMetrics(
3531
+ namespace: string,
3532
+ name: string,
3522
3533
  range: PrometheusTimeRange = "1h",
3523
3534
  enabled = true,
3524
3535
  ) {
3525
- return useQuery<PrometheusQueryResult>({
3526
- queryKey: ["promql-range", query, range],
3536
+ return useQuery<PrometheusHPAMetrics>({
3537
+ queryKey: ["prometheus-hpa-metrics", namespace, name, range],
3527
3538
  queryFn: () =>
3528
- fetchJSON(
3529
- `/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`,
3530
- ),
3531
- enabled: enabled && Boolean(query),
3539
+ fetchJSON(`/prometheus/hpa/${namespace}/${name}?range=${range}`),
3540
+ enabled: enabled && Boolean(namespace && name),
3532
3541
  staleTime: 30000,
3533
- refetchInterval: 60000,
3542
+ // A denial is a standing RBAC fact, not a blip: show the access state at
3543
+ // once and stop polling for it.
3544
+ refetchInterval: (query) =>
3545
+ isForbiddenError(query.state.error) ? false : 60000,
3546
+ retry: (failureCount, error) =>
3547
+ !isForbiddenError(error) && failureCount < 1,
3534
3548
  });
3535
3549
  }
3536
3550
 
@@ -13,6 +13,8 @@ export interface AgentInfo {
13
13
  profiles?: ExecutionProfile[];
14
14
  consentSurfaces?: Partial<Record<ExecutionProfile, string>>;
15
15
  hosted?: boolean;
16
+ /** Hosted backends opt in only when they implement assessment-bound, tool-free explanations. */
17
+ assessmentExplanations?: boolean;
16
18
  }
17
19
 
18
20
  export interface AgentsResponse {
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ investigationWorkspaceNavigationState,
4
+ investigationWorkspacePath,
5
+ investigationWorkspaceRestorePath,
6
+ investigationWorkspaceSearch,
7
+ isInvestigationWorkspacePath,
8
+ shouldExitUnavailableWorkspace,
9
+ workspaceRunIDFromPath,
10
+ } from "./DiagnoseContext";
11
+
12
+ describe("investigation workspace routes", () => {
13
+ it("recognizes the base workspace and selected-run routes", () => {
14
+ expect(isInvestigationWorkspacePath("/investigations")).toBe(true);
15
+ expect(isInvestigationWorkspacePath("/investigations/run_123")).toBe(true);
16
+ expect(workspaceRunIDFromPath("/investigations/run_123")).toBe("run_123");
17
+ expect(workspaceRunIDFromPath("/investigations/run%20one")).toBe("run one");
18
+ });
19
+
20
+ it("keeps only cross-view scope in workspace URLs", () => {
21
+ expect(
22
+ investigationWorkspaceSearch(
23
+ "?org=o1&namespaces=prod%2Cstage&ai-run=r1&resource=pod",
24
+ ),
25
+ ).toBe("?org=o1&namespaces=prod%2Cstage");
26
+ });
27
+
28
+ it("uses Home for global entry and an explicit route for drawer expansion", () => {
29
+ expect(investigationWorkspacePath(null)).toBe("/investigations");
30
+ expect(investigationWorkspacePath("run 123")).toBe(
31
+ "/investigations/run%20123",
32
+ );
33
+ });
34
+
35
+ it("builds opaque navigation state for a synthetic drawer return", () => {
36
+ expect(
37
+ investigationWorkspaceNavigationState({
38
+ returnPath: "/?ai-run=r1",
39
+ drawerOrigin: true,
40
+ closeHistorySteps: 2,
41
+ }),
42
+ ).toEqual({
43
+ investigationWorkspaceReturn: "/?ai-run=r1",
44
+ investigationReturnHistorySteps: "2",
45
+ });
46
+ });
47
+
48
+ it("restores the currently focused run into the original dock location", () => {
49
+ expect(
50
+ investigationWorkspaceRestorePath(
51
+ { investigationWorkspaceReturn: "/resources?kind=pods&ai-run=old#row" },
52
+ "new run",
53
+ "https://radar.local",
54
+ ),
55
+ ).toBe("/resources?kind=pods&ai-run=new+run#row");
56
+ });
57
+
58
+ it("falls back safely when a malformed return path normalizes cross-origin", () => {
59
+ expect(
60
+ investigationWorkspaceRestorePath(
61
+ { investigationWorkspaceReturn: "/\\\\evil.example" },
62
+ "run-1",
63
+ "https://radar.local",
64
+ ),
65
+ ).toBeNull();
66
+ });
67
+
68
+ it("exits only resolved AI-off workspace routes", () => {
69
+ expect(
70
+ shouldExitUnavailableWorkspace("/investigations/run-1", true, false),
71
+ ).toBe(true);
72
+ expect(
73
+ shouldExitUnavailableWorkspace("/investigations/run-1", false, false),
74
+ ).toBe(false);
75
+ expect(shouldExitUnavailableWorkspace("/?ai-run=run-1", true, false)).toBe(
76
+ false,
77
+ );
78
+ });
79
+
80
+ it("builds history-only return state for cross-tree workspace entry", () => {
81
+ expect(
82
+ investigationWorkspaceNavigationState({ closeHistorySteps: 1 }),
83
+ ).toEqual({ investigationReturnHistorySteps: "1" });
84
+ });
85
+
86
+ it("does not claim unrelated paths and rejects malformed run ids", () => {
87
+ expect(isInvestigationWorkspacePath("/resources/pods")).toBe(false);
88
+ expect(isInvestigationWorkspacePath("/investigations/run/extra")).toBe(
89
+ true,
90
+ );
91
+ expect(workspaceRunIDFromPath("/investigations")).toBeNull();
92
+ expect(workspaceRunIDFromPath("/investigations/%2Fbad")).toBeNull();
93
+ expect(workspaceRunIDFromPath("/investigations/%ZZ")).toBeNull();
94
+ });
95
+ });