@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 +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +12 -1
- package/src/api/client.ts +24 -10
- package/src/api/diagnose.ts +2 -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 -34
- package/src/components/diagnose/DiagnoseSurface.tsx +167 -92
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +665 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +631 -25
- package/src/components/diagnose/InvestigationView.tsx +23 -7
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- package/src/components/diagnose/investigationEvidence.test.ts +2102 -9
- package/src/components/diagnose/investigationEvidence.ts +1357 -65
- package/src/components/diagnose/investigationExplanation.test.ts +30 -1
- package/src/components/diagnose/investigationExplanation.ts +7 -0
- package/src/components/diagnose/investigationSourceFocus.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +55 -1
- package/src/components/diagnose/parts.tsx +68 -1
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- package/src/components/resources/ResourcesView.tsx +27 -2
- package/src/index.ts +7 -5
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.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.
|
|
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
|
@@ -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
|
|
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
|
-
//
|
|
3520
|
-
|
|
3521
|
-
|
|
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<
|
|
3526
|
-
queryKey: ["
|
|
3536
|
+
return useQuery<PrometheusHPAMetrics>({
|
|
3537
|
+
queryKey: ["prometheus-hpa-metrics", namespace, name, range],
|
|
3527
3538
|
queryFn: () =>
|
|
3528
|
-
fetchJSON(
|
|
3529
|
-
|
|
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
|
-
|
|
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
|
|
package/src/api/diagnose.ts
CHANGED
|
@@ -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
|
+
});
|