@skyhook-io/radar-app 0.2.2 → 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": "0.2.2",
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
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from './hooks/useRefreshAnimation'
4
+ import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
6
  import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
6
7
  import { HomeView } from './components/home/HomeView'
@@ -20,6 +21,7 @@ import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './c
20
21
  import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
21
22
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
22
23
  import { ContextSwitcher } from './components/ContextSwitcher'
24
+ import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
23
25
  import { useNavCustomization } from './context/NavCustomization'
24
26
  import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
25
27
  import { ConnectionProvider, useConnection } from './context/ConnectionContext'
@@ -27,13 +29,12 @@ import { ConnectionErrorView } from './components/ConnectionErrorView'
27
29
  import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
28
30
  import { UserMenu } from './components/UserMenu'
29
31
  import { ErrorBoundary } from './components/ui/ErrorBoundary'
30
- import { NamespaceSelector } from './components/ui/NamespaceSelector'
31
32
  import { UpdateNotification } from './components/ui/UpdateNotification'
32
33
  import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
33
34
  import { CommandPalette } from './components/ui/CommandPalette'
34
35
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
35
36
  import { useEventSource } from './hooks/useEventSource'
36
- import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
37
+ import { useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
37
38
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
38
39
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
39
40
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
@@ -45,6 +46,7 @@ import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNam
45
46
  import { SettingsDialog } from './components/settings/SettingsDialog'
46
47
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
47
48
  import { kindToPlural, openExternal } from './utils/navigation'
49
+ import type { ContextSwitcherHandle } from './components/ContextSwitcher'
48
50
 
49
51
  // All possible node kinds (core + GitOps)
50
52
  const ALL_NODE_KINDS: NodeKind[] = [
@@ -309,34 +311,51 @@ function AppInner() {
309
311
  // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
310
312
  const suppressViewClearRef = useRef(false)
311
313
 
312
- // Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
313
- // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
314
- 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
+
315
333
  useEffect(() => {
316
334
  if (mainView !== 'resources') {
317
- prevResourcesKindSlugRef.current = null
335
+ prevResourcesKindKeyRef.current = null
318
336
  return
319
337
  }
320
- const slug = normalizedResourcesKindSlug
321
- const prev = prevResourcesKindSlugRef.current
322
- prevResourcesKindSlugRef.current = slug
323
- 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) {
324
343
  setSelectedResource(null)
325
344
  setDrawerExpanded(false)
326
345
  }
327
- }, [mainView, normalizedResourcesKindSlug])
346
+ }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
328
347
 
329
348
  // Animation hooks for smooth mount/unmount transitions
330
- const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
349
+ const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
331
350
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
332
351
  const helpOverlay = useAnimatedUnmount(showHelp, 300)
333
352
  const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
334
353
  const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
335
354
 
336
355
  // Hold last valid values so drawers can animate out before data disappears
337
- const lastResourceRef = useRef(selectedResource)
338
- if (selectedResource) lastResourceRef.current = selectedResource
339
- const drawerResource = selectedResource || lastResourceRef.current
356
+ const lastResourceRef = useRef(routeSelectedResource)
357
+ if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
358
+ const drawerResource = routeSelectedResource || lastResourceRef.current
340
359
 
341
360
  const lastHelmReleaseRef = useRef(selectedHelmRelease)
342
361
  if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
@@ -345,8 +364,13 @@ function AppInner() {
345
364
  // Navigate to a resource — uses View Transitions cross-fade when drawer is already open
346
365
  const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
347
366
  const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
348
- if (selectedResource && document.startViewTransition) {
349
- document.startViewTransition(() => flushSync(update))
367
+ // Skip the cross-fade animation entirely on first open (no
368
+ // `selectedResource`); otherwise route through
369
+ // startViewTransitionSafe to swallow the InvalidStateError that
370
+ // the API rejects with on rapid back-to-back navigations.
371
+ // (SKY-833 bug 49)
372
+ if (selectedResource) {
373
+ startViewTransitionSafe(() => flushSync(update))
350
374
  } else {
351
375
  update()
352
376
  }
@@ -365,6 +389,10 @@ function AppInner() {
365
389
  // Context switching for command palette
366
390
  const switchContext = useSwitchContext()
367
391
 
392
+ // Refs for dropdown components to trigger them via shortcuts
393
+ const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
394
+ const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
395
+
368
396
  // View switching keyboard shortcuts
369
397
  const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
370
398
  useRegisterShortcuts([
@@ -376,6 +404,22 @@ function AppInner() {
376
404
  scope: 'global' as const,
377
405
  handler: () => setMainView(view),
378
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
+ },
379
423
  {
380
424
  id: 'theme-toggle',
381
425
  keys: 't',
@@ -446,7 +490,12 @@ function AppInner() {
446
490
  const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
447
491
 
448
492
  // Fetch available namespaces
449
- 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()
450
499
 
451
500
  // Context switch state
452
501
  const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
@@ -617,6 +666,39 @@ function AppInner() {
617
666
  // Serialize namespaces for stable dependency tracking
618
667
  const namespacesKey = namespaces.join(',')
619
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
+
620
702
  // Update URL query params when state changes (path is handled by setMainView)
621
703
  // Read from window.location.search (not React Router's searchParams) to preserve
622
704
  // params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
@@ -657,11 +739,24 @@ function AppInner() {
657
739
  // eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
658
740
  }, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
659
741
 
660
- // 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.
661
746
  useEffect(() => {
662
747
  const urlNamespaces = parseNamespacesFromURL(searchParams)
663
748
 
664
- 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
+ }
665
760
 
666
761
  // Restore helm release from URL (back navigation)
667
762
  const releaseParam = searchParams.get('release')
@@ -670,7 +765,7 @@ function AppInner() {
670
765
  if (slashIdx > 0) {
671
766
  const ns = releaseParam.slice(0, slashIdx)
672
767
  const name = releaseParam.slice(slashIdx + 1)
673
- setSelectedHelmRelease({ namespace: ns, name })
768
+ setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
674
769
  }
675
770
  }
676
771
  }, [searchParams])
@@ -784,7 +879,7 @@ function AppInner() {
784
879
 
785
880
  return (
786
881
  <PortForwardProvider>
787
- <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]">
788
883
  {/* Header */}
789
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">
790
885
  {/* Left: Logo + Cluster info */}
@@ -792,7 +887,7 @@ function AppInner() {
792
887
  {navCustomization.brandSlot ?? <Logo />}
793
888
 
794
889
  <div className="flex items-center gap-2">
795
- {navCustomization.contextSlot ?? <ContextSwitcher />}
890
+ {navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
796
891
  {/* Connection status - next to cluster name */}
797
892
  <div className="flex items-center gap-1.5 ml-1">
798
893
  <Tooltip
@@ -885,16 +980,13 @@ function AppInner() {
885
980
 
886
981
  {/* Right: Controls */}
887
982
  <div className="flex items-center gap-3 shrink-0">
888
- {/* Namespace selector with search */}
889
- <NamespaceSelector
890
- value={namespaces}
891
- onChange={setNamespaces}
892
- namespaces={availableNamespaces}
893
- namespacesError={namespacesError}
983
+ <NamespaceSwitcher
984
+ ref={namespaceSwitcherRef}
894
985
  disabled={mainView === 'helm'}
895
986
  disabledTooltip="Helm view always shows all namespaces"
896
987
  />
897
988
 
989
+
898
990
  {/* Command palette trigger */}
899
991
  <button
900
992
  onClick={() => setShowCommandPalette(true)}
@@ -1119,6 +1211,7 @@ function AppInner() {
1119
1211
  namespaces={availableNamespaces}
1120
1212
  onSelect={(ns) => {
1121
1213
  setNamespaces([ns])
1214
+ setActiveNamespace.mutate({ namespaces: [ns] })
1122
1215
  // Large clusters need server-side filtering — reconnect SSE with namespace
1123
1216
  setForceNamespaceFilter([ns])
1124
1217
  }}
@@ -1155,9 +1248,9 @@ function AppInner() {
1155
1248
  selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
1156
1249
  paused={topologyPaused}
1157
1250
  onTogglePause={handleTogglePause}
1158
- onMaximizeNamespace={(ns) => setNamespaces([ns])}
1251
+ onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
1159
1252
  namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
1160
- onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
1253
+ onClearNamespace={namespaces.length >= 1 ? () => setActiveNamespace.mutate({ namespaces: [] }) : undefined}
1161
1254
  namespacesKey={namespaces.join(',')}
1162
1255
  />
1163
1256
 
@@ -1186,7 +1279,7 @@ function AppInner() {
1186
1279
  {mainView === 'resources' && (
1187
1280
  <ResourcesView
1188
1281
  namespaces={namespaces}
1189
- selectedResource={selectedResource}
1282
+ selectedResource={routeSelectedResource}
1190
1283
  onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
1191
1284
  onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
1192
1285
  onKindChange={() => setSelectedResource(null)}
@@ -1205,7 +1298,10 @@ function AppInner() {
1205
1298
  initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
1206
1299
  requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
1207
1300
  availableNamespaces={availableNamespaces}
1208
- onNamespaceSelect={(ns) => setNamespaces([ns])}
1301
+ onNamespaceSelect={(ns) => {
1302
+ setNamespaces([ns])
1303
+ setActiveNamespace.mutate({ namespaces: [ns] })
1304
+ }}
1209
1305
  />
1210
1306
  )}
1211
1307
 
@@ -1214,10 +1310,15 @@ function AppInner() {
1214
1310
  <HelmView
1215
1311
  namespace=""
1216
1312
  selectedRelease={selectedHelmRelease}
1217
- onReleaseClick={(ns, name) => {
1218
- setSelectedHelmRelease({ namespace: ns, name })
1313
+ onReleaseClick={(ns, name, storageNamespace) => {
1314
+ setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
1219
1315
  const params = new URLSearchParams(window.location.search)
1220
1316
  params.set('release', `${ns}/${name}`)
1317
+ if (storageNamespace) {
1318
+ params.set('releaseStorage', storageNamespace)
1319
+ } else {
1320
+ params.delete('releaseStorage')
1321
+ }
1221
1322
  setSearchParams(params, { replace: true })
1222
1323
  }}
1223
1324
  />
@@ -1299,6 +1400,7 @@ function AppInner() {
1299
1400
  setSelectedHelmRelease(null)
1300
1401
  const params = new URLSearchParams(window.location.search)
1301
1402
  params.delete('release')
1403
+ params.delete('releaseStorage')
1302
1404
  setSearchParams(params, { replace: true })
1303
1405
  }}
1304
1406
  onNavigateToResource={(resource) => {
@@ -1354,9 +1456,14 @@ function AppInner() {
1354
1456
  { name },
1355
1457
  // Namespace filter from the previous context may not exist in the
1356
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.
1357
1461
  { onSettled: () => setNamespaces([]) },
1358
1462
  )}
1359
- onSetNamespaces={setNamespaces}
1463
+ onSetNamespaces={(ns) => {
1464
+ setNamespaces(ns)
1465
+ setActiveNamespace.mutate({ namespaces: ns })
1466
+ }}
1360
1467
  onToggleTheme={toggleTheme}
1361
1468
  onShowDiagnostics={() => setShowDiagnostics(true)}
1362
1469
  />
package/src/api/client.ts CHANGED
@@ -272,7 +272,7 @@ export interface DashboardResponse {
272
272
  audit: DashboardAudit | null
273
273
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
274
274
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
- partialData?: string[] // Resource kinds still loading after first paint (slow-cluster fallback)
275
+ partialData?: string[] // Critical kinds promoted at first paint that haven't yet finished syncing (live-filtered)
276
276
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
277
277
  }
278
278
 
@@ -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
  // ============================================================================
@@ -2603,6 +2694,31 @@ export interface DiagErrorEntry {
2603
2694
  level: string
2604
2695
  }
2605
2696
 
2697
+ export type DiagSyncPhase = 'not_started' | 'syncing_critical' | 'syncing_deferred' | 'complete'
2698
+
2699
+ export interface DiagInformerSyncStatus {
2700
+ kind: string
2701
+ key: string
2702
+ deferred: boolean
2703
+ synced: boolean
2704
+ syncedAt?: string
2705
+ items: number
2706
+ }
2707
+
2708
+ export interface DiagCacheSyncStatus {
2709
+ phase: DiagSyncPhase
2710
+ syncStarted?: string
2711
+ elapsedSec: number
2712
+ criticalTotal: number
2713
+ criticalSynced: number
2714
+ deferredTotal: number
2715
+ deferredSynced: number
2716
+ informers: DiagInformerSyncStatus[]
2717
+ pendingCritical?: string[]
2718
+ pendingDeferred?: string[]
2719
+ promotedKinds?: string[]
2720
+ }
2721
+
2606
2722
  export interface DiagnosticsSnapshot {
2607
2723
  timestamp: string
2608
2724
  radarVersion: string
@@ -2666,6 +2782,7 @@ export interface DiagnosticsSnapshot {
2666
2782
  typedCount: number
2667
2783
  dynamicCount: number
2668
2784
  watchedCRDs: string[]
2785
+ syncStatus?: DiagCacheSyncStatus
2669
2786
  }
2670
2787
  prometheus?: {
2671
2788
  connected: boolean