@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 +5 -5
- package/src/App.tsx +143 -36
- package/src/api/client.ts +121 -4
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/audit/AuditSettingsDialog.tsx +49 -10
- package/src/components/helm/ChartBrowser.tsx +11 -11
- package/src/components/helm/HelmReleaseDrawer.tsx +35 -19
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/helm/InstallWizard.tsx +79 -22
- package/src/components/home/HomeView.tsx +13 -1
- package/src/components/portforward/PortForwardButton.tsx +37 -16
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +17 -18
- package/src/components/ui/DiagnosticsOverlay.tsx +93 -2
- package/src/components/ui/UpdateNotification.tsx +7 -7
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +0 -436
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
313
|
-
//
|
|
314
|
-
|
|
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
|
-
|
|
335
|
+
prevResourcesKindKeyRef.current = null
|
|
318
336
|
return
|
|
319
337
|
}
|
|
320
|
-
const
|
|
321
|
-
const prev =
|
|
322
|
-
|
|
323
|
-
|
|
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,
|
|
346
|
+
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
328
347
|
|
|
329
348
|
// Animation hooks for smooth mount/unmount transitions
|
|
330
|
-
const resourceDrawer = useAnimatedUnmount(!!
|
|
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(
|
|
338
|
-
if (
|
|
339
|
-
const drawerResource =
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
889
|
-
|
|
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) =>
|
|
1251
|
+
onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
|
|
1159
1252
|
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1160
|
-
onClearNamespace={namespaces.length
|
|
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={
|
|
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) =>
|
|
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={
|
|
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[] //
|
|
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
|
-
|
|
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
|
|
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
|