@skyhook-io/radar-app 1.1.1 → 1.2.0

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 (40) hide show
  1. package/package.json +2 -1
  2. package/src/App.tsx +167 -64
  3. package/src/api/client.ts +197 -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/dock/TerminalTab.tsx +1 -1
  10. package/src/components/gitops/GitOpsView.tsx +1 -1
  11. package/src/components/helm/InstallWizard.tsx +5 -5
  12. package/src/components/helm/ValuesViewer.tsx +3 -39
  13. package/src/components/home/ClusterHealthCard.tsx +17 -13
  14. package/src/components/home/HomeView.tsx +18 -2
  15. package/src/components/home/MCPSetupDialog.tsx +5 -3
  16. package/src/components/resource/HPACharts.tsx +232 -0
  17. package/src/components/resource/PVCUsageBar.tsx +59 -0
  18. package/src/components/resource/PrometheusCharts.tsx +151 -434
  19. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  20. package/src/components/resource/RestartChart.tsx +124 -0
  21. package/src/components/resource/RightsizingStrip.tsx +167 -0
  22. package/src/components/resources/CompositeRenderer.tsx +101 -0
  23. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  24. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  25. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  26. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  27. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  28. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  29. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  30. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  31. package/src/components/resources/renderers/index.ts +1 -0
  32. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  33. package/src/components/traffic/TrafficFlowList.tsx +16 -11
  34. package/src/components/traffic/TrafficGraph.tsx +5 -1
  35. package/src/components/ui/DiagnosticsOverlay.tsx +127 -8
  36. package/src/components/workload/WorkloadView.tsx +107 -3
  37. package/src/context/NavCustomization.tsx +13 -0
  38. package/src/main.tsx +1 -0
  39. package/src/monaco-deep.d.ts +8 -0
  40. package/src/monaco-setup.ts +26 -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.2.0",
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",
@@ -31,6 +31,7 @@
31
31
  "@fontsource/dm-mono": "^5.2.7",
32
32
  "@monaco-editor/react": "^4.7.0",
33
33
  "diff": "^9.0.0",
34
+ "monaco-editor": "^0.55.1",
34
35
  "react-markdown": "^10.1.0",
35
36
  "react-virtuoso": "^4.18.6",
36
37
  "remark-gfm": "^4.0.1",
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(() => {
@@ -509,58 +523,97 @@ function AppInner() {
509
523
  // Query client for cache invalidation
510
524
  const queryClient = useQueryClient()
511
525
 
512
- // SSE-driven cache invalidation for resource lists, counts, and detail views.
513
- // Uses a 3-second throttle window: first event starts the timer, all events within the
514
- // window accumulate, then fire a single batch invalidation. This keeps max latency at 3s
515
- // while coalescing burst events (e.g., 100-pod rollout ~10 invalidations total).
516
- const pendingInvalidationRef = useRef<{
517
- kinds: Set<string>
518
- hasCountChange: boolean
526
+ // SSE-driven cache invalidation, split into two cadences so constant status
527
+ // churn on large clusters doesn't force the *expensive* queries (big resource
528
+ // lists + dashboard) to refetch every 3s. The core distinction: add/delete
529
+ // changes what rows/counts exist (membership keep fast); update is mostly
530
+ // status/restart/health noise that can fire constantly on a 10k-pod cluster
531
+ // and shouldn't drag a giant list onto a 3s cadence.
532
+ //
533
+ // FAST (3s): detail drawer for any change (one cheap mounted object), and
534
+ // on add/delete: the list, counts, and dashboard. GitOps + cert keep
535
+ // their existing every-batch behavior — Phase 2 makes GitOps relevance-aware.
536
+ // SLOW (15s): list + dashboard for kinds with update churn. A kind that also
537
+ // had an add/delete in the window gets refreshed by both tiers (an extra
538
+ // refetch per 15s at most) — that's fine and avoids a stale-list bug:
539
+ // deduping by "was structural this window" would wrongly suppress an
540
+ // update that arrived *after* the fast structural flush already ran.
541
+ const fastInvalidationRef = useRef<{
542
+ changedKinds: Set<string> // every changed kind (any op) → detail drawer
543
+ structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
544
+ secretsChanged: boolean
545
+ timer: number | null
546
+ }>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
547
+ const slowInvalidationRef = useRef<{
548
+ updatedKinds: Set<string> // update-only churn → throttled list + dashboard
519
549
  timer: number | null
520
- }>({ kinds: new Set(), hasCountChange: false, timer: null })
550
+ }>({ updatedKinds: new Set(), timer: null })
521
551
 
522
552
  const handleK8sEvent = useCallback((event: K8sEvent) => {
523
553
  // Skip K8s Event kind — informational, not resource mutations
524
554
  if (event.kind === 'Event') return
525
555
 
526
- const pending = pendingInvalidationRef.current
527
- pending.kinds.add(kindToPlural(event.kind))
528
- if (event.operation === 'add' || event.operation === 'delete') {
529
- pending.hasCountChange = true
556
+ const kind = kindToPlural(event.kind)
557
+ const structural = event.operation === 'add' || event.operation === 'delete'
558
+
559
+ const fast = fastInvalidationRef.current
560
+ fast.changedKinds.add(kind)
561
+ if (structural) fast.structuralKinds.add(kind)
562
+ if (kind === 'secrets') fast.secretsChanged = true
563
+
564
+ const slow = slowInvalidationRef.current
565
+ if (!structural) slow.updatedKinds.add(kind)
566
+
567
+ // FAST tier — membership-sensitive + cheap, bounded 3s latency.
568
+ if (fast.timer === null) {
569
+ fast.timer = window.setTimeout(() => {
570
+ const f = fastInvalidationRef.current
571
+ for (const k of f.changedKinds) {
572
+ queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
573
+ }
574
+ for (const k of f.structuralKinds) {
575
+ queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
576
+ }
577
+ if (f.structuralKinds.size > 0) {
578
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
579
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] })
580
+ }
581
+ if (f.secretsChanged) {
582
+ queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
583
+ }
584
+ // GitOps behavior unchanged from before — refreshes every batch when a
585
+ // GitOps view is mounted (Phase 2 will make this relevance-aware).
586
+ queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
587
+ queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
588
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
589
+ }, 3000)
530
590
  }
531
591
 
532
- // Start throttle window on first event (don't reset bounded 3s latency)
533
- if (pending.timer !== null) return
534
- pending.timer = window.setTimeout(() => {
535
- for (const kind of pending.kinds) {
536
- // Invalidate list queries (['resources', kind, ...]) and detail queries (['resource', kind, ...])
537
- queryClient.invalidateQueries({ queryKey: ['resources', kind] })
538
- queryClient.invalidateQueries({ queryKey: ['resource', kind] })
539
- }
540
- if (pending.hasCountChange) {
541
- queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
542
- }
543
- queryClient.invalidateQueries({ queryKey: ['dashboard'] })
544
- if (pending.kinds.has('secrets')) {
545
- queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
546
- }
547
- // GitOps tree + insights are derived views over the same informer
548
- // cache that produced this SSE event — when *anything* changes, the
549
- // managed-resource tree and the insights pipeline can have stale
550
- // changes/events/drift. Invalidating broadly here is cheap (only the
551
- // currently-mounted GitOps view re-fetches; other views have no
552
- // matching keys) and is what makes the detail page actually live.
553
- // Without this the failure card + topology lag behind the title chips
554
- // until window focus or a manual refresh.
555
- queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
556
- queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
557
- // Reset accumulator
558
- pending.kinds = new Set()
559
- pending.hasCountChange = false
560
- pending.timer = null
561
- }, 3000)
592
+ // SLOW tier throttle the expensive queries for status-only churn. Only
593
+ // updates schedule it; structural changes are fully handled by the fast tier.
594
+ if (!structural && slow.timer === null) {
595
+ slow.timer = window.setTimeout(() => {
596
+ const s = slowInvalidationRef.current
597
+ for (const k of s.updatedKinds) {
598
+ queryClient.invalidateQueries({ queryKey: ['resources', k] })
599
+ }
600
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] }) // health reflects status updates
601
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
602
+ }, 15000)
603
+ }
562
604
  }, [queryClient])
563
605
 
606
+ // Clear pending invalidation timers on unmount. Reset the refs (not just
607
+ // clearTimeout) so a same-instance remount doesn't inherit a non-null timer
608
+ // id — handleK8sEvent only schedules when timer === null, so a stale id would
609
+ // silently wedge all further SSE-driven invalidation.
610
+ useEffect(() => () => {
611
+ if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
612
+ if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
613
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
614
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
615
+ }, [])
616
+
564
617
  // SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
565
618
  // forceNamespaceFilter is only set for large clusters that require server-side filtering.
566
619
  // Fleet mode uses 'resources' topology on the backend — filtering is client-side
@@ -576,10 +629,10 @@ function AppInner() {
576
629
  queryClient.invalidateQueries()
577
630
 
578
631
  // Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
579
- if (pendingInvalidationRef.current.timer !== null) {
580
- clearTimeout(pendingInvalidationRef.current.timer)
581
- pendingInvalidationRef.current = { kinds: new Set(), hasCountChange: false, timer: null }
582
- }
632
+ if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
633
+ if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
634
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
635
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
583
636
 
584
637
  // Close any open drawers/overlays — old cluster's resources don't exist on the new one
585
638
  setSelectedResource(null)
@@ -943,6 +996,7 @@ function AppInner() {
943
996
  })
944
997
 
945
998
  return {
999
+ ...displayedTopology,
946
1000
  nodes: filteredNodes,
947
1001
  edges: filteredEdges,
948
1002
  }
@@ -1146,6 +1200,18 @@ function AppInner() {
1146
1200
  </button>
1147
1201
  )}
1148
1202
 
1203
+ {/* My Permissions — what the current user can do in the cluster,
1204
+ computed live by the apiserver via SelfSubjectRulesReview.
1205
+ Available in embedded mode too — Radar Hub users still benefit
1206
+ from "why can't I do X" debugging. */}
1207
+ <button
1208
+ onClick={() => setShowMyPermissions(true)}
1209
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1210
+ title="My permissions in this cluster"
1211
+ >
1212
+ <ShieldIcon className="w-4 h-4" />
1213
+ </button>
1214
+
1149
1215
  {/* User menu (when auth enabled) — hidden in embedded mode;
1150
1216
  host app typically provides its own via rightExtras. */}
1151
1217
  {!navCustomization.embedded && <UserMenu />}
@@ -1173,13 +1239,35 @@ function AppInner() {
1173
1239
  />
1174
1240
  )}
1175
1241
 
1176
- {/* Connecting view - show during initial connection or retry */}
1242
+ {/* Connecting view shown during initial connection or retry.
1243
+ Icon is viewport-anchored so its screen position matches the
1244
+ host hub splash across cross-document transitions. */}
1177
1245
  {!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>
1246
+ <div className="flex-1 relative bg-theme-base">
1247
+ {/* Icon absolutely anchored to viewport-center. The label block
1248
+ sits at a fixed offset below — independent of label height
1249
+ so multi-line messages (context + progress) don't shift the
1250
+ icon's screen position. */}
1251
+ <div className="fixed inset-0 pointer-events-none">
1252
+ <img
1253
+ src={radarLoadingIcon}
1254
+ alt=""
1255
+ aria-hidden
1256
+ // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1257
+ // `translate(-50%, -50%)` on odd-width viewports.
1258
+ className="absolute w-11 h-11"
1259
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1260
+ />
1261
+ <div
1262
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1263
+ style={{ top: 'calc(50% + 34px)' }}
1264
+ >
1265
+ {/* 17px semibold matches the other splash surfaces so font
1266
+ weight doesn't visibly swap during hub → cluster
1267
+ transitions. Subtitles below stay smaller/dimmer. */}
1268
+ <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
1269
+ Connecting to cluster
1270
+ </p>
1183
1271
  {connection.context && (
1184
1272
  <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
1185
1273
  )}
@@ -1193,13 +1281,24 @@ function AppInner() {
1193
1281
  </div>
1194
1282
  )}
1195
1283
 
1196
- {/* Context switching overlay */}
1284
+ {/* Context switching overlay — icon viewport-anchored, label below. */}
1197
1285
  {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>
1286
+ <div className="flex-1 relative bg-theme-base">
1287
+ <div className="fixed inset-0 pointer-events-none">
1288
+ <img
1289
+ src={radarLoadingIcon}
1290
+ alt=""
1291
+ aria-hidden
1292
+ // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1293
+ // `translate(-50%, -50%)` on odd-width viewports.
1294
+ className="absolute w-11 h-11"
1295
+ style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1296
+ />
1297
+ <div
1298
+ className="absolute left-1/2 -translate-x-1/2 text-center"
1299
+ style={{ top: 'calc(50% + 34px)' }}
1300
+ >
1301
+ <div className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">Switching context</div>
1203
1302
  {targetContext && (
1204
1303
  <div className="text-xs mt-2 text-theme-text-tertiary">
1205
1304
  {targetContext.provider ? (
@@ -1476,6 +1575,9 @@ function AppInner() {
1476
1575
  />
1477
1576
  )}
1478
1577
 
1578
+ {/* Compare two resources of the same kind side-by-side */}
1579
+ {mainView === 'compare' && <CompareViewRoute />}
1580
+
1479
1581
  </ErrorBoundary>
1480
1582
  </div>}
1481
1583
 
@@ -1584,6 +1686,7 @@ function AppInner() {
1584
1686
 
1585
1687
  {/* Settings dialog */}
1586
1688
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
1689
+ <MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
1587
1690
 
1588
1691
  {/* Debug overlay - only in dev mode */}
1589
1692
  {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'
@@ -139,6 +140,9 @@ export interface WorkloadCount {
139
140
  export interface DashboardMetrics {
140
141
  cpu?: MetricSummary
141
142
  memory?: MetricSummary
143
+ // When false, only requests/capacity are meaningful — live usage (from
144
+ // metrics-server) is unavailable and usage fields are zero.
145
+ usageAvailable: boolean
142
146
  }
143
147
 
144
148
  export interface MetricSummary {
@@ -890,7 +894,7 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
890
894
  }
891
895
 
892
896
  // List resources - queryKey includes group for cache sharing with ResourcesView
893
- export function useResources<T>(kind: string, namespace?: string, group?: string) {
897
+ export function useResources<T>(kind: string, namespace?: string, group?: string, options?: { enabled?: boolean }) {
894
898
  const params = new URLSearchParams()
895
899
  if (namespace) params.set('namespace', namespace)
896
900
  if (group) params.set('group', group)
@@ -899,6 +903,7 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
899
903
  return useQuery<T[]>({
900
904
  queryKey: ['resources', kind, group, namespace],
901
905
  queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
906
+ enabled: (options?.enabled ?? true) && Boolean(kind),
902
907
  staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
903
908
  })
904
909
  }
@@ -1219,19 +1224,21 @@ export interface PrometheusStatus {
1219
1224
  error?: string
1220
1225
  }
1221
1226
 
1222
- export interface PrometheusDataPoint {
1223
- timestamp: number
1224
- value: number
1225
- }
1227
+ // Time-series sample types live in @skyhook-io/k8s-ui (shared with library
1228
+ // consumers). Re-export here so radar-app callers keep their existing import
1229
+ // paths; the Prom-prefixed names are deprecated aliases.
1230
+ export type {
1231
+ TimeSeriesPoint,
1232
+ TimeSeries,
1233
+ PrometheusDataPoint,
1234
+ PrometheusSeries,
1235
+ } from '@skyhook-io/k8s-ui/components/charts'
1226
1236
 
1227
- export interface PrometheusSeries {
1228
- labels: Record<string, string>
1229
- dataPoints: PrometheusDataPoint[]
1230
- }
1237
+ import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
1231
1238
 
1232
1239
  export interface PrometheusQueryResult {
1233
1240
  resultType: string
1234
- series: PrometheusSeries[]
1241
+ series: ChartTimeSeries[]
1235
1242
  }
1236
1243
 
1237
1244
  export interface PrometheusResourceMetrics {
@@ -1246,9 +1253,44 @@ export interface PrometheusResourceMetrics {
1246
1253
  hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
1247
1254
  }
1248
1255
 
1249
- export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
1256
+ export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
1250
1257
  export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
1251
1258
 
1259
+ // PVC usage at a moment in time, derived from kubelet_volume_stats_*.
1260
+ // HasData=false silently indicates the CSI driver doesn't report or Prom
1261
+ // isn't scraping kubelet endpoints — UI should hide the gauge in that case.
1262
+ export interface PrometheusPVCUsage {
1263
+ namespace: string
1264
+ name: string
1265
+ used: number
1266
+ capacity: number
1267
+ ratio: number
1268
+ hasData: boolean
1269
+ }
1270
+
1271
+ export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
1272
+
1273
+ export interface RightsizingRow {
1274
+ container: string
1275
+ resource: 'cpu' | 'memory'
1276
+ currentRequest?: string
1277
+ currentLimit?: string
1278
+ p95?: string
1279
+ recommendedRequest?: string
1280
+ tone: RightsizingTone
1281
+ message: string
1282
+ }
1283
+
1284
+ export interface PrometheusRightsizing {
1285
+ kind: string
1286
+ namespace: string
1287
+ name: string
1288
+ window: string
1289
+ sampleAvailable: boolean
1290
+ rows: RightsizingRow[]
1291
+ reason?: string
1292
+ }
1293
+
1252
1294
  // Check Prometheus availability
1253
1295
  export function usePrometheusStatus() {
1254
1296
  return useQuery<PrometheusStatus>({
@@ -1281,6 +1323,86 @@ export function usePrometheusConnect() {
1281
1323
  })
1282
1324
  }
1283
1325
 
1326
+ // Auto-discover Prometheus on first mount of any Prom-backed view, and
1327
+ // auto-reconnect across radar restarts on subsequent mounts.
1328
+ //
1329
+ // Two paths through this hook, both running once per cluster context per
1330
+ // component-instance:
1331
+ // 1. Cached path — localStorage flag means "Prom was discovered before on
1332
+ // this context". Probe fires immediately on mount; the user sees
1333
+ // charts populate without manual interaction.
1334
+ // 2. First-time path — no flag yet. Probe fires after a small delay so the
1335
+ // initial workload-view render lands before we hit the cluster network.
1336
+ // Behavior matches Lens / Headlamp defaults; the trade-off is one
1337
+ // cluster probe per session per fresh kubeconfig context.
1338
+ //
1339
+ // On success either way we set the flag, so subsequent mounts take path 1.
1340
+ // On failure we clear the flag (path 1) or leave it cleared (path 2) and
1341
+ // reset attemptedRef, so the existing "Discover Prometheus" CTA renders
1342
+ // once status refreshes. Manual interaction stays available as the fallback.
1343
+ //
1344
+ // localStorage is the right surface: connection intent is browser-local,
1345
+ // not a server-side preference, and we want it to persist across radar
1346
+ // restarts on the same port.
1347
+ const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
1348
+ // First-mount delay before probing the cluster. Chosen short enough that the
1349
+ // CTA → charts transition feels prompt, long enough that the probe doesn't
1350
+ // race the initial workload-view render.
1351
+ const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
1352
+
1353
+ function promAutoConnectKey(contextName: string): string {
1354
+ return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
1355
+ }
1356
+
1357
+ export function useAutoPromConnect(): void {
1358
+ const queryClient = useQueryClient()
1359
+ const { data: clusterInfo } = useClusterInfo()
1360
+ const { data: status, isLoading: statusLoading } = usePrometheusStatus()
1361
+ const attemptedRef = useRef<string | null>(null)
1362
+
1363
+ useEffect(() => {
1364
+ if (typeof window === 'undefined') return
1365
+ const context = clusterInfo?.context
1366
+ if (!context || statusLoading) return
1367
+
1368
+ // Persist the "we've connected here before" signal once a connection lands.
1369
+ if (status?.connected) {
1370
+ try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
1371
+ // localStorage can throw in some restricted browser modes — fail open.
1372
+ }
1373
+ return
1374
+ }
1375
+
1376
+ if (attemptedRef.current === context) return
1377
+ let cached: string | null = null
1378
+ try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
1379
+ cached = null
1380
+ }
1381
+
1382
+ attemptedRef.current = context
1383
+
1384
+ // Cached path probes immediately; first-time path defers briefly so the
1385
+ // initial UI render isn't competing with the cluster network call.
1386
+ const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
1387
+ const timeout = window.setTimeout(() => {
1388
+ // Direct apiFetch (not via the usePrometheusConnect mutation) so the
1389
+ // meta-driven toast handler stays silent — the user didn't click anything.
1390
+ apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
1391
+ .then(resp => {
1392
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
1393
+ queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
1394
+ })
1395
+ .catch(() => {
1396
+ try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
1397
+ // ignore — manual CTA will render once status refreshes
1398
+ }
1399
+ attemptedRef.current = null
1400
+ })
1401
+ }, delay)
1402
+ return () => window.clearTimeout(timeout)
1403
+ }, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
1404
+ }
1405
+
1284
1406
  // Fetch Prometheus metrics for a resource
1285
1407
  export function usePrometheusResourceMetrics(
1286
1408
  kind: string,
@@ -1337,6 +1459,40 @@ export function usePrometheusClusterMetrics(
1337
1459
  })
1338
1460
  }
1339
1461
 
1462
+ // Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
1463
+ export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
1464
+ return useQuery<PrometheusPVCUsage>({
1465
+ queryKey: ['prometheus-pvc-usage', namespace, name],
1466
+ queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
1467
+ enabled: enabled && Boolean(namespace && name),
1468
+ staleTime: 60000,
1469
+ refetchInterval: 120000,
1470
+ })
1471
+ }
1472
+
1473
+ // Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
1474
+ export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
1475
+ return useQuery<PrometheusRightsizing>({
1476
+ queryKey: ['prometheus-rightsizing', kind, namespace, name],
1477
+ queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
1478
+ enabled: enabled && Boolean(kind && namespace && name),
1479
+ staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
1480
+ refetchInterval: 10 * 60 * 1000,
1481
+ })
1482
+ }
1483
+
1484
+ // Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
1485
+ export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
1486
+ return useQuery<PrometheusQueryResult>({
1487
+ queryKey: ['promql-range', query, range],
1488
+ queryFn: () =>
1489
+ fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
1490
+ enabled: enabled && Boolean(query),
1491
+ staleTime: 30000,
1492
+ refetchInterval: 60000,
1493
+ })
1494
+ }
1495
+
1340
1496
  // ============================================================================
1341
1497
  // Pod Logs
1342
1498
  // ============================================================================
@@ -2873,6 +3029,9 @@ export interface DiagInformerSyncStatus {
2873
3029
  synced: boolean
2874
3030
  syncedAt?: string
2875
3031
  items: number
3032
+ lastError?: string
3033
+ lastErrorAt?: string
3034
+ forbiddenSeen?: boolean
2876
3035
  }
2877
3036
 
2878
3037
  export interface DiagCacheSyncStatus {
@@ -2889,6 +3048,31 @@ export interface DiagCacheSyncStatus {
2889
3048
  promotedKinds?: string[]
2890
3049
  }
2891
3050
 
3051
+ export interface DiagSampleWindow {
3052
+ count: number
3053
+ last: number
3054
+ min: number
3055
+ p50: number
3056
+ p95: number
3057
+ p99: number
3058
+ max: number
3059
+ }
3060
+
3061
+ export interface DiagPerfSnapshot {
3062
+ topology: {
3063
+ totalBuilds: number
3064
+ durationUs: DiagSampleWindow
3065
+ nodeCount: DiagSampleWindow
3066
+ edgeCount: DiagSampleWindow
3067
+ payloadBytes: DiagSampleWindow
3068
+ estimatedNodes: DiagSampleWindow
3069
+ }
3070
+ sse: {
3071
+ totalBroadcasts: number
3072
+ totalDrops: number
3073
+ }
3074
+ }
3075
+
2892
3076
  export interface DiagnosticsSnapshot {
2893
3077
  timestamp: string
2894
3078
  radarVersion: string
@@ -2983,6 +3167,7 @@ export interface DiagnosticsSnapshot {
2983
3167
  sse?: {
2984
3168
  connectedClients: number
2985
3169
  }
3170
+ perf?: DiagPerfSnapshot
2986
3171
  runtime?: {
2987
3172
  heapMB: number
2988
3173
  heapObjectsK: number
@@ -2998,6 +3183,7 @@ export interface DiagnosticsSnapshot {
2998
3183
  debugEvents: boolean
2999
3184
  mcpEnabled: boolean
3000
3185
  hasPrometheusURL: boolean
3186
+ hasPrometheusHeaders: boolean
3001
3187
  }
3002
3188
  recentErrors?: DiagErrorEntry[]
3003
3189
  totalErrorsRecorded?: number