@skyhook-io/radar-app 1.16.1 → 1.16.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.tsx +51 -9
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +61 -49
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/cnpg.ts +95 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/timelineSource.ts +1 -1
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/NoClusterAccessBanner.tsx +68 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
- package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
- package/src/components/cnpg/CNPGOperator.tsx +234 -0
- package/src/components/cnpg/CNPGOverview.tsx +307 -0
- package/src/components/cnpg/CNPGPooling.tsx +116 -0
- package/src/components/cnpg/CNPGProtection.tsx +320 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
- package/src/components/cnpg/CNPGView.tsx +156 -0
- package/src/components/cnpg/paths.ts +13 -0
- package/src/components/cnpg/routes.test.ts +60 -0
- package/src/components/cnpg/routes.ts +112 -0
- package/src/components/cnpg/shared.tsx +290 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/helm/ChartBrowser.tsx +6 -7
- package/src/components/helm/HelmCompareRoute.tsx +108 -115
- package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
- package/src/components/helm/InstallWizard.tsx +5 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
- package/src/components/resources/ResourcesView.tsx +6 -38
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/timeline/TimelineList.test.ts +47 -29
- package/src/components/timeline/TimelineList.tsx +57 -11
- package/src/components/timeline/TimelineView.tsx +9 -2
- package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
- package/src/components/whats-new/WhatsNew.test.ts +107 -28
- package/src/components/whats-new/WhatsNew.tsx +146 -86
- package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
- package/src/components/whats-new/releaseNotes.ts +156 -25
- package/src/components/workload/WorkloadView.tsx +30 -4
- package/src/context/DiagnoseCustomization.tsx +13 -3
- package/src/contexts/CapabilitiesContext.tsx +9 -1
- package/src/hooks/useResourceCounts.ts +49 -0
- package/src/index.ts +4 -2
- package/src/components/helm/RoleGatedPanel.tsx +0 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.3",
|
|
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",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"diff": "^9.0.0",
|
|
37
37
|
"monaco-editor": "0.55.1",
|
|
38
38
|
"react-markdown": "^10.1.0",
|
|
39
|
-
"react-virtuoso": "^4.18.
|
|
39
|
+
"react-virtuoso": "^4.18.16",
|
|
40
40
|
"remark-gfm": "^4.0.1",
|
|
41
41
|
"shiki": "^4.4.2",
|
|
42
42
|
"yaml": "^2.9.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@skyhook-io/k8s-ui": ">=1.16.
|
|
45
|
+
"@skyhook-io/k8s-ui": ">=1.16.3",
|
|
46
46
|
"@tanstack/react-query": ">=5",
|
|
47
47
|
"@xyflow/react": ">=12.0.0",
|
|
48
48
|
"clsx": ">=2",
|
|
@@ -73,14 +73,14 @@
|
|
|
73
73
|
"globals": "^17.11.0",
|
|
74
74
|
"lucide-react": "^1.48.0",
|
|
75
75
|
"postcss": "^8.5.28",
|
|
76
|
-
"prettier": "^3.9.
|
|
76
|
+
"prettier": "^3.9.9",
|
|
77
77
|
"react": "^19.3.0",
|
|
78
78
|
"react-dom": "^19.3.0",
|
|
79
79
|
"react-router-dom": "^7.18.4",
|
|
80
80
|
"tailwind-merge": "^3.7.0",
|
|
81
81
|
"tailwindcss": "^4.3.3",
|
|
82
82
|
"typescript": "^6.0.2",
|
|
83
|
-
"typescript-eslint": "^8.
|
|
83
|
+
"typescript-eslint": "^8.71.0",
|
|
84
84
|
"vite": "^8.3.1"
|
|
85
85
|
},
|
|
86
86
|
"sideEffects": [
|
package/src/App.tsx
CHANGED
|
@@ -38,6 +38,9 @@ import { CloudFunnelButton } from './components/CloudFunnelButton'
|
|
|
38
38
|
import { useNavCustomization } from './context/NavCustomization'
|
|
39
39
|
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
40
40
|
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
41
|
+
import { CNPGView } from './components/cnpg/CNPGView'
|
|
42
|
+
import { CNPG_SCREENS, cnpgDetailKindFor, cnpgDetailPath, parseCNPGRoute } from './components/cnpg/routes'
|
|
43
|
+
import { currentPageLabel } from './components/cnpg/paths'
|
|
41
44
|
import { navigateFromPrimaryRail } from './components/nav/navigation'
|
|
42
45
|
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
43
46
|
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
@@ -45,6 +48,7 @@ import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitch
|
|
|
45
48
|
import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
46
49
|
import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
47
50
|
import { SyncProgressPanel } from './components/SyncProgressPanel'
|
|
51
|
+
import { NoClusterAccessBanner } from './components/NoClusterAccessBanner'
|
|
48
52
|
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
49
53
|
import { RadarUpgradeProvider } from './context/RadarUpgrade'
|
|
50
54
|
import { UserMenu } from './components/UserMenu'
|
|
@@ -124,7 +128,7 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
124
128
|
|
|
125
129
|
// Convert API resource name back to topology node ID prefix
|
|
126
130
|
// Extended MainView type that includes traffic and cost
|
|
127
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
|
|
131
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'capacity' | 'cnpg' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications' | 'investigations'
|
|
128
132
|
|
|
129
133
|
// Extract view from URL path
|
|
130
134
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -138,6 +142,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
138
142
|
if (path === 'traffic') return 'traffic'
|
|
139
143
|
if (path === 'cost') return 'cost'
|
|
140
144
|
if (path === 'capacity') return 'capacity'
|
|
145
|
+
if (path === 'cnpg') return 'cnpg'
|
|
141
146
|
if (path === 'workload') return 'workload'
|
|
142
147
|
if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
|
|
143
148
|
if (path === 'gitops') return 'gitops'
|
|
@@ -165,7 +170,7 @@ function usageView(pathname: string, view: ExtendedMainView, upgrade: boolean):
|
|
|
165
170
|
const CRASH_LABELS: Record<ExtendedMainView, string> = {
|
|
166
171
|
home: 'Home', topology: 'Topology', resources: 'Resources', timeline: 'Timeline',
|
|
167
172
|
issues: 'Issues', helm: 'Helm', helmCompare: 'HelmCompare', traffic: 'Traffic',
|
|
168
|
-
cost: 'Cost', capacity: 'Capacity', checks: 'Checks', gitops: 'GitOps',
|
|
173
|
+
cost: 'Cost', capacity: 'Capacity', cnpg: 'CloudNativePG', checks: 'Checks', gitops: 'GitOps',
|
|
169
174
|
applications: 'Applications', workload: 'Workload', compare: 'Compare',
|
|
170
175
|
investigations: 'Investigations',
|
|
171
176
|
}
|
|
@@ -282,6 +287,12 @@ function radarPageTitle(pathname: string, search = '', apiResources?: APIResourc
|
|
|
282
287
|
if (pathSegments[1] === 'activity') return 'Capacity Activity'
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
if (view === 'cnpg') {
|
|
291
|
+
const route = parseCNPGRoute(pathname)
|
|
292
|
+
if (route.detail) return route.detail.name
|
|
293
|
+
const screen = CNPG_SCREENS.find((s) => s.id === route.screen)
|
|
294
|
+
return `CloudNativePG ${screen?.label ?? 'Overview'}`
|
|
295
|
+
}
|
|
285
296
|
if (view === 'home') return 'Overview'
|
|
286
297
|
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
287
298
|
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
@@ -529,8 +540,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
529
540
|
navigate(withCrossViewParams(path, location.search))
|
|
530
541
|
}, [location.search, navigate])
|
|
531
542
|
|
|
532
|
-
// The standalone rail expresses intent to leave the full-width
|
|
533
|
-
//
|
|
543
|
+
// The standalone rail expresses intent to leave the full-width investigations
|
|
544
|
+
// page. Close it before routing so the destination is immediately visible;
|
|
534
545
|
// docked investigations stay open across views as a persistent side panel.
|
|
535
546
|
const handlePrimaryNavigate = useCallback((view: ExtendedMainView) => {
|
|
536
547
|
navigateFromPrimaryRail(
|
|
@@ -875,7 +886,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
875
886
|
gitops: 'g o', checks: 'g u', cost: 'g c', capacity: 'g p',
|
|
876
887
|
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
877
888
|
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
878
|
-
workload: '', compare: '', helmCompare: '', investigations: '',
|
|
889
|
+
workload: '', compare: '', helmCompare: '', investigations: '', cnpg: '',
|
|
879
890
|
}
|
|
880
891
|
const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
|
|
881
892
|
(v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
|
|
@@ -1064,6 +1075,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1064
1075
|
secretsChanged: boolean
|
|
1065
1076
|
timer: number | null
|
|
1066
1077
|
}>({ changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null })
|
|
1078
|
+
const cnpgInvalidationPendingRef = useRef(false)
|
|
1067
1079
|
const slowInvalidationRef = useRef<{
|
|
1068
1080
|
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
1069
1081
|
timer: number | null
|
|
@@ -1095,6 +1107,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1095
1107
|
const structural = event.operation === 'add' || event.operation === 'delete'
|
|
1096
1108
|
const applicationWorkload = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
|
|
1097
1109
|
|
|
1110
|
+
if (event.group?.endsWith('.cnpg.io')) cnpgInvalidationPendingRef.current = true
|
|
1111
|
+
|
|
1098
1112
|
const fast = fastInvalidationRef.current
|
|
1099
1113
|
fast.changedKinds.add(kind)
|
|
1100
1114
|
if (structural) fast.structuralKinds.add(kind)
|
|
@@ -1138,6 +1152,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1138
1152
|
// GitOps view is mounted (Phase 2 will make this relevance-aware).
|
|
1139
1153
|
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
1140
1154
|
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
1155
|
+
if (cnpgInvalidationPendingRef.current) {
|
|
1156
|
+
cnpgInvalidationPendingRef.current = false
|
|
1157
|
+
queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace'] })
|
|
1158
|
+
}
|
|
1141
1159
|
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), environmentNamespaces: new Set(), environmentPods: new Map(), secretsChanged: false, timer: null }
|
|
1142
1160
|
}, 3000)
|
|
1143
1161
|
}
|
|
@@ -1223,6 +1241,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1223
1241
|
const nextParams = new URLSearchParams()
|
|
1224
1242
|
const diagnoseRun = new URLSearchParams(location.search).get('ai-run')
|
|
1225
1243
|
if (diagnoseRun) nextParams.set('ai-run', diagnoseRun)
|
|
1244
|
+
// A CNPG detail keeps the context it belongs to, so it can say it is
|
|
1245
|
+
// not in the new one instead of loading a same-named object.
|
|
1246
|
+
const pinnedCtx = new URLSearchParams(location.search).get('ctx')
|
|
1247
|
+
if (pinnedCtx && location.pathname.startsWith('/cnpg/')) nextParams.set('ctx', pinnedCtx)
|
|
1226
1248
|
navigate(
|
|
1227
1249
|
{ pathname: location.pathname, search: nextParams.toString() },
|
|
1228
1250
|
{ replace: true, state: location.state },
|
|
@@ -1749,7 +1771,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1749
1771
|
setVisibleKinds(new Set())
|
|
1750
1772
|
}, [])
|
|
1751
1773
|
|
|
1752
|
-
const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
|
|
1774
|
+
const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView === 'cnpg' ? 'resources' : mainView
|
|
1753
1775
|
|
|
1754
1776
|
return (
|
|
1755
1777
|
<PortForwardProvider>
|
|
@@ -1890,11 +1912,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
1890
1912
|
|
|
1891
1913
|
{/* Local terminal */}
|
|
1892
1914
|
{capabilities.localTerminal && (
|
|
1893
|
-
<Tooltip content=
|
|
1915
|
+
<Tooltip content={connection.context || connection.state === 'disconnected' ? 'Open local terminal' : 'Waiting for a context to open a terminal'}>
|
|
1894
1916
|
<button
|
|
1895
1917
|
onClick={() => openLocalTerminal()}
|
|
1918
|
+
disabled={!connection.context && connection.state !== 'disconnected'}
|
|
1896
1919
|
aria-label="Open local terminal"
|
|
1897
|
-
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1920
|
+
className="p-1.5 rounded-md bg-theme-elevated enabled:hover:bg-theme-hover text-theme-text-secondary enabled:hover:text-theme-text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
1898
1921
|
>
|
|
1899
1922
|
<SquareTerminal className="w-4 h-4" />
|
|
1900
1923
|
</button>
|
|
@@ -2009,6 +2032,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2009
2032
|
</PaneLoader>
|
|
2010
2033
|
)}
|
|
2011
2034
|
|
|
2035
|
+
{contentReady && <NoClusterAccessBanner />}
|
|
2012
2036
|
{/* Main content - only show when connected and authenticated */}
|
|
2013
2037
|
{/* inert while a fullscreen detail overlay covers the views — keeps the
|
|
2014
2038
|
retained background list out of the focus order + a11y tree (the visual
|
|
@@ -2301,6 +2325,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2301
2325
|
<CapacityView onOpenResource={navigateToResource} />
|
|
2302
2326
|
)}
|
|
2303
2327
|
|
|
2328
|
+
{!viewsSyncGated && mainView === 'cnpg' && (
|
|
2329
|
+
<CNPGView
|
|
2330
|
+
namespaces={namespaces}
|
|
2331
|
+
selectedResource={routeSelectedResource}
|
|
2332
|
+
onOpenResource={navigateToResource}
|
|
2333
|
+
onCloseResource={() => setSelectedResource(null)}
|
|
2334
|
+
onClearNamespaces={clearAllNamespaces}
|
|
2335
|
+
/>
|
|
2336
|
+
)}
|
|
2337
|
+
|
|
2304
2338
|
{/* Takeover splash. When the host claims the current view via
|
|
2305
2339
|
fleetTakeoverHref, the redirect effect above is mid-flight — render a
|
|
2306
2340
|
brief splash instead of the inline view (which would flash + fire its
|
|
@@ -2368,7 +2402,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
|
|
|
2368
2402
|
onClose={closeDrawer}
|
|
2369
2403
|
onNavigate={(res) => navigateToResource(res)}
|
|
2370
2404
|
canCollapseToDrawer={!isMobile}
|
|
2371
|
-
onExpand={(
|
|
2405
|
+
onExpand={(res, opts) => {
|
|
2406
|
+
const cnpgPlural = cnpgDetailKindFor(res.kind, res.group)
|
|
2407
|
+
if (cnpgPlural) {
|
|
2408
|
+
navigate(
|
|
2409
|
+
cnpgDetailPath({ plural: cnpgPlural, namespace: res.namespace, name: res.name }, connection.context || undefined, opts?.yaml ? 'yaml' : undefined),
|
|
2410
|
+
{ state: { returnLabel: currentPageLabel(), returnCtx: connection.context } },
|
|
2411
|
+
)
|
|
2412
|
+
return
|
|
2413
|
+
}
|
|
2372
2414
|
// Grow the peek into a fullscreen overlay (?full=1, pushed so Back
|
|
2373
2415
|
// collapses) over whatever view is underneath — list, topology graph,
|
|
2374
2416
|
// GitOps, Applications — which stays mounted. Carry the YAML tab when
|
package/src/RadarApp.tsx
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from "./components/ui/Toast";
|
|
35
35
|
import { setApiBase, setBasename } from "./api/config";
|
|
36
36
|
import { shouldRetryRadarQuery } from "./api/radarFeatures";
|
|
37
|
+
import { isShownInline } from "./api/httpErrors";
|
|
37
38
|
import { NavCustomizationProvider } from "./context/NavCustomization";
|
|
38
39
|
import { FilterLocationBridge } from "./filter/FilterLocationBridge";
|
|
39
40
|
import type { NavCustomization } from "./context/NavCustomization";
|
|
@@ -49,6 +50,7 @@ import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
|
|
|
49
50
|
import type {
|
|
50
51
|
RenderDiagnoseAction,
|
|
51
52
|
RenderInvestigationRunActions,
|
|
53
|
+
RenderInvestigationRefusalAction,
|
|
52
54
|
DiagnoseConsentCopy,
|
|
53
55
|
} from "./context/DiagnoseCustomization";
|
|
54
56
|
import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
|
|
@@ -132,6 +134,10 @@ export interface RadarAppProps {
|
|
|
132
134
|
renderDiagnoseAction?: RenderDiagnoseAction;
|
|
133
135
|
/** Host-owned controls for the focused investigation; absent in standalone Radar. */
|
|
134
136
|
renderInvestigationRunActions?: RenderInvestigationRunActions;
|
|
137
|
+
/** Host action beside a refused investigation start or follow-up, e.g. Radar
|
|
138
|
+
* Cloud's "Upgrade" when the organization has used its investigations.
|
|
139
|
+
* Absent in standalone Radar, whose backend sends no refusal details. */
|
|
140
|
+
renderInvestigationRefusalAction?: RenderInvestigationRefusalAction;
|
|
135
141
|
/**
|
|
136
142
|
* Replaces the first-run consent card's trust copy. REQUIRED of any host whose
|
|
137
143
|
* backend runs the agent somewhere other than the user's own machine — the
|
|
@@ -179,7 +185,7 @@ export interface RadarAppProps {
|
|
|
179
185
|
// Default QueryClient with the same shape Radar's standalone binary uses.
|
|
180
186
|
// Extracted so both standalone + library consumers get identical
|
|
181
187
|
// toast-on-error / toast-on-success behavior.
|
|
182
|
-
function makeDefaultQueryClient(): QueryClient {
|
|
188
|
+
export function makeDefaultQueryClient(): QueryClient {
|
|
183
189
|
return new QueryClient({
|
|
184
190
|
defaultOptions: {
|
|
185
191
|
queries: {
|
|
@@ -190,7 +196,7 @@ function makeDefaultQueryClient(): QueryClient {
|
|
|
190
196
|
mutationCache: new MutationCache({
|
|
191
197
|
onError: (error, _variables, _context, mutation) => {
|
|
192
198
|
const message = mutation.options.meta?.errorMessage;
|
|
193
|
-
if (message) showApiError(message, (error as Error).message);
|
|
199
|
+
if (message && !isShownInline(error)) showApiError(message, (error as Error).message);
|
|
194
200
|
},
|
|
195
201
|
onSuccess: (_data, _variables, _context, mutation) => {
|
|
196
202
|
const message = mutation.options.meta?.successMessage;
|
|
@@ -224,6 +230,7 @@ export function RadarApp({
|
|
|
224
230
|
documentTitleSuffix,
|
|
225
231
|
renderDiagnoseAction,
|
|
226
232
|
renderInvestigationRunActions,
|
|
233
|
+
renderInvestigationRefusalAction,
|
|
227
234
|
diagnoseConsent,
|
|
228
235
|
initialPath,
|
|
229
236
|
onClusterLoadStateChange,
|
|
@@ -263,6 +270,7 @@ export function RadarApp({
|
|
|
263
270
|
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
264
271
|
consentCopy={diagnoseConsent}
|
|
265
272
|
renderRunActions={renderInvestigationRunActions}
|
|
273
|
+
renderRefusalAction={renderInvestigationRefusalAction}
|
|
266
274
|
>
|
|
267
275
|
<DiagnoseProvider
|
|
268
276
|
browserURLState={router !== "memory"}
|
package/src/api/client.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
} from '@skyhook-io/k8s-ui'
|
|
23
23
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
24
24
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
25
|
-
import {
|
|
25
|
+
import { useIsAuthEnabled, useNamespacedCapabilities } from '../contexts/CapabilitiesContext'
|
|
26
26
|
import type {
|
|
27
27
|
Topology,
|
|
28
28
|
ClusterInfo,
|
|
@@ -59,7 +59,7 @@ import type {
|
|
|
59
59
|
} from '../types'
|
|
60
60
|
import type { GitOpsOperationResponse } from '../types/gitops'
|
|
61
61
|
import { apiUrl, getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath, stripBasename } from './config'
|
|
62
|
-
import { httpStatusMessage, readErrorBody, readErrorResponse } from './httpErrors'
|
|
62
|
+
import { httpStatusMessage, markShownInline, readErrorBody, readErrorResponse } from './httpErrors'
|
|
63
63
|
import {
|
|
64
64
|
RadarFeatureUnsupportedError,
|
|
65
65
|
guardRadarFeature,
|
|
@@ -2256,6 +2256,9 @@ export interface AuthMe {
|
|
|
2256
2256
|
* When false, logout clears Radar's cookie but the proxy may re-auth
|
|
2257
2257
|
* the same user on the next request. */
|
|
2258
2258
|
proxyLogoutConfigured?: boolean;
|
|
2259
|
+
/** Connected and the user's access is known: true when their RBAC allows
|
|
2260
|
+
* reading no namespace at all. Absent when access couldn't be determined. */
|
|
2261
|
+
noNamespaceAccess?: boolean;
|
|
2259
2262
|
}
|
|
2260
2263
|
|
|
2261
2264
|
export function useAuthMe() {
|
|
@@ -2266,6 +2269,24 @@ export function useAuthMe() {
|
|
|
2266
2269
|
});
|
|
2267
2270
|
}
|
|
2268
2271
|
|
|
2272
|
+
/**
|
|
2273
|
+
* useNamespaceAccess asks the server to run namespace discovery for the caller,
|
|
2274
|
+
* so the answer never depends on whether the permission cache happens to hold
|
|
2275
|
+
* an entry. A user with no access is waiting on an admin; re-check every
|
|
2276
|
+
* minute so the banner clears once a binding lands.
|
|
2277
|
+
*/
|
|
2278
|
+
export function useNamespaceAccess(enabled: boolean) {
|
|
2279
|
+
return useQuery<AuthMe>({
|
|
2280
|
+
queryKey: ["namespace-access"],
|
|
2281
|
+
queryFn: () => fetchJSON("/auth/me?check=namespaces"),
|
|
2282
|
+
enabled,
|
|
2283
|
+
staleTime: 60000,
|
|
2284
|
+
// Stop only on an explicit false: an absent field means discovery couldn't
|
|
2285
|
+
// answer this time, which says nothing about the user's access.
|
|
2286
|
+
refetchInterval: (query) => (query.state.data?.noNamespaceAccess === false ? false : 60000),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2269
2290
|
// Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
|
|
2270
2291
|
// CloudRole.AtLeast — the frontend must agree with the backend on what
|
|
2271
2292
|
// "member-or-higher" means; otherwise we'd hide a button the
|
|
@@ -2282,16 +2303,12 @@ const CLOUD_ROLE_RANK: Record<string, number> = {
|
|
|
2282
2303
|
* present (OSS, OIDC, no role group, OR auth/me is still loading),
|
|
2283
2304
|
* `canAtLeast` returns true — the gate is strictly additive for
|
|
2284
2305
|
* Cloud-attributed users, mirroring the backend's `requireCloudRole`
|
|
2285
|
-
* semantics.
|
|
2286
|
-
*
|
|
2287
|
-
* need fail-closed behavior during the auth/me round-trip to prevent
|
|
2288
|
-
* a viewer from clicking through during the loading window.
|
|
2306
|
+
* semantics. It gates Radar's own features (settings, integrations), never
|
|
2307
|
+
* operations that run as the user, which Kubernetes RBAC decides.
|
|
2289
2308
|
*
|
|
2290
|
-
* Why optimistic during load:
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
* a content tab populate for a tick before being gated out. Click-
|
|
2294
|
-
* prevention belongs in the action-button hook, not here.
|
|
2309
|
+
* Why optimistic during load: a gated empty state rendered briefly to
|
|
2310
|
+
* OSS / kubectl-plugin users before auth/me resolves is a worse regression
|
|
2311
|
+
* than a Cloud viewer seeing content for a tick before being gated out.
|
|
2295
2312
|
*/
|
|
2296
2313
|
export function useCloudRole() {
|
|
2297
2314
|
const { data, isLoading } = useAuthMe();
|
|
@@ -2308,42 +2325,35 @@ export function useCloudRole() {
|
|
|
2308
2325
|
}
|
|
2309
2326
|
|
|
2310
2327
|
/**
|
|
2311
|
-
* useCanHelmAct
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2316
|
-
*
|
|
2317
|
-
* users — telling them "Helm write permissions required" is wrong if
|
|
2318
|
-
* the chart is fine and the actual gate is their viewer role.
|
|
2328
|
+
* useCanHelmAct answers whether Helm write buttons are usable for a release in
|
|
2329
|
+
* `namespace`. With auth on, Helm runs as the signed-in user, so the check is
|
|
2330
|
+
* the user's own Kubernetes RBAC in that namespace (Helm writes each release as
|
|
2331
|
+
* a Secret there); the Cloud role says nothing about cluster access when IdP
|
|
2332
|
+
* groups grant it. Without auth, Helm runs as Radar's ServiceAccount, which
|
|
2333
|
+
* needs the chart's rbac.helm=true.
|
|
2319
2334
|
*/
|
|
2320
|
-
export function useCanHelmAct(): { allowed: boolean; reason?: string } {
|
|
2321
|
-
const
|
|
2322
|
-
const
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
//
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
// canAtLeast (which is optimistic during loading) because passive
|
|
2329
|
-
// content gates don't have a click-handler to misfire.
|
|
2330
|
-
if (isLoading) {
|
|
2331
|
-
return { allowed: false, reason: "Loading permissions…" };
|
|
2332
|
-
}
|
|
2333
|
-
if (!canAtLeast("member")) {
|
|
2334
|
-
return {
|
|
2335
|
-
allowed: false,
|
|
2336
|
-
reason: `Your Radar Cloud role (${role ?? "unknown"}) cannot run Helm operations. Ask a member or owner.`,
|
|
2337
|
-
};
|
|
2335
|
+
export function useCanHelmAct(namespace?: string): { allowed: boolean; reason?: string } {
|
|
2336
|
+
const { canHelmWrite, helmWriteUnknown } = useNamespacedCapabilities(namespace);
|
|
2337
|
+
const authEnabled = useIsAuthEnabled();
|
|
2338
|
+
// Without a namespace answer (still loading, or the check failed) the global
|
|
2339
|
+
// value only says the user can create Secrets somewhere, which would enable
|
|
2340
|
+
// actions the server then refuses.
|
|
2341
|
+
if (authEnabled && helmWriteUnknown) {
|
|
2342
|
+
return { allowed: false, reason: `Couldn't confirm your permissions in ${namespace} yet.` };
|
|
2338
2343
|
}
|
|
2339
|
-
if (
|
|
2344
|
+
if (canHelmWrite) return { allowed: true };
|
|
2345
|
+
if (authEnabled) {
|
|
2340
2346
|
return {
|
|
2341
2347
|
allowed: false,
|
|
2342
|
-
reason:
|
|
2343
|
-
|
|
2348
|
+
reason: namespace
|
|
2349
|
+
? `Your Kubernetes permissions don't allow managing Helm releases in ${namespace} (creating Secrets there).`
|
|
2350
|
+
: "Your Kubernetes permissions don't allow managing Helm releases.",
|
|
2344
2351
|
};
|
|
2345
2352
|
}
|
|
2346
|
-
return {
|
|
2353
|
+
return {
|
|
2354
|
+
allowed: false,
|
|
2355
|
+
reason: "Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.",
|
|
2356
|
+
};
|
|
2347
2357
|
}
|
|
2348
2358
|
|
|
2349
2359
|
// Namespaces
|
|
@@ -4092,7 +4102,13 @@ export function useUpdateResource() {
|
|
|
4092
4102
|
});
|
|
4093
4103
|
if (!response.ok) {
|
|
4094
4104
|
const error = await readErrorBody(response);
|
|
4095
|
-
|
|
4105
|
+
const failure = new Error(error.error || `HTTP ${response.status}`);
|
|
4106
|
+
// A reviewed apply's failure is shown in the review itself (the
|
|
4107
|
+
// changed-after-review notice, or the error), so it gets no toast.
|
|
4108
|
+
if (reviewedResourceVersion !== undefined || reviewedContext !== undefined) {
|
|
4109
|
+
markShownInline(failure);
|
|
4110
|
+
}
|
|
4111
|
+
throw failure;
|
|
4096
4112
|
}
|
|
4097
4113
|
return response.json();
|
|
4098
4114
|
},
|
|
@@ -5451,14 +5467,10 @@ export function useHelmRelease(
|
|
|
5451
5467
|
}
|
|
5452
5468
|
|
|
5453
5469
|
// Get manifest for a Helm release (optionally at a specific revision).
|
|
5454
|
-
// `enabled` lets callers skip the query when the user's Cloud role
|
|
5455
|
-
// would 403 the read — saves a round-trip and avoids a transient
|
|
5456
|
-
// "error" state that the role-gated empty panel doesn't need.
|
|
5457
5470
|
export function useHelmManifest(
|
|
5458
5471
|
namespace: string,
|
|
5459
5472
|
name: string,
|
|
5460
5473
|
revision?: number,
|
|
5461
|
-
enabled = true,
|
|
5462
5474
|
) {
|
|
5463
5475
|
const params = revision ? `?revision=${revision}` : "";
|
|
5464
5476
|
return useQuery<string>({
|
|
@@ -5473,12 +5485,12 @@ export function useHelmManifest(
|
|
|
5473
5485
|
}
|
|
5474
5486
|
return response.text();
|
|
5475
5487
|
},
|
|
5476
|
-
enabled: Boolean(namespace && name
|
|
5488
|
+
enabled: Boolean(namespace && name),
|
|
5477
5489
|
staleTime: 60000, // 1 minute
|
|
5478
5490
|
});
|
|
5479
5491
|
}
|
|
5480
5492
|
|
|
5481
|
-
// Get values for a Helm release.
|
|
5493
|
+
// Get values for a Helm release.
|
|
5482
5494
|
export function useHelmValues(
|
|
5483
5495
|
namespace: string,
|
|
5484
5496
|
name: string,
|
|
@@ -5499,7 +5511,7 @@ export function useHelmValues(
|
|
|
5499
5511
|
});
|
|
5500
5512
|
}
|
|
5501
5513
|
|
|
5502
|
-
// Get diff between two revisions.
|
|
5514
|
+
// Get diff between two revisions.
|
|
5503
5515
|
export function useHelmManifestDiff(
|
|
5504
5516
|
namespace: string,
|
|
5505
5517
|
name: string,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// A reviewed YAML apply shows its failure in the review itself, so the global
|
|
4
|
+
// "Failed to update resource" toast would say the same thing twice.
|
|
5
|
+
import { act } from 'react'
|
|
6
|
+
import { createRoot } from 'react-dom/client'
|
|
7
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
8
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
const toasts = vi.hoisted(() => ({ errors: [] as string[] }))
|
|
11
|
+
vi.mock('@skyhook-io/k8s-ui', async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import('@skyhook-io/k8s-ui')>()),
|
|
13
|
+
showApiError: (title: string) => toasts.errors.push(title),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { makeDefaultQueryClient } = await import('../RadarApp')
|
|
17
|
+
const { useUpdateResource } = await import('./client')
|
|
18
|
+
|
|
19
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.unstubAllGlobals()
|
|
22
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
23
|
+
toasts.errors = []
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
async function save(reviewed: boolean) {
|
|
27
|
+
vi.stubGlobal('fetch', vi.fn(async () => new Response(
|
|
28
|
+
JSON.stringify({ error: 'resource changed after review; review the latest version before applying' }),
|
|
29
|
+
{ status: 409, headers: { 'Content-Type': 'application/json' } },
|
|
30
|
+
)))
|
|
31
|
+
let mutate: ReturnType<typeof useUpdateResource>['mutateAsync'] | undefined
|
|
32
|
+
function Probe() {
|
|
33
|
+
mutate = useUpdateResource().mutateAsync
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
const host = document.createElement('div')
|
|
37
|
+
const root = createRoot(host)
|
|
38
|
+
await act(async () => {
|
|
39
|
+
root.render(<QueryClientProvider client={makeDefaultQueryClient()}><Probe /></QueryClientProvider>)
|
|
40
|
+
})
|
|
41
|
+
await act(async () => {
|
|
42
|
+
await mutate!({
|
|
43
|
+
kind: 'configmaps', namespace: 'shop', name: 'app', yaml: 'kind: ConfigMap',
|
|
44
|
+
...(reviewed ? { reviewedResourceVersion: '42', reviewedContext: 'kind-a' } : {}),
|
|
45
|
+
}).catch(() => {})
|
|
46
|
+
})
|
|
47
|
+
act(() => root.unmount())
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it('does not toast a reviewed apply, whose review shows the failure', async () => {
|
|
51
|
+
await save(true)
|
|
52
|
+
expect(toasts.errors).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('still toasts a save that did not come from a review', async () => {
|
|
56
|
+
await save(false)
|
|
57
|
+
expect(toasts.errors).toEqual(['Failed to update resource'])
|
|
58
|
+
})
|
package/src/api/cnpg.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import type { CNPGWorkspaceResponse, TimelineEvent } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { fetchJSON } from './client'
|
|
4
|
+
|
|
5
|
+
// /api/cnpg/workspace
|
|
6
|
+
//
|
|
7
|
+
// Every CloudNativePG object the caller may read, with per-kind coverage, the
|
|
8
|
+
// CNPG issues on them and the no-schedule audit finding. One query feeds the
|
|
9
|
+
// workspace screens, the Resources sidebar counts and the composed summaries,
|
|
10
|
+
// so they can never disagree about what they count.
|
|
11
|
+
export function useCNPGWorkspace(namespaces: string[], options?: { enabled?: boolean }) {
|
|
12
|
+
const ns = [...namespaces].sort().join(',')
|
|
13
|
+
return useQuery<CNPGWorkspaceResponse>({
|
|
14
|
+
queryKey: ['cnpg', 'workspace', ns],
|
|
15
|
+
queryFn: ({ signal }) => fetchJSON<CNPGWorkspaceResponse>(`/cnpg/workspace${ns ? `?namespaces=${encodeURIComponent(ns)}` : ''}`, signal),
|
|
16
|
+
enabled: options?.enabled ?? true,
|
|
17
|
+
staleTime: 10_000,
|
|
18
|
+
refetchInterval: 30_000,
|
|
19
|
+
placeholderData: (prev) => prev,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CNPGOperatorCoverage {
|
|
24
|
+
state: 'full' | 'partial' | 'denied' | 'syncing' | 'error'
|
|
25
|
+
deniedNamespaces?: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CNPGOperatorComponent {
|
|
29
|
+
role: 'operator' | 'plugin'
|
|
30
|
+
pluginName?: string
|
|
31
|
+
namespace: string
|
|
32
|
+
deployment: string
|
|
33
|
+
image?: string
|
|
34
|
+
version?: string
|
|
35
|
+
readyReplicas: number | null
|
|
36
|
+
replicas: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CNPGOperatorConfig {
|
|
40
|
+
kind: 'ConfigMap' | 'Secret'
|
|
41
|
+
namespace: string
|
|
42
|
+
name: string
|
|
43
|
+
purpose: 'operator' | 'monitoring'
|
|
44
|
+
exists?: boolean | null
|
|
45
|
+
readable?: boolean
|
|
46
|
+
reason?: string
|
|
47
|
+
data?: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CNPGOperatorResponse {
|
|
51
|
+
coverage: { deployments: CNPGOperatorCoverage; services: CNPGOperatorCoverage }
|
|
52
|
+
components: CNPGOperatorComponent[]
|
|
53
|
+
config: CNPGOperatorConfig[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// /api/cnpg/operator
|
|
57
|
+
//
|
|
58
|
+
// Operator and plugin workloads plus where the operator's configuration lives.
|
|
59
|
+
// Deliberately not filtered by the namespace view filter: the operator runs in
|
|
60
|
+
// its own namespace, which users rarely have selected.
|
|
61
|
+
export function useCNPGOperator(options?: { enabled?: boolean }) {
|
|
62
|
+
return useQuery<CNPGOperatorResponse>({
|
|
63
|
+
queryKey: ['cnpg', 'operator'],
|
|
64
|
+
queryFn: ({ signal }) => fetchJSON<CNPGOperatorResponse>('/cnpg/operator', signal),
|
|
65
|
+
enabled: options?.enabled ?? true,
|
|
66
|
+
staleTime: 30_000,
|
|
67
|
+
refetchInterval: 60_000,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CNPGClusterActivityResponse {
|
|
72
|
+
events: TimelineEvent[]
|
|
73
|
+
oldest: string | null
|
|
74
|
+
attributionSince: string | null
|
|
75
|
+
truncated: boolean
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// /api/cnpg/clusters/{ns}/{name}/activity
|
|
79
|
+
//
|
|
80
|
+
// The Cluster's history together with its instance Pods and every CNPG object
|
|
81
|
+
// attributed to it, including ones since deleted.
|
|
82
|
+
export function useCNPGClusterActivity(namespace: string, name: string, sinceHours = 24) {
|
|
83
|
+
return useQuery<CNPGClusterActivityResponse>({
|
|
84
|
+
queryKey: ['cnpg', 'activity', namespace, name, sinceHours],
|
|
85
|
+
queryFn: ({ signal }) => {
|
|
86
|
+
const since = new Date(Date.now() - sinceHours * 3600_000).toISOString()
|
|
87
|
+
return fetchJSON<CNPGClusterActivityResponse>(
|
|
88
|
+
`/cnpg/clusters/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/activity?since=${encodeURIComponent(since)}&limit=500`,
|
|
89
|
+
signal,
|
|
90
|
+
)
|
|
91
|
+
},
|
|
92
|
+
staleTime: 10_000,
|
|
93
|
+
refetchInterval: 30_000,
|
|
94
|
+
})
|
|
95
|
+
}
|
package/src/api/diagnose.ts
CHANGED
|
@@ -310,6 +310,32 @@ export class DiagnoseError extends Error {
|
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/** A host's refusal of an investigation request, with what would unblock it. */
|
|
314
|
+
export interface InvestigationRefusal {
|
|
315
|
+
status: number;
|
|
316
|
+
/** The sentence shown to the user. */
|
|
317
|
+
message: string;
|
|
318
|
+
code?: string;
|
|
319
|
+
reason?: string;
|
|
320
|
+
action?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The refusal a DiagnoseError carries, or null when the host sent none
|
|
324
|
+
* (Radar's own backend never does). */
|
|
325
|
+
export function investigationRefusal(
|
|
326
|
+
error: unknown,
|
|
327
|
+
): InvestigationRefusal | null {
|
|
328
|
+
if (!(error instanceof DiagnoseError)) return null;
|
|
329
|
+
if (!error.code && !error.reason && !error.action) return null;
|
|
330
|
+
return {
|
|
331
|
+
status: error.status,
|
|
332
|
+
message: error.message,
|
|
333
|
+
code: error.code,
|
|
334
|
+
reason: error.reason,
|
|
335
|
+
action: error.action,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
313
339
|
const RUNS = () => `${getApiBase()}/diagnose/runs`;
|
|
314
340
|
|
|
315
341
|
// createRun starts a server-side investigation (or focuses a live one for the same
|