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