@skyhook-io/radar-app 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- package/src/components/ui/NamespaceSelector.tsx +0 -436
package/src/App.tsx
CHANGED
|
@@ -1,56 +1,77 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
2
2
|
import { flushSync } from 'react-dom'
|
|
3
|
-
import {
|
|
3
|
+
import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
|
|
4
|
+
import { englishPlural } from '@skyhook-io/k8s-ui/utils/pluralize'
|
|
4
5
|
import { useQueryClient } from '@tanstack/react-query'
|
|
5
|
-
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
|
6
|
+
import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
|
|
6
7
|
import { HomeView } from './components/home/HomeView'
|
|
7
8
|
import { DebugOverlay } from './components/DebugOverlay'
|
|
8
|
-
import {
|
|
9
|
+
import { GlobalDiagnoseButton } from './components/diagnose/LocalDiagnoseAction'
|
|
10
|
+
import { useDiagnoseLayout } from './components/diagnose/DiagnoseContext'
|
|
11
|
+
import { DiagnoseSurface } from './components/diagnose/DiagnoseSurface'
|
|
12
|
+
import { TopologyGraph, TopologySearch, TopologyBreadcrumb, TopologyFilterSidebar, TopologyControls, FreshnessControl, gitOpsRouteForKind, gitOpsRouteForResource, ScopePill, PaneLoader } from '@skyhook-io/k8s-ui'
|
|
13
|
+
import { initNavigationMap } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
14
|
+
import { useAPIResources, findAPIResourceForRoute } from './api/apiResources'
|
|
9
15
|
import { TimelineView } from './components/timeline/TimelineView'
|
|
10
16
|
import { ResourcesView } from './components/resources/ResourcesView'
|
|
11
17
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
12
18
|
import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawer'
|
|
13
19
|
import { WorkloadViewRoute } from './components/workload/WorkloadView'
|
|
20
|
+
import { CompareViewRoute } from './components/compare/CompareViewRoute'
|
|
14
21
|
import { HelmView } from './components/helm/HelmView'
|
|
22
|
+
import { HelmCompareRoute } from './components/helm/HelmCompareRoute'
|
|
15
23
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
16
24
|
import { CostView } from './components/cost/CostView'
|
|
17
25
|
import { AuditView } from './components/audit/AuditView'
|
|
26
|
+
import { IssuesPane } from './components/issues/IssuesPane'
|
|
27
|
+
import { GitOpsView } from './components/gitops/GitOpsView'
|
|
28
|
+
import { ApplicationsView } from './components/applications/ApplicationsView'
|
|
18
29
|
import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
|
|
19
30
|
import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
|
|
20
|
-
import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
|
|
31
|
+
import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
|
|
21
32
|
import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
22
33
|
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
34
|
+
import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
|
|
23
35
|
import { useNavCustomization } from './context/NavCustomization'
|
|
36
|
+
import type { FleetTakeoverTarget } from './context/NavCustomization'
|
|
37
|
+
import { PrimaryNavRail } from './components/nav/PrimaryNavRail'
|
|
38
|
+
import { useNavRailPinned } from './hooks/useNavRailPinned'
|
|
39
|
+
import { useMediaQuery } from './hooks/useMediaQuery'
|
|
24
40
|
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
25
41
|
import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
26
42
|
import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
27
43
|
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
28
44
|
import { UserMenu } from './components/UserMenu'
|
|
29
45
|
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
30
|
-
import { NamespaceSelector } from './components/ui/NamespaceSelector'
|
|
31
46
|
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
32
47
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
33
48
|
import { CommandPalette } from './components/ui/CommandPalette'
|
|
34
49
|
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
35
50
|
import { useEventSource } from './hooks/useEventSource'
|
|
36
|
-
import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
|
|
51
|
+
import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe, useAudit } from './api/client'
|
|
52
|
+
import { buildAuditSeverityMap } from './utils/auditBadges'
|
|
37
53
|
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
38
|
-
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
54
|
+
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts, useSuppressBaseShortcuts } from './hooks/useKeyboardShortcuts'
|
|
39
55
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
40
|
-
import
|
|
41
|
-
import {
|
|
56
|
+
import { useDocumentTitle } from './hooks/useDocumentTitle'
|
|
57
|
+
import type { ClusterLoadState } from './types/clusterLoadState'
|
|
58
|
+
import { useClusterLoadState } from './hooks/useClusterLoadState'
|
|
59
|
+
import { Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, SquareTerminal, ShieldCheck, GitBranch, HelpCircle, Loader2, RefreshCw } from 'lucide-react'
|
|
42
60
|
import { useTheme } from './context/ThemeContext'
|
|
43
61
|
import { Tooltip } from './components/ui/Tooltip'
|
|
44
62
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
45
63
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
46
|
-
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
47
|
-
import { kindToPlural, openExternal } from './utils/navigation'
|
|
64
|
+
import type { APIResource, TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
65
|
+
import { kindToPlural, pluralToKind, openExternal, apiVersionToGroup, relatedResourcePath, searchHitToSelectedResource } from './utils/navigation'
|
|
66
|
+
import { type OmnibarHandle } from './components/ui/Omnibar'
|
|
67
|
+
import { RadarOmnibar } from './components/ui/RadarOmnibar'
|
|
68
|
+
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
48
69
|
|
|
49
70
|
// All possible node kinds (core + GitOps)
|
|
50
71
|
const ALL_NODE_KINDS: NodeKind[] = [
|
|
51
72
|
'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
|
|
52
73
|
'Service', 'Deployment', 'Rollout', 'DaemonSet', 'StatefulSet',
|
|
53
|
-
'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
|
|
74
|
+
'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'ServiceAccount', 'SealedSecret', 'ServiceMonitor', 'PodMonitor', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
|
|
54
75
|
'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
|
|
55
76
|
'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
|
|
56
77
|
'Broker', 'Trigger', 'PingSource', 'ApiServerSource', 'ContainerSource', 'SinkBinding', 'Channel',
|
|
@@ -68,6 +89,12 @@ const DEFAULT_VISIBLE_KINDS = ALL_NODE_KINDS.filter(k => k !== 'ReplicaSet')
|
|
|
68
89
|
// Users can re-enable via the filter sidebar.
|
|
69
90
|
const CRD_HIDDEN_BY_DEFAULT = new Set(['GatewayClass', 'IngressClass', 'NodePool', 'NodeClaim', 'NodeClass'])
|
|
70
91
|
|
|
92
|
+
// Top-bar height in px. The body frame's right-side surfaces (AI panel, resource +
|
|
93
|
+
// Helm drawers) all inset their top by this so they sit BELOW the header. 0 in
|
|
94
|
+
// chromeless embeds (the host owns the chrome, no Radar header). Keep in sync with
|
|
95
|
+
// the <header> py/line-height; the drawers historically hardcoded the same 49.
|
|
96
|
+
const APP_HEADER_HEIGHT = 49
|
|
97
|
+
|
|
71
98
|
// CAPI kinds shown in Fleet topology mode (+ Node for Machine→Node edges)
|
|
72
99
|
// Includes core CAPI kinds and all infrastructure provider kinds
|
|
73
100
|
const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
@@ -86,37 +113,12 @@ const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
|
86
113
|
])
|
|
87
114
|
|
|
88
115
|
// Convert API resource name back to topology node ID prefix
|
|
89
|
-
function apiResourceToNodeIdPrefix(apiResource: string): string {
|
|
90
|
-
const prefixMap: Record<string, string> = {
|
|
91
|
-
'pods': 'pod',
|
|
92
|
-
'services': 'service',
|
|
93
|
-
'deployments': 'deployment',
|
|
94
|
-
'daemonsets': 'daemonset',
|
|
95
|
-
'statefulsets': 'statefulset',
|
|
96
|
-
'replicasets': 'replicaset',
|
|
97
|
-
'ingresses': 'ingress',
|
|
98
|
-
'gateways': 'gateway',
|
|
99
|
-
'httproutes': 'httproute',
|
|
100
|
-
'grpcroutes': 'grpcroute',
|
|
101
|
-
'tcproutes': 'tcproute',
|
|
102
|
-
'tlsroutes': 'tlsroute',
|
|
103
|
-
'configmaps': 'configmap',
|
|
104
|
-
'secrets': 'secret',
|
|
105
|
-
'horizontalpodautoscalers': 'horizontalpodautoscaler',
|
|
106
|
-
'jobs': 'job',
|
|
107
|
-
'cronjobs': 'cronjob',
|
|
108
|
-
'persistentvolumeclaims': 'persistentvolumeclaim',
|
|
109
|
-
'namespaces': 'namespace',
|
|
110
|
-
'httpproxies': 'httpproxy', // Contour
|
|
111
|
-
}
|
|
112
|
-
return prefixMap[apiResource] || apiResource.replace(/s$/, '')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
116
|
// Extended MainView type that includes traffic and cost
|
|
116
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | '
|
|
117
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'checks' | 'gitops' | 'compare' | 'helmCompare' | 'issues' | 'applications'
|
|
117
118
|
|
|
118
119
|
// Extract view from URL path
|
|
119
120
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
121
|
+
if (pathname.replace(/\/+$/, '') === '/helm/compare') return 'helmCompare'
|
|
120
122
|
const path = pathname.replace(/^\//, '').split('/')[0]
|
|
121
123
|
if (path === '' || path === 'home') return 'home'
|
|
122
124
|
if (path === 'topology') return 'topology'
|
|
@@ -126,10 +128,110 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
126
128
|
if (path === 'traffic') return 'traffic'
|
|
127
129
|
if (path === 'cost') return 'cost'
|
|
128
130
|
if (path === 'workload') return 'workload'
|
|
129
|
-
if (path === 'audit') return 'audit
|
|
131
|
+
if (path === 'checks' || path === 'audit') return 'checks' // /audit = legacy → checks
|
|
132
|
+
if (path === 'gitops') return 'gitops'
|
|
133
|
+
if (path === 'applications') return 'applications'
|
|
134
|
+
if (path === 'compare') return 'compare'
|
|
135
|
+
if (path === 'issues') return 'issues'
|
|
130
136
|
return 'home'
|
|
131
137
|
}
|
|
132
138
|
|
|
139
|
+
// The namespace scope filter is meaningful only on namespaced surfaces. On
|
|
140
|
+
// cluster-scoped views it does nothing, so we disable it with an explanation
|
|
141
|
+
// rather than leaving a dead control that silently ignores the pick:
|
|
142
|
+
// - Cost is reported per-namespace across the whole cluster (the view IS the
|
|
143
|
+
// breakdown; a filter would only hide rows).
|
|
144
|
+
// - A GitOps detail tree spans namespaces — its controller lives in one
|
|
145
|
+
// namespace but manages workloads across many.
|
|
146
|
+
// - A cluster-scoped resource kind (Nodes, PVs, ClusterRoles…) has no
|
|
147
|
+
// namespace at all.
|
|
148
|
+
// The pick itself is preserved so it re-applies when the user returns to a
|
|
149
|
+
// namespaced view.
|
|
150
|
+
function namespaceFilterDisabled(
|
|
151
|
+
view: ExtendedMainView,
|
|
152
|
+
pathname: string,
|
|
153
|
+
search = '',
|
|
154
|
+
apiResources?: APIResource[],
|
|
155
|
+
): { disabled: boolean; tooltip?: string } {
|
|
156
|
+
if (
|
|
157
|
+
view === 'cost' &&
|
|
158
|
+
!pathname.startsWith('/cost/rightsizing')
|
|
159
|
+
) {
|
|
160
|
+
return {
|
|
161
|
+
disabled: true,
|
|
162
|
+
tooltip: 'Cost is reported per namespace across the whole cluster — the namespace filter doesn’t apply here.',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const segments = pathname.replace(/^\//, '').split('/')
|
|
166
|
+
if (view === 'gitops' && segments[1] === 'detail') {
|
|
167
|
+
return {
|
|
168
|
+
disabled: true,
|
|
169
|
+
tooltip: 'This resource manages workloads across namespaces — the namespace filter doesn’t apply to its tree.',
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (view === 'resources') {
|
|
173
|
+
const kindSlug = segments[1]
|
|
174
|
+
const group = new URLSearchParams(search).get('apiGroup') || ''
|
|
175
|
+
const match = kindSlug ? findAPIResourceForRoute(apiResources, kindSlug, group) : undefined
|
|
176
|
+
if (match && !match.namespaced) {
|
|
177
|
+
return {
|
|
178
|
+
disabled: true,
|
|
179
|
+
tooltip: `${match.kind} is a cluster-scoped resource — namespaces don’t apply.`,
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { disabled: false }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Browser tab label for every Radar view, derived from the route URL so it's
|
|
187
|
+
// correct regardless of which component renders it. A detail drawer that opens
|
|
188
|
+
// over a list (?resource=…) is deliberately NOT titled — it's the same page, so
|
|
189
|
+
// it keeps the list's title.
|
|
190
|
+
function radarPageTitle(pathname: string, search = '', apiResources?: APIResource[]): string | null {
|
|
191
|
+
const decode = (s: string) => {
|
|
192
|
+
try {
|
|
193
|
+
return decodeURIComponent(s)
|
|
194
|
+
} catch {
|
|
195
|
+
return s
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const capitalize = (text: string) =>
|
|
199
|
+
text ? text.charAt(0).toUpperCase() + text.slice(1) : text
|
|
200
|
+
const pluralKindTitle = (kind: string, resourceName: string) =>
|
|
201
|
+
kind.toLowerCase() === resourceName.toLowerCase() || /Metrics$/.test(kind) ? kind : englishPlural(kind)
|
|
202
|
+
const pathSegments = pathname.replace(/^\//, '').split('/').filter(Boolean)
|
|
203
|
+
const view = getViewFromPath(pathname)
|
|
204
|
+
|
|
205
|
+
// Full-page resource detail: /workload/<kind>/<ns>/<name> (name may contain '/').
|
|
206
|
+
if (view === 'workload') return pathSegments.slice(3).map(decode).join('/') || null
|
|
207
|
+
// Resources is browsed per-kind: /resources/<kind> → "<Kind>" (e.g. ConfigMap);
|
|
208
|
+
// bare /resources (before it redirects to a default kind) → "Resources".
|
|
209
|
+
if (view === 'resources') {
|
|
210
|
+
const resourceName = decode(pathSegments[1] ?? '')
|
|
211
|
+
if (!resourceName) return 'Resources'
|
|
212
|
+
const group = new URLSearchParams(search).get('apiGroup') || ''
|
|
213
|
+
const match = findAPIResourceForRoute(apiResources, resourceName, group)
|
|
214
|
+
return pluralKindTitle(match?.kind ?? pluralToKind(resourceName), resourceName)
|
|
215
|
+
}
|
|
216
|
+
// GitOps detail is /gitops/detail/<kind>/<ns>/<name> → the resource name;
|
|
217
|
+
// anything else (the list) → "GitOps".
|
|
218
|
+
if (view === 'gitops')
|
|
219
|
+
return pathSegments[1] === 'detail' ? decode(pathSegments[4] ?? '') || 'GitOps' : 'GitOps'
|
|
220
|
+
if (view === 'applications') {
|
|
221
|
+
const appKey = new URLSearchParams(search).get('app')
|
|
222
|
+
if (!appKey) return 'Applications'
|
|
223
|
+
const decoded = decode(appKey)
|
|
224
|
+
const slash = decoded.lastIndexOf('/')
|
|
225
|
+
return slash >= 0 && slash < decoded.length - 1 ? decoded.slice(slash + 1) : decoded
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The landing view reads "Overview" rather than "Home" in the tab.
|
|
229
|
+
if (view === 'home') return 'Overview'
|
|
230
|
+
// Every other view's label is its id capitalized — getViewFromPath has already
|
|
231
|
+
// normalized aliases (e.g. /audit → 'checks'), so no lookup table is needed.
|
|
232
|
+
return capitalize(view)
|
|
233
|
+
}
|
|
234
|
+
|
|
133
235
|
function AuthBarrier({ authMode }: { authMode: string }) {
|
|
134
236
|
useEffect(() => {
|
|
135
237
|
if (authMode === 'oidc') {
|
|
@@ -139,12 +241,10 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
139
241
|
|
|
140
242
|
if (authMode === 'oidc') {
|
|
141
243
|
return (
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
</div>
|
|
147
|
-
</div>
|
|
244
|
+
<PaneLoader
|
|
245
|
+
label="Redirecting to login…"
|
|
246
|
+
className="flex-1 min-h-0 bg-theme-base"
|
|
247
|
+
/>
|
|
148
248
|
)
|
|
149
249
|
}
|
|
150
250
|
|
|
@@ -168,13 +268,77 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
168
268
|
)
|
|
169
269
|
}
|
|
170
270
|
|
|
171
|
-
|
|
271
|
+
// Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
|
|
272
|
+
// is not enough: Applications keeps the list and an app's detail on the same
|
|
273
|
+
// `/applications` pathname and distinguishes them with `?app=`, so a Back from
|
|
274
|
+
// detail to list would otherwise leave the peek orphaned. Only `app` is included
|
|
275
|
+
// (not the whole query) so filter/tab/namespace churn doesn't close the peek.
|
|
276
|
+
function peekOwnerKey(pathname: string, search: string): string {
|
|
277
|
+
return `${pathname}\n${new URLSearchParams(search).get('app') ?? ''}`
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface AppProps {
|
|
281
|
+
manageDocumentTitle?: boolean
|
|
282
|
+
documentTitleSuffix?: string
|
|
283
|
+
onClusterLoadStateChange?: (state: ClusterLoadState) => void
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
|
|
172
287
|
const navigate = useNavigate()
|
|
173
288
|
const location = useLocation()
|
|
289
|
+
const navigationType = useNavigationType()
|
|
174
290
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
175
291
|
const capabilities = useCapabilitiesContext()
|
|
176
292
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
177
293
|
const navCustomization = useNavCustomization()
|
|
294
|
+
// The AI panel is an absolute slot in the body frame (the column under the header):
|
|
295
|
+
// it reserves a right gutter on the CONTENT only, so the navbar + nav rail stay
|
|
296
|
+
// static. contentGutter is the docked panel width (0 when closed/overlay/maximized).
|
|
297
|
+
const { open: diagnoseOpen, contentGutter } = useDiagnoseLayout()
|
|
298
|
+
// Hand off to a host-owned URL. The host's `onHostNavigate` (Radar Cloud's
|
|
299
|
+
// cross-tree swap) navigates same-document so the chrome morphs instead of
|
|
300
|
+
// cold-booting; without it we fall back to a hard `window.location` nav.
|
|
301
|
+
const goHost = useCallback(
|
|
302
|
+
(url: string) => {
|
|
303
|
+
if (navCustomization.onHostNavigate) navCustomization.onHostNavigate(url)
|
|
304
|
+
else window.location.assign(url)
|
|
305
|
+
},
|
|
306
|
+
[navCustomization],
|
|
307
|
+
)
|
|
308
|
+
// Resolve every host-takeover URL ONCE (memoized on navCustomization) so the
|
|
309
|
+
// setMainView intercept, redirect effect, nav-pill filtering, inline-view
|
|
310
|
+
// gating, and the cert click handler all consume the SAME value — host
|
|
311
|
+
// callbacks aren't guaranteed idempotent (scope / flags / signed URLs can
|
|
312
|
+
// shift between calls). undefined = not taken over → Radar renders the view
|
|
313
|
+
// itself. `clusterChecksHref` is the deprecated pre-1.7 hook, folded into the
|
|
314
|
+
// 'checks' target for back-compat.
|
|
315
|
+
const takeover: Record<FleetTakeoverTarget, string | undefined> = useMemo(
|
|
316
|
+
() => ({
|
|
317
|
+
issues: navCustomization.fleetTakeoverHref?.('issues'),
|
|
318
|
+
gitops: navCustomization.fleetTakeoverHref?.('gitops'),
|
|
319
|
+
checks: navCustomization.fleetTakeoverHref?.('checks') ?? navCustomization.clusterChecksHref?.(),
|
|
320
|
+
certs: navCustomization.fleetTakeoverHref?.('certs'),
|
|
321
|
+
}),
|
|
322
|
+
[navCustomization],
|
|
323
|
+
)
|
|
324
|
+
const { pinned: navRailPinned, togglePinned: toggleNavRailPinned } = useNavRailPinned()
|
|
325
|
+
// Standalone Radar gets the left nav rail; embedded hosts (Radar Hub) own
|
|
326
|
+
// the left chrome via their own fleet rail and keep Radar's top-bar pills.
|
|
327
|
+
const showNavRail = !navCustomization.embedded
|
|
328
|
+
// Chromeless embed: the host (Radar Hub) owns ALL chrome and drives view
|
|
329
|
+
// navigation + scope from its own UI, so Radar renders just the active view's
|
|
330
|
+
// content — no top bar, no view-switcher. Used for per-cluster views surfaced
|
|
331
|
+
// as native cloud destinations behind a cluster picker.
|
|
332
|
+
const chromeless = navCustomization.embedded === true && navCustomization.chrome === 'none'
|
|
333
|
+
// Force the slim rail on narrow windows: a pinned 176px rail needs viewport
|
|
334
|
+
// ≥976 to keep content above its ~800px floor (collapsed needs only ≥856).
|
|
335
|
+
// Below 976 we render collapsed regardless of the pin preference — a
|
|
336
|
+
// temporary responsive override that does NOT touch the persisted value, so
|
|
337
|
+
// the user's pinned state returns when they widen again. Fly-out labels cover
|
|
338
|
+
// the collapsed state, so the manual toggle is hidden here rather than left
|
|
339
|
+
// inert (expanding would just re-breach the floor).
|
|
340
|
+
const railForcedSlim = useMediaQuery('(max-width: 975px)')
|
|
341
|
+
const navRailEffectivePinned = navRailPinned && !railForcedSlim
|
|
178
342
|
|
|
179
343
|
// Auth check — detect if auth is enabled but user is not authenticated
|
|
180
344
|
const { data: authMe, isPending: authMePending } = useAuthMe()
|
|
@@ -217,6 +381,22 @@ function AppInner() {
|
|
|
217
381
|
// Get mainView from URL path
|
|
218
382
|
const mainView = getViewFromPath(location.pathname)
|
|
219
383
|
|
|
384
|
+
// Initialize the kind→plural discovery map app-wide (not just on ResourcesView
|
|
385
|
+
// mount) so the omnibar can open a CRD hit with an irregular plural from any
|
|
386
|
+
// view — kindToPlural would otherwise English-guess the route before a
|
|
387
|
+
// resources view has run initNavigationMap().
|
|
388
|
+
const { data: navApiResources } = useAPIResources()
|
|
389
|
+
useEffect(() => { if (navApiResources) initNavigationMap(navApiResources) }, [navApiResources])
|
|
390
|
+
|
|
391
|
+
// View-aware namespace scope: disabled on cluster-scoped surfaces so the
|
|
392
|
+
// chip isn't a dead control next to the cluster switcher.
|
|
393
|
+
const namespaceFilter = namespaceFilterDisabled(mainView, location.pathname, location.search, navApiResources)
|
|
394
|
+
|
|
395
|
+
// One URL-derived tab title for every view (see radarPageTitle). Driving it
|
|
396
|
+
// from the URL — not the mounted component. Off unless the host opts in
|
|
397
|
+
// (standalone passes manageDocumentTitle), so embedders keep title ownership.
|
|
398
|
+
useDocumentTitle(manageDocumentTitle ? radarPageTitle(location.pathname, location.search, navApiResources) : null, documentTitleSuffix)
|
|
399
|
+
|
|
220
400
|
// Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
|
|
221
401
|
const normalizedResourcesKindSlug = useMemo(() => {
|
|
222
402
|
const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
|
|
@@ -236,6 +416,20 @@ function AppInner() {
|
|
|
236
416
|
|
|
237
417
|
// Set mainView by navigating to the path
|
|
238
418
|
const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
|
|
419
|
+
// Host takeover: fleet-shaped views (issues/gitops/checks) are owned by the
|
|
420
|
+
// host's fleet pages. Hand straight to the host instead of navigating to
|
|
421
|
+
// our own /<view> first — that intermediate hop mounts the view machinery
|
|
422
|
+
// and flashes the "Opening…" splash before the redirect effect bounces out.
|
|
423
|
+
// Skipping it makes the hand-off a single smooth cross-tree swap. (Direct
|
|
424
|
+
// /<view> URL entry still funnels through the redirect effect below.)
|
|
425
|
+
if (view === 'issues' || view === 'gitops' || view === 'checks') {
|
|
426
|
+
const href = takeover[view]
|
|
427
|
+
if (href) {
|
|
428
|
+
goHost(href)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
239
433
|
const path = view === 'home' ? '/' : `/${view}`
|
|
240
434
|
|
|
241
435
|
// Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
|
|
@@ -253,7 +447,31 @@ function AppInner() {
|
|
|
253
447
|
}
|
|
254
448
|
|
|
255
449
|
navigate({ pathname: path, search: newParams.toString() })
|
|
256
|
-
}, [navigate, searchParams])
|
|
450
|
+
}, [navigate, searchParams, takeover, goHost])
|
|
451
|
+
|
|
452
|
+
// Cloud (embedded) takes over the "fleet-shaped" per-cluster views with its
|
|
453
|
+
// own fleet pages scoped to this cluster — owned by the host's left rail — so
|
|
454
|
+
// Radar drops the matching pills (see the nav below). In-app nav hands off in
|
|
455
|
+
// setMainView (above); direct /<view> URL entry funnels through the redirect
|
|
456
|
+
// effect below. Both consume the memoized `takeover` resolved above. Standalone
|
|
457
|
+
// OSS (no fleetTakeoverHref) is unaffected and renders the in-app view.
|
|
458
|
+
//
|
|
459
|
+
// Has the host claimed this view? View-shaped targets only ('certs' has no
|
|
460
|
+
// Radar view — only its Home card consults `takeover`). Used to drop the nav
|
|
461
|
+
// pill and gate the inline view render in favor of the "Opening…" splash.
|
|
462
|
+
const isViewTakenOver = (view: ExtendedMainView): boolean =>
|
|
463
|
+
(view === 'issues' || view === 'gitops' || view === 'checks') && !!takeover[view]
|
|
464
|
+
// The host's URL for the CURRENT view, if taken over. Drives the redirect
|
|
465
|
+
// effect and the "Opening…" splash.
|
|
466
|
+
const viewTakeoverHref =
|
|
467
|
+
mainView === 'issues' || mainView === 'gitops' || mainView === 'checks'
|
|
468
|
+
? takeover[mainView]
|
|
469
|
+
: undefined
|
|
470
|
+
useEffect(() => {
|
|
471
|
+
if (viewTakeoverHref) {
|
|
472
|
+
window.location.replace(viewTakeoverHref)
|
|
473
|
+
}
|
|
474
|
+
}, [viewTakeoverHref])
|
|
257
475
|
|
|
258
476
|
const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
|
|
259
477
|
// For large clusters: force SSE to reconnect with namespace filter
|
|
@@ -267,6 +485,12 @@ function AppInner() {
|
|
|
267
485
|
// Topology filter state
|
|
268
486
|
const [visibleKinds, setVisibleKinds] = useState<Set<NodeKind>>(() => new Set(DEFAULT_VISIBLE_KINDS))
|
|
269
487
|
const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
|
|
488
|
+
// Topology node-search → canvas focus request (nonce lets the same node re-focus)
|
|
489
|
+
const [topologyFocus, setTopologyFocus] = useState<{ id: string; nonce: number } | null>(null)
|
|
490
|
+
// The topology pane element — the search overlay portals into it so its
|
|
491
|
+
// backdrop dims only the pane (not the app) and stays clickable. Callback-ref
|
|
492
|
+
// state so it updates once the pane mounts.
|
|
493
|
+
const [topologyPane, setTopologyPane] = useState<HTMLDivElement | null>(null)
|
|
270
494
|
// Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
|
|
271
495
|
const seededCRDKindsRef = useRef<Set<string>>(new Set())
|
|
272
496
|
|
|
@@ -284,15 +508,6 @@ function AppInner() {
|
|
|
284
508
|
// Settings dialog state
|
|
285
509
|
const [showSettings, setShowSettings] = useState(false)
|
|
286
510
|
|
|
287
|
-
// Listen for desktop "open-settings" event from native menu
|
|
288
|
-
useEffect(() => {
|
|
289
|
-
const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
|
|
290
|
-
| { EventsOn?: (event: string, callback: () => void) => () => void }
|
|
291
|
-
| undefined
|
|
292
|
-
if (!wailsRuntime?.EventsOn) return
|
|
293
|
-
return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
|
|
294
|
-
}, [])
|
|
295
|
-
|
|
296
511
|
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
297
512
|
useEffect(() => {
|
|
298
513
|
const handler = () => setShowSettings(true)
|
|
@@ -300,43 +515,119 @@ function AppInner() {
|
|
|
300
515
|
return () => window.removeEventListener('radar:open-settings', handler)
|
|
301
516
|
}, [])
|
|
302
517
|
|
|
518
|
+
// Listen for "open-local-terminal" DOM event — the AI surface is portaled above
|
|
519
|
+
// the DockProvider, so it can't call useOpenLocalTerminal directly; it dispatches
|
|
520
|
+
// this instead (mirrors the open-settings pattern).
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
const handler = (e: Event) => {
|
|
523
|
+
const { command, title } = (e as CustomEvent).detail ?? {}
|
|
524
|
+
openLocalTerminal({ initialCommand: command, title })
|
|
525
|
+
}
|
|
526
|
+
window.addEventListener('radar:open-local-terminal', handler)
|
|
527
|
+
return () => window.removeEventListener('radar:open-local-terminal', handler)
|
|
528
|
+
}, [openLocalTerminal])
|
|
529
|
+
|
|
303
530
|
// Diagnostics overlay state
|
|
304
531
|
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
|
305
532
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
533
|
+
// The peek drawer "expanded" into a fullscreen overlay = ?full=1 with a selected
|
|
534
|
+
// resource, on ANY view (resources list, topology graph, GitOps, Applications…) —
|
|
535
|
+
// the underlying view stays mounted. URL-derived so Back/Forward/refresh behave
|
|
536
|
+
// (non-list peeks aren't URL-backed, so refresh drops the overlay gracefully).
|
|
537
|
+
// Used by the routing effects below; the render uses `expandedView` (gated on what
|
|
538
|
+
// actually renders) — see further down.
|
|
539
|
+
const drawerExpanded = !!selectedResource && searchParams.get('full') === '1'
|
|
540
|
+
|
|
541
|
+
// On mobile there's no room for the side drawer — a resource detail is always full-screen.
|
|
542
|
+
const isMobile = useMediaQuery('(max-width: 639px)')
|
|
543
|
+
|
|
544
|
+
// On a history Pop (back/forward) the URL is authoritative. The URL-write
|
|
545
|
+
// effect, running with not-yet-synced state, would otherwise write the stale
|
|
546
|
+
// state back and revert the Pop — and oscillate with the URL→state read
|
|
547
|
+
// effect (infinite re-render, React #185, blank page). Suppress the writer
|
|
548
|
+
// for the synchronous reconciliation burst after a Pop, then auto-clear (see
|
|
549
|
+
// the arming effect) so later user-driven writes are never affected.
|
|
550
|
+
const skipUrlWriteAfterPopRef = useRef(false)
|
|
551
|
+
|
|
552
|
+
// Close resource drawer when the /resources route no longer matches the
|
|
553
|
+
// selected drawer resource. This covers both in-view kind switches and
|
|
554
|
+
// cross-kind navigations from expanded drawers (for example Node -> View Pods).
|
|
555
|
+
const prevResourcesKindKeyRef = useRef<string | null>(null)
|
|
556
|
+
// Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
|
|
557
|
+
// navigateToResource and peekOwnerKey.
|
|
558
|
+
const peekOwnerKeyRef = useRef<string | null>(null)
|
|
559
|
+
const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
|
|
560
|
+
const currentResourceGroup = searchParams.get('apiGroup') ?? ''
|
|
561
|
+
const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
|
|
562
|
+
const selectedResourceGroup = selectedResource?.group ?? ''
|
|
563
|
+
const selectedResourceRouteMismatch = mainView === 'resources' && !!selectedResource && (
|
|
564
|
+
selectedResourceKindSlug !== currentResourceKindSlug ||
|
|
565
|
+
selectedResourceGroup !== currentResourceGroup
|
|
566
|
+
)
|
|
567
|
+
const resourcesKindRouteChanged = mainView === 'resources' &&
|
|
568
|
+
prevResourcesKindKeyRef.current !== null &&
|
|
569
|
+
prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
570
|
+
|
|
571
|
+
// A peek opened outside /resources (topology, GitOps, Applications) carries no
|
|
572
|
+
// URL backing, so the only signal that the page beneath it has navigated is
|
|
573
|
+
// that its owner-key (pathname + ?app) no longer matches where it was opened.
|
|
574
|
+
// Hiding it here, at render time, closes the orphan on Back without adding
|
|
575
|
+
// another clearing effect. The /resources case is URL-backed and handled above;
|
|
576
|
+
// an expanded drawer (drawerExpanded) only exists on /resources (?full=1), so it
|
|
577
|
+
// is excluded here and never treated as an orphan.
|
|
578
|
+
const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
|
|
579
|
+
peekOwnerKeyRef.current !== null &&
|
|
580
|
+
peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
|
|
581
|
+
|
|
582
|
+
// In Applications the inline WorkloadView (?workload) and the peek drawer are
|
|
583
|
+
// mutually exclusive — never two detail surfaces at once. ?workload is the
|
|
584
|
+
// single source of truth: while it's set the peek yields to the inline view.
|
|
585
|
+
// (Opening a child peek from Applications clears ?workload, see onOpenResource.)
|
|
586
|
+
const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
|
|
587
|
+
|
|
588
|
+
const routeSelectedResource =
|
|
589
|
+
(resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
|
|
590
|
+
? null
|
|
591
|
+
: selectedResource
|
|
311
592
|
|
|
312
|
-
// Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
|
|
313
|
-
// Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
|
|
314
|
-
const prevResourcesKindSlugRef = useRef<string | null>(null)
|
|
315
593
|
useEffect(() => {
|
|
316
594
|
if (mainView !== 'resources') {
|
|
317
|
-
|
|
595
|
+
prevResourcesKindKeyRef.current = null
|
|
318
596
|
return
|
|
319
597
|
}
|
|
320
|
-
const
|
|
321
|
-
const prev =
|
|
322
|
-
|
|
323
|
-
|
|
598
|
+
const key = `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
599
|
+
const prev = prevResourcesKindKeyRef.current
|
|
600
|
+
prevResourcesKindKeyRef.current = key
|
|
601
|
+
|
|
602
|
+
if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
|
|
324
603
|
setSelectedResource(null)
|
|
325
|
-
setDrawerExpanded(false)
|
|
326
604
|
}
|
|
327
|
-
}, [mainView,
|
|
605
|
+
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
328
606
|
|
|
329
607
|
// Animation hooks for smooth mount/unmount transitions
|
|
330
|
-
const resourceDrawer = useAnimatedUnmount(!!
|
|
608
|
+
const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
|
|
331
609
|
const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
|
|
332
610
|
const helpOverlay = useAnimatedUnmount(showHelp, 300)
|
|
333
611
|
const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
|
|
334
612
|
const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
|
|
335
613
|
|
|
336
614
|
// Hold last valid values so drawers can animate out before data disappears
|
|
337
|
-
const lastResourceRef = useRef(
|
|
338
|
-
if (
|
|
339
|
-
const drawerResource =
|
|
615
|
+
const lastResourceRef = useRef(routeSelectedResource)
|
|
616
|
+
if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
|
|
617
|
+
const drawerResource = routeSelectedResource || lastResourceRef.current
|
|
618
|
+
|
|
619
|
+
// Effective fullscreen state — keyed off the resource that's ACTUALLY rendering
|
|
620
|
+
// (routeSelectedResource), not the raw selection, so an orphaned/mismatched peek
|
|
621
|
+
// can't inert the shell with no visible drawer. ?full=1 on any view, or forced on
|
|
622
|
+
// mobile (no room for a side drawer). Drives the inert backdrop + shortcut suppression.
|
|
623
|
+
const expandedView = !!routeSelectedResource && (searchParams.get('full') === '1' || isMobile)
|
|
624
|
+
useSuppressBaseShortcuts(expandedView)
|
|
625
|
+
// Held value for the drawer's `expanded` prop so closing an expanded drawer slides
|
|
626
|
+
// it out at full size instead of running a collapse morph mid-dismiss. Tracks the
|
|
627
|
+
// live state while a resource is selected; frozen during the slide-out.
|
|
628
|
+
const lastExpandedRef = useRef(expandedView)
|
|
629
|
+
if (routeSelectedResource) lastExpandedRef.current = expandedView
|
|
630
|
+
const drawerExpandedProp = routeSelectedResource ? expandedView : lastExpandedRef.current
|
|
340
631
|
|
|
341
632
|
const lastHelmReleaseRef = useRef(selectedHelmRelease)
|
|
342
633
|
if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
|
|
@@ -344,20 +635,116 @@ function AppInner() {
|
|
|
344
635
|
|
|
345
636
|
// Navigate to a resource — uses View Transitions cross-fade when drawer is already open
|
|
346
637
|
const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
|
|
638
|
+
// Record the page this peek was opened on. Outside /resources the drawer is
|
|
639
|
+
// not URL-backed, so this ref is what lets the render-time gate below close
|
|
640
|
+
// the peek when the page under it changes (e.g. browser Back off a GitOps
|
|
641
|
+
// detail page, or Applications detail → list via ?app). window.location is
|
|
642
|
+
// read (not the `location` closure) so the value is always current
|
|
643
|
+
// regardless of this callback's memoization.
|
|
644
|
+
peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
|
|
347
645
|
const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
|
|
348
|
-
|
|
349
|
-
|
|
646
|
+
// Skip the cross-fade animation entirely on first open (no
|
|
647
|
+
// `selectedResource`); otherwise route through
|
|
648
|
+
// startViewTransitionSafe to swallow the InvalidStateError that
|
|
649
|
+
// the API rejects with on rapid back-to-back navigations.
|
|
650
|
+
// (SKY-833 bug 49)
|
|
651
|
+
if (selectedResource) {
|
|
652
|
+
startViewTransitionSafe(() => flushSync(update))
|
|
350
653
|
} else {
|
|
351
654
|
update()
|
|
352
655
|
}
|
|
353
656
|
}, [selectedResource])
|
|
354
657
|
|
|
355
|
-
//
|
|
658
|
+
// Navigate from a detector finding (Audit / Issues) to the resources list for
|
|
659
|
+
// its kind, opening the resource. Shared by both queues — the body was
|
|
660
|
+
// duplicated verbatim at each render site. Encodes the opened resource in the
|
|
661
|
+
// URL (?resource=ns/name) — the same deep-link shape the resources view
|
|
662
|
+
// round-trips — so refresh/share keeps the drawer open instead of dropping it.
|
|
663
|
+
const navigateToResourceList = useCallback((resource: SelectedResource) => {
|
|
664
|
+
const pluralKind = kindToPlural(resource.kind)
|
|
665
|
+
setSelectedResource({ ...resource, kind: pluralKind })
|
|
666
|
+
const newParams = new URLSearchParams(searchParams)
|
|
667
|
+
newParams.delete('kind')
|
|
668
|
+
newParams.delete('mode')
|
|
669
|
+
newParams.delete('group')
|
|
670
|
+
// Open as a normal drawer — never inherit a stale ?full=1/tab from an
|
|
671
|
+
// expanded view we're navigating away from (only expand/drill set those).
|
|
672
|
+
newParams.delete('full')
|
|
673
|
+
newParams.delete('tab')
|
|
674
|
+
newParams.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
675
|
+
if (resource.group) {
|
|
676
|
+
newParams.set('apiGroup', resource.group)
|
|
677
|
+
} else {
|
|
678
|
+
newParams.delete('apiGroup')
|
|
679
|
+
}
|
|
680
|
+
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
681
|
+
}, [searchParams, navigate])
|
|
682
|
+
|
|
683
|
+
const navigateToHelmRelease = useCallback((namespace: string, name: string, storageNamespace?: string) => {
|
|
684
|
+
const newParams = new URLSearchParams()
|
|
685
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
686
|
+
if (globalNamespaces) {
|
|
687
|
+
newParams.set('namespaces', globalNamespaces)
|
|
688
|
+
}
|
|
689
|
+
newParams.set('release', `${namespace}/${name}`)
|
|
690
|
+
if (storageNamespace) {
|
|
691
|
+
newParams.set('releaseStorage', storageNamespace)
|
|
692
|
+
}
|
|
693
|
+
setSelectedHelmRelease({ namespace, name, storageNamespace })
|
|
694
|
+
if (mainView === 'helm') {
|
|
695
|
+
setSearchParams(newParams, { replace: true })
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
navigate({ pathname: '/helm', search: newParams.toString() })
|
|
699
|
+
}, [mainView, searchParams, navigate, setSearchParams])
|
|
700
|
+
|
|
701
|
+
// From the Issues queue: special controller/manager subjects route to their
|
|
702
|
+
// rich detail pages, not the generic resource drawer that's a dead-end for
|
|
703
|
+
// them. Member resources (Pods, Services, …) fall through to resources.
|
|
704
|
+
const navigateFromIssue = useCallback((resource: SelectedResource) => {
|
|
705
|
+
if (resource.kind === 'HelmRelease' && resource.group === 'helm.sh' && resource.namespace) {
|
|
706
|
+
navigateToHelmRelease(resource.namespace, resource.name)
|
|
707
|
+
return
|
|
708
|
+
}
|
|
709
|
+
const gitOpsPath = gitOpsRouteForResource({
|
|
710
|
+
apiVersion: resource.group ? `${resource.group}/v1` : 'v1',
|
|
711
|
+
kind: resource.kind,
|
|
712
|
+
metadata: { namespace: resource.namespace ?? '', name: resource.name },
|
|
713
|
+
})
|
|
714
|
+
if (gitOpsPath) {
|
|
715
|
+
navigate(gitOpsPath)
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
navigateToResourceList(resource)
|
|
719
|
+
}, [navigate, navigateToHelmRelease, navigateToResourceList])
|
|
720
|
+
|
|
721
|
+
// Collapse the over-list fullscreen back to the drawer = drop ?full=1 (and the
|
|
722
|
+
// resource-scoped ?tab) in place. The button means "collapse THIS to a drawer"
|
|
723
|
+
// regardless of how we got here (expand, deep link, or a drill trail), so it
|
|
724
|
+
// scrubs rather than walking history — `navigate(-1)` would leave the app on a
|
|
725
|
+
// deep link, or step back to the previous resource after a drill. Browser Back
|
|
726
|
+
// keeps its own natural history walk (it pops the ?full=1 entry → collapse).
|
|
356
727
|
const handleCollapseFromExpanded = useCallback(() => {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
728
|
+
const p = new URLSearchParams(searchParams)
|
|
729
|
+
p.delete('full')
|
|
730
|
+
p.delete('tab')
|
|
731
|
+
setSearchParams(p, { replace: true })
|
|
732
|
+
}, [searchParams, setSearchParams])
|
|
733
|
+
|
|
734
|
+
// Close the peek and drop any expand flags. Outside /resources the drawer isn't
|
|
735
|
+
// URL-backed, so a lingering ?full=1/tab would make the next peek reopen
|
|
736
|
+
// fullscreen instead of as a side drawer. (On /resources, ResourcesView's own
|
|
737
|
+
// updateURL also scrubs these — deleting them here too is idempotent.)
|
|
738
|
+
const closeDrawer = useCallback(() => {
|
|
739
|
+
setSelectedResource(null)
|
|
740
|
+
setDrawerInitialTab('detail')
|
|
741
|
+
if (searchParams.has('full') || searchParams.has('tab')) {
|
|
742
|
+
const p = new URLSearchParams(searchParams)
|
|
743
|
+
p.delete('full')
|
|
744
|
+
p.delete('tab')
|
|
745
|
+
setSearchParams(p, { replace: true })
|
|
746
|
+
}
|
|
747
|
+
}, [searchParams, setSearchParams])
|
|
361
748
|
|
|
362
749
|
// Theme toggle for keyboard shortcut
|
|
363
750
|
const { toggleTheme } = useTheme()
|
|
@@ -365,17 +752,55 @@ function AppInner() {
|
|
|
365
752
|
// Context switching for command palette
|
|
366
753
|
const switchContext = useSwitchContext()
|
|
367
754
|
|
|
755
|
+
// Refs for dropdown components to trigger them via shortcuts
|
|
756
|
+
const namespaceSwitcherRef = useRef<NamespaceSwitcherHandle>(null)
|
|
757
|
+
const omnibarRef = useRef<OmnibarHandle>(null)
|
|
758
|
+
|
|
759
|
+
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
760
|
+
|
|
368
761
|
// View switching keyboard shortcuts
|
|
369
|
-
|
|
762
|
+
// `g`+mnemonic sequences cover every view. Numeric 1–N can't: there are 11
|
|
763
|
+
// views and only 9 single digits, so `10`/`11` never match a keypress (a
|
|
764
|
+
// KeyboardEvent.key is one character). `g`-prefixed mnemonics scale, are the
|
|
765
|
+
// GitHub/Linear convention, and their second keys are all distinct (no clash
|
|
766
|
+
// with the scoped `g g` table shortcut). The letters are fixed regardless of
|
|
767
|
+
// position, so reordering the rail never changes a shortcut.
|
|
768
|
+
const VIEW_SHORTCUT_KEYS: Record<ExtendedMainView, string> = {
|
|
769
|
+
home: 'g h', resources: 'g r', issues: 'g i', topology: 'g t',
|
|
770
|
+
applications: 'g a', timeline: 'g l', traffic: 'g f', helm: 'g m',
|
|
771
|
+
gitops: 'g o', checks: 'g u', cost: 'g c',
|
|
772
|
+
// Non-rail views (reachable via deep links / actions, not the rail) get no
|
|
773
|
+
// dedicated mnemonic — listed for exhaustiveness so the type stays total.
|
|
774
|
+
workload: '', compare: '', helmCompare: '',
|
|
775
|
+
}
|
|
776
|
+
const views = Object.keys(VIEW_SHORTCUT_KEYS).filter(
|
|
777
|
+
(v): v is ExtendedMainView => VIEW_SHORTCUT_KEYS[v as ExtendedMainView] !== '',
|
|
778
|
+
)
|
|
370
779
|
useRegisterShortcuts([
|
|
371
|
-
...views.map((view
|
|
780
|
+
...views.map((view) => ({
|
|
372
781
|
id: `view-${view}`,
|
|
373
|
-
keys:
|
|
782
|
+
keys: VIEW_SHORTCUT_KEYS[view],
|
|
374
783
|
description: `Go to ${view.charAt(0).toUpperCase() + view.slice(1)}`,
|
|
375
784
|
category: 'Navigation' as const,
|
|
376
785
|
scope: 'global' as const,
|
|
377
786
|
handler: () => setMainView(view),
|
|
378
787
|
})),
|
|
788
|
+
{
|
|
789
|
+
id: 'switch-namespace',
|
|
790
|
+
keys: 'n',
|
|
791
|
+
description: 'Switch namespace',
|
|
792
|
+
category: 'Navigation' as const,
|
|
793
|
+
scope: 'global' as const,
|
|
794
|
+
handler: () => namespaceSwitcherRef.current?.open(),
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
id: 'switch-context',
|
|
798
|
+
keys: 'c',
|
|
799
|
+
description: 'Switch context',
|
|
800
|
+
category: 'Navigation' as const,
|
|
801
|
+
scope: 'global' as const,
|
|
802
|
+
handler: () => contextSwitcherRef.current?.open(),
|
|
803
|
+
},
|
|
379
804
|
{
|
|
380
805
|
id: 'theme-toggle',
|
|
381
806
|
keys: 't',
|
|
@@ -390,16 +815,23 @@ function AppInner() {
|
|
|
390
815
|
description: 'Show keyboard shortcuts',
|
|
391
816
|
category: 'General' as const,
|
|
392
817
|
scope: 'global' as const,
|
|
818
|
+
// Radar owns the shortcut registry even in a chromeless embed, so its `?`
|
|
819
|
+
// overlay is the one that actually lists the working shortcuts. The host
|
|
820
|
+
// (Radar Hub) drives it from its own chrome by dispatching a `?` keydown —
|
|
821
|
+
// it has no registry of its own to populate a competing overlay with.
|
|
393
822
|
handler: () => setShowHelp(prev => !prev),
|
|
394
823
|
},
|
|
395
824
|
{
|
|
396
825
|
id: 'command-palette',
|
|
397
826
|
keys: 'Cmd+k',
|
|
398
|
-
description: '
|
|
827
|
+
description: 'Search resources & commands',
|
|
399
828
|
category: 'General' as const,
|
|
400
829
|
scope: 'global' as const,
|
|
401
830
|
allowInInputs: true,
|
|
402
|
-
|
|
831
|
+
// Standalone focuses the top-center omnibar; embedded opens the modal. In
|
|
832
|
+
// a chromeless embed the HOST owns ⌘K (its own omnibar), so do nothing —
|
|
833
|
+
// otherwise both the host omnibar and Radar's palette fire on one ⌘K.
|
|
834
|
+
handler: () => { if (showNavRail) omnibarRef.current?.focus(); else if (!chromeless) setShowCommandPalette(true) },
|
|
403
835
|
},
|
|
404
836
|
{
|
|
405
837
|
id: 'diagnostics',
|
|
@@ -410,6 +842,20 @@ function AppInner() {
|
|
|
410
842
|
allowInInputs: true,
|
|
411
843
|
handler: () => setShowDiagnostics(prev => !prev),
|
|
412
844
|
},
|
|
845
|
+
// Settings exposes local-binary controls that don't apply to embedded hosts.
|
|
846
|
+
// Register the shortcut only when standalone (matching the gear button) —
|
|
847
|
+
// `enabled: false` would still list it in the `?` help overlay, which shows
|
|
848
|
+
// all registered shortcuts regardless of enabled state.
|
|
849
|
+
...(showNavRail
|
|
850
|
+
? [{
|
|
851
|
+
id: 'open-settings',
|
|
852
|
+
keys: 'g s',
|
|
853
|
+
description: 'Open settings',
|
|
854
|
+
category: 'General' as const,
|
|
855
|
+
scope: 'global' as const,
|
|
856
|
+
handler: () => setShowSettings(true),
|
|
857
|
+
}]
|
|
858
|
+
: []),
|
|
413
859
|
])
|
|
414
860
|
|
|
415
861
|
// Separate registration for help-close — its `enabled` changes with showHelp,
|
|
@@ -446,7 +892,12 @@ function AppInner() {
|
|
|
446
892
|
const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
|
|
447
893
|
|
|
448
894
|
// Fetch available namespaces
|
|
449
|
-
const { data: availableNamespaces
|
|
895
|
+
const { data: availableNamespaces } = useNamespaces()
|
|
896
|
+
|
|
897
|
+
// Per-user view filter served by the backend. Loaded eagerly so the
|
|
898
|
+
// picker can render its current state without showing the multi-select
|
|
899
|
+
// fallback during the initial scope fetch.
|
|
900
|
+
const { data: namespaceScope } = useNamespaceScope()
|
|
450
901
|
|
|
451
902
|
// Context switch state
|
|
452
903
|
const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
|
|
@@ -454,56 +905,143 @@ function AppInner() {
|
|
|
454
905
|
// Connection state (for graceful startup)
|
|
455
906
|
const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
|
|
456
907
|
|
|
908
|
+
// The app's content surface is ready to show: auth resolved, not mid context-
|
|
909
|
+
// switch, and the cluster connection is live. The main content area gates on
|
|
910
|
+
// exactly this, and so do the overlay drawers — otherwise a deep-link/refresh
|
|
911
|
+
// with `?resource=`/`?release=` renders the drawer on top of the connecting/
|
|
912
|
+
// switching splash, pushing the centered loading logo off-center and showing an
|
|
913
|
+
// empty drawer over a not-yet-loaded view. Gating both on the SAME readiness so
|
|
914
|
+
// a drawer only ever sits over a real content surface.
|
|
915
|
+
const contentReady = !isSwitching && !authMePending &&
|
|
916
|
+
!(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected'
|
|
917
|
+
|
|
918
|
+
const { clusterLoadState, showHomeClusterLoadFallback, clusterLoadInitial } = useClusterLoadState({
|
|
919
|
+
namespaces,
|
|
920
|
+
mainView,
|
|
921
|
+
chromeless,
|
|
922
|
+
contentReady,
|
|
923
|
+
onClusterLoadStateChange,
|
|
924
|
+
})
|
|
925
|
+
// Suppress the topbar warmup label during the initial dashboard fetch only on
|
|
926
|
+
// Home, where the center "Loading dashboard…" splash already covers it. Off
|
|
927
|
+
// Home there's no splash, so keep the label as the only text cue.
|
|
928
|
+
const showClusterWarmupLabel = clusterLoadState.loading && !(clusterLoadInitial && mainView === 'home')
|
|
929
|
+
|
|
457
930
|
// Query client for cache invalidation
|
|
458
931
|
const queryClient = useQueryClient()
|
|
459
932
|
|
|
460
|
-
// SSE-driven cache invalidation
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
933
|
+
// SSE-driven cache invalidation, split into two cadences so constant status
|
|
934
|
+
// churn on large clusters doesn't force the *expensive* queries (big resource
|
|
935
|
+
// lists + dashboard) to refetch every 3s. The core distinction: add/delete
|
|
936
|
+
// changes what rows/counts exist (membership — keep fast); update is mostly
|
|
937
|
+
// status/restart/health noise that can fire constantly on a 10k-pod cluster
|
|
938
|
+
// and shouldn't drag a giant list onto a 3s cadence.
|
|
939
|
+
//
|
|
940
|
+
// FAST (3s): detail drawer for any change (one cheap mounted object), and
|
|
941
|
+
// on add/delete: the list, counts, and dashboard. GitOps + cert keep
|
|
942
|
+
// their existing every-batch behavior — Phase 2 makes GitOps relevance-aware.
|
|
943
|
+
// SLOW (15s): list + dashboard for kinds with update churn. A kind that also
|
|
944
|
+
// had an add/delete in the window gets refreshed by both tiers (an extra
|
|
945
|
+
// refetch per 15s at most) — that's fine and avoids a stale-list bug:
|
|
946
|
+
// deduping by "was structural this window" would wrongly suppress an
|
|
947
|
+
// update that arrived *after* the fast structural flush already ran.
|
|
948
|
+
const fastInvalidationRef = useRef<{
|
|
949
|
+
changedKinds: Set<string> // every changed kind (any op) → detail drawer
|
|
950
|
+
structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
|
|
951
|
+
secretsChanged: boolean
|
|
952
|
+
timer: number | null
|
|
953
|
+
}>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
|
|
954
|
+
const slowInvalidationRef = useRef<{
|
|
955
|
+
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
467
956
|
timer: number | null
|
|
468
|
-
}>({
|
|
957
|
+
}>({ updatedKinds: new Set(), timer: null })
|
|
958
|
+
const timelineInvalidationRef = useRef<{ timer: number | null }>({ timer: null })
|
|
469
959
|
|
|
470
960
|
const handleK8sEvent = useCallback((event: K8sEvent) => {
|
|
961
|
+
// The timeline consumes every frame — including the K8s Event kind the
|
|
962
|
+
// resource tiers skip below (warnings like BackOff are timeline content).
|
|
963
|
+
// Its own trailing throttle keeps the live view fresh within seconds
|
|
964
|
+
// while batching bursts into one refetch; the 60s poll on useChanges
|
|
965
|
+
// remains the no-SSE fallback.
|
|
966
|
+
const tl = timelineInvalidationRef.current
|
|
967
|
+
if (tl.timer === null) {
|
|
968
|
+
tl.timer = window.setTimeout(() => {
|
|
969
|
+
queryClient.invalidateQueries({ queryKey: ['changes'] })
|
|
970
|
+
timelineInvalidationRef.current = { timer: null }
|
|
971
|
+
}, 5000)
|
|
972
|
+
}
|
|
973
|
+
|
|
471
974
|
// Skip K8s Event kind — informational, not resource mutations
|
|
472
975
|
if (event.kind === 'Event') return
|
|
473
976
|
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
977
|
+
const kind = kindToPlural(event.kind)
|
|
978
|
+
const structural = event.operation === 'add' || event.operation === 'delete'
|
|
979
|
+
|
|
980
|
+
const fast = fastInvalidationRef.current
|
|
981
|
+
fast.changedKinds.add(kind)
|
|
982
|
+
if (structural) fast.structuralKinds.add(kind)
|
|
983
|
+
if (kind === 'secrets') fast.secretsChanged = true
|
|
984
|
+
|
|
985
|
+
const slow = slowInvalidationRef.current
|
|
986
|
+
if (!structural) slow.updatedKinds.add(kind)
|
|
987
|
+
|
|
988
|
+
// FAST tier — membership-sensitive + cheap, bounded 3s latency.
|
|
989
|
+
if (fast.timer === null) {
|
|
990
|
+
fast.timer = window.setTimeout(() => {
|
|
991
|
+
const f = fastInvalidationRef.current
|
|
992
|
+
for (const k of f.changedKinds) {
|
|
993
|
+
queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
|
|
994
|
+
}
|
|
995
|
+
for (const k of f.structuralKinds) {
|
|
996
|
+
queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
|
|
997
|
+
}
|
|
998
|
+
if (f.structuralKinds.size > 0) {
|
|
999
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
1000
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
1001
|
+
}
|
|
1002
|
+
if (f.secretsChanged) {
|
|
1003
|
+
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
1004
|
+
}
|
|
1005
|
+
// GitOps behavior unchanged from before — refreshes every batch when a
|
|
1006
|
+
// GitOps view is mounted (Phase 2 will make this relevance-aware).
|
|
1007
|
+
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
1008
|
+
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
1009
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1010
|
+
}, 3000)
|
|
478
1011
|
}
|
|
479
1012
|
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
if (pending.kinds.has('secrets')) {
|
|
493
|
-
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
494
|
-
}
|
|
495
|
-
// Reset accumulator
|
|
496
|
-
pending.kinds = new Set()
|
|
497
|
-
pending.hasCountChange = false
|
|
498
|
-
pending.timer = null
|
|
499
|
-
}, 3000)
|
|
1013
|
+
// SLOW tier — throttle the expensive queries for status-only churn. Only
|
|
1014
|
+
// updates schedule it; structural changes are fully handled by the fast tier.
|
|
1015
|
+
if (!structural && slow.timer === null) {
|
|
1016
|
+
slow.timer = window.setTimeout(() => {
|
|
1017
|
+
const s = slowInvalidationRef.current
|
|
1018
|
+
for (const k of s.updatedKinds) {
|
|
1019
|
+
queryClient.invalidateQueries({ queryKey: ['resources', k] })
|
|
1020
|
+
}
|
|
1021
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) // health reflects status updates
|
|
1022
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1023
|
+
}, 15000)
|
|
1024
|
+
}
|
|
500
1025
|
}, [queryClient])
|
|
501
1026
|
|
|
1027
|
+
// Clear pending invalidation timers on unmount. Reset the refs (not just
|
|
1028
|
+
// clearTimeout) so a same-instance remount doesn't inherit a non-null timer
|
|
1029
|
+
// id — handleK8sEvent only schedules when timer === null, so a stale id would
|
|
1030
|
+
// silently wedge all further SSE-driven invalidation.
|
|
1031
|
+
useEffect(() => () => {
|
|
1032
|
+
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1033
|
+
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1034
|
+
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1035
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1036
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1037
|
+
timelineInvalidationRef.current = { timer: null }
|
|
1038
|
+
}, [])
|
|
1039
|
+
|
|
502
1040
|
// SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
|
|
503
1041
|
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
504
1042
|
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
505
1043
|
const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
|
|
506
|
-
const { topology, connected, reconnect:
|
|
1044
|
+
const { topology, connected: eventStreamConnected, connecting: eventStreamConnecting, reconnect: reconnectEventStream } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
|
|
507
1045
|
onContextSwitchComplete: endSwitch,
|
|
508
1046
|
onContextSwitchProgress: updateProgress,
|
|
509
1047
|
onContextChanged: () => {
|
|
@@ -514,14 +1052,16 @@ function AppInner() {
|
|
|
514
1052
|
queryClient.invalidateQueries()
|
|
515
1053
|
|
|
516
1054
|
// Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
}
|
|
1055
|
+
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
1056
|
+
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
1057
|
+
if (timelineInvalidationRef.current.timer !== null) clearTimeout(timelineInvalidationRef.current.timer)
|
|
1058
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
1059
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
1060
|
+
timelineInvalidationRef.current = { timer: null }
|
|
521
1061
|
|
|
522
1062
|
// Close any open drawers/overlays — old cluster's resources don't exist on the new one
|
|
1063
|
+
// (?full=1 is cleared by the URL reset below).
|
|
523
1064
|
setSelectedResource(null)
|
|
524
|
-
setDrawerExpanded(false)
|
|
525
1065
|
setSelectedHelmRelease(null)
|
|
526
1066
|
|
|
527
1067
|
// Reset URL to current view with no resource-specific params.
|
|
@@ -540,7 +1080,25 @@ function AppInner() {
|
|
|
540
1080
|
},
|
|
541
1081
|
onK8sEvent: handleK8sEvent,
|
|
542
1082
|
}, forceNamespaceFilter, showPolicyEffect)
|
|
543
|
-
|
|
1083
|
+
// On large clusters (where the server requires namespace filtering), keep
|
|
1084
|
+
// SSE's server-side filter in lockstep with the user's namespace pick.
|
|
1085
|
+
// Without this, header switches and deep-link loads can leave SSE filtered
|
|
1086
|
+
// to a stale namespace while sidebar/topology show a different one. Small
|
|
1087
|
+
// clusters never set forceNamespaceFilter and skip this path entirely.
|
|
1088
|
+
useEffect(() => {
|
|
1089
|
+
const isLarge = forceNamespaceFilter !== undefined || topology?.requiresNamespaceFilter === true
|
|
1090
|
+
if (!isLarge) return
|
|
1091
|
+
if (namespaces.length === 0) {
|
|
1092
|
+
setForceNamespaceFilter(prev => (prev === undefined ? prev : undefined))
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
setForceNamespaceFilter(prev => {
|
|
1096
|
+
const cur = prev ? [...prev].sort() : []
|
|
1097
|
+
const next = [...namespaces].sort()
|
|
1098
|
+
if (cur.length === next.length && cur.every((ns, i) => ns === next[i])) return prev
|
|
1099
|
+
return [...namespaces]
|
|
1100
|
+
})
|
|
1101
|
+
}, [namespaces, forceNamespaceFilter, topology?.requiresNamespaceFilter])
|
|
544
1102
|
|
|
545
1103
|
// Apply live topology updates only when not paused. While paused, buffer the
|
|
546
1104
|
// latest snapshot so we can apply it instantly when the user resumes.
|
|
@@ -566,6 +1124,28 @@ function AppInner() {
|
|
|
566
1124
|
// Track CRD discovery status from topology (more direct than cluster-info)
|
|
567
1125
|
// When discovery completes, topology will auto-update via SSE with new CRD nodes
|
|
568
1126
|
const crdDiscoveryStatus = topology?.crdDiscoveryStatus
|
|
1127
|
+
const clusterConnectionState = connection.state
|
|
1128
|
+
const clusterConnected = clusterConnectionState === 'connected'
|
|
1129
|
+
const liveUpdatesDisconnected = clusterConnected && !eventStreamConnected && !eventStreamConnecting
|
|
1130
|
+
const headerConnectionLabel =
|
|
1131
|
+
clusterConnectionState === 'disconnected' ? 'Disconnected' :
|
|
1132
|
+
clusterConnectionState === 'connecting' ? 'Connecting' :
|
|
1133
|
+
liveUpdatesDisconnected ? 'Live updates disconnected' :
|
|
1134
|
+
clusterLoadState.loading ? `Connected — ${clusterLoadState.message}` :
|
|
1135
|
+
crdDiscoveryStatus === 'discovering' ? 'Connected — discovering Custom Resources...' :
|
|
1136
|
+
'Connected'
|
|
1137
|
+
const headerConnectionDisplayLabel =
|
|
1138
|
+
clusterConnectionState === 'disconnected' ? 'Disconnected' :
|
|
1139
|
+
clusterConnectionState === 'connecting' ? 'Connecting' :
|
|
1140
|
+
liveUpdatesDisconnected ? 'Live updates disconnected' :
|
|
1141
|
+
showClusterWarmupLabel ? clusterLoadState.message :
|
|
1142
|
+
crdDiscoveryStatus === 'discovering' ? 'Discovering Custom Resources…' :
|
|
1143
|
+
''
|
|
1144
|
+
const showHeaderReconnect =
|
|
1145
|
+
clusterConnectionState === 'disconnected' ||
|
|
1146
|
+
liveUpdatesDisconnected
|
|
1147
|
+
const headerReconnect = clusterConnectionState === 'disconnected' ? retryConnection : reconnectEventStream
|
|
1148
|
+
const headerReconnectPending = clusterConnectionState === 'disconnected' ? isRetrying : false
|
|
569
1149
|
|
|
570
1150
|
// Debug: log discovery status changes
|
|
571
1151
|
useEffect(() => {
|
|
@@ -607,20 +1187,129 @@ function AppInner() {
|
|
|
607
1187
|
// TODO: Could show a list of pods in the group
|
|
608
1188
|
if (node.kind === 'PodGroup') return
|
|
609
1189
|
|
|
1190
|
+
const namespace = (node.data.namespace as string) || ''
|
|
1191
|
+
// GitOps CRs (Application/Kustomization/HelmRelease/etc.) have a dedicated
|
|
1192
|
+
// detail page with tree + insights + ops that the drawer can't reproduce.
|
|
1193
|
+
// Route there from the main topology when the node is one of those kinds;
|
|
1194
|
+
// everything else falls back to the drawer.
|
|
1195
|
+
const gitOpsPath = gitOpsRouteForKind(node.kind, namespace, node.name)
|
|
1196
|
+
if (gitOpsPath) {
|
|
1197
|
+
navigate(gitOpsPath)
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
|
|
610
1201
|
navigateToResource({
|
|
611
1202
|
kind: kindToPlural(node.kind),
|
|
612
|
-
namespace
|
|
1203
|
+
namespace,
|
|
613
1204
|
name: node.name,
|
|
1205
|
+
group: apiVersionToGroup(node.data.apiVersion as string | undefined),
|
|
614
1206
|
})
|
|
615
|
-
}, [])
|
|
1207
|
+
}, [navigate, navigateToResource])
|
|
616
1208
|
|
|
617
1209
|
// Serialize namespaces for stable dependency tracking
|
|
618
1210
|
const namespacesKey = namespaces.join(',')
|
|
619
1211
|
|
|
1212
|
+
// The server is canonical for the per-user namespace pick. Mirror its
|
|
1213
|
+
// `actives` into App.tsx state so consumer hooks (SSE, dashboard, resource
|
|
1214
|
+
// lists) stay in lockstep with the picker. The dedicated URL-write effect
|
|
1215
|
+
// below propagates the mirrored state to `?namespaces=`.
|
|
1216
|
+
const setActiveNamespace = useSetActiveNamespace()
|
|
1217
|
+
// Defer the state flip to onSuccess. Setting namespaces to [] before the
|
|
1218
|
+
// server-side pref has actually been cleared makes React Query refetch
|
|
1219
|
+
// under the new empty key while the server still returns the previous
|
|
1220
|
+
// pick's scope, caching stale data under the new key with no later
|
|
1221
|
+
// invalidation. onSettled would do the same on errors, leaving the UI
|
|
1222
|
+
// showing "All namespaces" while data is still namespace-scoped — onSuccess
|
|
1223
|
+
// keeps state aligned with the server.
|
|
1224
|
+
//
|
|
1225
|
+
// Don't touch the URL here either: setSearchParams on a still-set state
|
|
1226
|
+
// trips the URL→state sync into firing setNamespaces([]) and a duplicate
|
|
1227
|
+
// mutation immediately, which re-introduces the same race. The state→URL
|
|
1228
|
+
// effect propagates state=[] → URL on its own after onSuccess flips state.
|
|
1229
|
+
const clearAllNamespaces = useCallback(() => {
|
|
1230
|
+
if (namespaceScope?.cacheScoped) return
|
|
1231
|
+
if (namespaces.length === 0) return
|
|
1232
|
+
setActiveNamespace.mutate(
|
|
1233
|
+
{ namespaces: [] },
|
|
1234
|
+
{ onSuccess: () => setNamespaces([]) },
|
|
1235
|
+
)
|
|
1236
|
+
}, [namespaceScope?.cacheScoped, namespaces.length, setActiveNamespace])
|
|
1237
|
+
const initialBookmarkReconciledRef = useRef(false)
|
|
1238
|
+
const scopeActives = useMemo(() => namespaceScope?.actives ?? [], [namespaceScope?.actives])
|
|
1239
|
+
const namespaceScopeKey = useMemo(() => namespaceScope ? [...scopeActives].sort().join(',') : null, [namespaceScope, scopeActives])
|
|
1240
|
+
useEffect(() => {
|
|
1241
|
+
if (!namespaceScope) return
|
|
1242
|
+
const sortedScope = [...scopeActives].sort()
|
|
1243
|
+
const sortedState = [...namespaces].sort()
|
|
1244
|
+
const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
|
|
1245
|
+
debugNamespaceLog('app:scope-mirror', {
|
|
1246
|
+
scopeActives,
|
|
1247
|
+
stateNamespaces: namespaces,
|
|
1248
|
+
sameAsState,
|
|
1249
|
+
initialBookmarkReconciled: initialBookmarkReconciledRef.current,
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
// First-load bookmark reconciliation: if the URL had namespaces that
|
|
1253
|
+
// differ from the server pick when the scope first arrives, push the
|
|
1254
|
+
// URL choice to the server so shared/bookmarked deep links keep
|
|
1255
|
+
// working. The ref flips on the first scope load regardless of whether
|
|
1256
|
+
// the URL had namespaces — subsequent runs mirror server → state.
|
|
1257
|
+
if (!initialBookmarkReconciledRef.current) {
|
|
1258
|
+
initialBookmarkReconciledRef.current = true
|
|
1259
|
+
if (!sameAsState && sortedState.length > 0) {
|
|
1260
|
+
if (namespaceScope.cacheScoped && (!namespaceScope.namespaceRescope || sortedState.length !== 1)) {
|
|
1261
|
+
debugNamespaceLog('app:scope-mirror-cache-scope-preserve', {
|
|
1262
|
+
stateNamespaces: sortedState,
|
|
1263
|
+
scopeActives: sortedScope,
|
|
1264
|
+
})
|
|
1265
|
+
setNamespaces(scopeActives)
|
|
1266
|
+
return
|
|
1267
|
+
}
|
|
1268
|
+
debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
|
|
1269
|
+
stateNamespaces: sortedState,
|
|
1270
|
+
scopeActives: sortedScope,
|
|
1271
|
+
})
|
|
1272
|
+
setActiveNamespace.mutate({ namespaces: sortedState })
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (!sameAsState) {
|
|
1278
|
+
debugNamespaceLog('app:scope-mirror-set-namespaces', { nextNamespaces: scopeActives })
|
|
1279
|
+
setNamespaces(scopeActives)
|
|
1280
|
+
}
|
|
1281
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
|
|
1282
|
+
}, [namespaceScope, namespaceScopeKey])
|
|
1283
|
+
|
|
1284
|
+
// Arm the skip on every history Pop (location.key changes per nav), then
|
|
1285
|
+
// clear it on the next macrotask. The revert/oscillation is a synchronous
|
|
1286
|
+
// re-render burst, so a macrotask-deferred clear covers it; clearing
|
|
1287
|
+
// afterward means a stale arm can't survive into an unrelated later write
|
|
1288
|
+
// (e.g. a Pop that changes none of the write effect's deps would otherwise
|
|
1289
|
+
// leave the flag set and silently drop the next user-driven URL write).
|
|
1290
|
+
useEffect(() => {
|
|
1291
|
+
if (navigationType !== NavigationType.Pop) {
|
|
1292
|
+
// Any non-Pop navigation clears the guard. Without this, a Push/Replace
|
|
1293
|
+
// that lands before the macrotask fires would run this cleanup (cancelling
|
|
1294
|
+
// the timeout) and re-run as a no-op, leaving the flag stuck true and
|
|
1295
|
+
// silently suppressing all later URL writes.
|
|
1296
|
+
skipUrlWriteAfterPopRef.current = false
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
skipUrlWriteAfterPopRef.current = true
|
|
1300
|
+
const id = setTimeout(() => { skipUrlWriteAfterPopRef.current = false }, 0)
|
|
1301
|
+
return () => clearTimeout(id)
|
|
1302
|
+
}, [location.key, navigationType])
|
|
1303
|
+
|
|
620
1304
|
// Update URL query params when state changes (path is handled by setMainView)
|
|
621
1305
|
// Read from window.location.search (not React Router's searchParams) to preserve
|
|
622
1306
|
// params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
|
|
623
1307
|
useEffect(() => {
|
|
1308
|
+
// Don't write (and revert) the URL while state is still catching up to a
|
|
1309
|
+
// Pop — the read effect below owns syncing state from the popped URL. The
|
|
1310
|
+
// flag auto-clears on the next macrotask, so this never blocks a later
|
|
1311
|
+
// user-driven write.
|
|
1312
|
+
if (skipUrlWriteAfterPopRef.current) return
|
|
624
1313
|
const currentSearch = window.location.search
|
|
625
1314
|
const params = new URLSearchParams(currentSearch)
|
|
626
1315
|
|
|
@@ -652,29 +1341,91 @@ function AppInner() {
|
|
|
652
1341
|
|
|
653
1342
|
// Only update if params actually changed vs current URL
|
|
654
1343
|
if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
|
|
1344
|
+
debugNamespaceLog('app:url-write', {
|
|
1345
|
+
namespaces,
|
|
1346
|
+
currentSearch,
|
|
1347
|
+
nextSearch: params.toString(),
|
|
1348
|
+
mainView,
|
|
1349
|
+
})
|
|
655
1350
|
setSearchParams(params, { replace: true })
|
|
656
1351
|
}
|
|
657
1352
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
|
|
658
1353
|
}, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
|
|
659
1354
|
|
|
660
|
-
// Sync
|
|
1355
|
+
// Sync namespace + helm picks from the query string only when the query
|
|
1356
|
+
// string changes. If this also ran on pathname / mainView changes, a view
|
|
1357
|
+
// whose URL omits ?namespaces= would clear App state and POST [] to the
|
|
1358
|
+
// server while the per-user pick was still narrowed — the picker would
|
|
1359
|
+
// show the server scope but lists/dashboard would stay on "all namespaces".
|
|
661
1360
|
useEffect(() => {
|
|
662
1361
|
const urlNamespaces = parseNamespacesFromURL(searchParams)
|
|
1362
|
+
debugNamespaceLog('app:url-sync', {
|
|
1363
|
+
search: searchParams.toString(),
|
|
1364
|
+
urlNamespaces,
|
|
1365
|
+
stateNamespaces: namespaces,
|
|
1366
|
+
namespacesKey,
|
|
1367
|
+
})
|
|
663
1368
|
|
|
664
|
-
if (urlNamespaces.join(',') !== namespacesKey)
|
|
1369
|
+
if (urlNamespaces.join(',') !== namespacesKey) {
|
|
1370
|
+
if (namespaceScope?.cacheScoped && (!namespaceScope.namespaceRescope || urlNamespaces.length !== 1)) {
|
|
1371
|
+
const scopedNamespaces = namespaceScope.actives ?? []
|
|
1372
|
+
debugNamespaceLog('app:url-sync-cache-scope-preserve', { scopedNamespaces })
|
|
1373
|
+
setNamespaces(scopedNamespaces)
|
|
1374
|
+
return
|
|
1375
|
+
}
|
|
1376
|
+
debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
|
|
1377
|
+
setNamespaces(urlNamespaces)
|
|
1378
|
+
if (namespaceScope) {
|
|
1379
|
+
const sortedURL = [...urlNamespaces].sort()
|
|
1380
|
+
const sortedScope = [...(namespaceScope.actives ?? [])].sort()
|
|
1381
|
+
const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
|
|
1382
|
+
if (!same) {
|
|
1383
|
+
debugNamespaceLog('app:url-sync-mutate-server', {
|
|
1384
|
+
urlNamespaces,
|
|
1385
|
+
scopeActives: namespaceScope.actives ?? [],
|
|
1386
|
+
})
|
|
1387
|
+
setActiveNamespace.mutate({ namespaces: urlNamespaces })
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
665
1391
|
|
|
666
|
-
// Restore helm release from URL (back navigation)
|
|
667
1392
|
const releaseParam = searchParams.get('release')
|
|
668
1393
|
if (releaseParam) {
|
|
669
1394
|
const slashIdx = releaseParam.indexOf('/')
|
|
670
1395
|
if (slashIdx > 0) {
|
|
671
1396
|
const ns = releaseParam.slice(0, slashIdx)
|
|
672
1397
|
const name = releaseParam.slice(slashIdx + 1)
|
|
673
|
-
setSelectedHelmRelease({ namespace: ns, name })
|
|
1398
|
+
setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
|
|
674
1399
|
}
|
|
675
1400
|
}
|
|
1401
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- run only when searchParams change; namespacesKey/namespaceScope are read for that transition
|
|
676
1402
|
}, [searchParams])
|
|
677
1403
|
|
|
1404
|
+
useEffect(() => {
|
|
1405
|
+
if (navigationType !== NavigationType.Pop || mainView !== 'resources') return
|
|
1406
|
+
const kindFromPath = location.pathname.match(/^\/resources\/([^/]+)/)?.[1] ?? ''
|
|
1407
|
+
const resourceParam = searchParams.get('resource')
|
|
1408
|
+
if (kindFromPath && resourceParam) {
|
|
1409
|
+
const slashIdx = resourceParam.indexOf('/')
|
|
1410
|
+
const ns = slashIdx > 0 ? resourceParam.slice(0, slashIdx) : ''
|
|
1411
|
+
const name = slashIdx > 0 ? resourceParam.slice(slashIdx + 1) : resourceParam
|
|
1412
|
+
const apiGroup = searchParams.get('apiGroup') ?? ''
|
|
1413
|
+
const next: SelectedResource = { kind: kindFromPath, namespace: ns, name, group: apiGroup }
|
|
1414
|
+
setSelectedResource(prev => {
|
|
1415
|
+
if (
|
|
1416
|
+
prev &&
|
|
1417
|
+
prev.kind === next.kind &&
|
|
1418
|
+
prev.namespace === next.namespace &&
|
|
1419
|
+
prev.name === next.name &&
|
|
1420
|
+
(prev.group ?? '') === (next.group ?? '')
|
|
1421
|
+
) return prev
|
|
1422
|
+
return next
|
|
1423
|
+
})
|
|
1424
|
+
} else if (kindFromPath && !resourceParam) {
|
|
1425
|
+
setSelectedResource(prev => (prev === null ? prev : null))
|
|
1426
|
+
}
|
|
1427
|
+
}, [navigationType, mainView, location.pathname, searchParams])
|
|
1428
|
+
|
|
678
1429
|
// Auto-adjust grouping when namespaces change
|
|
679
1430
|
useEffect(() => {
|
|
680
1431
|
if (namespaces.length === 0 && groupingMode === 'none') {
|
|
@@ -684,39 +1435,44 @@ function AppInner() {
|
|
|
684
1435
|
// Switching to specific namespaces - disable namespace grouping
|
|
685
1436
|
setGroupingMode('none')
|
|
686
1437
|
}
|
|
1438
|
+
// Intentionally runs ONLY when the namespace selection changes. It reads the
|
|
1439
|
+
// current groupingMode but must not re-run when grouping changes, or it would
|
|
1440
|
+
// immediately revert a manual/fleet grouping choice. namespacesKey is the
|
|
1441
|
+
// manual dependency standing in for the namespaces array.
|
|
1442
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
687
1443
|
}, [namespacesKey])
|
|
688
1444
|
|
|
689
1445
|
// Clear resource selection when changing views or namespaces
|
|
690
1446
|
// But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
|
|
691
1447
|
const prevMainView = useRef(mainView)
|
|
692
1448
|
useEffect(() => {
|
|
693
|
-
// Skip clearing during controlled expand/collapse transitions
|
|
694
|
-
if (suppressViewClearRef.current) {
|
|
695
|
-
suppressViewClearRef.current = false
|
|
696
|
-
prevMainView.current = mainView
|
|
697
|
-
return
|
|
698
|
-
}
|
|
699
|
-
|
|
700
1449
|
const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
|
|
701
|
-
const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
|
|
702
1450
|
prevMainView.current = mainView
|
|
703
1451
|
|
|
704
|
-
//
|
|
705
|
-
|
|
1452
|
+
// The URL is the source of truth for what's selected. A deep link
|
|
1453
|
+
// (?resource=, ?release=) seeds the selection on mount; the effects that
|
|
1454
|
+
// run during that same mount must not wipe a selection the URL still
|
|
1455
|
+
// asserts. (On a real view switch the URL no longer carries the param, so
|
|
1456
|
+
// the clear proceeds.) Without this, deep-linking straight to a Helm
|
|
1457
|
+
// release lands on the release list with no drawer.
|
|
1458
|
+
// (drawerExpanded is URL-derived from ?full=1, so leaving /resources drops it
|
|
1459
|
+
// automatically — no explicit reset needed.)
|
|
1460
|
+
const params = new URLSearchParams(window.location.search)
|
|
1461
|
+
if (!navigatingToResources && !params.has('resource')) {
|
|
706
1462
|
setSelectedResource(null)
|
|
707
1463
|
}
|
|
708
|
-
|
|
709
|
-
if (!navigatingToHelm) {
|
|
1464
|
+
if (!params.has('release')) {
|
|
710
1465
|
setSelectedHelmRelease(null)
|
|
711
1466
|
}
|
|
712
|
-
setDrawerExpanded(false)
|
|
713
1467
|
}, [mainView])
|
|
714
1468
|
|
|
715
|
-
// Clear resource selection when namespaces change
|
|
1469
|
+
// Clear resource selection when namespaces change — but keep a selection the
|
|
1470
|
+
// URL still asserts (deep link, or a release/resource the user is viewing
|
|
1471
|
+
// while they adjust the namespace scope filter).
|
|
716
1472
|
useEffect(() => {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
setSelectedHelmRelease(null)
|
|
1473
|
+
const params = new URLSearchParams(window.location.search)
|
|
1474
|
+
if (!params.has('resource')) setSelectedResource(null)
|
|
1475
|
+
if (!params.has('release')) setSelectedHelmRelease(null)
|
|
720
1476
|
}, [namespacesKey])
|
|
721
1477
|
|
|
722
1478
|
// Filter topology based on visible kinds (uses displayedTopology which respects pause)
|
|
@@ -726,7 +1482,7 @@ function AppInner() {
|
|
|
726
1482
|
// Fleet mode overrides visible kinds to show only CAPI resources + Node
|
|
727
1483
|
const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
|
|
728
1484
|
|
|
729
|
-
// Filter by namespace (
|
|
1485
|
+
// Filter by namespace (client-side) and by visible kinds
|
|
730
1486
|
const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
|
|
731
1487
|
const filteredNodes = displayedTopology.nodes.filter(node =>
|
|
732
1488
|
effectiveKinds.has(node.kind) &&
|
|
@@ -749,11 +1505,50 @@ function AppInner() {
|
|
|
749
1505
|
})
|
|
750
1506
|
|
|
751
1507
|
return {
|
|
1508
|
+
...displayedTopology,
|
|
752
1509
|
nodes: filteredNodes,
|
|
753
1510
|
edges: filteredEdges,
|
|
754
1511
|
}
|
|
755
1512
|
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
756
1513
|
|
|
1514
|
+
// Cluster Audit findings, joined onto topology nodes by the audit key the
|
|
1515
|
+
// backend stamps on each node (data.auditKey). The graph surfaces DANGER only
|
|
1516
|
+
// (warnings would turn a dense graph into a heatmap); the node component reads
|
|
1517
|
+
// data.auditDanger. Re-runs only when findings change, and copies nodes only
|
|
1518
|
+
// when there are findings to attach — no overhead on clusters with none.
|
|
1519
|
+
const audit = useAudit(namespaces)
|
|
1520
|
+
const auditSeverityMap = useMemo(
|
|
1521
|
+
() => buildAuditSeverityMap(audit.data?.findings, audit.data?.checks),
|
|
1522
|
+
[audit.data?.findings, audit.data?.checks],
|
|
1523
|
+
)
|
|
1524
|
+
const topologyWithAudit = useMemo((): Topology | null => {
|
|
1525
|
+
if (!filteredTopology) return null
|
|
1526
|
+
if (auditSeverityMap.size === 0) return filteredTopology
|
|
1527
|
+
return {
|
|
1528
|
+
...filteredTopology,
|
|
1529
|
+
nodes: filteredTopology.nodes.map(node => {
|
|
1530
|
+
const counts = auditSeverityMap.get(node.data.auditKey as string)
|
|
1531
|
+
if (!counts) return node
|
|
1532
|
+
return { ...node, data: { ...node.data, auditDanger: counts.danger, auditWarning: counts.warning, auditMessages: counts.messages } }
|
|
1533
|
+
}),
|
|
1534
|
+
}
|
|
1535
|
+
}, [filteredTopology, auditSeverityMap])
|
|
1536
|
+
|
|
1537
|
+
// The graph node id of the currently open resource, used to highlight it on
|
|
1538
|
+
// the canvas. Looked up from the topology (not reconstructed) because node
|
|
1539
|
+
// ids are `<lowercaseKind>/<ns>/<name>` with special prefixes for CRD
|
|
1540
|
+
// collisions — rebuilding the string can't match those reliably.
|
|
1541
|
+
const selectedNodeId = useMemo(() => {
|
|
1542
|
+
if (!selectedResource) return undefined
|
|
1543
|
+
const ns = selectedResource.namespace || ''
|
|
1544
|
+
const match = topology?.nodes.find(n =>
|
|
1545
|
+
((n.data.namespace as string) || '') === ns &&
|
|
1546
|
+
n.name === selectedResource.name &&
|
|
1547
|
+
(kindToPlural(n.kind) === selectedResource.kind || n.kind === selectedResource.kind)
|
|
1548
|
+
)
|
|
1549
|
+
return match?.id
|
|
1550
|
+
}, [selectedResource, topology])
|
|
1551
|
+
|
|
757
1552
|
// Filter handlers
|
|
758
1553
|
const handleToggleKind = useCallback((kind: NodeKind) => {
|
|
759
1554
|
setVisibleKinds(prev => {
|
|
@@ -782,60 +1577,122 @@ function AppInner() {
|
|
|
782
1577
|
setVisibleKinds(new Set())
|
|
783
1578
|
}, [])
|
|
784
1579
|
|
|
1580
|
+
const navActiveView = mainView === 'helmCompare' ? 'helm' : mainView
|
|
1581
|
+
|
|
785
1582
|
return (
|
|
786
1583
|
<PortForwardProvider>
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1584
|
+
{/* Preserve the ~800px content floor: the rail is a fixed-width sibling, so
|
|
1585
|
+
the outer minimum must include it (176px pinned / 56px collapsed) or the
|
|
1586
|
+
content column (min-w-0, shrinkable) would fall below the old desktop
|
|
1587
|
+
floor at small windows. Embedded mode has no rail → plain 800. */}
|
|
1588
|
+
<div
|
|
1589
|
+
className={`relative flex bg-theme-base ${navCustomization.embedded ? 'h-full min-h-0' : 'h-screen'}`}
|
|
1590
|
+
style={{ minWidth: 800 + (showNavRail ? (navRailEffectivePinned ? 176 : 56) : 0) }}
|
|
1591
|
+
>
|
|
1592
|
+
{showNavRail && (
|
|
1593
|
+
<PrimaryNavRail
|
|
1594
|
+
activeView={navActiveView}
|
|
1595
|
+
onNavigate={setMainView}
|
|
1596
|
+
pinned={navRailEffectivePinned}
|
|
1597
|
+
onTogglePinned={toggleNavRailPinned}
|
|
1598
|
+
showPinToggle={!railForcedSlim}
|
|
1599
|
+
onOpenSettings={() => setShowSettings(true)}
|
|
1600
|
+
accountSlot={<UserMenu variant="rail" pinned={navRailEffectivePinned} />}
|
|
1601
|
+
/>
|
|
1602
|
+
)}
|
|
1603
|
+
{/* `relative` makes this column the containing block for the absolute
|
|
1604
|
+
overlays it hosts (BottomDock, expanded ResourceDetailDrawer) so they
|
|
1605
|
+
span the content area AFTER the rail rather than the full viewport
|
|
1606
|
+
under it. `fixed` splashes (connecting/switching) are unaffected. */}
|
|
1607
|
+
<div className="relative flex flex-col flex-1 min-w-0 h-full">
|
|
1608
|
+
{/* Header — suppressed in chromeless embed; the host owns the chrome.
|
|
1609
|
+
@container: the header's responsive layout keys off its OWN width (≈ viewport
|
|
1610
|
+
− nav rail), not the viewport, so it collapses gracefully on narrow windows.
|
|
1611
|
+
The AI panel docks BELOW the navbar and pushes only the content region, so the
|
|
1612
|
+
navbar is never squeezed by it — these thresholds react to real window width. */}
|
|
1613
|
+
{!chromeless && (
|
|
1614
|
+
<header className="@container 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">
|
|
1615
|
+
{/* Left: Logo + Cluster info. In the standalone (nav-rail) layout this
|
|
1616
|
+
is a FIXED-WIDTH column so the omnibar after it is force-pinned: the
|
|
1617
|
+
scope pill + status dot can change width (cluster/namespace value)
|
|
1618
|
+
without ever shifting the search box. The pill's own name/value caps
|
|
1619
|
+
keep it inside this width; the embedded/pill layout keeps auto width
|
|
1620
|
+
(its center bar is absolutely positioned). */}
|
|
1621
|
+
<div className={`flex items-center gap-4 shrink-0 ${showNavRail ? 'w-[492px]' : ''}`}>
|
|
1622
|
+
{/* Standalone rail owns the brand; only the embedded/pill layout
|
|
1623
|
+
shows it in the header (host may override via brandSlot). */}
|
|
1624
|
+
{navCustomization.brandSlot ?? (showNavRail ? null : <Logo />)}
|
|
1625
|
+
|
|
1626
|
+
<div className={`flex items-center gap-2 min-w-0 ${showNavRail ? 'flex-1' : ''}`}>
|
|
1627
|
+
{navCustomization.contextSlot ? (
|
|
1628
|
+
// Embedded host supplies its own cluster switcher — keep the two
|
|
1629
|
+
// controls separate (the host owns the cluster chip's styling).
|
|
1630
|
+
<>
|
|
1631
|
+
{navCustomization.contextSlot}
|
|
1632
|
+
<NamespaceSwitcher
|
|
1633
|
+
ref={namespaceSwitcherRef}
|
|
1634
|
+
disabled={namespaceFilter.disabled}
|
|
1635
|
+
disabledTooltip={namespaceFilter.tooltip}
|
|
1636
|
+
/>
|
|
1637
|
+
</>
|
|
1638
|
+
) : (
|
|
1639
|
+
// Standalone: cluster + namespace as one "scope" pill — two
|
|
1640
|
+
// borderless segments split by a divider, reading as a single
|
|
1641
|
+
// "what am I looking at" unit. The shared ScopePill shell is the
|
|
1642
|
+
// same one Radar Hub's cluster top bar uses, so the two match.
|
|
1643
|
+
<ScopePill>
|
|
1644
|
+
<ContextSwitcher ref={contextSwitcherRef} variant="segment" />
|
|
1645
|
+
<NamespaceSwitcher
|
|
1646
|
+
ref={namespaceSwitcherRef}
|
|
1647
|
+
variant="segment"
|
|
1648
|
+
disabled={namespaceFilter.disabled}
|
|
1649
|
+
disabledTooltip={namespaceFilter.tooltip}
|
|
1650
|
+
/>
|
|
1651
|
+
</ScopePill>
|
|
1652
|
+
)}
|
|
1653
|
+
{/* Connection status — a fixed-size dot (state in the tooltip), an
|
|
1654
|
+
optional reconnect button, and when the header is wide enough
|
|
1655
|
+
(xl+), a label. The label is nowrap and unbounded: it overflows
|
|
1656
|
+
the fixed left column into the empty gap before the centered
|
|
1657
|
+
search box rather than shifting anything (the dot + pill are
|
|
1658
|
+
shrink-0, so layout stays put — the search box never moves).
|
|
1659
|
+
Where the gap is smaller than the label, its tail tucks under
|
|
1660
|
+
the omnibar's solid background. Below xl it's the dot alone
|
|
1661
|
+
unless reconnect is available. */}
|
|
1662
|
+
<div className="ml-1 flex items-center gap-1.5 shrink-0">
|
|
798
1663
|
<Tooltip
|
|
799
|
-
content={
|
|
800
|
-
!connected
|
|
801
|
-
? 'Disconnected'
|
|
802
|
-
: crdDiscoveryStatus === 'discovering'
|
|
803
|
-
? 'Connected — discovering Custom Resources...'
|
|
804
|
-
: 'Connected'
|
|
805
|
-
}
|
|
1664
|
+
content={headerConnectionLabel}
|
|
806
1665
|
delay={100}
|
|
807
1666
|
position="bottom"
|
|
808
1667
|
>
|
|
809
1668
|
<span
|
|
810
|
-
className={`w-2 h-2 rounded-full ${
|
|
811
|
-
|
|
1669
|
+
className={`block w-2.5 h-2.5 shrink-0 rounded-full ${
|
|
1670
|
+
clusterConnectionState === 'disconnected' || liveUpdatesDisconnected
|
|
812
1671
|
? 'bg-red-500'
|
|
813
|
-
: crdDiscoveryStatus === 'discovering'
|
|
1672
|
+
: clusterConnectionState === 'connecting' || crdDiscoveryStatus === 'discovering' || clusterLoadState.loading
|
|
814
1673
|
? 'bg-amber-400 animate-pulse'
|
|
815
1674
|
: 'bg-green-500'
|
|
816
1675
|
}`}
|
|
817
1676
|
/>
|
|
818
1677
|
</Tooltip>
|
|
819
|
-
{
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
collide with the absolute-centered nav block at xl, which
|
|
824
|
-
is the same breakpoint where nav labels appear. */}
|
|
825
|
-
{(!connected || crdDiscoveryStatus === 'discovering') && (
|
|
826
|
-
<span className="text-xs text-theme-text-tertiary hidden xl:inline">
|
|
827
|
-
{!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
|
|
1678
|
+
{showNavRail && headerConnectionDisplayLabel && (
|
|
1679
|
+
<span className="hidden xl:flex items-center gap-1.5 whitespace-nowrap text-[11px] text-theme-text-tertiary">
|
|
1680
|
+
{showClusterWarmupLabel && <Loader2 className="w-3 h-3 animate-spin" />}
|
|
1681
|
+
{headerConnectionDisplayLabel}
|
|
828
1682
|
</span>
|
|
829
1683
|
)}
|
|
830
|
-
{
|
|
831
|
-
<
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1684
|
+
{showHeaderReconnect && (
|
|
1685
|
+
<Tooltip content="Reconnect" delay={100} position="bottom">
|
|
1686
|
+
<button
|
|
1687
|
+
type="button"
|
|
1688
|
+
onClick={headerReconnect}
|
|
1689
|
+
disabled={headerReconnectPending}
|
|
1690
|
+
aria-label={clusterConnectionState === 'disconnected' ? 'Reconnect cluster' : 'Reconnect live updates'}
|
|
1691
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50 disabled:pointer-events-none"
|
|
1692
|
+
>
|
|
1693
|
+
<RefreshCw className={`w-3 h-3 ${headerReconnectPending ? 'animate-spin' : ''}`} />
|
|
1694
|
+
</button>
|
|
1695
|
+
</Tooltip>
|
|
839
1696
|
)}
|
|
840
1697
|
</div>
|
|
841
1698
|
{/* Port forwards indicator — shown only when sessions exist */}
|
|
@@ -843,25 +1700,42 @@ function AppInner() {
|
|
|
843
1700
|
</div>
|
|
844
1701
|
</div>
|
|
845
1702
|
|
|
846
|
-
{/* Center: View tabs —
|
|
847
|
-
|
|
1703
|
+
{/* Center: View tabs — embedded/pill layout only. Standalone Radar
|
|
1704
|
+
navigates via the left rail (showNavRail), so the pill bar is
|
|
1705
|
+
suppressed there to avoid a duplicate primary nav. */}
|
|
1706
|
+
{!showNavRail && (
|
|
1707
|
+
<div className="@min-[920px]:absolute @min-[920px]:left-1/2 @min-[920px]:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 @min-[920px]:ml-0">
|
|
848
1708
|
{([
|
|
849
1709
|
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
850
1710
|
{ view: 'topology' as const, icon: Network, label: 'Topology' },
|
|
851
1711
|
{ view: 'resources' as const, icon: List, label: 'Resources' },
|
|
852
1712
|
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
853
1713
|
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
854
|
-
{ view: '
|
|
1714
|
+
{ view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
|
|
1715
|
+
// Applications is intentionally hidden from the pill bar for now —
|
|
1716
|
+
// the bar is full, and the view's primary home is Cloud's fleet
|
|
1717
|
+
// rail. The view still exists and is reachable via /applications
|
|
1718
|
+
// and the view-switching shortcuts. Same treatment as Cost below.
|
|
1719
|
+
{ view: 'traffic' as const, icon: Activity, label: 'Live Traffic' },
|
|
855
1720
|
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
856
1721
|
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
857
1722
|
// command palette (⌘K). Remove this comment to restore it.
|
|
858
|
-
{ view: '
|
|
859
|
-
] as const)
|
|
1723
|
+
{ view: 'checks' as const, icon: ShieldCheck, label: 'Checks' },
|
|
1724
|
+
] as const)
|
|
1725
|
+
// In Cloud, fleet-shaped views (Checks, Issues, GitOps) are owned by
|
|
1726
|
+
// the host's left rail; the per-cluster view is just that fleet page
|
|
1727
|
+
// filtered to this cluster, so duplicating it as a peer pill here
|
|
1728
|
+
// would be a second copy that teleports out of the cluster shell.
|
|
1729
|
+
// Drop any pill the host took over — cluster-scoped access stays
|
|
1730
|
+
// available via the Home cards (redirected by the takeover effect
|
|
1731
|
+
// above), ⌘K, and bookmarks. Standalone OSS keeps every pill.
|
|
1732
|
+
.filter(({ view }) => !isViewTakenOver(view))
|
|
1733
|
+
.map(({ view, icon: Icon, label }) => (
|
|
860
1734
|
<Tooltip key={view} content={label} delay={100} position="bottom">
|
|
861
1735
|
<button
|
|
862
1736
|
onClick={() => setMainView(view)}
|
|
863
|
-
className={`flex items-center gap-1
|
|
864
|
-
mainView === view
|
|
1737
|
+
className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
|
|
1738
|
+
mainView === view || (mainView === 'helmCompare' && view === 'helm')
|
|
865
1739
|
? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
|
|
866
1740
|
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
|
|
867
1741
|
}`}
|
|
@@ -877,27 +1751,51 @@ function AppInner() {
|
|
|
877
1751
|
off-system breakpoint chosen by measurement at the time
|
|
878
1752
|
of this PR — recompute if the cluster switcher cap or
|
|
879
1753
|
other left-section chrome changes appreciably. */}
|
|
880
|
-
<span className="hidden min-[
|
|
1754
|
+
<span className="hidden @min-[1264px]:inline">{label}</span>
|
|
881
1755
|
</button>
|
|
882
1756
|
</Tooltip>
|
|
883
1757
|
))}
|
|
884
1758
|
</div>
|
|
1759
|
+
)}
|
|
1760
|
+
|
|
1761
|
+
{/* Center: omnibar — standalone search + command surface (the ⌘K entry).
|
|
1762
|
+
Its container is one of three EQUAL flex-1 columns (see the left/right
|
|
1763
|
+
groups): equal side columns keep this middle column — and the search
|
|
1764
|
+
box centered in it — pinned regardless of how wide the left-side
|
|
1765
|
+
chrome gets (cluster name, namespace label, "Discovering…" /
|
|
1766
|
+
"Disconnected" text). Overflowing side content truncates instead of
|
|
1767
|
+
dragging the box. Same pattern as Radar Hub's ClusterTopBar. */}
|
|
1768
|
+
{showNavRail && (
|
|
1769
|
+
<div className="hidden @min-[720px]:flex flex-1 justify-center min-w-0 px-3">
|
|
1770
|
+
<RadarOmnibar
|
|
1771
|
+
ref={omnibarRef}
|
|
1772
|
+
onNavigateView={(view) => setMainView(view)}
|
|
1773
|
+
onNavigateKind={(kind, group) => {
|
|
1774
|
+
const params = new URLSearchParams(searchParams)
|
|
1775
|
+
params.delete('kind')
|
|
1776
|
+
if (group) params.set('apiGroup', group); else params.delete('apiGroup')
|
|
1777
|
+
params.delete('resource')
|
|
1778
|
+
params.delete('full')
|
|
1779
|
+
params.delete('tab')
|
|
1780
|
+
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1781
|
+
}}
|
|
1782
|
+
onSwitchContext={(name) => switchContext.mutate({ name }, { onSettled: () => setNamespaces([]) })}
|
|
1783
|
+
onSetNamespaces={(ns) => { setNamespaces(ns); setActiveNamespace.mutate({ namespaces: ns }) }}
|
|
1784
|
+
onToggleTheme={toggleTheme}
|
|
1785
|
+
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1786
|
+
onOpenResource={(hit) => navigateToResourceList(searchHitToSelectedResource(hit))}
|
|
1787
|
+
/>
|
|
1788
|
+
</div>
|
|
1789
|
+
)}
|
|
885
1790
|
|
|
886
1791
|
{/* Right: Controls */}
|
|
887
1792
|
<div className="flex items-center gap-3 shrink-0">
|
|
888
|
-
{/*
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
onChange={setNamespaces}
|
|
892
|
-
namespaces={availableNamespaces}
|
|
893
|
-
namespacesError={namespacesError}
|
|
894
|
-
disabled={mainView === 'helm'}
|
|
895
|
-
disabledTooltip="Helm view always shows all namespaces"
|
|
896
|
-
/>
|
|
897
|
-
|
|
898
|
-
{/* Command palette trigger */}
|
|
1793
|
+
{/* Command palette trigger — embedded only; standalone has the
|
|
1794
|
+
top-center omnibar (which is the ⌘K surface). */}
|
|
1795
|
+
{!showNavRail && (
|
|
899
1796
|
<button
|
|
900
1797
|
onClick={() => setShowCommandPalette(true)}
|
|
1798
|
+
aria-label="Open command palette"
|
|
901
1799
|
className="hidden lg:flex items-center gap-2 h-7 px-2.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
902
1800
|
>
|
|
903
1801
|
<Search className="w-3.5 h-3.5" />
|
|
@@ -905,23 +1803,29 @@ function AppInner() {
|
|
|
905
1803
|
{typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}K
|
|
906
1804
|
</kbd>
|
|
907
1805
|
</button>
|
|
1806
|
+
)}
|
|
908
1807
|
|
|
909
1808
|
{/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
|
|
910
1809
|
{!navCustomization.embedded && (
|
|
911
|
-
<div className="hidden
|
|
1810
|
+
<div className="hidden @min-[1100px]:block">
|
|
912
1811
|
<GitHubStarButton />
|
|
913
1812
|
</div>
|
|
914
1813
|
)}
|
|
915
1814
|
|
|
1815
|
+
{/* AI investigations (self-hides when no agent CLI is present) */}
|
|
1816
|
+
<GlobalDiagnoseButton />
|
|
1817
|
+
|
|
916
1818
|
{/* Local terminal */}
|
|
917
1819
|
{capabilities.localTerminal && (
|
|
1820
|
+
<Tooltip content="Open local terminal">
|
|
918
1821
|
<button
|
|
919
1822
|
onClick={() => openLocalTerminal()}
|
|
1823
|
+
aria-label="Open local terminal"
|
|
920
1824
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
921
|
-
title="Open local terminal"
|
|
922
1825
|
>
|
|
923
1826
|
<SquareTerminal className="w-4 h-4" />
|
|
924
1827
|
</button>
|
|
1828
|
+
</Tooltip>
|
|
925
1829
|
)}
|
|
926
1830
|
|
|
927
1831
|
{/* Theme toggle — hidden in embedded mode. Host apps (e.g. Radar
|
|
@@ -931,38 +1835,51 @@ function AppInner() {
|
|
|
931
1835
|
to the host's cookie/backend) and the user would see the theme
|
|
932
1836
|
bounce on every navigation between host routes and /c/:id. */}
|
|
933
1837
|
{!navCustomization.embedded && (
|
|
934
|
-
<div className="hidden
|
|
1838
|
+
<div className="hidden @min-[920px]:flex items-center">
|
|
935
1839
|
<ThemeToggle />
|
|
936
1840
|
</div>
|
|
937
1841
|
)}
|
|
938
1842
|
|
|
939
|
-
{/*
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
<
|
|
954
|
-
|
|
1843
|
+
{/* Help + Report-a-bug — standalone only (the left rail owns chrome;
|
|
1844
|
+
embedded hosts provide their own help/support). These replace the
|
|
1845
|
+
old floating bottom-right pair. Settings moved to the rail bottom. */}
|
|
1846
|
+
{showNavRail && (
|
|
1847
|
+
<>
|
|
1848
|
+
<Tooltip content="Keyboard shortcuts (?)">
|
|
1849
|
+
<button
|
|
1850
|
+
onClick={() => setShowHelp(true)}
|
|
1851
|
+
aria-label="Show keyboard shortcuts"
|
|
1852
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1853
|
+
>
|
|
1854
|
+
<HelpCircle className="w-4 h-4" />
|
|
1855
|
+
</button>
|
|
1856
|
+
</Tooltip>
|
|
1857
|
+
<Tooltip content="Report a bug / Diagnostics">
|
|
1858
|
+
<button
|
|
1859
|
+
onClick={() => setShowDiagnostics(true)}
|
|
1860
|
+
aria-label="Open diagnostics"
|
|
1861
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1862
|
+
>
|
|
1863
|
+
<Bug className="w-4 h-4" />
|
|
1864
|
+
</button>
|
|
1865
|
+
</Tooltip>
|
|
1866
|
+
</>
|
|
955
1867
|
)}
|
|
956
1868
|
|
|
957
|
-
{/*
|
|
958
|
-
|
|
959
|
-
{!navCustomization.embedded && <UserMenu />}
|
|
1869
|
+
{/* Account moved to the rail bottom (standalone). Embedded never showed
|
|
1870
|
+
Radar's UserMenu — the host provides its own via rightExtras. */}
|
|
960
1871
|
|
|
961
1872
|
{/* Consumer-provided extras (e.g. Radar Hub's Install button +
|
|
962
1873
|
avatar menu) appended to the right of the action bar. */}
|
|
963
1874
|
{navCustomization.rightExtras}
|
|
964
1875
|
</div>
|
|
965
1876
|
</header>
|
|
1877
|
+
)}
|
|
1878
|
+
|
|
1879
|
+
{/* Body frame — every content state lives here and reflows left of the docked
|
|
1880
|
+
AI panel (an absolute slot in this column). The header + nav rail are OUTSIDE
|
|
1881
|
+
this wrapper, so they never move when the panel opens. */}
|
|
1882
|
+
<div className="relative flex flex-1 flex-col min-h-0" style={{ paddingRight: contentGutter, transition: 'padding-right 0.2s ease' }}>
|
|
966
1883
|
|
|
967
1884
|
{/* Auth barrier - show when auth is enabled but user is not authenticated */}
|
|
968
1885
|
{authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
|
|
@@ -981,76 +1898,78 @@ function AppInner() {
|
|
|
981
1898
|
/>
|
|
982
1899
|
)}
|
|
983
1900
|
|
|
984
|
-
{/* Connecting view
|
|
1901
|
+
{/* Connecting view — shown during initial connection or retry.
|
|
1902
|
+
Icon is pane-anchored so its screen position matches the
|
|
1903
|
+
host hub splash across cross-document transitions. */}
|
|
985
1904
|
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
986
|
-
<
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
</div>
|
|
1905
|
+
<PaneLoader
|
|
1906
|
+
label="Connecting to cluster"
|
|
1907
|
+
className="flex-1 min-h-0 bg-theme-base"
|
|
1908
|
+
>
|
|
1909
|
+
{connection.context && (
|
|
1910
|
+
<span className="mt-1 block text-sm font-normal tracking-normal text-theme-text-secondary">
|
|
1911
|
+
{connection.context}
|
|
1912
|
+
</span>
|
|
1913
|
+
)}
|
|
1914
|
+
{connection.progressMessage && (
|
|
1915
|
+
<span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
|
|
1916
|
+
{connection.progressMessage}
|
|
1917
|
+
</span>
|
|
1918
|
+
)}
|
|
1919
|
+
</PaneLoader>
|
|
1002
1920
|
)}
|
|
1003
1921
|
|
|
1004
1922
|
{/* Context switching overlay */}
|
|
1005
1923
|
{isSwitching && (
|
|
1006
|
-
<
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
<>
|
|
1018
|
-
<span className="text-theme-text-tertiary/50">•</span>
|
|
1019
|
-
<span>{targetContext.account}</span>
|
|
1020
|
-
</>
|
|
1021
|
-
)}
|
|
1022
|
-
{targetContext.region && (
|
|
1023
|
-
<>
|
|
1024
|
-
<span className="text-theme-text-tertiary/50">•</span>
|
|
1025
|
-
<span>{targetContext.region}</span>
|
|
1026
|
-
</>
|
|
1027
|
-
)}
|
|
1924
|
+
<PaneLoader
|
|
1925
|
+
label="Switching context"
|
|
1926
|
+
className="flex-1 min-h-0 bg-theme-base"
|
|
1927
|
+
>
|
|
1928
|
+
{targetContext && (
|
|
1929
|
+
<span className="mt-2 block text-xs font-normal tracking-normal text-theme-text-tertiary">
|
|
1930
|
+
{targetContext.provider ? (
|
|
1931
|
+
<span className="flex items-center justify-center gap-1.5">
|
|
1932
|
+
<span className="text-blue-400 font-medium">{targetContext.provider}</span>
|
|
1933
|
+
{targetContext.account && (
|
|
1934
|
+
<>
|
|
1028
1935
|
<span className="text-theme-text-tertiary/50">•</span>
|
|
1029
|
-
<span
|
|
1030
|
-
|
|
1031
|
-
) : (
|
|
1032
|
-
<span>{targetContext.raw}</span>
|
|
1936
|
+
<span>{targetContext.account}</span>
|
|
1937
|
+
</>
|
|
1033
1938
|
)}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1939
|
+
{targetContext.region && (
|
|
1940
|
+
<>
|
|
1941
|
+
<span className="text-theme-text-tertiary/50">•</span>
|
|
1942
|
+
<span>{targetContext.region}</span>
|
|
1943
|
+
</>
|
|
1944
|
+
)}
|
|
1945
|
+
<span className="text-theme-text-tertiary/50">•</span>
|
|
1946
|
+
<span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
|
|
1947
|
+
</span>
|
|
1948
|
+
) : (
|
|
1949
|
+
<span>{targetContext.raw}</span>
|
|
1040
1950
|
)}
|
|
1041
|
-
</
|
|
1042
|
-
|
|
1043
|
-
|
|
1951
|
+
</span>
|
|
1952
|
+
)}
|
|
1953
|
+
{progressMessage && (
|
|
1954
|
+
<span className="mt-3 block text-xs font-normal tracking-normal text-theme-text-tertiary animate-pulse">
|
|
1955
|
+
{progressMessage}
|
|
1956
|
+
</span>
|
|
1957
|
+
)}
|
|
1958
|
+
</PaneLoader>
|
|
1044
1959
|
)}
|
|
1045
1960
|
|
|
1046
1961
|
{/* Main content - only show when connected and authenticated */}
|
|
1047
|
-
{
|
|
1962
|
+
{/* inert while a fullscreen detail overlay covers the views — keeps the
|
|
1963
|
+
retained background list out of the focus order + a11y tree (the visual
|
|
1964
|
+
cover already blocks pointer events). */}
|
|
1965
|
+
{contentReady && <div className="flex-1 flex overflow-hidden" inert={expandedView}>
|
|
1048
1966
|
<ErrorBoundary>
|
|
1049
1967
|
{/* Home dashboard */}
|
|
1050
1968
|
{mainView === 'home' && (
|
|
1051
1969
|
<HomeView
|
|
1052
1970
|
namespaces={namespaces}
|
|
1053
1971
|
topology={topology}
|
|
1972
|
+
fallbackClusterLoadState={showHomeClusterLoadFallback ? clusterLoadState : undefined}
|
|
1054
1973
|
onNavigateToView={setMainView}
|
|
1055
1974
|
onNavigateToResourceKind={(kind, apiGroup, filters) => {
|
|
1056
1975
|
// Navigate to resources view with kind in URL path
|
|
@@ -1059,6 +1978,8 @@ function AppInner() {
|
|
|
1059
1978
|
newParams.delete('kind') // kind is now in the path
|
|
1060
1979
|
newParams.delete('mode')
|
|
1061
1980
|
newParams.delete('resource')
|
|
1981
|
+
newParams.delete('full') // don't carry an expanded-overlay flag onto a fresh kind list
|
|
1982
|
+
newParams.delete('tab')
|
|
1062
1983
|
newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
|
|
1063
1984
|
if (apiGroup) {
|
|
1064
1985
|
newParams.set('apiGroup', apiGroup)
|
|
@@ -1078,21 +1999,16 @@ function AppInner() {
|
|
|
1078
1999
|
console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
|
|
1079
2000
|
navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
|
|
1080
2001
|
}}
|
|
1081
|
-
onNavigateToResource={
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
} else {
|
|
1092
|
-
newParams.delete('apiGroup')
|
|
1093
|
-
}
|
|
1094
|
-
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1095
|
-
}}
|
|
2002
|
+
onNavigateToResource={navigateFromIssue}
|
|
2003
|
+
// Certs has no Radar view, so it can't ride the view-redirect effect
|
|
2004
|
+
// above — wire the Certificate Health card straight to the host's
|
|
2005
|
+
// fleet Certs page (scoped to this cluster) when claimed. `assign`
|
|
2006
|
+
// (not replace): the user is navigating forward from a card, so this
|
|
2007
|
+
// belongs in history. Omitted → the card falls back to Radar's own
|
|
2008
|
+
// TLS-secrets resource list.
|
|
2009
|
+
onNavigateToCerts={
|
|
2010
|
+
takeover.certs ? () => goHost(takeover.certs!) : undefined
|
|
2011
|
+
}
|
|
1096
2012
|
/>
|
|
1097
2013
|
)}
|
|
1098
2014
|
|
|
@@ -1119,6 +2035,7 @@ function AppInner() {
|
|
|
1119
2035
|
namespaces={availableNamespaces}
|
|
1120
2036
|
onSelect={(ns) => {
|
|
1121
2037
|
setNamespaces([ns])
|
|
2038
|
+
setActiveNamespace.mutate({ namespaces: [ns] })
|
|
1122
2039
|
// Large clusters need server-side filtering — reconnect SSE with namespace
|
|
1123
2040
|
setForceNamespaceFilter([ns])
|
|
1124
2041
|
}}
|
|
@@ -1145,37 +2062,66 @@ function AppInner() {
|
|
|
1145
2062
|
}}
|
|
1146
2063
|
/>
|
|
1147
2064
|
|
|
1148
|
-
<div className="flex-1 relative">
|
|
2065
|
+
<div ref={setTopologyPane} className="flex-1 relative">
|
|
1149
2066
|
<TopologyGraph
|
|
1150
|
-
topology={
|
|
2067
|
+
topology={topologyWithAudit}
|
|
1151
2068
|
viewMode={topologyMode}
|
|
1152
2069
|
groupingMode={effectiveGroupingMode}
|
|
1153
2070
|
hideGroupHeader={hideGroupHeader}
|
|
1154
2071
|
onNodeClick={handleNodeClick}
|
|
1155
|
-
selectedNodeId={
|
|
2072
|
+
selectedNodeId={selectedNodeId}
|
|
1156
2073
|
paused={topologyPaused}
|
|
1157
2074
|
onTogglePause={handleTogglePause}
|
|
1158
|
-
onMaximizeNamespace={(ns) =>
|
|
1159
|
-
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1160
|
-
onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
|
|
2075
|
+
onMaximizeNamespace={(ns) => setActiveNamespace.mutate({ namespaces: [ns] })}
|
|
1161
2076
|
namespacesKey={namespaces.join(',')}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
2077
|
+
focusNodeId={topologyFocus?.id}
|
|
2078
|
+
focusNonce={topologyFocus?.nonce}
|
|
2079
|
+
>
|
|
2080
|
+
{/* Overlay row: left column (namespace breadcrumb over search)
|
|
2081
|
+
+ controls. items-start pins the controls to the top even
|
|
2082
|
+
when the breadcrumb grows the left column; w-full so
|
|
2083
|
+
justify-between spans the canvas. */}
|
|
2084
|
+
<div className="flex w-full items-start justify-between gap-2">
|
|
2085
|
+
<div className="flex flex-col items-start gap-2">
|
|
2086
|
+
{namespaces.length === 1 && (
|
|
2087
|
+
<TopologyBreadcrumb
|
|
2088
|
+
namespace={namespaces[0]}
|
|
2089
|
+
onClear={() => setActiveNamespace.mutate({ namespaces: [] })}
|
|
2090
|
+
/>
|
|
2091
|
+
)}
|
|
2092
|
+
<TopologySearch
|
|
2093
|
+
nodes={filteredTopology?.nodes ?? []}
|
|
2094
|
+
allNodes={topology?.nodes}
|
|
2095
|
+
viewModeLabel={topologyMode === 'fleet' ? 'Fleet' : topologyMode === 'traffic' ? 'Network Flow' : 'Resources'}
|
|
2096
|
+
onNodeSelect={handleNodeClick}
|
|
2097
|
+
onZoomToNode={(id) => setTopologyFocus((prev) => ({ id, nonce: (prev?.nonce ?? 0) + 1 }))}
|
|
2098
|
+
overlayContainer={topologyPane}
|
|
2099
|
+
/>
|
|
2100
|
+
</div>
|
|
2101
|
+
<TopologyControls
|
|
2102
|
+
viewMode={topologyMode}
|
|
2103
|
+
onViewModeChange={(mode) => {
|
|
2104
|
+
setTopologyMode(mode)
|
|
2105
|
+
// Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
|
|
2106
|
+
if (mode === 'fleet') setGroupingMode('namespace')
|
|
2107
|
+
}}
|
|
2108
|
+
groupingMode={groupingMode}
|
|
2109
|
+
onGroupingModeChange={setGroupingMode}
|
|
2110
|
+
showNoGrouping={hasNamespaceFilter}
|
|
2111
|
+
showPolicyEffect={showPolicyEffect}
|
|
2112
|
+
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
2113
|
+
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
2114
|
+
onNavigateToTraffic={() => setMainView('traffic')}
|
|
2115
|
+
leadingSlot={
|
|
2116
|
+
<FreshnessControl
|
|
2117
|
+
mode="auto"
|
|
2118
|
+
paused={topologyPaused}
|
|
2119
|
+
connectionState={connection.state}
|
|
2120
|
+
/>
|
|
2121
|
+
}
|
|
2122
|
+
/>
|
|
2123
|
+
</div>
|
|
2124
|
+
</TopologyGraph>
|
|
1179
2125
|
</div>
|
|
1180
2126
|
</>
|
|
1181
2127
|
)}
|
|
@@ -1186,10 +2132,11 @@ function AppInner() {
|
|
|
1186
2132
|
{mainView === 'resources' && (
|
|
1187
2133
|
<ResourcesView
|
|
1188
2134
|
namespaces={namespaces}
|
|
1189
|
-
selectedResource={
|
|
2135
|
+
selectedResource={routeSelectedResource}
|
|
1190
2136
|
onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
|
|
1191
2137
|
onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
|
|
1192
2138
|
onKindChange={() => setSelectedResource(null)}
|
|
2139
|
+
onClearNamespaces={clearAllNamespaces}
|
|
1193
2140
|
/>
|
|
1194
2141
|
)}
|
|
1195
2142
|
|
|
@@ -1198,27 +2145,65 @@ function AppInner() {
|
|
|
1198
2145
|
<TimelineView
|
|
1199
2146
|
namespaces={namespaces}
|
|
1200
2147
|
onResourceClick={(resource) => {
|
|
1201
|
-
navigate(
|
|
2148
|
+
navigate(relatedResourcePath(resource))
|
|
1202
2149
|
}}
|
|
1203
2150
|
initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
|
|
1204
2151
|
initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
|
|
1205
2152
|
initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
|
|
1206
2153
|
requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
|
|
1207
2154
|
availableNamespaces={availableNamespaces}
|
|
1208
|
-
onNamespaceSelect={(ns) =>
|
|
2155
|
+
onNamespaceSelect={(ns) => {
|
|
2156
|
+
setNamespaces([ns])
|
|
2157
|
+
setActiveNamespace.mutate({ namespaces: [ns] })
|
|
2158
|
+
}}
|
|
1209
2159
|
/>
|
|
1210
2160
|
)}
|
|
1211
2161
|
|
|
1212
|
-
{/* Helm view - always show all namespaces since releases span multiple ns */}
|
|
1213
2162
|
{mainView === 'helm' && (
|
|
1214
2163
|
<HelmView
|
|
1215
|
-
|
|
2164
|
+
namespaces={namespaces}
|
|
1216
2165
|
selectedRelease={selectedHelmRelease}
|
|
1217
|
-
onReleaseClick={
|
|
1218
|
-
|
|
2166
|
+
onReleaseClick={navigateToHelmRelease}
|
|
2167
|
+
/>
|
|
2168
|
+
)}
|
|
2169
|
+
|
|
2170
|
+
{mainView === 'helmCompare' && (
|
|
2171
|
+
<HelmCompareRoute />
|
|
2172
|
+
)}
|
|
2173
|
+
|
|
2174
|
+
{/* GitOps view (inline only when the host hasn't taken it over — see
|
|
2175
|
+
the takeover splash below). */}
|
|
2176
|
+
{mainView === 'gitops' && !isViewTakenOver('gitops') && (
|
|
2177
|
+
<GitOpsView
|
|
2178
|
+
namespaces={namespaces}
|
|
2179
|
+
onOpenResource={(resource) => {
|
|
2180
|
+
// Route through navigateToResource so the peek records the page it
|
|
2181
|
+
// opened on — that's what lets Back off the GitOps detail page close
|
|
2182
|
+
// the drawer instead of orphaning it on the list.
|
|
2183
|
+
navigateToResource(resource)
|
|
2184
|
+
}}
|
|
2185
|
+
onClearNamespaces={clearAllNamespaces}
|
|
2186
|
+
onOpenSettings={() => setShowSettings(true)}
|
|
2187
|
+
/>
|
|
2188
|
+
)}
|
|
2189
|
+
|
|
2190
|
+
{/* Applications view — deployable software grouped by app/release evidence */}
|
|
2191
|
+
{mainView === 'applications' && (
|
|
2192
|
+
<ApplicationsView
|
|
2193
|
+
namespaces={namespaces}
|
|
2194
|
+
onOpenResource={(resource) => {
|
|
2195
|
+
// The peek and the inline WorkloadView are mutually exclusive: drop
|
|
2196
|
+
// the inline workload selection so the app graph (not a second
|
|
2197
|
+
// detail panel) sits behind the peek. Search-only change keeps the
|
|
2198
|
+
// pathname — and thus the peek's owner-path — intact.
|
|
1219
2199
|
const params = new URLSearchParams(window.location.search)
|
|
1220
|
-
params.
|
|
1221
|
-
|
|
2200
|
+
if (params.has('workload') || params.has('tab') || params.has('run')) {
|
|
2201
|
+
params.delete('workload')
|
|
2202
|
+
params.delete('tab')
|
|
2203
|
+
params.delete('run')
|
|
2204
|
+
navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
|
|
2205
|
+
}
|
|
2206
|
+
navigateToResource(resource)
|
|
1222
2207
|
}}
|
|
1223
2208
|
/>
|
|
1224
2209
|
)}
|
|
@@ -1230,89 +2215,138 @@ function AppInner() {
|
|
|
1230
2215
|
|
|
1231
2216
|
{/* Cost detail view */}
|
|
1232
2217
|
{mainView === 'cost' && (
|
|
1233
|
-
<CostView onBack={() => setMainView('home')} />
|
|
2218
|
+
<CostView namespaces={namespaces} onBack={() => setMainView('home')} onOpenResource={navigateToResource} />
|
|
1234
2219
|
)}
|
|
1235
2220
|
|
|
1236
|
-
{/*
|
|
1237
|
-
|
|
2221
|
+
{/* Takeover splash. When the host claims the current view via
|
|
2222
|
+
fleetTakeoverHref, the redirect effect above is mid-flight — render a
|
|
2223
|
+
brief splash instead of the inline view (which would flash + fire its
|
|
2224
|
+
own fetches) while the cross-document nav lands. Covers checks /
|
|
2225
|
+
issues / gitops with one block since only one view is active. */}
|
|
2226
|
+
{viewTakeoverHref && (
|
|
2227
|
+
<PaneLoader
|
|
2228
|
+
label="Opening…"
|
|
2229
|
+
className="flex-1 min-h-0 bg-theme-base"
|
|
2230
|
+
/>
|
|
2231
|
+
)}
|
|
2232
|
+
|
|
2233
|
+
{/* Best practices detail view (inline only when the host hasn't taken
|
|
2234
|
+
Checks over — standalone OSS, or Cloud without a checks takeover). */}
|
|
2235
|
+
{mainView === 'checks' && !isViewTakenOver('checks') && (
|
|
1238
2236
|
<AuditView
|
|
1239
2237
|
namespaces={namespaces}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
}
|
|
1254
|
-
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
1255
|
-
}}
|
|
2238
|
+
onNavigateToResource={navigateToResourceList}
|
|
2239
|
+
/>
|
|
2240
|
+
)}
|
|
2241
|
+
|
|
2242
|
+
{/* Issues — per-cluster live triage queue (hidden route: not yet in the
|
|
2243
|
+
nav `views` list; reachable at /issues). Same shared <IssuesView> the
|
|
2244
|
+
Hub fleet uses; a GitOps reconciler subject routes to its detail page,
|
|
2245
|
+
other resources open the standard resource view. Inline only when the
|
|
2246
|
+
host hasn't taken it over. */}
|
|
2247
|
+
{mainView === 'issues' && !isViewTakenOver('issues') && (
|
|
2248
|
+
<IssuesPane
|
|
2249
|
+
namespaces={namespaces}
|
|
2250
|
+
onNavigateToResource={navigateFromIssue}
|
|
1256
2251
|
/>
|
|
1257
2252
|
)}
|
|
1258
2253
|
|
|
1259
|
-
{/* Workload full view
|
|
1260
|
-
|
|
2254
|
+
{/* Workload full view — the standalone fullscreen route for non-list
|
|
2255
|
+
surfaces and deep links. Expand-from-drawer is the ?full=1 overlay on
|
|
2256
|
+
/resources instead, so it never routes here. */}
|
|
2257
|
+
{mainView === 'workload' && (
|
|
1261
2258
|
<WorkloadViewRoute
|
|
1262
2259
|
onNavigateToResource={(resource) => {
|
|
1263
|
-
navigate(
|
|
2260
|
+
navigate(relatedResourcePath(resource))
|
|
1264
2261
|
}}
|
|
1265
2262
|
/>
|
|
1266
2263
|
)}
|
|
1267
2264
|
|
|
2265
|
+
{/* Compare two resources of the same kind side-by-side */}
|
|
2266
|
+
{mainView === 'compare' && <CompareViewRoute />}
|
|
2267
|
+
|
|
1268
2268
|
</ErrorBoundary>
|
|
1269
2269
|
</div>}
|
|
2270
|
+
</div>{/* /body frame */}
|
|
1270
2271
|
|
|
1271
|
-
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView
|
|
1272
|
-
|
|
2272
|
+
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView.
|
|
2273
|
+
Gated on contentReady so it never renders over the connecting/switching
|
|
2274
|
+
splash (which would push the centered logo off-center). */}
|
|
2275
|
+
{contentReady && resourceDrawer.shouldRender && drawerResource && (
|
|
1273
2276
|
<ResourceDetailDrawer
|
|
1274
2277
|
resource={drawerResource}
|
|
1275
2278
|
initialTab={drawerInitialTab}
|
|
2279
|
+
// No Radar header in chromeless embeds (Radar Hub) — anchor the drawer
|
|
2280
|
+
// to the top of the content area instead of leaving a 49px gap.
|
|
2281
|
+
headerHeight={chromeless ? 0 : undefined}
|
|
2282
|
+
rightInset={contentGutter}
|
|
1276
2283
|
isOpen={resourceDrawer.isOpen}
|
|
1277
|
-
expanded={
|
|
1278
|
-
onClose={
|
|
2284
|
+
expanded={drawerExpandedProp}
|
|
2285
|
+
onClose={closeDrawer}
|
|
1279
2286
|
onNavigate={(res) => navigateToResource(res)}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
2287
|
+
canCollapseToDrawer={!isMobile}
|
|
2288
|
+
onExpand={(_res, opts) => {
|
|
2289
|
+
// Grow the peek into a fullscreen overlay (?full=1, pushed so Back
|
|
2290
|
+
// collapses) over whatever view is underneath — list, topology graph,
|
|
2291
|
+
// GitOps, Applications — which stays mounted. Carry the YAML tab when
|
|
2292
|
+
// expanding from the drawer's YAML view so the editor (and its
|
|
2293
|
+
// session-persisted draft) is right there, not behind the Overview tab.
|
|
2294
|
+
const p = new URLSearchParams(searchParams)
|
|
2295
|
+
p.set('full', '1')
|
|
2296
|
+
if (opts?.yaml) p.set('tab', 'yaml')
|
|
2297
|
+
setSearchParams(p)
|
|
1284
2298
|
}}
|
|
1285
|
-
|
|
2299
|
+
// On mobile there's no drawer to collapse back to, so the collapse/back
|
|
2300
|
+
// control closes the resource (returns to the list) instead.
|
|
2301
|
+
onCollapse={isMobile ? closeDrawer : handleCollapseFromExpanded}
|
|
1286
2302
|
onNavigateToResource={(resource) => {
|
|
1287
|
-
|
|
1288
|
-
|
|
2303
|
+
// Drill into a related resource while expanded: stay in the over-list
|
|
2304
|
+
// overlay for the new resource (pushed, so Back walks resource→resource
|
|
2305
|
+
// still expanded). The backdrop list follows to the new kind.
|
|
2306
|
+
const pluralKind = kindToPlural(resource.kind)
|
|
2307
|
+
setSelectedResource({ ...resource, kind: pluralKind })
|
|
2308
|
+
const p = new URLSearchParams()
|
|
2309
|
+
const ns = searchParams.get('namespaces')
|
|
2310
|
+
if (ns) p.set('namespaces', ns)
|
|
2311
|
+
p.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
2312
|
+
if (resource.group) p.set('apiGroup', resource.group)
|
|
2313
|
+
p.set('full', '1')
|
|
2314
|
+
navigate({ pathname: `/resources/${pluralKind}`, search: p.toString() })
|
|
1289
2315
|
}}
|
|
1290
2316
|
/>
|
|
1291
2317
|
)}
|
|
1292
2318
|
|
|
1293
|
-
{/* Helm release drawer */}
|
|
1294
|
-
{helmDrawer.shouldRender && drawerHelmRelease && (
|
|
2319
|
+
{/* Helm release drawer — same contentReady gate as the resource drawer. */}
|
|
2320
|
+
{contentReady && helmDrawer.shouldRender && drawerHelmRelease && (
|
|
1295
2321
|
<HelmReleaseDrawer
|
|
1296
2322
|
release={drawerHelmRelease}
|
|
1297
2323
|
isOpen={helmDrawer.isOpen}
|
|
2324
|
+
rightInset={contentGutter}
|
|
1298
2325
|
onClose={() => {
|
|
1299
2326
|
setSelectedHelmRelease(null)
|
|
1300
2327
|
const params = new URLSearchParams(window.location.search)
|
|
1301
2328
|
params.delete('release')
|
|
2329
|
+
params.delete('releaseStorage')
|
|
1302
2330
|
setSearchParams(params, { replace: true })
|
|
1303
2331
|
}}
|
|
1304
2332
|
onNavigateToResource={(resource) => {
|
|
1305
|
-
// Navigate to resources view with kind in path and open the resource detail drawer
|
|
1306
2333
|
setSelectedHelmRelease(null)
|
|
1307
2334
|
const newParams = new URLSearchParams()
|
|
1308
2335
|
const globalNamespaces = searchParams.get('namespaces')
|
|
1309
2336
|
if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
|
|
2337
|
+
if (resource.group) newParams.set('apiGroup', resource.group)
|
|
1310
2338
|
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1311
2339
|
setSelectedResource(resource)
|
|
1312
2340
|
}}
|
|
1313
2341
|
/>
|
|
1314
2342
|
)}
|
|
1315
2343
|
|
|
2344
|
+
{/* AI investigation panel — an absolute slot in this column (the body frame),
|
|
2345
|
+
below the header and right of the nav rail, sharing the frame with the
|
|
2346
|
+
drawers above. Docked = right slot (pushes content via contentGutter);
|
|
2347
|
+
maximized = fills the frame. */}
|
|
2348
|
+
{diagnoseOpen && <DiagnoseSurface topInset={chromeless ? 0 : APP_HEADER_HEIGHT} />}
|
|
2349
|
+
|
|
1316
2350
|
{/* Port Forward floating panel (indicator lives in header) */}
|
|
1317
2351
|
<PortForwardPanel />
|
|
1318
2352
|
|
|
@@ -1325,8 +2359,12 @@ function AppInner() {
|
|
|
1325
2359
|
{/* Spacer for dock */}
|
|
1326
2360
|
<DockSpacer />
|
|
1327
2361
|
|
|
1328
|
-
{/* Floating action buttons —
|
|
1329
|
-
|
|
2362
|
+
{/* Floating action buttons — embedded only, and not in chromeless (the
|
|
2363
|
+
host owns help/diagnostics chrome). Standalone moved help + bug to
|
|
2364
|
+
visible top-bar icons (the rail owns chrome). */}
|
|
2365
|
+
{!showNavRail && !chromeless && (
|
|
2366
|
+
<FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
|
|
2367
|
+
)}
|
|
1330
2368
|
|
|
1331
2369
|
{/* Keyboard shortcut help overlay */}
|
|
1332
2370
|
{helpOverlay.shouldRender && <ShortcutHelpOverlay isOpen={helpOverlay.isOpen} onClose={() => setShowHelp(false)} currentView={mainView} />}
|
|
@@ -1343,6 +2381,8 @@ function AppInner() {
|
|
|
1343
2381
|
if (group) params.set('apiGroup', group)
|
|
1344
2382
|
else params.delete('apiGroup')
|
|
1345
2383
|
params.delete('resource')
|
|
2384
|
+
params.delete('full')
|
|
2385
|
+
params.delete('tab')
|
|
1346
2386
|
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1347
2387
|
// Focus the table search after navigation — the user came from ⌘K
|
|
1348
2388
|
// (keyboard flow) and expects to type a resource name immediately.
|
|
@@ -1354,9 +2394,15 @@ function AppInner() {
|
|
|
1354
2394
|
{ name },
|
|
1355
2395
|
// Namespace filter from the previous context may not exist in the
|
|
1356
2396
|
// new one — clear it so resource lists don't silently go empty.
|
|
2397
|
+
// The server clears all per-user picks on context switch already;
|
|
2398
|
+
// local state mirrors that via the namespace-scope effect.
|
|
1357
2399
|
{ onSettled: () => setNamespaces([]) },
|
|
1358
2400
|
)}
|
|
1359
|
-
onSetNamespaces={
|
|
2401
|
+
onSetNamespaces={(ns) => {
|
|
2402
|
+
if (namespaceScope?.cacheScoped && ns.length !== 1) return
|
|
2403
|
+
setNamespaces(ns)
|
|
2404
|
+
setActiveNamespace.mutate({ namespaces: ns })
|
|
2405
|
+
}}
|
|
1360
2406
|
onToggleTheme={toggleTheme}
|
|
1361
2407
|
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1362
2408
|
/>
|
|
@@ -1365,11 +2411,16 @@ function AppInner() {
|
|
|
1365
2411
|
{/* Diagnostics overlay */}
|
|
1366
2412
|
{diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
|
|
1367
2413
|
|
|
1368
|
-
{/* Settings dialog */}
|
|
1369
|
-
<SettingsDialog
|
|
2414
|
+
{/* Settings dialog — My permissions is rendered inline in its own section */}
|
|
2415
|
+
<SettingsDialog
|
|
2416
|
+
open={showSettings}
|
|
2417
|
+
onClose={() => setShowSettings(false)}
|
|
2418
|
+
/>
|
|
1370
2419
|
|
|
1371
|
-
{/* Debug overlay
|
|
1372
|
-
|
|
2420
|
+
{/* Debug overlay — dev mode, standalone only. Embedded hosts (Radar Hub)
|
|
2421
|
+
own their own dev tooling; ours would collide with theirs bottom-right. */}
|
|
2422
|
+
{import.meta.env.DEV && showNavRail && <DebugOverlay />}
|
|
2423
|
+
</div>
|
|
1373
2424
|
</div>
|
|
1374
2425
|
</PortForwardProvider>
|
|
1375
2426
|
)
|
|
@@ -1377,11 +2428,20 @@ function AppInner() {
|
|
|
1377
2428
|
|
|
1378
2429
|
// Spacer component that adds padding when dock is open
|
|
1379
2430
|
function DockSpacer() {
|
|
1380
|
-
const { tabs,
|
|
2431
|
+
const { tabs, isResizing } = useDock()
|
|
2432
|
+
const dockInset = useDockReservedHeight()
|
|
1381
2433
|
const location = useLocation()
|
|
1382
2434
|
// Traffic view manages its own layout — spacer would break its flex sizing
|
|
1383
2435
|
if (tabs.length === 0 || location.pathname === '/traffic') return null
|
|
1384
|
-
return
|
|
2436
|
+
return (
|
|
2437
|
+
<div
|
|
2438
|
+
className="shrink-0"
|
|
2439
|
+
style={{
|
|
2440
|
+
height: dockInset,
|
|
2441
|
+
transition: isResizing ? 'none' : `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)`,
|
|
2442
|
+
}}
|
|
2443
|
+
/>
|
|
2444
|
+
)
|
|
1385
2445
|
}
|
|
1386
2446
|
|
|
1387
2447
|
// Floating action buttons that position themselves above the dock
|
|
@@ -1394,12 +2454,12 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
|
|
|
1394
2454
|
return (
|
|
1395
2455
|
<div className={`fixed ${bottom} right-4 z-40 flex items-center gap-1.5`}>
|
|
1396
2456
|
<Tooltip content="Report bug / Diagnostics" position="top">
|
|
1397
|
-
<button onClick={onBugReport} className={btnClass}>
|
|
2457
|
+
<button onClick={onBugReport} aria-label="Open diagnostics" className={btnClass}>
|
|
1398
2458
|
<Bug className="w-3.5 h-3.5" />
|
|
1399
2459
|
</button>
|
|
1400
2460
|
</Tooltip>
|
|
1401
2461
|
<Tooltip content="Keyboard shortcuts (?)" position="top">
|
|
1402
|
-
<button onClick={onHelp} className={btnClass}>
|
|
2462
|
+
<button onClick={onHelp} aria-label="Show keyboard shortcuts" className={btnClass}>
|
|
1403
2463
|
?
|
|
1404
2464
|
</button>
|
|
1405
2465
|
</Tooltip>
|
|
@@ -1408,14 +2468,18 @@ function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp
|
|
|
1408
2468
|
}
|
|
1409
2469
|
|
|
1410
2470
|
// Main App component wrapped with providers
|
|
1411
|
-
function App() {
|
|
2471
|
+
function App({ manageDocumentTitle = false, documentTitleSuffix, onClusterLoadStateChange }: AppProps) {
|
|
1412
2472
|
return (
|
|
1413
2473
|
<ConnectionProvider>
|
|
1414
2474
|
<CapabilitiesProvider>
|
|
1415
2475
|
<ContextSwitchProvider>
|
|
1416
2476
|
<DockProvider>
|
|
1417
2477
|
<KeyboardShortcutProvider>
|
|
1418
|
-
<AppInner
|
|
2478
|
+
<AppInner
|
|
2479
|
+
manageDocumentTitle={manageDocumentTitle}
|
|
2480
|
+
documentTitleSuffix={documentTitleSuffix}
|
|
2481
|
+
onClusterLoadStateChange={onClusterLoadStateChange}
|
|
2482
|
+
/>
|
|
1419
2483
|
</KeyboardShortcutProvider>
|
|
1420
2484
|
</DockProvider>
|
|
1421
2485
|
</ContextSwitchProvider>
|
|
@@ -1551,6 +2615,7 @@ function GitHubStarButton() {
|
|
|
1551
2615
|
target="_blank"
|
|
1552
2616
|
rel="noopener noreferrer"
|
|
1553
2617
|
onClick={handleClick}
|
|
2618
|
+
aria-label={starred ? 'Open Radar on GitHub' : 'Star Radar on GitHub'}
|
|
1554
2619
|
className="flex items-center gap-1.5 h-7 px-2 rounded-md transition-colors bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary"
|
|
1555
2620
|
>
|
|
1556
2621
|
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
@@ -1603,10 +2668,11 @@ function ThemeToggle() {
|
|
|
1603
2668
|
const { theme, toggleTheme } = useTheme()
|
|
1604
2669
|
|
|
1605
2670
|
return (
|
|
2671
|
+
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
|
1606
2672
|
<button
|
|
1607
2673
|
onClick={toggleTheme}
|
|
2674
|
+
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
1608
2675
|
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1609
|
-
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
1610
2676
|
>
|
|
1611
2677
|
{theme === 'dark' ? (
|
|
1612
2678
|
<Sun className="w-4 h-4" />
|
|
@@ -1614,6 +2680,7 @@ function ThemeToggle() {
|
|
|
1614
2680
|
<Moon className="w-4 h-4" />
|
|
1615
2681
|
)}
|
|
1616
2682
|
</button>
|
|
2683
|
+
</Tooltip>
|
|
1617
2684
|
)
|
|
1618
2685
|
}
|
|
1619
2686
|
|