@skyhook-io/radar-app 1.0.2 → 1.1.0

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.0.2",
3
+ "version": "1.1.0",
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",
@@ -35,7 +35,7 @@
35
35
  "react-virtuoso": "^4.18.6",
36
36
  "remark-gfm": "^4.0.1",
37
37
  "shiki": "^4.0.1",
38
- "yaml": "^2.8.4"
38
+ "yaml": "^2.9.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@skyhook-io/k8s-ui": ">=1.5.0",
@@ -53,10 +53,10 @@
53
53
  "@playwright/test": "^1.59.1",
54
54
  "@skyhook-io/k8s-ui": "*",
55
55
  "@tailwindcss/typography": "^0.5.19",
56
- "@tailwindcss/vite": "^4.2.4",
56
+ "@tailwindcss/vite": "^4.3.0",
57
57
  "@tanstack/react-query": "^5.100.9",
58
58
  "@types/diff": "^8.0.0",
59
- "@types/node": "^25.5.0",
59
+ "@types/node": "^25.7.0",
60
60
  "@types/react": "^19.2.14",
61
61
  "@types/react-dom": "^19.2.3",
62
62
  "@vitejs/plugin-react": "^6.0.1",
@@ -66,13 +66,13 @@
66
66
  "lucide-react": "^1.12.0",
67
67
  "postcss": "^8.5.14",
68
68
  "prettier": "^3.8.1",
69
- "react": "^19.2.5",
69
+ "react": "^19.2.6",
70
70
  "react-dom": "^19.2.5",
71
71
  "react-router-dom": "^7.15.0",
72
72
  "tailwind-merge": "^3.5.0",
73
73
  "tailwindcss": "^4.2.4",
74
74
  "typescript": "^6.0.2",
75
- "vite": "^8.0.10"
75
+ "vite": "^8.0.12"
76
76
  },
77
77
  "sideEffects": [
78
78
  "*.css"
package/src/App.tsx CHANGED
@@ -3,10 +3,10 @@ import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from './hooks/useRefreshAnimation'
4
4
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
5
5
  import { useQueryClient } from '@tanstack/react-query'
6
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
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, TopologyFilterSidebar, TopologyControls } from '@skyhook-io/k8s-ui'
9
+ import { TopologyGraph, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
10
10
  import { TimelineView } from './components/timeline/TimelineView'
11
11
  import { ResourcesView } from './components/resources/ResourcesView'
12
12
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -16,11 +16,13 @@ import { HelmView } from './components/helm/HelmView'
16
16
  import { TrafficView } from './components/traffic/TrafficView'
17
17
  import { CostView } from './components/cost/CostView'
18
18
  import { AuditView } from './components/audit/AuditView'
19
+ import { GitOpsView } from './components/gitops/GitOpsView'
19
20
  import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
20
21
  import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
21
- import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
22
+ import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
22
23
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
23
24
  import { ContextSwitcher } from './components/ContextSwitcher'
25
+ import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
24
26
  import { useNavCustomization } from './context/NavCustomization'
25
27
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
26
28
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
@@ -28,24 +30,23 @@ import { ConnectionErrorView } from './components/ConnectionErrorView'
28
30
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
29
31
  import { UserMenu } from './components/UserMenu'
30
32
  import { ErrorBoundary } from './components/ui/ErrorBoundary'
31
- import { NamespaceSelector, type NamespaceSelectorHandle } from './components/ui/NamespaceSelector'
32
33
  import { UpdateNotification } from './components/ui/UpdateNotification'
33
34
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
34
35
  import { CommandPalette } from './components/ui/CommandPalette'
35
36
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
36
37
  import { useEventSource } from './hooks/useEventSource'
37
- import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
38
+ import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
38
39
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
39
40
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
40
41
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
41
42
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
42
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck } from 'lucide-react'
43
+ import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
43
44
  import { useTheme } from './context/ThemeContext'
44
45
  import { Tooltip } from './components/ui/Tooltip'
45
46
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
46
47
  import { SettingsDialog } from './components/settings/SettingsDialog'
47
48
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
48
- import { kindToPlural, openExternal } from './utils/navigation'
49
+ import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath } from './utils/navigation'
49
50
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
50
51
 
51
52
  // All possible node kinds (core + GitOps)
@@ -115,7 +116,7 @@ function apiResourceToNodeIdPrefix(apiResource: string): string {
115
116
  }
116
117
 
117
118
  // Extended MainView type that includes traffic and cost
118
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit'
119
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops'
119
120
 
120
121
  // Extract view from URL path
121
122
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -129,6 +130,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
129
130
  if (path === 'cost') return 'cost'
130
131
  if (path === 'workload') return 'workload'
131
132
  if (path === 'audit') return 'audit'
133
+ if (path === 'gitops') return 'gitops'
132
134
  return 'home'
133
135
  }
134
136
 
@@ -173,6 +175,7 @@ function AuthBarrier({ authMode }: { authMode: string }) {
173
175
  function AppInner() {
174
176
  const navigate = useNavigate()
175
177
  const location = useLocation()
178
+ const navigationType = useNavigationType()
176
179
  const [searchParams, setSearchParams] = useSearchParams()
177
180
  const capabilities = useCapabilitiesContext()
178
181
  const openLocalTerminal = useOpenLocalTerminal()
@@ -390,11 +393,11 @@ function AppInner() {
390
393
  const switchContext = useSwitchContext()
391
394
 
392
395
  // Refs for dropdown components to trigger them via shortcuts
393
- const namespaceSelectorRef = useRef<NamespaceSelectorHandle>(null)
396
+ const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
394
397
  const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
395
398
 
396
399
  // View switching keyboard shortcuts
397
- const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
400
+ const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'traffic', 'cost', 'audit']
398
401
  useRegisterShortcuts([
399
402
  ...views.map((view, i) => ({
400
403
  id: `view-${view}`,
@@ -410,7 +413,7 @@ function AppInner() {
410
413
  description: 'Switch namespace',
411
414
  category: 'Navigation' as const,
412
415
  scope: 'global' as const,
413
- handler: () => namespaceSelectorRef.current?.open(),
416
+ handler: () => namespaceSwitcherRef.current?.open(),
414
417
  },
415
418
  {
416
419
  id: 'switch-context',
@@ -490,7 +493,12 @@ function AppInner() {
490
493
  const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
491
494
 
492
495
  // Fetch available namespaces
493
- const { data: availableNamespaces, error: namespacesError } = useNamespaces()
496
+ const { data: availableNamespaces } = useNamespaces()
497
+
498
+ // Per-user view filter served by the backend. Loaded eagerly so the
499
+ // picker can render its current state without showing the multi-select
500
+ // fallback during the initial scope fetch.
501
+ const { data: namespaceScope } = useNamespaceScope()
494
502
 
495
503
  // Context switch state
496
504
  const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
@@ -536,6 +544,16 @@ function AppInner() {
536
544
  if (pending.kinds.has('secrets')) {
537
545
  queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
538
546
  }
547
+ // GitOps tree + insights are derived views over the same informer
548
+ // cache that produced this SSE event — when *anything* changes, the
549
+ // managed-resource tree and the insights pipeline can have stale
550
+ // changes/events/drift. Invalidating broadly here is cheap (only the
551
+ // currently-mounted GitOps view re-fetches; other views have no
552
+ // matching keys) and is what makes the detail page actually live.
553
+ // Without this the failure card + topology lag behind the title chips
554
+ // until window focus or a manual refresh.
555
+ queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
556
+ queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
539
557
  // Reset accumulator
540
558
  pending.kinds = new Set()
541
559
  pending.hasCountChange = false
@@ -586,6 +604,26 @@ function AppInner() {
586
604
  }, forceNamespaceFilter, showPolicyEffect)
587
605
  const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
588
606
 
607
+ // On large clusters (where the server requires namespace filtering), keep
608
+ // SSE's server-side filter in lockstep with the user's namespace pick.
609
+ // Without this, header switches and deep-link loads can leave SSE filtered
610
+ // to a stale namespace while sidebar/topology show a different one. Small
611
+ // clusters never set forceNamespaceFilter and skip this path entirely.
612
+ useEffect(() => {
613
+ const isLarge = forceNamespaceFilter !== undefined || topology?.requiresNamespaceFilter === true
614
+ if (!isLarge) return
615
+ if (namespaces.length === 0) {
616
+ setForceNamespaceFilter(prev => (prev === undefined ? prev : undefined))
617
+ return
618
+ }
619
+ setForceNamespaceFilter(prev => {
620
+ const cur = prev ? [...prev].sort() : []
621
+ const next = [...namespaces].sort()
622
+ if (cur.length === next.length && cur.every((ns, i) => ns === next[i])) return prev
623
+ return [...namespaces]
624
+ })
625
+ }, [namespaces, forceNamespaceFilter, topology?.requiresNamespaceFilter])
626
+
589
627
  // Apply live topology updates only when not paused. While paused, buffer the
590
628
  // latest snapshot so we can apply it instantly when the user resumes.
591
629
  useEffect(() => {
@@ -651,16 +689,72 @@ function AppInner() {
651
689
  // TODO: Could show a list of pods in the group
652
690
  if (node.kind === 'PodGroup') return
653
691
 
692
+ const namespace = (node.data.namespace as string) || ''
693
+ // GitOps CRs (Application/Kustomization/HelmRelease/etc.) have a dedicated
694
+ // detail page with tree + insights + ops that the drawer can't reproduce.
695
+ // Route there from the main topology when the node is one of those kinds;
696
+ // everything else falls back to the drawer.
697
+ const gitOpsPath = gitOpsRouteForKind(node.kind, namespace, node.name)
698
+ if (gitOpsPath) {
699
+ navigate(gitOpsPath)
700
+ return
701
+ }
702
+
654
703
  navigateToResource({
655
704
  kind: kindToPlural(node.kind),
656
- namespace: (node.data.namespace as string) || '',
705
+ namespace,
657
706
  name: node.name,
707
+ group: apiVersionToGroup(node.data.apiVersion as string | undefined),
658
708
  })
659
- }, [])
709
+ }, [navigate])
660
710
 
661
711
  // Serialize namespaces for stable dependency tracking
662
712
  const namespacesKey = namespaces.join(',')
663
713
 
714
+ // The server is canonical for the per-user namespace pick. Mirror its
715
+ // `actives` into App.tsx state so consumer hooks (SSE, dashboard, resource
716
+ // lists) stay in lockstep with the picker. The dedicated URL-write effect
717
+ // below propagates the mirrored state to `?namespaces=`.
718
+ const setActiveNamespace = useSetActiveNamespace()
719
+ const initialBookmarkReconciledRef = useRef(false)
720
+ const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
721
+ const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
722
+ useEffect(() => {
723
+ if (!namespaceScope) return
724
+ const sortedScope = [...scopeActives].sort()
725
+ const sortedState = [...namespaces].sort()
726
+ const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
727
+ debugNamespaceLog('app:scope-mirror', {
728
+ scopeActives,
729
+ stateNamespaces: namespaces,
730
+ sameAsState,
731
+ initialBookmarkReconciled: initialBookmarkReconciledRef.current,
732
+ })
733
+
734
+ // First-load bookmark reconciliation: if the URL had namespaces that
735
+ // differ from the server pick when the scope first arrives, push the
736
+ // URL choice to the server so shared/bookmarked deep links keep
737
+ // working. The ref flips on the first scope load regardless of whether
738
+ // the URL had namespaces — subsequent runs mirror server → state.
739
+ if (!initialBookmarkReconciledRef.current) {
740
+ initialBookmarkReconciledRef.current = true
741
+ if (!sameAsState && sortedState.length > 0) {
742
+ debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
743
+ stateNamespaces: sortedState,
744
+ scopeActives: sortedScope,
745
+ })
746
+ setActiveNamespace.mutate({ namespaces: sortedState })
747
+ return
748
+ }
749
+ }
750
+
751
+ if (!sameAsState) {
752
+ debugNamespaceLog('app:scope-mirror-set-namespaces', { nextNamespaces: scopeActives })
753
+ setNamespaces(scopeActives)
754
+ }
755
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
756
+ }, [namespaceScope, namespaceScopeKey])
757
+
664
758
  // Update URL query params when state changes (path is handled by setMainView)
665
759
  // Read from window.location.search (not React Router's searchParams) to preserve
666
760
  // params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
@@ -696,18 +790,48 @@ function AppInner() {
696
790
 
697
791
  // Only update if params actually changed vs current URL
698
792
  if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
793
+ debugNamespaceLog('app:url-write', {
794
+ namespaces,
795
+ currentSearch,
796
+ nextSearch: params.toString(),
797
+ mainView,
798
+ })
699
799
  setSearchParams(params, { replace: true })
700
800
  }
701
801
  // eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
702
802
  }, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
703
803
 
704
- // Sync state from URL when navigating (back/forward)
804
+ // Sync namespace + helm picks from the query string only when the query
805
+ // string changes. If this also ran on pathname / mainView changes, a view
806
+ // whose URL omits ?namespaces= would clear App state and POST [] to the
807
+ // server while the per-user pick was still narrowed — the picker would
808
+ // show the server scope but lists/dashboard would stay on "all namespaces".
705
809
  useEffect(() => {
706
810
  const urlNamespaces = parseNamespacesFromURL(searchParams)
811
+ debugNamespaceLog('app:url-sync', {
812
+ search: searchParams.toString(),
813
+ urlNamespaces,
814
+ stateNamespaces: namespaces,
815
+ namespacesKey,
816
+ })
707
817
 
708
- if (urlNamespaces.join(',') !== namespacesKey) setNamespaces(urlNamespaces)
818
+ if (urlNamespaces.join(',') !== namespacesKey) {
819
+ debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
820
+ setNamespaces(urlNamespaces)
821
+ if (namespaceScope) {
822
+ const sortedURL = [...urlNamespaces].sort()
823
+ const sortedScope = [...(namespaceScope.actives ?? [])].sort()
824
+ const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
825
+ if (!same) {
826
+ debugNamespaceLog('app:url-sync-mutate-server', {
827
+ urlNamespaces,
828
+ scopeActives: namespaceScope.actives ?? [],
829
+ })
830
+ setActiveNamespace.mutate({ namespaces: urlNamespaces })
831
+ }
832
+ }
833
+ }
709
834
 
710
- // Restore helm release from URL (back navigation)
711
835
  const releaseParam = searchParams.get('release')
712
836
  if (releaseParam) {
713
837
  const slashIdx = releaseParam.indexOf('/')
@@ -717,8 +841,34 @@ function AppInner() {
717
841
  setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
718
842
  }
719
843
  }
844
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run only when searchParams change; namespacesKey/namespaceScope are read for that transition
720
845
  }, [searchParams])
721
846
 
847
+ useEffect(() => {
848
+ if (navigationType !== NavigationType.Pop || mainView !== 'resources') return
849
+ const kindFromPath = location.pathname.match(/^\/resources\/([^/]+)/)?.[1] ?? ''
850
+ const resourceParam = searchParams.get('resource')
851
+ if (kindFromPath && resourceParam) {
852
+ const slashIdx = resourceParam.indexOf('/')
853
+ const ns = slashIdx > 0 ? resourceParam.slice(0, slashIdx) : ''
854
+ const name = slashIdx > 0 ? resourceParam.slice(slashIdx + 1) : resourceParam
855
+ const apiGroup = searchParams.get('apiGroup') ?? ''
856
+ const next: SelectedResource = { kind: kindFromPath, namespace: ns, name, group: apiGroup }
857
+ setSelectedResource(prev => {
858
+ if (
859
+ prev &&
860
+ prev.kind === next.kind &&
861
+ prev.namespace === next.namespace &&
862
+ prev.name === next.name &&
863
+ (prev.group ?? '') === (next.group ?? '')
864
+ ) return prev
865
+ return next
866
+ })
867
+ } else if (kindFromPath && !resourceParam) {
868
+ setSelectedResource(prev => (prev === null ? prev : null))
869
+ }
870
+ }, [navigationType, mainView, location.pathname, searchParams])
871
+
722
872
  // Auto-adjust grouping when namespaces change
723
873
  useEffect(() => {
724
874
  if (namespaces.length === 0 && groupingMode === 'none') {
@@ -828,7 +978,7 @@ function AppInner() {
828
978
 
829
979
  return (
830
980
  <PortForwardProvider>
831
- <div className="flex flex-col h-screen bg-theme-base min-w-[800px]">
981
+ <div className="relative flex flex-col h-screen bg-theme-base min-w-[800px]">
832
982
  {/* Header */}
833
983
  <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">
834
984
  {/* Left: Logo + Cluster info */}
@@ -867,7 +1017,7 @@ function AppInner() {
867
1017
  collide with the absolute-centered nav block at xl, which
868
1018
  is the same breakpoint where nav labels appear. */}
869
1019
  {(!connected || crdDiscoveryStatus === 'discovering') && (
870
- <span className="text-xs text-theme-text-tertiary hidden xl:inline">
1020
+ <span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
871
1021
  {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
872
1022
  </span>
873
1023
  )}
@@ -888,13 +1038,14 @@ function AppInner() {
888
1038
  </div>
889
1039
 
890
1040
  {/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
891
- <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-1 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
1041
+ <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
892
1042
  {([
893
1043
  { view: 'home' as const, icon: Home, label: 'Home' },
894
1044
  { view: 'topology' as const, icon: Network, label: 'Topology' },
895
1045
  { view: 'resources' as const, icon: List, label: 'Resources' },
896
1046
  { view: 'timeline' as const, icon: Clock, label: 'Timeline' },
897
1047
  { view: 'helm' as const, icon: Package, label: 'Helm' },
1048
+ { view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
898
1049
  { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
899
1050
  // Cost is intentionally hidden from the pill bar for now — the view still
900
1051
  // exists and is reachable via /cost, the Home dashboard card, and the
@@ -904,7 +1055,7 @@ function AppInner() {
904
1055
  <Tooltip key={view} content={label} delay={100} position="bottom">
905
1056
  <button
906
1057
  onClick={() => setMainView(view)}
907
- className={`flex items-center gap-1.5 px-2.5 py-1.5 text-sm rounded-full transition-colors ${
1058
+ className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
908
1059
  mainView === view
909
1060
  ? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
910
1061
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
@@ -929,17 +1080,13 @@ function AppInner() {
929
1080
 
930
1081
  {/* Right: Controls */}
931
1082
  <div className="flex items-center gap-3 shrink-0">
932
- {/* Namespace selector with search */}
933
- <NamespaceSelector
934
- ref={namespaceSelectorRef}
935
- value={namespaces}
936
- onChange={setNamespaces}
937
- namespaces={availableNamespaces}
938
- namespacesError={namespacesError}
1083
+ <NamespaceSwitcher
1084
+ ref={namespaceSwitcherRef}
939
1085
  disabled={mainView === 'helm'}
940
1086
  disabledTooltip="Helm view always shows all namespaces"
941
1087
  />
942
1088
 
1089
+
943
1090
  {/* Command palette trigger */}
944
1091
  <button
945
1092
  onClick={() => setShowCommandPalette(true)}
@@ -1164,6 +1311,7 @@ function AppInner() {
1164
1311
  namespaces={availableNamespaces}
1165
1312
  onSelect={(ns) => {
1166
1313
  setNamespaces([ns])
1314
+ setActiveNamespace.mutate({ namespaces: [ns] })
1167
1315
  // Large clusters need server-side filtering — reconnect SSE with namespace
1168
1316
  setForceNamespaceFilter([ns])
1169
1317
  }}
@@ -1200,9 +1348,9 @@ function AppInner() {
1200
1348
  selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
1201
1349
  paused={topologyPaused}
1202
1350
  onTogglePause={handleTogglePause}
1203
- onMaximizeNamespace={(ns) => setNamespaces([ns])}
1351
+ onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
1204
1352
  namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1205
- onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
1353
+ onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
1206
1354
  namespacesKey={namespaces.join(',')}
1207
1355
  />
1208
1356
 
@@ -1243,14 +1391,17 @@ function AppInner() {
1243
1391
  <TimelineView
1244
1392
  namespaces={namespaces}
1245
1393
  onResourceClick={(resource) => {
1246
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
1394
+ navigate(buildWorkloadPath(resource))
1247
1395
  }}
1248
1396
  initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
1249
1397
  initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
1250
1398
  initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
1251
1399
  requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
1252
1400
  availableNamespaces={availableNamespaces}
1253
- onNamespaceSelect={(ns) => setNamespaces([ns])}
1401
+ onNamespaceSelect={(ns) => {
1402
+ setNamespaces([ns])
1403
+ setActiveNamespace.mutate({ namespaces: [ns] })
1404
+ }}
1254
1405
  />
1255
1406
  )}
1256
1407
 
@@ -1273,6 +1424,16 @@ function AppInner() {
1273
1424
  />
1274
1425
  )}
1275
1426
 
1427
+ {/* GitOps view */}
1428
+ {mainView === 'gitops' && (
1429
+ <GitOpsView
1430
+ namespaces={namespaces}
1431
+ onOpenResource={(resource) => {
1432
+ setSelectedResource(resource)
1433
+ }}
1434
+ />
1435
+ )}
1436
+
1276
1437
  {/* Traffic view */}
1277
1438
  {mainView === 'traffic' && (
1278
1439
  <TrafficView namespaces={namespaces} />
@@ -1310,7 +1471,7 @@ function AppInner() {
1310
1471
  {mainView === 'workload' && !drawerExpanded && (
1311
1472
  <WorkloadViewRoute
1312
1473
  onNavigateToResource={(resource) => {
1313
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
1474
+ navigate(buildWorkloadPath(resource))
1314
1475
  }}
1315
1476
  />
1316
1477
  )}
@@ -1330,12 +1491,12 @@ function AppInner() {
1330
1491
  onExpand={(res) => {
1331
1492
  suppressViewClearRef.current = true
1332
1493
  setDrawerExpanded(true)
1333
- navigate(`/workload/${res.kind}/${res.namespace}/${res.name}`)
1494
+ navigate(buildWorkloadPath(res))
1334
1495
  }}
1335
1496
  onCollapse={handleCollapseFromExpanded}
1336
1497
  onNavigateToResource={(resource) => {
1337
1498
  setSelectedResource(resource)
1338
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`, { replace: true })
1499
+ navigate(buildWorkloadPath(resource), { replace: true })
1339
1500
  }}
1340
1501
  />
1341
1502
  )}
@@ -1353,11 +1514,11 @@ function AppInner() {
1353
1514
  setSearchParams(params, { replace: true })
1354
1515
  }}
1355
1516
  onNavigateToResource={(resource) => {
1356
- // Navigate to resources view with kind in path and open the resource detail drawer
1357
1517
  setSelectedHelmRelease(null)
1358
1518
  const newParams = new URLSearchParams()
1359
1519
  const globalNamespaces = searchParams.get('namespaces')
1360
1520
  if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
1521
+ if (resource.group) newParams.set('apiGroup', resource.group)
1361
1522
  navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1362
1523
  setSelectedResource(resource)
1363
1524
  }}
@@ -1405,9 +1566,14 @@ function AppInner() {
1405
1566
  { name },
1406
1567
  // Namespace filter from the previous context may not exist in the
1407
1568
  // new one — clear it so resource lists don't silently go empty.
1569
+ // The server clears all per-user picks on context switch already;
1570
+ // local state mirrors that via the namespace-scope effect.
1408
1571
  { onSettled: () => setNamespaces([]) },
1409
1572
  )}
1410
- onSetNamespaces={setNamespaces}
1573
+ onSetNamespaces={(ns) => {
1574
+ setNamespaces(ns)
1575
+ setActiveNamespace.mutate({ namespaces: ns })
1576
+ }}
1411
1577
  onToggleTheme={toggleTheme}
1412
1578
  onShowDiagnostics={() => setShowDiagnostics(true)}
1413
1579
  />
@@ -1428,11 +1594,20 @@ function AppInner() {
1428
1594
 
1429
1595
  // Spacer component that adds padding when dock is open
1430
1596
  function DockSpacer() {
1431
- const { tabs, isExpanded } = useDock()
1597
+ const { tabs, isResizing } = useDock()
1598
+ const dockInset = useDockReservedHeight()
1432
1599
  const location = useLocation()
1433
1600
  // Traffic view manages its own layout — spacer would break its flex sizing
1434
1601
  if (tabs.length === 0 || location.pathname === '/traffic') return null
1435
- return <div className="shrink-0" style={{ height: isExpanded ? 300 : 36, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)` }} />
1602
+ return (
1603
+ <div
1604
+ className="shrink-0"
1605
+ style={{
1606
+ height: dockInset,
1607
+ transition: isResizing ? 'none' : `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)`,
1608
+ }}
1609
+ />
1610
+ )
1436
1611
  }
1437
1612
 
1438
1613
  // Floating action buttons that position themselves above the dock