@skyhook-io/radar-app 0.2.2 → 0.3.1
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/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react'
|
|
2
2
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
3
3
|
import type { ContextInfo } from '../types'
|
|
4
|
-
import { getApiBase
|
|
4
|
+
import { getApiBase } from '../api/config'
|
|
5
|
+
import { apiFetch } from '../api/client'
|
|
5
6
|
|
|
6
7
|
export type ConnectionStateType = 'connected' | 'disconnected' | 'connecting'
|
|
7
8
|
|
|
@@ -10,7 +11,7 @@ export interface ConnectionState {
|
|
|
10
11
|
context: string
|
|
11
12
|
clusterName?: string
|
|
12
13
|
error?: string
|
|
13
|
-
errorType?: string // auth, network, timeout, unknown
|
|
14
|
+
errorType?: string // config, auth, rbac, network, timeout, tls, unknown
|
|
14
15
|
progressMessage?: string
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -26,13 +27,30 @@ interface ConnectionContextValue {
|
|
|
26
27
|
updateFromSSE: (status: ConnectionState) => void
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
class ConnectionRetryError extends Error {
|
|
31
|
+
errorType?: string
|
|
32
|
+
|
|
33
|
+
constructor(message: string, errorType?: string) {
|
|
34
|
+
super(message)
|
|
35
|
+
this.name = 'ConnectionRetryError'
|
|
36
|
+
this.errorType = errorType
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
const ConnectionContext = createContext<ConnectionContextValue | null>(null)
|
|
41
|
+
const AUTO_RETRY_INITIAL_DELAY_MS = 10000
|
|
42
|
+
const AUTO_RETRY_MAX_DELAY_MS = 60000
|
|
43
|
+
|
|
44
|
+
export function shouldAutoRetryConnection(errorType?: string): boolean {
|
|
45
|
+
return errorType !== 'config' && errorType !== 'rbac'
|
|
46
|
+
}
|
|
30
47
|
|
|
31
48
|
async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
// apiFetch handles a 401 globally (re-auth redirect). These endpoints are
|
|
50
|
+
// no longer auth-exempt, so a session that expires while the connection-
|
|
51
|
+
// error screen is parked open must route through that path rather than
|
|
52
|
+
// surfacing as a misleading "cannot connect to cluster" error.
|
|
53
|
+
const response = await apiFetch(`${getApiBase()}/connection`)
|
|
36
54
|
if (!response.ok) {
|
|
37
55
|
throw new Error('Failed to fetch connection status')
|
|
38
56
|
}
|
|
@@ -40,14 +58,12 @@ async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
|
|
|
40
58
|
}
|
|
41
59
|
|
|
42
60
|
async function retryConnection(): Promise<ConnectionState> {
|
|
43
|
-
const response = await
|
|
61
|
+
const response = await apiFetch(`${getApiBase()}/connection/retry`, {
|
|
44
62
|
method: 'POST',
|
|
45
|
-
credentials: getCredentialsMode(),
|
|
46
|
-
headers: getAuthHeaders(),
|
|
47
63
|
})
|
|
48
64
|
if (!response.ok) {
|
|
49
|
-
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
50
|
-
throw new
|
|
65
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' })) as { error?: string; errorType?: string }
|
|
66
|
+
throw new ConnectionRetryError(error.error || `HTTP ${response.status}`, error.errorType)
|
|
51
67
|
}
|
|
52
68
|
return response.json()
|
|
53
69
|
}
|
|
@@ -59,9 +75,29 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
59
75
|
context: '',
|
|
60
76
|
})
|
|
61
77
|
const [contexts, setContexts] = useState<ContextInfo[]>([])
|
|
78
|
+
const [isAutoRetrying, setIsAutoRetrying] = useState(false)
|
|
62
79
|
// Track if SSE has started delivering connection_state events
|
|
63
80
|
// Once SSE is active, it becomes the authoritative source for connection state
|
|
64
81
|
const sseActiveRef = useRef(false)
|
|
82
|
+
// Track whether we've reached 'connected' at least once. Distinguishes the
|
|
83
|
+
// initial connect (bootstrap queries already fetched while 'connecting') from
|
|
84
|
+
// a reconnect after a drop (cache may be stale across the gap).
|
|
85
|
+
const hasConnectedRef = useRef(false)
|
|
86
|
+
const autoRetryInFlightRef = useRef(false)
|
|
87
|
+
const autoRetryDelayRef = useRef(AUTO_RETRY_INITIAL_DELAY_MS)
|
|
88
|
+
const manualRetryPendingRef = useRef(false)
|
|
89
|
+
// Whether the QueryClient already held data when this provider mounted. A host
|
|
90
|
+
// can share one client across cluster-scoped RadarApp mounts (see RadarApp's
|
|
91
|
+
// `queryClient` prop); that client may carry another cluster's data under
|
|
92
|
+
// identical keys, so a warm-at-mount cache must be fully refreshed on first
|
|
93
|
+
// connect. A cold cache (standalone, or a per-cluster remount) takes the cheap
|
|
94
|
+
// error-only path. Snapshot synchronously before this provider's own query
|
|
95
|
+
// registers — ConnectionProvider is the outermost provider, so a fresh client
|
|
96
|
+
// is genuinely empty here.
|
|
97
|
+
const cacheWarmAtMountRef = useRef<boolean | null>(null)
|
|
98
|
+
if (cacheWarmAtMountRef.current === null) {
|
|
99
|
+
cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
|
|
100
|
+
}
|
|
65
101
|
|
|
66
102
|
// Fetch initial connection status
|
|
67
103
|
// Poll while connecting to get progress updates (SSE not established yet)
|
|
@@ -97,6 +133,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
97
133
|
const retryMutation = useMutation({
|
|
98
134
|
mutationFn: retryConnection,
|
|
99
135
|
onMutate: () => {
|
|
136
|
+
manualRetryPendingRef.current = true
|
|
100
137
|
// Reset SSE active flag - polling can provide state until SSE reconnects
|
|
101
138
|
sseActiveRef.current = false
|
|
102
139
|
// Set connecting state while retrying
|
|
@@ -104,7 +141,6 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
104
141
|
...prev,
|
|
105
142
|
state: 'connecting',
|
|
106
143
|
error: undefined,
|
|
107
|
-
errorType: undefined,
|
|
108
144
|
progressMessage: 'Connecting to cluster...',
|
|
109
145
|
}))
|
|
110
146
|
},
|
|
@@ -115,16 +151,94 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
115
151
|
queryClient.invalidateQueries()
|
|
116
152
|
},
|
|
117
153
|
onError: (error: Error) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
state
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
const retryError = error as ConnectionRetryError
|
|
155
|
+
setConnection(prev => {
|
|
156
|
+
if (sseActiveRef.current && prev.state === 'connected') return prev
|
|
157
|
+
return {
|
|
158
|
+
...prev,
|
|
159
|
+
state: 'disconnected',
|
|
160
|
+
error: error.message,
|
|
161
|
+
errorType: retryError.errorType || prev.errorType,
|
|
162
|
+
progressMessage: undefined,
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
onSettled: () => {
|
|
167
|
+
manualRetryPendingRef.current = false
|
|
124
168
|
},
|
|
125
169
|
})
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
manualRetryPendingRef.current = retryMutation.isPending
|
|
172
|
+
}, [retryMutation.isPending])
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (connection.state !== 'disconnected' || !shouldAutoRetryConnection(connection.errorType)) {
|
|
176
|
+
autoRetryDelayRef.current = AUTO_RETRY_INITIAL_DELAY_MS
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
let stopped = false
|
|
180
|
+
let retryTimeout: number | undefined
|
|
181
|
+
|
|
182
|
+
const scheduleRetry = () => {
|
|
183
|
+
retryTimeout = window.setTimeout(() => {
|
|
184
|
+
if (stopped) return
|
|
185
|
+
if (manualRetryPendingRef.current || autoRetryInFlightRef.current) {
|
|
186
|
+
scheduleRetry()
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
autoRetryInFlightRef.current = true
|
|
191
|
+
setIsAutoRetrying(true)
|
|
192
|
+
let recovered = false
|
|
193
|
+
retryConnection()
|
|
194
|
+
.then((result) => {
|
|
195
|
+
if (stopped) return
|
|
196
|
+
recovered = true
|
|
197
|
+
autoRetryDelayRef.current = AUTO_RETRY_INITIAL_DELAY_MS
|
|
198
|
+
sseActiveRef.current = false
|
|
199
|
+
setConnection(result)
|
|
200
|
+
queryClient.removeQueries()
|
|
201
|
+
queryClient.invalidateQueries()
|
|
202
|
+
})
|
|
203
|
+
.catch((error: Error) => {
|
|
204
|
+
if (stopped) return
|
|
205
|
+
const retryError = error as ConnectionRetryError
|
|
206
|
+
setConnection(prev => {
|
|
207
|
+
if (sseActiveRef.current && prev.state === 'connected') return prev
|
|
208
|
+
return {
|
|
209
|
+
...prev,
|
|
210
|
+
state: 'disconnected',
|
|
211
|
+
error: error.message || prev.error,
|
|
212
|
+
errorType: retryError.errorType || prev.errorType,
|
|
213
|
+
progressMessage: undefined,
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
autoRetryDelayRef.current = Math.min(autoRetryDelayRef.current * 2, AUTO_RETRY_MAX_DELAY_MS)
|
|
217
|
+
// Keep the visible disconnected state until a retry succeeds.
|
|
218
|
+
})
|
|
219
|
+
.finally(() => {
|
|
220
|
+
autoRetryInFlightRef.current = false
|
|
221
|
+
setIsAutoRetrying(false)
|
|
222
|
+
if (!stopped && !recovered) {
|
|
223
|
+
scheduleRetry()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
}, autoRetryDelayRef.current)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
scheduleRetry()
|
|
230
|
+
|
|
231
|
+
return () => {
|
|
232
|
+
stopped = true
|
|
233
|
+
if (retryTimeout !== undefined) {
|
|
234
|
+
window.clearTimeout(retryTimeout)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}, [connection.errorType, connection.state, queryClient])
|
|
126
238
|
|
|
127
239
|
const retry = useCallback(() => {
|
|
240
|
+
if (retryMutation.isPending || autoRetryInFlightRef.current) return
|
|
241
|
+
manualRetryPendingRef.current = true
|
|
128
242
|
retryMutation.mutate()
|
|
129
243
|
}, [retryMutation])
|
|
130
244
|
|
|
@@ -143,9 +257,20 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
143
257
|
return status
|
|
144
258
|
})
|
|
145
259
|
|
|
146
|
-
// If we just connected, invalidate queries to fetch fresh data
|
|
147
260
|
if (status.state === 'connected') {
|
|
148
|
-
|
|
261
|
+
const firstConnect = !hasConnectedRef.current
|
|
262
|
+
hasConnectedRef.current = true
|
|
263
|
+
// A reconnect after a drop (cache stale across the gap), or a first connect
|
|
264
|
+
// onto a client that already carried data at mount (shared across clusters),
|
|
265
|
+
// refreshes the whole cache. A clean first connect only needs to recover the
|
|
266
|
+
// bootstrap queries that 503'd while the cluster was still 'connecting'
|
|
267
|
+
// (status === 'error'); the rest already fetched fresh during 'connecting',
|
|
268
|
+
// so re-fetching the whole cache there would double-load every endpoint.
|
|
269
|
+
if (!firstConnect || cacheWarmAtMountRef.current) {
|
|
270
|
+
queryClient.invalidateQueries()
|
|
271
|
+
} else {
|
|
272
|
+
queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
|
|
273
|
+
}
|
|
149
274
|
}
|
|
150
275
|
}, [queryClient])
|
|
151
276
|
|
|
@@ -153,7 +278,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
153
278
|
connection,
|
|
154
279
|
contexts,
|
|
155
280
|
retry,
|
|
156
|
-
isRetrying: retryMutation.isPending,
|
|
281
|
+
isRetrying: retryMutation.isPending || isAutoRetrying,
|
|
157
282
|
updateFromSSE,
|
|
158
283
|
}
|
|
159
284
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Slot-based injection of a resource-level "Diagnose" action, and of the
|
|
2
|
+
// consent card's trust copy.
|
|
3
|
+
//
|
|
4
|
+
// Lets an embedding host (e.g. Radar Hub) inject a "Diagnose with AI" button
|
|
5
|
+
// into every resource detail action bar — without forking WorkloadView or the
|
|
6
|
+
// shared ResourceActionsBar. The host returns whatever node should render in
|
|
7
|
+
// the action bar's right-aligned universal-actions area, given the resource
|
|
8
|
+
// context.
|
|
9
|
+
//
|
|
10
|
+
// Default (no provider): Radar renders no Diagnose button — OSS stays
|
|
11
|
+
// agent-free.
|
|
12
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
13
|
+
import type { ReactNode } from 'react';
|
|
14
|
+
|
|
15
|
+
/** Render prop for the resource-level Diagnose action. */
|
|
16
|
+
export type RenderDiagnoseAction = (ctx: {
|
|
17
|
+
kind: string;
|
|
18
|
+
namespace: string;
|
|
19
|
+
name: string;
|
|
20
|
+
/** Coarse health of the resource (from its status badge), so the entry point can
|
|
21
|
+
* adapt: an urgent "Diagnose" on a problem vs. a quiet "ask AI" when fine/unknown. */
|
|
22
|
+
health?: "problem" | "healthy" | "unknown";
|
|
23
|
+
}) => ReactNode;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Trust copy for the first-run consent card.
|
|
27
|
+
*
|
|
28
|
+
* The card makes concrete, checkable claims about *where* the agent runs,
|
|
29
|
+
* *whose* model account it bills, and *where* the transcript is stored. Those
|
|
30
|
+
* claims are only true of OSS's bring-your-own-local-CLI agent. A host that
|
|
31
|
+
* runs the agent anywhere else (Radar Cloud runs it as a sandboxed Job under a
|
|
32
|
+
* managed key) MUST supply its own copy — shipping OSS's over a different data
|
|
33
|
+
* flow states the opposite of what happens.
|
|
34
|
+
*
|
|
35
|
+
* Radar owns the card's chrome (icon, layout, Approve/Cancel) either way; a
|
|
36
|
+
* host only replaces the claims.
|
|
37
|
+
*/
|
|
38
|
+
export type DiagnoseConsentCopy = {
|
|
39
|
+
title: string;
|
|
40
|
+
body: ReactNode;
|
|
41
|
+
/** Detail list under the body; each entry is rendered as its own "•" row. */
|
|
42
|
+
bullets?: ReactNode[];
|
|
43
|
+
/** Label for the settings link. `null` hides it — for hosts with one fixed
|
|
44
|
+
* agent and no isolation choice, where it would open an empty dialog. */
|
|
45
|
+
settingsLabel?: string | null;
|
|
46
|
+
approveLabel?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One context for the whole customization seam — the values are host config
|
|
50
|
+
// set once at mount, so per-value re-render isolation buys nothing.
|
|
51
|
+
export interface DiagnoseCustomization {
|
|
52
|
+
renderAction: RenderDiagnoseAction | undefined;
|
|
53
|
+
consentCopy: DiagnoseConsentCopy | undefined;
|
|
54
|
+
// undefined = default (CustomEvent → Radar's own Settings dialog);
|
|
55
|
+
// null = hide the settings affordances.
|
|
56
|
+
onOpenSettings: (() => void) | null | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULTS: DiagnoseCustomization = {
|
|
60
|
+
renderAction: undefined,
|
|
61
|
+
consentCopy: undefined,
|
|
62
|
+
onOpenSettings: undefined,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const DiagnoseCustomizationContext = createContext<DiagnoseCustomization>(DEFAULTS);
|
|
66
|
+
|
|
67
|
+
export function DiagnoseCustomizationProvider({
|
|
68
|
+
value,
|
|
69
|
+
consentCopy,
|
|
70
|
+
onOpenSettings,
|
|
71
|
+
children,
|
|
72
|
+
}: {
|
|
73
|
+
value: RenderDiagnoseAction | undefined;
|
|
74
|
+
consentCopy?: DiagnoseConsentCopy;
|
|
75
|
+
/** Where "AI settings" affordances lead. Omit for Radar's own Settings
|
|
76
|
+
* dialog; pass `null` to hide them. */
|
|
77
|
+
onOpenSettings?: (() => void) | null;
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}) {
|
|
80
|
+
const ctx = useMemo(
|
|
81
|
+
() => ({ renderAction: value, consentCopy, onOpenSettings }),
|
|
82
|
+
[value, consentCopy, onOpenSettings],
|
|
83
|
+
);
|
|
84
|
+
return (
|
|
85
|
+
<DiagnoseCustomizationContext.Provider value={ctx}>
|
|
86
|
+
{children}
|
|
87
|
+
</DiagnoseCustomizationContext.Provider>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useDiagnoseCustomization(): DiagnoseCustomization {
|
|
92
|
+
return useContext(DiagnoseCustomizationContext);
|
|
93
|
+
}
|
|
@@ -13,11 +13,86 @@
|
|
|
13
13
|
import { createContext, useContext } from 'react';
|
|
14
14
|
import type { ReactNode } from 'react';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Per-cluster destinations an embedded host can take over with its own
|
|
18
|
+
* fleet-scoped pages. See `fleetTakeoverHref`. 'issues' | 'gitops' | 'checks'
|
|
19
|
+
* are also Radar view names (so route entry redirects too); 'certs' is
|
|
20
|
+
* card-only (Radar has no certs view).
|
|
21
|
+
*/
|
|
22
|
+
export type FleetTakeoverTarget = 'issues' | 'gitops' | 'checks' | 'certs';
|
|
23
|
+
|
|
16
24
|
interface NavCustomizationBase {
|
|
17
25
|
/** Replaces Radar's Skyhook/radar logo + wordmark. */
|
|
18
26
|
brandSlot?: ReactNode;
|
|
19
27
|
/** Replaces the ContextSwitcher (kubeconfig-context picker). */
|
|
20
28
|
contextSlot?: ReactNode;
|
|
29
|
+
/**
|
|
30
|
+
* When set, a "Compare across clusters" option is added to the Compare
|
|
31
|
+
* button in resource action bars. The host returns the URL that should
|
|
32
|
+
* be navigated to (via window.location.assign — typically a hub fleet
|
|
33
|
+
* route). Standalone Radar omits this and the compare action stays
|
|
34
|
+
* single-cluster.
|
|
35
|
+
*/
|
|
36
|
+
crossClusterCompareHref?: (ref: {
|
|
37
|
+
kind: string;
|
|
38
|
+
namespace: string;
|
|
39
|
+
name: string;
|
|
40
|
+
group?: string;
|
|
41
|
+
}) => string;
|
|
42
|
+
/**
|
|
43
|
+
* Lets an embedded host (e.g. Radar Cloud) take over selected per-cluster
|
|
44
|
+
* destinations with its OWN fleet pages scoped to this cluster, instead of
|
|
45
|
+
* Radar rendering them inline. Given a semantic target the host returns the
|
|
46
|
+
* URL to navigate to, or `undefined`/omits the hook to let Radar render its
|
|
47
|
+
* own view as usual (standalone OSS does the latter for everything).
|
|
48
|
+
*
|
|
49
|
+
* This is how the Home dashboard's "fleet-shaped" cards reach the host's
|
|
50
|
+
* canonical surfaces rather than a second, diverging per-cluster copy:
|
|
51
|
+
* - 'issues' → the Active Issues panel + cluster-health issues count
|
|
52
|
+
* - 'gitops' → the GitOps controllers card
|
|
53
|
+
* - 'checks' → the Cluster Audit card (and any route to /audit; legacy
|
|
54
|
+
* `clusterChecksHref` folded in here)
|
|
55
|
+
* - 'certs' → the Certificate Health card
|
|
56
|
+
*
|
|
57
|
+
* View-shaped targets (issues / gitops / checks) are honored for every entry:
|
|
58
|
+
* in-app nav (Home cards, ⌘K, "view all") hands straight to the host from
|
|
59
|
+
* `setMainView` via `onHostNavigate` (smooth same-document hand-off, no
|
|
60
|
+
* intermediate /<view> mount); a direct /<view> URL (bookmark/deep link)
|
|
61
|
+
* funnels through a redirect effect that uses `window.location.replace` so
|
|
62
|
+
* the transient URL stays out of history. 'certs' has no Radar view, so only
|
|
63
|
+
* the card consults it. `onHostNavigate` is optional — without it everything
|
|
64
|
+
* falls back to `window.location` (a hard reload).
|
|
65
|
+
*/
|
|
66
|
+
fleetTakeoverHref?: (target: FleetTakeoverTarget) => string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* @deprecated Superseded by `fleetTakeoverHref('checks')`. Kept so consumers
|
|
69
|
+
* still on the pre-1.7 hook keep working (App.tsx folds it into the 'checks'
|
|
70
|
+
* target) — this makes adding `fleetTakeoverHref` an additive, non-breaking
|
|
71
|
+
* change. Remove in a major release once all consumers have migrated.
|
|
72
|
+
*/
|
|
73
|
+
clusterChecksHref?: () => string;
|
|
74
|
+
/**
|
|
75
|
+
* Optional smooth navigator for host-owned URLs. When the host takes a
|
|
76
|
+
* destination over (`fleetTakeoverHref`, `crossClusterCompareHref`), Radar
|
|
77
|
+
* would otherwise hand off via `window.location` — a full document reload
|
|
78
|
+
* that cold-boots the host (white flash, re-auth, chrome teardown). A host
|
|
79
|
+
* that can navigate SAME-DOCUMENT (e.g. Radar Cloud's cross-tree swap with a
|
|
80
|
+
* View Transition) passes this so the hand-off morphs instead of reloading.
|
|
81
|
+
* Omitted → Radar falls back to `window.location` (hard nav), so standalone
|
|
82
|
+
* OSS / other hosts are unaffected.
|
|
83
|
+
*/
|
|
84
|
+
onHostNavigate?: (url: string) => void;
|
|
85
|
+
/**
|
|
86
|
+
* Chrome level for embedded hosts. Default ('full', or omitted) renders
|
|
87
|
+
* Radar's top bar + the view-switcher. 'none' suppresses BOTH — the host
|
|
88
|
+
* drives view navigation and cluster/namespace scope from its OWN chrome, and
|
|
89
|
+
* Radar renders just the active view's content full-bleed. Radar Hub uses this
|
|
90
|
+
* to surface per-cluster views (Topology / Resources / Traffic / Cost) that
|
|
91
|
+
* don't aggregate to the fleet as native cloud destinations under one chrome,
|
|
92
|
+
* gated by a cluster picker — instead of a second, redundant in-cluster nav.
|
|
93
|
+
* Only meaningful with `embedded: true`.
|
|
94
|
+
*/
|
|
95
|
+
chrome?: 'full' | 'none';
|
|
21
96
|
}
|
|
22
97
|
|
|
23
98
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Provides the active timeline data source to the timeline wrappers.
|
|
2
|
+
//
|
|
3
|
+
// Radar's binary never sets a source, so the default is the local event store
|
|
4
|
+
// (GET {apiBase}/changes) — unchanged OSS behavior. A host embedding RadarApp
|
|
5
|
+
// behind a proxy that serves retained history passes `timelineSource` on
|
|
6
|
+
// RadarApp; this provider resolves it once and hands the timeline wrappers a
|
|
7
|
+
// source-agnostic `useEvents` hook.
|
|
8
|
+
//
|
|
9
|
+
// Default (no provider): the local source, so components work standalone.
|
|
10
|
+
import { createContext, useContext, useMemo, Fragment } from 'react'
|
|
11
|
+
import type { ReactNode } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
localSource,
|
|
14
|
+
resolveTimelineSource,
|
|
15
|
+
type TimelineSource,
|
|
16
|
+
type TimelineSourceConfig,
|
|
17
|
+
} from '../api/timelineSource'
|
|
18
|
+
|
|
19
|
+
const TimelineSourceContext = createContext<TimelineSource>(localSource)
|
|
20
|
+
|
|
21
|
+
export function TimelineSourceProvider({
|
|
22
|
+
config,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
config?: TimelineSourceConfig
|
|
26
|
+
children: ReactNode
|
|
27
|
+
}) {
|
|
28
|
+
// Resolve from the config's two fields, not the object: a host passing a
|
|
29
|
+
// fresh config literal each render must not re-resolve the source.
|
|
30
|
+
const mode = config?.mode
|
|
31
|
+
const maxRangeDays = config?.maxRangeDays
|
|
32
|
+
const source = useMemo(
|
|
33
|
+
() => resolveTimelineSource(mode === 'retained' ? { mode, maxRangeDays } : undefined),
|
|
34
|
+
[mode, maxRangeDays],
|
|
35
|
+
)
|
|
36
|
+
return (
|
|
37
|
+
<TimelineSourceContext.Provider value={source}>
|
|
38
|
+
{/* Key the subtree on the source mode. `useEvents` is a hook, and the local
|
|
39
|
+
vs retained sources are different hook functions with different internal
|
|
40
|
+
hook counts; a host flipping `timelineSource` mode mid-session would
|
|
41
|
+
otherwise corrupt React's hook order. Remounting on the flip keeps the
|
|
42
|
+
hook sequence consistent. */}
|
|
43
|
+
<Fragment key={mode ?? 'local'}>{children}</Fragment>
|
|
44
|
+
</TimelineSourceContext.Provider>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useTimelineSource(): TimelineSource {
|
|
49
|
+
return useContext(TimelineSourceContext)
|
|
50
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useMemo, ReactNode } from 'react'
|
|
2
2
|
import { useCapabilities, useNamespaceCapabilities } from '../api/client'
|
|
3
|
-
import type
|
|
3
|
+
import { OPTIONAL_RESOURCE_KINDS, type Capabilities, type ResourcePermissions } from '../types'
|
|
4
4
|
|
|
5
5
|
// Default capabilities for local development (when running locally, all features work)
|
|
6
6
|
const defaultCapabilities: Capabilities = {
|
|
@@ -12,6 +12,12 @@ const defaultCapabilities: Capabilities = {
|
|
|
12
12
|
secretsUpdate: true,
|
|
13
13
|
helmWrite: true,
|
|
14
14
|
nodeWrite: true,
|
|
15
|
+
workloadWrites: {
|
|
16
|
+
deployments: true,
|
|
17
|
+
daemonSets: true,
|
|
18
|
+
statefulSets: true,
|
|
19
|
+
rollouts: true,
|
|
20
|
+
},
|
|
15
21
|
mcpEnabled: true,
|
|
16
22
|
// Default to 'local' for the loading window so the UI renders the
|
|
17
23
|
// OSS standalone shape until /api/capabilities resolves. Both
|
|
@@ -30,6 +36,12 @@ const restrictedCapabilities: Capabilities = {
|
|
|
30
36
|
secretsUpdate: false,
|
|
31
37
|
helmWrite: false,
|
|
32
38
|
nodeWrite: false,
|
|
39
|
+
workloadWrites: {
|
|
40
|
+
deployments: false,
|
|
41
|
+
daemonSets: false,
|
|
42
|
+
statefulSets: false,
|
|
43
|
+
rollouts: false,
|
|
44
|
+
},
|
|
33
45
|
mcpEnabled: false,
|
|
34
46
|
deployment: { mode: 'local' },
|
|
35
47
|
}
|
|
@@ -79,6 +91,14 @@ export function useCanPortForward(): boolean {
|
|
|
79
91
|
return useContext(CapabilitiesContext).portForward
|
|
80
92
|
}
|
|
81
93
|
|
|
94
|
+
// True when Radar runs as a local binary (live port-forward is possible). When
|
|
95
|
+
// false (in-cluster / Radar Cloud) a live forward can't bind a usable local
|
|
96
|
+
// listener, so the UI offers a copy-paste `kubectl port-forward` command instead.
|
|
97
|
+
// Defaults to local during the capabilities-loading window (see defaultCapabilities).
|
|
98
|
+
export function useIsLocalDeployment(): boolean {
|
|
99
|
+
return useContext(CapabilitiesContext).deployment?.mode === 'local'
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
export function useCanViewSecrets(): boolean {
|
|
83
103
|
return useContext(CapabilitiesContext).secrets
|
|
84
104
|
}
|
|
@@ -100,12 +120,17 @@ export function useResourcePermissions(): ResourcePermissions | undefined {
|
|
|
100
120
|
return useContext(CapabilitiesContext).resources
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
// See OPTIONAL_RESOURCE_KINDS for why these are filtered.
|
|
124
|
+
function isOptionalKind(kind: string): boolean {
|
|
125
|
+
return (OPTIONAL_RESOURCE_KINDS as ReadonlyArray<string>).includes(kind)
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
export function useRestrictedResources(): string[] {
|
|
104
129
|
const resources = useContext(CapabilitiesContext).resources
|
|
105
130
|
return useMemo(() => {
|
|
106
131
|
if (!resources) return []
|
|
107
132
|
return Object.entries(resources)
|
|
108
|
-
.filter(([, allowed]) => !allowed)
|
|
133
|
+
.filter(([kind, allowed]) => !allowed && !isOptionalKind(kind))
|
|
109
134
|
.map(([kind]) => kind)
|
|
110
135
|
}, [resources])
|
|
111
136
|
}
|
|
@@ -113,13 +138,11 @@ export function useRestrictedResources(): string[] {
|
|
|
113
138
|
export function useHasLimitedAccess(): boolean {
|
|
114
139
|
const resources = useContext(CapabilitiesContext).resources
|
|
115
140
|
if (!resources) return false
|
|
116
|
-
return Object.
|
|
141
|
+
return Object.entries(resources).some(([kind, allowed]) => !allowed && !isOptionalKind(kind))
|
|
117
142
|
}
|
|
118
143
|
|
|
119
|
-
// Namespace-scoped capability hooks
|
|
120
|
-
//
|
|
121
|
-
// Falls back to global capability values while the namespace check is loading
|
|
122
|
-
// or when all capabilities are already granted.
|
|
144
|
+
// Namespace-scoped capability hooks. A concrete namespace gets its own
|
|
145
|
+
// capability check; callers use global capability values until it resolves.
|
|
123
146
|
export function useNamespacedCapabilities(namespace: string | undefined) {
|
|
124
147
|
const globalCaps = useContext(CapabilitiesContext)
|
|
125
148
|
const { data: nsCaps, error } = useNamespaceCapabilities(namespace, globalCaps)
|
|
@@ -132,5 +155,6 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
|
|
|
132
155
|
canExec: nsCaps?.exec ?? globalCaps.exec,
|
|
133
156
|
canViewLogs: nsCaps?.logs ?? globalCaps.logs,
|
|
134
157
|
canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
|
|
135
|
-
|
|
158
|
+
workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
|
|
159
|
+
}), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, nsCaps])
|
|
136
160
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { FilterLocationProvider, type FilterLocation } from '@skyhook-io/k8s-ui';
|
|
4
|
+
|
|
5
|
+
// Adapts OSS Radar's react-router search params to the app-agnostic
|
|
6
|
+
// FilterLocation seam that @skyhook-io/k8s-ui's useFilterState reads. Mounted
|
|
7
|
+
// once inside the router; every list view's shared filter state flows through
|
|
8
|
+
// it, keeping the URL the single source of truth. (Radar Hub provides its own
|
|
9
|
+
// bridge over its router — k8s-ui itself never depends on react-router.)
|
|
10
|
+
export function FilterLocationBridge({ children }: { children: ReactNode }) {
|
|
11
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
12
|
+
|
|
13
|
+
// React Router's functional updater is NOT state-queued: two updates in one
|
|
14
|
+
// tick can both read the same params and clobber. Advance a ref synchronously
|
|
15
|
+
// so successive filter changes (e.g. toggling two facets fast) compose.
|
|
16
|
+
const latest = useRef(searchParams);
|
|
17
|
+
latest.current = searchParams;
|
|
18
|
+
|
|
19
|
+
const update = useCallback<FilterLocation['update']>(
|
|
20
|
+
(updater, opts) => {
|
|
21
|
+
const next = updater(new URLSearchParams(latest.current));
|
|
22
|
+
latest.current = next;
|
|
23
|
+
setSearchParams(next, opts);
|
|
24
|
+
},
|
|
25
|
+
[setSearchParams],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const value = useMemo<FilterLocation>(() => ({ searchParams, update }), [searchParams, update]);
|
|
29
|
+
return <FilterLocationProvider value={value}>{children}</FilterLocationProvider>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { useDashboard, type DashboardResponse } from '../api/client'
|
|
4
|
+
import { dashboardClusterLoadState, idleClusterLoadState, type ClusterLoadState } from '../types/clusterLoadState'
|
|
5
|
+
|
|
6
|
+
interface UseClusterLoadStateArgs {
|
|
7
|
+
namespaces: string[]
|
|
8
|
+
mainView: string
|
|
9
|
+
chromeless: boolean
|
|
10
|
+
contentReady: boolean
|
|
11
|
+
onClusterLoadStateChange?: (state: ClusterLoadState) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UseClusterLoadStateResult {
|
|
15
|
+
clusterLoadState: ClusterLoadState
|
|
16
|
+
showHomeClusterLoadFallback: boolean
|
|
17
|
+
// The dashboard's first fetch is still in flight (no data yet). In this phase a
|
|
18
|
+
// center "Loading dashboard…" splash is shown, so the topbar label would be
|
|
19
|
+
// redundant — callers can suppress it and keep just the status dot.
|
|
20
|
+
clusterLoadInitial: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Tracks cluster-data warmup (deferred/partial dashboard load) once the main
|
|
24
|
+
// connection is usable. Standalone / embedded-with-chrome render it in Radar's
|
|
25
|
+
// topbar; chromeless hosts (Radar Hub) receive it via onClusterLoadStateChange;
|
|
26
|
+
// a chromeless host without a callback gets a fallback row on Home.
|
|
27
|
+
export function useClusterLoadState({
|
|
28
|
+
namespaces,
|
|
29
|
+
mainView,
|
|
30
|
+
chromeless,
|
|
31
|
+
contentReady,
|
|
32
|
+
onClusterLoadStateChange,
|
|
33
|
+
}: UseClusterLoadStateArgs): UseClusterLoadStateResult {
|
|
34
|
+
const showHomeClusterLoadFallback = chromeless && !onClusterLoadStateChange && mainView === 'home'
|
|
35
|
+
const needsClusterLoadState =
|
|
36
|
+
contentReady && (!chromeless || Boolean(onClusterLoadStateChange) || mainView === 'home')
|
|
37
|
+
|
|
38
|
+
// Off Home, stop observing once warmup has settled so we don't keep refetching
|
|
39
|
+
// /dashboard on other views. `enabled: false` makes the observer inactive,
|
|
40
|
+
// which also drops it from the SSE-driven invalidateQueries(['dashboard'])
|
|
41
|
+
// refetches — a stopped refetchInterval alone would not. Home stays live via
|
|
42
|
+
// HomeView's own observer; returning Home or changing scope re-arms, since
|
|
43
|
+
// `enabled` is derived from the current cached (re)load state.
|
|
44
|
+
const queryClient = useQueryClient()
|
|
45
|
+
const cached = queryClient.getQueryState<DashboardResponse>(['dashboard', namespaces])
|
|
46
|
+
const warmupSettled =
|
|
47
|
+
cached?.status === 'error' || (cached?.data != null && !dashboardClusterLoadState(cached.data).loading)
|
|
48
|
+
const enabled = needsClusterLoadState && (mainView === 'home' || !warmupSettled)
|
|
49
|
+
|
|
50
|
+
const { data, isPending, isError } = useDashboard(namespaces, { enabled })
|
|
51
|
+
|
|
52
|
+
const clusterLoadState = useMemo<ClusterLoadState>(() => {
|
|
53
|
+
if (!needsClusterLoadState || isError) return idleClusterLoadState
|
|
54
|
+
if (data) return dashboardClusterLoadState(data)
|
|
55
|
+
if (isPending) return { loading: true, message: 'Loading dashboard…', pendingKinds: [] }
|
|
56
|
+
return idleClusterLoadState
|
|
57
|
+
}, [needsClusterLoadState, isError, data, isPending])
|
|
58
|
+
|
|
59
|
+
// Ref the host callback so an unmemoized prop doesn't churn emits, and so the
|
|
60
|
+
// unmount reset fires only on real unmount (not on every callback identity change).
|
|
61
|
+
const emitRef = useRef(onClusterLoadStateChange)
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
emitRef.current = onClusterLoadStateChange
|
|
64
|
+
})
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
emitRef.current?.(clusterLoadState)
|
|
67
|
+
}, [clusterLoadState])
|
|
68
|
+
useEffect(() => () => emitRef.current?.(idleClusterLoadState), [])
|
|
69
|
+
|
|
70
|
+
const clusterLoadInitial = clusterLoadState.loading && !data
|
|
71
|
+
|
|
72
|
+
return { clusterLoadState, showHomeClusterLoadFallback, clusterLoadInitial }
|
|
73
|
+
}
|