@skyhook-io/radar-app 1.8.6 → 1.8.8
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 +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- 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.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- 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 +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- 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 +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- 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 +66 -116
- package/src/components/settings/SettingsDialog.tsx +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -11,7 +11,7 @@ export interface ConnectionState {
|
|
|
11
11
|
context: string
|
|
12
12
|
clusterName?: string
|
|
13
13
|
error?: string
|
|
14
|
-
errorType?: string // auth, network, timeout, unknown
|
|
14
|
+
errorType?: string // config, auth, rbac, network, timeout, tls, unknown
|
|
15
15
|
progressMessage?: string
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -27,7 +27,23 @@ interface ConnectionContextValue {
|
|
|
27
27
|
updateFromSSE: (status: ConnectionState) => void
|
|
28
28
|
}
|
|
29
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
|
+
|
|
30
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
|
+
}
|
|
31
47
|
|
|
32
48
|
async function fetchConnectionStatus(): Promise<ConnectionStatusResponse> {
|
|
33
49
|
// apiFetch handles a 401 globally (re-auth redirect). These endpoints are
|
|
@@ -46,8 +62,8 @@ async function retryConnection(): Promise<ConnectionState> {
|
|
|
46
62
|
method: 'POST',
|
|
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,6 +75,7 @@ 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)
|
|
@@ -66,6 +83,9 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
66
83
|
// initial connect (bootstrap queries already fetched while 'connecting') from
|
|
67
84
|
// a reconnect after a drop (cache may be stale across the gap).
|
|
68
85
|
const hasConnectedRef = useRef(false)
|
|
86
|
+
const autoRetryInFlightRef = useRef(false)
|
|
87
|
+
const autoRetryDelayRef = useRef(AUTO_RETRY_INITIAL_DELAY_MS)
|
|
88
|
+
const manualRetryPendingRef = useRef(false)
|
|
69
89
|
// Whether the QueryClient already held data when this provider mounted. A host
|
|
70
90
|
// can share one client across cluster-scoped RadarApp mounts (see RadarApp's
|
|
71
91
|
// `queryClient` prop); that client may carry another cluster's data under
|
|
@@ -113,6 +133,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
113
133
|
const retryMutation = useMutation({
|
|
114
134
|
mutationFn: retryConnection,
|
|
115
135
|
onMutate: () => {
|
|
136
|
+
manualRetryPendingRef.current = true
|
|
116
137
|
// Reset SSE active flag - polling can provide state until SSE reconnects
|
|
117
138
|
sseActiveRef.current = false
|
|
118
139
|
// Set connecting state while retrying
|
|
@@ -120,7 +141,6 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
120
141
|
...prev,
|
|
121
142
|
state: 'connecting',
|
|
122
143
|
error: undefined,
|
|
123
|
-
errorType: undefined,
|
|
124
144
|
progressMessage: 'Connecting to cluster...',
|
|
125
145
|
}))
|
|
126
146
|
},
|
|
@@ -131,16 +151,94 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
131
151
|
queryClient.invalidateQueries()
|
|
132
152
|
},
|
|
133
153
|
onError: (error: Error) => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
state
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
140
168
|
},
|
|
141
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])
|
|
142
238
|
|
|
143
239
|
const retry = useCallback(() => {
|
|
240
|
+
if (retryMutation.isPending || autoRetryInFlightRef.current) return
|
|
241
|
+
manualRetryPendingRef.current = true
|
|
144
242
|
retryMutation.mutate()
|
|
145
243
|
}, [retryMutation])
|
|
146
244
|
|
|
@@ -180,7 +278,7 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
180
278
|
connection,
|
|
181
279
|
contexts,
|
|
182
280
|
retry,
|
|
183
|
-
isRetrying: retryMutation.isPending,
|
|
281
|
+
isRetrying: retryMutation.isPending || isAutoRetrying,
|
|
184
282
|
updateFromSSE,
|
|
185
283
|
}
|
|
186
284
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Slot-based injection of a resource-level "Diagnose" action.
|
|
2
|
+
//
|
|
3
|
+
// Lets an embedding host (e.g. Radar Hub) inject a "Diagnose with AI" button
|
|
4
|
+
// into every resource detail action bar — without forking WorkloadView or the
|
|
5
|
+
// shared ResourceActionsBar. The host returns whatever node should render in
|
|
6
|
+
// the action bar's right-aligned universal-actions area, given the resource
|
|
7
|
+
// context.
|
|
8
|
+
//
|
|
9
|
+
// Default (no provider): Radar renders no Diagnose button — OSS stays
|
|
10
|
+
// agent-free.
|
|
11
|
+
import { createContext, useContext } from 'react';
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
|
|
14
|
+
/** Render prop for the resource-level Diagnose action. */
|
|
15
|
+
export type RenderDiagnoseAction = (ctx: {
|
|
16
|
+
kind: string;
|
|
17
|
+
namespace: string;
|
|
18
|
+
name: string;
|
|
19
|
+
/** Coarse health of the resource (from its status badge), so the entry point can
|
|
20
|
+
* adapt: an urgent "Diagnose" on a problem vs. a quiet "ask AI" when fine/unknown. */
|
|
21
|
+
health?: "problem" | "healthy" | "unknown";
|
|
22
|
+
}) => ReactNode;
|
|
23
|
+
|
|
24
|
+
const DiagnoseCustomizationContext = createContext<RenderDiagnoseAction | undefined>(undefined);
|
|
25
|
+
|
|
26
|
+
export function DiagnoseCustomizationProvider({
|
|
27
|
+
value,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
value: RenderDiagnoseAction | undefined;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<DiagnoseCustomizationContext.Provider value={value}>
|
|
35
|
+
{children}
|
|
36
|
+
</DiagnoseCustomizationContext.Provider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useDiagnoseCustomization(): RenderDiagnoseAction | undefined {
|
|
41
|
+
return useContext(DiagnoseCustomizationContext);
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,7 @@ interface UseEventSourceReturn {
|
|
|
7
7
|
topology: Topology | null
|
|
8
8
|
events: K8sEvent[]
|
|
9
9
|
connected: boolean
|
|
10
|
+
connecting: boolean
|
|
10
11
|
reconnect: () => void
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -43,6 +44,7 @@ export function useEventSource(
|
|
|
43
44
|
const [topology, setTopology] = useState<Topology | null>(null)
|
|
44
45
|
const [events, setEvents] = useState<K8sEvent[]>([])
|
|
45
46
|
const [connected, setConnected] = useState(false)
|
|
47
|
+
const [connecting, setConnecting] = useState(true)
|
|
46
48
|
const eventSourceRef = useRef<EventSource | null>(null)
|
|
47
49
|
const reconnectTimeoutRef = useRef<number | null>(null)
|
|
48
50
|
const waitingForTopologyAfterSwitch = useRef(false)
|
|
@@ -66,6 +68,7 @@ export function useEventSource(
|
|
|
66
68
|
optionsRef.current = options
|
|
67
69
|
|
|
68
70
|
const connect = useCallback(() => {
|
|
71
|
+
setConnecting(true)
|
|
69
72
|
// Clean up existing connection
|
|
70
73
|
if (eventSourceRef.current) {
|
|
71
74
|
eventSourceRef.current.close()
|
|
@@ -99,6 +102,7 @@ export function useEventSource(
|
|
|
99
102
|
es.onopen = () => {
|
|
100
103
|
console.log('SSE connected')
|
|
101
104
|
setConnected(true)
|
|
105
|
+
setConnecting(false)
|
|
102
106
|
// Reset backoff on successful connection
|
|
103
107
|
reconnectDelayRef.current = INITIAL_RECONNECT_DELAY_MS
|
|
104
108
|
}
|
|
@@ -106,6 +110,7 @@ export function useEventSource(
|
|
|
106
110
|
es.onerror = (error) => {
|
|
107
111
|
console.error('SSE error:', error)
|
|
108
112
|
setConnected(false)
|
|
113
|
+
setConnecting(false)
|
|
109
114
|
es.close()
|
|
110
115
|
|
|
111
116
|
// Reconnect with exponential backoff
|
|
@@ -257,6 +262,7 @@ export function useEventSource(
|
|
|
257
262
|
topology,
|
|
258
263
|
events,
|
|
259
264
|
connected,
|
|
265
|
+
connecting,
|
|
260
266
|
reconnect,
|
|
261
267
|
}
|
|
262
268
|
}
|
package/src/index.css
CHANGED
|
@@ -81,7 +81,11 @@ body {
|
|
|
81
81
|
color: var(--text-primary);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
#
|
|
84
|
+
/* App-shell layout for standalone Radar (mounts into #radar — see index.html).
|
|
85
|
+
Keyed off #radar, NOT #root: this file ships as the published library
|
|
86
|
+
stylesheet, so a #root rule here would match a host app's own #root and
|
|
87
|
+
reshape its document. A unique id can't collide — host apps have no #radar. */
|
|
88
|
+
#radar {
|
|
85
89
|
height: 100vh;
|
|
86
90
|
display: flex;
|
|
87
91
|
flex-direction: column;
|
|
@@ -249,6 +253,163 @@ body {
|
|
|
249
253
|
animation: fade-in-up 0.5s ease-out both;
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
/* Transcript entries (tool calls, reasoning) easing in as they stream — a small
|
|
257
|
+
rise from below + fade. Transform-only motion (no height change) so it never
|
|
258
|
+
perturbs the investigation's stick-to-bottom scroll geometry. */
|
|
259
|
+
@keyframes transcript-enter {
|
|
260
|
+
from {
|
|
261
|
+
opacity: 0;
|
|
262
|
+
transform: translateY(6px);
|
|
263
|
+
}
|
|
264
|
+
to {
|
|
265
|
+
opacity: 1;
|
|
266
|
+
transform: translateY(0);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
.animate-transcript-enter {
|
|
270
|
+
animation: transcript-enter 180ms ease-out both;
|
|
271
|
+
}
|
|
272
|
+
@media (prefers-reduced-motion: reduce) {
|
|
273
|
+
.animate-transcript-enter {
|
|
274
|
+
animation: none;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* "Thinking" status text — an accent light-sweep across muted text, the now-standard
|
|
279
|
+
live-agent cue (Perplexity/Vercel). Honest: it only marks active work, never fakes
|
|
280
|
+
progress. background-clip paints the gradient INTO the glyphs. */
|
|
281
|
+
@keyframes ai-shimmer {
|
|
282
|
+
from {
|
|
283
|
+
background-position: 200% 0;
|
|
284
|
+
}
|
|
285
|
+
to {
|
|
286
|
+
background-position: -200% 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
.ai-shimmer {
|
|
290
|
+
background: linear-gradient(
|
|
291
|
+
90deg,
|
|
292
|
+
var(--text-tertiary) 0%,
|
|
293
|
+
var(--text-tertiary) 38%,
|
|
294
|
+
var(--accent) 50%,
|
|
295
|
+
var(--text-tertiary) 62%,
|
|
296
|
+
var(--text-tertiary) 100%
|
|
297
|
+
);
|
|
298
|
+
background-size: 200% 100%;
|
|
299
|
+
-webkit-background-clip: text;
|
|
300
|
+
background-clip: text;
|
|
301
|
+
-webkit-text-fill-color: transparent;
|
|
302
|
+
color: transparent;
|
|
303
|
+
animation: ai-shimmer 2.4s linear infinite;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* Synthesis "thinking" dot — a calm breathing pulse (not a spinner) for the staged
|
|
307
|
+
pre-verdict beats. */
|
|
308
|
+
@keyframes synth-pulse {
|
|
309
|
+
0%,
|
|
310
|
+
100% {
|
|
311
|
+
opacity: 0.35;
|
|
312
|
+
transform: scale(0.85);
|
|
313
|
+
}
|
|
314
|
+
50% {
|
|
315
|
+
opacity: 1;
|
|
316
|
+
transform: scale(1.15);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
.animate-synth-pulse {
|
|
320
|
+
animation: synth-pulse 1.1s ease-in-out infinite;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* Result-reveal choreography. The card eases in; the root-cause label gets an accent
|
|
324
|
+
underline that sweeps out; the whole card pulses its accent border once to mark
|
|
325
|
+
"diagnosis ready." Sequenced via animation-delay relative to one mount. */
|
|
326
|
+
@keyframes result-in {
|
|
327
|
+
from {
|
|
328
|
+
opacity: 0;
|
|
329
|
+
transform: translateY(8px);
|
|
330
|
+
}
|
|
331
|
+
to {
|
|
332
|
+
opacity: 1;
|
|
333
|
+
transform: translateY(0);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
.animate-result-in {
|
|
337
|
+
animation: result-in 320ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
338
|
+
}
|
|
339
|
+
@keyframes underline-sweep {
|
|
340
|
+
from {
|
|
341
|
+
transform: scaleX(0);
|
|
342
|
+
}
|
|
343
|
+
to {
|
|
344
|
+
transform: scaleX(1);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
.animate-underline-sweep {
|
|
348
|
+
transform-origin: left;
|
|
349
|
+
animation: underline-sweep 460ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
|
|
350
|
+
}
|
|
351
|
+
/* Richer verdict reveal — a colored ring around the border plus an UNEVEN, breathing
|
|
352
|
+
outer glow (two soft peaks), held longer (~2.4s). Tinted per verdict via --glow
|
|
353
|
+
(amber root-cause, emerald healthy, accent remediation, slate inconclusive). */
|
|
354
|
+
@keyframes verdict-reveal {
|
|
355
|
+
0% {
|
|
356
|
+
box-shadow:
|
|
357
|
+
0 0 0 0 color-mix(in oklab, var(--glow, var(--accent)) 0%, transparent),
|
|
358
|
+
0 0 0 0 transparent;
|
|
359
|
+
}
|
|
360
|
+
18% {
|
|
361
|
+
box-shadow:
|
|
362
|
+
0 0 0 1px color-mix(in oklab, var(--glow, var(--accent)) 60%, transparent),
|
|
363
|
+
0 0 20px 2px color-mix(in oklab, var(--glow, var(--accent)) 42%, transparent);
|
|
364
|
+
}
|
|
365
|
+
44% {
|
|
366
|
+
box-shadow:
|
|
367
|
+
0 0 0 1px color-mix(in oklab, var(--glow, var(--accent)) 32%, transparent),
|
|
368
|
+
0 0 10px 1px color-mix(in oklab, var(--glow, var(--accent)) 20%, transparent);
|
|
369
|
+
}
|
|
370
|
+
68% {
|
|
371
|
+
box-shadow:
|
|
372
|
+
0 0 0 1px color-mix(in oklab, var(--glow, var(--accent)) 50%, transparent),
|
|
373
|
+
0 0 24px 3px color-mix(in oklab, var(--glow, var(--accent)) 38%, transparent);
|
|
374
|
+
}
|
|
375
|
+
100% {
|
|
376
|
+
box-shadow:
|
|
377
|
+
0 0 0 0 transparent,
|
|
378
|
+
0 0 0 0 transparent;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
.animate-verdict-reveal {
|
|
382
|
+
animation: verdict-reveal 2400ms cubic-bezier(0.32, 0.72, 0, 1) 120ms both;
|
|
383
|
+
}
|
|
384
|
+
@media (prefers-reduced-motion: reduce) {
|
|
385
|
+
.ai-shimmer {
|
|
386
|
+
animation: none;
|
|
387
|
+
background: none;
|
|
388
|
+
-webkit-text-fill-color: currentColor;
|
|
389
|
+
color: var(--text-secondary);
|
|
390
|
+
}
|
|
391
|
+
.animate-synth-pulse,
|
|
392
|
+
.animate-result-in,
|
|
393
|
+
.animate-underline-sweep,
|
|
394
|
+
.animate-verdict-reveal {
|
|
395
|
+
animation: none;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* Gentle colorize when syntax-highlighted code swaps in over plain text — opacity
|
|
400
|
+
only (no transform → no layout shift) and floored at 0.5 so it never blanks. */
|
|
401
|
+
@keyframes code-colorize {
|
|
402
|
+
from {
|
|
403
|
+
opacity: 0.5;
|
|
404
|
+
}
|
|
405
|
+
to {
|
|
406
|
+
opacity: 1;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
.animate-code-colorize {
|
|
410
|
+
animation: code-colorize 150ms ease-out;
|
|
411
|
+
}
|
|
412
|
+
|
|
252
413
|
.slide-in-from-right-5 {
|
|
253
414
|
--tw-enter-translate-x: 1.25rem;
|
|
254
415
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// bundler that transpiles TSX and resolves workspace-style peer deps. The
|
|
5
5
|
// same source is consumed by Radar's binary via main.tsx.
|
|
6
6
|
export { RadarApp, type RadarAppProps } from './RadarApp';
|
|
7
|
+
export type { ClusterLoadState } from './types/clusterLoadState';
|
|
7
8
|
export {
|
|
8
9
|
setApiBase,
|
|
9
10
|
setBasename,
|
|
@@ -15,6 +16,17 @@ export {
|
|
|
15
16
|
getCredentialsMode,
|
|
16
17
|
} from './api/config';
|
|
17
18
|
export type { NavCustomization, FleetTakeoverTarget } from './context/NavCustomization';
|
|
19
|
+
// Timeline data-source selection — lets an embedder back the timeline with a
|
|
20
|
+
// retained-history endpoint instead of Radar's local event store. Additive;
|
|
21
|
+
// absent = local (standalone behavior).
|
|
22
|
+
export type {
|
|
23
|
+
TimelineSourceConfig,
|
|
24
|
+
TimelineSourceCapabilities,
|
|
25
|
+
TimelineOverviewBucket,
|
|
26
|
+
TimelineCoverageSpan,
|
|
27
|
+
TimelineOverviewResult,
|
|
28
|
+
} from './api/timelineSource';
|
|
29
|
+
export type { RenderDiagnoseAction } from './context/DiagnoseCustomization';
|
|
18
30
|
export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
|
|
19
31
|
|
|
20
32
|
// Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
|
package/src/main.tsx
CHANGED
|
@@ -159,7 +159,7 @@ window.addEventListener('mouseup', (e: MouseEvent) => {
|
|
|
159
159
|
// tab, so it opts into per-view document.title. Library consumers (e.g.
|
|
160
160
|
// radar-hub-web) render <RadarApp apiBase="..." basename="..." /> WITHOUT this
|
|
161
161
|
// flag, keeping their own tab title.
|
|
162
|
-
ReactDOM.createRoot(document.getElementById('
|
|
162
|
+
ReactDOM.createRoot(document.getElementById('radar')!).render(
|
|
163
163
|
<React.StrictMode>
|
|
164
164
|
<RadarApp manageDocumentTitle />
|
|
165
165
|
</React.StrictMode>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ClusterLoadState {
|
|
2
|
+
loading: boolean
|
|
3
|
+
message: string | null
|
|
4
|
+
pendingKinds: string[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const idleClusterLoadState: ClusterLoadState = {
|
|
8
|
+
loading: false,
|
|
9
|
+
message: null,
|
|
10
|
+
pendingKinds: [],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function dashboardClusterLoadState(data?: {
|
|
14
|
+
deferredLoading?: boolean
|
|
15
|
+
partialData?: string[]
|
|
16
|
+
}): ClusterLoadState {
|
|
17
|
+
const pendingKinds = data?.partialData ?? []
|
|
18
|
+
if (pendingKinds.length > 0) {
|
|
19
|
+
return {
|
|
20
|
+
loading: true,
|
|
21
|
+
message: `Still loading: ${pendingKinds.join(', ')}`,
|
|
22
|
+
pendingKinds,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (data?.deferredLoading) {
|
|
26
|
+
return {
|
|
27
|
+
loading: true,
|
|
28
|
+
message: 'Loading remaining resources…',
|
|
29
|
+
pendingKinds: [],
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return idleClusterLoadState
|
|
33
|
+
}
|
package/src/utils/navigation.ts
CHANGED
|
@@ -57,6 +57,16 @@ export function resourcePath(resource: SelectedResource): string {
|
|
|
57
57
|
return `/resources/${kindToPlural(resource.kind)}${query ? `?${query}` : ''}`
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const FULLSCREEN_RESOURCE_KINDS = new Set(['pods', 'deployments', 'statefulsets', 'daemonsets', 'jobs', 'cronjobs', 'nodes'])
|
|
61
|
+
|
|
62
|
+
export function relatedResourcePath(resource: SelectedResource): string {
|
|
63
|
+
const apiKind = kindToPlural(resource.kind).toLowerCase()
|
|
64
|
+
if (FULLSCREEN_RESOURCE_KINDS.has(apiKind)) {
|
|
65
|
+
return buildWorkloadPath({ ...resource, kind: apiKind })
|
|
66
|
+
}
|
|
67
|
+
return resourcePath(resource)
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
// radar-specific: open URL in system browser (desktop app support)
|
|
61
71
|
export function openExternal(url: string): void {
|
|
62
72
|
fetch(apiUrl('/desktop/open-url'), {
|