@skyhook-io/radar-app 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
package/src/App.tsx CHANGED
@@ -1,56 +1,77 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { useRefreshAnimation } from './hooks/useRefreshAnimation'
3
+ import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
4
+ import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
6
+ import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
6
7
  import { HomeView } from './components/home/HomeView'
7
8
  import { DebugOverlay } from './components/DebugOverlay'
8
- import { TopologyGraph, TopologyFilterSidebar, TopologyControls } 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, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
13
+ import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
14
+ import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
9
15
  import { TimelineView } from './components/timeline/TimelineView'
10
16
  import { ResourcesView } from './components/resources/ResourcesView'
11
17
  import { serializeColumnFilters } from './components/resources/resource-utils'
12
18
  import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawer'
13
19
  import { WorkloadViewRoute } from './components/workload/WorkloadView'
20
+ import { CompareViewRoute } from './components/compare/CompareViewRoute'
14
21
  import { HelmView } from './components/helm/HelmView'
22
+ import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
15
23
  import { TrafficView } from './components/traffic/TrafficView'
16
24
  import { CostView } from './components/cost/CostView'
17
25
  import { AuditView } from './components/audit/AuditView'
26
+ import { IssuesPane } from './components/issues/IssuesPane'
27
+ import { GitOpsView } from './components/gitops/GitOpsView'
28
+ import { ApplicationsView } from './components/applications/ApplicationsView'
18
29
  import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
19
30
  import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
20
- import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
31
+ import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
21
32
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
22
33
  import { ContextSwitcher } from './components/ContextSwitcher'
34
+ import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
23
35
  import { useNavCustomization } from './context/NavCustomization'
36
+ import type { FleetTakeoverTarget } from './context/NavCustomization'
37
+ import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
38
+ import { useNavRailPinned } from './hooks/useNavRailPinned'
39
+ import { useMediaQuery } from './hooks/useMediaQuery'
24
40
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
25
41
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
26
42
  import { ConnectionErrorView } from './components/ConnectionErrorView'
27
43
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
28
44
  import { UserMenu } from './components/UserMenu'
29
45
  import { ErrorBoundary } from './components/ui/ErrorBoundary'
30
- import { NamespaceSelector } from './components/ui/NamespaceSelector'
31
46
  import { UpdateNotification } from './components/ui/UpdateNotification'
32
47
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
33
48
  import { CommandPalette } from './components/ui/CommandPalette'
34
49
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
35
50
  import { useEventSource } from './hooks/useEventSource'
36
- import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
51
+ import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
52
+ import { buildAuditSeverityMap } from './utils/auditBadges'
37
53
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
38
- import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
54
+ import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
39
55
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
40
- import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
41
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck } from 'lucide-react'
56
+ import { useDocumentTitle } from './hooks/useDocumentTitle'
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'
42
60
  import { useTheme } from './context/ThemeContext'
43
61
  import { Tooltip } from './components/ui/Tooltip'
44
62
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
45
63
  import { SettingsDialog } from './components/settings/SettingsDialog'
46
- import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
47
- import { kindToPlural, openExternal } from './utils/navigation'
64
+ import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
65
+ import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
66
+ import { type OmnibarHandle } from './components/ui/Omnibar'
67
+ import { RadarOmnibar } from './components/ui/RadarOmnibar'
68
+ import type { ContextSwitcherHandle } from './components/ContextSwitcher'
48
69
 
49
70
  // All possible node kinds (core + GitOps)
50
71
  const ALL_NODE_KINDS: NodeKind[] = [
51
72
  'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
52
73
  'Service', 'Deployment', 'Rollout', 'DaemonSet', 'StatefulSet',
53
- 'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
74
+ 'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'ServiceAccount', 'SealedSecret', 'ServiceMonitor', 'PodMonitor', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
54
75
  'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
55
76
  'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
56
77
  'Broker', 'Trigger', 'PingSource', 'ApiServerSource', 'ContainerSource', 'SinkBinding', 'Channel',
@@ -68,6 +89,12 @@ const DEFAULT_VISIBLE_KINDS = ALL_NODE_KINDS.filter(k => k !== 'ReplicaSet')
68
89
  // Users can re-enable via the filter sidebar.
69
90
  const CRD_HIDDEN_BY_DEFAULT = new Set(['GatewayClass', 'IngressClass', 'NodePool', 'NodeClaim', 'NodeClass'])
70
91
 
92
+ // Top-bar height in px. The body frame's right-side surfaces (AI panel, resource +
93
+ // Helm drawers) all inset their top by this so they sit BELOW the header. 0 in
94
+ // chromeless embeds (the host owns the chrome, no Radar header). Keep in sync with
95
+ // the <header> py/line-height; the drawers historically hardcoded the same 49.
96
+ const APP_HEADER_HEIGHT = 49
97
+
71
98
  // CAPI kinds shown in Fleet topology mode (+ Node for Machine→Node edges)
72
99
  // Includes core CAPI kinds and all infrastructure provider kinds
73
100
  const FLEET_MODE_KINDS = new Set<NodeKind>([
@@ -86,37 +113,12 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
86
113
  ])
87
114
 
88
115
  // Convert API resource name back to topology node ID prefix
89
- function apiResourceToNodeIdPrefix(apiResource: string): string {
90
- const prefixMap: Record<string, string> = {
91
- 'pods': 'pod',
92
- 'services': 'service',
93
- 'deployments': 'deployment',
94
- 'daemonsets': 'daemonset',
95
- 'statefulsets': 'statefulset',
96
- 'replicasets': 'replicaset',
97
- 'ingresses': 'ingress',
98
- 'gateways': 'gateway',
99
- 'httproutes': 'httproute',
100
- 'grpcroutes': 'grpcroute',
101
- 'tcproutes': 'tcproute',
102
- 'tlsroutes': 'tlsroute',
103
- 'configmaps': 'configmap',
104
- 'secrets': 'secret',
105
- 'horizontalpodautoscalers': 'horizontalpodautoscaler',
106
- 'jobs': 'job',
107
- 'cronjobs': 'cronjob',
108
- 'persistentvolumeclaims': 'persistentvolumeclaim',
109
- 'namespaces': 'namespace',
110
- 'httpproxies': 'httpproxy', // Contour
111
- }
112
- return prefixMap[apiResource] || apiResource.replace(/s$/, '')
113
- }
114
-
115
116
  // Extended MainView type that includes traffic and cost
116
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit'
117
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
117
118
 
118
119
  // Extract view from URL path
119
120
  function getViewFromPath(pathname: string): ExtendedMainView {
121
+ if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
120
122
  const path = pathname.replace(/^\//, '').split('/')[0]
121
123
  if (path === '' || path === 'home') return 'home'
122
124
  if (path === 'topology') return 'topology'
@@ -126,10 +128,110 @@ function getViewFromPath(pathname: string): ExtendedMainView {
126
128
  if (path === 'traffic') return 'traffic'
127
129
  if (path === 'cost') return 'cost'
128
130
  if (path === 'workload') return 'workload'
129
- if (path === 'audit') return 'audit'
131
+ if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
132
+ if (path === 'gitops') return 'gitops'
133
+ if (path === 'applications') return 'applications'
134
+ if (path === 'compare') return 'compare'
135
+ if (path === 'issues') return 'issues'
130
136
  return 'home'
131
137
  }
132
138
 
139
+ // The namespace scope filter is meaningful only on namespaced surfaces. On
140
+ // cluster-scoped views it does nothing, so we disable it with an explanation
141
+ // rather than leaving a dead control that silently ignores the pick:
142
+ // - Cost is reported per-namespace across the whole cluster (the view IS the
143
+ // breakdown; a filter would only hide rows).
144
+ // - A GitOps detail tree spans namespaces — its controller lives in one
145
+ // namespace but manages workloads across many.
146
+ // - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
147
+ // namespace at all.
148
+ // The pick itself is preserved so it re-applies when the user returns to a
149
+ // namespaced view.
150
+ function namespaceFilterDisabled(
151
+ view: ExtendedMainView,
152
+ pathname: string,
153
+ search = '',
154
+ apiResources?: APIResource[],
155
+ ): { disabled: boolean; tooltip?: string } {
156
+ if (
157
+ view === 'cost' &&
158
+ !pathname.startsWith('/cost/rightsizing')
159
+ ) {
160
+ return {
161
+ disabled: true,
162
+ tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
163
+ }
164
+ }
165
+ const segments = pathname.replace(/^\//, '').split('/')
166
+ if (view === 'gitops' && segments[1] === 'detail') {
167
+ return {
168
+ disabled: true,
169
+ tooltip: 'This resource manages workloads across namespaces — the namespace filter doesn’t apply to its tree.',
170
+ }
171
+ }
172
+ if (view === 'resources') {
173
+ const kindSlug = segments[1]
174
+ const group = new URLSearchParams(search).get('apiGroup') || ''
175
+ const match = kindSlug ? findAPIResourceForRoute(apiResources, kindSlug, group) : undefined
176
+ if (match && !match.namespaced) {
177
+ return {
178
+ disabled: true,
179
+ tooltip: `${match.kind} is a cluster-scoped resource — namespaces don’t apply.`,
180
+ }
181
+ }
182
+ }
183
+ return { disabled: false }
184
+ }
185
+
186
+ // Browser tab label for every Radar view, derived from the route URL so it's
187
+ // correct regardless of which component renders it. A detail drawer that opens
188
+ // over a list (?resource=…) is deliberately NOT titled — it's the same page, so
189
+ // it keeps the list's title.
190
+ function radarPageTitle(pathname: string, search = '', apiResources?: APIResource[]): string | null {
191
+ const decode = (s: string) => {
192
+ try {
193
+ return decodeURIComponent(s)
194
+ } catch {
195
+ return s
196
+ }
197
+ }
198
+ const capitalize = (text: string) =>
199
+ text ? text.charAt(0).toUpperCase() + text.slice(1) : text
200
+ const pluralKindTitle = (kind: string, resourceName: string) =>
201
+ kind.toLowerCase() === resourceName.toLowerCase() || /Metrics$/.test(kind) ? kind : englishPlural(kind)
202
+ const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
203
+ const view = getViewFromPath(pathname)
204
+
205
+ // Full-page resource detail: /workload/<kind>/<ns>/<name> (name may contain '/').
206
+ if (view === 'workload') return pathSegments.slice(3).map(decode).join('/') || null
207
+ // Resources is browsed per-kind: /resources/<kind> → "<Kind>" (e.g. ConfigMap);
208
+ // bare /resources (before it redirects to a default kind) → "Resources".
209
+ if (view === 'resources') {
210
+ const resourceName = decode(pathSegments[1] ?? '')
211
+ if (!resourceName) return 'Resources'
212
+ const group = new URLSearchParams(search).get('apiGroup') || ''
213
+ const match = findAPIResourceForRoute(apiResources, resourceName, group)
214
+ return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
215
+ }
216
+ // GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
217
+ // anything else (the list) → "GitOps".
218
+ if (view === 'gitops')
219
+ return pathSegments[1] === 'detail' ? decode(pathSegments[4] ?? '') || 'GitOps' : 'GitOps'
220
+ if (view === 'applications') {
221
+ const appKey = new URLSearchParams(search).get('app')
222
+ if (!appKey) return 'Applications'
223
+ const decoded = decode(appKey)
224
+ const slash = decoded.lastIndexOf('/')
225
+ return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
226
+ }
227
+
228
+ // The landing view reads "Overview" rather than "Home" in the tab.
229
+ if (view === 'home') return 'Overview'
230
+ // Every other view's label is its id capitalized — getViewFromPath has already
231
+ // normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
232
+ return capitalize(view)
233
+ }
234
+
133
235
  function AuthBarrier({ authMode }: { authMode: string }) {
134
236
  useEffect(() => {
135
237
  if (authMode === 'oidc') {
@@ -139,12 +241,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
139
241
 
140
242
  if (authMode === 'oidc') {
141
243
  return (
142
- <div className="flex-1 flex items-center justify-center bg-theme-base">
143
- <div className="flex flex-col items-center gap-4">
144
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
145
- <p className="text-sm text-theme-text-secondary">Redirecting to login…</p>
146
- </div>
147
- </div>
244
+ <PaneLoader
245
+ label="Redirecting to login…"
246
+ className="flex-1 min-h-0 bg-theme-base"
247
+ />
148
248
  )
149
249
  }
150
250
 
@@ -168,13 +268,77 @@ function AuthBarrier({ authMode }: { authMode: string }) {
168
268
  )
169
269
  }
170
270
 
171
- function AppInner() {
271
+ // Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
272
+ // is not enough: Applications keeps the list and an app's detail on the same
273
+ // `/applications` pathname and distinguishes them with `?app=`, so a Back from
274
+ // detail to list would otherwise leave the peek orphaned. Only `app` is included
275
+ // (not the whole query) so filter/tab/namespace churn doesn't close the peek.
276
+ function peekOwnerKey(pathname: string, search: string): string {
277
+ return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
278
+ }
279
+
280
+ interface AppProps {
281
+ manageDocumentTitle?: boolean
282
+ documentTitleSuffix?: string
283
+ onClusterLoadStateChange?: (state: ClusterLoadState) => void
284
+ }
285
+
286
+ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
172
287
  const navigate = useNavigate()
173
288
  const location = useLocation()
289
+ const navigationType = useNavigationType()
174
290
  const [searchParams, setSearchParams] = useSearchParams()
175
291
  const capabilities = useCapabilitiesContext()
176
292
  const openLocalTerminal = useOpenLocalTerminal()
177
293
  const navCustomization = useNavCustomization()
294
+ // The AI panel is an absolute slot in the body frame (the column under the header):
295
+ // it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
296
+ // static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
297
+ const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
298
+ // Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
299
+ // cross-tree swap) navigates same-document so the chrome morphs instead of
300
+ // cold-booting; without it we fall back to a hard `window.location` nav.
301
+ const goHost = useCallback(
302
+ (url: string) => {
303
+ if (navCustomization.onHostNavigate) navCustomization.onHostNavigate(url)
304
+ else window.location.assign(url)
305
+ },
306
+ [navCustomization],
307
+ )
308
+ // Resolve every host-takeover URL ONCE (memoized on navCustomization) so the
309
+ // setMainView intercept, redirect effect, nav-pill filtering, inline-view
310
+ // gating, and the cert click handler all consume the SAME value — host
311
+ // callbacks aren't guaranteed idempotent (scope / flags / signed URLs can
312
+ // shift between calls). undefined = not taken over → Radar renders the view
313
+ // itself. `clusterChecksHref` is the deprecated pre-1.7 hook, folded into the
314
+ // 'checks' target for back-compat.
315
+ const takeover: Record<FleetTakeoverTarget, string | undefined> = useMemo(
316
+ () => ({
317
+ issues: navCustomization.fleetTakeoverHref?.('issues'),
318
+ gitops: navCustomization.fleetTakeoverHref?.('gitops'),
319
+ checks: navCustomization.fleetTakeoverHref?.('checks') ?? navCustomization.clusterChecksHref?.(),
320
+ certs: navCustomization.fleetTakeoverHref?.('certs'),
321
+ }),
322
+ [navCustomization],
323
+ )
324
+ const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
325
+ // Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
326
+ // the left chrome via their own fleet rail and keep Radar's top-bar pills.
327
+ const showNavRail = !navCustomization.embedded
328
+ // Chromeless embed: the host (Radar Hub) owns ALL chrome and drives view
329
+ // navigation + scope from its own UI, so Radar renders just the active view's
330
+ // content — no top bar, no view-switcher. Used for per-cluster views surfaced
331
+ // as native cloud destinations behind a cluster picker.
332
+ const chromeless = navCustomization.embedded === true && navCustomization.chrome === 'none'
333
+ // Force the slim rail on narrow windows: a pinned 176px rail needs viewport
334
+ // ≥976 to keep content above its ~800px floor (collapsed needs only ≥856).
335
+ // Below 976 we render collapsed regardless of the pin preference — a
336
+ // temporary responsive override that does NOT touch the persisted value, so
337
+ // the user's pinned state returns when they widen again. Fly-out labels cover
338
+ // the collapsed state, so the manual toggle is hidden here rather than left
339
+ // inert (expanding would just re-breach the floor).
340
+ const railForcedSlim = useMediaQuery('(max-width: 975px)')
341
+ const navRailEffectivePinned = navRailPinned && !railForcedSlim
178
342
 
179
343
  // Auth check — detect if auth is enabled but user is not authenticated
180
344
  const { data: authMe, isPending: authMePending } = useAuthMe()
@@ -217,6 +381,22 @@ function AppInner() {
217
381
  // Get mainView from URL path
218
382
  const mainView = getViewFromPath(location.pathname)
219
383
 
384
+ // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
385
+ // mount) so the omnibar can open a CRD hit with an irregular plural from any
386
+ // view — kindToPlural would otherwise English-guess the route before a
387
+ // resources view has run initNavigationMap().
388
+ const { data: navApiResources } = useAPIResources()
389
+ useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
390
+
391
+ // View-aware namespace scope: disabled on cluster-scoped surfaces so the
392
+ // chip isn't a dead control next to the cluster switcher.
393
+ const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, location.search, navApiResources)
394
+
395
+ // One URL-derived tab title for every view (see radarPageTitle). Driving it
396
+ // from the URL — not the mounted component. Off unless the host opts in
397
+ // (standalone passes manageDocumentTitle), so embedders keep title ownership.
398
+ useDocumentTitle(manageDocumentTitle ? radarPageTitle(location.pathname, location.search, navApiResources) : null, documentTitleSuffix)
399
+
220
400
  // Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
221
401
  const normalizedResourcesKindSlug = useMemo(() => {
222
402
  const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
@@ -236,6 +416,20 @@ function AppInner() {
236
416
 
237
417
  // Set mainView by navigating to the path
238
418
  const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
419
+ // Host takeover: fleet-shaped views (issues/gitops/checks) are owned by the
420
+ // host's fleet pages. Hand straight to the host instead of navigating to
421
+ // our own /<view> first — that intermediate hop mounts the view machinery
422
+ // and flashes the "Opening…" splash before the redirect effect bounces out.
423
+ // Skipping it makes the hand-off a single smooth cross-tree swap. (Direct
424
+ // /<view> URL entry still funnels through the redirect effect below.)
425
+ if (view === 'issues' || view === 'gitops' || view === 'checks') {
426
+ const href = takeover[view]
427
+ if (href) {
428
+ goHost(href)
429
+ return
430
+ }
431
+ }
432
+
239
433
  const path = view === 'home' ? '/' : `/${view}`
240
434
 
241
435
  // Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
@@ -253,7 +447,31 @@ function AppInner() {
253
447
  }
254
448
 
255
449
  navigate({ pathname: path, search: newParams.toString() })
256
- }, [navigate, searchParams])
450
+ }, [navigate, searchParams, takeover, goHost])
451
+
452
+ // Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
453
+ // own fleet pages scoped to this cluster — owned by the host's left rail — so
454
+ // Radar drops the matching pills (see the nav below). In-app nav hands off in
455
+ // setMainView (above); direct /<view> URL entry funnels through the redirect
456
+ // effect below. Both consume the memoized `takeover` resolved above. Standalone
457
+ // OSS (no fleetTakeoverHref) is unaffected and renders the in-app view.
458
+ //
459
+ // Has the host claimed this view? View-shaped targets only ('certs' has no
460
+ // Radar view — only its Home card consults `takeover`). Used to drop the nav
461
+ // pill and gate the inline view render in favor of the "Opening…" splash.
462
+ const isViewTakenOver = (view: ExtendedMainView): boolean =>
463
+ (view === 'issues' || view === 'gitops' || view === 'checks') && !!takeover[view]
464
+ // The host's URL for the CURRENT view, if taken over. Drives the redirect
465
+ // effect and the "Opening…" splash.
466
+ const viewTakeoverHref =
467
+ mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
468
+ ? takeover[mainView]
469
+ : undefined
470
+ useEffect(() => {
471
+ if (viewTakeoverHref) {
472
+ window.location.replace(viewTakeoverHref)
473
+ }
474
+ }, [viewTakeoverHref])
257
475
 
258
476
  const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
259
477
  // For large clusters: force SSE to reconnect with namespace filter
@@ -267,6 +485,12 @@ function AppInner() {
267
485
  // Topology filter state
268
486
  const [visibleKinds, setVisibleKinds] = useState<Set<NodeKind>>(() => new Set(DEFAULT_VISIBLE_KINDS))
269
487
  const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
488
+ // Topology node-search → canvas focus request (nonce lets the same node re-focus)
489
+ const [topologyFocus, setTopologyFocus] = useState<{ id: string; nonce: number } | null>(null)
490
+ // The topology pane element — the search overlay portals into it so its
491
+ // backdrop dims only the pane (not the app) and stays clickable. Callback-ref
492
+ // state so it updates once the pane mounts.
493
+ const [topologyPane, setTopologyPane] = useState<HTMLDivElement | null>(null)
270
494
  // Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
271
495
  const seededCRDKindsRef = useRef<Set<string>>(new Set())
272
496
 
@@ -284,15 +508,6 @@ function AppInner() {
284
508
  // Settings dialog state
285
509
  const [showSettings, setShowSettings] = useState(false)
286
510
 
287
- // Listen for desktop "open-settings" event from native menu
288
- useEffect(() => {
289
- const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
290
- | { EventsOn?: (event: string, callback: () => void) => () => void }
291
- | undefined
292
- if (!wailsRuntime?.EventsOn) return
293
- return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
294
- }, [])
295
-
296
511
  // Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
297
512
  useEffect(() => {
298
513
  const handler = () => setShowSettings(true)
@@ -300,43 +515,119 @@ function AppInner() {
300
515
  return () => window.removeEventListener('radar:open-settings', handler)
301
516
  }, [])
302
517
 
518
+ // Listen for "open-local-terminal" DOM event — the AI surface is portaled above
519
+ // the DockProvider, so it can't call useOpenLocalTerminal directly; it dispatches
520
+ // this instead (mirrors the open-settings pattern).
521
+ useEffect(() => {
522
+ const handler = (e: Event) => {
523
+ const { command, title } = (e as CustomEvent).detail ?? {}
524
+ openLocalTerminal({ initialCommand: command, title })
525
+ }
526
+ window.addEventListener('radar:open-local-terminal', handler)
527
+ return () => window.removeEventListener('radar:open-local-terminal', handler)
528
+ }, [openLocalTerminal])
529
+
303
530
  // Diagnostics overlay state
304
531
  const [showDiagnostics, setShowDiagnostics] = useState(false)
305
532
 
306
- // Drawer expanded state (drawer grows to full width and renders WorkloadView)
307
- const [drawerExpanded, setDrawerExpanded] = useState(false)
308
-
309
- // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
310
- const suppressViewClearRef = useRef(false)
533
+ // The peek drawer "expanded" into a fullscreen overlay = ?full=1 with a selected
534
+ // resource, on ANY view (resources list, topology graph, GitOps, Applications…)
535
+ // the underlying view stays mounted. URL-derived so Back/Forward/refresh behave
536
+ // (non-list peeks aren't URL-backed, so refresh drops the overlay gracefully).
537
+ // Used by the routing effects below; the render uses `expandedView` (gated on what
538
+ // actually renders) — see further down.
539
+ const drawerExpanded = !!selectedResource && searchParams.get('full') === '1'
540
+
541
+ // On mobile there's no room for the side drawer — a resource detail is always full-screen.
542
+ const isMobile = useMediaQuery('(max-width: 639px)')
543
+
544
+ // On a history Pop (back/forward) the URL is authoritative. The URL-write
545
+ // effect, running with not-yet-synced state, would otherwise write the stale
546
+ // state back and revert the Pop — and oscillate with the URL→state read
547
+ // effect (infinite re-render, React #185, blank page). Suppress the writer
548
+ // for the synchronous reconciliation burst after a Pop, then auto-clear (see
549
+ // the arming effect) so later user-driven writes are never affected.
550
+ const skipUrlWriteAfterPopRef = useRef(false)
551
+
552
+ // Close resource drawer when the /resources route no longer matches the
553
+ // selected drawer resource. This covers both in-view kind switches and
554
+ // cross-kind navigations from expanded drawers (for example Node -> View Pods).
555
+ const prevResourcesKindKeyRef = useRef<string | null>(null)
556
+ // Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
557
+ // navigateToResource and peekOwnerKey.
558
+ const peekOwnerKeyRef = useRef<string | null>(null)
559
+ const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
560
+ const currentResourceGroup = searchParams.get('apiGroup') ?? ''
561
+ const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
562
+ const selectedResourceGroup = selectedResource?.group ?? ''
563
+ const selectedResourceRouteMismatch = mainView === 'resources' && !!selectedResource && (
564
+ selectedResourceKindSlug !== currentResourceKindSlug ||
565
+ selectedResourceGroup !== currentResourceGroup
566
+ )
567
+ const resourcesKindRouteChanged = mainView === 'resources' &&
568
+ prevResourcesKindKeyRef.current !== null &&
569
+ prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
570
+
571
+ // A peek opened outside /resources (topology, GitOps, Applications) carries no
572
+ // URL backing, so the only signal that the page beneath it has navigated is
573
+ // that its owner-key (pathname + ?app) no longer matches where it was opened.
574
+ // Hiding it here, at render time, closes the orphan on Back without adding
575
+ // another clearing effect. The /resources case is URL-backed and handled above;
576
+ // an expanded drawer (drawerExpanded) only exists on /resources (?full=1), so it
577
+ // is excluded here and never treated as an orphan.
578
+ const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
579
+ peekOwnerKeyRef.current !== null &&
580
+ peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
581
+
582
+ // In Applications the inline WorkloadView (?workload) and the peek drawer are
583
+ // mutually exclusive — never two detail surfaces at once. ?workload is the
584
+ // single source of truth: while it's set the peek yields to the inline view.
585
+ // (Opening a child peek from Applications clears ?workload, see onOpenResource.)
586
+ const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
587
+
588
+ const routeSelectedResource =
589
+ (resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
590
+ ? null
591
+ : selectedResource
311
592
 
312
- // Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
313
- // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
314
- const prevResourcesKindSlugRef = useRef<string | null>(null)
315
593
  useEffect(() => {
316
594
  if (mainView !== 'resources') {
317
- prevResourcesKindSlugRef.current = null
595
+ prevResourcesKindKeyRef.current = null
318
596
  return
319
597
  }
320
- const slug = normalizedResourcesKindSlug
321
- const prev = prevResourcesKindSlugRef.current
322
- prevResourcesKindSlugRef.current = slug
323
- if (prev !== null && prev !== slug) {
598
+ const key = `${currentResourceGroup}/${currentResourceKindSlug}`
599
+ const prev = prevResourcesKindKeyRef.current
600
+ prevResourcesKindKeyRef.current = key
601
+
602
+ if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
324
603
  setSelectedResource(null)
325
- setDrawerExpanded(false)
326
604
  }
327
- }, [mainView, normalizedResourcesKindSlug])
605
+ }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
328
606
 
329
607
  // Animation hooks for smooth mount/unmount transitions
330
- const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
608
+ const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
331
609
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
332
610
  const helpOverlay = useAnimatedUnmount(showHelp, 300)
333
611
  const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
334
612
  const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
335
613
 
336
614
  // Hold last valid values so drawers can animate out before data disappears
337
- const lastResourceRef = useRef(selectedResource)
338
- if (selectedResource) lastResourceRef.current = selectedResource
339
- const drawerResource = selectedResource || lastResourceRef.current
615
+ const lastResourceRef = useRef(routeSelectedResource)
616
+ if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
617
+ const drawerResource = routeSelectedResource || lastResourceRef.current
618
+
619
+ // Effective fullscreen state — keyed off the resource that's ACTUALLY rendering
620
+ // (routeSelectedResource), not the raw selection, so an orphaned/mismatched peek
621
+ // can't inert the shell with no visible drawer. ?full=1 on any view, or forced on
622
+ // mobile (no room for a side drawer). Drives the inert backdrop + shortcut suppression.
623
+ const expandedView = !!routeSelectedResource && (searchParams.get('full') === '1' || isMobile)
624
+ useSuppressBaseShortcuts(expandedView)
625
+ // Held value for the drawer's `expanded` prop so closing an expanded drawer slides
626
+ // it out at full size instead of running a collapse morph mid-dismiss. Tracks the
627
+ // live state while a resource is selected; frozen during the slide-out.
628
+ const lastExpandedRef = useRef(expandedView)
629
+ if (routeSelectedResource) lastExpandedRef.current = expandedView
630
+ const drawerExpandedProp = routeSelectedResource ? expandedView : lastExpandedRef.current
340
631
 
341
632
  const lastHelmReleaseRef = useRef(selectedHelmRelease)
342
633
  if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
@@ -344,20 +635,116 @@ function AppInner() {
344
635
 
345
636
  // Navigate to a resource — uses View Transitions cross-fade when drawer is already open
346
637
  const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
638
+ // Record the page this peek was opened on. Outside /resources the drawer is
639
+ // not URL-backed, so this ref is what lets the render-time gate below close
640
+ // the peek when the page under it changes (e.g. browser Back off a GitOps
641
+ // detail page, or Applications detail → list via ?app). window.location is
642
+ // read (not the `location` closure) so the value is always current
643
+ // regardless of this callback's memoization.
644
+ peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
347
645
  const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
348
- if (selectedResource && document.startViewTransition) {
349
- document.startViewTransition(() => flushSync(update))
646
+ // Skip the cross-fade animation entirely on first open (no
647
+ // `selectedResource`); otherwise route through
648
+ // startViewTransitionSafe to swallow the InvalidStateError that
649
+ // the API rejects with on rapid back-to-back navigations.
650
+ // (SKY-833 bug 49)
651
+ if (selectedResource) {
652
+ startViewTransitionSafe(() => flushSync(update))
350
653
  } else {
351
654
  update()
352
655
  }
353
656
  }, [selectedResource])
354
657
 
355
- // Collapse from expanded WorkloadView back to drawer
658
+ // Navigate from a detector finding (Audit / Issues) to the resources list for
659
+ // its kind, opening the resource. Shared by both queues — the body was
660
+ // duplicated verbatim at each render site. Encodes the opened resource in the
661
+ // URL (?resource=ns/name) — the same deep-link shape the resources view
662
+ // round-trips — so refresh/share keeps the drawer open instead of dropping it.
663
+ const navigateToResourceList = useCallback((resource: SelectedResource) => {
664
+ const pluralKind = kindToPlural(resource.kind)
665
+ setSelectedResource({ ...resource, kind: pluralKind })
666
+ const newParams = new URLSearchParams(searchParams)
667
+ newParams.delete('kind')
668
+ newParams.delete('mode')
669
+ newParams.delete('group')
670
+ // Open as a normal drawer — never inherit a stale ?full=1/tab from an
671
+ // expanded view we're navigating away from (only expand/drill set those).
672
+ newParams.delete('full')
673
+ newParams.delete('tab')
674
+ newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
675
+ if (resource.group) {
676
+ newParams.set('apiGroup', resource.group)
677
+ } else {
678
+ newParams.delete('apiGroup')
679
+ }
680
+ navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
681
+ }, [searchParams, navigate])
682
+
683
+ const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
684
+ const newParams = new URLSearchParams()
685
+ const globalNamespaces = searchParams.get('namespaces')
686
+ if (globalNamespaces) {
687
+ newParams.set('namespaces', globalNamespaces)
688
+ }
689
+ newParams.set('release', `${namespace}/${name}`)
690
+ if (storageNamespace) {
691
+ newParams.set('releaseStorage', storageNamespace)
692
+ }
693
+ setSelectedHelmRelease({ namespace, name, storageNamespace })
694
+ if (mainView === 'helm') {
695
+ setSearchParams(newParams, { replace: true })
696
+ return
697
+ }
698
+ navigate({ pathname: '/helm', search: newParams.toString() })
699
+ }, [mainView, searchParams, navigate, setSearchParams])
700
+
701
+ // From the Issues queue: special controller/manager subjects route to their
702
+ // rich detail pages, not the generic resource drawer that's a dead-end for
703
+ // them. Member resources (Pods, Services, …) fall through to resources.
704
+ const navigateFromIssue = useCallback((resource: SelectedResource) => {
705
+ if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
706
+ navigateToHelmRelease(resource.namespace, resource.name)
707
+ return
708
+ }
709
+ const gitOpsPath = gitOpsRouteForResource({
710
+ apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
711
+ kind: resource.kind,
712
+ metadata: { namespace: resource.namespace ?? '', name: resource.name },
713
+ })
714
+ if (gitOpsPath) {
715
+ navigate(gitOpsPath)
716
+ return
717
+ }
718
+ navigateToResourceList(resource)
719
+ }, [navigate, navigateToHelmRelease, navigateToResourceList])
720
+
721
+ // Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
722
+ // resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
723
+ // regardless of how we got here (expand, deep link, or a drill trail), so it
724
+ // scrubs rather than walking history — `navigate(-1)` would leave the app on a
725
+ // deep link, or step back to the previous resource after a drill. Browser Back
726
+ // keeps its own natural history walk (it pops the ?full=1 entry → collapse).
356
727
  const handleCollapseFromExpanded = useCallback(() => {
357
- suppressViewClearRef.current = true
358
- setDrawerExpanded(false)
359
- navigate(-1)
360
- }, [navigate])
728
+ const p = new URLSearchParams(searchParams)
729
+ p.delete('full')
730
+ p.delete('tab')
731
+ setSearchParams(p, { replace: true })
732
+ }, [searchParams, setSearchParams])
733
+
734
+ // Close the peek and drop any expand flags. Outside /resources the drawer isn't
735
+ // URL-backed, so a lingering ?full=1/tab would make the next peek reopen
736
+ // fullscreen instead of as a side drawer. (On /resources, ResourcesView's own
737
+ // updateURL also scrubs these — deleting them here too is idempotent.)
738
+ const closeDrawer = useCallback(() => {
739
+ setSelectedResource(null)
740
+ setDrawerInitialTab('detail')
741
+ if (searchParams.has('full') || searchParams.has('tab')) {
742
+ const p = new URLSearchParams(searchParams)
743
+ p.delete('full')
744
+ p.delete('tab')
745
+ setSearchParams(p, { replace: true })
746
+ }
747
+ }, [searchParams, setSearchParams])
361
748
 
362
749
  // Theme toggle for keyboard shortcut
363
750
  const { toggleTheme } = useTheme()
@@ -365,17 +752,55 @@ function AppInner() {
365
752
  // Context switching for command palette
366
753
  const switchContext = useSwitchContext()
367
754
 
755
+ // Refs for dropdown components to trigger them via shortcuts
756
+ const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
757
+ const omnibarRef = useRef<OmnibarHandle>(null)
758
+
759
+ const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
760
+
368
761
  // View switching keyboard shortcuts
369
- const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
762
+ // `g`+mnemonic sequences cover every view. Numeric 1–N can't: there are 11
763
+ // views and only 9 single digits, so `10`/`11` never match a keypress (a
764
+ // KeyboardEvent.key is one character). `g`-prefixed mnemonics scale, are the
765
+ // GitHub/Linear convention, and their second keys are all distinct (no clash
766
+ // with the scoped `g g` table shortcut). The letters are fixed regardless of
767
+ // position, so reordering the rail never changes a shortcut.
768
+ const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
769
+ home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
770
+ applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
771
+ gitops: 'g o', checks: 'g u', cost: 'g c',
772
+ // Non-rail views (reachable via deep links / actions, not the rail) get no
773
+ // dedicated mnemonic — listed for exhaustiveness so the type stays total.
774
+ workload: '', compare: '', helmCompare: '',
775
+ }
776
+ const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
777
+ (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
778
+ )
370
779
  useRegisterShortcuts([
371
- ...views.map((view, i) => ({
780
+ ...views.map((view) => ({
372
781
  id: `view-${view}`,
373
- keys: String(i + 1),
782
+ keys: VIEW_SHORTCUT_KEYS[view],
374
783
  description: `Go to ${view.charAt(0).toUpperCase() + view.slice(1)}`,
375
784
  category: 'Navigation' as const,
376
785
  scope: 'global' as const,
377
786
  handler: () => setMainView(view),
378
787
  })),
788
+ {
789
+ id: 'switch-namespace',
790
+ keys: 'n',
791
+ description: 'Switch namespace',
792
+ category: 'Navigation' as const,
793
+ scope: 'global' as const,
794
+ handler: () => namespaceSwitcherRef.current?.open(),
795
+ },
796
+ {
797
+ id: 'switch-context',
798
+ keys: 'c',
799
+ description: 'Switch context',
800
+ category: 'Navigation' as const,
801
+ scope: 'global' as const,
802
+ handler: () => contextSwitcherRef.current?.open(),
803
+ },
379
804
  {
380
805
  id: 'theme-toggle',
381
806
  keys: 't',
@@ -390,16 +815,23 @@ function AppInner() {
390
815
  description: 'Show keyboard shortcuts',
391
816
  category: 'General' as const,
392
817
  scope: 'global' as const,
818
+ // Radar owns the shortcut registry even in a chromeless embed, so its `?`
819
+ // overlay is the one that actually lists the working shortcuts. The host
820
+ // (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
821
+ // it has no registry of its own to populate a competing overlay with.
393
822
  handler: () => setShowHelp(prev => !prev),
394
823
  },
395
824
  {
396
825
  id: 'command-palette',
397
826
  keys: 'Cmd+k',
398
- description: 'Open command palette',
827
+ description: 'Search resources & commands',
399
828
  category: 'General' as const,
400
829
  scope: 'global' as const,
401
830
  allowInInputs: true,
402
- handler: () => setShowCommandPalette(true),
831
+ // Standalone focuses the top-center omnibar; embedded opens the modal. In
832
+ // a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
833
+ // otherwise both the host omnibar and Radar's palette fire on one ⌘K.
834
+ handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
403
835
  },
404
836
  {
405
837
  id: 'diagnostics',
@@ -410,6 +842,20 @@ function AppInner() {
410
842
  allowInInputs: true,
411
843
  handler: () => setShowDiagnostics(prev => !prev),
412
844
  },
845
+ // Settings exposes local-binary controls that don't apply to embedded hosts.
846
+ // Register the shortcut only when standalone (matching the gear button) —
847
+ // `enabled: false` would still list it in the `?` help overlay, which shows
848
+ // all registered shortcuts regardless of enabled state.
849
+ ...(showNavRail
850
+ ? [{
851
+ id: 'open-settings',
852
+ keys: 'g s',
853
+ description: 'Open settings',
854
+ category: 'General' as const,
855
+ scope: 'global' as const,
856
+ handler: () => setShowSettings(true),
857
+ }]
858
+ : []),
413
859
  ])
414
860
 
415
861
  // Separate registration for help-close — its `enabled` changes with showHelp,
@@ -446,7 +892,12 @@ function AppInner() {
446
892
  const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
447
893
 
448
894
  // Fetch available namespaces
449
- const { data: availableNamespaces, error: namespacesError } = useNamespaces()
895
+ const { data: availableNamespaces } = useNamespaces()
896
+
897
+ // Per-user view filter served by the backend. Loaded eagerly so the
898
+ // picker can render its current state without showing the multi-select
899
+ // fallback during the initial scope fetch.
900
+ const { data: namespaceScope } = useNamespaceScope()
450
901
 
451
902
  // Context switch state
452
903
  const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
@@ -454,56 +905,143 @@ function AppInner() {
454
905
  // Connection state (for graceful startup)
455
906
  const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
456
907
 
908
+ // The app's content surface is ready to show: auth resolved, not mid context-
909
+ // switch, and the cluster connection is live. The main content area gates on
910
+ // exactly this, and so do the overlay drawers — otherwise a deep-link/refresh
911
+ // with `?resource=`/`?release=` renders the drawer on top of the connecting/
912
+ // switching splash, pushing the centered loading logo off-center and showing an
913
+ // empty drawer over a not-yet-loaded view. Gating both on the SAME readiness so
914
+ // a drawer only ever sits over a real content surface.
915
+ const contentReady = !isSwitching && !authMePending &&
916
+ !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
917
+
918
+ const { clusterLoadState, showHomeClusterLoadFallback, clusterLoadInitial } = useClusterLoadState({
919
+ namespaces,
920
+ mainView,
921
+ chromeless,
922
+ contentReady,
923
+ onClusterLoadStateChange,
924
+ })
925
+ // Suppress the topbar warmup label during the initial dashboard fetch only on
926
+ // Home, where the center "Loading dashboard…" splash already covers it. Off
927
+ // Home there's no splash, so keep the label as the only text cue.
928
+ const showClusterWarmupLabel = clusterLoadState.loading && !(clusterLoadInitial && mainView === 'home')
929
+
457
930
  // Query client for cache invalidation
458
931
  const queryClient = useQueryClient()
459
932
 
460
- // SSE-driven cache invalidation for resource lists, counts, and detail views.
461
- // Uses a 3-second throttle window: first event starts the timer, all events within the
462
- // window accumulate, then fire a single batch invalidation. This keeps max latency at 3s
463
- // while coalescing burst events (e.g., 100-pod rollout ~10 invalidations total).
464
- const pendingInvalidationRef = useRef<{
465
- kinds: Set<string>
466
- hasCountChange: boolean
933
+ // SSE-driven cache invalidation, split into two cadences so constant status
934
+ // churn on large clusters doesn't force the *expensive* queries (big resource
935
+ // lists + dashboard) to refetch every 3s. The core distinction: add/delete
936
+ // changes what rows/counts exist (membership keep fast); update is mostly
937
+ // status/restart/health noise that can fire constantly on a 10k-pod cluster
938
+ // and shouldn't drag a giant list onto a 3s cadence.
939
+ //
940
+ // FAST (3s): detail drawer for any change (one cheap mounted object), and
941
+ // on add/delete: the list, counts, and dashboard. GitOps + cert keep
942
+ // their existing every-batch behavior — Phase 2 makes GitOps relevance-aware.
943
+ // SLOW (15s): list + dashboard for kinds with update churn. A kind that also
944
+ // had an add/delete in the window gets refreshed by both tiers (an extra
945
+ // refetch per 15s at most) — that's fine and avoids a stale-list bug:
946
+ // deduping by "was structural this window" would wrongly suppress an
947
+ // update that arrived *after* the fast structural flush already ran.
948
+ const fastInvalidationRef = useRef<{
949
+ changedKinds: Set<string> // every changed kind (any op) → detail drawer
950
+ structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
951
+ secretsChanged: boolean
952
+ timer: number | null
953
+ }>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
954
+ const slowInvalidationRef = useRef<{
955
+ updatedKinds: Set<string> // update-only churn → throttled list + dashboard
467
956
  timer: number | null
468
- }>({ kinds: new Set(), hasCountChange: false, timer: null })
957
+ }>({ updatedKinds: new Set(), timer: null })
958
+ const timelineInvalidationRef = useRef<{ timer: number | null }>({ timer: null })
469
959
 
470
960
  const handleK8sEvent = useCallback((event: K8sEvent) => {
961
+ // The timeline consumes every frame — including the K8s Event kind the
962
+ // resource tiers skip below (warnings like BackOff are timeline content).
963
+ // Its own trailing throttle keeps the live view fresh within seconds
964
+ // while batching bursts into one refetch; the 60s poll on useChanges
965
+ // remains the no-SSE fallback.
966
+ const tl = timelineInvalidationRef.current
967
+ if (tl.timer === null) {
968
+ tl.timer = window.setTimeout(() => {
969
+ queryClient.invalidateQueries({ queryKey: ['changes'] })
970
+ timelineInvalidationRef.current = { timer: null }
971
+ }, 5000)
972
+ }
973
+
471
974
  // Skip K8s Event kind — informational, not resource mutations
472
975
  if (event.kind === 'Event') return
473
976
 
474
- const pending = pendingInvalidationRef.current
475
- pending.kinds.add(kindToPlural(event.kind))
476
- if (event.operation === 'add' || event.operation === 'delete') {
477
- pending.hasCountChange = true
977
+ const kind = kindToPlural(event.kind)
978
+ const structural = event.operation === 'add' || event.operation === 'delete'
979
+
980
+ const fast = fastInvalidationRef.current
981
+ fast.changedKinds.add(kind)
982
+ if (structural) fast.structuralKinds.add(kind)
983
+ if (kind === 'secrets') fast.secretsChanged = true
984
+
985
+ const slow = slowInvalidationRef.current
986
+ if (!structural) slow.updatedKinds.add(kind)
987
+
988
+ // FAST tier — membership-sensitive + cheap, bounded 3s latency.
989
+ if (fast.timer === null) {
990
+ fast.timer = window.setTimeout(() => {
991
+ const f = fastInvalidationRef.current
992
+ for (const k of f.changedKinds) {
993
+ queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
994
+ }
995
+ for (const k of f.structuralKinds) {
996
+ queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
997
+ }
998
+ if (f.structuralKinds.size > 0) {
999
+ queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
1000
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] })
1001
+ }
1002
+ if (f.secretsChanged) {
1003
+ queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
1004
+ }
1005
+ // GitOps behavior unchanged from before — refreshes every batch when a
1006
+ // GitOps view is mounted (Phase 2 will make this relevance-aware).
1007
+ queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
1008
+ queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
1009
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
1010
+ }, 3000)
478
1011
  }
479
1012
 
480
- // Start throttle window on first event (don't reset bounded 3s latency)
481
- if (pending.timer !== null) return
482
- pending.timer = window.setTimeout(() => {
483
- for (const kind of pending.kinds) {
484
- // Invalidate list queries (['resources', kind, ...]) and detail queries (['resource', kind, ...])
485
- queryClient.invalidateQueries({ queryKey: ['resources', kind] })
486
- queryClient.invalidateQueries({ queryKey: ['resource', kind] })
487
- }
488
- if (pending.hasCountChange) {
489
- queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
490
- }
491
- queryClient.invalidateQueries({ queryKey: ['dashboard'] })
492
- if (pending.kinds.has('secrets')) {
493
- queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
494
- }
495
- // Reset accumulator
496
- pending.kinds = new Set()
497
- pending.hasCountChange = false
498
- pending.timer = null
499
- }, 3000)
1013
+ // SLOW tier throttle the expensive queries for status-only churn. Only
1014
+ // updates schedule it; structural changes are fully handled by the fast tier.
1015
+ if (!structural && slow.timer === null) {
1016
+ slow.timer = window.setTimeout(() => {
1017
+ const s = slowInvalidationRef.current
1018
+ for (const k of s.updatedKinds) {
1019
+ queryClient.invalidateQueries({ queryKey: ['resources', k] })
1020
+ }
1021
+ queryClient.invalidateQueries({ queryKey: ['dashboard'] }) // health reflects status updates
1022
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1023
+ }, 15000)
1024
+ }
500
1025
  }, [queryClient])
501
1026
 
1027
+ // Clear pending invalidation timers on unmount. Reset the refs (not just
1028
+ // clearTimeout) so a same-instance remount doesn't inherit a non-null timer
1029
+ // id — handleK8sEvent only schedules when timer === null, so a stale id would
1030
+ // silently wedge all further SSE-driven invalidation.
1031
+ useEffect(() => () => {
1032
+ if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1033
+ if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1034
+ if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1035
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
1036
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1037
+ timelineInvalidationRef.current = { timer: null }
1038
+ }, [])
1039
+
502
1040
  // SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
503
1041
  // forceNamespaceFilter is only set for large clusters that require server-side filtering.
504
1042
  // Fleet mode uses 'resources' topology on the backend — filtering is client-side
505
1043
  const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
506
- const { topology, connected, reconnect: reconnectSSE } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
1044
+ const { topology, connected: eventStreamConnected, connecting: eventStreamConnecting, reconnect: reconnectEventStream } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
507
1045
  onContextSwitchComplete: endSwitch,
508
1046
  onContextSwitchProgress: updateProgress,
509
1047
  onContextChanged: () => {
@@ -514,14 +1052,16 @@ function AppInner() {
514
1052
  queryClient.invalidateQueries()
515
1053
 
516
1054
  // Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
517
- if (pendingInvalidationRef.current.timer !== null) {
518
- clearTimeout(pendingInvalidationRef.current.timer)
519
- pendingInvalidationRef.current = { kinds: new Set(), hasCountChange: false, timer: null }
520
- }
1055
+ if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
1056
+ if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
1057
+ if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
1058
+ fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
1059
+ slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
1060
+ timelineInvalidationRef.current = { timer: null }
521
1061
 
522
1062
  // Close any open drawers/overlays — old cluster's resources don't exist on the new one
1063
+ // (?full=1 is cleared by the URL reset below).
523
1064
  setSelectedResource(null)
524
- setDrawerExpanded(false)
525
1065
  setSelectedHelmRelease(null)
526
1066
 
527
1067
  // Reset URL to current view with no resource-specific params.
@@ -540,7 +1080,25 @@ function AppInner() {
540
1080
  },
541
1081
  onK8sEvent: handleK8sEvent,
542
1082
  }, forceNamespaceFilter, showPolicyEffect)
543
- const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
1083
+ // On large clusters (where the server requires namespace filtering), keep
1084
+ // SSE's server-side filter in lockstep with the user's namespace pick.
1085
+ // Without this, header switches and deep-link loads can leave SSE filtered
1086
+ // to a stale namespace while sidebar/topology show a different one. Small
1087
+ // clusters never set forceNamespaceFilter and skip this path entirely.
1088
+ useEffect(() => {
1089
+ const isLarge = forceNamespaceFilter !== undefined || topology?.requiresNamespaceFilter === true
1090
+ if (!isLarge) return
1091
+ if (namespaces.length === 0) {
1092
+ setForceNamespaceFilter(prev => (prev === undefined ? prev : undefined))
1093
+ return
1094
+ }
1095
+ setForceNamespaceFilter(prev => {
1096
+ const cur = prev ? [...prev].sort() : []
1097
+ const next = [...namespaces].sort()
1098
+ if (cur.length === next.length && cur.every((ns, i) => ns === next[i])) return prev
1099
+ return [...namespaces]
1100
+ })
1101
+ }, [namespaces, forceNamespaceFilter, topology?.requiresNamespaceFilter])
544
1102
 
545
1103
  // Apply live topology updates only when not paused. While paused, buffer the
546
1104
  // latest snapshot so we can apply it instantly when the user resumes.
@@ -566,6 +1124,28 @@ function AppInner() {
566
1124
  // Track CRD discovery status from topology (more direct than cluster-info)
567
1125
  // When discovery completes, topology will auto-update via SSE with new CRD nodes
568
1126
  const crdDiscoveryStatus = topology?.crdDiscoveryStatus
1127
+ const clusterConnectionState = connection.state
1128
+ const clusterConnected = clusterConnectionState === 'connected'
1129
+ const liveUpdatesDisconnected = clusterConnected && !eventStreamConnected && !eventStreamConnecting
1130
+ const headerConnectionLabel =
1131
+ clusterConnectionState === 'disconnected' ? 'Disconnected' :
1132
+ clusterConnectionState === 'connecting' ? 'Connecting' :
1133
+ liveUpdatesDisconnected ? 'Live updates disconnected' :
1134
+ clusterLoadState.loading ? `Connected — ${clusterLoadState.message}` :
1135
+ crdDiscoveryStatus === 'discovering' ? 'Connected — discovering Custom Resources...' :
1136
+ 'Connected'
1137
+ const headerConnectionDisplayLabel =
1138
+ clusterConnectionState === 'disconnected' ? 'Disconnected' :
1139
+ clusterConnectionState === 'connecting' ? 'Connecting' :
1140
+ liveUpdatesDisconnected ? 'Live updates disconnected' :
1141
+ showClusterWarmupLabel ? clusterLoadState.message :
1142
+ crdDiscoveryStatus === 'discovering' ? 'Discovering Custom Resources…' :
1143
+ ''
1144
+ const showHeaderReconnect =
1145
+ clusterConnectionState === 'disconnected' ||
1146
+ liveUpdatesDisconnected
1147
+ const headerReconnect = clusterConnectionState === 'disconnected' ? retryConnection : reconnectEventStream
1148
+ const headerReconnectPending = clusterConnectionState === 'disconnected' ? isRetrying : false
569
1149
 
570
1150
  // Debug: log discovery status changes
571
1151
  useEffect(() => {
@@ -607,20 +1187,129 @@ function AppInner() {
607
1187
  // TODO: Could show a list of pods in the group
608
1188
  if (node.kind === 'PodGroup') return
609
1189
 
1190
+ const namespace = (node.data.namespace as string) || ''
1191
+ // GitOps CRs (Application/Kustomization/HelmRelease/etc.) have a dedicated
1192
+ // detail page with tree + insights + ops that the drawer can't reproduce.
1193
+ // Route there from the main topology when the node is one of those kinds;
1194
+ // everything else falls back to the drawer.
1195
+ const gitOpsPath = gitOpsRouteForKind(node.kind, namespace, node.name)
1196
+ if (gitOpsPath) {
1197
+ navigate(gitOpsPath)
1198
+ return
1199
+ }
1200
+
610
1201
  navigateToResource({
611
1202
  kind: kindToPlural(node.kind),
612
- namespace: (node.data.namespace as string) || '',
1203
+ namespace,
613
1204
  name: node.name,
1205
+ group: apiVersionToGroup(node.data.apiVersion as string | undefined),
614
1206
  })
615
- }, [])
1207
+ }, [navigate, navigateToResource])
616
1208
 
617
1209
  // Serialize namespaces for stable dependency tracking
618
1210
  const namespacesKey = namespaces.join(',')
619
1211
 
1212
+ // The server is canonical for the per-user namespace pick. Mirror its
1213
+ // `actives` into App.tsx state so consumer hooks (SSE, dashboard, resource
1214
+ // lists) stay in lockstep with the picker. The dedicated URL-write effect
1215
+ // below propagates the mirrored state to `?namespaces=`.
1216
+ const setActiveNamespace = useSetActiveNamespace()
1217
+ // Defer the state flip to onSuccess. Setting namespaces to [] before the
1218
+ // server-side pref has actually been cleared makes React Query refetch
1219
+ // under the new empty key while the server still returns the previous
1220
+ // pick's scope, caching stale data under the new key with no later
1221
+ // invalidation. onSettled would do the same on errors, leaving the UI
1222
+ // showing "All namespaces" while data is still namespace-scoped — onSuccess
1223
+ // keeps state aligned with the server.
1224
+ //
1225
+ // Don't touch the URL here either: setSearchParams on a still-set state
1226
+ // trips the URL→state sync into firing setNamespaces([]) and a duplicate
1227
+ // mutation immediately, which re-introduces the same race. The state→URL
1228
+ // effect propagates state=[] → URL on its own after onSuccess flips state.
1229
+ const clearAllNamespaces = useCallback(() => {
1230
+ if (namespaceScope?.cacheScoped) return
1231
+ if (namespaces.length === 0) return
1232
+ setActiveNamespace.mutate(
1233
+ { namespaces: [] },
1234
+ { onSuccess: () => setNamespaces([]) },
1235
+ )
1236
+ }, [namespaceScope?.cacheScoped, namespaces.length, setActiveNamespace])
1237
+ const initialBookmarkReconciledRef = useRef(false)
1238
+ const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
1239
+ const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
1240
+ useEffect(() => {
1241
+ if (!namespaceScope) return
1242
+ const sortedScope = [...scopeActives].sort()
1243
+ const sortedState = [...namespaces].sort()
1244
+ const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
1245
+ debugNamespaceLog('app:scope-mirror', {
1246
+ scopeActives,
1247
+ stateNamespaces: namespaces,
1248
+ sameAsState,
1249
+ initialBookmarkReconciled: initialBookmarkReconciledRef.current,
1250
+ })
1251
+
1252
+ // First-load bookmark reconciliation: if the URL had namespaces that
1253
+ // differ from the server pick when the scope first arrives, push the
1254
+ // URL choice to the server so shared/bookmarked deep links keep
1255
+ // working. The ref flips on the first scope load regardless of whether
1256
+ // the URL had namespaces — subsequent runs mirror server → state.
1257
+ if (!initialBookmarkReconciledRef.current) {
1258
+ initialBookmarkReconciledRef.current = true
1259
+ if (!sameAsState && sortedState.length > 0) {
1260
+ if (namespaceScope.cacheScoped && (!namespaceScope.namespaceRescope || sortedState.length !== 1)) {
1261
+ debugNamespaceLog('app:scope-mirror-cache-scope-preserve', {
1262
+ stateNamespaces: sortedState,
1263
+ scopeActives: sortedScope,
1264
+ })
1265
+ setNamespaces(scopeActives)
1266
+ return
1267
+ }
1268
+ debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
1269
+ stateNamespaces: sortedState,
1270
+ scopeActives: sortedScope,
1271
+ })
1272
+ setActiveNamespace.mutate({ namespaces: sortedState })
1273
+ return
1274
+ }
1275
+ }
1276
+
1277
+ if (!sameAsState) {
1278
+ debugNamespaceLog('app:scope-mirror-set-namespaces', { nextNamespaces: scopeActives })
1279
+ setNamespaces(scopeActives)
1280
+ }
1281
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
1282
+ }, [namespaceScope, namespaceScopeKey])
1283
+
1284
+ // Arm the skip on every history Pop (location.key changes per nav), then
1285
+ // clear it on the next macrotask. The revert/oscillation is a synchronous
1286
+ // re-render burst, so a macrotask-deferred clear covers it; clearing
1287
+ // afterward means a stale arm can't survive into an unrelated later write
1288
+ // (e.g. a Pop that changes none of the write effect's deps would otherwise
1289
+ // leave the flag set and silently drop the next user-driven URL write).
1290
+ useEffect(() => {
1291
+ if (navigationType !== NavigationType.Pop) {
1292
+ // Any non-Pop navigation clears the guard. Without this, a Push/Replace
1293
+ // that lands before the macrotask fires would run this cleanup (cancelling
1294
+ // the timeout) and re-run as a no-op, leaving the flag stuck true and
1295
+ // silently suppressing all later URL writes.
1296
+ skipUrlWriteAfterPopRef.current = false
1297
+ return
1298
+ }
1299
+ skipUrlWriteAfterPopRef.current = true
1300
+ const id = setTimeout(() => { skipUrlWriteAfterPopRef.current = false }, 0)
1301
+ return () => clearTimeout(id)
1302
+ }, [location.key, navigationType])
1303
+
620
1304
  // Update URL query params when state changes (path is handled by setMainView)
621
1305
  // Read from window.location.search (not React Router's searchParams) to preserve
622
1306
  // params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
623
1307
  useEffect(() => {
1308
+ // Don't write (and revert) the URL while state is still catching up to a
1309
+ // Pop — the read effect below owns syncing state from the popped URL. The
1310
+ // flag auto-clears on the next macrotask, so this never blocks a later
1311
+ // user-driven write.
1312
+ if (skipUrlWriteAfterPopRef.current) return
624
1313
  const currentSearch = window.location.search
625
1314
  const params = new URLSearchParams(currentSearch)
626
1315
 
@@ -652,29 +1341,91 @@ function AppInner() {
652
1341
 
653
1342
  // Only update if params actually changed vs current URL
654
1343
  if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
1344
+ debugNamespaceLog('app:url-write', {
1345
+ namespaces,
1346
+ currentSearch,
1347
+ nextSearch: params.toString(),
1348
+ mainView,
1349
+ })
655
1350
  setSearchParams(params, { replace: true })
656
1351
  }
657
1352
  // eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
658
1353
  }, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
659
1354
 
660
- // Sync state from URL when navigating (back/forward)
1355
+ // Sync namespace + helm picks from the query string only when the query
1356
+ // string changes. If this also ran on pathname / mainView changes, a view
1357
+ // whose URL omits ?namespaces= would clear App state and POST [] to the
1358
+ // server while the per-user pick was still narrowed — the picker would
1359
+ // show the server scope but lists/dashboard would stay on "all namespaces".
661
1360
  useEffect(() => {
662
1361
  const urlNamespaces = parseNamespacesFromURL(searchParams)
1362
+ debugNamespaceLog('app:url-sync', {
1363
+ search: searchParams.toString(),
1364
+ urlNamespaces,
1365
+ stateNamespaces: namespaces,
1366
+ namespacesKey,
1367
+ })
663
1368
 
664
- if (urlNamespaces.join(',') !== namespacesKey) setNamespaces(urlNamespaces)
1369
+ if (urlNamespaces.join(',') !== namespacesKey) {
1370
+ if (namespaceScope?.cacheScoped && (!namespaceScope.namespaceRescope || urlNamespaces.length !== 1)) {
1371
+ const scopedNamespaces = namespaceScope.actives ?? []
1372
+ debugNamespaceLog('app:url-sync-cache-scope-preserve', { scopedNamespaces })
1373
+ setNamespaces(scopedNamespaces)
1374
+ return
1375
+ }
1376
+ debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
1377
+ setNamespaces(urlNamespaces)
1378
+ if (namespaceScope) {
1379
+ const sortedURL = [...urlNamespaces].sort()
1380
+ const sortedScope = [...(namespaceScope.actives ?? [])].sort()
1381
+ const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
1382
+ if (!same) {
1383
+ debugNamespaceLog('app:url-sync-mutate-server', {
1384
+ urlNamespaces,
1385
+ scopeActives: namespaceScope.actives ?? [],
1386
+ })
1387
+ setActiveNamespace.mutate({ namespaces: urlNamespaces })
1388
+ }
1389
+ }
1390
+ }
665
1391
 
666
- // Restore helm release from URL (back navigation)
667
1392
  const releaseParam = searchParams.get('release')
668
1393
  if (releaseParam) {
669
1394
  const slashIdx = releaseParam.indexOf('/')
670
1395
  if (slashIdx > 0) {
671
1396
  const ns = releaseParam.slice(0, slashIdx)
672
1397
  const name = releaseParam.slice(slashIdx + 1)
673
- setSelectedHelmRelease({ namespace: ns, name })
1398
+ setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
674
1399
  }
675
1400
  }
1401
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run only when searchParams change; namespacesKey/namespaceScope are read for that transition
676
1402
  }, [searchParams])
677
1403
 
1404
+ useEffect(() => {
1405
+ if (navigationType !== NavigationType.Pop || mainView !== 'resources') return
1406
+ const kindFromPath = location.pathname.match(/^\/resources\/([^/]+)/)?.[1] ?? ''
1407
+ const resourceParam = searchParams.get('resource')
1408
+ if (kindFromPath && resourceParam) {
1409
+ const slashIdx = resourceParam.indexOf('/')
1410
+ const ns = slashIdx > 0 ? resourceParam.slice(0, slashIdx) : ''
1411
+ const name = slashIdx > 0 ? resourceParam.slice(slashIdx + 1) : resourceParam
1412
+ const apiGroup = searchParams.get('apiGroup') ?? ''
1413
+ const next: SelectedResource = { kind: kindFromPath, namespace: ns, name, group: apiGroup }
1414
+ setSelectedResource(prev => {
1415
+ if (
1416
+ prev &&
1417
+ prev.kind === next.kind &&
1418
+ prev.namespace === next.namespace &&
1419
+ prev.name === next.name &&
1420
+ (prev.group ?? '') === (next.group ?? '')
1421
+ ) return prev
1422
+ return next
1423
+ })
1424
+ } else if (kindFromPath && !resourceParam) {
1425
+ setSelectedResource(prev => (prev === null ? prev : null))
1426
+ }
1427
+ }, [navigationType, mainView, location.pathname, searchParams])
1428
+
678
1429
  // Auto-adjust grouping when namespaces change
679
1430
  useEffect(() => {
680
1431
  if (namespaces.length === 0 && groupingMode === 'none') {
@@ -684,39 +1435,44 @@ function AppInner() {
684
1435
  // Switching to specific namespaces - disable namespace grouping
685
1436
  setGroupingMode('none')
686
1437
  }
1438
+ // Intentionally runs ONLY when the namespace selection changes. It reads the
1439
+ // current groupingMode but must not re-run when grouping changes, or it would
1440
+ // immediately revert a manual/fleet grouping choice. namespacesKey is the
1441
+ // manual dependency standing in for the namespaces array.
1442
+ // eslint-disable-next-line react-hooks/exhaustive-deps
687
1443
  }, [namespacesKey])
688
1444
 
689
1445
  // Clear resource selection when changing views or namespaces
690
1446
  // But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
691
1447
  const prevMainView = useRef(mainView)
692
1448
  useEffect(() => {
693
- // Skip clearing during controlled expand/collapse transitions
694
- if (suppressViewClearRef.current) {
695
- suppressViewClearRef.current = false
696
- prevMainView.current = mainView
697
- return
698
- }
699
-
700
1449
  const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
701
- const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
702
1450
  prevMainView.current = mainView
703
1451
 
704
- // Don't clear selectedResource when navigating TO resources view (deep link from Helm)
705
- if (!navigatingToResources) {
1452
+ // The URL is the source of truth for what's selected. A deep link
1453
+ // (?resource=, ?release=) seeds the selection on mount; the effects that
1454
+ // run during that same mount must not wipe a selection the URL still
1455
+ // asserts. (On a real view switch the URL no longer carries the param, so
1456
+ // the clear proceeds.) Without this, deep-linking straight to a Helm
1457
+ // release lands on the release list with no drawer.
1458
+ // (drawerExpanded is URL-derived from ?full=1, so leaving /resources drops it
1459
+ // automatically — no explicit reset needed.)
1460
+ const params = new URLSearchParams(window.location.search)
1461
+ if (!navigatingToResources && !params.has('resource')) {
706
1462
  setSelectedResource(null)
707
1463
  }
708
- // Don't clear helm release when navigating TO helm (back button restores from URL)
709
- if (!navigatingToHelm) {
1464
+ if (!params.has('release')) {
710
1465
  setSelectedHelmRelease(null)
711
1466
  }
712
- setDrawerExpanded(false)
713
1467
  }, [mainView])
714
1468
 
715
- // Clear resource selection when namespaces change
1469
+ // Clear resource selection when namespaces change — but keep a selection the
1470
+ // URL still asserts (deep link, or a release/resource the user is viewing
1471
+ // while they adjust the namespace scope filter).
716
1472
  useEffect(() => {
717
- setSelectedResource(null)
718
- setDrawerExpanded(false)
719
- setSelectedHelmRelease(null)
1473
+ const params = new URLSearchParams(window.location.search)
1474
+ if (!params.has('resource')) setSelectedResource(null)
1475
+ if (!params.has('release')) setSelectedHelmRelease(null)
720
1476
  }, [namespacesKey])
721
1477
 
722
1478
  // Filter topology based on visible kinds (uses displayedTopology which respects pause)
@@ -726,7 +1482,7 @@ function AppInner() {
726
1482
  // Fleet mode overrides visible kinds to show only CAPI resources + Node
727
1483
  const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
728
1484
 
729
- // Filter by namespace (frontend-side) and by visible kinds
1485
+ // Filter by namespace (client-side) and by visible kinds
730
1486
  const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
731
1487
  const filteredNodes = displayedTopology.nodes.filter(node =>
732
1488
  effectiveKinds.has(node.kind) &&
@@ -749,11 +1505,50 @@ function AppInner() {
749
1505
  })
750
1506
 
751
1507
  return {
1508
+ ...displayedTopology,
752
1509
  nodes: filteredNodes,
753
1510
  edges: filteredEdges,
754
1511
  }
755
1512
  }, [displayedTopology, visibleKinds, namespaces, topologyMode])
756
1513
 
1514
+ // Cluster Audit findings, joined onto topology nodes by the audit key the
1515
+ // backend stamps on each node (data.auditKey). The graph surfaces DANGER only
1516
+ // (warnings would turn a dense graph into a heatmap); the node component reads
1517
+ // data.auditDanger. Re-runs only when findings change, and copies nodes only
1518
+ // when there are findings to attach — no overhead on clusters with none.
1519
+ const audit = useAudit(namespaces)
1520
+ const auditSeverityMap = useMemo(
1521
+ () => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
1522
+ [audit.data?.findings, audit.data?.checks],
1523
+ )
1524
+ const topologyWithAudit = useMemo((): Topology | null => {
1525
+ if (!filteredTopology) return null
1526
+ if (auditSeverityMap.size === 0) return filteredTopology
1527
+ return {
1528
+ ...filteredTopology,
1529
+ nodes: filteredTopology.nodes.map(node => {
1530
+ const counts = auditSeverityMap.get(node.data.auditKey as string)
1531
+ if (!counts) return node
1532
+ return { ...node, data: { ...node.data, auditDanger: counts.danger, auditWarning: counts.warning, auditMessages: counts.messages } }
1533
+ }),
1534
+ }
1535
+ }, [filteredTopology, auditSeverityMap])
1536
+
1537
+ // The graph node id of the currently open resource, used to highlight it on
1538
+ // the canvas. Looked up from the topology (not reconstructed) because node
1539
+ // ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
1540
+ // collisions — rebuilding the string can't match those reliably.
1541
+ const selectedNodeId = useMemo(() => {
1542
+ if (!selectedResource) return undefined
1543
+ const ns = selectedResource.namespace || ''
1544
+ const match = topology?.nodes.find(n =>
1545
+ ((n.data.namespace as string) || '') === ns &&
1546
+ n.name === selectedResource.name &&
1547
+ (kindToPlural(n.kind) === selectedResource.kind || n.kind === selectedResource.kind)
1548
+ )
1549
+ return match?.id
1550
+ }, [selectedResource, topology])
1551
+
757
1552
  // Filter handlers
758
1553
  const handleToggleKind = useCallback((kind: NodeKind) => {
759
1554
  setVisibleKinds(prev => {
@@ -782,60 +1577,122 @@ function AppInner() {
782
1577
  setVisibleKinds(new Set())
783
1578
  }, [])
784
1579
 
1580
+ const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
1581
+
785
1582
  return (
786
1583
  <PortForwardProvider>
787
- <div className="flex flex-col h-screen bg-theme-base min-w-[800px]">
788
- {/* Header */}
789
- <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">
790
- {/* Left: Logo + Cluster info */}
791
- <div className="flex items-center gap-4 shrink-0">
792
- {navCustomization.brandSlot ?? <Logo />}
793
-
794
- <div className="flex items-center gap-2">
795
- {navCustomization.contextSlot ?? <ContextSwitcher />}
796
- {/* Connection status - next to cluster name */}
797
- <div className="flex items-center gap-1.5 ml-1">
1584
+ {/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
1585
+ the outer minimum must include it (176px pinned / 56px collapsed) or the
1586
+ content column (min-w-0, shrinkable) would fall below the old desktop
1587
+ floor at small windows. Embedded mode has no rail → plain 800. */}
1588
+ <div
1589
+ className={`relative flex bg-theme-base ${navCustomization.embedded ? 'h-full min-h-0' : 'h-screen'}`}
1590
+ style={{ minWidth: 800 + (showNavRail ? (navRailEffectivePinned ? 176 : 56) : 0) }}
1591
+ >
1592
+ {showNavRail && (
1593
+ <PrimaryNavRail
1594
+ activeView={navActiveView}
1595
+ onNavigate={setMainView}
1596
+ pinned={navRailEffectivePinned}
1597
+ onTogglePinned={toggleNavRailPinned}
1598
+ showPinToggle={!railForcedSlim}
1599
+ onOpenSettings={() => setShowSettings(true)}
1600
+ accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
1601
+ />
1602
+ )}
1603
+ {/* `relative` makes this column the containing block for the absolute
1604
+ overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
1605
+ span the content area AFTER the rail rather than the full viewport
1606
+ under it. `fixed` splashes (connecting/switching) are unaffected. */}
1607
+ <div className="relative flex flex-col flex-1 min-w-0 h-full">
1608
+ {/* Header — suppressed in chromeless embed; the host owns the chrome.
1609
+ @container: the header's responsive layout keys off its OWN width (≈ viewport
1610
+ − nav rail), not the viewport, so it collapses gracefully on narrow windows.
1611
+ The AI panel docks BELOW the navbar and pushes only the content region, so the
1612
+ navbar is never squeezed by it — these thresholds react to real window width. */}
1613
+ {!chromeless && (
1614
+ <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">
1615
+ {/* Left: Logo + Cluster info. In the standalone (nav-rail) layout this
1616
+ is a FIXED-WIDTH column so the omnibar after it is force-pinned: the
1617
+ scope pill + status dot can change width (cluster/namespace value)
1618
+ without ever shifting the search box. The pill's own name/value caps
1619
+ keep it inside this width; the embedded/pill layout keeps auto width
1620
+ (its center bar is absolutely positioned). */}
1621
+ <div className={`flex items-center gap-4 shrink-0 ${showNavRail ? 'w-[492px]' : ''}`}>
1622
+ {/* Standalone rail owns the brand; only the embedded/pill layout
1623
+ shows it in the header (host may override via brandSlot). */}
1624
+ {navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
1625
+
1626
+ <div className={`flex items-center gap-2 min-w-0 ${showNavRail ? 'flex-1' : ''}`}>
1627
+ {navCustomization.contextSlot ? (
1628
+ // Embedded host supplies its own cluster switcher — keep the two
1629
+ // controls separate (the host owns the cluster chip's styling).
1630
+ <>
1631
+ {navCustomization.contextSlot}
1632
+ <NamespaceSwitcher
1633
+ ref={namespaceSwitcherRef}
1634
+ disabled={namespaceFilter.disabled}
1635
+ disabledTooltip={namespaceFilter.tooltip}
1636
+ />
1637
+ </>
1638
+ ) : (
1639
+ // Standalone: cluster + namespace as one "scope" pill — two
1640
+ // borderless segments split by a divider, reading as a single
1641
+ // "what am I looking at" unit. The shared ScopePill shell is the
1642
+ // same one Radar Hub's cluster top bar uses, so the two match.
1643
+ <ScopePill>
1644
+ <ContextSwitcher ref={contextSwitcherRef} variant="segment" />
1645
+ <NamespaceSwitcher
1646
+ ref={namespaceSwitcherRef}
1647
+ variant="segment"
1648
+ disabled={namespaceFilter.disabled}
1649
+ disabledTooltip={namespaceFilter.tooltip}
1650
+ />
1651
+ </ScopePill>
1652
+ )}
1653
+ {/* Connection status — a fixed-size dot (state in the tooltip), an
1654
+ optional reconnect button, and when the header is wide enough
1655
+ (xl+), a label. The label is nowrap and unbounded: it overflows
1656
+ the fixed left column into the empty gap before the centered
1657
+ search box rather than shifting anything (the dot + pill are
1658
+ shrink-0, so layout stays put — the search box never moves).
1659
+ Where the gap is smaller than the label, its tail tucks under
1660
+ the omnibar's solid background. Below xl it's the dot alone
1661
+ unless reconnect is available. */}
1662
+ <div className="ml-1 flex items-center gap-1.5 shrink-0">
798
1663
  <Tooltip
799
- content={
800
- !connected
801
- ? 'Disconnected'
802
- : crdDiscoveryStatus === 'discovering'
803
- ? 'Connected — discovering Custom Resources...'
804
- : 'Connected'
805
- }
1664
+ content={headerConnectionLabel}
806
1665
  delay={100}
807
1666
  position="bottom"
808
1667
  >
809
1668
  <span
810
- className={`w-2 h-2 rounded-full ${
811
- !connected
1669
+ className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
1670
+ clusterConnectionState === 'disconnected' || liveUpdatesDisconnected
812
1671
  ? 'bg-red-500'
813
- : crdDiscoveryStatus === 'discovering'
1672
+ : clusterConnectionState === 'connecting' || crdDiscoveryStatus === 'discovering' || clusterLoadState.loading
814
1673
  ? 'bg-amber-400 animate-pulse'
815
1674
  : 'bg-green-500'
816
1675
  }`}
817
1676
  />
818
1677
  </Tooltip>
819
- {/* Inline label only for non-steady states where the user
820
- might need to act or wait. The healthy "Connected" case
821
- is the dot alone; the dot's tooltip discloses it. Keeping
822
- "Connected" text here would expand the left section and
823
- collide with the absolute-centered nav block at xl, which
824
- is the same breakpoint where nav labels appear. */}
825
- {(!connected || crdDiscoveryStatus === 'discovering') && (
826
- <span className="text-xs text-theme-text-tertiary hidden xl:inline">
827
- {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
1678
+ {showNavRail && headerConnectionDisplayLabel && (
1679
+ <span className="hidden xl:flex items-center gap-1.5 whitespace-nowrap text-[11px] text-theme-text-tertiary">
1680
+ {showClusterWarmupLabel && <Loader2 className="w-3 h-3 animate-spin" />}
1681
+ {headerConnectionDisplayLabel}
828
1682
  </span>
829
1683
  )}
830
- {!connected && (
831
- <button
832
- onClick={reconnect}
833
- disabled={isReconnecting}
834
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
835
- title="Reconnect"
836
- >
837
- <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
838
- </button>
1684
+ {showHeaderReconnect && (
1685
+ <Tooltip content="Reconnect" delay={100} position="bottom">
1686
+ <button
1687
+ type="button"
1688
+ onClick={headerReconnect}
1689
+ disabled={headerReconnectPending}
1690
+ aria-label={clusterConnectionState === 'disconnected' ? 'Reconnect cluster' : 'Reconnect live updates'}
1691
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1692
+ >
1693
+ <RefreshCw className={`w-3 h-3 ${headerReconnectPending ? 'animate-spin' : ''}`} />
1694
+ </button>
1695
+ </Tooltip>
839
1696
  )}
840
1697
  </div>
841
1698
  {/* Port forwards indicator — shown only when sessions exist */}
@@ -843,25 +1700,42 @@ function AppInner() {
843
1700
  </div>
844
1701
  </div>
845
1702
 
846
- {/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
847
- <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-1 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
1703
+ {/* Center: View tabs — embedded/pill layout only. Standalone Radar
1704
+ navigates via the left rail (showNavRail), so the pill bar is
1705
+ suppressed there to avoid a duplicate primary nav. */}
1706
+ {!showNavRail && (
1707
+ <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">
848
1708
  {([
849
1709
  { view: 'home' as const, icon: Home, label: 'Home' },
850
1710
  { view: 'topology' as const, icon: Network, label: 'Topology' },
851
1711
  { view: 'resources' as const, icon: List, label: 'Resources' },
852
1712
  { view: 'timeline' as const, icon: Clock, label: 'Timeline' },
853
1713
  { view: 'helm' as const, icon: Package, label: 'Helm' },
854
- { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
1714
+ { view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
1715
+ // Applications is intentionally hidden from the pill bar for now —
1716
+ // the bar is full, and the view's primary home is Cloud's fleet
1717
+ // rail. The view still exists and is reachable via /applications
1718
+ // and the view-switching shortcuts. Same treatment as Cost below.
1719
+ { view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
855
1720
  // Cost is intentionally hidden from the pill bar for now — the view still
856
1721
  // exists and is reachable via /cost, the Home dashboard card, and the
857
1722
  // command palette (⌘K). Remove this comment to restore it.
858
- { view: 'audit' as const, icon: ShieldCheck, label: 'Audit' },
859
- ] as const).map(({ view, icon: Icon, label }) => (
1723
+ { view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
1724
+ ] as const)
1725
+ // In Cloud, fleet-shaped views (Checks, Issues, GitOps) are owned by
1726
+ // the host's left rail; the per-cluster view is just that fleet page
1727
+ // filtered to this cluster, so duplicating it as a peer pill here
1728
+ // would be a second copy that teleports out of the cluster shell.
1729
+ // Drop any pill the host took over — cluster-scoped access stays
1730
+ // available via the Home cards (redirected by the takeover effect
1731
+ // above), ⌘K, and bookmarks. Standalone OSS keeps every pill.
1732
+ .filter(({ view }) => !isViewTakenOver(view))
1733
+ .map(({ view, icon: Icon, label }) => (
860
1734
  <Tooltip key={view} content={label} delay={100} position="bottom">
861
1735
  <button
862
1736
  onClick={() => setMainView(view)}
863
- className={`flex items-center gap-1.5 px-2.5 py-1.5 text-sm rounded-full transition-colors ${
864
- mainView === view
1737
+ className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
1738
+ mainView === view || (mainView === 'helmCompare' && view === 'helm')
865
1739
  ? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
866
1740
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
867
1741
  }`}
@@ -877,27 +1751,51 @@ function AppInner() {
877
1751
  off-system breakpoint chosen by measurement at the time
878
1752
  of this PR — recompute if the cluster switcher cap or
879
1753
  other left-section chrome changes appreciably. */}
880
- <span className="hidden min-[1440px]:inline">{label}</span>
1754
+ <span className="hidden @min-[1264px]:inline">{label}</span>
881
1755
  </button>
882
1756
  </Tooltip>
883
1757
  ))}
884
1758
  </div>
1759
+ )}
1760
+
1761
+ {/* Center: omnibar — standalone search + command surface (the ⌘K entry).
1762
+ Its container is one of three EQUAL flex-1 columns (see the left/right
1763
+ groups): equal side columns keep this middle column — and the search
1764
+ box centered in it — pinned regardless of how wide the left-side
1765
+ chrome gets (cluster name, namespace label, "Discovering…" /
1766
+ "Disconnected" text). Overflowing side content truncates instead of
1767
+ dragging the box. Same pattern as Radar Hub's ClusterTopBar. */}
1768
+ {showNavRail && (
1769
+ <div className="hidden @min-[720px]:flex flex-1 justify-center min-w-0 px-3">
1770
+ <RadarOmnibar
1771
+ ref={omnibarRef}
1772
+ onNavigateView={(view) => setMainView(view)}
1773
+ onNavigateKind={(kind, group) => {
1774
+ const params = new URLSearchParams(searchParams)
1775
+ params.delete('kind')
1776
+ if (group) params.set('apiGroup', group); else params.delete('apiGroup')
1777
+ params.delete('resource')
1778
+ params.delete('full')
1779
+ params.delete('tab')
1780
+ navigate({ pathname: `/resources/${kind}`, search: params.toString() })
1781
+ }}
1782
+ onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
1783
+ onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
1784
+ onToggleTheme={toggleTheme}
1785
+ onShowDiagnostics={() => setShowDiagnostics(true)}
1786
+ onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
1787
+ />
1788
+ </div>
1789
+ )}
885
1790
 
886
1791
  {/* Right: Controls */}
887
1792
  <div className="flex items-center gap-3 shrink-0">
888
- {/* Namespace selector with search */}
889
- <NamespaceSelector
890
- value={namespaces}
891
- onChange={setNamespaces}
892
- namespaces={availableNamespaces}
893
- namespacesError={namespacesError}
894
- disabled={mainView === 'helm'}
895
- disabledTooltip="Helm view always shows all namespaces"
896
- />
897
-
898
- {/* Command palette trigger */}
1793
+ {/* Command palette trigger embedded only; standalone has the
1794
+ top-center omnibar (which is the ⌘K surface). */}
1795
+ {!showNavRail && (
899
1796
  <button
900
1797
  onClick={() => setShowCommandPalette(true)}
1798
+ aria-label="Open command palette"
901
1799
  className="hidden lg:flex items-center gap-2 h-7 px-2.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
902
1800
  >
903
1801
  <Search className="w-3.5 h-3.5" />
@@ -905,23 +1803,29 @@ function AppInner() {
905
1803
  {typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}K
906
1804
  </kbd>
907
1805
  </button>
1806
+ )}
908
1807
 
909
1808
  {/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
910
1809
  {!navCustomization.embedded && (
911
- <div className="hidden lg:block">
1810
+ <div className="hidden @min-[1100px]:block">
912
1811
  <GitHubStarButton />
913
1812
  </div>
914
1813
  )}
915
1814
 
1815
+ {/* AI investigations (self-hides when no agent CLI is present) */}
1816
+ <GlobalDiagnoseButton />
1817
+
916
1818
  {/* Local terminal */}
917
1819
  {capabilities.localTerminal && (
1820
+ <Tooltip content="Open local terminal">
918
1821
  <button
919
1822
  onClick={() => openLocalTerminal()}
1823
+ aria-label="Open local terminal"
920
1824
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
921
- title="Open local terminal"
922
1825
  >
923
1826
  <SquareTerminal className="w-4 h-4" />
924
1827
  </button>
1828
+ </Tooltip>
925
1829
  )}
926
1830
 
927
1831
  {/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
@@ -931,38 +1835,51 @@ function AppInner() {
931
1835
  to the host's cookie/backend) and the user would see the theme
932
1836
  bounce on every navigation between host routes and /c/:id. */}
933
1837
  {!navCustomization.embedded && (
934
- <div className="hidden md:block">
1838
+ <div className="hidden @min-[920px]:flex items-center">
935
1839
  <ThemeToggle />
936
1840
  </div>
937
1841
  )}
938
1842
 
939
- {/* Settingshidden in embedded mode. The standalone dialog
940
- exposes local-binary controls (kubeconfig paths, server port,
941
- "open browser on start", "Stop and restart the radar command
942
- to apply") that don't apply to a hosted user who doesn't SSH
943
- into the cluster. The audit view still opens the dialog via
944
- its "N namespaces hidden" link for the narrow audit-ignores
945
- setting — that's a deliberate escape hatch, not a general
946
- surface. */}
947
- {!navCustomization.embedded && (
948
- <button
949
- onClick={() => setShowSettings(true)}
950
- className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
951
- title="Settings"
952
- >
953
- <Settings className="w-4 h-4" />
954
- </button>
1843
+ {/* Help + Report-a-bug standalone only (the left rail owns chrome;
1844
+ embedded hosts provide their own help/support). These replace the
1845
+ old floating bottom-right pair. Settings moved to the rail bottom. */}
1846
+ {showNavRail && (
1847
+ <>
1848
+ <Tooltip content="Keyboard shortcuts (?)">
1849
+ <button
1850
+ onClick={() => setShowHelp(true)}
1851
+ aria-label="Show keyboard shortcuts"
1852
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1853
+ >
1854
+ <HelpCircle className="w-4 h-4" />
1855
+ </button>
1856
+ </Tooltip>
1857
+ <Tooltip content="Report a bug / Diagnostics">
1858
+ <button
1859
+ onClick={() => setShowDiagnostics(true)}
1860
+ aria-label="Open diagnostics"
1861
+ className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1862
+ >
1863
+ <Bug className="w-4 h-4" />
1864
+ </button>
1865
+ </Tooltip>
1866
+ </>
955
1867
  )}
956
1868
 
957
- {/* User menu (when auth enabled) hidden in embedded mode;
958
- host app typically provides its own via rightExtras. */}
959
- {!navCustomization.embedded && <UserMenu />}
1869
+ {/* Account moved to the rail bottom (standalone). Embedded never showed
1870
+ Radar's UserMenu the host provides its own via rightExtras. */}
960
1871
 
961
1872
  {/* Consumer-provided extras (e.g. Radar Hub's Install button +
962
1873
  avatar menu) appended to the right of the action bar. */}
963
1874
  {navCustomization.rightExtras}
964
1875
  </div>
965
1876
  </header>
1877
+ )}
1878
+
1879
+ {/* Body frame — every content state lives here and reflows left of the docked
1880
+ AI panel (an absolute slot in this column). The header + nav rail are OUTSIDE
1881
+ this wrapper, so they never move when the panel opens. */}
1882
+ <div className="relative flex flex-1 flex-col min-h-0" style={{ paddingRight: contentGutter, transition: 'padding-right 0.2s ease' }}>
966
1883
 
967
1884
  {/* Auth barrier - show when auth is enabled but user is not authenticated */}
968
1885
  {authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
@@ -981,76 +1898,78 @@ function AppInner() {
981
1898
  />
982
1899
  )}
983
1900
 
984
- {/* Connecting view - show during initial connection or retry */}
1901
+ {/* Connecting view shown during initial connection or retry.
1902
+ Icon is pane-anchored so its screen position matches the
1903
+ host hub splash across cross-document transitions. */}
985
1904
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
986
- <div className="flex-1 flex items-center justify-center bg-theme-base">
987
- <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
988
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
989
- <div className="text-center">
990
- <p className="font-medium text-theme-text-primary">Connecting to cluster</p>
991
- {connection.context && (
992
- <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
993
- )}
994
- {connection.progressMessage && (
995
- <p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
996
- {connection.progressMessage}
997
- </p>
998
- )}
999
- </div>
1000
- </div>
1001
- </div>
1905
+ <PaneLoader
1906
+ label="Connecting to cluster"
1907
+ className="flex-1 min-h-0 bg-theme-base"
1908
+ >
1909
+ {connection.context && (
1910
+ <span className="mt-1 block text-sm font-normal tracking-normal text-theme-text-secondary">
1911
+ {connection.context}
1912
+ </span>
1913
+ )}
1914
+ {connection.progressMessage && (
1915
+ <span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
1916
+ {connection.progressMessage}
1917
+ </span>
1918
+ )}
1919
+ </PaneLoader>
1002
1920
  )}
1003
1921
 
1004
1922
  {/* Context switching overlay */}
1005
1923
  {isSwitching && (
1006
- <div className="flex-1 flex items-center justify-center bg-theme-base">
1007
- <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
1008
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1009
- <div className="text-center">
1010
- <div className="text-sm font-medium text-theme-text-primary">Switching context</div>
1011
- {targetContext && (
1012
- <div className="text-xs mt-2 text-theme-text-tertiary">
1013
- {targetContext.provider ? (
1014
- <span className="flex items-center justify-center gap-1.5">
1015
- <span className="text-blue-400 font-medium">{targetContext.provider}</span>
1016
- {targetContext.account && (
1017
- <>
1018
- <span className="text-theme-text-tertiary/50">•</span>
1019
- <span>{targetContext.account}</span>
1020
- </>
1021
- )}
1022
- {targetContext.region && (
1023
- <>
1024
- <span className="text-theme-text-tertiary/50">•</span>
1025
- <span>{targetContext.region}</span>
1026
- </>
1027
- )}
1924
+ <PaneLoader
1925
+ label="Switching context"
1926
+ className="flex-1 min-h-0 bg-theme-base"
1927
+ >
1928
+ {targetContext && (
1929
+ <span className="mt-2 block text-xs font-normal tracking-normal text-theme-text-tertiary">
1930
+ {targetContext.provider ? (
1931
+ <span className="flex items-center justify-center gap-1.5">
1932
+ <span className="text-blue-400 font-medium">{targetContext.provider}</span>
1933
+ {targetContext.account && (
1934
+ <>
1028
1935
  <span className="text-theme-text-tertiary/50">•</span>
1029
- <span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
1030
- </span>
1031
- ) : (
1032
- <span>{targetContext.raw}</span>
1936
+ <span>{targetContext.account}</span>
1937
+ </>
1033
1938
  )}
1034
- </div>
1035
- )}
1036
- {progressMessage && (
1037
- <div className="text-xs mt-3 text-theme-text-tertiary animate-pulse">
1038
- {progressMessage}
1039
- </div>
1939
+ {targetContext.region && (
1940
+ <>
1941
+ <span className="text-theme-text-tertiary/50">•</span>
1942
+ <span>{targetContext.region}</span>
1943
+ </>
1944
+ )}
1945
+ <span className="text-theme-text-tertiary/50">•</span>
1946
+ <span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
1947
+ </span>
1948
+ ) : (
1949
+ <span>{targetContext.raw}</span>
1040
1950
  )}
1041
- </div>
1042
- </div>
1043
- </div>
1951
+ </span>
1952
+ )}
1953
+ {progressMessage && (
1954
+ <span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
1955
+ {progressMessage}
1956
+ </span>
1957
+ )}
1958
+ </PaneLoader>
1044
1959
  )}
1045
1960
 
1046
1961
  {/* Main content - only show when connected and authenticated */}
1047
- {!isSwitching && !authMePending && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected' && <div className="flex-1 flex overflow-hidden">
1962
+ {/* inert while a fullscreen detail overlay covers the views keeps the
1963
+ retained background list out of the focus order + a11y tree (the visual
1964
+ cover already blocks pointer events). */}
1965
+ {contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
1048
1966
  <ErrorBoundary>
1049
1967
  {/* Home dashboard */}
1050
1968
  {mainView === 'home' && (
1051
1969
  <HomeView
1052
1970
  namespaces={namespaces}
1053
1971
  topology={topology}
1972
+ fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
1054
1973
  onNavigateToView={setMainView}
1055
1974
  onNavigateToResourceKind={(kind, apiGroup, filters) => {
1056
1975
  // Navigate to resources view with kind in URL path
@@ -1059,6 +1978,8 @@ function AppInner() {
1059
1978
  newParams.delete('kind') // kind is now in the path
1060
1979
  newParams.delete('mode')
1061
1980
  newParams.delete('resource')
1981
+ newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
1982
+ newParams.delete('tab')
1062
1983
  newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
1063
1984
  if (apiGroup) {
1064
1985
  newParams.set('apiGroup', apiGroup)
@@ -1078,21 +1999,16 @@ function AppInner() {
1078
1999
  console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
1079
2000
  navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
1080
2001
  }}
1081
- onNavigateToResource={(resource) => {
1082
- // Switch to resources view and open the resource detail drawer
1083
- setSelectedResource(resource)
1084
- const newParams = new URLSearchParams(searchParams)
1085
- newParams.delete('kind') // kind is now in the path
1086
- newParams.delete('mode')
1087
- newParams.delete('group')
1088
- newParams.delete('resource')
1089
- if (resource.group) {
1090
- newParams.set('apiGroup', resource.group)
1091
- } else {
1092
- newParams.delete('apiGroup')
1093
- }
1094
- navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1095
- }}
2002
+ onNavigateToResource={navigateFromIssue}
2003
+ // Certs has no Radar view, so it can't ride the view-redirect effect
2004
+ // above — wire the Certificate Health card straight to the host's
2005
+ // fleet Certs page (scoped to this cluster) when claimed. `assign`
2006
+ // (not replace): the user is navigating forward from a card, so this
2007
+ // belongs in history. Omitted → the card falls back to Radar's own
2008
+ // TLS-secrets resource list.
2009
+ onNavigateToCerts={
2010
+ takeover.certs ? () => goHost(takeover.certs!) : undefined
2011
+ }
1096
2012
  />
1097
2013
  )}
1098
2014
 
@@ -1119,6 +2035,7 @@ function AppInner() {
1119
2035
  namespaces={availableNamespaces}
1120
2036
  onSelect={(ns) => {
1121
2037
  setNamespaces([ns])
2038
+ setActiveNamespace.mutate({ namespaces: [ns] })
1122
2039
  // Large clusters need server-side filtering — reconnect SSE with namespace
1123
2040
  setForceNamespaceFilter([ns])
1124
2041
  }}
@@ -1145,37 +2062,66 @@ function AppInner() {
1145
2062
  }}
1146
2063
  />
1147
2064
 
1148
- <div className="flex-1 relative">
2065
+ <div ref={setTopologyPane} className="flex-1 relative">
1149
2066
  <TopologyGraph
1150
- topology={filteredTopology}
2067
+ topology={topologyWithAudit}
1151
2068
  viewMode={topologyMode}
1152
2069
  groupingMode={effectiveGroupingMode}
1153
2070
  hideGroupHeader={hideGroupHeader}
1154
2071
  onNodeClick={handleNodeClick}
1155
- selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
2072
+ selectedNodeId={selectedNodeId}
1156
2073
  paused={topologyPaused}
1157
2074
  onTogglePause={handleTogglePause}
1158
- onMaximizeNamespace={(ns) => setNamespaces([ns])}
1159
- namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1160
- onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
2075
+ onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
1161
2076
  namespacesKey={namespaces.join(',')}
1162
- />
1163
-
1164
- {/* Topology controls overlay - top right */}
1165
- <TopologyControls
1166
- viewMode={topologyMode}
1167
- onViewModeChange={(mode) => {
1168
- setTopologyMode(mode)
1169
- // Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
1170
- if (mode === 'fleet') setGroupingMode('namespace')
1171
- }}
1172
- groupingMode={groupingMode}
1173
- onGroupingModeChange={setGroupingMode}
1174
- showNoGrouping={hasNamespaceFilter}
1175
- showPolicyEffect={showPolicyEffect}
1176
- onShowPolicyEffectChange={setShowPolicyEffect}
1177
- showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1178
- />
2077
+ focusNodeId={topologyFocus?.id}
2078
+ focusNonce={topologyFocus?.nonce}
2079
+ >
2080
+ {/* Overlay row: left column (namespace breadcrumb over search)
2081
+ + controls. items-start pins the controls to the top even
2082
+ when the breadcrumb grows the left column; w-full so
2083
+ justify-between spans the canvas. */}
2084
+ <div className="flex w-full items-start justify-between gap-2">
2085
+ <div className="flex flex-col items-start gap-2">
2086
+ {namespaces.length === 1 && (
2087
+ <TopologyBreadcrumb
2088
+ namespace={namespaces[0]}
2089
+ onClear={() => setActiveNamespace.mutate({ namespaces: [] })}
2090
+ />
2091
+ )}
2092
+ <TopologySearch
2093
+ nodes={filteredTopology?.nodes ?? []}
2094
+ allNodes={topology?.nodes}
2095
+ viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
2096
+ onNodeSelect={handleNodeClick}
2097
+ onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
2098
+ overlayContainer={topologyPane}
2099
+ />
2100
+ </div>
2101
+ <TopologyControls
2102
+ viewMode={topologyMode}
2103
+ onViewModeChange={(mode) => {
2104
+ setTopologyMode(mode)
2105
+ // Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
2106
+ if (mode === 'fleet') setGroupingMode('namespace')
2107
+ }}
2108
+ groupingMode={groupingMode}
2109
+ onGroupingModeChange={setGroupingMode}
2110
+ showNoGrouping={hasNamespaceFilter}
2111
+ showPolicyEffect={showPolicyEffect}
2112
+ onShowPolicyEffectChange={setShowPolicyEffect}
2113
+ showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
2114
+ onNavigateToTraffic={() => setMainView('traffic')}
2115
+ leadingSlot={
2116
+ <FreshnessControl
2117
+ mode="auto"
2118
+ paused={topologyPaused}
2119
+ connectionState={connection.state}
2120
+ />
2121
+ }
2122
+ />
2123
+ </div>
2124
+ </TopologyGraph>
1179
2125
  </div>
1180
2126
  </>
1181
2127
  )}
@@ -1186,10 +2132,11 @@ function AppInner() {
1186
2132
  {mainView === 'resources' && (
1187
2133
  <ResourcesView
1188
2134
  namespaces={namespaces}
1189
- selectedResource={selectedResource}
2135
+ selectedResource={routeSelectedResource}
1190
2136
  onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
1191
2137
  onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
1192
2138
  onKindChange={() => setSelectedResource(null)}
2139
+ onClearNamespaces={clearAllNamespaces}
1193
2140
  />
1194
2141
  )}
1195
2142
 
@@ -1198,27 +2145,65 @@ function AppInner() {
1198
2145
  <TimelineView
1199
2146
  namespaces={namespaces}
1200
2147
  onResourceClick={(resource) => {
1201
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
2148
+ navigate(relatedResourcePath(resource))
1202
2149
  }}
1203
2150
  initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
1204
2151
  initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
1205
2152
  initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
1206
2153
  requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
1207
2154
  availableNamespaces={availableNamespaces}
1208
- onNamespaceSelect={(ns) => setNamespaces([ns])}
2155
+ onNamespaceSelect={(ns) => {
2156
+ setNamespaces([ns])
2157
+ setActiveNamespace.mutate({ namespaces: [ns] })
2158
+ }}
1209
2159
  />
1210
2160
  )}
1211
2161
 
1212
- {/* Helm view - always show all namespaces since releases span multiple ns */}
1213
2162
  {mainView === 'helm' && (
1214
2163
  <HelmView
1215
- namespace=""
2164
+ namespaces={namespaces}
1216
2165
  selectedRelease={selectedHelmRelease}
1217
- onReleaseClick={(ns, name) => {
1218
- setSelectedHelmRelease({ namespace: ns, name })
2166
+ onReleaseClick={navigateToHelmRelease}
2167
+ />
2168
+ )}
2169
+
2170
+ {mainView === 'helmCompare' && (
2171
+ <HelmCompareRoute />
2172
+ )}
2173
+
2174
+ {/* GitOps view (inline only when the host hasn't taken it over — see
2175
+ the takeover splash below). */}
2176
+ {mainView === 'gitops' && !isViewTakenOver('gitops') && (
2177
+ <GitOpsView
2178
+ namespaces={namespaces}
2179
+ onOpenResource={(resource) => {
2180
+ // Route through navigateToResource so the peek records the page it
2181
+ // opened on — that's what lets Back off the GitOps detail page close
2182
+ // the drawer instead of orphaning it on the list.
2183
+ navigateToResource(resource)
2184
+ }}
2185
+ onClearNamespaces={clearAllNamespaces}
2186
+ onOpenSettings={() => setShowSettings(true)}
2187
+ />
2188
+ )}
2189
+
2190
+ {/* Applications view — deployable software grouped by app/release evidence */}
2191
+ {mainView === 'applications' && (
2192
+ <ApplicationsView
2193
+ namespaces={namespaces}
2194
+ onOpenResource={(resource) => {
2195
+ // The peek and the inline WorkloadView are mutually exclusive: drop
2196
+ // the inline workload selection so the app graph (not a second
2197
+ // detail panel) sits behind the peek. Search-only change keeps the
2198
+ // pathname — and thus the peek's owner-path — intact.
1219
2199
  const params = new URLSearchParams(window.location.search)
1220
- params.set('release', `${ns}/${name}`)
1221
- setSearchParams(params, { replace: true })
2200
+ if (params.has('workload') || params.has('tab') || params.has('run')) {
2201
+ params.delete('workload')
2202
+ params.delete('tab')
2203
+ params.delete('run')
2204
+ navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
2205
+ }
2206
+ navigateToResource(resource)
1222
2207
  }}
1223
2208
  />
1224
2209
  )}
@@ -1230,89 +2215,138 @@ function AppInner() {
1230
2215
 
1231
2216
  {/* Cost detail view */}
1232
2217
  {mainView === 'cost' && (
1233
- <CostView onBack={() => setMainView('home')} />
2218
+ <CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
1234
2219
  )}
1235
2220
 
1236
- {/* Best practices detail view */}
1237
- {mainView === 'audit' && (
2221
+ {/* Takeover splash. When the host claims the current view via
2222
+ fleetTakeoverHref, the redirect effect above is mid-flight — render a
2223
+ brief splash instead of the inline view (which would flash + fire its
2224
+ own fetches) while the cross-document nav lands. Covers checks /
2225
+ issues / gitops with one block since only one view is active. */}
2226
+ {viewTakeoverHref && (
2227
+ <PaneLoader
2228
+ label="Opening…"
2229
+ className="flex-1 min-h-0 bg-theme-base"
2230
+ />
2231
+ )}
2232
+
2233
+ {/* Best practices detail view (inline only when the host hasn't taken
2234
+ Checks over — standalone OSS, or Cloud without a checks takeover). */}
2235
+ {mainView === 'checks' && !isViewTakenOver('checks') && (
1238
2236
  <AuditView
1239
2237
  namespaces={namespaces}
1240
- onBack={() => setMainView('home')}
1241
- onNavigateToResource={(resource) => {
1242
- const pluralKind = kindToPlural(resource.kind)
1243
- setSelectedResource({ ...resource, kind: pluralKind })
1244
- const newParams = new URLSearchParams(searchParams)
1245
- newParams.delete('kind')
1246
- newParams.delete('mode')
1247
- newParams.delete('group')
1248
- newParams.delete('resource')
1249
- if (resource.group) {
1250
- newParams.set('apiGroup', resource.group)
1251
- } else {
1252
- newParams.delete('apiGroup')
1253
- }
1254
- navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
1255
- }}
2238
+ onNavigateToResource={navigateToResourceList}
2239
+ />
2240
+ )}
2241
+
2242
+ {/* Issues per-cluster live triage queue (hidden route: not yet in the
2243
+ nav `views` list; reachable at /issues). Same shared <IssuesView> the
2244
+ Hub fleet uses; a GitOps reconciler subject routes to its detail page,
2245
+ other resources open the standard resource view. Inline only when the
2246
+ host hasn't taken it over. */}
2247
+ {mainView === 'issues' && !isViewTakenOver('issues') && (
2248
+ <IssuesPane
2249
+ namespaces={namespaces}
2250
+ onNavigateToResource={navigateFromIssue}
1256
2251
  />
1257
2252
  )}
1258
2253
 
1259
- {/* Workload full view (direct URL only expand from drawer uses drawer's expanded state) */}
1260
- {mainView === 'workload' && !drawerExpanded && (
2254
+ {/* Workload full view — the standalone fullscreen route for non-list
2255
+ surfaces and deep links. Expand-from-drawer is the ?full=1 overlay on
2256
+ /resources instead, so it never routes here. */}
2257
+ {mainView === 'workload' && (
1261
2258
  <WorkloadViewRoute
1262
2259
  onNavigateToResource={(resource) => {
1263
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
2260
+ navigate(relatedResourcePath(resource))
1264
2261
  }}
1265
2262
  />
1266
2263
  )}
1267
2264
 
2265
+ {/* Compare two resources of the same kind side-by-side */}
2266
+ {mainView === 'compare' && <CompareViewRoute />}
2267
+
1268
2268
  </ErrorBoundary>
1269
2269
  </div>}
2270
+ </div>{/* /body frame */}
1270
2271
 
1271
- {/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView */}
1272
- {resourceDrawer.shouldRender && drawerResource && (
2272
+ {/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
2273
+ Gated on contentReady so it never renders over the connecting/switching
2274
+ splash (which would push the centered logo off-center). */}
2275
+ {contentReady && resourceDrawer.shouldRender && drawerResource && (
1273
2276
  <ResourceDetailDrawer
1274
2277
  resource={drawerResource}
1275
2278
  initialTab={drawerInitialTab}
2279
+ // No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
2280
+ // to the top of the content area instead of leaving a 49px gap.
2281
+ headerHeight={chromeless ? 0 : undefined}
2282
+ rightInset={contentGutter}
1276
2283
  isOpen={resourceDrawer.isOpen}
1277
- expanded={drawerExpanded}
1278
- onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
2284
+ expanded={drawerExpandedProp}
2285
+ onClose={closeDrawer}
1279
2286
  onNavigate={(res) => navigateToResource(res)}
1280
- onExpand={(res) => {
1281
- suppressViewClearRef.current = true
1282
- setDrawerExpanded(true)
1283
- navigate(`/workload/${res.kind}/${res.namespace}/${res.name}`)
2287
+ canCollapseToDrawer={!isMobile}
2288
+ onExpand={(_res, opts) => {
2289
+ // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2290
+ // collapses) over whatever view is underneath — list, topology graph,
2291
+ // GitOps, Applications — which stays mounted. Carry the YAML tab when
2292
+ // expanding from the drawer's YAML view so the editor (and its
2293
+ // session-persisted draft) is right there, not behind the Overview tab.
2294
+ const p = new URLSearchParams(searchParams)
2295
+ p.set('full', '1')
2296
+ if (opts?.yaml) p.set('tab', 'yaml')
2297
+ setSearchParams(p)
1284
2298
  }}
1285
- onCollapse={handleCollapseFromExpanded}
2299
+ // On mobile there's no drawer to collapse back to, so the collapse/back
2300
+ // control closes the resource (returns to the list) instead.
2301
+ onCollapse={isMobile ? closeDrawer : handleCollapseFromExpanded}
1286
2302
  onNavigateToResource={(resource) => {
1287
- setSelectedResource(resource)
1288
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`, { replace: true })
2303
+ // Drill into a related resource while expanded: stay in the over-list
2304
+ // overlay for the new resource (pushed, so Back walks resource→resource
2305
+ // still expanded). The backdrop list follows to the new kind.
2306
+ const pluralKind = kindToPlural(resource.kind)
2307
+ setSelectedResource({ ...resource, kind: pluralKind })
2308
+ const p = new URLSearchParams()
2309
+ const ns = searchParams.get('namespaces')
2310
+ if (ns) p.set('namespaces', ns)
2311
+ p.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
2312
+ if (resource.group) p.set('apiGroup', resource.group)
2313
+ p.set('full', '1')
2314
+ navigate({ pathname: `/resources/${pluralKind}`, search: p.toString() })
1289
2315
  }}
1290
2316
  />
1291
2317
  )}
1292
2318
 
1293
- {/* Helm release drawer */}
1294
- {helmDrawer.shouldRender && drawerHelmRelease && (
2319
+ {/* Helm release drawer — same contentReady gate as the resource drawer. */}
2320
+ {contentReady && helmDrawer.shouldRender && drawerHelmRelease && (
1295
2321
  <HelmReleaseDrawer
1296
2322
  release={drawerHelmRelease}
1297
2323
  isOpen={helmDrawer.isOpen}
2324
+ rightInset={contentGutter}
1298
2325
  onClose={() => {
1299
2326
  setSelectedHelmRelease(null)
1300
2327
  const params = new URLSearchParams(window.location.search)
1301
2328
  params.delete('release')
2329
+ params.delete('releaseStorage')
1302
2330
  setSearchParams(params, { replace: true })
1303
2331
  }}
1304
2332
  onNavigateToResource={(resource) => {
1305
- // Navigate to resources view with kind in path and open the resource detail drawer
1306
2333
  setSelectedHelmRelease(null)
1307
2334
  const newParams = new URLSearchParams()
1308
2335
  const globalNamespaces = searchParams.get('namespaces')
1309
2336
  if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
2337
+ if (resource.group) newParams.set('apiGroup', resource.group)
1310
2338
  navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1311
2339
  setSelectedResource(resource)
1312
2340
  }}
1313
2341
  />
1314
2342
  )}
1315
2343
 
2344
+ {/* AI investigation panel — an absolute slot in this column (the body frame),
2345
+ below the header and right of the nav rail, sharing the frame with the
2346
+ drawers above. Docked = right slot (pushes content via contentGutter);
2347
+ maximized = fills the frame. */}
2348
+ {diagnoseOpen && <DiagnoseSurface topInset={chromeless ? 0 : APP_HEADER_HEIGHT} />}
2349
+
1316
2350
  {/* Port Forward floating panel (indicator lives in header) */}
1317
2351
  <PortForwardPanel />
1318
2352
 
@@ -1325,8 +2359,12 @@ function AppInner() {
1325
2359
  {/* Spacer for dock */}
1326
2360
  <DockSpacer />
1327
2361
 
1328
- {/* Floating action buttons — bottom-right, above dock */}
1329
- <FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
2362
+ {/* Floating action buttons — embedded only, and not in chromeless (the
2363
+ host owns help/diagnostics chrome). Standalone moved help + bug to
2364
+ visible top-bar icons (the rail owns chrome). */}
2365
+ {!showNavRail && !chromeless && (
2366
+ <FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
2367
+ )}
1330
2368
 
1331
2369
  {/* Keyboard shortcut help overlay */}
1332
2370
  {helpOverlay.shouldRender && <ShortcutHelpOverlay isOpen={helpOverlay.isOpen} onClose={() => setShowHelp(false)} currentView={mainView} />}
@@ -1343,6 +2381,8 @@ function AppInner() {
1343
2381
  if (group) params.set('apiGroup', group)
1344
2382
  else params.delete('apiGroup')
1345
2383
  params.delete('resource')
2384
+ params.delete('full')
2385
+ params.delete('tab')
1346
2386
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
1347
2387
  // Focus the table search after navigation — the user came from ⌘K
1348
2388
  // (keyboard flow) and expects to type a resource name immediately.
@@ -1354,9 +2394,15 @@ function AppInner() {
1354
2394
  { name },
1355
2395
  // Namespace filter from the previous context may not exist in the
1356
2396
  // new one — clear it so resource lists don't silently go empty.
2397
+ // The server clears all per-user picks on context switch already;
2398
+ // local state mirrors that via the namespace-scope effect.
1357
2399
  { onSettled: () => setNamespaces([]) },
1358
2400
  )}
1359
- onSetNamespaces={setNamespaces}
2401
+ onSetNamespaces={(ns) => {
2402
+ if (namespaceScope?.cacheScoped && ns.length !== 1) return
2403
+ setNamespaces(ns)
2404
+ setActiveNamespace.mutate({ namespaces: ns })
2405
+ }}
1360
2406
  onToggleTheme={toggleTheme}
1361
2407
  onShowDiagnostics={() => setShowDiagnostics(true)}
1362
2408
  />
@@ -1365,11 +2411,16 @@ function AppInner() {
1365
2411
  {/* Diagnostics overlay */}
1366
2412
  {diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
1367
2413
 
1368
- {/* Settings dialog */}
1369
- <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
2414
+ {/* Settings dialog — My permissions is rendered inline in its own section */}
2415
+ <SettingsDialog
2416
+ open={showSettings}
2417
+ onClose={() => setShowSettings(false)}
2418
+ />
1370
2419
 
1371
- {/* Debug overlay - only in dev mode */}
1372
- {import.meta.env.DEV && <DebugOverlay />}
2420
+ {/* Debug overlay dev mode, standalone only. Embedded hosts (Radar Hub)
2421
+ own their own dev tooling; ours would collide with theirs bottom-right. */}
2422
+ {import.meta.env.DEV && showNavRail && <DebugOverlay />}
2423
+ </div>
1373
2424
  </div>
1374
2425
  </PortForwardProvider>
1375
2426
  )
@@ -1377,11 +2428,20 @@ function AppInner() {
1377
2428
 
1378
2429
  // Spacer component that adds padding when dock is open
1379
2430
  function DockSpacer() {
1380
- const { tabs, isExpanded } = useDock()
2431
+ const { tabs, isResizing } = useDock()
2432
+ const dockInset = useDockReservedHeight()
1381
2433
  const location = useLocation()
1382
2434
  // Traffic view manages its own layout — spacer would break its flex sizing
1383
2435
  if (tabs.length === 0 || location.pathname === '/traffic') return null
1384
- return <div className="shrink-0" style={{ height: isExpanded ? 300 : 36, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)` }} />
2436
+ return (
2437
+ <div
2438
+ className="shrink-0"
2439
+ style={{
2440
+ height: dockInset,
2441
+ transition: isResizing ? 'none' : `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)`,
2442
+ }}
2443
+ />
2444
+ )
1385
2445
  }
1386
2446
 
1387
2447
  // Floating action buttons that position themselves above the dock
@@ -1394,12 +2454,12 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
1394
2454
  return (
1395
2455
  <div className={`fixed ${bottom} right-4 z-40 flex items-center gap-1.5`}>
1396
2456
  <Tooltip content="Report bug / Diagnostics" position="top">
1397
- <button onClick={onBugReport} className={btnClass}>
2457
+ <button onClick={onBugReport} aria-label="Open diagnostics" className={btnClass}>
1398
2458
  <Bug className="w-3.5 h-3.5" />
1399
2459
  </button>
1400
2460
  </Tooltip>
1401
2461
  <Tooltip content="Keyboard shortcuts (?)" position="top">
1402
- <button onClick={onHelp} className={btnClass}>
2462
+ <button onClick={onHelp} aria-label="Show keyboard shortcuts" className={btnClass}>
1403
2463
  ?
1404
2464
  </button>
1405
2465
  </Tooltip>
@@ -1408,14 +2468,18 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
1408
2468
  }
1409
2469
 
1410
2470
  // Main App component wrapped with providers
1411
- function App() {
2471
+ function App({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
1412
2472
  return (
1413
2473
  <ConnectionProvider>
1414
2474
  <CapabilitiesProvider>
1415
2475
  <ContextSwitchProvider>
1416
2476
  <DockProvider>
1417
2477
  <KeyboardShortcutProvider>
1418
- <AppInner />
2478
+ <AppInner
2479
+ manageDocumentTitle={manageDocumentTitle}
2480
+ documentTitleSuffix={documentTitleSuffix}
2481
+ onClusterLoadStateChange={onClusterLoadStateChange}
2482
+ />
1419
2483
  </KeyboardShortcutProvider>
1420
2484
  </DockProvider>
1421
2485
  </ContextSwitchProvider>
@@ -1551,6 +2615,7 @@ function GitHubStarButton() {
1551
2615
  target="_blank"
1552
2616
  rel="noopener noreferrer"
1553
2617
  onClick={handleClick}
2618
+ aria-label={starred ? 'Open Radar on GitHub' : 'Star Radar on GitHub'}
1554
2619
  className="flex items-center gap-1.5 h-7 px-2 rounded-md transition-colors bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary"
1555
2620
  >
1556
2621
  <svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
@@ -1603,10 +2668,11 @@ function ThemeToggle() {
1603
2668
  const { theme, toggleTheme } = useTheme()
1604
2669
 
1605
2670
  return (
2671
+ <Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
1606
2672
  <button
1607
2673
  onClick={toggleTheme}
2674
+ aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
1608
2675
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1609
- title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
1610
2676
  >
1611
2677
  {theme === 'dark' ? (
1612
2678
  <Sun className="w-4 h-4" />
@@ -1614,6 +2680,7 @@ function ThemeToggle() {
1614
2680
  <Moon className="w-4 h-4" />
1615
2681
  )}
1616
2682
  </button>
2683
+ </Tooltip>
1617
2684
  )
1618
2685
  }
1619
2686