@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.
Files changed (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. 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 Error(error.error || `HTTP ${response.status}`)
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
- setConnection(prev => ({
135
- ...prev,
136
- state: 'disconnected',
137
- error: error.message,
138
- progressMessage: undefined,
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
- #root {
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('root')!).render(
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
+ }
@@ -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'), {