@skyhook-io/radar-app 1.8.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.3",
3
+ "version": "1.8.5",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,8 +56,8 @@
56
56
  "@skyhook-io/k8s-ui": "*",
57
57
  "@tailwindcss/typography": "^0.5.20",
58
58
  "@tailwindcss/vite": "^4.3.1",
59
- "@tanstack/react-query": "^5.100.14",
60
- "@types/node": "^26.0.0",
59
+ "@tanstack/react-query": "^5.101.2",
60
+ "@types/node": "^26.0.1",
61
61
  "@types/react": "^19.2.17",
62
62
  "@types/react-dom": "^19.2.3",
63
63
  "@vitejs/plugin-react": "^6.0.2",
@@ -68,8 +68,8 @@
68
68
  "eslint-plugin-react-hooks": "^7.1.1",
69
69
  "eslint-plugin-react-refresh": "^0.5.3",
70
70
  "globals": "^17.7.0",
71
- "lucide-react": "^1.16.0",
72
- "postcss": "^8.5.14",
71
+ "lucide-react": "^1.22.0",
72
+ "postcss": "^8.5.16",
73
73
  "prettier": "^3.8.1",
74
74
  "react": "^19.2.7",
75
75
  "react-dom": "^19.2.7",
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,11 +1716,6 @@ 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 && (
@@ -1639,7 +1756,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1639
1756
  to the host's cookie/backend) and the user would see the theme
1640
1757
  bounce on every navigation between host routes and /c/:id. */}
1641
1758
  {!navCustomization.embedded && (
1642
- <div className="hidden md:block">
1759
+ <div className="hidden md:flex items-center">
1643
1760
  <ThemeToggle />
1644
1761
  </div>
1645
1762
  )}
@@ -1791,7 +1908,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1791
1908
  )}
1792
1909
 
1793
1910
  {/* Main content - only show when connected and authenticated */}
1794
- {contentReady && <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}>
1795
1915
  <ErrorBoundary>
1796
1916
  {/* Home dashboard */}
1797
1917
  {mainView === 'home' && (
@@ -1806,6 +1926,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1806
1926
  newParams.delete('kind') // kind is now in the path
1807
1927
  newParams.delete('mode')
1808
1928
  newParams.delete('resource')
1929
+ newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
1930
+ newParams.delete('tab')
1809
1931
  newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
1810
1932
  if (apiGroup) {
1811
1933
  newParams.set('apiGroup', apiGroup)
@@ -1933,6 +2055,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1933
2055
  onShowPolicyEffectChange={setShowPolicyEffect}
1934
2056
  showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
1935
2057
  onNavigateToTraffic={() => setMainView('traffic')}
2058
+ leadingSlot={
2059
+ <FreshnessControl
2060
+ mode="auto"
2061
+ paused={topologyPaused}
2062
+ connectionState={connection.state}
2063
+ />
2064
+ }
1936
2065
  />
1937
2066
  </div>
1938
2067
  </>
@@ -1979,6 +2108,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
1979
2108
  />
1980
2109
  )}
1981
2110
 
2111
+ {mainView === 'helmCompare' && (
2112
+ <HelmCompareRoute />
2113
+ )}
2114
+
1982
2115
  {/* GitOps view (inline only when the host hasn't taken it over — see
1983
2116
  the takeover splash below). */}
1984
2117
  {mainView === 'gitops' && !isViewTakenOver('gitops') && (
@@ -2074,8 +2207,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2074
2207
  />
2075
2208
  )}
2076
2209
 
2077
- {/* Workload full view (direct URL only expand from drawer uses drawer's expanded state) */}
2078
- {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' && (
2079
2214
  <WorkloadViewRoute
2080
2215
  onNavigateToResource={(resource) => {
2081
2216
  navigate(buildWorkloadPath(resource))
@@ -2100,18 +2235,37 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2100
2235
  // to the top of the content area instead of leaving a 49px gap.
2101
2236
  headerHeight={chromeless ? 0 : undefined}
2102
2237
  isOpen={resourceDrawer.isOpen}
2103
- expanded={drawerExpanded}
2104
- onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
2238
+ expanded={drawerExpandedProp}
2239
+ onClose={closeDrawer}
2105
2240
  onNavigate={(res) => navigateToResource(res)}
2106
- onExpand={(res) => {
2107
- suppressViewClearRef.current = true
2108
- setDrawerExpanded(true)
2109
- 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)
2110
2252
  }}
2111
- 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}
2112
2256
  onNavigateToResource={(resource) => {
2113
- setSelectedResource(resource)
2114
- 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() })
2115
2269
  }}
2116
2270
  />
2117
2271
  )}
@@ -2174,6 +2328,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
2174
2328
  if (group) params.set('apiGroup', group)
2175
2329
  else params.delete('apiGroup')
2176
2330
  params.delete('resource')
2331
+ params.delete('full')
2332
+ params.delete('tab')
2177
2333
  navigate({ pathname: `/resources/${kind}`, search: params.toString() })
2178
2334
  // Focus the table search after navigation — the user came from ⌘K
2179
2335
  // (keyboard flow) and expects to type a resource name immediately.