@skyhook-io/radar-app 1.8.3 → 1.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +256 -94
  3. package/src/RadarApp.tsx +4 -1
  4. package/src/api/client.metrics.test.ts +106 -0
  5. package/src/api/client.ts +147 -20
  6. package/src/components/ConnectionErrorView.tsx +1 -1
  7. package/src/components/ContextSwitcher.tsx +5 -1
  8. package/src/components/NamespaceSwitcher.tsx +21 -300
  9. package/src/components/applications/ApplicationsView.tsx +22 -7
  10. package/src/components/audit/AuditView.tsx +11 -2
  11. package/src/components/cost/CostView.tsx +12 -2
  12. package/src/components/gitops/GitOpsView.tsx +22 -7
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +4 -4
  17. package/src/components/home/ClusterHealthCard.tsx +6 -1
  18. package/src/components/home/HomeView.tsx +20 -7
  19. package/src/components/home/mcpToolCatalog.ts +1 -1
  20. package/src/components/issues/IssuesPane.tsx +29 -18
  21. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  22. package/src/components/resources/ResourcesView.tsx +3 -0
  23. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  24. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  25. package/src/components/timeline/TimelineView.tsx +26 -2
  26. package/src/components/traffic/TrafficView.tsx +17 -10
  27. package/src/components/ui/Markdown.tsx +2 -2
  28. package/src/components/ui/Omnibar.tsx +1 -1
  29. package/src/components/workload/WorkloadView.tsx +5 -1
  30. package/src/filter/FilterLocationBridge.tsx +30 -0
  31. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  32. package/src/index.ts +15 -0
package/src/App.tsx CHANGED
@@ -1,15 +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'
5
4
  import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
6
5
  import { useQueryClient } from '@tanstack/react-query'
7
6
  import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
8
7
  import { HomeView } from './components/home/HomeView'
9
8
  import { DebugOverlay } from './components/DebugOverlay'
10
- 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'
11
10
  import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
12
- import { useAPIResources } from './api/apiResources'
11
+ import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
13
12
  import { TimelineView } from './components/timeline/TimelineView'
14
13
  import { ResourcesView } from './components/resources/ResourcesView'
15
14
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -17,6 +16,7 @@ import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawe
17
16
  import { WorkloadViewRoute } from './components/workload/WorkloadView'
18
17
  import { CompareViewRoute } from './components/compare/CompareViewRoute'
19
18
  import { HelmView } from './components/helm/HelmView'
19
+ import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
20
20
  import { TrafficView } from './components/traffic/TrafficView'
21
21
  import { CostView } from './components/cost/CostView'
22
22
  import { AuditView } from './components/audit/AuditView'
@@ -48,11 +48,11 @@ import { useEventSource } from './hooks/useEventSource'
48
48
  import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
49
49
  import { buildAuditSeverityMap } from './utils/auditBadges'
50
50
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
51
- import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
51
+ import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
52
52
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
53
53
  import { useDocumentTitle } from './hooks/useDocumentTitle'
54
54
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
55
- 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'
56
56
  import { useTheme } from './context/ThemeContext'
57
57
  import { Tooltip } from './components/ui/Tooltip'
58
58
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
@@ -105,10 +105,11 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
105
105
 
106
106
  // Convert API resource name back to topology node ID prefix
107
107
  // Extended MainView type that includes traffic and cost
108
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
108
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
109
109
 
110
110
  // Extract view from URL path
111
111
  function getViewFromPath(pathname: string): ExtendedMainView {
112
+ if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
112
113
  const path = pathname.replace(/^\//, '').split('/')[0]
113
114
  if (path === '' || path === 'home') return 'home'
114
115
  if (path === 'topology') return 'topology'
@@ -126,6 +127,48 @@ function getViewFromPath(pathname: string): ExtendedMainView {
126
127
  return 'home'
127
128
  }
128
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
+
129
172
  // Browser tab label for every Radar view, derived from the route URL so it's
130
173
  // correct regardless of which component renders it. A detail drawer that opens
131
174
  // over a list (?resource=…) is deliberately NOT titled — it's the same page, so
@@ -337,6 +380,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
337
380
  const { data: navApiResources } = useAPIResources()
338
381
  useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
339
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
+
340
390
  // One URL-derived tab title for every view (see radarPageTitle). Driving it
341
391
  // from the URL — not the mounted component. Off unless the host opts in
342
392
  // (standalone passes manageDocumentTitle), so embedders keep title ownership.
@@ -460,11 +510,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
460
510
  // Diagnostics overlay state
461
511
  const [showDiagnostics, setShowDiagnostics] = useState(false)
462
512
 
463
- // Drawer expanded state (drawer grows to full width and renders WorkloadView)
464
- 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'
465
520
 
466
- // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
467
- 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)')
468
523
 
469
524
  // On a history Pop (back/forward) the URL is authoritative. The URL-write
470
525
  // effect, running with not-yet-synced state, would otherwise write the stale
@@ -497,9 +552,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
497
552
  // URL backing, so the only signal that the page beneath it has navigated is
498
553
  // that its owner-key (pathname + ?app) no longer matches where it was opened.
499
554
  // Hiding it here, at render time, closes the orphan on Back without adding
500
- // another clearing effect that would race the `suppressViewClearRef` lifecycle.
501
- // The /resources case is URL-backed and handled above; an expanded drawer
502
- // (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.
503
558
  const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
504
559
  peekOwnerKeyRef.current !== null &&
505
560
  peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
@@ -526,7 +581,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
526
581
 
527
582
  if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
528
583
  setSelectedResource(null)
529
- setDrawerExpanded(false)
530
584
  }
531
585
  }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
532
586
 
@@ -542,6 +596,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
542
596
  if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
543
597
  const drawerResource = routeSelectedResource || lastResourceRef.current
544
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
+
545
612
  const lastHelmReleaseRef = useRef(selectedHelmRelease)
546
613
  if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
547
614
  const drawerHelmRelease = selectedHelmRelease || lastHelmReleaseRef.current
@@ -580,6 +647,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
580
647
  newParams.delete('kind')
581
648
  newParams.delete('mode')
582
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')
583
654
  newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
584
655
  if (resource.group) {
585
656
  newParams.set('apiGroup', resource.group)
@@ -627,12 +698,33 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
627
698
  navigateToResourceList(resource)
628
699
  }, [navigate, navigateToHelmRelease, navigateToResourceList])
629
700
 
630
- // Collapse from expanded WorkloadView back to drawer
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).
631
707
  const handleCollapseFromExpanded = useCallback(() => {
632
- suppressViewClearRef.current = true
633
- setDrawerExpanded(false)
634
- navigate(-1)
635
- }, [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])
636
728
 
637
729
  // Theme toggle for keyboard shortcut
638
730
  const { toggleTheme } = useTheme()
@@ -659,7 +751,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
659
751
  gitops: 'g o', checks: 'g u', cost: 'g c',
660
752
  // Non-rail views (reachable via deep links / actions, not the rail) get no
661
753
  // dedicated mnemonic — listed for exhaustiveness so the type stays total.
662
- workload: '', compare: '',
754
+ workload: '', compare: '', helmCompare: '',
663
755
  }
664
756
  const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
665
757
  (v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
@@ -901,7 +993,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
901
993
  // forceNamespaceFilter is only set for large clusters that require server-side filtering.
902
994
  // Fleet mode uses 'resources' topology on the backend — filtering is client-side
903
995
  const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
904
- const { topology, connected, reconnect: reconnectSSE } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
996
+ const { topology } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
905
997
  onContextSwitchComplete: endSwitch,
906
998
  onContextSwitchProgress: updateProgress,
907
999
  onContextChanged: () => {
@@ -918,8 +1010,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
918
1010
  slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
919
1011
 
920
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).
921
1014
  setSelectedResource(null)
922
- setDrawerExpanded(false)
923
1015
  setSelectedHelmRelease(null)
924
1016
 
925
1017
  // Reset URL to current view with no resource-specific params.
@@ -938,7 +1030,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
938
1030
  },
939
1031
  onK8sEvent: handleK8sEvent,
940
1032
  }, forceNamespaceFilter, showPolicyEffect)
941
- const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
1033
+ const clusterConnected = connection.state === 'connected'
942
1034
 
943
1035
  // On large clusters (where the server requires namespace filtering), keep
944
1036
  // SSE's server-side filter in lockstep with the user's namespace pick.
@@ -1284,15 +1376,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1284
1376
  // But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
1285
1377
  const prevMainView = useRef(mainView)
1286
1378
  useEffect(() => {
1287
- // Skip clearing during controlled expand/collapse transitions
1288
- if (suppressViewClearRef.current) {
1289
- suppressViewClearRef.current = false
1290
- prevMainView.current = mainView
1291
- return
1292
- }
1293
-
1294
1379
  const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
1295
- const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
1296
1380
  prevMainView.current = mainView
1297
1381
 
1298
1382
  // The URL is the source of truth for what's selected. A deep link
@@ -1301,14 +1385,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1301
1385
  // asserts. (On a real view switch the URL no longer carries the param, so
1302
1386
  // the clear proceeds.) Without this, deep-linking straight to a Helm
1303
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.)
1304
1390
  const params = new URLSearchParams(window.location.search)
1305
1391
  if (!navigatingToResources && !params.has('resource')) {
1306
1392
  setSelectedResource(null)
1307
1393
  }
1308
- if (!navigatingToHelm && !params.has('release')) {
1394
+ if (!params.has('release')) {
1309
1395
  setSelectedHelmRelease(null)
1310
1396
  }
1311
- setDrawerExpanded(false)
1312
1397
  }, [mainView])
1313
1398
 
1314
1399
  // Clear resource selection when namespaces change — but keep a selection the
@@ -1318,7 +1403,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1318
1403
  const params = new URLSearchParams(window.location.search)
1319
1404
  if (!params.has('resource')) setSelectedResource(null)
1320
1405
  if (!params.has('release')) setSelectedHelmRelease(null)
1321
- setDrawerExpanded(false)
1322
1406
  }, [namespacesKey])
1323
1407
 
1324
1408
  // Filter topology based on visible kinds (uses displayedTopology which respects pause)
@@ -1423,6 +1507,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1423
1507
  setVisibleKinds(new Set())
1424
1508
  }, [])
1425
1509
 
1510
+ const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
1511
+
1426
1512
  return (
1427
1513
  <PortForwardProvider>
1428
1514
  {/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
@@ -1435,7 +1521,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1435
1521
  >
1436
1522
  {showNavRail && (
1437
1523
  <PrimaryNavRail
1438
- activeView={mainView}
1524
+ activeView={navActiveView}
1439
1525
  onNavigate={setMainView}
1440
1526
  pinned={navRailEffectivePinned}
1441
1527
  onTogglePinned={toggleNavRailPinned}
@@ -1452,20 +1538,58 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1452
1538
  {/* Header — suppressed in chromeless embed; the host owns the chrome. */}
1453
1539
  {!chromeless && (
1454
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">
1455
- {/* Left: Logo + Cluster info */}
1456
- <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]' : ''}`}>
1457
1548
  {/* Standalone rail owns the brand; only the embedded/pill layout
1458
1549
  shows it in the header (host may override via brandSlot). */}
1459
1550
  {navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
1460
1551
 
1461
- <div className="flex items-center gap-2">
1462
- {navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
1463
- {/* Connection status - next to cluster name */}
1464
- <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">
1465
1589
  <Tooltip
1466
1590
  content={
1467
- !connected
1468
- ? 'Disconnected'
1591
+ !clusterConnected
1592
+ ? 'Cluster disconnected — click to reconnect'
1469
1593
  : crdDiscoveryStatus === 'discovering'
1470
1594
  ? 'Connected — discovering Custom Resources...'
1471
1595
  : 'Connected'
@@ -1473,38 +1597,29 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1473
1597
  delay={100}
1474
1598
  position="bottom"
1475
1599
  >
1476
- <span
1477
- className={`w-2 h-2 rounded-full ${
1478
- !connected
1479
- ? 'bg-red-500'
1480
- : crdDiscoveryStatus === 'discovering'
1481
- ? 'bg-amber-400 animate-pulse'
1482
- : 'bg-green-500'
1483
- }`}
1484
- />
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
+ )}
1485
1617
  </Tooltip>
1486
- {/* Inline label only for non-steady states where the user
1487
- might need to act or wait. The healthy "Connected" case
1488
- is the dot alone; the dot's tooltip discloses it. Keeping
1489
- "Connected" text here would expand the left section and
1490
- collide with the absolute-centered nav block at xl, which
1491
- is the same breakpoint where nav labels appear. */}
1492
- {(!connected || crdDiscoveryStatus === 'discovering') && (
1493
- <span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
1494
- {!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…'}
1495
1621
  </span>
1496
1622
  )}
1497
- {!connected && (
1498
- <Tooltip content="Reconnect">
1499
- <button
1500
- onClick={reconnect}
1501
- disabled={isReconnecting}
1502
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
1503
- >
1504
- <RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
1505
- </button>
1506
- </Tooltip>
1507
- )}
1508
1623
  </div>
1509
1624
  {/* Port forwards indicator — shown only when sessions exist */}
1510
1625
  <PortForwardIndicator />
@@ -1546,7 +1661,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1546
1661
  <button
1547
1662
  onClick={() => setMainView(view)}
1548
1663
  className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
1549
- mainView === view
1664
+ mainView === view || (mainView === 'helmCompare' && view === 'helm')
1550
1665
  ? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
1551
1666
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
1552
1667
  }`}
@@ -1570,7 +1685,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1570
1685
  )}
1571
1686
 
1572
1687
  {/* Center: omnibar — standalone search + command surface (the ⌘K entry).
1573
- 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. */}
1574
1694
  {showNavRail && (
1575
1695
  <div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
1576
1696
  <RadarOmnibar
@@ -1581,6 +1701,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1581
1701
  params.delete('kind')
1582
1702
  if (group) params.set('apiGroup', group); else params.delete('apiGroup')
1583
1703
  params.delete('resource')
1704
+ params.delete('full')
1705
+ params.delete('tab')
1584
1706
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
1585
1707
  }}
1586
1708
  onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
@@ -1594,16 +1716,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1594
1716
 
1595
1717
  {/* Right: Controls */}
1596
1718
  <div className="flex items-center gap-3 shrink-0">
1597
- <NamespaceSwitcher
1598
- ref={namespaceSwitcherRef}
1599
- />
1600
-
1601
-
1602
1719
  {/* Command palette trigger — embedded only; standalone has the
1603
1720
  top-center omnibar (which is the ⌘K surface). */}
1604
1721
  {!showNavRail && (
1605
1722
  <button
1606
1723
  onClick={() => setShowCommandPalette(true)}
1724
+ aria-label="Open command palette"
1607
1725
  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"
1608
1726
  >
1609
1727
  <Search className="w-3.5 h-3.5" />
@@ -1625,6 +1743,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1625
1743
  <Tooltip content="Open local terminal">
1626
1744
  <button
1627
1745
  onClick={() => openLocalTerminal()}
1746
+ aria-label="Open local terminal"
1628
1747
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1629
1748
  >
1630
1749
  <SquareTerminal className="w-4 h-4" />
@@ -1639,7 +1758,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1639
1758
  to the host's cookie/backend) and the user would see the theme
1640
1759
  bounce on every navigation between host routes and /c/:id. */}
1641
1760
  {!navCustomization.embedded && (
1642
- <div className="hidden md:block">
1761
+ <div className="hidden md:flex items-center">
1643
1762
  <ThemeToggle />
1644
1763
  </div>
1645
1764
  )}
@@ -1652,6 +1771,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1652
1771
  <Tooltip content="Keyboard shortcuts (?)">
1653
1772
  <button
1654
1773
  onClick={() => setShowHelp(true)}
1774
+ aria-label="Show keyboard shortcuts"
1655
1775
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1656
1776
  >
1657
1777
  <HelpCircle className="w-4 h-4" />
@@ -1660,6 +1780,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1660
1780
  <Tooltip content="Report a bug / Diagnostics">
1661
1781
  <button
1662
1782
  onClick={() => setShowDiagnostics(true)}
1783
+ aria-label="Open diagnostics"
1663
1784
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1664
1785
  >
1665
1786
  <Bug className="w-4 h-4" />
@@ -1791,7 +1912,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1791
1912
  )}
1792
1913
 
1793
1914
  {/* Main content - only show when connected and authenticated */}
1794
- {contentReady && <div className="flex-1 flex overflow-hidden">
1915
+ {/* inert while a fullscreen detail overlay covers the views — keeps the
1916
+ retained background list out of the focus order + a11y tree (the visual
1917
+ cover already blocks pointer events). */}
1918
+ {contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
1795
1919
  <ErrorBoundary>
1796
1920
  {/* Home dashboard */}
1797
1921
  {mainView === 'home' && (
@@ -1806,6 +1930,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1806
1930
  newParams.delete('kind') // kind is now in the path
1807
1931
  newParams.delete('mode')
1808
1932
  newParams.delete('resource')
1933
+ newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
1934
+ newParams.delete('tab')
1809
1935
  newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
1810
1936
  if (apiGroup) {
1811
1937
  newParams.set('apiGroup', apiGroup)
@@ -1933,6 +2059,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1933
2059
  onShowPolicyEffectChange={setShowPolicyEffect}
1934
2060
  showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1935
2061
  onNavigateToTraffic={() => setMainView('traffic')}
2062
+ leadingSlot={
2063
+ <FreshnessControl
2064
+ mode="auto"
2065
+ paused={topologyPaused}
2066
+ connectionState={connection.state}
2067
+ />
2068
+ }
1936
2069
  />
1937
2070
  </div>
1938
2071
  </>
@@ -1979,6 +2112,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1979
2112
  />
1980
2113
  )}
1981
2114
 
2115
+ {mainView === 'helmCompare' && (
2116
+ <HelmCompareRoute />
2117
+ )}
2118
+
1982
2119
  {/* GitOps view (inline only when the host hasn't taken it over — see
1983
2120
  the takeover splash below). */}
1984
2121
  {mainView === 'gitops' && !isViewTakenOver('gitops') && (
@@ -2074,8 +2211,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2074
2211
  />
2075
2212
  )}
2076
2213
 
2077
- {/* Workload full view (direct URL only expand from drawer uses drawer's expanded state) */}
2078
- {mainView === 'workload' && !drawerExpanded && (
2214
+ {/* Workload full view — the standalone fullscreen route for non-list
2215
+ surfaces and deep links. Expand-from-drawer is the ?full=1 overlay on
2216
+ /resources instead, so it never routes here. */}
2217
+ {mainView === 'workload' && (
2079
2218
  <WorkloadViewRoute
2080
2219
  onNavigateToResource={(resource) => {
2081
2220
  navigate(buildWorkloadPath(resource))
@@ -2100,18 +2239,37 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2100
2239
  // to the top of the content area instead of leaving a 49px gap.
2101
2240
  headerHeight={chromeless ? 0 : undefined}
2102
2241
  isOpen={resourceDrawer.isOpen}
2103
- expanded={drawerExpanded}
2104
- onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
2242
+ expanded={drawerExpandedProp}
2243
+ onClose={closeDrawer}
2105
2244
  onNavigate={(res) => navigateToResource(res)}
2106
- onExpand={(res) => {
2107
- suppressViewClearRef.current = true
2108
- setDrawerExpanded(true)
2109
- navigate(buildWorkloadPath(res))
2245
+ canCollapseToDrawer={!isMobile}
2246
+ onExpand={(_res, opts) => {
2247
+ // Grow the peek into a fullscreen overlay (?full=1, pushed so Back
2248
+ // collapses) over whatever view is underneath — list, topology graph,
2249
+ // GitOps, Applications — which stays mounted. Carry the YAML tab when
2250
+ // expanding from the drawer's YAML view so the editor (and its
2251
+ // session-persisted draft) is right there, not behind the Overview tab.
2252
+ const p = new URLSearchParams(searchParams)
2253
+ p.set('full', '1')
2254
+ if (opts?.yaml) p.set('tab', 'yaml')
2255
+ setSearchParams(p)
2110
2256
  }}
2111
- onCollapse={handleCollapseFromExpanded}
2257
+ // On mobile there's no drawer to collapse back to, so the collapse/back
2258
+ // control closes the resource (returns to the list) instead.
2259
+ onCollapse={isMobile ? closeDrawer : handleCollapseFromExpanded}
2112
2260
  onNavigateToResource={(resource) => {
2113
- setSelectedResource(resource)
2114
- navigate(buildWorkloadPath(resource), { replace: true })
2261
+ // Drill into a related resource while expanded: stay in the over-list
2262
+ // overlay for the new resource (pushed, so Back walks resource→resource
2263
+ // still expanded). The backdrop list follows to the new kind.
2264
+ const pluralKind = kindToPlural(resource.kind)
2265
+ setSelectedResource({ ...resource, kind: pluralKind })
2266
+ const p = new URLSearchParams()
2267
+ const ns = searchParams.get('namespaces')
2268
+ if (ns) p.set('namespaces', ns)
2269
+ p.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
2270
+ if (resource.group) p.set('apiGroup', resource.group)
2271
+ p.set('full', '1')
2272
+ navigate({ pathname: `/resources/${pluralKind}`, search: p.toString() })
2115
2273
  }}
2116
2274
  />
2117
2275
  )}
@@ -2174,6 +2332,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2174
2332
  if (group) params.set('apiGroup', group)
2175
2333
  else params.delete('apiGroup')
2176
2334
  params.delete('resource')
2335
+ params.delete('full')
2336
+ params.delete('tab')
2177
2337
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
2178
2338
  // Focus the table search after navigation — the user came from ⌘K
2179
2339
  // (keyboard flow) and expects to type a resource name immediately.
@@ -2250,12 +2410,12 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
2250
2410
  return (
2251
2411
  <div className={`fixed ${bottom} right-4 z-40 flex items-center gap-1.5`}>
2252
2412
  <Tooltip content="Report bug / Diagnostics" position="top">
2253
- <button onClick={onBugReport} className={btnClass}>
2413
+ <button onClick={onBugReport} aria-label="Open diagnostics" className={btnClass}>
2254
2414
  <Bug className="w-3.5 h-3.5" />
2255
2415
  </button>
2256
2416
  </Tooltip>
2257
2417
  <Tooltip content="Keyboard shortcuts (?)" position="top">
2258
- <button onClick={onHelp} className={btnClass}>
2418
+ <button onClick={onHelp} aria-label="Show keyboard shortcuts" className={btnClass}>
2259
2419
  ?
2260
2420
  </button>
2261
2421
  </Tooltip>
@@ -2407,6 +2567,7 @@ function GitHubStarButton() {
2407
2567
  target="_blank"
2408
2568
  rel="noopener noreferrer"
2409
2569
  onClick={handleClick}
2570
+ aria-label={starred ? 'Open Radar on GitHub' : 'Star Radar on GitHub'}
2410
2571
  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"
2411
2572
  >
2412
2573
  <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>
@@ -2462,6 +2623,7 @@ function ThemeToggle() {
2462
2623
  <Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
2463
2624
  <button
2464
2625
  onClick={toggleTheme}
2626
+ aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
2465
2627
  className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
2466
2628
  >
2467
2629
  {theme === 'dark' ? (