@skyhook-io/radar-app 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
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.3"
38
+ "yaml": "^2.8.4"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@skyhook-io/k8s-ui": ">=1.5.0",
@@ -54,7 +54,7 @@
54
54
  "@skyhook-io/k8s-ui": "*",
55
55
  "@tailwindcss/typography": "^0.5.19",
56
56
  "@tailwindcss/vite": "^4.2.4",
57
- "@tanstack/react-query": "^5.100.6",
57
+ "@tanstack/react-query": "^5.100.9",
58
58
  "@types/diff": "^8.0.0",
59
59
  "@types/node": "^25.5.0",
60
60
  "@types/react": "^19.2.14",
@@ -64,11 +64,11 @@
64
64
  "clsx": "^2.1.1",
65
65
  "elkjs": "^0.11.1",
66
66
  "lucide-react": "^1.12.0",
67
- "postcss": "^8.5.12",
67
+ "postcss": "^8.5.14",
68
68
  "prettier": "^3.8.1",
69
69
  "react": "^19.2.5",
70
70
  "react-dom": "^19.2.5",
71
- "react-router-dom": "^7.14.2",
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",
package/src/App.tsx CHANGED
@@ -21,6 +21,7 @@ import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './c
21
21
  import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
22
22
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
23
23
  import { ContextSwitcher } from './components/ContextSwitcher'
24
+ import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
24
25
  import { useNavCustomization } from './context/NavCustomization'
25
26
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
26
27
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
@@ -28,13 +29,12 @@ import { ConnectionErrorView } from './components/ConnectionErrorView'
28
29
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
29
30
  import { UserMenu } from './components/UserMenu'
30
31
  import { ErrorBoundary } from './components/ui/ErrorBoundary'
31
- import { NamespaceSelector } from './components/ui/NamespaceSelector'
32
32
  import { UpdateNotification } from './components/ui/UpdateNotification'
33
33
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
34
34
  import { CommandPalette } from './components/ui/CommandPalette'
35
35
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
36
36
  import { useEventSource } from './hooks/useEventSource'
37
- import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
37
+ import { useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
38
38
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
39
39
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
40
40
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
@@ -46,6 +46,7 @@ import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNam
46
46
  import { SettingsDialog } from './components/settings/SettingsDialog'
47
47
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
48
48
  import { kindToPlural, openExternal } from './utils/navigation'
49
+ import type { ContextSwitcherHandle } from './components/ContextSwitcher'
49
50
 
50
51
  // All possible node kinds (core + GitOps)
51
52
  const ALL_NODE_KINDS: NodeKind[] = [
@@ -310,34 +311,51 @@ function AppInner() {
310
311
  // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
311
312
  const suppressViewClearRef = useRef(false)
312
313
 
313
- // Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
314
- // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
315
- const prevResourcesKindSlugRef = useRef<string | null>(null)
314
+ // Close resource drawer when the /resources route no longer matches the
315
+ // selected drawer resource. This covers both in-view kind switches and
316
+ // cross-kind navigations from expanded drawers (for example Node -> View Pods).
317
+ const prevResourcesKindKeyRef = useRef<string | null>(null)
318
+ const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
319
+ const currentResourceGroup = searchParams.get('apiGroup') ?? ''
320
+ const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
321
+ const selectedResourceGroup = selectedResource?.group ?? ''
322
+ const selectedResourceRouteMismatch = mainView === 'resources' && !!selectedResource && (
323
+ selectedResourceKindSlug !== currentResourceKindSlug ||
324
+ selectedResourceGroup !== currentResourceGroup
325
+ )
326
+ const resourcesKindRouteChanged = mainView === 'resources' &&
327
+ prevResourcesKindKeyRef.current !== null &&
328
+ prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
329
+ const routeSelectedResource = resourcesKindRouteChanged && selectedResourceRouteMismatch
330
+ ? null
331
+ : selectedResource
332
+
316
333
  useEffect(() => {
317
334
  if (mainView !== 'resources') {
318
- prevResourcesKindSlugRef.current = null
335
+ prevResourcesKindKeyRef.current = null
319
336
  return
320
337
  }
321
- const slug = normalizedResourcesKindSlug
322
- const prev = prevResourcesKindSlugRef.current
323
- prevResourcesKindSlugRef.current = slug
324
- if (prev !== null && prev !== slug) {
338
+ const key = `${currentResourceGroup}/${currentResourceKindSlug}`
339
+ const prev = prevResourcesKindKeyRef.current
340
+ prevResourcesKindKeyRef.current = key
341
+
342
+ if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
325
343
  setSelectedResource(null)
326
344
  setDrawerExpanded(false)
327
345
  }
328
- }, [mainView, normalizedResourcesKindSlug])
346
+ }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
329
347
 
330
348
  // Animation hooks for smooth mount/unmount transitions
331
- const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
349
+ const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
332
350
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
333
351
  const helpOverlay = useAnimatedUnmount(showHelp, 300)
334
352
  const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
335
353
  const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
336
354
 
337
355
  // Hold last valid values so drawers can animate out before data disappears
338
- const lastResourceRef = useRef(selectedResource)
339
- if (selectedResource) lastResourceRef.current = selectedResource
340
- const drawerResource = selectedResource || lastResourceRef.current
356
+ const lastResourceRef = useRef(routeSelectedResource)
357
+ if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
358
+ const drawerResource = routeSelectedResource || lastResourceRef.current
341
359
 
342
360
  const lastHelmReleaseRef = useRef(selectedHelmRelease)
343
361
  if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
@@ -371,6 +389,10 @@ function AppInner() {
371
389
  // Context switching for command palette
372
390
  const switchContext = useSwitchContext()
373
391
 
392
+ // Refs for dropdown components to trigger them via shortcuts
393
+ const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
394
+ const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
395
+
374
396
  // View switching keyboard shortcuts
375
397
  const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
376
398
  useRegisterShortcuts([
@@ -382,6 +404,22 @@ function AppInner() {
382
404
  scope: 'global' as const,
383
405
  handler: () => setMainView(view),
384
406
  })),
407
+ {
408
+ id: 'switch-namespace',
409
+ keys: 'n',
410
+ description: 'Switch namespace',
411
+ category: 'Navigation' as const,
412
+ scope: 'global' as const,
413
+ handler: () => namespaceSwitcherRef.current?.open(),
414
+ },
415
+ {
416
+ id: 'switch-context',
417
+ keys: 'c',
418
+ description: 'Switch context',
419
+ category: 'Navigation' as const,
420
+ scope: 'global' as const,
421
+ handler: () => contextSwitcherRef.current?.open(),
422
+ },
385
423
  {
386
424
  id: 'theme-toggle',
387
425
  keys: 't',
@@ -452,7 +490,12 @@ function AppInner() {
452
490
  const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
453
491
 
454
492
  // Fetch available namespaces
455
- const { data: availableNamespaces, error: namespacesError } = useNamespaces()
493
+ const { data: availableNamespaces } = useNamespaces()
494
+
495
+ // Per-user view filter served by the backend. Loaded eagerly so the
496
+ // picker can render its current state without showing the multi-select
497
+ // fallback during the initial scope fetch.
498
+ const { data: namespaceScope } = useNamespaceScope()
456
499
 
457
500
  // Context switch state
458
501
  const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
@@ -623,6 +666,39 @@ function AppInner() {
623
666
  // Serialize namespaces for stable dependency tracking
624
667
  const namespacesKey = namespaces.join(',')
625
668
 
669
+ // The server is canonical for the per-user namespace pick. Mirror its
670
+ // `actives` into App.tsx state so consumer hooks (SSE, dashboard, resource
671
+ // lists) stay in lockstep with the picker. The dedicated URL-write effect
672
+ // below propagates the mirrored state to `?namespaces=`.
673
+ const setActiveNamespace = useSetActiveNamespace()
674
+ const initialBookmarkReconciledRef = useRef(false)
675
+ const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
676
+ const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
677
+ useEffect(() => {
678
+ if (!namespaceScope) return
679
+ const sortedScope = [...scopeActives].sort()
680
+ const sortedState = [...namespaces].sort()
681
+ const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
682
+
683
+ // First-load bookmark reconciliation: if the URL had namespaces that
684
+ // differ from the server pick when the scope first arrives, push the
685
+ // URL choice to the server so shared/bookmarked deep links keep
686
+ // working. The ref flips on the first scope load regardless of whether
687
+ // the URL had namespaces — subsequent runs mirror server → state.
688
+ if (!initialBookmarkReconciledRef.current) {
689
+ initialBookmarkReconciledRef.current = true
690
+ if (!sameAsState && sortedState.length > 0) {
691
+ setActiveNamespace.mutate({ namespaces: sortedState })
692
+ return
693
+ }
694
+ }
695
+
696
+ if (!sameAsState) {
697
+ setNamespaces(scopeActives)
698
+ }
699
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
700
+ }, [namespaceScope, namespaceScopeKey])
701
+
626
702
  // Update URL query params when state changes (path is handled by setMainView)
627
703
  // Read from window.location.search (not React Router's searchParams) to preserve
628
704
  // params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
@@ -663,11 +739,24 @@ function AppInner() {
663
739
  // eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
664
740
  }, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
665
741
 
666
- // Sync state from URL when navigating (back/forward)
742
+ // Sync state from URL when navigating (back/forward). Push the URL choice
743
+ // to the server too so the canonical pick stays in lockstep — otherwise a
744
+ // back-nav would visually update but leave the next reload pulling the
745
+ // server's previous (forward-nav) pick.
667
746
  useEffect(() => {
668
747
  const urlNamespaces = parseNamespacesFromURL(searchParams)
669
748
 
670
- if (urlNamespaces.join(',') !== namespacesKey) setNamespaces(urlNamespaces)
749
+ if (urlNamespaces.join(',') !== namespacesKey) {
750
+ setNamespaces(urlNamespaces)
751
+ if (namespaceScope) {
752
+ const sortedURL = [...urlNamespaces].sort()
753
+ const sortedScope = [...(namespaceScope.actives ?? [])].sort()
754
+ const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
755
+ if (!same) {
756
+ setActiveNamespace.mutate({ namespaces: urlNamespaces })
757
+ }
758
+ }
759
+ }
671
760
 
672
761
  // Restore helm release from URL (back navigation)
673
762
  const releaseParam = searchParams.get('release')
@@ -676,7 +765,7 @@ function AppInner() {
676
765
  if (slashIdx > 0) {
677
766
  const ns = releaseParam.slice(0, slashIdx)
678
767
  const name = releaseParam.slice(slashIdx + 1)
679
- setSelectedHelmRelease({ namespace: ns, name })
768
+ setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
680
769
  }
681
770
  }
682
771
  }, [searchParams])
@@ -790,7 +879,7 @@ function AppInner() {
790
879
 
791
880
  return (
792
881
  <PortForwardProvider>
793
- <div className="flex flex-col h-screen bg-theme-base min-w-[800px]">
882
+ <div className="relative flex flex-col h-screen bg-theme-base min-w-[800px]">
794
883
  {/* Header */}
795
884
  <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">
796
885
  {/* Left: Logo + Cluster info */}
@@ -798,7 +887,7 @@ function AppInner() {
798
887
  {navCustomization.brandSlot ?? <Logo />}
799
888
 
800
889
  <div className="flex items-center gap-2">
801
- {navCustomization.contextSlot ?? <ContextSwitcher />}
890
+ {navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
802
891
  {/* Connection status - next to cluster name */}
803
892
  <div className="flex items-center gap-1.5 ml-1">
804
893
  <Tooltip
@@ -891,16 +980,13 @@ function AppInner() {
891
980
 
892
981
  {/* Right: Controls */}
893
982
  <div className="flex items-center gap-3 shrink-0">
894
- {/* Namespace selector with search */}
895
- <NamespaceSelector
896
- value={namespaces}
897
- onChange={setNamespaces}
898
- namespaces={availableNamespaces}
899
- namespacesError={namespacesError}
983
+ <NamespaceSwitcher
984
+ ref={namespaceSwitcherRef}
900
985
  disabled={mainView === 'helm'}
901
986
  disabledTooltip="Helm view always shows all namespaces"
902
987
  />
903
988
 
989
+
904
990
  {/* Command palette trigger */}
905
991
  <button
906
992
  onClick={() => setShowCommandPalette(true)}
@@ -1125,6 +1211,7 @@ function AppInner() {
1125
1211
  namespaces={availableNamespaces}
1126
1212
  onSelect={(ns) => {
1127
1213
  setNamespaces([ns])
1214
+ setActiveNamespace.mutate({ namespaces: [ns] })
1128
1215
  // Large clusters need server-side filtering — reconnect SSE with namespace
1129
1216
  setForceNamespaceFilter([ns])
1130
1217
  }}
@@ -1161,9 +1248,9 @@ function AppInner() {
1161
1248
  selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
1162
1249
  paused={topologyPaused}
1163
1250
  onTogglePause={handleTogglePause}
1164
- onMaximizeNamespace={(ns) => setNamespaces([ns])}
1251
+ onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
1165
1252
  namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1166
- onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
1253
+ onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
1167
1254
  namespacesKey={namespaces.join(',')}
1168
1255
  />
1169
1256
 
@@ -1192,7 +1279,7 @@ function AppInner() {
1192
1279
  {mainView === 'resources' && (
1193
1280
  <ResourcesView
1194
1281
  namespaces={namespaces}
1195
- selectedResource={selectedResource}
1282
+ selectedResource={routeSelectedResource}
1196
1283
  onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
1197
1284
  onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
1198
1285
  onKindChange={() => setSelectedResource(null)}
@@ -1211,7 +1298,10 @@ function AppInner() {
1211
1298
  initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
1212
1299
  requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
1213
1300
  availableNamespaces={availableNamespaces}
1214
- onNamespaceSelect={(ns) => setNamespaces([ns])}
1301
+ onNamespaceSelect={(ns) => {
1302
+ setNamespaces([ns])
1303
+ setActiveNamespace.mutate({ namespaces: [ns] })
1304
+ }}
1215
1305
  />
1216
1306
  )}
1217
1307
 
@@ -1220,10 +1310,15 @@ function AppInner() {
1220
1310
  <HelmView
1221
1311
  namespace=""
1222
1312
  selectedRelease={selectedHelmRelease}
1223
- onReleaseClick={(ns, name) => {
1224
- setSelectedHelmRelease({ namespace: ns, name })
1313
+ onReleaseClick={(ns, name, storageNamespace) => {
1314
+ setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
1225
1315
  const params = new URLSearchParams(window.location.search)
1226
1316
  params.set('release', `${ns}/${name}`)
1317
+ if (storageNamespace) {
1318
+ params.set('releaseStorage', storageNamespace)
1319
+ } else {
1320
+ params.delete('releaseStorage')
1321
+ }
1227
1322
  setSearchParams(params, { replace: true })
1228
1323
  }}
1229
1324
  />
@@ -1305,6 +1400,7 @@ function AppInner() {
1305
1400
  setSelectedHelmRelease(null)
1306
1401
  const params = new URLSearchParams(window.location.search)
1307
1402
  params.delete('release')
1403
+ params.delete('releaseStorage')
1308
1404
  setSearchParams(params, { replace: true })
1309
1405
  }}
1310
1406
  onNavigateToResource={(resource) => {
@@ -1360,9 +1456,14 @@ function AppInner() {
1360
1456
  { name },
1361
1457
  // Namespace filter from the previous context may not exist in the
1362
1458
  // new one — clear it so resource lists don't silently go empty.
1459
+ // The server clears all per-user picks on context switch already;
1460
+ // local state mirrors that via the namespace-scope effect.
1363
1461
  { onSettled: () => setNamespaces([]) },
1364
1462
  )}
1365
- onSetNamespaces={setNamespaces}
1463
+ onSetNamespaces={(ns) => {
1464
+ setNamespaces(ns)
1465
+ setActiveNamespace.mutate({ namespaces: ns })
1466
+ }}
1366
1467
  onToggleTheme={toggleTheme}
1367
1468
  onShowDiagnostics={() => setShowDiagnostics(true)}
1368
1469
  />
package/src/api/client.ts CHANGED
@@ -298,6 +298,7 @@ export function useAudit(namespaces: string[] = []) {
298
298
  queryFn: () => fetchJSON(`/audit${params}`),
299
299
  staleTime: 30000,
300
300
  refetchInterval: 60000,
301
+ placeholderData: (prev) => prev,
301
302
  })
302
303
  }
303
304
 
@@ -1347,6 +1348,7 @@ export interface AvailablePort {
1347
1348
  protocol: string
1348
1349
  containerName?: string
1349
1350
  name?: string
1351
+ scheme?: 'http' | 'https'
1350
1352
  }
1351
1353
 
1352
1354
  export function useAvailablePorts(type: 'pod' | 'service', namespace: string, name: string) {
@@ -1967,15 +1969,20 @@ function streamHelmProgress(
1967
1969
 
1968
1970
  if (data.type === 'complete') {
1969
1971
  resolve(data)
1972
+ return
1970
1973
  } else if (data.type === 'error') {
1971
1974
  reject(new Error(data.message || failureLabel))
1975
+ return
1972
1976
  }
1973
- } catch {
1974
- // Ignore parse errors
1977
+ } catch (err) {
1978
+ reject(err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`))
1979
+ return
1975
1980
  }
1976
1981
  }
1977
1982
  }
1978
1983
  }
1984
+
1985
+ reject(new Error(`${failureLabel}: stream ended before completion`))
1979
1986
  })
1980
1987
  .catch(reject)
1981
1988
  })
@@ -1986,10 +1993,13 @@ export function upgradeWithProgress(
1986
1993
  namespace: string,
1987
1994
  name: string,
1988
1995
  version: string,
1996
+ repositoryName: string | undefined,
1989
1997
  onProgress: (event: InstallProgressEvent) => void
1990
1998
  ): Promise<void> {
1999
+ const params = new URLSearchParams({ version })
2000
+ if (repositoryName) params.set('repository', repositoryName)
1991
2001
  return streamHelmProgress(
1992
- `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?version=${encodeURIComponent(version)}`,
2002
+ `${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
1993
2003
  { method: 'POST' },
1994
2004
  onProgress,
1995
2005
  'Upgrade failed',
@@ -2441,6 +2451,87 @@ export function useSwitchContext() {
2441
2451
  })
2442
2452
  }
2443
2453
 
2454
+ // ============================================================================
2455
+ // Active namespace switcher
2456
+ // ============================================================================
2457
+
2458
+ export interface NamespaceScope {
2459
+ actives: string[]
2460
+ kubeconfigNamespace: string
2461
+ /**
2462
+ * 'cluster-wide' — no per-user pick; user can list across namespaces.
2463
+ * 'namespace' — per-user view filter pinned to one or more namespaces.
2464
+ * 'restricted' — user can't list namespaces and isn't pinned to any.
2465
+ */
2466
+ mode: 'cluster-wide' | 'namespace' | 'restricted'
2467
+ accessibleNamespaces: string[]
2468
+ /** false when accessibleNamespaces is a best-effort short list (no list perm). */
2469
+ authoritative: boolean
2470
+ /** false when clearing would leave no usable namespace fallback. */
2471
+ canClearNamespace: boolean
2472
+ }
2473
+
2474
+ export function useNamespaceScope() {
2475
+ return useQuery<NamespaceScope>({
2476
+ queryKey: ['namespace-scope'],
2477
+ queryFn: () => fetchJSON('/cluster/namespace-scope'),
2478
+ staleTime: 30000,
2479
+ })
2480
+ }
2481
+
2482
+ const NAMESPACE_SWITCH_TIMEOUT = 5000
2483
+
2484
+ export function useSetActiveNamespace() {
2485
+ const queryClient = useQueryClient()
2486
+ return useMutation<NamespaceScope, Error, { namespaces: string[] }>({
2487
+ meta: {
2488
+ // Surface 403s (RBAC drift, denied bookmark) and network errors via the
2489
+ // global toast. Without this, App.tsx call sites that mutate without
2490
+ // their own onError (bookmark reconciliation, back-nav, topology
2491
+ // maximize/clear, command palette) silently revert when the scope
2492
+ // refetches and the mirror effect overwrites local state.
2493
+ errorMessage: 'Failed to update namespace selection',
2494
+ },
2495
+ mutationFn: async ({ namespaces }) => {
2496
+ const controller = new AbortController()
2497
+ const timeoutId = setTimeout(() => controller.abort(), NAMESPACE_SWITCH_TIMEOUT)
2498
+ try {
2499
+ const response = await apiFetch(`${getApiBase()}/cluster/namespace`, {
2500
+ method: 'POST',
2501
+ headers: { 'Content-Type': 'application/json' },
2502
+ body: JSON.stringify({ namespaces }),
2503
+ signal: controller.signal,
2504
+ })
2505
+ clearTimeout(timeoutId)
2506
+ if (!response.ok) {
2507
+ const error = await response.json().catch(() => ({ error: 'Unknown error' }))
2508
+ throw new Error(error.error || `HTTP ${response.status}`)
2509
+ }
2510
+ return response.json()
2511
+ } catch (error) {
2512
+ clearTimeout(timeoutId)
2513
+ if (error instanceof Error && error.name === 'AbortError') {
2514
+ throw new Error('Namespace switch timed out. The cluster may be unreachable.')
2515
+ }
2516
+ throw error
2517
+ }
2518
+ },
2519
+ onSuccess: () => {
2520
+ // The user's view filter changed; every cached query result was
2521
+ // shaped by the previous filter, so drop and refetch.
2522
+ queryClient.removeQueries()
2523
+ queryClient.invalidateQueries()
2524
+ },
2525
+ onError: () => {
2526
+ // A failed switch can leave the server's stored pick out of sync
2527
+ // with the cached scope (network timeout after the server wrote;
2528
+ // partial mutation). Refetch so the displayed picker matches what
2529
+ // the server actually persisted instead of what we assumed.
2530
+ queryClient.invalidateQueries({ queryKey: ['namespace-scope'] })
2531
+ },
2532
+ })
2533
+ }
2534
+
2444
2535
  // ============================================================================
2445
2536
  // Image Filesystem Inspection
2446
2537
  // ============================================================================
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo, useState, forwardRef } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
3
  import {
4
4
  ClusterSwitcher,
@@ -16,11 +16,15 @@ interface ContextSwitcherProps {
16
16
  className?: string
17
17
  }
18
18
 
19
+ export interface ContextSwitcherHandle {
20
+ open: () => void
21
+ }
22
+
19
23
  interface ParsedContext extends ParsedContextName {
20
24
  context: ContextInfo
21
25
  }
22
26
 
23
- export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
27
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
24
28
  const [showConfirm, setShowConfirm] = useState(false)
25
29
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
26
30
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -33,13 +37,37 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
33
37
  const { tabs } = useDock()
34
38
 
35
39
  // Parse contexts and decide whether to render group headers (multi-account only).
36
- const { parsedById, hasMultipleAccounts } = useMemo(() => {
37
- if (!contexts) return { parsedById: new Map<string, ParsedContext>(), hasMultipleAccounts: false }
38
- const parsed: ParsedContext[] = contexts.map(ctx => ({ context: ctx, ...parseContextName(ctx.name) }))
40
+ // hasMultipleSources gates the kubeconfig-source chip only useful when 2+
41
+ // distinct kubeconfig files are in play. Single-source setups (the common
42
+ // case) skip the chip entirely so the dropdown stays clean.
43
+ const { parsedById, hasMultipleAccounts, hasMultipleSources } = useMemo(() => {
44
+ if (!contexts) return {
45
+ parsedById: new Map<string, ParsedContext>(),
46
+ hasMultipleAccounts: false,
47
+ hasMultipleSources: false,
48
+ }
49
+ // Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
50
+ // before parsing — qualified names won't match the GKE/EKS/AKS regexes
51
+ // otherwise, and the suffix is redundant with the source chip we
52
+ // render separately.
53
+ const stripSourceSuffix = (name: string, source?: string): string => {
54
+ if (!source) return name
55
+ const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
+ return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
57
+ }
58
+ const parsed: ParsedContext[] = contexts.map(ctx => ({
59
+ context: ctx,
60
+ ...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
61
+ }))
39
62
  const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
63
+ const sources = new Set(contexts.map(c => c.source).filter(Boolean))
40
64
  const byId = new Map<string, ParsedContext>()
41
65
  for (const p of parsed) byId.set(p.context.name, p)
42
- return { parsedById: byId, hasMultipleAccounts: accounts.size > 1 }
66
+ return {
67
+ parsedById: byId,
68
+ hasMultipleAccounts: accounts.size > 1,
69
+ hasMultipleSources: sources.size > 1,
70
+ }
43
71
  }, [contexts])
44
72
 
45
73
  // Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
@@ -61,20 +89,16 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
61
89
  : hasMultipleAccounts
62
90
  ? 'Other'
63
91
  : undefined
64
- // `name` is the raw context — ClusterSwitcher renders it through
65
- // ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
66
- // tail. `secondary` shows the original raw when we collapsed it,
67
- // so users always see the full context at a glance (rather than
68
- // having to hover to reveal it).
69
92
  return {
70
93
  id: p.context.name,
71
- name: p.context.name,
94
+ name: p.raw,
72
95
  secondary: p.provider ? p.raw : undefined,
73
96
  badge: p.region || undefined,
97
+ sourceLabel: hasMultipleSources ? p.context.source : undefined,
74
98
  group: { key: groupKey, label: groupLabel },
75
99
  }
76
100
  })
77
- }, [parsedById, hasMultipleAccounts])
101
+ }, [parsedById, hasMultipleAccounts, hasMultipleSources])
78
102
 
79
103
  const performSwitch = async (parsed: ParsedContext) => {
80
104
  startSwitch({
@@ -152,15 +176,24 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
152
176
  )
153
177
  }
154
178
 
155
- const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
156
- const currentId = contexts?.find(c => c.isCurrent)?.name
179
+ const currentCtx = contexts?.find(c => c.isCurrent)
180
+ const currentId = currentCtx?.name
181
+ // Use parsed.raw (the source-stripped form) for the trigger so the
182
+ // disambiguation suffix doesn't double up with the source chip.
183
+ // Fall back to clusterInfo.context for the very-early window before
184
+ // /api/contexts has resolved.
185
+ const currentParsed = currentId ? parsedById.get(currentId) : undefined
186
+ const currentRaw = currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
187
+ const currentSourceLabel = hasMultipleSources ? currentCtx?.source || undefined : undefined
157
188
 
158
189
  return (
159
190
  <>
160
191
  <ClusterSwitcher
192
+ ref={ref}
161
193
  className={className}
162
194
  currentId={currentId}
163
195
  currentName={currentRaw}
196
+ currentSourceLabel={currentSourceLabel}
164
197
  items={items}
165
198
  onSelect={handleSelect}
166
199
  loading={switchContext.isPending}
@@ -222,4 +255,4 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
222
255
  )}
223
256
  </>
224
257
  )
225
- }
258
+ })