@skyhook-io/radar-app 1.8.6 → 1.8.7

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 (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. package/src/utils/navigation.ts +10 -0
package/src/App.tsx CHANGED
@@ -6,7 +6,10 @@ import { useQueryClient } from '@tanstack/react-query'
6
6
  import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
7
7
  import { HomeView } from './components/home/HomeView'
8
8
  import { DebugOverlay } from './components/DebugOverlay'
9
- import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill } from '@skyhook-io/k8s-ui'
9
+ import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
10
+ import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
11
+ import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
12
+ import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
10
13
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
11
14
  import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
12
15
  import { TimelineView } from './components/timeline/TimelineView'
@@ -51,15 +54,16 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
51
54
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
52
55
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
53
56
  import { useDocumentTitle } from './hooks/useDocumentTitle'
54
- import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
55
- import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
57
+ import type { ClusterLoadState } from './types/clusterLoadState'
58
+ import { useClusterLoadState } from './hooks/useClusterLoadState'
59
+ import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
56
60
  import { useTheme } from './context/ThemeContext'
57
61
  import { Tooltip } from './components/ui/Tooltip'
58
62
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
59
63
  import { SettingsDialog } from './components/settings/SettingsDialog'
60
64
  import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
61
65
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
62
- import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
66
+ import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
63
67
  import { type OmnibarHandle } from './components/ui/Omnibar'
64
68
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
65
69
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
@@ -68,7 +72,7 @@ import type { ContextSwitcherHandle } from './components/ContextSwitcher'
68
72
  const ALL_NODE_KINDS: NodeKind[] = [
69
73
  'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
70
74
  'Service', 'Deployment', 'Rollout', 'DaemonSet', 'StatefulSet',
71
- 'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
75
+ 'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'ServiceAccount', 'SealedSecret', 'ServiceMonitor', 'PodMonitor', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
72
76
  'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
73
77
  'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
74
78
  'Broker', 'Trigger', 'PingSource', 'ApiServerSource', 'ContainerSource', 'SinkBinding', 'Channel',
@@ -86,6 +90,12 @@ const DEFAULT_VISIBLE_KINDS = ALL_NODE_KINDS.filter(k => k !== 'ReplicaSet')
86
90
  // Users can re-enable via the filter sidebar.
87
91
  const CRD_HIDDEN_BY_DEFAULT = new Set(['GatewayClass', 'IngressClass', 'NodePool', 'NodeClaim', 'NodeClass'])
88
92
 
93
+ // Top-bar height in px. The body frame's right-side surfaces (AI panel, resource +
94
+ // Helm drawers) all inset their top by this so they sit BELOW the header. 0 in
95
+ // chromeless embeds (the host owns the chrome, no Radar header). Keep in sync with
96
+ // the <header> py/line-height; the drawers historically hardcoded the same 49.
97
+ const APP_HEADER_HEIGHT = 49
98
+
89
99
  // CAPI kinds shown in Fleet topology mode (+ Node for Machine→Node edges)
90
100
  // Includes core CAPI kinds and all infrastructure provider kinds
91
101
  const FLEET_MODE_KINDS = new Set<NodeKind>([
@@ -226,27 +236,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
226
236
 
227
237
  if (authMode === 'oidc') {
228
238
  return (
229
- <div className="flex-1 relative bg-theme-base">
230
- <div className="absolute inset-0 pointer-events-none">
231
- <img
232
- src={radarLoadingIcon}
233
- alt=""
234
- aria-hidden
235
- // Integer offset (50% − 22) — matches the Connecting/Opening splashes;
236
- // avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
237
- className="absolute w-11 h-11"
238
- style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
239
- />
240
- <div
241
- className="absolute left-1/2 -translate-x-1/2 text-center"
242
- style={{ top: 'calc(50% + 34px)' }}
243
- >
244
- <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
245
- Redirecting to login…
246
- </p>
247
- </div>
248
- </div>
249
- </div>
239
+ <PaneLoader
240
+ label="Redirecting to login…"
241
+ className="flex-1 min-h-0 bg-theme-base"
242
+ />
250
243
  )
251
244
  }
252
245
 
@@ -279,7 +272,13 @@ function peekOwnerKey(pathname: string, search: string): string {
279
272
  return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
280
273
  }
281
274
 
282
- function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
275
+ interface AppProps {
276
+ manageDocumentTitle?: boolean
277
+ documentTitleSuffix?: string
278
+ onClusterLoadStateChange?: (state: ClusterLoadState) => void
279
+ }
280
+
281
+ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
283
282
  const navigate = useNavigate()
284
283
  const location = useLocation()
285
284
  const navigationType = useNavigationType()
@@ -287,6 +286,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
287
286
  const capabilities = useCapabilitiesContext()
288
287
  const openLocalTerminal = useOpenLocalTerminal()
289
288
  const navCustomization = useNavCustomization()
289
+ // The AI panel is an absolute slot in the body frame (the column under the header):
290
+ // it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
291
+ // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
292
+ const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
290
293
  // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
291
294
  // cross-tree swap) navigates same-document so the chrome morphs instead of
292
295
  // cold-booting; without it we fall back to a hard `window.location` nav.
@@ -507,6 +510,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
507
510
  return () => window.removeEventListener('radar:open-settings', handler)
508
511
  }, [])
509
512
 
513
+ // Listen for "open-local-terminal" DOM event — the AI surface is portaled above
514
+ // the DockProvider, so it can't call useOpenLocalTerminal directly; it dispatches
515
+ // this instead (mirrors the open-settings pattern).
516
+ useEffect(() => {
517
+ const handler = (e: Event) => {
518
+ const { command, title } = (e as CustomEvent).detail ?? {}
519
+ openLocalTerminal({ initialCommand: command, title })
520
+ }
521
+ window.addEventListener('radar:open-local-terminal', handler)
522
+ return () => window.removeEventListener('radar:open-local-terminal', handler)
523
+ }, [openLocalTerminal])
524
+
510
525
  // Diagnostics overlay state
511
526
  const [showDiagnostics, setShowDiagnostics] = useState(false)
512
527
 
@@ -895,6 +910,18 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
895
910
  const contentReady = !isSwitching && !authMePending &&
896
911
  !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
897
912
 
913
+ const { clusterLoadState, showHomeClusterLoadFallback, clusterLoadInitial } = useClusterLoadState({
914
+ namespaces,
915
+ mainView,
916
+ chromeless,
917
+ contentReady,
918
+ onClusterLoadStateChange,
919
+ })
920
+ // Suppress the topbar warmup label during the initial dashboard fetch only on
921
+ // Home, where the center "Loading dashboard…" splash already covers it. Off
922
+ // Home there's no splash, so keep the label as the only text cue.
923
+ const showClusterWarmupLabel = clusterLoadState.loading && !(clusterLoadInitial && mainView === 'home')
924
+
898
925
  // Query client for cache invalidation
899
926
  const queryClient = useQueryClient()
900
927
 
@@ -923,8 +950,22 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
923
950
  updatedKinds: Set<string> // update-only churn → throttled list + dashboard
924
951
  timer: number | null
925
952
  }>({ updatedKinds: new Set(), timer: null })
953
+ const timelineInvalidationRef = useRef<{ timer: number | null }>({ timer: null })
926
954
 
927
955
  const handleK8sEvent = useCallback((event: K8sEvent) => {
956
+ // The timeline consumes every frame — including the K8s Event kind the
957
+ // resource tiers skip below (warnings like BackOff are timeline content).
958
+ // Its own trailing throttle keeps the live view fresh within seconds
959
+ // while batching bursts into one refetch; the 60s poll on useChanges
960
+ // remains the no-SSE fallback.
961
+ const tl = timelineInvalidationRef.current
962
+ if (tl.timer === null) {
963
+ tl.timer = window.setTimeout(() => {
964
+ queryClient.invalidateQueries({ queryKey: ['changes'] })
965
+ timelineInvalidationRef.current = { timer: null }
966
+ }, 5000)
967
+ }
968
+
928
969
  // Skip K8s Event kind — informational, not resource mutations
929
970
  if (event.kind === 'Event') return
930
971
 
@@ -985,15 +1026,17 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
985
1026
  useEffect(() => () => {
986
1027
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
987
1028
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1029
+ if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
988
1030
  fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
989
1031
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1032
+ timelineInvalidationRef.current = { timer: null }
990
1033
  }, [])
991
1034
 
992
1035
  // SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
993
1036
  // forceNamespaceFilter is only set for large clusters that require server-side filtering.
994
1037
  // Fleet mode uses 'resources' topology on the backend — filtering is client-side
995
1038
  const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
996
- const { topology } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
1039
+ const { topology, connected: eventStreamConnected, connecting: eventStreamConnecting, reconnect: reconnectEventStream } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
997
1040
  onContextSwitchComplete: endSwitch,
998
1041
  onContextSwitchProgress: updateProgress,
999
1042
  onContextChanged: () => {
@@ -1006,8 +1049,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1006
1049
  // Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
1007
1050
  if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1008
1051
  if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1052
+ if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1009
1053
  fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
1010
1054
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1055
+ timelineInvalidationRef.current = { timer: null }
1011
1056
 
1012
1057
  // Close any open drawers/overlays — old cluster's resources don't exist on the new one
1013
1058
  // (?full=1 is cleared by the URL reset below).
@@ -1030,8 +1075,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1030
1075
  },
1031
1076
  onK8sEvent: handleK8sEvent,
1032
1077
  }, forceNamespaceFilter, showPolicyEffect)
1033
- const clusterConnected = connection.state === 'connected'
1034
-
1035
1078
  // On large clusters (where the server requires namespace filtering), keep
1036
1079
  // SSE's server-side filter in lockstep with the user's namespace pick.
1037
1080
  // Without this, header switches and deep-link loads can leave SSE filtered
@@ -1076,6 +1119,28 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1076
1119
  // Track CRD discovery status from topology (more direct than cluster-info)
1077
1120
  // When discovery completes, topology will auto-update via SSE with new CRD nodes
1078
1121
  const crdDiscoveryStatus = topology?.crdDiscoveryStatus
1122
+ const clusterConnectionState = connection.state
1123
+ const clusterConnected = clusterConnectionState === 'connected'
1124
+ const liveUpdatesDisconnected = clusterConnected && !eventStreamConnected && !eventStreamConnecting
1125
+ const headerConnectionLabel =
1126
+ clusterConnectionState === 'disconnected' ? 'Disconnected' :
1127
+ clusterConnectionState === 'connecting' ? 'Connecting' :
1128
+ liveUpdatesDisconnected ? 'Live updates disconnected' :
1129
+ clusterLoadState.loading ? `Connected — ${clusterLoadState.message}` :
1130
+ crdDiscoveryStatus === 'discovering' ? 'Connected — discovering Custom Resources...' :
1131
+ 'Connected'
1132
+ const headerConnectionDisplayLabel =
1133
+ clusterConnectionState === 'disconnected' ? 'Disconnected' :
1134
+ clusterConnectionState === 'connecting' ? 'Connecting' :
1135
+ liveUpdatesDisconnected ? 'Live updates disconnected' :
1136
+ showClusterWarmupLabel ? clusterLoadState.message :
1137
+ crdDiscoveryStatus === 'discovering' ? 'Discovering Custom Resources…' :
1138
+ ''
1139
+ const showHeaderReconnect =
1140
+ clusterConnectionState === 'disconnected' ||
1141
+ liveUpdatesDisconnected
1142
+ const headerReconnect = clusterConnectionState === 'disconnected' ? retryConnection : reconnectEventStream
1143
+ const headerReconnectPending = clusterConnectionState === 'disconnected' ? isRetrying : false
1079
1144
 
1080
1145
  // Debug: log discovery status changes
1081
1146
  useEffect(() => {
@@ -1516,7 +1581,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1516
1581
  content column (min-w-0, shrinkable) would fall below the old desktop
1517
1582
  floor at small windows. Embedded mode has no rail → plain 800. */}
1518
1583
  <div
1519
- className="relative flex h-screen bg-theme-base"
1584
+ className={`relative flex bg-theme-base ${navCustomization.embedded ? 'h-full min-h-0' : 'h-screen'}`}
1520
1585
  style={{ minWidth: 800 + (showNavRail ? (navRailEffectivePinned ? 176 : 56) : 0) }}
1521
1586
  >
1522
1587
  {showNavRail && (
@@ -1535,9 +1600,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1535
1600
  span the content area AFTER the rail rather than the full viewport
1536
1601
  under it. `fixed` splashes (connecting/switching) are unaffected. */}
1537
1602
  <div className="relative flex flex-col flex-1 min-w-0 h-full">
1538
- {/* Header — suppressed in chromeless embed; the host owns the chrome. */}
1603
+ {/* Header — suppressed in chromeless embed; the host owns the chrome.
1604
+ @container: the header's responsive layout keys off its OWN width (≈ viewport
1605
+ − nav rail), not the viewport, so it collapses gracefully on narrow windows.
1606
+ The AI panel docks BELOW the navbar and pushes only the content region, so the
1607
+ navbar is never squeezed by it — these thresholds react to real window width. */}
1539
1608
  {!chromeless && (
1540
- <header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
1609
+ <header className="@container relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
1541
1610
  {/* Left: Logo + Cluster info. In the standalone (nav-rail) layout this
1542
1611
  is a FIXED-WIDTH column so the omnibar after it is force-pinned: the
1543
1612
  scope pill + status dot can change width (cluster/namespace value)
@@ -1576,49 +1645,49 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1576
1645
  />
1577
1646
  </ScopePill>
1578
1647
  )}
1579
- {/* Connection status — a fixed-size dot (state in the tooltip; the
1580
- dot is the reconnect control when disconnected) plus, when the
1581
- header is wide enough (xl+), a label. The label is nowrap and
1582
- unbounded: it overflows the fixed left column into the empty gap
1583
- before the centered search box rather than shifting anything (the
1584
- dot + pill are shrink-0, so layout stays put — the search box
1585
- never moves). Where the gap is smaller than the label, its tail
1586
- tucks under the omnibar's solid background. Below xl it's the dot
1587
- alone. */}
1648
+ {/* Connection status — a fixed-size dot (state in the tooltip), an
1649
+ optional reconnect button, and when the header is wide enough
1650
+ (xl+), a label. The label is nowrap and unbounded: it overflows
1651
+ the fixed left column into the empty gap before the centered
1652
+ search box rather than shifting anything (the dot + pill are
1653
+ shrink-0, so layout stays put — the search box never moves).
1654
+ Where the gap is smaller than the label, its tail tucks under
1655
+ the omnibar's solid background. Below xl it's the dot alone
1656
+ unless reconnect is available. */}
1588
1657
  <div className="ml-1 flex items-center gap-1.5 shrink-0">
1589
1658
  <Tooltip
1590
- content={
1591
- !clusterConnected
1592
- ? 'Cluster disconnected — click to reconnect'
1593
- : crdDiscoveryStatus === 'discovering'
1594
- ? 'Connected — discovering Custom Resources...'
1595
- : 'Connected'
1596
- }
1659
+ content={headerConnectionLabel}
1597
1660
  delay={100}
1598
1661
  position="bottom"
1599
1662
  >
1600
- {clusterConnected ? (
1601
- <span
1602
- className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
1603
- crdDiscoveryStatus === 'discovering' ? 'bg-amber-400 animate-pulse' : 'bg-green-500'
1604
- }`}
1605
- />
1606
- ) : (
1663
+ <span
1664
+ className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
1665
+ clusterConnectionState === 'disconnected' || liveUpdatesDisconnected
1666
+ ? 'bg-red-500'
1667
+ : clusterConnectionState === 'connecting' || crdDiscoveryStatus === 'discovering' || clusterLoadState.loading
1668
+ ? 'bg-amber-400 animate-pulse'
1669
+ : 'bg-green-500'
1670
+ }`}
1671
+ />
1672
+ </Tooltip>
1673
+ {showNavRail && headerConnectionDisplayLabel && (
1674
+ <span className="hidden xl:flex items-center gap-1.5 whitespace-nowrap text-[11px] text-theme-text-tertiary">
1675
+ {showClusterWarmupLabel && <Loader2 className="w-3 h-3 animate-spin" />}
1676
+ {headerConnectionDisplayLabel}
1677
+ </span>
1678
+ )}
1679
+ {showHeaderReconnect && (
1680
+ <Tooltip content="Reconnect" delay={100} position="bottom">
1607
1681
  <button
1608
1682
  type="button"
1609
- onClick={retryConnection}
1610
- disabled={isRetrying}
1611
- aria-label="Cluster disconnected reconnect"
1612
- className="block shrink-0 disabled:cursor-default"
1683
+ onClick={headerReconnect}
1684
+ disabled={headerReconnectPending}
1685
+ aria-label={clusterConnectionState === 'disconnected' ? 'Reconnect cluster' : 'Reconnect live updates'}
1686
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1613
1687
  >
1614
- <span className={`block w-2.5 h-2.5 rounded-full bg-red-500 ${isRetrying ? 'animate-pulse' : ''}`} />
1688
+ <RefreshCw className={`w-3 h-3 ${headerReconnectPending ? 'animate-spin' : ''}`} />
1615
1689
  </button>
1616
- )}
1617
- </Tooltip>
1618
- {showNavRail && (!clusterConnected || crdDiscoveryStatus === 'discovering') && (
1619
- <span className="hidden xl:block whitespace-nowrap text-[11px] text-theme-text-tertiary">
1620
- {!clusterConnected ? 'Disconnected' : 'Discovering Custom Resources…'}
1621
- </span>
1690
+ </Tooltip>
1622
1691
  )}
1623
1692
  </div>
1624
1693
  {/* Port forwards indicator — shown only when sessions exist */}
@@ -1630,7 +1699,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1630
1699
  navigates via the left rail (showNavRail), so the pill bar is
1631
1700
  suppressed there to avoid a duplicate primary nav. */}
1632
1701
  {!showNavRail && (
1633
- <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
1702
+ <div className="@min-[920px]:absolute @min-[920px]:left-1/2 @min-[920px]:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 @min-[920px]:ml-0">
1634
1703
  {([
1635
1704
  { view: 'home' as const, icon: Home, label: 'Home' },
1636
1705
  { view: 'topology' as const, icon: Network, label: 'Topology' },
@@ -1677,7 +1746,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1677
1746
  off-system breakpoint chosen by measurement at the time
1678
1747
  of this PR — recompute if the cluster switcher cap or
1679
1748
  other left-section chrome changes appreciably. */}
1680
- <span className="hidden min-[1440px]:inline">{label}</span>
1749
+ <span className="hidden @min-[1264px]:inline">{label}</span>
1681
1750
  </button>
1682
1751
  </Tooltip>
1683
1752
  ))}
@@ -1692,7 +1761,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1692
1761
  "Disconnected" text). Overflowing side content truncates instead of
1693
1762
  dragging the box. Same pattern as Radar Hub's ClusterTopBar. */}
1694
1763
  {showNavRail && (
1695
- <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1764
+ <div className="hidden @min-[720px]:flex flex-1 justify-center min-w-0 px-3">
1696
1765
  <RadarOmnibar
1697
1766
  ref={omnibarRef}
1698
1767
  onNavigateView={(view) => setMainView(view)}
@@ -1733,11 +1802,14 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1733
1802
 
1734
1803
  {/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
1735
1804
  {!navCustomization.embedded && (
1736
- <div className="hidden lg:block">
1805
+ <div className="hidden @min-[1100px]:block">
1737
1806
  <GitHubStarButton />
1738
1807
  </div>
1739
1808
  )}
1740
1809
 
1810
+ {/* AI investigations (self-hides when no agent CLI is present) */}
1811
+ <GlobalDiagnoseButton />
1812
+
1741
1813
  {/* Local terminal */}
1742
1814
  {capabilities.localTerminal && (
1743
1815
  <Tooltip content="Open local terminal">
@@ -1758,7 +1830,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1758
1830
  to the host's cookie/backend) and the user would see the theme
1759
1831
  bounce on every navigation between host routes and /c/:id. */}
1760
1832
  {!navCustomization.embedded && (
1761
- <div className="hidden md:flex items-center">
1833
+ <div className="hidden @min-[920px]:flex items-center">
1762
1834
  <ThemeToggle />
1763
1835
  </div>
1764
1836
  )}
@@ -1799,6 +1871,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1799
1871
  </header>
1800
1872
  )}
1801
1873
 
1874
+ {/* Body frame — every content state lives here and reflows left of the docked
1875
+ AI panel (an absolute slot in this column). The header + nav rail are OUTSIDE
1876
+ this wrapper, so they never move when the panel opens. */}
1877
+ <div className="relative flex flex-1 flex-col min-h-0" style={{ paddingRight: contentGutter, transition: 'padding-right 0.2s ease' }}>
1878
+
1802
1879
  {/* Auth barrier - show when auth is enabled but user is not authenticated */}
1803
1880
  {authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
1804
1881
  <AuthBarrier authMode="proxy" />
@@ -1820,95 +1897,60 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1820
1897
  Icon is pane-anchored so its screen position matches the
1821
1898
  host hub splash across cross-document transitions. */}
1822
1899
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
1823
- <div className="flex-1 relative bg-theme-base">
1824
- {/* Icon absolutely anchored to the pane center. The label block
1825
- sits at a fixed offset below — independent of label height
1826
- so multi-line messages (context + progress) don't shift the
1827
- icon's screen position. */}
1828
- <div className="absolute inset-0 pointer-events-none">
1829
- <img
1830
- src={radarLoadingIcon}
1831
- alt=""
1832
- aria-hidden
1833
- // Integer offset (50% 22) avoids sub-pixel jitter from
1834
- // `translate(-50%, -50%)` on odd-width viewports.
1835
- className="absolute w-11 h-11"
1836
- style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1837
- />
1838
- <div
1839
- className="absolute left-1/2 -translate-x-1/2 text-center"
1840
- style={{ top: 'calc(50% + 34px)' }}
1841
- >
1842
- {/* 17px semibold matches the other splash surfaces so font
1843
- weight doesn't visibly swap during hub → cluster
1844
- transitions. Subtitles below stay smaller/dimmer. */}
1845
- <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
1846
- Connecting to cluster
1847
- </p>
1848
- {connection.context && (
1849
- <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
1850
- )}
1851
- {connection.progressMessage && (
1852
- <p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
1853
- {connection.progressMessage}
1854
- </p>
1855
- )}
1856
- </div>
1857
- </div>
1858
- </div>
1900
+ <PaneLoader
1901
+ label="Connecting to cluster"
1902
+ className="flex-1 min-h-0 bg-theme-base"
1903
+ >
1904
+ {connection.context && (
1905
+ <span className="mt-1 block text-sm font-normal tracking-normal text-theme-text-secondary">
1906
+ {connection.context}
1907
+ </span>
1908
+ )}
1909
+ {connection.progressMessage && (
1910
+ <span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
1911
+ {connection.progressMessage}
1912
+ </span>
1913
+ )}
1914
+ </PaneLoader>
1859
1915
  )}
1860
1916
 
1861
- {/* Context switching overlay — icon pane-anchored, label below. */}
1917
+ {/* Context switching overlay */}
1862
1918
  {isSwitching && (
1863
- <div className="flex-1 relative bg-theme-base">
1864
- <div className="absolute inset-0 pointer-events-none">
1865
- <img
1866
- src={radarLoadingIcon}
1867
- alt=""
1868
- aria-hidden
1869
- // Integer offset (50% − 22) — avoids sub-pixel jitter from
1870
- // `translate(-50%, -50%)` on odd-width viewports.
1871
- className="absolute w-11 h-11"
1872
- style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
1873
- />
1874
- <div
1875
- className="absolute left-1/2 -translate-x-1/2 text-center"
1876
- style={{ top: 'calc(50% + 34px)' }}
1877
- >
1878
- <div className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">Switching context</div>
1879
- {targetContext && (
1880
- <div className="text-xs mt-2 text-theme-text-tertiary">
1881
- {targetContext.provider ? (
1882
- <span className="flex items-center justify-center gap-1.5">
1883
- <span className="text-blue-400 font-medium">{targetContext.provider}</span>
1884
- {targetContext.account && (
1885
- <>
1886
- <span className="text-theme-text-tertiary/50">•</span>
1887
- <span>{targetContext.account}</span>
1888
- </>
1889
- )}
1890
- {targetContext.region && (
1891
- <>
1892
- <span className="text-theme-text-tertiary/50">•</span>
1893
- <span>{targetContext.region}</span>
1894
- </>
1895
- )}
1919
+ <PaneLoader
1920
+ label="Switching context"
1921
+ className="flex-1 min-h-0 bg-theme-base"
1922
+ >
1923
+ {targetContext && (
1924
+ <span className="mt-2 block text-xs font-normal tracking-normal text-theme-text-tertiary">
1925
+ {targetContext.provider ? (
1926
+ <span className="flex items-center justify-center gap-1.5">
1927
+ <span className="text-blue-400 font-medium">{targetContext.provider}</span>
1928
+ {targetContext.account && (
1929
+ <>
1896
1930
  <span className="text-theme-text-tertiary/50">•</span>
1897
- <span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
1898
- </span>
1899
- ) : (
1900
- <span>{targetContext.raw}</span>
1931
+ <span>{targetContext.account}</span>
1932
+ </>
1901
1933
  )}
1902
- </div>
1903
- )}
1904
- {progressMessage && (
1905
- <div className="text-xs mt-3 text-theme-text-tertiary animate-pulse">
1906
- {progressMessage}
1907
- </div>
1934
+ {targetContext.region && (
1935
+ <>
1936
+ <span className="text-theme-text-tertiary/50">•</span>
1937
+ <span>{targetContext.region}</span>
1938
+ </>
1939
+ )}
1940
+ <span className="text-theme-text-tertiary/50">•</span>
1941
+ <span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
1942
+ </span>
1943
+ ) : (
1944
+ <span>{targetContext.raw}</span>
1908
1945
  )}
1909
- </div>
1910
- </div>
1911
- </div>
1946
+ </span>
1947
+ )}
1948
+ {progressMessage && (
1949
+ <span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
1950
+ {progressMessage}
1951
+ </span>
1952
+ )}
1953
+ </PaneLoader>
1912
1954
  )}
1913
1955
 
1914
1956
  {/* Main content - only show when connected and authenticated */}
@@ -1922,6 +1964,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1922
1964
  <HomeView
1923
1965
  namespaces={namespaces}
1924
1966
  topology={topology}
1967
+ fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
1925
1968
  onNavigateToView={setMainView}
1926
1969
  onNavigateToResourceKind={(kind, apiGroup, filters) => {
1927
1970
  // Navigate to resources view with kind in URL path
@@ -2090,7 +2133,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2090
2133
  <TimelineView
2091
2134
  namespaces={namespaces}
2092
2135
  onResourceClick={(resource) => {
2093
- navigate(buildWorkloadPath(resource))
2136
+ navigate(relatedResourcePath(resource))
2094
2137
  }}
2095
2138
  initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
2096
2139
  initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
@@ -2141,9 +2184,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2141
2184
  // detail panel) sits behind the peek. Search-only change keeps the
2142
2185
  // pathname — and thus the peek's owner-path — intact.
2143
2186
  const params = new URLSearchParams(window.location.search)
2144
- if (params.has('workload') || params.has('tab')) {
2187
+ if (params.has('workload') || params.has('tab') || params.has('run')) {
2145
2188
  params.delete('workload')
2146
2189
  params.delete('tab')
2190
+ params.delete('run')
2147
2191
  navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
2148
2192
  }
2149
2193
  navigateToResource(resource)
@@ -2167,27 +2211,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2167
2211
  own fetches) while the cross-document nav lands. Covers checks /
2168
2212
  issues / gitops with one block since only one view is active. */}
2169
2213
  {viewTakeoverHref && (
2170
- <div className="flex-1 relative bg-theme-base">
2171
- {/* Viewport-anchored, 17px — identical to the "Connecting" splash so
2172
- the mark doesn't move or resize across the takeover hand-off. */}
2173
- <div className="absolute inset-0 pointer-events-none">
2174
- <img
2175
- src={radarLoadingIcon}
2176
- alt=""
2177
- aria-hidden
2178
- className="absolute w-11 h-11"
2179
- style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
2180
- />
2181
- <div
2182
- className="absolute left-1/2 -translate-x-1/2 text-center"
2183
- style={{ top: 'calc(50% + 34px)' }}
2184
- >
2185
- <p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
2186
- Opening…
2187
- </p>
2188
- </div>
2189
- </div>
2190
- </div>
2214
+ <PaneLoader
2215
+ label="Opening…"
2216
+ className="flex-1 min-h-0 bg-theme-base"
2217
+ />
2191
2218
  )}
2192
2219
 
2193
2220
  {/* Best practices detail view (inline only when the host hasn't taken
@@ -2217,7 +2244,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2217
2244
  {mainView === 'workload' && (
2218
2245
  <WorkloadViewRoute
2219
2246
  onNavigateToResource={(resource) => {
2220
- navigate(buildWorkloadPath(resource))
2247
+ navigate(relatedResourcePath(resource))
2221
2248
  }}
2222
2249
  />
2223
2250
  )}
@@ -2227,6 +2254,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2227
2254
 
2228
2255
  </ErrorBoundary>
2229
2256
  </div>}
2257
+ </div>{/* /body frame */}
2230
2258
 
2231
2259
  {/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
2232
2260
  Gated on contentReady so it never renders over the connecting/switching
@@ -2238,6 +2266,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2238
2266
  // No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
2239
2267
  // to the top of the content area instead of leaving a 49px gap.
2240
2268
  headerHeight={chromeless ? 0 : undefined}
2269
+ rightInset={contentGutter}
2241
2270
  isOpen={resourceDrawer.isOpen}
2242
2271
  expanded={drawerExpandedProp}
2243
2272
  onClose={closeDrawer}
@@ -2279,6 +2308,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2279
2308
  <HelmReleaseDrawer
2280
2309
  release={drawerHelmRelease}
2281
2310
  isOpen={helmDrawer.isOpen}
2311
+ rightInset={contentGutter}
2282
2312
  onClose={() => {
2283
2313
  setSelectedHelmRelease(null)
2284
2314
  const params = new URLSearchParams(window.location.search)
@@ -2298,6 +2328,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2298
2328
  />
2299
2329
  )}
2300
2330
 
2331
+ {/* AI investigation panel — an absolute slot in this column (the body frame),
2332
+ below the header and right of the nav rail, sharing the frame with the
2333
+ drawers above. Docked = right slot (pushes content via contentGutter);
2334
+ maximized = fills the frame. */}
2335
+ {diagnoseOpen && <DiagnoseSurface topInset={chromeless ? 0 : APP_HEADER_HEIGHT} />}
2336
+
2301
2337
  {/* Port Forward floating panel (indicator lives in header) */}
2302
2338
  <PortForwardPanel />
2303
2339
 
@@ -2424,14 +2460,18 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
2424
2460
  }
2425
2461
 
2426
2462
  // Main App component wrapped with providers
2427
- function App({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
2463
+ function App({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
2428
2464
  return (
2429
2465
  <ConnectionProvider>
2430
2466
  <CapabilitiesProvider>
2431
2467
  <ContextSwitchProvider>
2432
2468
  <DockProvider>
2433
2469
  <KeyboardShortcutProvider>
2434
- <AppInner manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
2470
+ <AppInner
2471
+ manageDocumentTitle={manageDocumentTitle}
2472
+ documentTitleSuffix={documentTitleSuffix}
2473
+ onClusterLoadStateChange={onClusterLoadStateChange}
2474
+ />
2435
2475
  </KeyboardShortcutProvider>
2436
2476
  </DockProvider>
2437
2477
  </ContextSwitchProvider>