@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 +5 -5
- package/src/App.tsx +135 -34
- package/src/api/client.ts +94 -3
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/NamespaceSwitcher.tsx +298 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +30 -13
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/portforward/PortForwardManager.tsx +152 -111
- package/src/components/resources/ResourcesView.tsx +2 -2
- 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": "1.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
|
@@ -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
|
|
314
|
-
//
|
|
315
|
-
|
|
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
|
-
|
|
335
|
+
prevResourcesKindKeyRef.current = null
|
|
319
336
|
return
|
|
320
337
|
}
|
|
321
|
-
const
|
|
322
|
-
const prev =
|
|
323
|
-
|
|
324
|
-
|
|
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,
|
|
346
|
+
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
329
347
|
|
|
330
348
|
// Animation hooks for smooth mount/unmount transitions
|
|
331
|
-
const resourceDrawer = useAnimatedUnmount(!!
|
|
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(
|
|
339
|
-
if (
|
|
340
|
-
const drawerResource =
|
|
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
|
|
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)
|
|
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
|
-
|
|
895
|
-
|
|
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) =>
|
|
1251
|
+
onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
|
|
1165
1252
|
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1166
|
-
onClearNamespace={namespaces.length
|
|
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={
|
|
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) =>
|
|
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={
|
|
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
|
-
|
|
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
|
// ============================================================================
|
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 {
|
|
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.
|
|
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
|
|
156
|
-
const currentId =
|
|
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
|
+
})
|