@skyhook-io/radar-app 1.1.1 → 1.1.2

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +81 -18
  3. package/src/api/client.ts +165 -11
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/gitops/GitOpsView.tsx +1 -1
  10. package/src/components/helm/InstallWizard.tsx +5 -5
  11. package/src/components/helm/ValuesViewer.tsx +3 -39
  12. package/src/components/home/HomeView.tsx +18 -2
  13. package/src/components/resource/HPACharts.tsx +232 -0
  14. package/src/components/resource/PVCUsageBar.tsx +59 -0
  15. package/src/components/resource/PrometheusCharts.tsx +151 -434
  16. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  17. package/src/components/resource/RestartChart.tsx +124 -0
  18. package/src/components/resource/RightsizingStrip.tsx +167 -0
  19. package/src/components/resources/CompositeRenderer.tsx +101 -0
  20. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  21. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  22. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  23. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  24. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  25. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  26. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  28. package/src/components/resources/renderers/index.ts +1 -0
  29. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  30. package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
  31. package/src/components/workload/WorkloadView.tsx +107 -3
  32. package/src/context/NavCustomization.tsx +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -12,6 +12,7 @@ import { ResourcesView } from './components/resources/ResourcesView'
12
12
  import { serializeColumnFilters } from './components/resources/resource-utils'
13
13
  import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawer'
14
14
  import { WorkloadViewRoute } from './components/workload/WorkloadView'
15
+ import { CompareViewRoute } from './components/compare/CompareViewRoute'
15
16
  import { HelmView } from './components/helm/HelmView'
16
17
  import { TrafficView } from './components/traffic/TrafficView'
17
18
  import { CostView } from './components/cost/CostView'
@@ -40,11 +41,12 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
40
41
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
41
42
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
42
43
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
43
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
44
+ import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch, Shield as ShieldIcon } from 'lucide-react'
44
45
  import { useTheme } from './context/ThemeContext'
45
46
  import { Tooltip } from './components/ui/Tooltip'
46
47
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
47
48
  import { SettingsDialog } from './components/settings/SettingsDialog'
49
+ import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
48
50
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
49
51
  import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath } from './utils/navigation'
50
52
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
@@ -116,7 +118,7 @@ function apiResourceToNodeIdPrefix(apiResource: string): string {
116
118
  }
117
119
 
118
120
  // Extended MainView type that includes traffic and cost
119
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops'
121
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare'
120
122
 
121
123
  // Extract view from URL path
122
124
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -131,6 +133,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
131
133
  if (path === 'workload') return 'workload'
132
134
  if (path === 'audit') return 'audit'
133
135
  if (path === 'gitops') return 'gitops'
136
+ if (path === 'compare') return 'compare'
134
137
  return 'home'
135
138
  }
136
139
 
@@ -143,10 +146,20 @@ function AuthBarrier({ authMode }: { authMode: string }) {
143
146
 
144
147
  if (authMode === 'oidc') {
145
148
  return (
146
- <div className="flex-1 flex items-center justify-center bg-theme-base">
147
- <div className="flex flex-col items-center gap-4">
148
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
149
- <p className="text-sm text-theme-text-secondary">Redirecting to login…</p>
149
+ <div className="flex-1 relative bg-theme-base">
150
+ <div className="fixed inset-0 pointer-events-none">
151
+ <img
152
+ src={radarLoadingIcon}
153
+ alt=""
154
+ aria-hidden
155
+ className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11"
156
+ />
157
+ <p
158
+ className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary"
159
+ style={{ top: 'calc(50% + 34px)' }}
160
+ >
161
+ Redirecting to login…
162
+ </p>
150
163
  </div>
151
164
  </div>
152
165
  )
@@ -288,6 +301,7 @@ function AppInner() {
288
301
 
289
302
  // Settings dialog state
290
303
  const [showSettings, setShowSettings] = useState(false)
304
+ const [showMyPermissions, setShowMyPermissions] = useState(false)
291
305
 
292
306
  // Listen for desktop "open-settings" event from native menu
293
307
  useEffect(() => {
@@ -1146,6 +1160,18 @@ function AppInner() {
1146
1160
  </button>
1147
1161
  )}
1148
1162
 
1163
+ {/* My Permissions — what the current user can do in the cluster,
1164
+ computed live by the apiserver via SelfSubjectRulesReview.
1165
+ Available in embedded mode too — Radar Hub users still benefit
1166
+ from "why can't I do X" debugging. */}
1167
+ <button
1168
+ onClick={() => setShowMyPermissions(true)}
1169
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1170
+ title="My permissions in this cluster"
1171
+ >
1172
+ <ShieldIcon className="w-4 h-4" />
1173
+ </button>
1174
+
1149
1175
  {/* User menu (when auth enabled) — hidden in embedded mode;
1150
1176
  host app typically provides its own via rightExtras. */}
1151
1177
  {!navCustomization.embedded && <UserMenu />}
@@ -1173,13 +1199,35 @@ function AppInner() {
1173
1199
  />
1174
1200
  )}
1175
1201
 
1176
- {/* Connecting view - show during initial connection or retry */}
1202
+ {/* Connecting view shown during initial connection or retry.
1203
+ Icon is viewport-anchored so its screen position matches the
1204
+ host hub splash across cross-document transitions. */}
1177
1205
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
1178
- <div className="flex-1 flex items-center justify-center bg-theme-base">
1179
- <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
1180
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1181
- <div className="text-center">
1182
- <p className="font-medium text-theme-text-primary">Connecting to cluster</p>
1206
+ <div className="flex-1 relative bg-theme-base">
1207
+ {/* Icon absolutely anchored to viewport-center. The label block
1208
+ sits at a fixed offset below — independent of label height
1209
+ so multi-line messages (context + progress) don't shift the
1210
+ icon's screen position. */}
1211
+ <div className="fixed inset-0 pointer-events-none">
1212
+ <img
1213
+ src={radarLoadingIcon}
1214
+ alt=""
1215
+ aria-hidden
1216
+ // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1217
+ // `translate(-50%, -50%)` on odd-width viewports.
1218
+ className="absolute w-11 h-11"
1219
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1220
+ />
1221
+ <div
1222
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1223
+ style={{ top: 'calc(50% + 34px)' }}
1224
+ >
1225
+ {/* 17px semibold matches the other splash surfaces so font
1226
+ weight doesn't visibly swap during hub → cluster
1227
+ transitions. Subtitles below stay smaller/dimmer. */}
1228
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
1229
+ Connecting to cluster
1230
+ </p>
1183
1231
  {connection.context && (
1184
1232
  <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
1185
1233
  )}
@@ -1193,13 +1241,24 @@ function AppInner() {
1193
1241
  </div>
1194
1242
  )}
1195
1243
 
1196
- {/* Context switching overlay */}
1244
+ {/* Context switching overlay — icon viewport-anchored, label below. */}
1197
1245
  {isSwitching && (
1198
- <div className="flex-1 flex items-center justify-center bg-theme-base">
1199
- <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
1200
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1201
- <div className="text-center">
1202
- <div className="text-sm font-medium text-theme-text-primary">Switching context</div>
1246
+ <div className="flex-1 relative bg-theme-base">
1247
+ <div className="fixed inset-0 pointer-events-none">
1248
+ <img
1249
+ src={radarLoadingIcon}
1250
+ alt=""
1251
+ aria-hidden
1252
+ // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1253
+ // `translate(-50%, -50%)` on odd-width viewports.
1254
+ className="absolute w-11 h-11"
1255
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1256
+ />
1257
+ <div
1258
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1259
+ style={{ top: 'calc(50% + 34px)' }}
1260
+ >
1261
+ <div className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">Switching context</div>
1203
1262
  {targetContext && (
1204
1263
  <div className="text-xs mt-2 text-theme-text-tertiary">
1205
1264
  {targetContext.provider ? (
@@ -1476,6 +1535,9 @@ function AppInner() {
1476
1535
  />
1477
1536
  )}
1478
1537
 
1538
+ {/* Compare two resources of the same kind side-by-side */}
1539
+ {mainView === 'compare' && <CompareViewRoute />}
1540
+
1479
1541
  </ErrorBoundary>
1480
1542
  </div>}
1481
1543
 
@@ -1584,6 +1646,7 @@ function AppInner() {
1584
1646
 
1585
1647
  {/* Settings dialog */}
1586
1648
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
1649
+ <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1587
1650
 
1588
1651
  {/* Debug overlay - only in dev mode */}
1589
1652
  {import.meta.env.DEV && <DebugOverlay />}
package/src/api/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef } from 'react'
1
2
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
2
3
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
3
4
  import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
@@ -890,7 +891,7 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
890
891
  }
891
892
 
892
893
  // List resources - queryKey includes group for cache sharing with ResourcesView
893
- export function useResources<T>(kind: string, namespace?: string, group?: string) {
894
+ export function useResources<T>(kind: string, namespace?: string, group?: string, options?: { enabled?: boolean }) {
894
895
  const params = new URLSearchParams()
895
896
  if (namespace) params.set('namespace', namespace)
896
897
  if (group) params.set('group', group)
@@ -899,6 +900,7 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
899
900
  return useQuery<T[]>({
900
901
  queryKey: ['resources', kind, group, namespace],
901
902
  queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
903
+ enabled: (options?.enabled ?? true) && Boolean(kind),
902
904
  staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
903
905
  })
904
906
  }
@@ -1219,19 +1221,21 @@ export interface PrometheusStatus {
1219
1221
  error?: string
1220
1222
  }
1221
1223
 
1222
- export interface PrometheusDataPoint {
1223
- timestamp: number
1224
- value: number
1225
- }
1224
+ // Time-series sample types live in @skyhook-io/k8s-ui (shared with library
1225
+ // consumers). Re-export here so radar-app callers keep their existing import
1226
+ // paths; the Prom-prefixed names are deprecated aliases.
1227
+ export type {
1228
+ TimeSeriesPoint,
1229
+ TimeSeries,
1230
+ PrometheusDataPoint,
1231
+ PrometheusSeries,
1232
+ } from '@skyhook-io/k8s-ui/components/charts'
1226
1233
 
1227
- export interface PrometheusSeries {
1228
- labels: Record<string, string>
1229
- dataPoints: PrometheusDataPoint[]
1230
- }
1234
+ import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
1231
1235
 
1232
1236
  export interface PrometheusQueryResult {
1233
1237
  resultType: string
1234
- series: PrometheusSeries[]
1238
+ series: ChartTimeSeries[]
1235
1239
  }
1236
1240
 
1237
1241
  export interface PrometheusResourceMetrics {
@@ -1246,9 +1250,44 @@ export interface PrometheusResourceMetrics {
1246
1250
  hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
1247
1251
  }
1248
1252
 
1249
- export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
1253
+ export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
1250
1254
  export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
1251
1255
 
1256
+ // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
1257
+ // HasData=false silently indicates the CSI driver doesn't report or Prom
1258
+ // isn't scraping kubelet endpoints — UI should hide the gauge in that case.
1259
+ export interface PrometheusPVCUsage {
1260
+ namespace: string
1261
+ name: string
1262
+ used: number
1263
+ capacity: number
1264
+ ratio: number
1265
+ hasData: boolean
1266
+ }
1267
+
1268
+ export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
1269
+
1270
+ export interface RightsizingRow {
1271
+ container: string
1272
+ resource: 'cpu' | 'memory'
1273
+ currentRequest?: string
1274
+ currentLimit?: string
1275
+ p95?: string
1276
+ recommendedRequest?: string
1277
+ tone: RightsizingTone
1278
+ message: string
1279
+ }
1280
+
1281
+ export interface PrometheusRightsizing {
1282
+ kind: string
1283
+ namespace: string
1284
+ name: string
1285
+ window: string
1286
+ sampleAvailable: boolean
1287
+ rows: RightsizingRow[]
1288
+ reason?: string
1289
+ }
1290
+
1252
1291
  // Check Prometheus availability
1253
1292
  export function usePrometheusStatus() {
1254
1293
  return useQuery<PrometheusStatus>({
@@ -1281,6 +1320,86 @@ export function usePrometheusConnect() {
1281
1320
  })
1282
1321
  }
1283
1322
 
1323
+ // Auto-discover Prometheus on first mount of any Prom-backed view, and
1324
+ // auto-reconnect across radar restarts on subsequent mounts.
1325
+ //
1326
+ // Two paths through this hook, both running once per cluster context per
1327
+ // component-instance:
1328
+ // 1. Cached path — localStorage flag means "Prom was discovered before on
1329
+ // this context". Probe fires immediately on mount; the user sees
1330
+ // charts populate without manual interaction.
1331
+ // 2. First-time path — no flag yet. Probe fires after a small delay so the
1332
+ // initial workload-view render lands before we hit the cluster network.
1333
+ // Behavior matches Lens / Headlamp defaults; the trade-off is one
1334
+ // cluster probe per session per fresh kubeconfig context.
1335
+ //
1336
+ // On success either way we set the flag, so subsequent mounts take path 1.
1337
+ // On failure we clear the flag (path 1) or leave it cleared (path 2) and
1338
+ // reset attemptedRef, so the existing "Discover Prometheus" CTA renders
1339
+ // once status refreshes. Manual interaction stays available as the fallback.
1340
+ //
1341
+ // localStorage is the right surface: connection intent is browser-local,
1342
+ // not a server-side preference, and we want it to persist across radar
1343
+ // restarts on the same port.
1344
+ const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
1345
+ // First-mount delay before probing the cluster. Chosen short enough that the
1346
+ // CTA → charts transition feels prompt, long enough that the probe doesn't
1347
+ // race the initial workload-view render.
1348
+ const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
1349
+
1350
+ function promAutoConnectKey(contextName: string): string {
1351
+ return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
1352
+ }
1353
+
1354
+ export function useAutoPromConnect(): void {
1355
+ const queryClient = useQueryClient()
1356
+ const { data: clusterInfo } = useClusterInfo()
1357
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
1358
+ const attemptedRef = useRef<string | null>(null)
1359
+
1360
+ useEffect(() => {
1361
+ if (typeof window === 'undefined') return
1362
+ const context = clusterInfo?.context
1363
+ if (!context || statusLoading) return
1364
+
1365
+ // Persist the "we've connected here before" signal once a connection lands.
1366
+ if (status?.connected) {
1367
+ try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
1368
+ // localStorage can throw in some restricted browser modes — fail open.
1369
+ }
1370
+ return
1371
+ }
1372
+
1373
+ if (attemptedRef.current === context) return
1374
+ let cached: string | null = null
1375
+ try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
1376
+ cached = null
1377
+ }
1378
+
1379
+ attemptedRef.current = context
1380
+
1381
+ // Cached path probes immediately; first-time path defers briefly so the
1382
+ // initial UI render isn't competing with the cluster network call.
1383
+ const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
1384
+ const timeout = window.setTimeout(() => {
1385
+ // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1386
+ // meta-driven toast handler stays silent — the user didn't click anything.
1387
+ apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
1388
+ .then(resp => {
1389
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1390
+ queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1391
+ })
1392
+ .catch(() => {
1393
+ try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
1394
+ // ignore — manual CTA will render once status refreshes
1395
+ }
1396
+ attemptedRef.current = null
1397
+ })
1398
+ }, delay)
1399
+ return () => window.clearTimeout(timeout)
1400
+ }, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
1401
+ }
1402
+
1284
1403
  // Fetch Prometheus metrics for a resource
1285
1404
  export function usePrometheusResourceMetrics(
1286
1405
  kind: string,
@@ -1337,6 +1456,40 @@ export function usePrometheusClusterMetrics(
1337
1456
  })
1338
1457
  }
1339
1458
 
1459
+ // Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
1460
+ export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
1461
+ return useQuery<PrometheusPVCUsage>({
1462
+ queryKey: ['prometheus-pvc-usage', namespace, name],
1463
+ queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
1464
+ enabled: enabled && Boolean(namespace && name),
1465
+ staleTime: 60000,
1466
+ refetchInterval: 120000,
1467
+ })
1468
+ }
1469
+
1470
+ // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
1471
+ export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
1472
+ return useQuery<PrometheusRightsizing>({
1473
+ queryKey: ['prometheus-rightsizing', kind, namespace, name],
1474
+ queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
1475
+ enabled: enabled && Boolean(kind && namespace && name),
1476
+ staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
1477
+ refetchInterval: 10 * 60 * 1000,
1478
+ })
1479
+ }
1480
+
1481
+ // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
1482
+ export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
1483
+ return useQuery<PrometheusQueryResult>({
1484
+ queryKey: ['promql-range', query, range],
1485
+ queryFn: () =>
1486
+ fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
1487
+ enabled: enabled && Boolean(query),
1488
+ staleTime: 30000,
1489
+ refetchInterval: 60000,
1490
+ })
1491
+ }
1492
+
1340
1493
  // ============================================================================
1341
1494
  // Pod Logs
1342
1495
  // ============================================================================
@@ -2998,6 +3151,7 @@ export interface DiagnosticsSnapshot {
2998
3151
  debugEvents: boolean
2999
3152
  mcpEnabled: boolean
3000
3153
  hasPrometheusURL: boolean
3154
+ hasPrometheusHeaders: boolean
3001
3155
  }
3002
3156
  recentErrors?: DiagErrorEntry[]
3003
3157
  totalErrorsRecorded?: number
@@ -0,0 +1,57 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import type {
3
+ RBACSubjectResponse,
4
+ RBACRoleResponse,
5
+ RBACWhoamiResponse,
6
+ RBACNamespaceResponse,
7
+ } from '@skyhook-io/k8s-ui'
8
+ import { fetchJSON } from './client'
9
+
10
+ // /api/rbac/subject/{kind}/{namespace}/{name} (ServiceAccount)
11
+ // /api/rbac/subject/{kind}/{name} (User/Group — no namespace)
12
+ export function useRBACSubject(kind: 'ServiceAccount' | 'User' | 'Group', namespace: string, name: string, enabled = true) {
13
+ // Subject lookups depend on cluster-wide RBAC. They don't change often,
14
+ // and operators bouncing between Pod/SA pages re-hit the same SA. Use a
15
+ // 15s stale window so cross-page navigation is instant.
16
+ const path =
17
+ kind === 'ServiceAccount'
18
+ ? `/rbac/subject/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`
19
+ : `/rbac/subject/${kind}/${encodeURIComponent(name)}`
20
+ return useQuery<RBACSubjectResponse>({
21
+ queryKey: ['rbac', 'subject', kind, namespace, name],
22
+ queryFn: () => fetchJSON<RBACSubjectResponse>(path),
23
+ enabled: enabled && !!name && (kind !== 'ServiceAccount' || !!namespace),
24
+ staleTime: 15000,
25
+ })
26
+ }
27
+
28
+ // /api/rbac/role/{kind}/{namespace}/{name} (use "_" for ClusterRole's empty namespace)
29
+ export function useRBACRole(kind: 'Role' | 'ClusterRole', namespace: string, name: string, enabled = true) {
30
+ const nsSegment = kind === 'ClusterRole' ? '_' : encodeURIComponent(namespace)
31
+ return useQuery<RBACRoleResponse>({
32
+ queryKey: ['rbac', 'role', kind, namespace, name],
33
+ queryFn: () => fetchJSON<RBACRoleResponse>(`/rbac/role/${kind}/${nsSegment}/${encodeURIComponent(name)}`),
34
+ enabled: enabled && !!name && (kind !== 'Role' || !!namespace),
35
+ staleTime: 15000,
36
+ })
37
+ }
38
+
39
+ // /api/rbac/namespace/{namespace}
40
+ export function useRBACNamespace(namespace: string, enabled = true) {
41
+ return useQuery<RBACNamespaceResponse>({
42
+ queryKey: ['rbac', 'namespace', namespace],
43
+ queryFn: () => fetchJSON<RBACNamespaceResponse>(`/rbac/namespace/${encodeURIComponent(namespace)}`),
44
+ enabled: enabled && !!namespace,
45
+ staleTime: 15000,
46
+ })
47
+ }
48
+
49
+ // /api/rbac/whoami?namespace=<ns>
50
+ export function useRBACWhoami(namespace: string, enabled = true) {
51
+ return useQuery<RBACWhoamiResponse>({
52
+ queryKey: ['rbac', 'whoami', namespace],
53
+ queryFn: () => fetchJSON<RBACWhoamiResponse>(`/rbac/whoami?namespace=${encodeURIComponent(namespace)}`),
54
+ enabled: enabled && !!namespace,
55
+ staleTime: 30000,
56
+ })
57
+ }
@@ -0,0 +1,116 @@
1
+ import { useCallback, useState } from 'react'
2
+ import { useNavigate, useSearchParams } from 'react-router-dom'
3
+ import {
4
+ ResourceCompareView,
5
+ CompareResourcePicker,
6
+ parseRef,
7
+ refToParam,
8
+ type CompareResourceRef,
9
+ type CompareSide,
10
+ type CompareSideError,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import { useResource } from '../../api/client'
13
+ import { useTheme } from '../../context/ThemeContext'
14
+ import { useCompareCandidates } from './useCompareCandidates'
15
+
16
+ export function CompareViewRoute() {
17
+ const navigate = useNavigate()
18
+ const [searchParams, setSearchParams] = useSearchParams()
19
+ const { theme } = useTheme()
20
+
21
+ const kind = (searchParams.get('kind') ?? '').toLowerCase()
22
+ // Matches Radar's repo-wide URL convention. The bare `group` param is
23
+ // reserved for topology grouping mode and gets stripped by App.tsx's URL
24
+ // sync on every non-topology view.
25
+ const group = searchParams.get('apiGroup') ?? undefined
26
+ const aParsed = parseRef(searchParams.get('a'))
27
+ const bParsed = parseRef(searchParams.get('b'))
28
+
29
+ const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
30
+
31
+ const a: CompareResourceRef | null = aParsed ? { kind, namespace: aParsed.namespace, name: aParsed.name, group } : null
32
+ const b: CompareResourceRef | null = bParsed ? { kind, namespace: bParsed.namespace, name: bParsed.name, group } : null
33
+
34
+ const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
35
+ const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
36
+
37
+ const { candidates, isPending: candidatesPending, error: candidatesError } = useCompareCandidates(kind, group, !!pickerOpen)
38
+
39
+ const updateParam = useCallback(
40
+ (next: Record<string, string>) => {
41
+ const params = new URLSearchParams(searchParams)
42
+ for (const [k, v] of Object.entries(next)) params.set(k, v)
43
+ setSearchParams(params, { replace: true })
44
+ },
45
+ [searchParams, setSearchParams],
46
+ )
47
+
48
+ const handleSwap = useCallback(() => {
49
+ if (!a || !b) return
50
+ updateParam({ a: refToParam(b), b: refToParam(a) })
51
+ }, [a, b, updateParam])
52
+
53
+ const handleClose = useCallback(() => {
54
+ navigate(-1)
55
+ }, [navigate])
56
+
57
+ const handlePick = useCallback(
58
+ (picked: CompareResourceRef) => {
59
+ if (!pickerOpen) return
60
+ updateParam({ [pickerOpen]: refToParam({ namespace: picked.namespace, name: picked.name }) })
61
+ setPickerOpen(null)
62
+ },
63
+ [pickerOpen, updateParam],
64
+ )
65
+
66
+ if (!kind || !a || !b) {
67
+ return (
68
+ <div className="flex flex-col items-center justify-center h-full text-theme-text-secondary gap-3 p-8">
69
+ <p className="text-sm">This compare link is missing required parameters.</p>
70
+ <button
71
+ onClick={() => navigate('/resources')}
72
+ className="px-3 py-1.5 text-xs font-medium btn-brand rounded-lg"
73
+ >
74
+ Back to resources
75
+ </button>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ // A refetch failure with cached data is not worth shouting about — show the
81
+ // stale data instead of blanking the side with a misleading "failed" banner.
82
+ const errors: CompareSideError[] = []
83
+ if (aQuery.error && !aQuery.data) errors.push({ side: 'a', message: aQuery.error instanceof Error ? aQuery.error.message : String(aQuery.error) })
84
+ if (bQuery.error && !bQuery.data) errors.push({ side: 'b', message: bQuery.error instanceof Error ? bQuery.error.message : String(bQuery.error) })
85
+
86
+ const source = pickerOpen === 'a' ? a : pickerOpen === 'b' ? b : null
87
+
88
+ return (
89
+ <>
90
+ <ResourceCompareView
91
+ a={a}
92
+ b={b}
93
+ aData={aQuery.data}
94
+ bData={bQuery.data}
95
+ errors={errors}
96
+ editorTheme={theme === 'dark' ? 'vs-dark' : 'vs'}
97
+ onSwap={handleSwap}
98
+ onClose={handleClose}
99
+ onChangeA={() => setPickerOpen('a')}
100
+ onChangeB={() => setPickerOpen('b')}
101
+ />
102
+ {source && pickerOpen && (
103
+ <CompareResourcePicker
104
+ open={true}
105
+ onClose={() => setPickerOpen(null)}
106
+ source={source}
107
+ sourceSide={pickerOpen}
108
+ candidates={candidates}
109
+ loading={candidatesPending}
110
+ error={candidatesError}
111
+ onPick={handlePick}
112
+ />
113
+ )}
114
+ </>
115
+ )
116
+ }
@@ -0,0 +1,27 @@
1
+ import { useMemo } from 'react'
2
+ import type { CompareResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useResources } from '../../api/client'
4
+
5
+ /**
6
+ * Fetch candidates for the compare picker — same kind as the source.
7
+ * Pass `enabled=false` when the picker is closed to avoid hitting the API.
8
+ */
9
+ export function useCompareCandidates(kind: string, group: string | undefined, enabled: boolean) {
10
+ const query = useResources<{ metadata?: { name?: string; namespace?: string } }>(
11
+ enabled ? kind : '',
12
+ undefined,
13
+ group,
14
+ )
15
+ const candidates: CompareResourceRef[] = useMemo(() => {
16
+ if (!query.data) return []
17
+ return query.data
18
+ .filter(r => r?.metadata?.name)
19
+ .map(r => ({
20
+ kind,
21
+ namespace: r.metadata?.namespace ?? '',
22
+ name: r.metadata!.name!,
23
+ group,
24
+ }))
25
+ }, [query.data, kind, group])
26
+ return { candidates, isPending: query.isPending, error: query.error }
27
+ }