@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.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. 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, getAuthHeaders, getCredentialsMode } from '../api/config'
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
- const response = await fetch(`${getApiBase()}/connection`, {
33
- credentials: getCredentialsMode(),
34
- headers: getAuthHeaders(),
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 fetch(`${getApiBase()}/connection/retry`, {
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 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,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
- setConnection(prev => ({
119
- ...prev,
120
- state: 'disconnected',
121
- error: error.message,
122
- progressMessage: undefined,
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
- queryClient.invalidateQueries()
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 { Capabilities, ResourcePermissions } from '../types'
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.values(resources).some(allowed => !allowed)
141
+ return Object.entries(resources).some(([kind, allowed]) => !allowed && !isOptionalKind(kind))
117
142
  }
118
143
 
119
- // Namespace-scoped capability hooks: lazily re-check exec/logs/portForward
120
- // scoped to a specific namespace when global RBAC checks denied them.
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
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, nsCaps])
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
+ }