@skyhook-io/radar-app 1.8.2 → 1.8.5

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 (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. package/src/utils/navigation.ts +5 -3
package/src/App.tsx CHANGED
@@ -1,14 +1,14 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { useRefreshAnimation } from './hooks/useRefreshAnimation'
4
3
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
4
+ import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
5
5
  import { useQueryClient } from '@tanstack/react-query'
6
6
  import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
7
7
  import { HomeView } from './components/home/HomeView'
8
8
  import { DebugOverlay } from './components/DebugOverlay'
9
- import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind, gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
9
+ import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill } from '@skyhook-io/k8s-ui'
10
10
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
11
- import { useAPIResources } from './api/apiResources'
11
+ import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
12
12
  import { TimelineView } from './components/timeline/TimelineView'
13
13
  import { ResourcesView } from './components/resources/ResourcesView'
14
14
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -16,6 +16,7 @@ import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawe
16
16
  import { WorkloadViewRoute } from './components/workload/WorkloadView'
17
17
  import { CompareViewRoute } from './components/compare/CompareViewRoute'
18
18
  import { HelmView } from './components/helm/HelmView'
19
+ import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
19
20
  import { TrafficView } from './components/traffic/TrafficView'
20
21
  import { CostView } from './components/cost/CostView'
21
22
  import { AuditView } from './components/audit/AuditView'
@@ -44,19 +45,21 @@ import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
44
45
  import { CommandPalette } from './components/ui/CommandPalette'
45
46
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
46
47
  import { useEventSource } from './hooks/useEventSource'
47
- import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
48
+ import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
49
+ import { buildAuditSeverityMap } from './utils/auditBadges'
48
50
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
49
- import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
51
+ import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
50
52
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
53
+ import { useDocumentTitle } from './hooks/useDocumentTitle'
51
54
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
52
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
55
+ import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
53
56
  import { useTheme } from './context/ThemeContext'
54
57
  import { Tooltip } from './components/ui/Tooltip'
55
58
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
56
59
  import { SettingsDialog } from './components/settings/SettingsDialog'
57
60
  import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
58
61
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
59
- import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
62
+ import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
60
63
  import { type OmnibarHandle } from './components/ui/Omnibar'
61
64
  import { RadarOmnibar } from './components/ui/RadarOmnibar'
62
65
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
@@ -102,10 +105,11 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
102
105
 
103
106
  // Convert API resource name back to topology node ID prefix
104
107
  // Extended MainView type that includes traffic and cost
105
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
108
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
106
109
 
107
110
  // Extract view from URL path
108
111
  function getViewFromPath(pathname: string): ExtendedMainView {
112
+ if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
109
113
  const path = pathname.replace(/^\//, '').split('/')[0]
110
114
  if (path === '' || path === 'home') return 'home'
111
115
  if (path === 'topology') return 'topology'
@@ -123,6 +127,96 @@ function getViewFromPath(pathname: string): ExtendedMainView {
123
127
  return 'home'
124
128
  }
125
129
 
130
+ // The namespace scope filter is meaningful only on namespaced surfaces. On
131
+ // cluster-scoped views it does nothing, so we disable it with an explanation
132
+ // rather than leaving a dead control that silently ignores the pick:
133
+ // - Cost is reported per-namespace across the whole cluster (the view IS the
134
+ // breakdown; a filter would only hide rows).
135
+ // - A GitOps detail tree spans namespaces — its controller lives in one
136
+ // namespace but manages workloads across many.
137
+ // - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
138
+ // namespace at all.
139
+ // The pick itself is preserved so it re-applies when the user returns to a
140
+ // namespaced view.
141
+ function namespaceFilterDisabled(
142
+ view: ExtendedMainView,
143
+ pathname: string,
144
+ apiResources?: { name: string; kind: string; namespaced: boolean }[],
145
+ ): { disabled: boolean; tooltip?: string } {
146
+ if (view === 'cost') {
147
+ return {
148
+ disabled: true,
149
+ tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
150
+ }
151
+ }
152
+ const segments = pathname.replace(/^\//, '').split('/')
153
+ if (view === 'gitops' && segments[1] === 'detail') {
154
+ return {
155
+ disabled: true,
156
+ tooltip: 'This resource manages workloads across namespaces — the namespace filter doesn’t apply to its tree.',
157
+ }
158
+ }
159
+ if (view === 'resources') {
160
+ const kindSlug = segments[1]
161
+ const match = kindSlug ? apiResources?.find(r => r.name === kindSlug) : undefined
162
+ if (match && !match.namespaced) {
163
+ return {
164
+ disabled: true,
165
+ tooltip: `${match.kind} is a cluster-scoped resource — namespaces don’t apply.`,
166
+ }
167
+ }
168
+ }
169
+ return { disabled: false }
170
+ }
171
+
172
+ // Browser tab label for every Radar view, derived from the route URL so it's
173
+ // correct regardless of which component renders it. A detail drawer that opens
174
+ // over a list (?resource=…) is deliberately NOT titled — it's the same page, so
175
+ // it keeps the list's title.
176
+ function radarPageTitle(pathname: string, search = '', apiResources?: { name: string; kind: string; group?: string }[]): string | null {
177
+ const decode = (s: string) => {
178
+ try {
179
+ return decodeURIComponent(s)
180
+ } catch {
181
+ return s
182
+ }
183
+ }
184
+ const capitalize = (text: string) =>
185
+ text ? text.charAt(0).toUpperCase() + text.slice(1) : text
186
+ const pluralKindTitle = (kind: string, resourceName: string) =>
187
+ kind.toLowerCase() === resourceName.toLowerCase() ? kind : englishPlural(kind)
188
+ const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
189
+ const view = getViewFromPath(pathname)
190
+
191
+ // Full-page resource detail: /workload/<kind>/<ns>/<name> (name may contain '/').
192
+ if (view === 'workload') return pathSegments.slice(3).map(decode).join('/') || null
193
+ // Resources is browsed per-kind: /resources/<kind> → "<Kind>" (e.g. ConfigMap);
194
+ // bare /resources (before it redirects to a default kind) → "Resources".
195
+ if (view === 'resources') {
196
+ const resourceName = decode(pathSegments[1] ?? '')
197
+ if (!resourceName) return 'Resources'
198
+ const match = apiResources?.find((r) => r.name === resourceName)
199
+ return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
200
+ }
201
+ // GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
202
+ // anything else (the list) → "GitOps".
203
+ if (view === 'gitops')
204
+ return pathSegments[1] === 'detail' ? decode(pathSegments[4] ?? '') || 'GitOps' : 'GitOps'
205
+ if (view === 'applications') {
206
+ const appKey = new URLSearchParams(search).get('app')
207
+ if (!appKey) return 'Applications'
208
+ const decoded = decode(appKey)
209
+ const slash = decoded.lastIndexOf('/')
210
+ return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
211
+ }
212
+
213
+ // The landing view reads "Overview" rather than "Home" in the tab.
214
+ if (view === 'home') return 'Overview'
215
+ // Every other view's label is its id capitalized — getViewFromPath has already
216
+ // normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
217
+ return capitalize(view)
218
+ }
219
+
126
220
  function AuthBarrier({ authMode }: { authMode: string }) {
127
221
  useEffect(() => {
128
222
  if (authMode === 'oidc') {
@@ -133,12 +227,12 @@ function AuthBarrier({ authMode }: { authMode: string }) {
133
227
  if (authMode === 'oidc') {
134
228
  return (
135
229
  <div className="flex-1 relative bg-theme-base">
136
- <div className="fixed inset-0 pointer-events-none">
230
+ <div className="absolute inset-0 pointer-events-none">
137
231
  <img
138
232
  src={radarLoadingIcon}
139
233
  alt=""
140
234
  aria-hidden
141
- // Integer offset (vw/2 − 22) — matches the Connecting/Opening splashes;
235
+ // Integer offset (50% − 22) — matches the Connecting/Opening splashes;
142
236
  // avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
143
237
  className="absolute w-11 h-11"
144
238
  style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
@@ -182,10 +276,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
182
276
  // detail to list would otherwise leave the peek orphaned. Only `app` is included
183
277
  // (not the whole query) so filter/tab/namespace churn doesn't close the peek.
184
278
  function peekOwnerKey(pathname: string, search: string): string {
185
- return `${pathname}${new URLSearchParams(search).get('app') ?? ''}`
279
+ return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
186
280
  }
187
281
 
188
- function AppInner() {
282
+ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
189
283
  const navigate = useNavigate()
190
284
  const location = useLocation()
191
285
  const navigationType = useNavigationType()
@@ -279,6 +373,25 @@ function AppInner() {
279
373
  // Get mainView from URL path
280
374
  const mainView = getViewFromPath(location.pathname)
281
375
 
376
+ // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
377
+ // mount) so the omnibar can open a CRD hit with an irregular plural from any
378
+ // view — kindToPlural would otherwise English-guess the route before a
379
+ // resources view has run initNavigationMap().
380
+ const { data: navApiResources } = useAPIResources()
381
+ useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
382
+
383
+ // View-aware namespace scope: disabled on cluster-scoped surfaces so the
384
+ // chip isn't a dead control next to the cluster switcher.
385
+ // Fall back to the static core-resource list before discovery loads, so the
386
+ // chip disables immediately on a cluster-scoped core kind (Nodes, PVs, …)
387
+ // instead of flickering enabled until /api-resources resolves.
388
+ const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, navApiResources ?? CORE_RESOURCES)
389
+
390
+ // One URL-derived tab title for every view (see radarPageTitle). Driving it
391
+ // from the URL — not the mounted component. Off unless the host opts in
392
+ // (standalone passes manageDocumentTitle), so embedders keep title ownership.
393
+ useDocumentTitle(manageDocumentTitle ? radarPageTitle(location.pathname, location.search, navApiResources) : null, documentTitleSuffix)
394
+
282
395
  // Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
283
396
  const normalizedResourcesKindSlug = useMemo(() => {
284
397
  const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
@@ -387,15 +500,6 @@ function AppInner() {
387
500
  const [showSettings, setShowSettings] = useState(false)
388
501
  const [showMyPermissions, setShowMyPermissions] = useState(false)
389
502
 
390
- // Listen for desktop "open-settings" event from native menu
391
- useEffect(() => {
392
- const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
393
- | { EventsOn?: (event: string, callback: () => void) => () => void }
394
- | undefined
395
- if (!wailsRuntime?.EventsOn) return
396
- return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
397
- }, [])
398
-
399
503
  // Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
400
504
  useEffect(() => {
401
505
  const handler = () => setShowSettings(true)
@@ -406,11 +510,16 @@ function AppInner() {
406
510
  // Diagnostics overlay state
407
511
  const [showDiagnostics, setShowDiagnostics] = useState(false)
408
512
 
409
- // Drawer expanded state (drawer grows to full width and renders WorkloadView)
410
- const [drawerExpanded, setDrawerExpanded] = useState(false)
513
+ // The peek drawer "expanded" into a fullscreen overlay = ?full=1 with a selected
514
+ // resource, on ANY view (resources list, topology graph, GitOps, Applications…)
515
+ // the underlying view stays mounted. URL-derived so Back/Forward/refresh behave
516
+ // (non-list peeks aren't URL-backed, so refresh drops the overlay gracefully).
517
+ // Used by the routing effects below; the render uses `expandedView` (gated on what
518
+ // actually renders) — see further down.
519
+ const drawerExpanded = !!selectedResource && searchParams.get('full') === '1'
411
520
 
412
- // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
413
- const suppressViewClearRef = useRef(false)
521
+ // On mobile there's no room for the side drawer a resource detail is always full-screen.
522
+ const isMobile = useMediaQuery('(max-width: 639px)')
414
523
 
415
524
  // On a history Pop (back/forward) the URL is authoritative. The URL-write
416
525
  // effect, running with not-yet-synced state, would otherwise write the stale
@@ -443,9 +552,9 @@ function AppInner() {
443
552
  // URL backing, so the only signal that the page beneath it has navigated is
444
553
  // that its owner-key (pathname + ?app) no longer matches where it was opened.
445
554
  // Hiding it here, at render time, closes the orphan on Back without adding
446
- // another clearing effect that would race the `suppressViewClearRef` lifecycle.
447
- // The /resources case is URL-backed and handled above; an expanded drawer
448
- // (drawerExpanded) legitimately lives at /workload and must stay open there.
555
+ // another clearing effect. The /resources case is URL-backed and handled above;
556
+ // an expanded drawer (drawerExpanded) only exists on /resources (?full=1), so it
557
+ // is excluded here and never treated as an orphan.
449
558
  const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
450
559
  peekOwnerKeyRef.current !== null &&
451
560
  peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
@@ -472,7 +581,6 @@ function AppInner() {
472
581
 
473
582
  if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
474
583
  setSelectedResource(null)
475
- setDrawerExpanded(false)
476
584
  }
477
585
  }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
478
586
 
@@ -488,6 +596,19 @@ function AppInner() {
488
596
  if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
489
597
  const drawerResource = routeSelectedResource || lastResourceRef.current
490
598
 
599
+ // Effective fullscreen state — keyed off the resource that's ACTUALLY rendering
600
+ // (routeSelectedResource), not the raw selection, so an orphaned/mismatched peek
601
+ // can't inert the shell with no visible drawer. ?full=1 on any view, or forced on
602
+ // mobile (no room for a side drawer). Drives the inert backdrop + shortcut suppression.
603
+ const expandedView = !!routeSelectedResource && (searchParams.get('full') === '1' || isMobile)
604
+ useSuppressBaseShortcuts(expandedView)
605
+ // Held value for the drawer's `expanded` prop so closing an expanded drawer slides
606
+ // it out at full size instead of running a collapse morph mid-dismiss. Tracks the
607
+ // live state while a resource is selected; frozen during the slide-out.
608
+ const lastExpandedRef = useRef(expandedView)
609
+ if (routeSelectedResource) lastExpandedRef.current = expandedView
610
+ const drawerExpandedProp = routeSelectedResource ? expandedView : lastExpandedRef.current
611
+
491
612
  const lastHelmReleaseRef = useRef(selectedHelmRelease)
492
613
  if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
493
614
  const drawerHelmRelease = selectedHelmRelease || lastHelmReleaseRef.current
@@ -526,6 +647,10 @@ function AppInner() {
526
647
  newParams.delete('kind')
527
648
  newParams.delete('mode')
528
649
  newParams.delete('group')
650
+ // Open as a normal drawer — never inherit a stale ?full=1/tab from an
651
+ // expanded view we're navigating away from (only expand/drill set those).
652
+ newParams.delete('full')
653
+ newParams.delete('tab')
529
654
  newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
530
655
  if (resource.group) {
531
656
  newParams.set('apiGroup', resource.group)
@@ -535,11 +660,32 @@ function AppInner() {
535
660
  navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
536
661
  }, [searchParams, navigate])
537
662
 
538
- // From the Issues queue: a GitOps reconciler subject (Argo Application / Flux
539
- // Kustomization / HelmRelease) routes to its rich detail page (tree + insights
540
- // + ops), not the generic resource drawer that's a dead-end for it. Member
541
- // resources (Pods, Services, …) fall through to the standard resource view.
663
+ const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
664
+ const newParams = new URLSearchParams()
665
+ const globalNamespaces = searchParams.get('namespaces')
666
+ if (globalNamespaces) {
667
+ newParams.set('namespaces', globalNamespaces)
668
+ }
669
+ newParams.set('release', `${namespace}/${name}`)
670
+ if (storageNamespace) {
671
+ newParams.set('releaseStorage', storageNamespace)
672
+ }
673
+ setSelectedHelmRelease({ namespace, name, storageNamespace })
674
+ if (mainView === 'helm') {
675
+ setSearchParams(newParams, { replace: true })
676
+ return
677
+ }
678
+ navigate({ pathname: '/helm', search: newParams.toString() })
679
+ }, [mainView, searchParams, navigate, setSearchParams])
680
+
681
+ // From the Issues queue: special controller/manager subjects route to their
682
+ // rich detail pages, not the generic resource drawer that's a dead-end for
683
+ // them. Member resources (Pods, Services, …) fall through to resources.
542
684
  const navigateFromIssue = useCallback((resource: SelectedResource) => {
685
+ if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
686
+ navigateToHelmRelease(resource.namespace, resource.name)
687
+ return
688
+ }
543
689
  const gitOpsPath = gitOpsRouteForResource({
544
690
  apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
545
691
  kind: resource.kind,
@@ -550,14 +696,35 @@ function AppInner() {
550
696
  return
551
697
  }
552
698
  navigateToResourceList(resource)
553
- }, [navigate, navigateToResourceList])
554
-
555
- // Collapse from expanded WorkloadView back to drawer
699
+ }, [navigate, navigateToHelmRelease, navigateToResourceList])
700
+
701
+ // Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
702
+ // resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
703
+ // regardless of how we got here (expand, deep link, or a drill trail), so it
704
+ // scrubs rather than walking history — `navigate(-1)` would leave the app on a
705
+ // deep link, or step back to the previous resource after a drill. Browser Back
706
+ // keeps its own natural history walk (it pops the ?full=1 entry → collapse).
556
707
  const handleCollapseFromExpanded = useCallback(() => {
557
- suppressViewClearRef.current = true
558
- setDrawerExpanded(false)
559
- navigate(-1)
560
- }, [navigate])
708
+ const p = new URLSearchParams(searchParams)
709
+ p.delete('full')
710
+ p.delete('tab')
711
+ setSearchParams(p, { replace: true })
712
+ }, [searchParams, setSearchParams])
713
+
714
+ // Close the peek and drop any expand flags. Outside /resources the drawer isn't
715
+ // URL-backed, so a lingering ?full=1/tab would make the next peek reopen
716
+ // fullscreen instead of as a side drawer. (On /resources, ResourcesView's own
717
+ // updateURL also scrubs these — deleting them here too is idempotent.)
718
+ const closeDrawer = useCallback(() => {
719
+ setSelectedResource(null)
720
+ setDrawerInitialTab('detail')
721
+ if (searchParams.has('full') || searchParams.has('tab')) {
722
+ const p = new URLSearchParams(searchParams)
723
+ p.delete('full')
724
+ p.delete('tab')
725
+ setSearchParams(p, { replace: true })
726
+ }
727
+ }, [searchParams, setSearchParams])
561
728
 
562
729
  // Theme toggle for keyboard shortcut
563
730
  const { toggleTheme } = useTheme()
@@ -569,12 +736,6 @@ function AppInner() {
569
736
  const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
570
737
  const omnibarRef = useRef<OmnibarHandle>(null)
571
738
 
572
- // Initialize the kind→plural discovery map app-wide (not just on ResourcesView
573
- // mount) so the omnibar can open a CRD hit with an irregular plural from any
574
- // view — kindToPlural would otherwise English-guess the route before a
575
- // resources view has run n().
576
- const { data: navApiResources } = useAPIResources()
577
- useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
578
739
  const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
579
740
 
580
741
  // View switching keyboard shortcuts
@@ -590,7 +751,7 @@ function AppInner() {
590
751
  gitops: 'g o', checks: 'g u', cost: 'g c',
591
752
  // Non-rail views (reachable via deep links / actions, not the rail) get no
592
753
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
593
- workload: '', compare: '',
754
+ workload: '', compare: '', helmCompare: '',
594
755
  }
595
756
  const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
596
757
  (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
@@ -724,6 +885,16 @@ function AppInner() {
724
885
  // Connection state (for graceful startup)
725
886
  const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
726
887
 
888
+ // The app's content surface is ready to show: auth resolved, not mid context-
889
+ // switch, and the cluster connection is live. The main content area gates on
890
+ // exactly this, and so do the overlay drawers — otherwise a deep-link/refresh
891
+ // with `?resource=`/`?release=` renders the drawer on top of the connecting/
892
+ // switching splash, pushing the centered loading logo off-center and showing an
893
+ // empty drawer over a not-yet-loaded view. Gating both on the SAME readiness so
894
+ // a drawer only ever sits over a real content surface.
895
+ const contentReady = !isSwitching && !authMePending &&
896
+ !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
897
+
727
898
  // Query client for cache invalidation
728
899
  const queryClient = useQueryClient()
729
900
 
@@ -822,7 +993,7 @@ function AppInner() {
822
993
  // forceNamespaceFilter is only set for large clusters that require server-side filtering.
823
994
  // Fleet mode uses 'resources' topology on the backend — filtering is client-side
824
995
  const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
825
- const { topology, connected, reconnect: reconnectSSE } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
996
+ const { topology } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
826
997
  onContextSwitchComplete: endSwitch,
827
998
  onContextSwitchProgress: updateProgress,
828
999
  onContextChanged: () => {
@@ -839,8 +1010,8 @@ function AppInner() {
839
1010
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
840
1011
 
841
1012
  // Close any open drawers/overlays — old cluster's resources don't exist on the new one
1013
+ // (?full=1 is cleared by the URL reset below).
842
1014
  setSelectedResource(null)
843
- setDrawerExpanded(false)
844
1015
  setSelectedHelmRelease(null)
845
1016
 
846
1017
  // Reset URL to current view with no resource-specific params.
@@ -859,7 +1030,7 @@ function AppInner() {
859
1030
  },
860
1031
  onK8sEvent: handleK8sEvent,
861
1032
  }, forceNamespaceFilter, showPolicyEffect)
862
- const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
1033
+ const clusterConnected = connection.state === 'connected'
863
1034
 
864
1035
  // On large clusters (where the server requires namespace filtering), keep
865
1036
  // SSE's server-side filter in lockstep with the user's namespace pick.
@@ -986,12 +1157,13 @@ function AppInner() {
986
1157
  // mutation immediately, which re-introduces the same race. The state→URL
987
1158
  // effect propagates state=[] → URL on its own after onSuccess flips state.
988
1159
  const clearAllNamespaces = useCallback(() => {
1160
+ if (namespaceScope?.cacheScoped) return
989
1161
  if (namespaces.length === 0) return
990
1162
  setActiveNamespace.mutate(
991
1163
  { namespaces: [] },
992
1164
  { onSuccess: () => setNamespaces([]) },
993
1165
  )
994
- }, [namespaces.length, setActiveNamespace])
1166
+ }, [namespaceScope?.cacheScoped, namespaces.length, setActiveNamespace])
995
1167
  const initialBookmarkReconciledRef = useRef(false)
996
1168
  const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
997
1169
  const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
@@ -1015,6 +1187,14 @@ function AppInner() {
1015
1187
  if (!initialBookmarkReconciledRef.current) {
1016
1188
  initialBookmarkReconciledRef.current = true
1017
1189
  if (!sameAsState && sortedState.length > 0) {
1190
+ if (namespaceScope.cacheScoped && (!namespaceScope.namespaceRescope || sortedState.length !== 1)) {
1191
+ debugNamespaceLog('app:scope-mirror-cache-scope-preserve', {
1192
+ stateNamespaces: sortedState,
1193
+ scopeActives: sortedScope,
1194
+ })
1195
+ setNamespaces(scopeActives)
1196
+ return
1197
+ }
1018
1198
  debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
1019
1199
  stateNamespaces: sortedState,
1020
1200
  scopeActives: sortedScope,
@@ -1117,6 +1297,12 @@ function AppInner() {
1117
1297
  })
1118
1298
 
1119
1299
  if (urlNamespaces.join(',') !== namespacesKey) {
1300
+ if (namespaceScope?.cacheScoped && (!namespaceScope.namespaceRescope || urlNamespaces.length !== 1)) {
1301
+ const scopedNamespaces = namespaceScope.actives ?? []
1302
+ debugNamespaceLog('app:url-sync-cache-scope-preserve', { scopedNamespaces })
1303
+ setNamespaces(scopedNamespaces)
1304
+ return
1305
+ }
1120
1306
  debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
1121
1307
  setNamespaces(urlNamespaces)
1122
1308
  if (namespaceScope) {
@@ -1190,15 +1376,7 @@ function AppInner() {
1190
1376
  // But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
1191
1377
  const prevMainView = useRef(mainView)
1192
1378
  useEffect(() => {
1193
- // Skip clearing during controlled expand/collapse transitions
1194
- if (suppressViewClearRef.current) {
1195
- suppressViewClearRef.current = false
1196
- prevMainView.current = mainView
1197
- return
1198
- }
1199
-
1200
1379
  const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
1201
- const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
1202
1380
  prevMainView.current = mainView
1203
1381
 
1204
1382
  // The URL is the source of truth for what's selected. A deep link
@@ -1207,14 +1385,15 @@ function AppInner() {
1207
1385
  // asserts. (On a real view switch the URL no longer carries the param, so
1208
1386
  // the clear proceeds.) Without this, deep-linking straight to a Helm
1209
1387
  // release lands on the release list with no drawer.
1388
+ // (drawerExpanded is URL-derived from ?full=1, so leaving /resources drops it
1389
+ // automatically — no explicit reset needed.)
1210
1390
  const params = new URLSearchParams(window.location.search)
1211
1391
  if (!navigatingToResources && !params.has('resource')) {
1212
1392
  setSelectedResource(null)
1213
1393
  }
1214
- if (!navigatingToHelm && !params.has('release')) {
1394
+ if (!params.has('release')) {
1215
1395
  setSelectedHelmRelease(null)
1216
1396
  }
1217
- setDrawerExpanded(false)
1218
1397
  }, [mainView])
1219
1398
 
1220
1399
  // Clear resource selection when namespaces change — but keep a selection the
@@ -1224,7 +1403,6 @@ function AppInner() {
1224
1403
  const params = new URLSearchParams(window.location.search)
1225
1404
  if (!params.has('resource')) setSelectedResource(null)
1226
1405
  if (!params.has('release')) setSelectedHelmRelease(null)
1227
- setDrawerExpanded(false)
1228
1406
  }, [namespacesKey])
1229
1407
 
1230
1408
  // Filter topology based on visible kinds (uses displayedTopology which respects pause)
@@ -1263,6 +1441,29 @@ function AppInner() {
1263
1441
  }
1264
1442
  }, [displayedTopology, visibleKinds, namespaces, topologyMode])
1265
1443
 
1444
+ // Cluster Audit findings, joined onto topology nodes by the audit key the
1445
+ // backend stamps on each node (data.auditKey). The graph surfaces DANGER only
1446
+ // (warnings would turn a dense graph into a heatmap); the node component reads
1447
+ // data.auditDanger. Re-runs only when findings change, and copies nodes only
1448
+ // when there are findings to attach — no overhead on clusters with none.
1449
+ const audit = useAudit(namespaces)
1450
+ const auditSeverityMap = useMemo(
1451
+ () => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
1452
+ [audit.data?.findings, audit.data?.checks],
1453
+ )
1454
+ const topologyWithAudit = useMemo((): Topology | null => {
1455
+ if (!filteredTopology) return null
1456
+ if (auditSeverityMap.size === 0) return filteredTopology
1457
+ return {
1458
+ ...filteredTopology,
1459
+ nodes: filteredTopology.nodes.map(node => {
1460
+ const counts = auditSeverityMap.get(node.data.auditKey as string)
1461
+ if (!counts) return node
1462
+ return { ...node, data: { ...node.data, auditDanger: counts.danger, auditWarning: counts.warning, auditMessages: counts.messages } }
1463
+ }),
1464
+ }
1465
+ }, [filteredTopology, auditSeverityMap])
1466
+
1266
1467
  // The graph node id of the currently open resource, used to highlight it on
1267
1468
  // the canvas. Looked up from the topology (not reconstructed) because node
1268
1469
  // ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
@@ -1306,6 +1507,8 @@ function AppInner() {
1306
1507
  setVisibleKinds(new Set())
1307
1508
  }, [])
1308
1509
 
1510
+ const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
1511
+
1309
1512
  return (
1310
1513
  <PortForwardProvider>
1311
1514
  {/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
@@ -1318,7 +1521,7 @@ function AppInner() {
1318
1521
  >
1319
1522
  {showNavRail && (
1320
1523
  <PrimaryNavRail
1321
- activeView={mainView}
1524
+ activeView={navActiveView}
1322
1525
  onNavigate={setMainView}
1323
1526
  pinned={navRailEffectivePinned}
1324
1527
  onTogglePinned={toggleNavRailPinned}
@@ -1335,20 +1538,58 @@ function AppInner() {
1335
1538
  {/* Header — suppressed in chromeless embed; the host owns the chrome. */}
1336
1539
  {!chromeless && (
1337
1540
  <header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
1338
- {/* Left: Logo + Cluster info */}
1339
- <div className="flex items-center gap-4 shrink-0">
1541
+ {/* Left: Logo + Cluster info. In the standalone (nav-rail) layout this
1542
+ is a FIXED-WIDTH column so the omnibar after it is force-pinned: the
1543
+ scope pill + status dot can change width (cluster/namespace value)
1544
+ without ever shifting the search box. The pill's own name/value caps
1545
+ keep it inside this width; the embedded/pill layout keeps auto width
1546
+ (its center bar is absolutely positioned). */}
1547
+ <div className={`flex items-center gap-4 shrink-0 ${showNavRail ? 'w-[492px]' : ''}`}>
1340
1548
  {/* Standalone rail owns the brand; only the embedded/pill layout
1341
1549
  shows it in the header (host may override via brandSlot). */}
1342
1550
  {navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
1343
1551
 
1344
- <div className="flex items-center gap-2">
1345
- {navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
1346
- {/* Connection status - next to cluster name */}
1347
- <div className="flex items-center gap-1.5 ml-1">
1552
+ <div className={`flex items-center gap-2 min-w-0 ${showNavRail ? 'flex-1' : ''}`}>
1553
+ {navCustomization.contextSlot ? (
1554
+ // Embedded host supplies its own cluster switcher — keep the two
1555
+ // controls separate (the host owns the cluster chip's styling).
1556
+ <>
1557
+ {navCustomization.contextSlot}
1558
+ <NamespaceSwitcher
1559
+ ref={namespaceSwitcherRef}
1560
+ disabled={namespaceFilter.disabled}
1561
+ disabledTooltip={namespaceFilter.tooltip}
1562
+ />
1563
+ </>
1564
+ ) : (
1565
+ // Standalone: cluster + namespace as one "scope" pill — two
1566
+ // borderless segments split by a divider, reading as a single
1567
+ // "what am I looking at" unit. The shared ScopePill shell is the
1568
+ // same one Radar Hub's cluster top bar uses, so the two match.
1569
+ <ScopePill>
1570
+ <ContextSwitcher ref={contextSwitcherRef} variant="segment" />
1571
+ <NamespaceSwitcher
1572
+ ref={namespaceSwitcherRef}
1573
+ variant="segment"
1574
+ disabled={namespaceFilter.disabled}
1575
+ disabledTooltip={namespaceFilter.tooltip}
1576
+ />
1577
+ </ScopePill>
1578
+ )}
1579
+ {/* Connection status — a fixed-size dot (state in the tooltip; the
1580
+ dot is the reconnect control when disconnected) plus, when the
1581
+ header is wide enough (xl+), a label. The label is nowrap and
1582
+ unbounded: it overflows the fixed left column into the empty gap
1583
+ before the centered search box rather than shifting anything (the
1584
+ dot + pill are shrink-0, so layout stays put — the search box
1585
+ never moves). Where the gap is smaller than the label, its tail
1586
+ tucks under the omnibar's solid background. Below xl it's the dot
1587
+ alone. */}
1588
+ <div className="ml-1 flex items-center gap-1.5 shrink-0">
1348
1589
  <Tooltip
1349
1590
  content={
1350
- !connected
1351
- ? 'Disconnected'
1591
+ !clusterConnected
1592
+ ? 'Cluster disconnected — click to reconnect'
1352
1593
  : crdDiscoveryStatus === 'discovering'
1353
1594
  ? 'Connected — discovering Custom Resources...'
1354
1595
  : 'Connected'
@@ -1356,38 +1597,29 @@ function AppInner() {
1356
1597
  delay={100}
1357
1598
  position="bottom"
1358
1599
  >
1359
- <span
1360
- className={`w-2 h-2 rounded-full ${
1361
- !connected
1362
- ? 'bg-red-500'
1363
- : crdDiscoveryStatus === 'discovering'
1364
- ? 'bg-amber-400 animate-pulse'
1365
- : 'bg-green-500'
1366
- }`}
1367
- />
1600
+ {clusterConnected ? (
1601
+ <span
1602
+ className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
1603
+ crdDiscoveryStatus === 'discovering' ? 'bg-amber-400 animate-pulse' : 'bg-green-500'
1604
+ }`}
1605
+ />
1606
+ ) : (
1607
+ <button
1608
+ type="button"
1609
+ onClick={retryConnection}
1610
+ disabled={isRetrying}
1611
+ aria-label="Cluster disconnected — reconnect"
1612
+ className="block shrink-0 disabled:cursor-default"
1613
+ >
1614
+ <span className={`block w-2.5 h-2.5 rounded-full bg-red-500 ${isRetrying ? 'animate-pulse' : ''}`} />
1615
+ </button>
1616
+ )}
1368
1617
  </Tooltip>
1369
- {/* Inline label only for non-steady states where the user
1370
- might need to act or wait. The healthy "Connected" case
1371
- is the dot alone; the dot's tooltip discloses it. Keeping
1372
- "Connected" text here would expand the left section and
1373
- collide with the absolute-centered nav block at xl, which
1374
- is the same breakpoint where nav labels appear. */}
1375
- {(!connected || crdDiscoveryStatus === 'discovering') && (
1376
- <span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
1377
- {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
1618
+ {showNavRail && (!clusterConnected || crdDiscoveryStatus === 'discovering') && (
1619
+ <span className="hidden xl:block whitespace-nowrap text-[11px] text-theme-text-tertiary">
1620
+ {!clusterConnected ? 'Disconnected' : 'Discovering Custom Resources…'}
1378
1621
  </span>
1379
1622
  )}
1380
- {!connected && (
1381
- <Tooltip content="Reconnect">
1382
- <button
1383
- onClick={reconnect}
1384
- disabled={isReconnecting}
1385
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1386
- >
1387
- <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
1388
- </button>
1389
- </Tooltip>
1390
- )}
1391
1623
  </div>
1392
1624
  {/* Port forwards indicator — shown only when sessions exist */}
1393
1625
  <PortForwardIndicator />
@@ -1429,7 +1661,7 @@ function AppInner() {
1429
1661
  <button
1430
1662
  onClick={() => setMainView(view)}
1431
1663
  className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
1432
- mainView === view
1664
+ mainView === view || (mainView === 'helmCompare' && view === 'helm')
1433
1665
  ? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
1434
1666
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
1435
1667
  }`}
@@ -1453,7 +1685,12 @@ function AppInner() {
1453
1685
  )}
1454
1686
 
1455
1687
  {/* Center: omnibar — standalone search + command surface (the ⌘K entry).
1456
- Fills the space the pill bar left; embedded keeps the pills + modal. */}
1688
+ Its container is one of three EQUAL flex-1 columns (see the left/right
1689
+ groups): equal side columns keep this middle column — and the search
1690
+ box centered in it — pinned regardless of how wide the left-side
1691
+ chrome gets (cluster name, namespace label, "Discovering…" /
1692
+ "Disconnected" text). Overflowing side content truncates instead of
1693
+ dragging the box. Same pattern as Radar Hub's ClusterTopBar. */}
1457
1694
  {showNavRail && (
1458
1695
  <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1459
1696
  <RadarOmnibar
@@ -1464,6 +1701,8 @@ function AppInner() {
1464
1701
  params.delete('kind')
1465
1702
  if (group) params.set('apiGroup', group); else params.delete('apiGroup')
1466
1703
  params.delete('resource')
1704
+ params.delete('full')
1705
+ params.delete('tab')
1467
1706
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
1468
1707
  }}
1469
1708
  onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
@@ -1477,11 +1716,6 @@ function AppInner() {
1477
1716
 
1478
1717
  {/* Right: Controls */}
1479
1718
  <div className="flex items-center gap-3 shrink-0">
1480
- <NamespaceSwitcher
1481
- ref={namespaceSwitcherRef}
1482
- />
1483
-
1484
-
1485
1719
  {/* Command palette trigger — embedded only; standalone has the
1486
1720
  top-center omnibar (which is the ⌘K surface). */}
1487
1721
  {!showNavRail && (
@@ -1522,7 +1756,7 @@ function AppInner() {
1522
1756
  to the host's cookie/backend) and the user would see the theme
1523
1757
  bounce on every navigation between host routes and /c/:id. */}
1524
1758
  {!navCustomization.embedded && (
1525
- <div className="hidden md:block">
1759
+ <div className="hidden md:flex items-center">
1526
1760
  <ThemeToggle />
1527
1761
  </div>
1528
1762
  )}
@@ -1579,20 +1813,20 @@ function AppInner() {
1579
1813
  )}
1580
1814
 
1581
1815
  {/* Connecting view — shown during initial connection or retry.
1582
- Icon is viewport-anchored so its screen position matches the
1816
+ Icon is pane-anchored so its screen position matches the
1583
1817
  host hub splash across cross-document transitions. */}
1584
1818
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
1585
1819
  <div className="flex-1 relative bg-theme-base">
1586
- {/* Icon absolutely anchored to viewport-center. The label block
1820
+ {/* Icon absolutely anchored to the pane center. The label block
1587
1821
  sits at a fixed offset below — independent of label height
1588
1822
  so multi-line messages (context + progress) don't shift the
1589
1823
  icon's screen position. */}
1590
- <div className="fixed inset-0 pointer-events-none">
1824
+ <div className="absolute inset-0 pointer-events-none">
1591
1825
  <img
1592
1826
  src={radarLoadingIcon}
1593
1827
  alt=""
1594
1828
  aria-hidden
1595
- // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1829
+ // Integer offset (50% − 22) — avoids sub-pixel jitter from
1596
1830
  // `translate(-50%, -50%)` on odd-width viewports.
1597
1831
  className="absolute w-11 h-11"
1598
1832
  style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
@@ -1620,15 +1854,15 @@ function AppInner() {
1620
1854
  </div>
1621
1855
  )}
1622
1856
 
1623
- {/* Context switching overlay — icon viewport-anchored, label below. */}
1857
+ {/* Context switching overlay — icon pane-anchored, label below. */}
1624
1858
  {isSwitching && (
1625
1859
  <div className="flex-1 relative bg-theme-base">
1626
- <div className="fixed inset-0 pointer-events-none">
1860
+ <div className="absolute inset-0 pointer-events-none">
1627
1861
  <img
1628
1862
  src={radarLoadingIcon}
1629
1863
  alt=""
1630
1864
  aria-hidden
1631
- // Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
1865
+ // Integer offset (50% − 22) — avoids sub-pixel jitter from
1632
1866
  // `translate(-50%, -50%)` on odd-width viewports.
1633
1867
  className="absolute w-11 h-11"
1634
1868
  style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
@@ -1674,7 +1908,10 @@ function AppInner() {
1674
1908
  )}
1675
1909
 
1676
1910
  {/* Main content - only show when connected and authenticated */}
1677
- {!isSwitching && !authMePending && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected' && <div className="flex-1 flex overflow-hidden">
1911
+ {/* inert while a fullscreen detail overlay covers the views keeps the
1912
+ retained background list out of the focus order + a11y tree (the visual
1913
+ cover already blocks pointer events). */}
1914
+ {contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
1678
1915
  <ErrorBoundary>
1679
1916
  {/* Home dashboard */}
1680
1917
  {mainView === 'home' && (
@@ -1689,6 +1926,8 @@ function AppInner() {
1689
1926
  newParams.delete('kind') // kind is now in the path
1690
1927
  newParams.delete('mode')
1691
1928
  newParams.delete('resource')
1929
+ newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
1930
+ newParams.delete('tab')
1692
1931
  newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
1693
1932
  if (apiGroup) {
1694
1933
  newParams.set('apiGroup', apiGroup)
@@ -1773,7 +2012,7 @@ function AppInner() {
1773
2012
 
1774
2013
  <div className="flex-1 relative">
1775
2014
  <TopologyGraph
1776
- topology={filteredTopology}
2015
+ topology={topologyWithAudit}
1777
2016
  viewMode={topologyMode}
1778
2017
  groupingMode={effectiveGroupingMode}
1779
2018
  hideGroupHeader={hideGroupHeader}
@@ -1816,6 +2055,13 @@ function AppInner() {
1816
2055
  onShowPolicyEffectChange={setShowPolicyEffect}
1817
2056
  showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1818
2057
  onNavigateToTraffic={() => setMainView('traffic')}
2058
+ leadingSlot={
2059
+ <FreshnessControl
2060
+ mode="auto"
2061
+ paused={topologyPaused}
2062
+ connectionState={connection.state}
2063
+ />
2064
+ }
1819
2065
  />
1820
2066
  </div>
1821
2067
  </>
@@ -1858,20 +2104,14 @@ function AppInner() {
1858
2104
  <HelmView
1859
2105
  namespaces={namespaces}
1860
2106
  selectedRelease={selectedHelmRelease}
1861
- onReleaseClick={(ns, name, storageNamespace) => {
1862
- setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
1863
- const params = new URLSearchParams(window.location.search)
1864
- params.set('release', `${ns}/${name}`)
1865
- if (storageNamespace) {
1866
- params.set('releaseStorage', storageNamespace)
1867
- } else {
1868
- params.delete('releaseStorage')
1869
- }
1870
- setSearchParams(params, { replace: true })
1871
- }}
2107
+ onReleaseClick={navigateToHelmRelease}
1872
2108
  />
1873
2109
  )}
1874
2110
 
2111
+ {mainView === 'helmCompare' && (
2112
+ <HelmCompareRoute />
2113
+ )}
2114
+
1875
2115
  {/* GitOps view (inline only when the host hasn't taken it over — see
1876
2116
  the takeover splash below). */}
1877
2117
  {mainView === 'gitops' && !isViewTakenOver('gitops') && (
@@ -1926,7 +2166,7 @@ function AppInner() {
1926
2166
  <div className="flex-1 relative bg-theme-base">
1927
2167
  {/* Viewport-anchored, 17px — identical to the "Connecting" splash so
1928
2168
  the mark doesn't move or resize across the takeover hand-off. */}
1929
- <div className="fixed inset-0 pointer-events-none">
2169
+ <div className="absolute inset-0 pointer-events-none">
1930
2170
  <img
1931
2171
  src={radarLoadingIcon}
1932
2172
  alt=""
@@ -1967,8 +2207,10 @@ function AppInner() {
1967
2207
  />
1968
2208
  )}
1969
2209
 
1970
- {/* Workload full view (direct URL only expand from drawer uses drawer's expanded state) */}
1971
- {mainView === 'workload' && !drawerExpanded && (
2210
+ {/* Workload full view — the standalone fullscreen route for non-list
2211
+ surfaces and deep links. Expand-from-drawer is the ?full=1 overlay on
2212
+ /resources instead, so it never routes here. */}
2213
+ {mainView === 'workload' && (
1972
2214
  <WorkloadViewRoute
1973
2215
  onNavigateToResource={(resource) => {
1974
2216
  navigate(buildWorkloadPath(resource))
@@ -1982,8 +2224,10 @@ function AppInner() {
1982
2224
  </ErrorBoundary>
1983
2225
  </div>}
1984
2226
 
1985
- {/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView */}
1986
- {resourceDrawer.shouldRender && drawerResource && (
2227
+ {/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
2228
+ Gated on contentReady so it never renders over the connecting/switching
2229
+ splash (which would push the centered logo off-center). */}
2230
+ {contentReady && resourceDrawer.shouldRender && drawerResource && (
1987
2231
  <ResourceDetailDrawer
1988
2232
  resource={drawerResource}
1989
2233
  initialTab={drawerInitialTab}
@@ -1991,24 +2235,43 @@ function AppInner() {
1991
2235
  // to the top of the content area instead of leaving a 49px gap.
1992
2236
  headerHeight={chromeless ? 0 : undefined}
1993
2237
  isOpen={resourceDrawer.isOpen}
1994
- expanded={drawerExpanded}
1995
- onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
2238
+ expanded={drawerExpandedProp}
2239
+ onClose={closeDrawer}
1996
2240
  onNavigate={(res) => navigateToResource(res)}
1997
- onExpand={(res) => {
1998
- suppressViewClearRef.current = true
1999
- setDrawerExpanded(true)
2000
- navigate(buildWorkloadPath(res))
2241
+ canCollapseToDrawer={!isMobile}
2242
+ onExpand={(_res, opts) => {
2243
+ // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2244
+ // collapses) over whatever view is underneath — list, topology graph,
2245
+ // GitOps, Applications — which stays mounted. Carry the YAML tab when
2246
+ // expanding from the drawer's YAML view so the editor (and its
2247
+ // session-persisted draft) is right there, not behind the Overview tab.
2248
+ const p = new URLSearchParams(searchParams)
2249
+ p.set('full', '1')
2250
+ if (opts?.yaml) p.set('tab', 'yaml')
2251
+ setSearchParams(p)
2001
2252
  }}
2002
- onCollapse={handleCollapseFromExpanded}
2253
+ // On mobile there's no drawer to collapse back to, so the collapse/back
2254
+ // control closes the resource (returns to the list) instead.
2255
+ onCollapse={isMobile ? closeDrawer : handleCollapseFromExpanded}
2003
2256
  onNavigateToResource={(resource) => {
2004
- setSelectedResource(resource)
2005
- navigate(buildWorkloadPath(resource), { replace: true })
2257
+ // Drill into a related resource while expanded: stay in the over-list
2258
+ // overlay for the new resource (pushed, so Back walks resource→resource
2259
+ // still expanded). The backdrop list follows to the new kind.
2260
+ const pluralKind = kindToPlural(resource.kind)
2261
+ setSelectedResource({ ...resource, kind: pluralKind })
2262
+ const p = new URLSearchParams()
2263
+ const ns = searchParams.get('namespaces')
2264
+ if (ns) p.set('namespaces', ns)
2265
+ p.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
2266
+ if (resource.group) p.set('apiGroup', resource.group)
2267
+ p.set('full', '1')
2268
+ navigate({ pathname: `/resources/${pluralKind}`, search: p.toString() })
2006
2269
  }}
2007
2270
  />
2008
2271
  )}
2009
2272
 
2010
- {/* Helm release drawer */}
2011
- {helmDrawer.shouldRender && drawerHelmRelease && (
2273
+ {/* Helm release drawer — same contentReady gate as the resource drawer. */}
2274
+ {contentReady && helmDrawer.shouldRender && drawerHelmRelease && (
2012
2275
  <HelmReleaseDrawer
2013
2276
  release={drawerHelmRelease}
2014
2277
  isOpen={helmDrawer.isOpen}
@@ -2065,6 +2328,8 @@ function AppInner() {
2065
2328
  if (group) params.set('apiGroup', group)
2066
2329
  else params.delete('apiGroup')
2067
2330
  params.delete('resource')
2331
+ params.delete('full')
2332
+ params.delete('tab')
2068
2333
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
2069
2334
  // Focus the table search after navigation — the user came from ⌘K
2070
2335
  // (keyboard flow) and expects to type a resource name immediately.
@@ -2081,6 +2346,7 @@ function AppInner() {
2081
2346
  { onSettled: () => setNamespaces([]) },
2082
2347
  )}
2083
2348
  onSetNamespaces={(ns) => {
2349
+ if (namespaceScope?.cacheScoped && ns.length !== 1) return
2084
2350
  setNamespaces(ns)
2085
2351
  setActiveNamespace.mutate({ namespaces: ns })
2086
2352
  }}
@@ -2154,14 +2420,14 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
2154
2420
  }
2155
2421
 
2156
2422
  // Main App component wrapped with providers
2157
- function App() {
2423
+ function App({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
2158
2424
  return (
2159
2425
  <ConnectionProvider>
2160
2426
  <CapabilitiesProvider>
2161
2427
  <ContextSwitchProvider>
2162
2428
  <DockProvider>
2163
2429
  <KeyboardShortcutProvider>
2164
- <AppInner />
2430
+ <AppInner manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
2165
2431
  </KeyboardShortcutProvider>
2166
2432
  </DockProvider>
2167
2433
  </ContextSwitchProvider>