@skyhook-io/radar-app 1.8.2 → 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 +412 -146
- package/src/RadarApp.tsx +21 -1
- package/src/api/client.ts +144 -12
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -278
- 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/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +23 -17
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +18 -7
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +45 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- 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/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
package/src/App.tsx
CHANGED
|
@@ -1,14 +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'
|
|
4
|
+
import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
|
|
5
5
|
import { useQueryClient } from '@tanstack/react-query'
|
|
6
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, 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'
|
|
10
10
|
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
11
|
-
import { useAPIResources } from './api/apiResources'
|
|
11
|
+
import { useAPIResources, CORE_RESOURCES } from './api/apiResources'
|
|
12
12
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
13
13
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
14
14
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
@@ -16,6 +16,7 @@ import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawe
|
|
|
16
16
|
import { WorkloadViewRoute } from './components/workload/WorkloadView'
|
|
17
17
|
import { CompareViewRoute } from './components/compare/CompareViewRoute'
|
|
18
18
|
import { HelmView } from './components/helm/HelmView'
|
|
19
|
+
import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
|
|
19
20
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
20
21
|
import { CostView } from './components/cost/CostView'
|
|
21
22
|
import { AuditView } from './components/audit/AuditView'
|
|
@@ -44,19 +45,21 @@ import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
|
44
45
|
import { CommandPalette } from './components/ui/CommandPalette'
|
|
45
46
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
46
47
|
import { useEventSource } from './hooks/useEventSource'
|
|
47
|
-
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
|
|
48
|
+
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
49
|
+
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
48
50
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
49
|
-
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
51
|
+
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
|
|
50
52
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
53
|
+
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
51
54
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
52
|
-
import {
|
|
55
|
+
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle } from 'lucide-react'
|
|
53
56
|
import { useTheme } from './context/ThemeContext'
|
|
54
57
|
import { Tooltip } from './components/ui/Tooltip'
|
|
55
58
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
56
59
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
57
60
|
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
58
61
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
59
|
-
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
62
|
+
import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, buildWorkloadPath, searchHitToSelectedResource } from './utils/navigation'
|
|
60
63
|
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
61
64
|
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
62
65
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
@@ -102,10 +105,11 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
102
105
|
|
|
103
106
|
// Convert API resource name back to topology node ID prefix
|
|
104
107
|
// Extended MainView type that includes traffic and cost
|
|
105
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'issues' | 'applications'
|
|
108
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
106
109
|
|
|
107
110
|
// Extract view from URL path
|
|
108
111
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
112
|
+
if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
|
|
109
113
|
const path = pathname.replace(/^\//, '').split('/')[0]
|
|
110
114
|
if (path === '' || path === 'home') return 'home'
|
|
111
115
|
if (path === 'topology') return 'topology'
|
|
@@ -123,6 +127,96 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
123
127
|
return 'home'
|
|
124
128
|
}
|
|
125
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
|
+
|
|
172
|
+
// Browser tab label for every Radar view, derived from the route URL so it's
|
|
173
|
+
// correct regardless of which component renders it. A detail drawer that opens
|
|
174
|
+
// over a list (?resource=…) is deliberately NOT titled — it's the same page, so
|
|
175
|
+
// it keeps the list's title.
|
|
176
|
+
function radarPageTitle(pathname: string, search = '', apiResources?: { name: string; kind: string; group?: string }[]): string | null {
|
|
177
|
+
const decode = (s: string) => {
|
|
178
|
+
try {
|
|
179
|
+
return decodeURIComponent(s)
|
|
180
|
+
} catch {
|
|
181
|
+
return s
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const capitalize = (text: string) =>
|
|
185
|
+
text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
|
186
|
+
const pluralKindTitle = (kind: string, resourceName: string) =>
|
|
187
|
+
kind.toLowerCase() === resourceName.toLowerCase() ? kind : englishPlural(kind)
|
|
188
|
+
const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
|
|
189
|
+
const view = getViewFromPath(pathname)
|
|
190
|
+
|
|
191
|
+
// Full-page resource detail: /workload/<kind>/<ns>/<name> (name may contain '/').
|
|
192
|
+
if (view === 'workload') return pathSegments.slice(3).map(decode).join('/') || null
|
|
193
|
+
// Resources is browsed per-kind: /resources/<kind> → "<Kind>" (e.g. ConfigMap);
|
|
194
|
+
// bare /resources (before it redirects to a default kind) → "Resources".
|
|
195
|
+
if (view === 'resources') {
|
|
196
|
+
const resourceName = decode(pathSegments[1] ?? '')
|
|
197
|
+
if (!resourceName) return 'Resources'
|
|
198
|
+
const match = apiResources?.find((r) => r.name === resourceName)
|
|
199
|
+
return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
|
|
200
|
+
}
|
|
201
|
+
// GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
|
|
202
|
+
// anything else (the list) → "GitOps".
|
|
203
|
+
if (view === 'gitops')
|
|
204
|
+
return pathSegments[1] === 'detail' ? decode(pathSegments[4] ?? '') || 'GitOps' : 'GitOps'
|
|
205
|
+
if (view === 'applications') {
|
|
206
|
+
const appKey = new URLSearchParams(search).get('app')
|
|
207
|
+
if (!appKey) return 'Applications'
|
|
208
|
+
const decoded = decode(appKey)
|
|
209
|
+
const slash = decoded.lastIndexOf('/')
|
|
210
|
+
return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
214
|
+
if (view === 'home') return 'Overview'
|
|
215
|
+
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
216
|
+
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
217
|
+
return capitalize(view)
|
|
218
|
+
}
|
|
219
|
+
|
|
126
220
|
function AuthBarrier({ authMode }: { authMode: string }) {
|
|
127
221
|
useEffect(() => {
|
|
128
222
|
if (authMode === 'oidc') {
|
|
@@ -133,12 +227,12 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
133
227
|
if (authMode === 'oidc') {
|
|
134
228
|
return (
|
|
135
229
|
<div className="flex-1 relative bg-theme-base">
|
|
136
|
-
<div className="
|
|
230
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
137
231
|
<img
|
|
138
232
|
src={radarLoadingIcon}
|
|
139
233
|
alt=""
|
|
140
234
|
aria-hidden
|
|
141
|
-
// Integer offset (
|
|
235
|
+
// Integer offset (50% − 22) — matches the Connecting/Opening splashes;
|
|
142
236
|
// avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
|
|
143
237
|
className="absolute w-11 h-11"
|
|
144
238
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -182,10 +276,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
182
276
|
// detail to list would otherwise leave the peek orphaned. Only `app` is included
|
|
183
277
|
// (not the whole query) so filter/tab/namespace churn doesn't close the peek.
|
|
184
278
|
function peekOwnerKey(pathname: string, search: string): string {
|
|
185
|
-
return `${pathname}
|
|
279
|
+
return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
|
|
186
280
|
}
|
|
187
281
|
|
|
188
|
-
function AppInner() {
|
|
282
|
+
function AppInner({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
|
|
189
283
|
const navigate = useNavigate()
|
|
190
284
|
const location = useLocation()
|
|
191
285
|
const navigationType = useNavigationType()
|
|
@@ -279,6 +373,25 @@ function AppInner() {
|
|
|
279
373
|
// Get mainView from URL path
|
|
280
374
|
const mainView = getViewFromPath(location.pathname)
|
|
281
375
|
|
|
376
|
+
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
377
|
+
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
378
|
+
// view — kindToPlural would otherwise English-guess the route before a
|
|
379
|
+
// resources view has run initNavigationMap().
|
|
380
|
+
const { data: navApiResources } = useAPIResources()
|
|
381
|
+
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
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
|
+
|
|
390
|
+
// One URL-derived tab title for every view (see radarPageTitle). Driving it
|
|
391
|
+
// from the URL — not the mounted component. Off unless the host opts in
|
|
392
|
+
// (standalone passes manageDocumentTitle), so embedders keep title ownership.
|
|
393
|
+
useDocumentTitle(manageDocumentTitle ? radarPageTitle(location.pathname, location.search, navApiResources) : null, documentTitleSuffix)
|
|
394
|
+
|
|
282
395
|
// Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
|
|
283
396
|
const normalizedResourcesKindSlug = useMemo(() => {
|
|
284
397
|
const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
|
|
@@ -387,15 +500,6 @@ function AppInner() {
|
|
|
387
500
|
const [showSettings, setShowSettings] = useState(false)
|
|
388
501
|
const [showMyPermissions, setShowMyPermissions] = useState(false)
|
|
389
502
|
|
|
390
|
-
// Listen for desktop "open-settings" event from native menu
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
|
|
393
|
-
| { EventsOn?: (event: string, callback: () => void) => () => void }
|
|
394
|
-
| undefined
|
|
395
|
-
if (!wailsRuntime?.EventsOn) return
|
|
396
|
-
return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
|
|
397
|
-
}, [])
|
|
398
|
-
|
|
399
503
|
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
400
504
|
useEffect(() => {
|
|
401
505
|
const handler = () => setShowSettings(true)
|
|
@@ -406,11 +510,16 @@ function AppInner() {
|
|
|
406
510
|
// Diagnostics overlay state
|
|
407
511
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
|
408
512
|
|
|
409
|
-
//
|
|
410
|
-
|
|
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'
|
|
411
520
|
|
|
412
|
-
//
|
|
413
|
-
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)')
|
|
414
523
|
|
|
415
524
|
// On a history Pop (back/forward) the URL is authoritative. The URL-write
|
|
416
525
|
// effect, running with not-yet-synced state, would otherwise write the stale
|
|
@@ -443,9 +552,9 @@ function AppInner() {
|
|
|
443
552
|
// URL backing, so the only signal that the page beneath it has navigated is
|
|
444
553
|
// that its owner-key (pathname + ?app) no longer matches where it was opened.
|
|
445
554
|
// Hiding it here, at render time, closes the orphan on Back without adding
|
|
446
|
-
// another clearing effect
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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.
|
|
449
558
|
const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
|
|
450
559
|
peekOwnerKeyRef.current !== null &&
|
|
451
560
|
peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
|
|
@@ -472,7 +581,6 @@ function AppInner() {
|
|
|
472
581
|
|
|
473
582
|
if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
|
|
474
583
|
setSelectedResource(null)
|
|
475
|
-
setDrawerExpanded(false)
|
|
476
584
|
}
|
|
477
585
|
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
478
586
|
|
|
@@ -488,6 +596,19 @@ function AppInner() {
|
|
|
488
596
|
if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
|
|
489
597
|
const drawerResource = routeSelectedResource || lastResourceRef.current
|
|
490
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
|
+
|
|
491
612
|
const lastHelmReleaseRef = useRef(selectedHelmRelease)
|
|
492
613
|
if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
|
|
493
614
|
const drawerHelmRelease = selectedHelmRelease || lastHelmReleaseRef.current
|
|
@@ -526,6 +647,10 @@ function AppInner() {
|
|
|
526
647
|
newParams.delete('kind')
|
|
527
648
|
newParams.delete('mode')
|
|
528
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')
|
|
529
654
|
newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
530
655
|
if (resource.group) {
|
|
531
656
|
newParams.set('apiGroup', resource.group)
|
|
@@ -535,11 +660,32 @@ function AppInner() {
|
|
|
535
660
|
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
536
661
|
}, [searchParams, navigate])
|
|
537
662
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
663
|
+
const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
|
|
664
|
+
const newParams = new URLSearchParams()
|
|
665
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
666
|
+
if (globalNamespaces) {
|
|
667
|
+
newParams.set('namespaces', globalNamespaces)
|
|
668
|
+
}
|
|
669
|
+
newParams.set('release', `${namespace}/${name}`)
|
|
670
|
+
if (storageNamespace) {
|
|
671
|
+
newParams.set('releaseStorage', storageNamespace)
|
|
672
|
+
}
|
|
673
|
+
setSelectedHelmRelease({ namespace, name, storageNamespace })
|
|
674
|
+
if (mainView === 'helm') {
|
|
675
|
+
setSearchParams(newParams, { replace: true })
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
navigate({ pathname: '/helm', search: newParams.toString() })
|
|
679
|
+
}, [mainView, searchParams, navigate, setSearchParams])
|
|
680
|
+
|
|
681
|
+
// From the Issues queue: special controller/manager subjects route to their
|
|
682
|
+
// rich detail pages, not the generic resource drawer that's a dead-end for
|
|
683
|
+
// them. Member resources (Pods, Services, …) fall through to resources.
|
|
542
684
|
const navigateFromIssue = useCallback((resource: SelectedResource) => {
|
|
685
|
+
if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
|
|
686
|
+
navigateToHelmRelease(resource.namespace, resource.name)
|
|
687
|
+
return
|
|
688
|
+
}
|
|
543
689
|
const gitOpsPath = gitOpsRouteForResource({
|
|
544
690
|
apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
|
|
545
691
|
kind: resource.kind,
|
|
@@ -550,14 +696,35 @@ function AppInner() {
|
|
|
550
696
|
return
|
|
551
697
|
}
|
|
552
698
|
navigateToResourceList(resource)
|
|
553
|
-
}, [navigate, navigateToResourceList])
|
|
554
|
-
|
|
555
|
-
// Collapse
|
|
699
|
+
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
700
|
+
|
|
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).
|
|
556
707
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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])
|
|
561
728
|
|
|
562
729
|
// Theme toggle for keyboard shortcut
|
|
563
730
|
const { toggleTheme } = useTheme()
|
|
@@ -569,12 +736,6 @@ function AppInner() {
|
|
|
569
736
|
const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
|
|
570
737
|
const omnibarRef = useRef<OmnibarHandle>(null)
|
|
571
738
|
|
|
572
|
-
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
573
|
-
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
574
|
-
// view — kindToPlural would otherwise English-guess the route before a
|
|
575
|
-
// resources view has run n().
|
|
576
|
-
const { data: navApiResources } = useAPIResources()
|
|
577
|
-
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
578
739
|
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
579
740
|
|
|
580
741
|
// View switching keyboard shortcuts
|
|
@@ -590,7 +751,7 @@ function AppInner() {
|
|
|
590
751
|
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
591
752
|
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
592
753
|
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
593
|
-
workload: '', compare: '',
|
|
754
|
+
workload: '', compare: '', helmCompare: '',
|
|
594
755
|
}
|
|
595
756
|
const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
|
|
596
757
|
(v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
|
|
@@ -724,6 +885,16 @@ function AppInner() {
|
|
|
724
885
|
// Connection state (for graceful startup)
|
|
725
886
|
const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
|
|
726
887
|
|
|
888
|
+
// The app's content surface is ready to show: auth resolved, not mid context-
|
|
889
|
+
// switch, and the cluster connection is live. The main content area gates on
|
|
890
|
+
// exactly this, and so do the overlay drawers — otherwise a deep-link/refresh
|
|
891
|
+
// with `?resource=`/`?release=` renders the drawer on top of the connecting/
|
|
892
|
+
// switching splash, pushing the centered loading logo off-center and showing an
|
|
893
|
+
// empty drawer over a not-yet-loaded view. Gating both on the SAME readiness so
|
|
894
|
+
// a drawer only ever sits over a real content surface.
|
|
895
|
+
const contentReady = !isSwitching && !authMePending &&
|
|
896
|
+
!(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
|
|
897
|
+
|
|
727
898
|
// Query client for cache invalidation
|
|
728
899
|
const queryClient = useQueryClient()
|
|
729
900
|
|
|
@@ -822,7 +993,7 @@ function AppInner() {
|
|
|
822
993
|
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
823
994
|
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
824
995
|
const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
|
|
825
|
-
const { topology
|
|
996
|
+
const { topology } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
|
|
826
997
|
onContextSwitchComplete: endSwitch,
|
|
827
998
|
onContextSwitchProgress: updateProgress,
|
|
828
999
|
onContextChanged: () => {
|
|
@@ -839,8 +1010,8 @@ function AppInner() {
|
|
|
839
1010
|
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
840
1011
|
|
|
841
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).
|
|
842
1014
|
setSelectedResource(null)
|
|
843
|
-
setDrawerExpanded(false)
|
|
844
1015
|
setSelectedHelmRelease(null)
|
|
845
1016
|
|
|
846
1017
|
// Reset URL to current view with no resource-specific params.
|
|
@@ -859,7 +1030,7 @@ function AppInner() {
|
|
|
859
1030
|
},
|
|
860
1031
|
onK8sEvent: handleK8sEvent,
|
|
861
1032
|
}, forceNamespaceFilter, showPolicyEffect)
|
|
862
|
-
const
|
|
1033
|
+
const clusterConnected = connection.state === 'connected'
|
|
863
1034
|
|
|
864
1035
|
// On large clusters (where the server requires namespace filtering), keep
|
|
865
1036
|
// SSE's server-side filter in lockstep with the user's namespace pick.
|
|
@@ -986,12 +1157,13 @@ function AppInner() {
|
|
|
986
1157
|
// mutation immediately, which re-introduces the same race. The state→URL
|
|
987
1158
|
// effect propagates state=[] → URL on its own after onSuccess flips state.
|
|
988
1159
|
const clearAllNamespaces = useCallback(() => {
|
|
1160
|
+
if (namespaceScope?.cacheScoped) return
|
|
989
1161
|
if (namespaces.length === 0) return
|
|
990
1162
|
setActiveNamespace.mutate(
|
|
991
1163
|
{ namespaces: [] },
|
|
992
1164
|
{ onSuccess: () => setNamespaces([]) },
|
|
993
1165
|
)
|
|
994
|
-
}, [namespaces.length, setActiveNamespace])
|
|
1166
|
+
}, [namespaceScope?.cacheScoped, namespaces.length, setActiveNamespace])
|
|
995
1167
|
const initialBookmarkReconciledRef = useRef(false)
|
|
996
1168
|
const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
|
|
997
1169
|
const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
|
|
@@ -1015,6 +1187,14 @@ function AppInner() {
|
|
|
1015
1187
|
if (!initialBookmarkReconciledRef.current) {
|
|
1016
1188
|
initialBookmarkReconciledRef.current = true
|
|
1017
1189
|
if (!sameAsState && sortedState.length > 0) {
|
|
1190
|
+
if (namespaceScope.cacheScoped && (!namespaceScope.namespaceRescope || sortedState.length !== 1)) {
|
|
1191
|
+
debugNamespaceLog('app:scope-mirror-cache-scope-preserve', {
|
|
1192
|
+
stateNamespaces: sortedState,
|
|
1193
|
+
scopeActives: sortedScope,
|
|
1194
|
+
})
|
|
1195
|
+
setNamespaces(scopeActives)
|
|
1196
|
+
return
|
|
1197
|
+
}
|
|
1018
1198
|
debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
|
|
1019
1199
|
stateNamespaces: sortedState,
|
|
1020
1200
|
scopeActives: sortedScope,
|
|
@@ -1117,6 +1297,12 @@ function AppInner() {
|
|
|
1117
1297
|
})
|
|
1118
1298
|
|
|
1119
1299
|
if (urlNamespaces.join(',') !== namespacesKey) {
|
|
1300
|
+
if (namespaceScope?.cacheScoped && (!namespaceScope.namespaceRescope || urlNamespaces.length !== 1)) {
|
|
1301
|
+
const scopedNamespaces = namespaceScope.actives ?? []
|
|
1302
|
+
debugNamespaceLog('app:url-sync-cache-scope-preserve', { scopedNamespaces })
|
|
1303
|
+
setNamespaces(scopedNamespaces)
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1120
1306
|
debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
|
|
1121
1307
|
setNamespaces(urlNamespaces)
|
|
1122
1308
|
if (namespaceScope) {
|
|
@@ -1190,15 +1376,7 @@ function AppInner() {
|
|
|
1190
1376
|
// But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
|
|
1191
1377
|
const prevMainView = useRef(mainView)
|
|
1192
1378
|
useEffect(() => {
|
|
1193
|
-
// Skip clearing during controlled expand/collapse transitions
|
|
1194
|
-
if (suppressViewClearRef.current) {
|
|
1195
|
-
suppressViewClearRef.current = false
|
|
1196
|
-
prevMainView.current = mainView
|
|
1197
|
-
return
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1379
|
const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
|
|
1201
|
-
const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
|
|
1202
1380
|
prevMainView.current = mainView
|
|
1203
1381
|
|
|
1204
1382
|
// The URL is the source of truth for what's selected. A deep link
|
|
@@ -1207,14 +1385,15 @@ function AppInner() {
|
|
|
1207
1385
|
// asserts. (On a real view switch the URL no longer carries the param, so
|
|
1208
1386
|
// the clear proceeds.) Without this, deep-linking straight to a Helm
|
|
1209
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.)
|
|
1210
1390
|
const params = new URLSearchParams(window.location.search)
|
|
1211
1391
|
if (!navigatingToResources && !params.has('resource')) {
|
|
1212
1392
|
setSelectedResource(null)
|
|
1213
1393
|
}
|
|
1214
|
-
if (!
|
|
1394
|
+
if (!params.has('release')) {
|
|
1215
1395
|
setSelectedHelmRelease(null)
|
|
1216
1396
|
}
|
|
1217
|
-
setDrawerExpanded(false)
|
|
1218
1397
|
}, [mainView])
|
|
1219
1398
|
|
|
1220
1399
|
// Clear resource selection when namespaces change — but keep a selection the
|
|
@@ -1224,7 +1403,6 @@ function AppInner() {
|
|
|
1224
1403
|
const params = new URLSearchParams(window.location.search)
|
|
1225
1404
|
if (!params.has('resource')) setSelectedResource(null)
|
|
1226
1405
|
if (!params.has('release')) setSelectedHelmRelease(null)
|
|
1227
|
-
setDrawerExpanded(false)
|
|
1228
1406
|
}, [namespacesKey])
|
|
1229
1407
|
|
|
1230
1408
|
// Filter topology based on visible kinds (uses displayedTopology which respects pause)
|
|
@@ -1263,6 +1441,29 @@ function AppInner() {
|
|
|
1263
1441
|
}
|
|
1264
1442
|
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
1265
1443
|
|
|
1444
|
+
// Cluster Audit findings, joined onto topology nodes by the audit key the
|
|
1445
|
+
// backend stamps on each node (data.auditKey). The graph surfaces DANGER only
|
|
1446
|
+
// (warnings would turn a dense graph into a heatmap); the node component reads
|
|
1447
|
+
// data.auditDanger. Re-runs only when findings change, and copies nodes only
|
|
1448
|
+
// when there are findings to attach — no overhead on clusters with none.
|
|
1449
|
+
const audit = useAudit(namespaces)
|
|
1450
|
+
const auditSeverityMap = useMemo(
|
|
1451
|
+
() => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
|
|
1452
|
+
[audit.data?.findings, audit.data?.checks],
|
|
1453
|
+
)
|
|
1454
|
+
const topologyWithAudit = useMemo((): Topology | null => {
|
|
1455
|
+
if (!filteredTopology) return null
|
|
1456
|
+
if (auditSeverityMap.size === 0) return filteredTopology
|
|
1457
|
+
return {
|
|
1458
|
+
...filteredTopology,
|
|
1459
|
+
nodes: filteredTopology.nodes.map(node => {
|
|
1460
|
+
const counts = auditSeverityMap.get(node.data.auditKey as string)
|
|
1461
|
+
if (!counts) return node
|
|
1462
|
+
return { ...node, data: { ...node.data, auditDanger: counts.danger, auditWarning: counts.warning, auditMessages: counts.messages } }
|
|
1463
|
+
}),
|
|
1464
|
+
}
|
|
1465
|
+
}, [filteredTopology, auditSeverityMap])
|
|
1466
|
+
|
|
1266
1467
|
// The graph node id of the currently open resource, used to highlight it on
|
|
1267
1468
|
// the canvas. Looked up from the topology (not reconstructed) because node
|
|
1268
1469
|
// ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
|
|
@@ -1306,6 +1507,8 @@ function AppInner() {
|
|
|
1306
1507
|
setVisibleKinds(new Set())
|
|
1307
1508
|
}, [])
|
|
1308
1509
|
|
|
1510
|
+
const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
|
|
1511
|
+
|
|
1309
1512
|
return (
|
|
1310
1513
|
<PortForwardProvider>
|
|
1311
1514
|
{/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
|
|
@@ -1318,7 +1521,7 @@ function AppInner() {
|
|
|
1318
1521
|
>
|
|
1319
1522
|
{showNavRail && (
|
|
1320
1523
|
<PrimaryNavRail
|
|
1321
|
-
activeView={
|
|
1524
|
+
activeView={navActiveView}
|
|
1322
1525
|
onNavigate={setMainView}
|
|
1323
1526
|
pinned={navRailEffectivePinned}
|
|
1324
1527
|
onTogglePinned={toggleNavRailPinned}
|
|
@@ -1335,20 +1538,58 @@ function AppInner() {
|
|
|
1335
1538
|
{/* Header — suppressed in chromeless embed; the host owns the chrome. */}
|
|
1336
1539
|
{!chromeless && (
|
|
1337
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">
|
|
1338
|
-
{/* Left: Logo + Cluster info
|
|
1339
|
-
|
|
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]' : ''}`}>
|
|
1340
1548
|
{/* Standalone rail owns the brand; only the embedded/pill layout
|
|
1341
1549
|
shows it in the header (host may override via brandSlot). */}
|
|
1342
1550
|
{navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
|
|
1343
1551
|
|
|
1344
|
-
<div className=
|
|
1345
|
-
{navCustomization.contextSlot
|
|
1346
|
-
|
|
1347
|
-
|
|
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">
|
|
1348
1589
|
<Tooltip
|
|
1349
1590
|
content={
|
|
1350
|
-
!
|
|
1351
|
-
? '
|
|
1591
|
+
!clusterConnected
|
|
1592
|
+
? 'Cluster disconnected — click to reconnect'
|
|
1352
1593
|
: crdDiscoveryStatus === 'discovering'
|
|
1353
1594
|
? 'Connected — discovering Custom Resources...'
|
|
1354
1595
|
: 'Connected'
|
|
@@ -1356,38 +1597,29 @@ function AppInner() {
|
|
|
1356
1597
|
delay={100}
|
|
1357
1598
|
position="bottom"
|
|
1358
1599
|
>
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
? 'bg-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
+
)}
|
|
1368
1617
|
</Tooltip>
|
|
1369
|
-
{
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
"Connected" text here would expand the left section and
|
|
1373
|
-
collide with the absolute-centered nav block at xl, which
|
|
1374
|
-
is the same breakpoint where nav labels appear. */}
|
|
1375
|
-
{(!connected || crdDiscoveryStatus === 'discovering') && (
|
|
1376
|
-
<span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
|
|
1377
|
-
{!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…'}
|
|
1378
1621
|
</span>
|
|
1379
1622
|
)}
|
|
1380
|
-
{!connected && (
|
|
1381
|
-
<Tooltip content="Reconnect">
|
|
1382
|
-
<button
|
|
1383
|
-
onClick={reconnect}
|
|
1384
|
-
disabled={isReconnecting}
|
|
1385
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
|
|
1386
|
-
>
|
|
1387
|
-
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
|
1388
|
-
</button>
|
|
1389
|
-
</Tooltip>
|
|
1390
|
-
)}
|
|
1391
1623
|
</div>
|
|
1392
1624
|
{/* Port forwards indicator — shown only when sessions exist */}
|
|
1393
1625
|
<PortForwardIndicator />
|
|
@@ -1429,7 +1661,7 @@ function AppInner() {
|
|
|
1429
1661
|
<button
|
|
1430
1662
|
onClick={() => setMainView(view)}
|
|
1431
1663
|
className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
|
|
1432
|
-
mainView === view
|
|
1664
|
+
mainView === view || (mainView === 'helmCompare' && view === 'helm')
|
|
1433
1665
|
? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
|
|
1434
1666
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
|
|
1435
1667
|
}`}
|
|
@@ -1453,7 +1685,12 @@ function AppInner() {
|
|
|
1453
1685
|
)}
|
|
1454
1686
|
|
|
1455
1687
|
{/* Center: omnibar — standalone search + command surface (the ⌘K entry).
|
|
1456
|
-
|
|
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. */}
|
|
1457
1694
|
{showNavRail && (
|
|
1458
1695
|
<div className="hidden sm:flex flex-1 justify-center min-w-0 px-3">
|
|
1459
1696
|
<RadarOmnibar
|
|
@@ -1464,6 +1701,8 @@ function AppInner() {
|
|
|
1464
1701
|
params.delete('kind')
|
|
1465
1702
|
if (group) params.set('apiGroup', group); else params.delete('apiGroup')
|
|
1466
1703
|
params.delete('resource')
|
|
1704
|
+
params.delete('full')
|
|
1705
|
+
params.delete('tab')
|
|
1467
1706
|
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1468
1707
|
}}
|
|
1469
1708
|
onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
|
|
@@ -1477,11 +1716,6 @@ function AppInner() {
|
|
|
1477
1716
|
|
|
1478
1717
|
{/* Right: Controls */}
|
|
1479
1718
|
<div className="flex items-center gap-3 shrink-0">
|
|
1480
|
-
<NamespaceSwitcher
|
|
1481
|
-
ref={namespaceSwitcherRef}
|
|
1482
|
-
/>
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
1719
|
{/* Command palette trigger — embedded only; standalone has the
|
|
1486
1720
|
top-center omnibar (which is the ⌘K surface). */}
|
|
1487
1721
|
{!showNavRail && (
|
|
@@ -1522,7 +1756,7 @@ function AppInner() {
|
|
|
1522
1756
|
to the host's cookie/backend) and the user would see the theme
|
|
1523
1757
|
bounce on every navigation between host routes and /c/:id. */}
|
|
1524
1758
|
{!navCustomization.embedded && (
|
|
1525
|
-
<div className="hidden md:
|
|
1759
|
+
<div className="hidden md:flex items-center">
|
|
1526
1760
|
<ThemeToggle />
|
|
1527
1761
|
</div>
|
|
1528
1762
|
)}
|
|
@@ -1579,20 +1813,20 @@ function AppInner() {
|
|
|
1579
1813
|
)}
|
|
1580
1814
|
|
|
1581
1815
|
{/* Connecting view — shown during initial connection or retry.
|
|
1582
|
-
Icon is
|
|
1816
|
+
Icon is pane-anchored so its screen position matches the
|
|
1583
1817
|
host hub splash across cross-document transitions. */}
|
|
1584
1818
|
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
1585
1819
|
<div className="flex-1 relative bg-theme-base">
|
|
1586
|
-
{/* Icon absolutely anchored to
|
|
1820
|
+
{/* Icon absolutely anchored to the pane center. The label block
|
|
1587
1821
|
sits at a fixed offset below — independent of label height
|
|
1588
1822
|
so multi-line messages (context + progress) don't shift the
|
|
1589
1823
|
icon's screen position. */}
|
|
1590
|
-
<div className="
|
|
1824
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1591
1825
|
<img
|
|
1592
1826
|
src={radarLoadingIcon}
|
|
1593
1827
|
alt=""
|
|
1594
1828
|
aria-hidden
|
|
1595
|
-
// Integer offset (
|
|
1829
|
+
// Integer offset (50% − 22) — avoids sub-pixel jitter from
|
|
1596
1830
|
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1597
1831
|
className="absolute w-11 h-11"
|
|
1598
1832
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -1620,15 +1854,15 @@ function AppInner() {
|
|
|
1620
1854
|
</div>
|
|
1621
1855
|
)}
|
|
1622
1856
|
|
|
1623
|
-
{/* Context switching overlay — icon
|
|
1857
|
+
{/* Context switching overlay — icon pane-anchored, label below. */}
|
|
1624
1858
|
{isSwitching && (
|
|
1625
1859
|
<div className="flex-1 relative bg-theme-base">
|
|
1626
|
-
<div className="
|
|
1860
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1627
1861
|
<img
|
|
1628
1862
|
src={radarLoadingIcon}
|
|
1629
1863
|
alt=""
|
|
1630
1864
|
aria-hidden
|
|
1631
|
-
// Integer offset (
|
|
1865
|
+
// Integer offset (50% − 22) — avoids sub-pixel jitter from
|
|
1632
1866
|
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1633
1867
|
className="absolute w-11 h-11"
|
|
1634
1868
|
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
@@ -1674,7 +1908,10 @@ function AppInner() {
|
|
|
1674
1908
|
)}
|
|
1675
1909
|
|
|
1676
1910
|
{/* Main content - only show when connected and authenticated */}
|
|
1677
|
-
{
|
|
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}>
|
|
1678
1915
|
<ErrorBoundary>
|
|
1679
1916
|
{/* Home dashboard */}
|
|
1680
1917
|
{mainView === 'home' && (
|
|
@@ -1689,6 +1926,8 @@ function AppInner() {
|
|
|
1689
1926
|
newParams.delete('kind') // kind is now in the path
|
|
1690
1927
|
newParams.delete('mode')
|
|
1691
1928
|
newParams.delete('resource')
|
|
1929
|
+
newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
|
|
1930
|
+
newParams.delete('tab')
|
|
1692
1931
|
newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
|
|
1693
1932
|
if (apiGroup) {
|
|
1694
1933
|
newParams.set('apiGroup', apiGroup)
|
|
@@ -1773,7 +2012,7 @@ function AppInner() {
|
|
|
1773
2012
|
|
|
1774
2013
|
<div className="flex-1 relative">
|
|
1775
2014
|
<TopologyGraph
|
|
1776
|
-
topology={
|
|
2015
|
+
topology={topologyWithAudit}
|
|
1777
2016
|
viewMode={topologyMode}
|
|
1778
2017
|
groupingMode={effectiveGroupingMode}
|
|
1779
2018
|
hideGroupHeader={hideGroupHeader}
|
|
@@ -1816,6 +2055,13 @@ function AppInner() {
|
|
|
1816
2055
|
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
1817
2056
|
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
1818
2057
|
onNavigateToTraffic={() => setMainView('traffic')}
|
|
2058
|
+
leadingSlot={
|
|
2059
|
+
<FreshnessControl
|
|
2060
|
+
mode="auto"
|
|
2061
|
+
paused={topologyPaused}
|
|
2062
|
+
connectionState={connection.state}
|
|
2063
|
+
/>
|
|
2064
|
+
}
|
|
1819
2065
|
/>
|
|
1820
2066
|
</div>
|
|
1821
2067
|
</>
|
|
@@ -1858,20 +2104,14 @@ function AppInner() {
|
|
|
1858
2104
|
<HelmView
|
|
1859
2105
|
namespaces={namespaces}
|
|
1860
2106
|
selectedRelease={selectedHelmRelease}
|
|
1861
|
-
onReleaseClick={
|
|
1862
|
-
setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
|
|
1863
|
-
const params = new URLSearchParams(window.location.search)
|
|
1864
|
-
params.set('release', `${ns}/${name}`)
|
|
1865
|
-
if (storageNamespace) {
|
|
1866
|
-
params.set('releaseStorage', storageNamespace)
|
|
1867
|
-
} else {
|
|
1868
|
-
params.delete('releaseStorage')
|
|
1869
|
-
}
|
|
1870
|
-
setSearchParams(params, { replace: true })
|
|
1871
|
-
}}
|
|
2107
|
+
onReleaseClick={navigateToHelmRelease}
|
|
1872
2108
|
/>
|
|
1873
2109
|
)}
|
|
1874
2110
|
|
|
2111
|
+
{mainView === 'helmCompare' && (
|
|
2112
|
+
<HelmCompareRoute />
|
|
2113
|
+
)}
|
|
2114
|
+
|
|
1875
2115
|
{/* GitOps view (inline only when the host hasn't taken it over — see
|
|
1876
2116
|
the takeover splash below). */}
|
|
1877
2117
|
{mainView === 'gitops' && !isViewTakenOver('gitops') && (
|
|
@@ -1926,7 +2166,7 @@ function AppInner() {
|
|
|
1926
2166
|
<div className="flex-1 relative bg-theme-base">
|
|
1927
2167
|
{/* Viewport-anchored, 17px — identical to the "Connecting" splash so
|
|
1928
2168
|
the mark doesn't move or resize across the takeover hand-off. */}
|
|
1929
|
-
<div className="
|
|
2169
|
+
<div className="absolute inset-0 pointer-events-none">
|
|
1930
2170
|
<img
|
|
1931
2171
|
src={radarLoadingIcon}
|
|
1932
2172
|
alt=""
|
|
@@ -1967,8 +2207,10 @@ function AppInner() {
|
|
|
1967
2207
|
/>
|
|
1968
2208
|
)}
|
|
1969
2209
|
|
|
1970
|
-
{/* Workload full view
|
|
1971
|
-
|
|
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' && (
|
|
1972
2214
|
<WorkloadViewRoute
|
|
1973
2215
|
onNavigateToResource={(resource) => {
|
|
1974
2216
|
navigate(buildWorkloadPath(resource))
|
|
@@ -1982,8 +2224,10 @@ function AppInner() {
|
|
|
1982
2224
|
</ErrorBoundary>
|
|
1983
2225
|
</div>}
|
|
1984
2226
|
|
|
1985
|
-
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView
|
|
1986
|
-
|
|
2227
|
+
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
|
|
2228
|
+
Gated on contentReady so it never renders over the connecting/switching
|
|
2229
|
+
splash (which would push the centered logo off-center). */}
|
|
2230
|
+
{contentReady && resourceDrawer.shouldRender && drawerResource && (
|
|
1987
2231
|
<ResourceDetailDrawer
|
|
1988
2232
|
resource={drawerResource}
|
|
1989
2233
|
initialTab={drawerInitialTab}
|
|
@@ -1991,24 +2235,43 @@ function AppInner() {
|
|
|
1991
2235
|
// to the top of the content area instead of leaving a 49px gap.
|
|
1992
2236
|
headerHeight={chromeless ? 0 : undefined}
|
|
1993
2237
|
isOpen={resourceDrawer.isOpen}
|
|
1994
|
-
expanded={
|
|
1995
|
-
onClose={
|
|
2238
|
+
expanded={drawerExpandedProp}
|
|
2239
|
+
onClose={closeDrawer}
|
|
1996
2240
|
onNavigate={(res) => navigateToResource(res)}
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
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)
|
|
2001
2252
|
}}
|
|
2002
|
-
|
|
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}
|
|
2003
2256
|
onNavigateToResource={(resource) => {
|
|
2004
|
-
|
|
2005
|
-
|
|
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() })
|
|
2006
2269
|
}}
|
|
2007
2270
|
/>
|
|
2008
2271
|
)}
|
|
2009
2272
|
|
|
2010
|
-
{/* Helm release drawer */}
|
|
2011
|
-
{helmDrawer.shouldRender && drawerHelmRelease && (
|
|
2273
|
+
{/* Helm release drawer — same contentReady gate as the resource drawer. */}
|
|
2274
|
+
{contentReady && helmDrawer.shouldRender && drawerHelmRelease && (
|
|
2012
2275
|
<HelmReleaseDrawer
|
|
2013
2276
|
release={drawerHelmRelease}
|
|
2014
2277
|
isOpen={helmDrawer.isOpen}
|
|
@@ -2065,6 +2328,8 @@ function AppInner() {
|
|
|
2065
2328
|
if (group) params.set('apiGroup', group)
|
|
2066
2329
|
else params.delete('apiGroup')
|
|
2067
2330
|
params.delete('resource')
|
|
2331
|
+
params.delete('full')
|
|
2332
|
+
params.delete('tab')
|
|
2068
2333
|
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
2069
2334
|
// Focus the table search after navigation — the user came from ⌘K
|
|
2070
2335
|
// (keyboard flow) and expects to type a resource name immediately.
|
|
@@ -2081,6 +2346,7 @@ function AppInner() {
|
|
|
2081
2346
|
{ onSettled: () => setNamespaces([]) },
|
|
2082
2347
|
)}
|
|
2083
2348
|
onSetNamespaces={(ns) => {
|
|
2349
|
+
if (namespaceScope?.cacheScoped && ns.length !== 1) return
|
|
2084
2350
|
setNamespaces(ns)
|
|
2085
2351
|
setActiveNamespace.mutate({ namespaces: ns })
|
|
2086
2352
|
}}
|
|
@@ -2154,14 +2420,14 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
|
|
|
2154
2420
|
}
|
|
2155
2421
|
|
|
2156
2422
|
// Main App component wrapped with providers
|
|
2157
|
-
function App() {
|
|
2423
|
+
function App({ manageDocumentTitle = false, documentTitleSuffix }: { manageDocumentTitle?: boolean; documentTitleSuffix?: string }) {
|
|
2158
2424
|
return (
|
|
2159
2425
|
<ConnectionProvider>
|
|
2160
2426
|
<CapabilitiesProvider>
|
|
2161
2427
|
<ContextSwitchProvider>
|
|
2162
2428
|
<DockProvider>
|
|
2163
2429
|
<KeyboardShortcutProvider>
|
|
2164
|
-
<AppInner />
|
|
2430
|
+
<AppInner manageDocumentTitle={manageDocumentTitle} documentTitleSuffix={documentTitleSuffix} />
|
|
2165
2431
|
</KeyboardShortcutProvider>
|
|
2166
2432
|
</DockProvider>
|
|
2167
2433
|
</ContextSwitchProvider>
|