@skyhook-io/radar-app 0.1.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 +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- package/src/vite-env.d.ts +1 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,1538 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
3
|
+
import { useRefreshAnimation } from './hooks/useRefreshAnimation'
|
|
4
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
5
|
+
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
|
|
6
|
+
import { HomeView } from './components/home/HomeView'
|
|
7
|
+
import { DebugOverlay } from './components/DebugOverlay'
|
|
8
|
+
import { TopologyGraph, TopologyFilterSidebar, TopologyControls } from '@skyhook-io/k8s-ui'
|
|
9
|
+
import { TimelineView } from './components/timeline/TimelineView'
|
|
10
|
+
import { ResourcesView } from './components/resources/ResourcesView'
|
|
11
|
+
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
12
|
+
import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawer'
|
|
13
|
+
import { WorkloadViewRoute } from './components/workload/WorkloadView'
|
|
14
|
+
import { HelmView } from './components/helm/HelmView'
|
|
15
|
+
import { TrafficView } from './components/traffic/TrafficView'
|
|
16
|
+
import { CostView } from './components/cost/CostView'
|
|
17
|
+
import { AuditView } from './components/audit/AuditView'
|
|
18
|
+
import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
|
|
19
|
+
import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
|
|
20
|
+
import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
|
|
21
|
+
import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
|
|
22
|
+
import { ContextSwitcher } from './components/ContextSwitcher'
|
|
23
|
+
import { useNavCustomization } from './context/NavCustomization'
|
|
24
|
+
import { ContextSwitchProvider, useContextSwitch } from './context/ContextSwitchContext'
|
|
25
|
+
import { ConnectionProvider, useConnection } from './context/ConnectionContext'
|
|
26
|
+
import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
27
|
+
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
28
|
+
import { UserMenu } from './components/UserMenu'
|
|
29
|
+
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
30
|
+
import { NamespaceSelector } from './components/ui/NamespaceSelector'
|
|
31
|
+
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
32
|
+
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
33
|
+
import { CommandPalette } from './components/ui/CommandPalette'
|
|
34
|
+
import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
|
|
35
|
+
import { useEventSource } from './hooks/useEventSource'
|
|
36
|
+
import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
|
|
37
|
+
import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
|
|
38
|
+
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
39
|
+
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
40
|
+
import { Loader2 } from 'lucide-react'
|
|
41
|
+
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck } from 'lucide-react'
|
|
42
|
+
import { useTheme } from './context/ThemeContext'
|
|
43
|
+
import { Tooltip } from './components/ui/Tooltip'
|
|
44
|
+
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
45
|
+
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'
|
|
48
|
+
|
|
49
|
+
// All possible node kinds (core + GitOps)
|
|
50
|
+
const ALL_NODE_KINDS: NodeKind[] = [
|
|
51
|
+
'Internet', 'Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute',
|
|
52
|
+
'Service', 'Deployment', 'Rollout', 'DaemonSet', 'StatefulSet',
|
|
53
|
+
'ReplicaSet', 'Pod', 'PodGroup', 'ConfigMap', 'Secret', 'HorizontalPodAutoscaler', 'Job', 'CronJob', 'PersistentVolumeClaim', 'Namespace',
|
|
54
|
+
'Application', 'Kustomization', 'HelmRelease', 'GitRepository',
|
|
55
|
+
'KnativeService', 'KnativeConfiguration', 'KnativeRevision', 'KnativeRoute',
|
|
56
|
+
'Broker', 'Trigger', 'PingSource', 'ApiServerSource', 'ContainerSource', 'SinkBinding', 'Channel',
|
|
57
|
+
'IngressRoute', 'IngressRouteTCP', 'IngressRouteUDP', 'Middleware', 'MiddlewareTCP',
|
|
58
|
+
'TraefikService', 'ServersTransport', 'ServersTransportTCP', 'TLSOption', 'TLSStore',
|
|
59
|
+
'HTTPProxy', // Contour
|
|
60
|
+
'CAPICluster', 'MachineDeployment', 'MachineSet', 'Machine', 'MachinePool', // Cluster API
|
|
61
|
+
'KubeadmControlPlane', 'ClusterClass', 'MachineHealthCheck',
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
// Default visible kinds (ReplicaSet hidden by default - noisy intermediate object)
|
|
65
|
+
const DEFAULT_VISIBLE_KINDS = ALL_NODE_KINDS.filter(k => k !== 'ReplicaSet')
|
|
66
|
+
|
|
67
|
+
// CRD kinds hidden by default in the topology (infrastructure plumbing).
|
|
68
|
+
// Users can re-enable via the filter sidebar.
|
|
69
|
+
const CRD_HIDDEN_BY_DEFAULT = new Set(['GatewayClass', 'IngressClass', 'NodePool', 'NodeClaim', 'NodeClass'])
|
|
70
|
+
|
|
71
|
+
// CAPI kinds shown in Fleet topology mode (+ Node for Machine→Node edges)
|
|
72
|
+
// Includes core CAPI kinds and all infrastructure provider kinds
|
|
73
|
+
const FLEET_MODE_KINDS = new Set<NodeKind>([
|
|
74
|
+
'CAPICluster', 'MachineDeployment', 'MachineSet', 'Machine', 'MachinePool',
|
|
75
|
+
'KubeadmControlPlane', 'ClusterClass', 'MachineHealthCheck', 'Node',
|
|
76
|
+
// AWS provider
|
|
77
|
+
'AWSManagedControlPlane', 'AWSManagedMachinePool', 'AWSMachine',
|
|
78
|
+
'AWSMachineTemplate', 'AWSManagedCluster', 'AWSClusterControllerIdentity',
|
|
79
|
+
'EKSConfig', 'EKSConfigTemplate',
|
|
80
|
+
// GCP provider
|
|
81
|
+
'GCPManagedControlPlane', 'GCPManagedMachinePool', 'GCPMachine',
|
|
82
|
+
'GCPMachineTemplate', 'GCPManagedCluster',
|
|
83
|
+
// Azure provider
|
|
84
|
+
'AzureManagedControlPlane', 'AzureManagedMachinePool', 'AzureMachine',
|
|
85
|
+
'AzureMachineTemplate', 'AzureManagedCluster',
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
// 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
|
+
// Extended MainView type that includes traffic and cost
|
|
116
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit'
|
|
117
|
+
|
|
118
|
+
// Extract view from URL path
|
|
119
|
+
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
120
|
+
const path = pathname.replace(/^\//, '').split('/')[0]
|
|
121
|
+
if (path === '' || path === 'home') return 'home'
|
|
122
|
+
if (path === 'topology') return 'topology'
|
|
123
|
+
if (path === 'resources') return 'resources'
|
|
124
|
+
if (path === 'timeline') return 'timeline'
|
|
125
|
+
if (path === 'helm') return 'helm'
|
|
126
|
+
if (path === 'traffic') return 'traffic'
|
|
127
|
+
if (path === 'cost') return 'cost'
|
|
128
|
+
if (path === 'workload') return 'workload'
|
|
129
|
+
if (path === 'audit') return 'audit'
|
|
130
|
+
return 'home'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function AuthBarrier({ authMode }: { authMode: string }) {
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (authMode === 'oidc') {
|
|
136
|
+
window.location.href = routePath('/auth/login')
|
|
137
|
+
}
|
|
138
|
+
}, [authMode])
|
|
139
|
+
|
|
140
|
+
if (authMode === 'oidc') {
|
|
141
|
+
return (
|
|
142
|
+
<div className="flex-1 flex items-center justify-center bg-theme-base">
|
|
143
|
+
<div className="flex flex-col items-center gap-4">
|
|
144
|
+
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
|
145
|
+
<p className="text-sm text-theme-text-secondary">Redirecting to login...</p>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="flex-1 flex items-center justify-center bg-theme-base">
|
|
153
|
+
<div className="flex flex-col items-center gap-4 max-w-md text-center">
|
|
154
|
+
<div className="w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center">
|
|
155
|
+
<svg className="w-6 h-6 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
156
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m0 0v2m0-2h2m-2 0H10m4-6V7a4 4 0 00-8 0v4h8z" />
|
|
157
|
+
<rect x="5" y="11" width="14" height="11" rx="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
158
|
+
</svg>
|
|
159
|
+
</div>
|
|
160
|
+
<div>
|
|
161
|
+
<p className="text-lg font-medium text-theme-text-primary">Authentication Required</p>
|
|
162
|
+
<p className="text-sm text-theme-text-secondary mt-2">
|
|
163
|
+
Radar is configured with proxy authentication. Access it through your organization's auth proxy to authenticate.
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function AppInner() {
|
|
172
|
+
const navigate = useNavigate()
|
|
173
|
+
const location = useLocation()
|
|
174
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
175
|
+
const capabilities = useCapabilitiesContext()
|
|
176
|
+
const openLocalTerminal = useOpenLocalTerminal()
|
|
177
|
+
const navCustomization = useNavCustomization()
|
|
178
|
+
|
|
179
|
+
// Auth check — detect if auth is enabled but user is not authenticated
|
|
180
|
+
const { data: authMe, isPending: authMePending } = useAuthMe()
|
|
181
|
+
|
|
182
|
+
// Restore navigation path after session-expiry re-auth redirect
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const returnPath = sessionStorage.getItem('radar_return_path')
|
|
185
|
+
if (returnPath) {
|
|
186
|
+
sessionStorage.removeItem('radar_return_path')
|
|
187
|
+
navigate(returnPath, { replace: true })
|
|
188
|
+
}
|
|
189
|
+
}, [navigate])
|
|
190
|
+
|
|
191
|
+
// Parse namespaces from URL (supports both 'namespaces' and legacy 'namespace')
|
|
192
|
+
const parseNamespacesFromURL = (params: URLSearchParams): string[] => {
|
|
193
|
+
// Prefer 'namespaces' (plural, comma-separated)
|
|
194
|
+
const nsParam = params.get('namespaces')
|
|
195
|
+
if (nsParam) {
|
|
196
|
+
return nsParam.split(',').map(s => s.trim()).filter(Boolean)
|
|
197
|
+
}
|
|
198
|
+
// Fall back to 'namespace' (singular) for backward compatibility
|
|
199
|
+
const ns = params.get('namespace')
|
|
200
|
+
if (ns) {
|
|
201
|
+
return [ns]
|
|
202
|
+
}
|
|
203
|
+
return []
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Initialize state from URL
|
|
207
|
+
const getInitialState = () => {
|
|
208
|
+
const namespaces = parseNamespacesFromURL(searchParams)
|
|
209
|
+
return {
|
|
210
|
+
namespaces,
|
|
211
|
+
topologyMode: (searchParams.get('mode') as TopologyMode) || 'resources',
|
|
212
|
+
// Default to namespace grouping when viewing all namespaces
|
|
213
|
+
grouping: (searchParams.get('group') as GroupingMode) || (namespaces.length === 0 ? 'namespace' : 'none'),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Get mainView from URL path
|
|
218
|
+
const mainView = getViewFromPath(location.pathname)
|
|
219
|
+
|
|
220
|
+
// Set mainView by navigating to the path
|
|
221
|
+
const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
|
|
222
|
+
const path = view === 'home' ? '/' : `/${view}`
|
|
223
|
+
|
|
224
|
+
// Start fresh — keep only cross-view params (namespaces), discard all view-specific ones
|
|
225
|
+
const newParams = new URLSearchParams()
|
|
226
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
227
|
+
if (globalNamespaces) {
|
|
228
|
+
newParams.set('namespaces', globalNamespaces)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Add any new params
|
|
232
|
+
if (params) {
|
|
233
|
+
for (const [key, value] of Object.entries(params)) {
|
|
234
|
+
newParams.set(key, value)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
navigate({ pathname: path, search: newParams.toString() })
|
|
239
|
+
}, [navigate, searchParams])
|
|
240
|
+
|
|
241
|
+
const [namespaces, setNamespaces] = useState<string[]>(getInitialState().namespaces)
|
|
242
|
+
// For large clusters: force SSE to reconnect with namespace filter
|
|
243
|
+
const [forceNamespaceFilter, setForceNamespaceFilter] = useState<string[] | undefined>(undefined)
|
|
244
|
+
const [selectedResource, setSelectedResource] = useState<SelectedResource | null>(null)
|
|
245
|
+
const [drawerInitialTab, setDrawerInitialTab] = useState<'detail' | 'yaml'>('detail')
|
|
246
|
+
const [selectedHelmRelease, setSelectedHelmRelease] = useState<SelectedHelmRelease | null>(null)
|
|
247
|
+
const [topologyMode, setTopologyMode] = useState<TopologyMode>(getInitialState().topologyMode)
|
|
248
|
+
const [groupingMode, setGroupingMode] = useState<GroupingMode>(getInitialState().grouping)
|
|
249
|
+
const [showPolicyEffect, setShowPolicyEffect] = useState(false)
|
|
250
|
+
// Topology filter state
|
|
251
|
+
const [visibleKinds, setVisibleKinds] = useState<Set<NodeKind>>(() => new Set(DEFAULT_VISIBLE_KINDS))
|
|
252
|
+
const [filterSidebarCollapsed, setFilterSidebarCollapsed] = useState(false)
|
|
253
|
+
// Track CRD kinds that have been auto-added to visibleKinds so we don't override user toggles
|
|
254
|
+
const seededCRDKindsRef = useRef<Set<string>>(new Set())
|
|
255
|
+
|
|
256
|
+
// Topology live-update pause state
|
|
257
|
+
const [topologyPaused, setTopologyPaused] = useState(false)
|
|
258
|
+
const [displayedTopology, setDisplayedTopology] = useState<typeof topology>(null)
|
|
259
|
+
const pendingTopologyRef = useRef<typeof topology>(null)
|
|
260
|
+
|
|
261
|
+
// Help overlay state
|
|
262
|
+
const [showHelp, setShowHelp] = useState(false)
|
|
263
|
+
|
|
264
|
+
// Command palette state
|
|
265
|
+
const [showCommandPalette, setShowCommandPalette] = useState(false)
|
|
266
|
+
|
|
267
|
+
// Settings dialog state
|
|
268
|
+
const [showSettings, setShowSettings] = useState(false)
|
|
269
|
+
|
|
270
|
+
// Listen for desktop "open-settings" event from native menu
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
|
|
273
|
+
| { EventsOn?: (event: string, callback: () => void) => () => void }
|
|
274
|
+
| undefined
|
|
275
|
+
if (!wailsRuntime?.EventsOn) return
|
|
276
|
+
return wailsRuntime.EventsOn('open-settings', () => setShowSettings(true))
|
|
277
|
+
}, [])
|
|
278
|
+
|
|
279
|
+
// Listen for "open-settings" DOM event (used by MCPSetupDialog etc.)
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
const handler = () => setShowSettings(true)
|
|
282
|
+
window.addEventListener('radar:open-settings', handler)
|
|
283
|
+
return () => window.removeEventListener('radar:open-settings', handler)
|
|
284
|
+
}, [])
|
|
285
|
+
|
|
286
|
+
// Diagnostics overlay state
|
|
287
|
+
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
|
288
|
+
|
|
289
|
+
// Drawer expanded state (drawer grows to full width and renders WorkloadView)
|
|
290
|
+
const [drawerExpanded, setDrawerExpanded] = useState(false)
|
|
291
|
+
|
|
292
|
+
// Suppress the mainView-change clear effect during controlled expand/collapse transitions.
|
|
293
|
+
const suppressViewClearRef = useRef(false)
|
|
294
|
+
|
|
295
|
+
// Animation hooks for smooth mount/unmount transitions
|
|
296
|
+
const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
|
|
297
|
+
const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
|
|
298
|
+
const helpOverlay = useAnimatedUnmount(showHelp, 300)
|
|
299
|
+
const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
|
|
300
|
+
const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
|
|
301
|
+
|
|
302
|
+
// Hold last valid values so drawers can animate out before data disappears
|
|
303
|
+
const lastResourceRef = useRef(selectedResource)
|
|
304
|
+
if (selectedResource) lastResourceRef.current = selectedResource
|
|
305
|
+
const drawerResource = selectedResource || lastResourceRef.current
|
|
306
|
+
|
|
307
|
+
const lastHelmReleaseRef = useRef(selectedHelmRelease)
|
|
308
|
+
if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
|
|
309
|
+
const drawerHelmRelease = selectedHelmRelease || lastHelmReleaseRef.current
|
|
310
|
+
|
|
311
|
+
// Navigate to a resource — uses View Transitions cross-fade when drawer is already open
|
|
312
|
+
const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
|
|
313
|
+
const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
|
|
314
|
+
if (selectedResource && document.startViewTransition) {
|
|
315
|
+
document.startViewTransition(() => flushSync(update))
|
|
316
|
+
} else {
|
|
317
|
+
update()
|
|
318
|
+
}
|
|
319
|
+
}, [selectedResource])
|
|
320
|
+
|
|
321
|
+
// Collapse from expanded WorkloadView back to drawer
|
|
322
|
+
const handleCollapseFromExpanded = useCallback(() => {
|
|
323
|
+
suppressViewClearRef.current = true
|
|
324
|
+
setDrawerExpanded(false)
|
|
325
|
+
navigate(-1)
|
|
326
|
+
}, [navigate])
|
|
327
|
+
|
|
328
|
+
// Theme toggle for keyboard shortcut
|
|
329
|
+
const { toggleTheme } = useTheme()
|
|
330
|
+
|
|
331
|
+
// Context switching for command palette
|
|
332
|
+
const switchContext = useSwitchContext()
|
|
333
|
+
|
|
334
|
+
// View switching keyboard shortcuts
|
|
335
|
+
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic']
|
|
336
|
+
useRegisterShortcuts([
|
|
337
|
+
...views.map((view, i) => ({
|
|
338
|
+
id: `view-${view}`,
|
|
339
|
+
keys: String(i + 1),
|
|
340
|
+
description: `Go to ${view.charAt(0).toUpperCase() + view.slice(1)}`,
|
|
341
|
+
category: 'Navigation' as const,
|
|
342
|
+
scope: 'global' as const,
|
|
343
|
+
handler: () => setMainView(view),
|
|
344
|
+
})),
|
|
345
|
+
{
|
|
346
|
+
id: 'theme-toggle',
|
|
347
|
+
keys: 't',
|
|
348
|
+
description: 'Toggle dark/light theme',
|
|
349
|
+
category: 'General' as const,
|
|
350
|
+
scope: 'global' as const,
|
|
351
|
+
handler: () => toggleTheme(),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: 'help-toggle',
|
|
355
|
+
keys: '?',
|
|
356
|
+
description: 'Show keyboard shortcuts',
|
|
357
|
+
category: 'General' as const,
|
|
358
|
+
scope: 'global' as const,
|
|
359
|
+
handler: () => setShowHelp(prev => !prev),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: 'command-palette',
|
|
363
|
+
keys: 'Cmd+k',
|
|
364
|
+
description: 'Open command palette',
|
|
365
|
+
category: 'General' as const,
|
|
366
|
+
scope: 'global' as const,
|
|
367
|
+
allowInInputs: true,
|
|
368
|
+
handler: () => setShowCommandPalette(true),
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: 'diagnostics',
|
|
372
|
+
keys: 'Ctrl+Shift+d',
|
|
373
|
+
description: 'Open diagnostics',
|
|
374
|
+
category: 'General' as const,
|
|
375
|
+
scope: 'global' as const,
|
|
376
|
+
allowInInputs: true,
|
|
377
|
+
handler: () => setShowDiagnostics(prev => !prev),
|
|
378
|
+
},
|
|
379
|
+
])
|
|
380
|
+
|
|
381
|
+
// Separate registration for help-close — its `enabled` changes with showHelp,
|
|
382
|
+
// and keeping it in the batch above would cause all stable shortcuts to churn.
|
|
383
|
+
useRegisterShortcut({
|
|
384
|
+
id: 'help-close',
|
|
385
|
+
keys: 'Escape',
|
|
386
|
+
description: 'Close overlay',
|
|
387
|
+
category: 'General',
|
|
388
|
+
scope: 'global',
|
|
389
|
+
handler: () => setShowHelp(false),
|
|
390
|
+
enabled: showHelp,
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// Compute effective grouping mode:
|
|
394
|
+
// - All namespaces: must use 'namespace' or 'app' (no 'none')
|
|
395
|
+
// - Single/specific namespaces with 'none': use 'namespace' internally but hide header
|
|
396
|
+
const hasNamespaceFilter = namespaces.length > 0
|
|
397
|
+
const effectiveGroupingMode: GroupingMode = useMemo(() => {
|
|
398
|
+
if (!hasNamespaceFilter && groupingMode === 'none') {
|
|
399
|
+
// All namespaces view - force namespace grouping
|
|
400
|
+
return 'namespace'
|
|
401
|
+
}
|
|
402
|
+
if (hasNamespaceFilter && groupingMode === 'none') {
|
|
403
|
+
// Filtered namespaces with "no grouping" - use namespace grouping for layout
|
|
404
|
+
return 'namespace'
|
|
405
|
+
}
|
|
406
|
+
return groupingMode
|
|
407
|
+
}, [hasNamespaceFilter, groupingMode])
|
|
408
|
+
|
|
409
|
+
// Hide group header when viewing a single namespace with namespace grouping —
|
|
410
|
+
// the namespace name is already shown in the breadcrumb/picker. Preserve headers
|
|
411
|
+
// for app/label grouping so those group boundaries remain visible.
|
|
412
|
+
const hideGroupHeader = namespaces.length === 1 && effectiveGroupingMode === 'namespace'
|
|
413
|
+
|
|
414
|
+
// Fetch available namespaces
|
|
415
|
+
const { data: availableNamespaces, error: namespacesError } = useNamespaces()
|
|
416
|
+
|
|
417
|
+
// Context switch state
|
|
418
|
+
const { isSwitching, targetContext, progressMessage, updateProgress, endSwitch } = useContextSwitch()
|
|
419
|
+
|
|
420
|
+
// Connection state (for graceful startup)
|
|
421
|
+
const { connection, retry: retryConnection, isRetrying, updateFromSSE: updateConnectionFromSSE } = useConnection()
|
|
422
|
+
|
|
423
|
+
// Query client for cache invalidation
|
|
424
|
+
const queryClient = useQueryClient()
|
|
425
|
+
|
|
426
|
+
// SSE-driven cache invalidation for resource lists, counts, and detail views.
|
|
427
|
+
// Uses a 3-second throttle window: first event starts the timer, all events within the
|
|
428
|
+
// window accumulate, then fire a single batch invalidation. This keeps max latency at 3s
|
|
429
|
+
// while coalescing burst events (e.g., 100-pod rollout → ~10 invalidations total).
|
|
430
|
+
const pendingInvalidationRef = useRef<{
|
|
431
|
+
kinds: Set<string>
|
|
432
|
+
hasCountChange: boolean
|
|
433
|
+
timer: number | null
|
|
434
|
+
}>({ kinds: new Set(), hasCountChange: false, timer: null })
|
|
435
|
+
|
|
436
|
+
const handleK8sEvent = useCallback((event: K8sEvent) => {
|
|
437
|
+
// Skip K8s Event kind — informational, not resource mutations
|
|
438
|
+
if (event.kind === 'Event') return
|
|
439
|
+
|
|
440
|
+
const pending = pendingInvalidationRef.current
|
|
441
|
+
pending.kinds.add(kindToPlural(event.kind))
|
|
442
|
+
if (event.operation === 'add' || event.operation === 'delete') {
|
|
443
|
+
pending.hasCountChange = true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Start throttle window on first event (don't reset — bounded 3s latency)
|
|
447
|
+
if (pending.timer !== null) return
|
|
448
|
+
pending.timer = window.setTimeout(() => {
|
|
449
|
+
for (const kind of pending.kinds) {
|
|
450
|
+
// Invalidate list queries (['resources', kind, ...]) and detail queries (['resource', kind, ...])
|
|
451
|
+
queryClient.invalidateQueries({ queryKey: ['resources', kind] })
|
|
452
|
+
queryClient.invalidateQueries({ queryKey: ['resource', kind] })
|
|
453
|
+
}
|
|
454
|
+
if (pending.hasCountChange) {
|
|
455
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
456
|
+
}
|
|
457
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
458
|
+
if (pending.kinds.has('secrets')) {
|
|
459
|
+
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
460
|
+
}
|
|
461
|
+
// Reset accumulator
|
|
462
|
+
pending.kinds = new Set()
|
|
463
|
+
pending.hasCountChange = false
|
|
464
|
+
pending.timer = null
|
|
465
|
+
}, 3000)
|
|
466
|
+
}, [queryClient])
|
|
467
|
+
|
|
468
|
+
// SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
|
|
469
|
+
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
470
|
+
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
471
|
+
const sseMode = topologyMode === 'fleet' ? 'resources' : topologyMode
|
|
472
|
+
const { topology, connected, reconnect: reconnectSSE } = useEventSource(namespaces, sseMode as 'resources' | 'traffic', {
|
|
473
|
+
onContextSwitchComplete: endSwitch,
|
|
474
|
+
onContextSwitchProgress: updateProgress,
|
|
475
|
+
onContextChanged: () => {
|
|
476
|
+
// Clear all React Query caches when cluster context changes
|
|
477
|
+
// This ensures helm releases, resources, etc. are refetched from the new cluster
|
|
478
|
+
// removeQueries clears cached data, invalidateQueries triggers refetch
|
|
479
|
+
queryClient.removeQueries()
|
|
480
|
+
queryClient.invalidateQueries()
|
|
481
|
+
|
|
482
|
+
// Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
|
|
483
|
+
if (pendingInvalidationRef.current.timer !== null) {
|
|
484
|
+
clearTimeout(pendingInvalidationRef.current.timer)
|
|
485
|
+
pendingInvalidationRef.current = { kinds: new Set(), hasCountChange: false, timer: null }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Close any open drawers/overlays — old cluster's resources don't exist on the new one
|
|
489
|
+
setSelectedResource(null)
|
|
490
|
+
setDrawerExpanded(false)
|
|
491
|
+
setSelectedHelmRelease(null)
|
|
492
|
+
|
|
493
|
+
// Reset URL to current view with no resource-specific params.
|
|
494
|
+
// Old cluster's selected pod/resource/kind don't exist on the new cluster.
|
|
495
|
+
navigate({ pathname: location.pathname, search: '' }, { replace: true })
|
|
496
|
+
|
|
497
|
+
// Auto-unpause so the new cluster's topology loads immediately
|
|
498
|
+
setTopologyPaused(false)
|
|
499
|
+
pendingTopologyRef.current = null
|
|
500
|
+
},
|
|
501
|
+
onConnectionStateChange: updateConnectionFromSSE,
|
|
502
|
+
onDeferredReady: () => {
|
|
503
|
+
// Deferred informers (secrets, events, configmaps, etc.) have finished syncing.
|
|
504
|
+
// Refetch dashboard so counts, warning events, and cert health fill in.
|
|
505
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
506
|
+
},
|
|
507
|
+
onK8sEvent: handleK8sEvent,
|
|
508
|
+
}, forceNamespaceFilter, showPolicyEffect)
|
|
509
|
+
const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
|
|
510
|
+
|
|
511
|
+
// Apply live topology updates only when not paused. While paused, buffer the
|
|
512
|
+
// latest snapshot so we can apply it instantly when the user resumes.
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
if (!topologyPaused) {
|
|
515
|
+
setDisplayedTopology(topology)
|
|
516
|
+
} else {
|
|
517
|
+
pendingTopologyRef.current = topology
|
|
518
|
+
}
|
|
519
|
+
}, [topology, topologyPaused])
|
|
520
|
+
|
|
521
|
+
const handleTogglePause = useCallback(() => {
|
|
522
|
+
setTopologyPaused(prev => {
|
|
523
|
+
if (prev && pendingTopologyRef.current !== null) {
|
|
524
|
+
// Resuming — apply the buffered snapshot immediately
|
|
525
|
+
setDisplayedTopology(pendingTopologyRef.current)
|
|
526
|
+
pendingTopologyRef.current = null
|
|
527
|
+
}
|
|
528
|
+
return !prev
|
|
529
|
+
})
|
|
530
|
+
}, [])
|
|
531
|
+
|
|
532
|
+
// Track CRD discovery status from topology (more direct than cluster-info)
|
|
533
|
+
// When discovery completes, topology will auto-update via SSE with new CRD nodes
|
|
534
|
+
const crdDiscoveryStatus = topology?.crdDiscoveryStatus
|
|
535
|
+
|
|
536
|
+
// Debug: log discovery status changes
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (crdDiscoveryStatus) {
|
|
539
|
+
console.log('[CRD Discovery] Status:', crdDiscoveryStatus)
|
|
540
|
+
}
|
|
541
|
+
}, [crdDiscoveryStatus])
|
|
542
|
+
|
|
543
|
+
// Auto-add CRD kinds (not in ALL_NODE_KINDS) to visibleKinds the first time they appear.
|
|
544
|
+
// Uses a ref to track which kinds have been seeded so user toggle-off choices are preserved.
|
|
545
|
+
const allNodeKindsSet = useMemo(() => new Set<string>(ALL_NODE_KINDS), [])
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!topology?.nodes) return
|
|
548
|
+
const newKinds: NodeKind[] = []
|
|
549
|
+
for (const node of topology.nodes) {
|
|
550
|
+
const k = node.kind as string
|
|
551
|
+
if (!allNodeKindsSet.has(k) && !seededCRDKindsRef.current.has(k)) {
|
|
552
|
+
seededCRDKindsRef.current.add(k)
|
|
553
|
+
if (!CRD_HIDDEN_BY_DEFAULT.has(k)) {
|
|
554
|
+
newKinds.push(node.kind)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (newKinds.length > 0) {
|
|
559
|
+
setVisibleKinds(prev => {
|
|
560
|
+
const next = new Set(prev)
|
|
561
|
+
for (const k of newKinds) next.add(k)
|
|
562
|
+
return next
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
}, [topology, allNodeKindsSet])
|
|
566
|
+
|
|
567
|
+
// Handle node selection - convert TopologyNode to SelectedResource for the drawer
|
|
568
|
+
const handleNodeClick = useCallback((node: TopologyNode) => {
|
|
569
|
+
// Skip Internet node - it's not a real resource
|
|
570
|
+
if (node.kind === 'Internet') return
|
|
571
|
+
|
|
572
|
+
// For PodGroup, we can't open a single resource drawer
|
|
573
|
+
// TODO: Could show a list of pods in the group
|
|
574
|
+
if (node.kind === 'PodGroup') return
|
|
575
|
+
|
|
576
|
+
navigateToResource({
|
|
577
|
+
kind: kindToPlural(node.kind),
|
|
578
|
+
namespace: (node.data.namespace as string) || '',
|
|
579
|
+
name: node.name,
|
|
580
|
+
})
|
|
581
|
+
}, [])
|
|
582
|
+
|
|
583
|
+
// Serialize namespaces for stable dependency tracking
|
|
584
|
+
const namespacesKey = namespaces.join(',')
|
|
585
|
+
|
|
586
|
+
// Update URL query params when state changes (path is handled by setMainView)
|
|
587
|
+
// Read from window.location.search (not React Router's searchParams) to preserve
|
|
588
|
+
// params set by child components via window.history.replaceState (e.g., kind from ResourcesView).
|
|
589
|
+
useEffect(() => {
|
|
590
|
+
const currentSearch = window.location.search
|
|
591
|
+
const params = new URLSearchParams(currentSearch)
|
|
592
|
+
|
|
593
|
+
// Update namespaces param
|
|
594
|
+
if (namespaces.length > 0) {
|
|
595
|
+
params.set('namespaces', namespaces.join(','))
|
|
596
|
+
} else {
|
|
597
|
+
params.delete('namespaces')
|
|
598
|
+
}
|
|
599
|
+
// Remove legacy 'namespace' param if present
|
|
600
|
+
params.delete('namespace')
|
|
601
|
+
|
|
602
|
+
// Topology-specific params: only set when on topology view, clean up otherwise
|
|
603
|
+
if (mainView === 'topology') {
|
|
604
|
+
if (topologyMode !== 'resources') {
|
|
605
|
+
params.set('mode', topologyMode)
|
|
606
|
+
} else {
|
|
607
|
+
params.delete('mode')
|
|
608
|
+
}
|
|
609
|
+
if (groupingMode !== 'none' && (namespaces.length === 0 || groupingMode !== 'namespace')) {
|
|
610
|
+
params.set('group', groupingMode)
|
|
611
|
+
} else {
|
|
612
|
+
params.delete('group')
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
params.delete('mode')
|
|
616
|
+
params.delete('group')
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Only update if params actually changed vs current URL
|
|
620
|
+
if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
|
|
621
|
+
setSearchParams(params, { replace: true })
|
|
622
|
+
}
|
|
623
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
|
|
624
|
+
}, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
|
|
625
|
+
|
|
626
|
+
// Sync state from URL when navigating (back/forward)
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
const urlNamespaces = parseNamespacesFromURL(searchParams)
|
|
629
|
+
|
|
630
|
+
if (urlNamespaces.join(',') !== namespacesKey) setNamespaces(urlNamespaces)
|
|
631
|
+
|
|
632
|
+
// Restore helm release from URL (back navigation)
|
|
633
|
+
const releaseParam = searchParams.get('release')
|
|
634
|
+
if (releaseParam) {
|
|
635
|
+
const slashIdx = releaseParam.indexOf('/')
|
|
636
|
+
if (slashIdx > 0) {
|
|
637
|
+
const ns = releaseParam.slice(0, slashIdx)
|
|
638
|
+
const name = releaseParam.slice(slashIdx + 1)
|
|
639
|
+
setSelectedHelmRelease({ namespace: ns, name })
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}, [searchParams])
|
|
643
|
+
|
|
644
|
+
// Auto-adjust grouping when namespaces change
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
if (namespaces.length === 0 && groupingMode === 'none') {
|
|
647
|
+
// Switching to all namespaces - enable namespace grouping by default
|
|
648
|
+
setGroupingMode('namespace')
|
|
649
|
+
} else if (namespaces.length > 0 && groupingMode === 'namespace') {
|
|
650
|
+
// Switching to specific namespaces - disable namespace grouping
|
|
651
|
+
setGroupingMode('none')
|
|
652
|
+
}
|
|
653
|
+
}, [namespacesKey])
|
|
654
|
+
|
|
655
|
+
// Clear resource selection when changing views or namespaces
|
|
656
|
+
// But preserve selectedResource when navigating TO resources view (e.g., from Helm deep link)
|
|
657
|
+
const prevMainView = useRef(mainView)
|
|
658
|
+
useEffect(() => {
|
|
659
|
+
// Skip clearing during controlled expand/collapse transitions
|
|
660
|
+
if (suppressViewClearRef.current) {
|
|
661
|
+
suppressViewClearRef.current = false
|
|
662
|
+
prevMainView.current = mainView
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const navigatingToResources = mainView === 'resources' && prevMainView.current !== 'resources'
|
|
667
|
+
const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
|
|
668
|
+
prevMainView.current = mainView
|
|
669
|
+
|
|
670
|
+
// Don't clear selectedResource when navigating TO resources view (deep link from Helm)
|
|
671
|
+
if (!navigatingToResources) {
|
|
672
|
+
setSelectedResource(null)
|
|
673
|
+
}
|
|
674
|
+
// Don't clear helm release when navigating TO helm (back button restores from URL)
|
|
675
|
+
if (!navigatingToHelm) {
|
|
676
|
+
setSelectedHelmRelease(null)
|
|
677
|
+
}
|
|
678
|
+
setDrawerExpanded(false)
|
|
679
|
+
}, [mainView])
|
|
680
|
+
|
|
681
|
+
// Clear resource selection when namespaces change
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
setSelectedResource(null)
|
|
684
|
+
setDrawerExpanded(false)
|
|
685
|
+
setSelectedHelmRelease(null)
|
|
686
|
+
}, [namespacesKey])
|
|
687
|
+
|
|
688
|
+
// Filter topology based on visible kinds (uses displayedTopology which respects pause)
|
|
689
|
+
const filteredTopology = useMemo((): Topology | null => {
|
|
690
|
+
if (!displayedTopology) return null
|
|
691
|
+
|
|
692
|
+
// Fleet mode overrides visible kinds to show only CAPI resources + Node
|
|
693
|
+
const effectiveKinds = topologyMode === 'fleet' ? FLEET_MODE_KINDS : visibleKinds
|
|
694
|
+
|
|
695
|
+
// Filter by namespace (frontend-side) and by visible kinds
|
|
696
|
+
const nsSet = namespaces.length > 0 ? new Set(namespaces) : null
|
|
697
|
+
const filteredNodes = displayedTopology.nodes.filter(node =>
|
|
698
|
+
effectiveKinds.has(node.kind) &&
|
|
699
|
+
(!nsSet || nsSet.has(node.data.namespace as string) || !(node.data.namespace as string))
|
|
700
|
+
)
|
|
701
|
+
const filteredNodeIds = new Set(filteredNodes.map(n => n.id))
|
|
702
|
+
|
|
703
|
+
// Keep edges where both source and target are visible
|
|
704
|
+
// Also respect skipIfKindVisible - hide shortcut edges when intermediate kind is shown
|
|
705
|
+
const filteredEdges = displayedTopology.edges.filter(edge => {
|
|
706
|
+
// Both endpoints must be visible
|
|
707
|
+
if (!filteredNodeIds.has(edge.source) || !filteredNodeIds.has(edge.target)) {
|
|
708
|
+
return false
|
|
709
|
+
}
|
|
710
|
+
// If this is a shortcut edge, hide it when the intermediate kind is visible
|
|
711
|
+
if (edge.skipIfKindVisible && effectiveKinds.has(edge.skipIfKindVisible as NodeKind)) {
|
|
712
|
+
return false
|
|
713
|
+
}
|
|
714
|
+
return true
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
nodes: filteredNodes,
|
|
719
|
+
edges: filteredEdges,
|
|
720
|
+
}
|
|
721
|
+
}, [displayedTopology, visibleKinds, namespaces, topologyMode])
|
|
722
|
+
|
|
723
|
+
// Filter handlers
|
|
724
|
+
const handleToggleKind = useCallback((kind: NodeKind) => {
|
|
725
|
+
setVisibleKinds(prev => {
|
|
726
|
+
const next = new Set(prev)
|
|
727
|
+
if (next.has(kind)) {
|
|
728
|
+
next.delete(kind)
|
|
729
|
+
} else {
|
|
730
|
+
next.add(kind)
|
|
731
|
+
}
|
|
732
|
+
return next
|
|
733
|
+
})
|
|
734
|
+
}, [])
|
|
735
|
+
|
|
736
|
+
const handleShowAllKinds = useCallback(() => {
|
|
737
|
+
// Include all static kinds plus any dynamic CRD kinds from the topology
|
|
738
|
+
const allKinds = new Set<NodeKind>(ALL_NODE_KINDS)
|
|
739
|
+
if (topology?.nodes) {
|
|
740
|
+
for (const node of topology.nodes) {
|
|
741
|
+
allKinds.add(node.kind)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
setVisibleKinds(allKinds)
|
|
745
|
+
}, [topology])
|
|
746
|
+
|
|
747
|
+
const handleHideAllKinds = useCallback(() => {
|
|
748
|
+
setVisibleKinds(new Set())
|
|
749
|
+
}, [])
|
|
750
|
+
|
|
751
|
+
return (
|
|
752
|
+
<PortForwardProvider>
|
|
753
|
+
<div className="flex flex-col h-screen bg-theme-base min-w-[800px]">
|
|
754
|
+
{/* Header */}
|
|
755
|
+
<header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
|
|
756
|
+
{/* Left: Logo + Cluster info */}
|
|
757
|
+
<div className="flex items-center gap-4 shrink-0">
|
|
758
|
+
{navCustomization.brandSlot ?? (
|
|
759
|
+
<div className="flex items-center gap-2.5">
|
|
760
|
+
<Logo />
|
|
761
|
+
<span className="text-xl text-theme-text-primary leading-none -translate-y-0.5" style={{ fontFamily: "'DM Sans', sans-serif", fontWeight: 520 }}>radar</span>
|
|
762
|
+
</div>
|
|
763
|
+
)}
|
|
764
|
+
|
|
765
|
+
<div className="flex items-center gap-2">
|
|
766
|
+
{navCustomization.contextSlot ?? <ContextSwitcher />}
|
|
767
|
+
{/* Connection status - next to cluster name */}
|
|
768
|
+
<div className="flex items-center gap-1.5 ml-1">
|
|
769
|
+
<Tooltip
|
|
770
|
+
content={
|
|
771
|
+
!connected
|
|
772
|
+
? 'Disconnected'
|
|
773
|
+
: crdDiscoveryStatus === 'discovering'
|
|
774
|
+
? 'Connected — discovering Custom Resources...'
|
|
775
|
+
: 'Connected'
|
|
776
|
+
}
|
|
777
|
+
delay={100}
|
|
778
|
+
position="bottom"
|
|
779
|
+
>
|
|
780
|
+
<span
|
|
781
|
+
className={`w-2 h-2 rounded-full ${
|
|
782
|
+
!connected
|
|
783
|
+
? 'bg-red-500'
|
|
784
|
+
: crdDiscoveryStatus === 'discovering'
|
|
785
|
+
? 'bg-amber-400 animate-pulse'
|
|
786
|
+
: 'bg-green-500'
|
|
787
|
+
}`}
|
|
788
|
+
/>
|
|
789
|
+
</Tooltip>
|
|
790
|
+
<span className="text-xs text-theme-text-tertiary hidden xl:inline">
|
|
791
|
+
{!connected
|
|
792
|
+
? 'Disconnected'
|
|
793
|
+
: crdDiscoveryStatus === 'discovering'
|
|
794
|
+
? 'Discovering Custom Resources...'
|
|
795
|
+
: 'Connected'}
|
|
796
|
+
</span>
|
|
797
|
+
{!connected && (
|
|
798
|
+
<button
|
|
799
|
+
onClick={reconnect}
|
|
800
|
+
disabled={isReconnecting}
|
|
801
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-50"
|
|
802
|
+
title="Reconnect"
|
|
803
|
+
>
|
|
804
|
+
<RefreshCw className={`w-3 h-3 ${isReconnecting ? 'animate-spin' : ''}`} />
|
|
805
|
+
</button>
|
|
806
|
+
)}
|
|
807
|
+
</div>
|
|
808
|
+
{/* Port forwards indicator — shown only when sessions exist */}
|
|
809
|
+
<PortForwardIndicator />
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
{/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
|
|
814
|
+
<div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-1 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
|
|
815
|
+
{([
|
|
816
|
+
{ view: 'home' as const, icon: Home, label: 'Home' },
|
|
817
|
+
{ view: 'topology' as const, icon: Network, label: 'Topology' },
|
|
818
|
+
{ view: 'resources' as const, icon: List, label: 'Resources' },
|
|
819
|
+
{ view: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
|
820
|
+
{ view: 'helm' as const, icon: Package, label: 'Helm' },
|
|
821
|
+
{ view: 'traffic' as const, icon: Activity, label: 'Traffic' },
|
|
822
|
+
// Cost is intentionally hidden from the pill bar for now — the view still
|
|
823
|
+
// exists and is reachable via /cost, the Home dashboard card, and the
|
|
824
|
+
// command palette (⌘K). Remove this comment to restore it.
|
|
825
|
+
{ view: 'audit' as const, icon: ShieldCheck, label: 'Audit' },
|
|
826
|
+
] as const).map(({ view, icon: Icon, label }) => (
|
|
827
|
+
<Tooltip key={view} content={label} delay={100} position="bottom">
|
|
828
|
+
<button
|
|
829
|
+
onClick={() => setMainView(view)}
|
|
830
|
+
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-sm rounded-full transition-colors ${
|
|
831
|
+
mainView === view
|
|
832
|
+
? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
|
|
833
|
+
: 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
|
|
834
|
+
}`}
|
|
835
|
+
>
|
|
836
|
+
<Icon className="w-4 h-4" />
|
|
837
|
+
<span className="hidden lg:inline">{label}</span>
|
|
838
|
+
</button>
|
|
839
|
+
</Tooltip>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
{/* Right: Controls */}
|
|
844
|
+
<div className="flex items-center gap-3 shrink-0">
|
|
845
|
+
{/* Namespace selector with search */}
|
|
846
|
+
<NamespaceSelector
|
|
847
|
+
value={namespaces}
|
|
848
|
+
onChange={setNamespaces}
|
|
849
|
+
namespaces={availableNamespaces}
|
|
850
|
+
namespacesError={namespacesError}
|
|
851
|
+
disabled={mainView === 'helm'}
|
|
852
|
+
disabledTooltip="Helm view always shows all namespaces"
|
|
853
|
+
/>
|
|
854
|
+
|
|
855
|
+
{/* Command palette trigger */}
|
|
856
|
+
<button
|
|
857
|
+
onClick={() => setShowCommandPalette(true)}
|
|
858
|
+
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"
|
|
859
|
+
>
|
|
860
|
+
<Search className="w-3.5 h-3.5" />
|
|
861
|
+
<kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
|
|
862
|
+
{typeof navigator !== 'undefined' && navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'}K
|
|
863
|
+
</kbd>
|
|
864
|
+
</button>
|
|
865
|
+
|
|
866
|
+
{/* GitHub star — hidden in embedded mode (not OSS-distribution chrome). */}
|
|
867
|
+
{!navCustomization.embedded && (
|
|
868
|
+
<div className="hidden lg:block">
|
|
869
|
+
<GitHubStarButton />
|
|
870
|
+
</div>
|
|
871
|
+
)}
|
|
872
|
+
|
|
873
|
+
{/* Local terminal */}
|
|
874
|
+
{capabilities.localTerminal && (
|
|
875
|
+
<button
|
|
876
|
+
onClick={() => openLocalTerminal()}
|
|
877
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
878
|
+
title="Open local terminal"
|
|
879
|
+
>
|
|
880
|
+
<SquareTerminal className="w-4 h-4" />
|
|
881
|
+
</button>
|
|
882
|
+
)}
|
|
883
|
+
|
|
884
|
+
{/* Theme toggle */}
|
|
885
|
+
<div className="hidden md:block">
|
|
886
|
+
<ThemeToggle />
|
|
887
|
+
</div>
|
|
888
|
+
|
|
889
|
+
{/* Settings */}
|
|
890
|
+
<button
|
|
891
|
+
onClick={() => setShowSettings(true)}
|
|
892
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
893
|
+
title="Settings"
|
|
894
|
+
>
|
|
895
|
+
<Settings className="w-4 h-4" />
|
|
896
|
+
</button>
|
|
897
|
+
|
|
898
|
+
{/* User menu (when auth enabled) — hidden in embedded mode;
|
|
899
|
+
host app typically provides its own via rightExtras. */}
|
|
900
|
+
{!navCustomization.embedded && <UserMenu />}
|
|
901
|
+
|
|
902
|
+
{/* Consumer-provided extras (e.g. Radar Hub's Install button +
|
|
903
|
+
avatar menu) appended to the right of the action bar. */}
|
|
904
|
+
{navCustomization.rightExtras}
|
|
905
|
+
</div>
|
|
906
|
+
</header>
|
|
907
|
+
|
|
908
|
+
{/* Auth barrier - show when auth is enabled but user is not authenticated */}
|
|
909
|
+
{authMe?.authEnabled && !authMe?.username && authMe.authMode === 'proxy' && (
|
|
910
|
+
<AuthBarrier authMode="proxy" />
|
|
911
|
+
)}
|
|
912
|
+
{authMe?.authEnabled && !authMe?.username && authMe.authMode === 'oidc' && (
|
|
913
|
+
<AuthBarrier authMode="oidc" />
|
|
914
|
+
)}
|
|
915
|
+
|
|
916
|
+
{/* Connection error view - show when disconnected */}
|
|
917
|
+
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'disconnected' && (
|
|
918
|
+
<ConnectionErrorView
|
|
919
|
+
connection={connection}
|
|
920
|
+
onRetry={retryConnection}
|
|
921
|
+
isRetrying={isRetrying}
|
|
922
|
+
/>
|
|
923
|
+
)}
|
|
924
|
+
|
|
925
|
+
{/* Connecting view - show during initial connection or retry */}
|
|
926
|
+
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
927
|
+
<div className="flex-1 flex items-center justify-center bg-theme-base">
|
|
928
|
+
<div className="flex flex-col items-center gap-4 text-theme-text-secondary">
|
|
929
|
+
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
|
930
|
+
<div className="text-center">
|
|
931
|
+
<p className="font-medium text-theme-text-primary">Connecting to cluster</p>
|
|
932
|
+
<p className="text-sm text-theme-text-secondary mt-1">{connection.context || 'Loading...'}</p>
|
|
933
|
+
{connection.progressMessage && (
|
|
934
|
+
<p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
|
|
935
|
+
{connection.progressMessage}
|
|
936
|
+
</p>
|
|
937
|
+
)}
|
|
938
|
+
</div>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
)}
|
|
942
|
+
|
|
943
|
+
{/* Context switching overlay */}
|
|
944
|
+
{isSwitching && (
|
|
945
|
+
<div className="flex-1 flex items-center justify-center bg-theme-base">
|
|
946
|
+
<div className="flex flex-col items-center gap-4 text-theme-text-secondary">
|
|
947
|
+
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
|
948
|
+
<div className="text-center">
|
|
949
|
+
<div className="text-sm font-medium text-theme-text-primary">Switching context</div>
|
|
950
|
+
{targetContext && (
|
|
951
|
+
<div className="text-xs mt-2 text-theme-text-tertiary">
|
|
952
|
+
{targetContext.provider ? (
|
|
953
|
+
<span className="flex items-center justify-center gap-1.5">
|
|
954
|
+
<span className="text-blue-400 font-medium">{targetContext.provider}</span>
|
|
955
|
+
{targetContext.account && (
|
|
956
|
+
<>
|
|
957
|
+
<span className="text-theme-text-tertiary/50">•</span>
|
|
958
|
+
<span>{targetContext.account}</span>
|
|
959
|
+
</>
|
|
960
|
+
)}
|
|
961
|
+
{targetContext.region && (
|
|
962
|
+
<>
|
|
963
|
+
<span className="text-theme-text-tertiary/50">•</span>
|
|
964
|
+
<span>{targetContext.region}</span>
|
|
965
|
+
</>
|
|
966
|
+
)}
|
|
967
|
+
<span className="text-theme-text-tertiary/50">•</span>
|
|
968
|
+
<span className="text-theme-text-secondary font-medium">{targetContext.clusterName}</span>
|
|
969
|
+
</span>
|
|
970
|
+
) : (
|
|
971
|
+
<span>{targetContext.raw}</span>
|
|
972
|
+
)}
|
|
973
|
+
</div>
|
|
974
|
+
)}
|
|
975
|
+
{progressMessage && (
|
|
976
|
+
<div className="text-xs mt-3 text-theme-text-tertiary animate-pulse">
|
|
977
|
+
{progressMessage}
|
|
978
|
+
</div>
|
|
979
|
+
)}
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
)}
|
|
984
|
+
|
|
985
|
+
{/* Main content - only show when connected and authenticated */}
|
|
986
|
+
{!isSwitching && !authMePending && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connected' && <div className="flex-1 flex overflow-hidden">
|
|
987
|
+
<ErrorBoundary>
|
|
988
|
+
{/* Home dashboard */}
|
|
989
|
+
{mainView === 'home' && (
|
|
990
|
+
<HomeView
|
|
991
|
+
namespaces={namespaces}
|
|
992
|
+
topology={topology}
|
|
993
|
+
onNavigateToView={setMainView}
|
|
994
|
+
onNavigateToResourceKind={(kind, apiGroup, filters) => {
|
|
995
|
+
// Navigate to resources view with kind in URL path
|
|
996
|
+
console.debug('[filters] App.onNavigateToResourceKind:', { kind, apiGroup, filters })
|
|
997
|
+
const newParams = new URLSearchParams(searchParams)
|
|
998
|
+
newParams.delete('kind') // kind is now in the path
|
|
999
|
+
newParams.delete('mode')
|
|
1000
|
+
newParams.delete('resource')
|
|
1001
|
+
newParams.delete('group') // Clear topology grouping param to avoid leaking into resources view
|
|
1002
|
+
if (apiGroup) {
|
|
1003
|
+
newParams.set('apiGroup', apiGroup)
|
|
1004
|
+
} else {
|
|
1005
|
+
newParams.delete('apiGroup')
|
|
1006
|
+
}
|
|
1007
|
+
// Apply column filters if provided
|
|
1008
|
+
if (filters && Object.keys(filters).length > 0) {
|
|
1009
|
+
const filtersStr = serializeColumnFilters(filters)
|
|
1010
|
+
if (filtersStr) {
|
|
1011
|
+
newParams.set('filters', filtersStr)
|
|
1012
|
+
}
|
|
1013
|
+
} else {
|
|
1014
|
+
newParams.delete('filters')
|
|
1015
|
+
}
|
|
1016
|
+
const targetURL = `/resources/${kind}?${newParams.toString()}`
|
|
1017
|
+
console.debug('[filters] App.onNavigateToResourceKind: navigating to', targetURL)
|
|
1018
|
+
navigate({ pathname: `/resources/${kind}`, search: newParams.toString() })
|
|
1019
|
+
}}
|
|
1020
|
+
onNavigateToResource={(resource) => {
|
|
1021
|
+
// Switch to resources view and open the resource detail drawer
|
|
1022
|
+
setSelectedResource(resource)
|
|
1023
|
+
const newParams = new URLSearchParams(searchParams)
|
|
1024
|
+
newParams.delete('kind') // kind is now in the path
|
|
1025
|
+
newParams.delete('mode')
|
|
1026
|
+
newParams.delete('group')
|
|
1027
|
+
newParams.delete('resource')
|
|
1028
|
+
if (resource.group) {
|
|
1029
|
+
newParams.set('apiGroup', resource.group)
|
|
1030
|
+
} else {
|
|
1031
|
+
newParams.delete('apiGroup')
|
|
1032
|
+
}
|
|
1033
|
+
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1034
|
+
}}
|
|
1035
|
+
/>
|
|
1036
|
+
)}
|
|
1037
|
+
|
|
1038
|
+
{/* Topology view */}
|
|
1039
|
+
{mainView === 'topology' && (
|
|
1040
|
+
<>
|
|
1041
|
+
{topology?.requiresNamespaceFilter && namespaces.length === 0 ? (
|
|
1042
|
+
/* Large cluster: prompt user to select a namespace */
|
|
1043
|
+
<div className="flex-1 flex items-center justify-center">
|
|
1044
|
+
<div className="max-w-md w-full mx-4 text-center">
|
|
1045
|
+
<div className="bg-theme-surface border border-theme-border rounded-xl shadow-lg p-6">
|
|
1046
|
+
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-blue-500/10 flex items-center justify-center">
|
|
1047
|
+
<Network className="w-6 h-6 text-blue-400" />
|
|
1048
|
+
</div>
|
|
1049
|
+
<h2 className="text-lg font-semibold text-theme-text-primary mb-2">
|
|
1050
|
+
Large Cluster Detected
|
|
1051
|
+
</h2>
|
|
1052
|
+
<p className="text-sm text-theme-text-secondary mb-5">
|
|
1053
|
+
This cluster has too many resources to render the full topology.
|
|
1054
|
+
Select a namespace to explore.
|
|
1055
|
+
</p>
|
|
1056
|
+
<div className="relative">
|
|
1057
|
+
<LargeClusterNamespacePicker
|
|
1058
|
+
namespaces={availableNamespaces}
|
|
1059
|
+
onSelect={(ns) => {
|
|
1060
|
+
setNamespaces([ns])
|
|
1061
|
+
// Large clusters need server-side filtering — reconnect SSE with namespace
|
|
1062
|
+
setForceNamespaceFilter([ns])
|
|
1063
|
+
}}
|
|
1064
|
+
/>
|
|
1065
|
+
</div>
|
|
1066
|
+
</div>
|
|
1067
|
+
</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
) : (
|
|
1070
|
+
<>
|
|
1071
|
+
{/* Filter sidebar */}
|
|
1072
|
+
<TopologyFilterSidebar
|
|
1073
|
+
nodes={topology?.nodes || []}
|
|
1074
|
+
visibleKinds={visibleKinds}
|
|
1075
|
+
onToggleKind={handleToggleKind}
|
|
1076
|
+
onShowAll={handleShowAllKinds}
|
|
1077
|
+
onHideAll={handleHideAllKinds}
|
|
1078
|
+
collapsed={filterSidebarCollapsed}
|
|
1079
|
+
onToggleCollapse={() => setFilterSidebarCollapsed(prev => !prev)}
|
|
1080
|
+
hiddenKinds={topology?.hiddenKinds}
|
|
1081
|
+
onEnableHiddenKind={(kind) => {
|
|
1082
|
+
setVisibleKinds(prev => new Set(prev).add(kind as NodeKind))
|
|
1083
|
+
console.log(`[topology] User requested to show hidden kind: ${kind}`)
|
|
1084
|
+
}}
|
|
1085
|
+
/>
|
|
1086
|
+
|
|
1087
|
+
<div className="flex-1 relative">
|
|
1088
|
+
<TopologyGraph
|
|
1089
|
+
topology={filteredTopology}
|
|
1090
|
+
viewMode={topologyMode}
|
|
1091
|
+
groupingMode={effectiveGroupingMode}
|
|
1092
|
+
hideGroupHeader={hideGroupHeader}
|
|
1093
|
+
onNodeClick={handleNodeClick}
|
|
1094
|
+
selectedNodeId={selectedResource ? `${apiResourceToNodeIdPrefix(selectedResource.kind)}-${selectedResource.namespace}-${selectedResource.name}` : undefined}
|
|
1095
|
+
paused={topologyPaused}
|
|
1096
|
+
onTogglePause={handleTogglePause}
|
|
1097
|
+
onMaximizeNamespace={(ns) => setNamespaces([ns])}
|
|
1098
|
+
namespaceBreadcrumb={namespaces.length === 1 ? namespaces[0] : undefined}
|
|
1099
|
+
onClearNamespace={namespaces.length === 1 ? () => setNamespaces([]) : undefined}
|
|
1100
|
+
namespacesKey={namespaces.join(',')}
|
|
1101
|
+
/>
|
|
1102
|
+
|
|
1103
|
+
{/* Topology controls overlay - top right */}
|
|
1104
|
+
<TopologyControls
|
|
1105
|
+
viewMode={topologyMode}
|
|
1106
|
+
onViewModeChange={(mode) => {
|
|
1107
|
+
setTopologyMode(mode)
|
|
1108
|
+
// Fleet mode: namespace grouping for structure, but expanded (not collapsed chips)
|
|
1109
|
+
if (mode === 'fleet') setGroupingMode('namespace')
|
|
1110
|
+
}}
|
|
1111
|
+
groupingMode={groupingMode}
|
|
1112
|
+
onGroupingModeChange={setGroupingMode}
|
|
1113
|
+
showNoGrouping={hasNamespaceFilter}
|
|
1114
|
+
showPolicyEffect={showPolicyEffect}
|
|
1115
|
+
onShowPolicyEffectChange={setShowPolicyEffect}
|
|
1116
|
+
showFleetMode={displayedTopology?.nodes?.some(n => FLEET_MODE_KINDS.has(n.kind as NodeKind)) ?? false}
|
|
1117
|
+
/>
|
|
1118
|
+
</div>
|
|
1119
|
+
</>
|
|
1120
|
+
)}
|
|
1121
|
+
</>
|
|
1122
|
+
)}
|
|
1123
|
+
|
|
1124
|
+
{/* Resources view */}
|
|
1125
|
+
{mainView === 'resources' && (
|
|
1126
|
+
<ResourcesView
|
|
1127
|
+
namespaces={namespaces}
|
|
1128
|
+
selectedResource={selectedResource}
|
|
1129
|
+
onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
|
|
1130
|
+
onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
|
|
1131
|
+
onKindChange={() => setSelectedResource(null)}
|
|
1132
|
+
/>
|
|
1133
|
+
)}
|
|
1134
|
+
|
|
1135
|
+
{/* Timeline view */}
|
|
1136
|
+
{mainView === 'timeline' && (
|
|
1137
|
+
<TimelineView
|
|
1138
|
+
namespaces={namespaces}
|
|
1139
|
+
onResourceClick={(resource) => {
|
|
1140
|
+
navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
|
|
1141
|
+
}}
|
|
1142
|
+
initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
|
|
1143
|
+
initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
|
|
1144
|
+
initialTimeRange={(searchParams.get('time') as '5m' | '30m' | '1h' | '6h' | '24h' | 'all') || undefined}
|
|
1145
|
+
requiresNamespaceFilter={topology?.requiresNamespaceFilter && namespaces.length === 0}
|
|
1146
|
+
availableNamespaces={availableNamespaces}
|
|
1147
|
+
onNamespaceSelect={(ns) => setNamespaces([ns])}
|
|
1148
|
+
/>
|
|
1149
|
+
)}
|
|
1150
|
+
|
|
1151
|
+
{/* Helm view - always show all namespaces since releases span multiple ns */}
|
|
1152
|
+
{mainView === 'helm' && (
|
|
1153
|
+
<HelmView
|
|
1154
|
+
namespace=""
|
|
1155
|
+
selectedRelease={selectedHelmRelease}
|
|
1156
|
+
onReleaseClick={(ns, name) => {
|
|
1157
|
+
setSelectedHelmRelease({ namespace: ns, name })
|
|
1158
|
+
const params = new URLSearchParams(window.location.search)
|
|
1159
|
+
params.set('release', `${ns}/${name}`)
|
|
1160
|
+
setSearchParams(params, { replace: true })
|
|
1161
|
+
}}
|
|
1162
|
+
/>
|
|
1163
|
+
)}
|
|
1164
|
+
|
|
1165
|
+
{/* Traffic view */}
|
|
1166
|
+
{mainView === 'traffic' && (
|
|
1167
|
+
<TrafficView namespaces={namespaces} />
|
|
1168
|
+
)}
|
|
1169
|
+
|
|
1170
|
+
{/* Cost detail view */}
|
|
1171
|
+
{mainView === 'cost' && (
|
|
1172
|
+
<CostView onBack={() => setMainView('home')} />
|
|
1173
|
+
)}
|
|
1174
|
+
|
|
1175
|
+
{/* Best practices detail view */}
|
|
1176
|
+
{mainView === 'audit' && (
|
|
1177
|
+
<AuditView
|
|
1178
|
+
namespaces={namespaces}
|
|
1179
|
+
onBack={() => setMainView('home')}
|
|
1180
|
+
onNavigateToResource={(resource) => {
|
|
1181
|
+
const pluralKind = kindToPlural(resource.kind)
|
|
1182
|
+
setSelectedResource({ ...resource, kind: pluralKind })
|
|
1183
|
+
const newParams = new URLSearchParams(searchParams)
|
|
1184
|
+
newParams.delete('kind')
|
|
1185
|
+
newParams.delete('mode')
|
|
1186
|
+
newParams.delete('group')
|
|
1187
|
+
newParams.delete('resource')
|
|
1188
|
+
if (resource.group) {
|
|
1189
|
+
newParams.set('apiGroup', resource.group)
|
|
1190
|
+
} else {
|
|
1191
|
+
newParams.delete('apiGroup')
|
|
1192
|
+
}
|
|
1193
|
+
navigate({ pathname: `/resources/${pluralKind}`, search: newParams.toString() })
|
|
1194
|
+
}}
|
|
1195
|
+
/>
|
|
1196
|
+
)}
|
|
1197
|
+
|
|
1198
|
+
{/* Workload full view (direct URL only — expand from drawer uses drawer's expanded state) */}
|
|
1199
|
+
{mainView === 'workload' && !drawerExpanded && (
|
|
1200
|
+
<WorkloadViewRoute
|
|
1201
|
+
onNavigateToResource={(resource) => {
|
|
1202
|
+
navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
|
|
1203
|
+
}}
|
|
1204
|
+
/>
|
|
1205
|
+
)}
|
|
1206
|
+
|
|
1207
|
+
</ErrorBoundary>
|
|
1208
|
+
</div>}
|
|
1209
|
+
|
|
1210
|
+
{/* Resource detail drawer — stays mounted, expands to full-screen WorkloadView */}
|
|
1211
|
+
{resourceDrawer.shouldRender && drawerResource && (
|
|
1212
|
+
<ResourceDetailDrawer
|
|
1213
|
+
resource={drawerResource}
|
|
1214
|
+
initialTab={drawerInitialTab}
|
|
1215
|
+
isOpen={resourceDrawer.isOpen}
|
|
1216
|
+
expanded={drawerExpanded}
|
|
1217
|
+
onClose={() => { setSelectedResource(null); setDrawerInitialTab('detail'); setDrawerExpanded(false) }}
|
|
1218
|
+
onNavigate={(res) => navigateToResource(res)}
|
|
1219
|
+
onExpand={(res) => {
|
|
1220
|
+
suppressViewClearRef.current = true
|
|
1221
|
+
setDrawerExpanded(true)
|
|
1222
|
+
navigate(`/workload/${res.kind}/${res.namespace}/${res.name}`)
|
|
1223
|
+
}}
|
|
1224
|
+
onCollapse={handleCollapseFromExpanded}
|
|
1225
|
+
onNavigateToResource={(resource) => {
|
|
1226
|
+
setSelectedResource(resource)
|
|
1227
|
+
navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`, { replace: true })
|
|
1228
|
+
}}
|
|
1229
|
+
/>
|
|
1230
|
+
)}
|
|
1231
|
+
|
|
1232
|
+
{/* Helm release drawer */}
|
|
1233
|
+
{helmDrawer.shouldRender && drawerHelmRelease && (
|
|
1234
|
+
<HelmReleaseDrawer
|
|
1235
|
+
release={drawerHelmRelease}
|
|
1236
|
+
isOpen={helmDrawer.isOpen}
|
|
1237
|
+
onClose={() => {
|
|
1238
|
+
setSelectedHelmRelease(null)
|
|
1239
|
+
const params = new URLSearchParams(window.location.search)
|
|
1240
|
+
params.delete('release')
|
|
1241
|
+
setSearchParams(params, { replace: true })
|
|
1242
|
+
}}
|
|
1243
|
+
onNavigateToResource={(resource) => {
|
|
1244
|
+
// Navigate to resources view with kind in path and open the resource detail drawer
|
|
1245
|
+
setSelectedHelmRelease(null)
|
|
1246
|
+
const newParams = new URLSearchParams()
|
|
1247
|
+
const globalNamespaces = searchParams.get('namespaces')
|
|
1248
|
+
if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
|
|
1249
|
+
navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
|
|
1250
|
+
setSelectedResource(resource)
|
|
1251
|
+
}}
|
|
1252
|
+
/>
|
|
1253
|
+
)}
|
|
1254
|
+
|
|
1255
|
+
{/* Port Forward floating panel (indicator lives in header) */}
|
|
1256
|
+
<PortForwardPanel />
|
|
1257
|
+
|
|
1258
|
+
{/* Update notification — hidden in embedded mode (OSS download nudge). */}
|
|
1259
|
+
{!navCustomization.embedded && <UpdateNotification />}
|
|
1260
|
+
|
|
1261
|
+
{/* Bottom Dock for Terminal/Logs */}
|
|
1262
|
+
<BottomDock />
|
|
1263
|
+
|
|
1264
|
+
{/* Spacer for dock */}
|
|
1265
|
+
<DockSpacer />
|
|
1266
|
+
|
|
1267
|
+
{/* Floating action buttons — bottom-right, above dock */}
|
|
1268
|
+
<FloatingButtons showHelp={showHelp} showCommandPalette={showCommandPalette} showDiagnostics={showDiagnostics} onHelp={() => setShowHelp(true)} onBugReport={() => setShowDiagnostics(true)} />
|
|
1269
|
+
|
|
1270
|
+
{/* Keyboard shortcut help overlay */}
|
|
1271
|
+
{helpOverlay.shouldRender && <ShortcutHelpOverlay isOpen={helpOverlay.isOpen} onClose={() => setShowHelp(false)} currentView={mainView} />}
|
|
1272
|
+
|
|
1273
|
+
{/* Command palette */}
|
|
1274
|
+
{commandPaletteAnim.shouldRender && (
|
|
1275
|
+
<CommandPalette
|
|
1276
|
+
isOpen={commandPaletteAnim.isOpen}
|
|
1277
|
+
onClose={() => setShowCommandPalette(false)}
|
|
1278
|
+
onNavigateView={(view) => setMainView(view)}
|
|
1279
|
+
onNavigateKind={(kind, group) => {
|
|
1280
|
+
const params = new URLSearchParams(searchParams)
|
|
1281
|
+
params.delete('kind')
|
|
1282
|
+
if (group) params.set('apiGroup', group)
|
|
1283
|
+
else params.delete('apiGroup')
|
|
1284
|
+
params.delete('resource')
|
|
1285
|
+
navigate({ pathname: `/resources/${kind}`, search: params.toString() })
|
|
1286
|
+
// Focus the table search after navigation — the user came from ⌘K
|
|
1287
|
+
// (keyboard flow) and expects to type a resource name immediately.
|
|
1288
|
+
setTimeout(() => {
|
|
1289
|
+
(document.querySelector('input[placeholder="Search... (press /)"]') as HTMLInputElement)?.focus()
|
|
1290
|
+
}, 100)
|
|
1291
|
+
}}
|
|
1292
|
+
onSwitchContext={(name) => switchContext.mutate(
|
|
1293
|
+
{ name },
|
|
1294
|
+
// Namespace filter from the previous context may not exist in the
|
|
1295
|
+
// new one — clear it so resource lists don't silently go empty.
|
|
1296
|
+
{ onSettled: () => setNamespaces([]) },
|
|
1297
|
+
)}
|
|
1298
|
+
onSetNamespaces={setNamespaces}
|
|
1299
|
+
onToggleTheme={toggleTheme}
|
|
1300
|
+
onShowDiagnostics={() => setShowDiagnostics(true)}
|
|
1301
|
+
/>
|
|
1302
|
+
)}
|
|
1303
|
+
|
|
1304
|
+
{/* Diagnostics overlay */}
|
|
1305
|
+
{diagnosticsOverlay.shouldRender && <DiagnosticsOverlay isOpen={diagnosticsOverlay.isOpen} onClose={() => setShowDiagnostics(false)} />}
|
|
1306
|
+
|
|
1307
|
+
{/* Settings dialog */}
|
|
1308
|
+
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
|
|
1309
|
+
|
|
1310
|
+
{/* Debug overlay - only in dev mode */}
|
|
1311
|
+
{import.meta.env.DEV && <DebugOverlay />}
|
|
1312
|
+
</div>
|
|
1313
|
+
</PortForwardProvider>
|
|
1314
|
+
)
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Spacer component that adds padding when dock is open
|
|
1318
|
+
function DockSpacer() {
|
|
1319
|
+
const { tabs, isExpanded } = useDock()
|
|
1320
|
+
const location = useLocation()
|
|
1321
|
+
// Traffic view manages its own layout — spacer would break its flex sizing
|
|
1322
|
+
if (tabs.length === 0 || location.pathname === '/traffic') return null
|
|
1323
|
+
return <div className="shrink-0" style={{ height: isExpanded ? 300 : 36, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)` }} />
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Floating action buttons that position themselves above the dock
|
|
1327
|
+
function FloatingButtons({ showHelp, showCommandPalette, showDiagnostics, onHelp, onBugReport }: { showHelp: boolean; showCommandPalette: boolean; showDiagnostics: boolean; onHelp: () => void; onBugReport: () => void }) {
|
|
1328
|
+
const { tabs } = useDock()
|
|
1329
|
+
if (showHelp || showCommandPalette || showDiagnostics) return null
|
|
1330
|
+
// When dock tab bar is visible (36px), shift the buttons up above it
|
|
1331
|
+
const bottom = tabs.length > 0 ? 'bottom-10' : 'bottom-2'
|
|
1332
|
+
const btnClass = 'w-7 h-7 flex items-center justify-center rounded-full bg-theme-elevated/80 hover:bg-theme-hover border border-theme-border-light text-theme-text-tertiary hover:text-theme-text-secondary text-xs font-medium shadow-sm backdrop-blur-sm transition-all'
|
|
1333
|
+
return (
|
|
1334
|
+
<div className={`fixed ${bottom} right-4 z-40 flex items-center gap-1.5`}>
|
|
1335
|
+
<Tooltip content="Report bug / Diagnostics" position="top">
|
|
1336
|
+
<button onClick={onBugReport} className={btnClass}>
|
|
1337
|
+
<Bug className="w-3.5 h-3.5" />
|
|
1338
|
+
</button>
|
|
1339
|
+
</Tooltip>
|
|
1340
|
+
<Tooltip content="Keyboard shortcuts (?)" position="top">
|
|
1341
|
+
<button onClick={onHelp} className={btnClass}>
|
|
1342
|
+
?
|
|
1343
|
+
</button>
|
|
1344
|
+
</Tooltip>
|
|
1345
|
+
</div>
|
|
1346
|
+
)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// Main App component wrapped with providers
|
|
1350
|
+
function App() {
|
|
1351
|
+
return (
|
|
1352
|
+
<ConnectionProvider>
|
|
1353
|
+
<CapabilitiesProvider>
|
|
1354
|
+
<ContextSwitchProvider>
|
|
1355
|
+
<DockProvider>
|
|
1356
|
+
<KeyboardShortcutProvider>
|
|
1357
|
+
<AppInner />
|
|
1358
|
+
</KeyboardShortcutProvider>
|
|
1359
|
+
</DockProvider>
|
|
1360
|
+
</ContextSwitchProvider>
|
|
1361
|
+
</CapabilitiesProvider>
|
|
1362
|
+
</ConnectionProvider>
|
|
1363
|
+
)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// Skyhook logo that switches based on theme
|
|
1367
|
+
function Logo() {
|
|
1368
|
+
const { theme } = useTheme()
|
|
1369
|
+
const logoSrc = theme === 'dark'
|
|
1370
|
+
? '/assets/skyhook/logotype-white-color.svg'
|
|
1371
|
+
: '/assets/skyhook/logotype-dark-color.svg'
|
|
1372
|
+
|
|
1373
|
+
return <img src={logoSrc} alt="Skyhook" className="h-5 w-auto" />
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// GitHub star button with live star count + programmatic starring via gh CLI
|
|
1377
|
+
// Shows a callout popover when the backend says shouldPrompt is true (synced with CLI state)
|
|
1378
|
+
function GitHubStarButton() {
|
|
1379
|
+
const [starCount, setStarCount] = useState<number | null>(null)
|
|
1380
|
+
const [starred, setStarred] = useState(false)
|
|
1381
|
+
const [ghAvailable, setGhAvailable] = useState(false)
|
|
1382
|
+
const [showCallout, setShowCallout] = useState(false)
|
|
1383
|
+
const calloutRef = useRef<HTMLDivElement>(null)
|
|
1384
|
+
const buttonRef = useRef<HTMLAnchorElement>(null)
|
|
1385
|
+
|
|
1386
|
+
useEffect(() => {
|
|
1387
|
+
// Fetch star count from GitHub public API
|
|
1388
|
+
fetch('https://api.github.com/repos/skyhook-io/radar')
|
|
1389
|
+
.then(res => res.ok ? res.json() : null)
|
|
1390
|
+
.then(data => { if (data && typeof data.stargazers_count === 'number') setStarCount(data.stargazers_count) })
|
|
1391
|
+
.catch(() => {})
|
|
1392
|
+
|
|
1393
|
+
// Check if user already starred (via backend/gh CLI) and whether to show prompt
|
|
1394
|
+
fetch(apiUrl('/github/starred'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
1395
|
+
.then(res => res.ok ? res.json() : null)
|
|
1396
|
+
.then(data => {
|
|
1397
|
+
if (data) {
|
|
1398
|
+
setStarred(data.starred)
|
|
1399
|
+
setGhAvailable(data.ghAvailable)
|
|
1400
|
+
if (data.shouldPrompt && !data.starred) {
|
|
1401
|
+
// Delay the callout, then re-check in case CLI prompted during the wait
|
|
1402
|
+
setTimeout(() => {
|
|
1403
|
+
fetch(apiUrl('/github/starred'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
1404
|
+
.then(res => res.ok ? res.json() : null)
|
|
1405
|
+
.then(fresh => {
|
|
1406
|
+
if (fresh?.shouldPrompt && !fresh.starred) {
|
|
1407
|
+
setShowCallout(true)
|
|
1408
|
+
}
|
|
1409
|
+
})
|
|
1410
|
+
.catch(() => {})
|
|
1411
|
+
}, 3000)
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
})
|
|
1415
|
+
.catch(() => {})
|
|
1416
|
+
}, [])
|
|
1417
|
+
|
|
1418
|
+
const handleDismiss = useCallback(() => {
|
|
1419
|
+
setShowCallout(false)
|
|
1420
|
+
fetch(apiUrl('/github/dismiss'), { method: 'POST', credentials: getCredentialsMode(), headers: getAuthHeaders() }).catch(() => {})
|
|
1421
|
+
}, [])
|
|
1422
|
+
|
|
1423
|
+
// Close callout when clicking outside
|
|
1424
|
+
useEffect(() => {
|
|
1425
|
+
if (!showCallout) return
|
|
1426
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
1427
|
+
if (
|
|
1428
|
+
calloutRef.current && !calloutRef.current.contains(e.target as Node) &&
|
|
1429
|
+
buttonRef.current && !buttonRef.current.contains(e.target as Node)
|
|
1430
|
+
) {
|
|
1431
|
+
handleDismiss()
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
1435
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
1436
|
+
}, [showCallout, handleDismiss])
|
|
1437
|
+
|
|
1438
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
1439
|
+
if (starred) return // Already starred, just let the link open GitHub
|
|
1440
|
+
|
|
1441
|
+
if (ghAvailable) {
|
|
1442
|
+
// Star via backend gh CLI
|
|
1443
|
+
e.preventDefault()
|
|
1444
|
+
fetch(apiUrl('/github/star'), { method: 'POST', credentials: getCredentialsMode(), headers: getAuthHeaders() })
|
|
1445
|
+
.then(res => res.ok ? res.json() : null)
|
|
1446
|
+
.then(data => {
|
|
1447
|
+
if (data?.starred) {
|
|
1448
|
+
setStarred(true)
|
|
1449
|
+
setShowCallout(false)
|
|
1450
|
+
setStarCount(prev => prev !== null ? prev + 1 : prev)
|
|
1451
|
+
}
|
|
1452
|
+
})
|
|
1453
|
+
.catch(() => {
|
|
1454
|
+
// Fallback: open GitHub in browser
|
|
1455
|
+
openExternal('https://github.com/skyhook-io/radar')
|
|
1456
|
+
})
|
|
1457
|
+
} else {
|
|
1458
|
+
// No gh CLI — link opens GitHub; dismiss the callout
|
|
1459
|
+
setShowCallout(false)
|
|
1460
|
+
fetch(apiUrl('/github/dismiss'), { method: 'POST', credentials: getCredentialsMode(), headers: getAuthHeaders() }).catch(() => {})
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return (
|
|
1465
|
+
<div className="relative">
|
|
1466
|
+
<a
|
|
1467
|
+
ref={buttonRef}
|
|
1468
|
+
href="https://github.com/skyhook-io/radar"
|
|
1469
|
+
target="_blank"
|
|
1470
|
+
rel="noopener noreferrer"
|
|
1471
|
+
onClick={handleClick}
|
|
1472
|
+
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"
|
|
1473
|
+
>
|
|
1474
|
+
<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>
|
|
1475
|
+
<Star className={`w-3 h-3 hidden xl:block ${starred ? 'text-yellow-500 fill-current' : ''}`} />
|
|
1476
|
+
{starCount !== null && (
|
|
1477
|
+
<>
|
|
1478
|
+
<span className="w-px h-3 bg-theme-border hidden xl:block" />
|
|
1479
|
+
<span className="text-xs tabular-nums hidden xl:inline">{starCount.toLocaleString()}</span>
|
|
1480
|
+
</>
|
|
1481
|
+
)}
|
|
1482
|
+
</a>
|
|
1483
|
+
|
|
1484
|
+
{/* Callout popover — synced with CLI star.json state */}
|
|
1485
|
+
{showCallout && (
|
|
1486
|
+
<div
|
|
1487
|
+
ref={calloutRef}
|
|
1488
|
+
className="absolute top-full right-0 mt-2 w-64 p-3 bg-theme-surface border border-theme-border rounded-lg shadow-lg z-50"
|
|
1489
|
+
>
|
|
1490
|
+
{/* Arrow */}
|
|
1491
|
+
<div className="absolute -top-1.5 right-4 w-3 h-3 bg-theme-surface border-l border-t border-theme-border rotate-45" />
|
|
1492
|
+
<p className="text-sm text-theme-text-primary mb-2">
|
|
1493
|
+
Enjoying Radar? Show your support with a star!
|
|
1494
|
+
</p>
|
|
1495
|
+
<div className="flex items-center gap-2">
|
|
1496
|
+
<a
|
|
1497
|
+
href="https://github.com/skyhook-io/radar"
|
|
1498
|
+
target="_blank"
|
|
1499
|
+
rel="noopener noreferrer"
|
|
1500
|
+
onClick={handleClick}
|
|
1501
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-yellow-500/15 text-yellow-500 hover:bg-yellow-500/25 rounded-md transition-colors"
|
|
1502
|
+
>
|
|
1503
|
+
<Star className="w-3.5 h-3.5" />
|
|
1504
|
+
Star on GitHub
|
|
1505
|
+
</a>
|
|
1506
|
+
<button
|
|
1507
|
+
onClick={handleDismiss}
|
|
1508
|
+
className="px-2 py-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
|
|
1509
|
+
>
|
|
1510
|
+
Maybe later
|
|
1511
|
+
</button>
|
|
1512
|
+
</div>
|
|
1513
|
+
</div>
|
|
1514
|
+
)}
|
|
1515
|
+
</div>
|
|
1516
|
+
)
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Theme toggle button component
|
|
1520
|
+
function ThemeToggle() {
|
|
1521
|
+
const { theme, toggleTheme } = useTheme()
|
|
1522
|
+
|
|
1523
|
+
return (
|
|
1524
|
+
<button
|
|
1525
|
+
onClick={toggleTheme}
|
|
1526
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1527
|
+
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
|
1528
|
+
>
|
|
1529
|
+
{theme === 'dark' ? (
|
|
1530
|
+
<Sun className="w-4 h-4" />
|
|
1531
|
+
) : (
|
|
1532
|
+
<Moon className="w-4 h-4" />
|
|
1533
|
+
)}
|
|
1534
|
+
</button>
|
|
1535
|
+
)
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export default App
|