@skyhook-io/radar-app 1.0.1 → 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 +144 -25
- package/src/api/client.ts +158 -8
- 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/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/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,9 +16,10 @@ 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'
|
|
24
25
|
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
@@ -34,18 +35,18 @@ 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, useNamespaceScope, useSetActiveNamespace, 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()
|
|
@@ -394,7 +397,7 @@ function AppInner() {
|
|
|
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}`,
|
|
@@ -541,6 +544,16 @@ function AppInner() {
|
|
|
541
544
|
if (pending.kinds.has('secrets')) {
|
|
542
545
|
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
543
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'] })
|
|
544
557
|
// Reset accumulator
|
|
545
558
|
pending.kinds = new Set()
|
|
546
559
|
pending.hasCountChange = false
|
|
@@ -591,6 +604,26 @@ function AppInner() {
|
|
|
591
604
|
}, forceNamespaceFilter, showPolicyEffect)
|
|
592
605
|
const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
|
|
593
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
|
+
|
|
594
627
|
// Apply live topology updates only when not paused. While paused, buffer the
|
|
595
628
|
// latest snapshot so we can apply it instantly when the user resumes.
|
|
596
629
|
useEffect(() => {
|
|
@@ -656,12 +689,24 @@ function AppInner() {
|
|
|
656
689
|
// TODO: Could show a list of pods in the group
|
|
657
690
|
if (node.kind === 'PodGroup') return
|
|
658
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
|
+
|
|
659
703
|
navigateToResource({
|
|
660
704
|
kind: kindToPlural(node.kind),
|
|
661
|
-
namespace
|
|
705
|
+
namespace,
|
|
662
706
|
name: node.name,
|
|
707
|
+
group: apiVersionToGroup(node.data.apiVersion as string | undefined),
|
|
663
708
|
})
|
|
664
|
-
}, [])
|
|
709
|
+
}, [navigate])
|
|
665
710
|
|
|
666
711
|
// Serialize namespaces for stable dependency tracking
|
|
667
712
|
const namespacesKey = namespaces.join(',')
|
|
@@ -679,6 +724,12 @@ function AppInner() {
|
|
|
679
724
|
const sortedScope = [...scopeActives].sort()
|
|
680
725
|
const sortedState = [...namespaces].sort()
|
|
681
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
|
+
})
|
|
682
733
|
|
|
683
734
|
// First-load bookmark reconciliation: if the URL had namespaces that
|
|
684
735
|
// differ from the server pick when the scope first arrives, push the
|
|
@@ -688,12 +739,17 @@ function AppInner() {
|
|
|
688
739
|
if (!initialBookmarkReconciledRef.current) {
|
|
689
740
|
initialBookmarkReconciledRef.current = true
|
|
690
741
|
if (!sameAsState && sortedState.length > 0) {
|
|
742
|
+
debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
|
|
743
|
+
stateNamespaces: sortedState,
|
|
744
|
+
scopeActives: sortedScope,
|
|
745
|
+
})
|
|
691
746
|
setActiveNamespace.mutate({ namespaces: sortedState })
|
|
692
747
|
return
|
|
693
748
|
}
|
|
694
749
|
}
|
|
695
750
|
|
|
696
751
|
if (!sameAsState) {
|
|
752
|
+
debugNamespaceLog('app:scope-mirror-set-namespaces', { nextNamespaces: scopeActives })
|
|
697
753
|
setNamespaces(scopeActives)
|
|
698
754
|
}
|
|
699
755
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
|
|
@@ -734,31 +790,48 @@ function AppInner() {
|
|
|
734
790
|
|
|
735
791
|
// Only update if params actually changed vs current URL
|
|
736
792
|
if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
|
|
793
|
+
debugNamespaceLog('app:url-write', {
|
|
794
|
+
namespaces,
|
|
795
|
+
currentSearch,
|
|
796
|
+
nextSearch: params.toString(),
|
|
797
|
+
mainView,
|
|
798
|
+
})
|
|
737
799
|
setSearchParams(params, { replace: true })
|
|
738
800
|
}
|
|
739
801
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
|
|
740
802
|
}, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
|
|
741
803
|
|
|
742
|
-
// Sync
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
// server
|
|
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".
|
|
746
809
|
useEffect(() => {
|
|
747
810
|
const urlNamespaces = parseNamespacesFromURL(searchParams)
|
|
811
|
+
debugNamespaceLog('app:url-sync', {
|
|
812
|
+
search: searchParams.toString(),
|
|
813
|
+
urlNamespaces,
|
|
814
|
+
stateNamespaces: namespaces,
|
|
815
|
+
namespacesKey,
|
|
816
|
+
})
|
|
748
817
|
|
|
749
818
|
if (urlNamespaces.join(',') !== namespacesKey) {
|
|
819
|
+
debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
|
|
750
820
|
setNamespaces(urlNamespaces)
|
|
751
821
|
if (namespaceScope) {
|
|
752
822
|
const sortedURL = [...urlNamespaces].sort()
|
|
753
823
|
const sortedScope = [...(namespaceScope.actives ?? [])].sort()
|
|
754
824
|
const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
|
|
755
825
|
if (!same) {
|
|
826
|
+
debugNamespaceLog('app:url-sync-mutate-server', {
|
|
827
|
+
urlNamespaces,
|
|
828
|
+
scopeActives: namespaceScope.actives ?? [],
|
|
829
|
+
})
|
|
756
830
|
setActiveNamespace.mutate({ namespaces: urlNamespaces })
|
|
757
831
|
}
|
|
758
832
|
}
|
|
759
833
|
}
|
|
760
834
|
|
|
761
|
-
// Restore helm release from URL (back navigation)
|
|
762
835
|
const releaseParam = searchParams.get('release')
|
|
763
836
|
if (releaseParam) {
|
|
764
837
|
const slashIdx = releaseParam.indexOf('/')
|
|
@@ -768,8 +841,34 @@ function AppInner() {
|
|
|
768
841
|
setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
|
|
769
842
|
}
|
|
770
843
|
}
|
|
844
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- run only when searchParams change; namespacesKey/namespaceScope are read for that transition
|
|
771
845
|
}, [searchParams])
|
|
772
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
|
+
|
|
773
872
|
// Auto-adjust grouping when namespaces change
|
|
774
873
|
useEffect(() => {
|
|
775
874
|
if (namespaces.length === 0 && groupingMode === 'none') {
|
|
@@ -918,7 +1017,7 @@ function AppInner() {
|
|
|
918
1017
|
collide with the absolute-centered nav block at xl, which
|
|
919
1018
|
is the same breakpoint where nav labels appear. */}
|
|
920
1019
|
{(!connected || crdDiscoveryStatus === 'discovering') && (
|
|
921
|
-
<span className="text-
|
|
1020
|
+
<span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
|
|
922
1021
|
{!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
|
|
923
1022
|
</span>
|
|
924
1023
|
)}
|
|
@@ -939,13 +1038,14 @@ function AppInner() {
|
|
|
939
1038
|
</div>
|
|
940
1039
|
|
|
941
1040
|
{/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
|
|
942
|
-
<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">
|
|
943
1042
|
{([
|
|
944
1043
|
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
945
1044
|
{ view: 'topology' as const, icon: Network, label: 'Topology' },
|
|
946
1045
|
{ view: 'resources' as const, icon: List, label: 'Resources' },
|
|
947
1046
|
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
948
1047
|
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
1048
|
+
{ view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
|
|
949
1049
|
{ view: 'traffic' as const, icon: Activity, label: 'Traffic' },
|
|
950
1050
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
951
1051
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
@@ -955,7 +1055,7 @@ function AppInner() {
|
|
|
955
1055
|
<Tooltip key={view} content={label} delay={100} position="bottom">
|
|
956
1056
|
<button
|
|
957
1057
|
onClick={() => setMainView(view)}
|
|
958
|
-
className={`flex items-center gap-1
|
|
1058
|
+
className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
|
|
959
1059
|
mainView === view
|
|
960
1060
|
? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
|
|
961
1061
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
|
|
@@ -1291,7 +1391,7 @@ function AppInner() {
|
|
|
1291
1391
|
<TimelineView
|
|
1292
1392
|
namespaces={namespaces}
|
|
1293
1393
|
onResourceClick={(resource) => {
|
|
1294
|
-
navigate(
|
|
1394
|
+
navigate(buildWorkloadPath(resource))
|
|
1295
1395
|
}}
|
|
1296
1396
|
initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
|
|
1297
1397
|
initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
|
|
@@ -1324,6 +1424,16 @@ function AppInner() {
|
|
|
1324
1424
|
/>
|
|
1325
1425
|
)}
|
|
1326
1426
|
|
|
1427
|
+
{/* GitOps view */}
|
|
1428
|
+
{mainView === 'gitops' && (
|
|
1429
|
+
<GitOpsView
|
|
1430
|
+
namespaces={namespaces}
|
|
1431
|
+
onOpenResource={(resource) => {
|
|
1432
|
+
setSelectedResource(resource)
|
|
1433
|
+
}}
|
|
1434
|
+
/>
|
|
1435
|
+
)}
|
|
1436
|
+
|
|
1327
1437
|
{/* Traffic view */}
|
|
1328
1438
|
{mainView === 'traffic' && (
|
|
1329
1439
|
<TrafficView namespaces={namespaces} />
|
|
@@ -1361,7 +1471,7 @@ function AppInner() {
|
|
|
1361
1471
|
{mainView === 'workload' && !drawerExpanded && (
|
|
1362
1472
|
<WorkloadViewRoute
|
|
1363
1473
|
onNavigateToResource={(resource) => {
|
|
1364
|
-
navigate(
|
|
1474
|
+
navigate(buildWorkloadPath(resource))
|
|
1365
1475
|
}}
|
|
1366
1476
|
/>
|
|
1367
1477
|
)}
|
|
@@ -1381,12 +1491,12 @@ function AppInner() {
|
|
|
1381
1491
|
onExpand={(res) => {
|
|
1382
1492
|
suppressViewClearRef.current = true
|
|
1383
1493
|
setDrawerExpanded(true)
|
|
1384
|
-
navigate(
|
|
1494
|
+
navigate(buildWorkloadPath(res))
|
|
1385
1495
|
}}
|
|
1386
1496
|
onCollapse={handleCollapseFromExpanded}
|
|
1387
1497
|
onNavigateToResource={(resource) => {
|
|
1388
1498
|
setSelectedResource(resource)
|
|
1389
|
-
navigate(
|
|
1499
|
+
navigate(buildWorkloadPath(resource), { replace: true })
|
|
1390
1500
|
}}
|
|
1391
1501
|
/>
|
|
1392
1502
|
)}
|
|
@@ -1404,11 +1514,11 @@ function AppInner() {
|
|
|
1404
1514
|
setSearchParams(params, { replace: true })
|
|
1405
1515
|
}}
|
|
1406
1516
|
onNavigateToResource={(resource) => {
|
|
1407
|
-
// Navigate to resources view with kind in path and open the resource detail drawer
|
|
1408
1517
|
setSelectedHelmRelease(null)
|
|
1409
1518
|
const newParams = new URLSearchParams()
|
|
1410
1519
|
const globalNamespaces = searchParams.get('namespaces')
|
|
1411
1520
|
if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
|
|
1521
|
+
if (resource.group) newParams.set('apiGroup', resource.group)
|
|
1412
1522
|
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1413
1523
|
setSelectedResource(resource)
|
|
1414
1524
|
}}
|
|
@@ -1484,11 +1594,20 @@ function AppInner() {
|
|
|
1484
1594
|
|
|
1485
1595
|
// Spacer component that adds padding when dock is open
|
|
1486
1596
|
function DockSpacer() {
|
|
1487
|
-
const { tabs,
|
|
1597
|
+
const { tabs, isResizing } = useDock()
|
|
1598
|
+
const dockInset = useDockReservedHeight()
|
|
1488
1599
|
const location = useLocation()
|
|
1489
1600
|
// Traffic view manages its own layout — spacer would break its flex sizing
|
|
1490
1601
|
if (tabs.length === 0 || location.pathname === '/traffic') return null
|
|
1491
|
-
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
|
+
)
|
|
1492
1611
|
}
|
|
1493
1612
|
|
|
1494
1613
|
// Floating action buttons that position themselves above the dock
|