@skyhook-io/radar-app 1.8.3 → 1.8.5
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 +248 -92
- package/src/RadarApp.tsx +4 -1
- package/src/api/client.ts +32 -6
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -300
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/gitops/GitOpsView.tsx +22 -7
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +189 -352
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +4 -4
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +3 -0
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +5 -1
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.5",
|
|
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",
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"@skyhook-io/k8s-ui": "*",
|
|
57
57
|
"@tailwindcss/typography": "^0.5.20",
|
|
58
58
|
"@tailwindcss/vite": "^4.3.1",
|
|
59
|
-
"@tanstack/react-query": "^5.
|
|
60
|
-
"@types/node": "^26.0.
|
|
59
|
+
"@tanstack/react-query": "^5.101.2",
|
|
60
|
+
"@types/node": "^26.0.1",
|
|
61
61
|
"@types/react": "^19.2.17",
|
|
62
62
|
"@types/react-dom": "^19.2.3",
|
|
63
63
|
"@vitejs/plugin-react": "^6.0.2",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
69
69
|
"eslint-plugin-react-refresh": "^0.5.3",
|
|
70
70
|
"globals": "^17.7.0",
|
|
71
|
-
"lucide-react": "^1.
|
|
72
|
-
"postcss": "^8.5.
|
|
71
|
+
"lucide-react": "^1.22.0",
|
|
72
|
+
"postcss": "^8.5.16",
|
|
73
73
|
"prettier": "^3.8.1",
|
|
74
74
|
"react": "^19.2.7",
|
|
75
75
|
"react-dom": "^19.2.7",
|
package/src/App.tsx
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
|
-
import { useRefreshAnimation } from './hooks/useRefreshAnimation'
|
|
4
3
|
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
5
4
|
import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
|
|
6
5
|
import { useQueryClient } from '@tanstack/react-query'
|
|
7
6
|
import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
|
|
8
7
|
import { HomeView } from './components/home/HomeView'
|
|
9
8
|
import { DebugOverlay } from './components/DebugOverlay'
|
|
10
|
-
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind, gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
|
|
9
|
+
import { TopologyGraph, TopologySearch, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill } from '@skyhook-io/k8s-ui'
|
|
11
10
|
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
12
|
-
import { useAPIResources } from './api/apiResources'
|
|
11
|
+
import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
|
|
13
12
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
14
13
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
15
14
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -17,6 +16,7 @@ import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawe
|
|
|
17
16
|
import { WorkloadViewRoute } from './components/workload/WorkloadView'
|
|
18
17
|
import { CompareViewRoute } from './components/compare/CompareViewRoute'
|
|
19
18
|
import { HelmView } from './components/helm/HelmView'
|
|
19
|
+
import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
|
|
20
20
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
21
21
|
import { CostView } from './components/cost/CostView'
|
|
22
22
|
import { AuditView } from './components/audit/AuditView'
|
|
@@ -48,11 +48,11 @@ import { useEventSource } from './hooks/useEventSource'
|
|
|
48
48
|
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
49
49
|
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
50
50
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
51
|
-
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
51
|
+
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
|
|
52
52
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
53
53
|
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
54
54
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
55
|
-
import {
|
|
55
|
+
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
|
|
56
56
|
import { useTheme } from './context/ThemeContext'
|
|
57
57
|
import { Tooltip } from './components/ui/Tooltip'
|
|
58
58
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
@@ -105,10 +105,11 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
105
105
|
|
|
106
106
|
// Convert API resource name back to topology node ID prefix
|
|
107
107
|
// Extended MainView type that includes traffic and cost
|
|
108
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
|
|
108
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
109
109
|
|
|
110
110
|
// Extract view from URL path
|
|
111
111
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
112
|
+
if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
|
|
112
113
|
const path = pathname.replace(/^\//, '').split('/')[0]
|
|
113
114
|
if (path === '' || path === 'home') return 'home'
|
|
114
115
|
if (path === 'topology') return 'topology'
|
|
@@ -126,6 +127,48 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
126
127
|
return 'home'
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
// The namespace scope filter is meaningful only on namespaced surfaces. On
|
|
131
|
+
// cluster-scoped views it does nothing, so we disable it with an explanation
|
|
132
|
+
// rather than leaving a dead control that silently ignores the pick:
|
|
133
|
+
// - Cost is reported per-namespace across the whole cluster (the view IS the
|
|
134
|
+
// breakdown; a filter would only hide rows).
|
|
135
|
+
// - A GitOps detail tree spans namespaces — its controller lives in one
|
|
136
|
+
// namespace but manages workloads across many.
|
|
137
|
+
// - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
|
|
138
|
+
// namespace at all.
|
|
139
|
+
// The pick itself is preserved so it re-applies when the user returns to a
|
|
140
|
+
// namespaced view.
|
|
141
|
+
function namespaceFilterDisabled(
|
|
142
|
+
view: ExtendedMainView,
|
|
143
|
+
pathname: string,
|
|
144
|
+
apiResources?: { name: string; kind: string; namespaced: boolean }[],
|
|
145
|
+
): { disabled: boolean; tooltip?: string } {
|
|
146
|
+
if (view === 'cost') {
|
|
147
|
+
return {
|
|
148
|
+
disabled: true,
|
|
149
|
+
tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const segments = pathname.replace(/^\//, '').split('/')
|
|
153
|
+
if (view === 'gitops' && segments[1] === 'detail') {
|
|
154
|
+
return {
|
|
155
|
+
disabled: true,
|
|
156
|
+
tooltip: 'This resource manages workloads across namespaces — the namespace filter doesn’t apply to its tree.',
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (view === 'resources') {
|
|
160
|
+
const kindSlug = segments[1]
|
|
161
|
+
const match = kindSlug ? apiResources?.find(r => r.name === kindSlug) : undefined
|
|
162
|
+
if (match && !match.namespaced) {
|
|
163
|
+
return {
|
|
164
|
+
disabled: true,
|
|
165
|
+
tooltip: `${match.kind} is a cluster-scoped resource — namespaces don’t apply.`,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { disabled: false }
|
|
170
|
+
}
|
|
171
|
+
|
|
129
172
|
// Browser tab label for every Radar view, derived from the route URL so it's
|
|
130
173
|
// correct regardless of which component renders it. A detail drawer that opens
|
|
131
174
|
// over a list (?resource=…) is deliberately NOT titled — it's the same page, so
|
|
@@ -337,6 +380,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
337
380
|
const { data: navApiResources } = useAPIResources()
|
|
338
381
|
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
339
382
|
|
|
383
|
+
// View-aware namespace scope: disabled on cluster-scoped surfaces so the
|
|
384
|
+
// chip isn't a dead control next to the cluster switcher.
|
|
385
|
+
// Fall back to the static core-resource list before discovery loads, so the
|
|
386
|
+
// chip disables immediately on a cluster-scoped core kind (Nodes, PVs, …)
|
|
387
|
+
// instead of flickering enabled until /api-resources resolves.
|
|
388
|
+
const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, navApiResources ?? CORE_RESOURCES)
|
|
389
|
+
|
|
340
390
|
// One URL-derived tab title for every view (see radarPageTitle). Driving it
|
|
341
391
|
// from the URL — not the mounted component. Off unless the host opts in
|
|
342
392
|
// (standalone passes manageDocumentTitle), so embedders keep title ownership.
|
|
@@ -460,11 +510,16 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
460
510
|
// Diagnostics overlay state
|
|
461
511
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
|
462
512
|
|
|
463
|
-
//
|
|
464
|
-
|
|
513
|
+
// The peek drawer "expanded" into a fullscreen overlay = ?full=1 with a selected
|
|
514
|
+
// resource, on ANY view (resources list, topology graph, GitOps, Applications…) —
|
|
515
|
+
// the underlying view stays mounted. URL-derived so Back/Forward/refresh behave
|
|
516
|
+
// (non-list peeks aren't URL-backed, so refresh drops the overlay gracefully).
|
|
517
|
+
// Used by the routing effects below; the render uses `expandedView` (gated on what
|
|
518
|
+
// actually renders) — see further down.
|
|
519
|
+
const drawerExpanded = !!selectedResource && searchParams.get('full') === '1'
|
|
465
520
|
|
|
466
|
-
//
|
|
467
|
-
const
|
|
521
|
+
// On mobile there's no room for the side drawer — a resource detail is always full-screen.
|
|
522
|
+
const isMobile = useMediaQuery('(max-width: 639px)')
|
|
468
523
|
|
|
469
524
|
// On a history Pop (back/forward) the URL is authoritative. The URL-write
|
|
470
525
|
// effect, running with not-yet-synced state, would otherwise write the stale
|
|
@@ -497,9 +552,9 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
497
552
|
// URL backing, so the only signal that the page beneath it has navigated is
|
|
498
553
|
// that its owner-key (pathname + ?app) no longer matches where it was opened.
|
|
499
554
|
// Hiding it here, at render time, closes the orphan on Back without adding
|
|
500
|
-
// another clearing effect
|
|
501
|
-
//
|
|
502
|
-
//
|
|
555
|
+
// another clearing effect. The /resources case is URL-backed and handled above;
|
|
556
|
+
// an expanded drawer (drawerExpanded) only exists on /resources (?full=1), so it
|
|
557
|
+
// is excluded here and never treated as an orphan.
|
|
503
558
|
const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
|
|
504
559
|
peekOwnerKeyRef.current !== null &&
|
|
505
560
|
peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
|
|
@@ -526,7 +581,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
526
581
|
|
|
527
582
|
if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
|
|
528
583
|
setSelectedResource(null)
|
|
529
|
-
setDrawerExpanded(false)
|
|
530
584
|
}
|
|
531
585
|
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
532
586
|
|
|
@@ -542,6 +596,19 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
542
596
|
if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
|
|
543
597
|
const drawerResource = routeSelectedResource || lastResourceRef.current
|
|
544
598
|
|
|
599
|
+
// Effective fullscreen state — keyed off the resource that's ACTUALLY rendering
|
|
600
|
+
// (routeSelectedResource), not the raw selection, so an orphaned/mismatched peek
|
|
601
|
+
// can't inert the shell with no visible drawer. ?full=1 on any view, or forced on
|
|
602
|
+
// mobile (no room for a side drawer). Drives the inert backdrop + shortcut suppression.
|
|
603
|
+
const expandedView = !!routeSelectedResource && (searchParams.get('full') === '1' || isMobile)
|
|
604
|
+
useSuppressBaseShortcuts(expandedView)
|
|
605
|
+
// Held value for the drawer's `expanded` prop so closing an expanded drawer slides
|
|
606
|
+
// it out at full size instead of running a collapse morph mid-dismiss. Tracks the
|
|
607
|
+
// live state while a resource is selected; frozen during the slide-out.
|
|
608
|
+
const lastExpandedRef = useRef(expandedView)
|
|
609
|
+
if (routeSelectedResource) lastExpandedRef.current = expandedView
|
|
610
|
+
const drawerExpandedProp = routeSelectedResource ? expandedView : lastExpandedRef.current
|
|
611
|
+
|
|
545
612
|
const lastHelmReleaseRef = useRef(selectedHelmRelease)
|
|
546
613
|
if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
|
|
547
614
|
const drawerHelmRelease = selectedHelmRelease || lastHelmReleaseRef.current
|
|
@@ -580,6 +647,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
580
647
|
newParams.delete('kind')
|
|
581
648
|
newParams.delete('mode')
|
|
582
649
|
newParams.delete('group')
|
|
650
|
+
// Open as a normal drawer — never inherit a stale ?full=1/tab from an
|
|
651
|
+
// expanded view we're navigating away from (only expand/drill set those).
|
|
652
|
+
newParams.delete('full')
|
|
653
|
+
newParams.delete('tab')
|
|
583
654
|
newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
584
655
|
if (resource.group) {
|
|
585
656
|
newParams.set('apiGroup', resource.group)
|
|
@@ -627,12 +698,33 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
627
698
|
navigateToResourceList(resource)
|
|
628
699
|
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
629
700
|
|
|
630
|
-
// Collapse
|
|
701
|
+
// Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
|
|
702
|
+
// resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
|
|
703
|
+
// regardless of how we got here (expand, deep link, or a drill trail), so it
|
|
704
|
+
// scrubs rather than walking history — `navigate(-1)` would leave the app on a
|
|
705
|
+
// deep link, or step back to the previous resource after a drill. Browser Back
|
|
706
|
+
// keeps its own natural history walk (it pops the ?full=1 entry → collapse).
|
|
631
707
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
708
|
+
const p = new URLSearchParams(searchParams)
|
|
709
|
+
p.delete('full')
|
|
710
|
+
p.delete('tab')
|
|
711
|
+
setSearchParams(p, { replace: true })
|
|
712
|
+
}, [searchParams, setSearchParams])
|
|
713
|
+
|
|
714
|
+
// Close the peek and drop any expand flags. Outside /resources the drawer isn't
|
|
715
|
+
// URL-backed, so a lingering ?full=1/tab would make the next peek reopen
|
|
716
|
+
// fullscreen instead of as a side drawer. (On /resources, ResourcesView's own
|
|
717
|
+
// updateURL also scrubs these — deleting them here too is idempotent.)
|
|
718
|
+
const closeDrawer = useCallback(() => {
|
|
719
|
+
setSelectedResource(null)
|
|
720
|
+
setDrawerInitialTab('detail')
|
|
721
|
+
if (searchParams.has('full') || searchParams.has('tab')) {
|
|
722
|
+
const p = new URLSearchParams(searchParams)
|
|
723
|
+
p.delete('full')
|
|
724
|
+
p.delete('tab')
|
|
725
|
+
setSearchParams(p, { replace: true })
|
|
726
|
+
}
|
|
727
|
+
}, [searchParams, setSearchParams])
|
|
636
728
|
|
|
637
729
|
// Theme toggle for keyboard shortcut
|
|
638
730
|
const { toggleTheme } = useTheme()
|
|
@@ -659,7 +751,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
659
751
|
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
660
752
|
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
661
753
|
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
662
|
-
workload: '', compare: '',
|
|
754
|
+
workload: '', compare: '', helmCompare: '',
|
|
663
755
|
}
|
|
664
756
|
const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
|
|
665
757
|
(v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
|
|
@@ -901,7 +993,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
901
993
|
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
902
994
|
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
903
995
|
const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
|
|
904
|
-
const { topology
|
|
996
|
+
const { topology } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
|
|
905
997
|
onContextSwitchComplete: endSwitch,
|
|
906
998
|
onContextSwitchProgress: updateProgress,
|
|
907
999
|
onContextChanged: () => {
|
|
@@ -918,8 +1010,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
918
1010
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
919
1011
|
|
|
920
1012
|
// Close any open drawers/overlays — old cluster's resources don't exist on the new one
|
|
1013
|
+
// (?full=1 is cleared by the URL reset below).
|
|
921
1014
|
setSelectedResource(null)
|
|
922
|
-
setDrawerExpanded(false)
|
|
923
1015
|
setSelectedHelmRelease(null)
|
|
924
1016
|
|
|
925
1017
|
// Reset URL to current view with no resource-specific params.
|
|
@@ -938,7 +1030,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
938
1030
|
},
|
|
939
1031
|
onK8sEvent: handleK8sEvent,
|
|
940
1032
|
}, forceNamespaceFilter, showPolicyEffect)
|
|
941
|
-
const
|
|
1033
|
+
const clusterConnected = connection.state === 'connected'
|
|
942
1034
|
|
|
943
1035
|
// On large clusters (where the server requires namespace filtering), keep
|
|
944
1036
|
// SSE's server-side filter in lockstep with the user's namespace pick.
|
|
@@ -1284,15 +1376,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1284
1376
|
// But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
|
|
1285
1377
|
const prevMainView = useRef(mainView)
|
|
1286
1378
|
useEffect(() => {
|
|
1287
|
-
// Skip clearing during controlled expand/collapse transitions
|
|
1288
|
-
if (suppressViewClearRef.current) {
|
|
1289
|
-
suppressViewClearRef.current = false
|
|
1290
|
-
prevMainView.current = mainView
|
|
1291
|
-
return
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
1379
|
const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
|
|
1295
|
-
const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
|
|
1296
1380
|
prevMainView.current = mainView
|
|
1297
1381
|
|
|
1298
1382
|
// The URL is the source of truth for what's selected. A deep link
|
|
@@ -1301,14 +1385,15 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1301
1385
|
// asserts. (On a real view switch the URL no longer carries the param, so
|
|
1302
1386
|
// the clear proceeds.) Without this, deep-linking straight to a Helm
|
|
1303
1387
|
// release lands on the release list with no drawer.
|
|
1388
|
+
// (drawerExpanded is URL-derived from ?full=1, so leaving /resources drops it
|
|
1389
|
+
// automatically — no explicit reset needed.)
|
|
1304
1390
|
const params = new URLSearchParams(window.location.search)
|
|
1305
1391
|
if (!navigatingToResources && !params.has('resource')) {
|
|
1306
1392
|
setSelectedResource(null)
|
|
1307
1393
|
}
|
|
1308
|
-
if (!
|
|
1394
|
+
if (!params.has('release')) {
|
|
1309
1395
|
setSelectedHelmRelease(null)
|
|
1310
1396
|
}
|
|
1311
|
-
setDrawerExpanded(false)
|
|
1312
1397
|
}, [mainView])
|
|
1313
1398
|
|
|
1314
1399
|
// Clear resource selection when namespaces change — but keep a selection the
|
|
@@ -1318,7 +1403,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1318
1403
|
const params = new URLSearchParams(window.location.search)
|
|
1319
1404
|
if (!params.has('resource')) setSelectedResource(null)
|
|
1320
1405
|
if (!params.has('release')) setSelectedHelmRelease(null)
|
|
1321
|
-
setDrawerExpanded(false)
|
|
1322
1406
|
}, [namespacesKey])
|
|
1323
1407
|
|
|
1324
1408
|
// Filter topology based on visible kinds (uses displayedTopology which respects pause)
|
|
@@ -1423,6 +1507,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1423
1507
|
setVisibleKinds(new Set())
|
|
1424
1508
|
}, [])
|
|
1425
1509
|
|
|
1510
|
+
const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
|
|
1511
|
+
|
|
1426
1512
|
return (
|
|
1427
1513
|
<PortForwardProvider>
|
|
1428
1514
|
{/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
|
|
@@ -1435,7 +1521,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1435
1521
|
>
|
|
1436
1522
|
{showNavRail && (
|
|
1437
1523
|
<PrimaryNavRail
|
|
1438
|
-
activeView={
|
|
1524
|
+
activeView={navActiveView}
|
|
1439
1525
|
onNavigate={setMainView}
|
|
1440
1526
|
pinned={navRailEffectivePinned}
|
|
1441
1527
|
onTogglePinned={toggleNavRailPinned}
|
|
@@ -1452,20 +1538,58 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1452
1538
|
{/* Header — suppressed in chromeless embed; the host owns the chrome. */}
|
|
1453
1539
|
{!chromeless && (
|
|
1454
1540
|
<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">
|
|
1455
|
-
{/* Left: Logo + Cluster info
|
|
1456
|
-
|
|
1541
|
+
{/* Left: Logo + Cluster info. In the standalone (nav-rail) layout this
|
|
1542
|
+
is a FIXED-WIDTH column so the omnibar after it is force-pinned: the
|
|
1543
|
+
scope pill + status dot can change width (cluster/namespace value)
|
|
1544
|
+
without ever shifting the search box. The pill's own name/value caps
|
|
1545
|
+
keep it inside this width; the embedded/pill layout keeps auto width
|
|
1546
|
+
(its center bar is absolutely positioned). */}
|
|
1547
|
+
<div className={`flex items-center gap-4 shrink-0 ${showNavRail ? 'w-[492px]' : ''}`}>
|
|
1457
1548
|
{/* Standalone rail owns the brand; only the embedded/pill layout
|
|
1458
1549
|
shows it in the header (host may override via brandSlot). */}
|
|
1459
1550
|
{navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
|
|
1460
1551
|
|
|
1461
|
-
<div className=
|
|
1462
|
-
{navCustomization.contextSlot
|
|
1463
|
-
|
|
1464
|
-
|
|
1552
|
+
<div className={`flex items-center gap-2 min-w-0 ${showNavRail ? 'flex-1' : ''}`}>
|
|
1553
|
+
{navCustomization.contextSlot ? (
|
|
1554
|
+
// Embedded host supplies its own cluster switcher — keep the two
|
|
1555
|
+
// controls separate (the host owns the cluster chip's styling).
|
|
1556
|
+
<>
|
|
1557
|
+
{navCustomization.contextSlot}
|
|
1558
|
+
<NamespaceSwitcher
|
|
1559
|
+
ref={namespaceSwitcherRef}
|
|
1560
|
+
disabled={namespaceFilter.disabled}
|
|
1561
|
+
disabledTooltip={namespaceFilter.tooltip}
|
|
1562
|
+
/>
|
|
1563
|
+
</>
|
|
1564
|
+
) : (
|
|
1565
|
+
// Standalone: cluster + namespace as one "scope" pill — two
|
|
1566
|
+
// borderless segments split by a divider, reading as a single
|
|
1567
|
+
// "what am I looking at" unit. The shared ScopePill shell is the
|
|
1568
|
+
// same one Radar Hub's cluster top bar uses, so the two match.
|
|
1569
|
+
<ScopePill>
|
|
1570
|
+
<ContextSwitcher ref={contextSwitcherRef} variant="segment" />
|
|
1571
|
+
<NamespaceSwitcher
|
|
1572
|
+
ref={namespaceSwitcherRef}
|
|
1573
|
+
variant="segment"
|
|
1574
|
+
disabled={namespaceFilter.disabled}
|
|
1575
|
+
disabledTooltip={namespaceFilter.tooltip}
|
|
1576
|
+
/>
|
|
1577
|
+
</ScopePill>
|
|
1578
|
+
)}
|
|
1579
|
+
{/* Connection status — a fixed-size dot (state in the tooltip; the
|
|
1580
|
+
dot is the reconnect control when disconnected) plus, when the
|
|
1581
|
+
header is wide enough (xl+), a label. The label is nowrap and
|
|
1582
|
+
unbounded: it overflows the fixed left column into the empty gap
|
|
1583
|
+
before the centered search box rather than shifting anything (the
|
|
1584
|
+
dot + pill are shrink-0, so layout stays put — the search box
|
|
1585
|
+
never moves). Where the gap is smaller than the label, its tail
|
|
1586
|
+
tucks under the omnibar's solid background. Below xl it's the dot
|
|
1587
|
+
alone. */}
|
|
1588
|
+
<div className="ml-1 flex items-center gap-1.5 shrink-0">
|
|
1465
1589
|
<Tooltip
|
|
1466
1590
|
content={
|
|
1467
|
-
!
|
|
1468
|
-
? '
|
|
1591
|
+
!clusterConnected
|
|
1592
|
+
? 'Cluster disconnected — click to reconnect'
|
|
1469
1593
|
: crdDiscoveryStatus === 'discovering'
|
|
1470
1594
|
? 'Connected — discovering Custom Resources...'
|
|
1471
1595
|
: 'Connected'
|
|
@@ -1473,38 +1597,29 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1473
1597
|
delay={100}
|
|
1474
1598
|
position="bottom"
|
|
1475
1599
|
>
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
? 'bg-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1600
|
+
{clusterConnected ? (
|
|
1601
|
+
<span
|
|
1602
|
+
className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
|
|
1603
|
+
crdDiscoveryStatus === 'discovering' ? 'bg-amber-400 animate-pulse' : 'bg-green-500'
|
|
1604
|
+
}`}
|
|
1605
|
+
/>
|
|
1606
|
+
) : (
|
|
1607
|
+
<button
|
|
1608
|
+
type="button"
|
|
1609
|
+
onClick={retryConnection}
|
|
1610
|
+
disabled={isRetrying}
|
|
1611
|
+
aria-label="Cluster disconnected — reconnect"
|
|
1612
|
+
className="block shrink-0 disabled:cursor-default"
|
|
1613
|
+
>
|
|
1614
|
+
<span className={`block w-2.5 h-2.5 rounded-full bg-red-500 ${isRetrying ? 'animate-pulse' : ''}`} />
|
|
1615
|
+
</button>
|
|
1616
|
+
)}
|
|
1485
1617
|
</Tooltip>
|
|
1486
|
-
{
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
"Connected" text here would expand the left section and
|
|
1490
|
-
collide with the absolute-centered nav block at xl, which
|
|
1491
|
-
is the same breakpoint where nav labels appear. */}
|
|
1492
|
-
{(!connected || crdDiscoveryStatus === 'discovering') && (
|
|
1493
|
-
<span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
|
|
1494
|
-
{!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
|
|
1618
|
+
{showNavRail && (!clusterConnected || crdDiscoveryStatus === 'discovering') && (
|
|
1619
|
+
<span className="hidden xl:block whitespace-nowrap text-[11px] text-theme-text-tertiary">
|
|
1620
|
+
{!clusterConnected ? 'Disconnected' : 'Discovering Custom Resources…'}
|
|
1495
1621
|
</span>
|
|
1496
1622
|
)}
|
|
1497
|
-
{!connected && (
|
|
1498
|
-
<Tooltip content="Reconnect">
|
|
1499
|
-
<button
|
|
1500
|
-
onClick={reconnect}
|
|
1501
|
-
disabled={isReconnecting}
|
|
1502
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
|
|
1503
|
-
>
|
|
1504
|
-
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
|
1505
|
-
</button>
|
|
1506
|
-
</Tooltip>
|
|
1507
|
-
)}
|
|
1508
1623
|
</div>
|
|
1509
1624
|
{/* Port forwards indicator — shown only when sessions exist */}
|
|
1510
1625
|
<PortForwardIndicator />
|
|
@@ -1546,7 +1661,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1546
1661
|
<button
|
|
1547
1662
|
onClick={() => setMainView(view)}
|
|
1548
1663
|
className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
|
|
1549
|
-
mainView === view
|
|
1664
|
+
mainView === view || (mainView === 'helmCompare' && view === 'helm')
|
|
1550
1665
|
? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
|
|
1551
1666
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
|
|
1552
1667
|
}`}
|
|
@@ -1570,7 +1685,12 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1570
1685
|
)}
|
|
1571
1686
|
|
|
1572
1687
|
{/* Center: omnibar — standalone search + command surface (the ⌘K entry).
|
|
1573
|
-
|
|
1688
|
+
Its container is one of three EQUAL flex-1 columns (see the left/right
|
|
1689
|
+
groups): equal side columns keep this middle column — and the search
|
|
1690
|
+
box centered in it — pinned regardless of how wide the left-side
|
|
1691
|
+
chrome gets (cluster name, namespace label, "Discovering…" /
|
|
1692
|
+
"Disconnected" text). Overflowing side content truncates instead of
|
|
1693
|
+
dragging the box. Same pattern as Radar Hub's ClusterTopBar. */}
|
|
1574
1694
|
{showNavRail && (
|
|
1575
1695
|
<div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
|
|
1576
1696
|
<RadarOmnibar
|
|
@@ -1581,6 +1701,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1581
1701
|
params.delete('kind')
|
|
1582
1702
|
if (group) params.set('apiGroup', group); else params.delete('apiGroup')
|
|
1583
1703
|
params.delete('resource')
|
|
1704
|
+
params.delete('full')
|
|
1705
|
+
params.delete('tab')
|
|
1584
1706
|
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1585
1707
|
}}
|
|
1586
1708
|
onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
|
|
@@ -1594,11 +1716,6 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1594
1716
|
|
|
1595
1717
|
{/* Right: Controls */}
|
|
1596
1718
|
<div className="flex items-center gap-3 shrink-0">
|
|
1597
|
-
<NamespaceSwitcher
|
|
1598
|
-
ref={namespaceSwitcherRef}
|
|
1599
|
-
/>
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
1719
|
{/* Command palette trigger — embedded only; standalone has the
|
|
1603
1720
|
top-center omnibar (which is the ⌘K surface). */}
|
|
1604
1721
|
{!showNavRail && (
|
|
@@ -1639,7 +1756,7 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1639
1756
|
to the host's cookie/backend) and the user would see the theme
|
|
1640
1757
|
bounce on every navigation between host routes and /c/:id. */}
|
|
1641
1758
|
{!navCustomization.embedded && (
|
|
1642
|
-
<div className="hidden md:
|
|
1759
|
+
<div className="hidden md:flex items-center">
|
|
1643
1760
|
<ThemeToggle />
|
|
1644
1761
|
</div>
|
|
1645
1762
|
)}
|
|
@@ -1791,7 +1908,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1791
1908
|
)}
|
|
1792
1909
|
|
|
1793
1910
|
{/* Main content - only show when connected and authenticated */}
|
|
1794
|
-
{
|
|
1911
|
+
{/* inert while a fullscreen detail overlay covers the views — keeps the
|
|
1912
|
+
retained background list out of the focus order + a11y tree (the visual
|
|
1913
|
+
cover already blocks pointer events). */}
|
|
1914
|
+
{contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
|
|
1795
1915
|
<ErrorBoundary>
|
|
1796
1916
|
{/* Home dashboard */}
|
|
1797
1917
|
{mainView === 'home' && (
|
|
@@ -1806,6 +1926,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1806
1926
|
newParams.delete('kind') // kind is now in the path
|
|
1807
1927
|
newParams.delete('mode')
|
|
1808
1928
|
newParams.delete('resource')
|
|
1929
|
+
newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
|
|
1930
|
+
newParams.delete('tab')
|
|
1809
1931
|
newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
|
|
1810
1932
|
if (apiGroup) {
|
|
1811
1933
|
newParams.set('apiGroup', apiGroup)
|
|
@@ -1933,6 +2055,13 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1933
2055
|
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
1934
2056
|
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
1935
2057
|
onNavigateToTraffic={() => setMainView('traffic')}
|
|
2058
|
+
leadingSlot={
|
|
2059
|
+
<FreshnessControl
|
|
2060
|
+
mode="auto"
|
|
2061
|
+
paused={topologyPaused}
|
|
2062
|
+
connectionState={connection.state}
|
|
2063
|
+
/>
|
|
2064
|
+
}
|
|
1936
2065
|
/>
|
|
1937
2066
|
</div>
|
|
1938
2067
|
</>
|
|
@@ -1979,6 +2108,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
1979
2108
|
/>
|
|
1980
2109
|
)}
|
|
1981
2110
|
|
|
2111
|
+
{mainView === 'helmCompare' && (
|
|
2112
|
+
<HelmCompareRoute />
|
|
2113
|
+
)}
|
|
2114
|
+
|
|
1982
2115
|
{/* GitOps view (inline only when the host hasn't taken it over — see
|
|
1983
2116
|
the takeover splash below). */}
|
|
1984
2117
|
{mainView === 'gitops' && !isViewTakenOver('gitops') && (
|
|
@@ -2074,8 +2207,10 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
2074
2207
|
/>
|
|
2075
2208
|
)}
|
|
2076
2209
|
|
|
2077
|
-
{/* Workload full view
|
|
2078
|
-
|
|
2210
|
+
{/* Workload full view — the standalone fullscreen route for non-list
|
|
2211
|
+
surfaces and deep links. Expand-from-drawer is the ?full=1 overlay on
|
|
2212
|
+
/resources instead, so it never routes here. */}
|
|
2213
|
+
{mainView === 'workload' && (
|
|
2079
2214
|
<WorkloadViewRoute
|
|
2080
2215
|
onNavigateToResource={(resource) => {
|
|
2081
2216
|
navigate(buildWorkloadPath(resource))
|
|
@@ -2100,18 +2235,37 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
2100
2235
|
// to the top of the content area instead of leaving a 49px gap.
|
|
2101
2236
|
headerHeight={chromeless ? 0 : undefined}
|
|
2102
2237
|
isOpen={resourceDrawer.isOpen}
|
|
2103
|
-
expanded={
|
|
2104
|
-
onClose={
|
|
2238
|
+
expanded={drawerExpandedProp}
|
|
2239
|
+
onClose={closeDrawer}
|
|
2105
2240
|
onNavigate={(res) => navigateToResource(res)}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2241
|
+
canCollapseToDrawer={!isMobile}
|
|
2242
|
+
onExpand={(_res, opts) => {
|
|
2243
|
+
// Grow the peek into a fullscreen overlay (?full=1, pushed so Back
|
|
2244
|
+
// collapses) over whatever view is underneath — list, topology graph,
|
|
2245
|
+
// GitOps, Applications — which stays mounted. Carry the YAML tab when
|
|
2246
|
+
// expanding from the drawer's YAML view so the editor (and its
|
|
2247
|
+
// session-persisted draft) is right there, not behind the Overview tab.
|
|
2248
|
+
const p = new URLSearchParams(searchParams)
|
|
2249
|
+
p.set('full', '1')
|
|
2250
|
+
if (opts?.yaml) p.set('tab', 'yaml')
|
|
2251
|
+
setSearchParams(p)
|
|
2110
2252
|
}}
|
|
2111
|
-
|
|
2253
|
+
// On mobile there's no drawer to collapse back to, so the collapse/back
|
|
2254
|
+
// control closes the resource (returns to the list) instead.
|
|
2255
|
+
onCollapse={isMobile ? closeDrawer : handleCollapseFromExpanded}
|
|
2112
2256
|
onNavigateToResource={(resource) => {
|
|
2113
|
-
|
|
2114
|
-
|
|
2257
|
+
// Drill into a related resource while expanded: stay in the over-list
|
|
2258
|
+
// overlay for the new resource (pushed, so Back walks resource→resource
|
|
2259
|
+
// still expanded). The backdrop list follows to the new kind.
|
|
2260
|
+
const pluralKind = kindToPlural(resource.kind)
|
|
2261
|
+
setSelectedResource({ ...resource, kind: pluralKind })
|
|
2262
|
+
const p = new URLSearchParams()
|
|
2263
|
+
const ns = searchParams.get('namespaces')
|
|
2264
|
+
if (ns) p.set('namespaces', ns)
|
|
2265
|
+
p.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
2266
|
+
if (resource.group) p.set('apiGroup', resource.group)
|
|
2267
|
+
p.set('full', '1')
|
|
2268
|
+
navigate({ pathname: `/resources/${pluralKind}`, search: p.toString() })
|
|
2115
2269
|
}}
|
|
2116
2270
|
/>
|
|
2117
2271
|
)}
|
|
@@ -2174,6 +2328,8 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manage
|
|
|
2174
2328
|
if (group) params.set('apiGroup', group)
|
|
2175
2329
|
else params.delete('apiGroup')
|
|
2176
2330
|
params.delete('resource')
|
|
2331
|
+
params.delete('full')
|
|
2332
|
+
params.delete('tab')
|
|
2177
2333
|
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
2178
2334
|
// Focus the table search after navigation — the user came from ⌘K
|
|
2179
2335
|
// (keyboard flow) and expects to type a resource name immediately.
|