@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.
Files changed (251) hide show
  1. package/README.md +67 -0
  2. package/package.json +80 -0
  3. package/src/App.tsx +1538 -0
  4. package/src/RadarApp.tsx +145 -0
  5. package/src/api/apiResources.ts +28 -0
  6. package/src/api/client.ts +2583 -0
  7. package/src/api/config.ts +116 -0
  8. package/src/api/traffic.ts +139 -0
  9. package/src/components/ConnectionErrorView.tsx +272 -0
  10. package/src/components/ContextSwitcher.tsx +481 -0
  11. package/src/components/DebugOverlay.tsx +94 -0
  12. package/src/components/UserMenu.tsx +87 -0
  13. package/src/components/audit/AuditSettingsDialog.tsx +162 -0
  14. package/src/components/audit/AuditView.tsx +123 -0
  15. package/src/components/cost/CostTrendChart.tsx +388 -0
  16. package/src/components/cost/CostView.tsx +545 -0
  17. package/src/components/dock/BottomDock.tsx +96 -0
  18. package/src/components/dock/DockContext.tsx +11 -0
  19. package/src/components/dock/LocalTerminalTab.tsx +22 -0
  20. package/src/components/dock/LogsTab.tsx +26 -0
  21. package/src/components/dock/NodeTerminalTab.tsx +50 -0
  22. package/src/components/dock/TerminalTab.tsx +42 -0
  23. package/src/components/dock/TrafficFlowListTab.tsx +18 -0
  24. package/src/components/dock/WorkloadLogsTab.tsx +23 -0
  25. package/src/components/dock/index.ts +2 -0
  26. package/src/components/gitops/GitOpsActions.tsx +1 -0
  27. package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
  28. package/src/components/gitops/ManagedResourcesList.tsx +1 -0
  29. package/src/components/gitops/SyncCountdown.tsx +1 -0
  30. package/src/components/gitops/index.ts +4 -0
  31. package/src/components/helm/ChartBrowser.tsx +580 -0
  32. package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
  33. package/src/components/helm/HelmView.tsx +475 -0
  34. package/src/components/helm/InstallWizard.tsx +1060 -0
  35. package/src/components/helm/ManifestDiffViewer.tsx +91 -0
  36. package/src/components/helm/ManifestViewer.tsx +61 -0
  37. package/src/components/helm/OwnedResources.tsx +465 -0
  38. package/src/components/helm/RevisionHistory.tsx +167 -0
  39. package/src/components/helm/ValuesDiffPreview.tsx +190 -0
  40. package/src/components/helm/ValuesViewer.tsx +365 -0
  41. package/src/components/helm/helm-utils.ts +37 -0
  42. package/src/components/home/ActivitySummary.tsx +262 -0
  43. package/src/components/home/CertificateHealthCard.tsx +105 -0
  44. package/src/components/home/ClusterHealthCard.tsx +483 -0
  45. package/src/components/home/CostCard.tsx +112 -0
  46. package/src/components/home/HealthRing.tsx +1 -0
  47. package/src/components/home/HelmSummary.tsx +129 -0
  48. package/src/components/home/HomeView.tsx +224 -0
  49. package/src/components/home/MCPSetupDialog.tsx +417 -0
  50. package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
  51. package/src/components/home/TopologyPreview.tsx +219 -0
  52. package/src/components/home/TrafficSummary.tsx +154 -0
  53. package/src/components/logs/JsonLogLine.tsx +1 -0
  54. package/src/components/logs/LogCore.tsx +2 -0
  55. package/src/components/logs/LogsViewer.tsx +44 -0
  56. package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
  57. package/src/components/logs/useLogBuffer.ts +2 -0
  58. package/src/components/logs/useLogSearch.ts +1 -0
  59. package/src/components/portforward/PortForwardButton.tsx +375 -0
  60. package/src/components/portforward/PortForwardManager.tsx +871 -0
  61. package/src/components/resource/PrometheusCharts.tsx +687 -0
  62. package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
  63. package/src/components/resources/ImageFilesystemModal.tsx +745 -0
  64. package/src/components/resources/PodFilesystemModal.tsx +407 -0
  65. package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
  66. package/src/components/resources/ResourcesView.tsx +190 -0
  67. package/src/components/resources/drawer-components.tsx +1 -0
  68. package/src/components/resources/file-browser-utils.ts +35 -0
  69. package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
  70. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
  71. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
  72. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
  73. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
  74. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
  75. package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
  76. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
  77. package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
  78. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
  79. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
  80. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
  82. package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
  83. package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
  84. package/src/components/resources/renderers/EventRenderer.tsx +1 -0
  85. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
  86. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
  87. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
  88. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
  89. package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
  90. package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
  91. package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
  92. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
  93. package/src/components/resources/renderers/HPARenderer.tsx +1 -0
  94. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
  95. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
  96. package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
  97. package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
  98. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
  99. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
  100. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
  101. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
  102. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
  103. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
  104. package/src/components/resources/renderers/JobRenderer.tsx +1 -0
  105. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
  106. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
  107. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
  108. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
  109. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
  110. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
  111. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
  112. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
  113. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
  114. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
  116. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
  117. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
  118. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
  119. package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
  120. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
  121. package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
  122. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
  123. package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
  124. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
  125. package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
  126. package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
  127. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
  128. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
  129. package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
  130. package/src/components/resources/renderers/PodRenderer.tsx +94 -0
  131. package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
  132. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
  133. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
  134. package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
  135. package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
  136. package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
  137. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
  138. package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
  139. package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
  140. package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
  141. package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
  142. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
  143. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
  144. package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
  145. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
  146. package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
  147. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
  148. package/src/components/resources/renderers/VPARenderer.tsx +1 -0
  149. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
  150. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
  151. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
  152. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
  153. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
  154. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
  155. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
  156. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
  157. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
  158. package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
  159. package/src/components/resources/renderers/argo-cells.tsx +1 -0
  160. package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
  161. package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
  162. package/src/components/resources/renderers/eso-cells.tsx +1 -0
  163. package/src/components/resources/renderers/flux-cells.tsx +1 -0
  164. package/src/components/resources/renderers/index.ts +91 -0
  165. package/src/components/resources/renderers/istio-cells.tsx +1 -0
  166. package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
  167. package/src/components/resources/renderers/keda-cells.tsx +1 -0
  168. package/src/components/resources/renderers/knative-cells.tsx +1 -0
  169. package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
  170. package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
  171. package/src/components/resources/renderers/traefik-cells.tsx +1 -0
  172. package/src/components/resources/renderers/trivy-cells.tsx +1 -0
  173. package/src/components/resources/renderers/trivy-shared.tsx +1 -0
  174. package/src/components/resources/renderers/velero-cells.tsx +1 -0
  175. package/src/components/resources/resource-utils-argo.ts +2 -0
  176. package/src/components/resources/resource-utils-certmanager.ts +2 -0
  177. package/src/components/resources/resource-utils-cnpg.ts +2 -0
  178. package/src/components/resources/resource-utils-eso.ts +2 -0
  179. package/src/components/resources/resource-utils-flux.ts +2 -0
  180. package/src/components/resources/resource-utils-istio.ts +2 -0
  181. package/src/components/resources/resource-utils-karpenter.ts +2 -0
  182. package/src/components/resources/resource-utils-keda.ts +2 -0
  183. package/src/components/resources/resource-utils-knative.ts +2 -0
  184. package/src/components/resources/resource-utils-kyverno.ts +2 -0
  185. package/src/components/resources/resource-utils-prometheus.ts +2 -0
  186. package/src/components/resources/resource-utils-traefik.ts +1 -0
  187. package/src/components/resources/resource-utils-trivy.ts +2 -0
  188. package/src/components/resources/resource-utils-velero.ts +2 -0
  189. package/src/components/resources/resource-utils.ts +5 -0
  190. package/src/components/settings/SettingsDialog.tsx +537 -0
  191. package/src/components/shared/CreateResourceDialog.tsx +17 -0
  192. package/src/components/shared/EditableYamlView.tsx +24 -0
  193. package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
  194. package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
  195. package/src/components/timeline/DiffViewer.tsx +1 -0
  196. package/src/components/timeline/TimelineList.tsx +69 -0
  197. package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
  198. package/src/components/timeline/TimelineView.tsx +157 -0
  199. package/src/components/timeline/shared.tsx +1 -0
  200. package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
  201. package/src/components/traffic/TrafficFlowList.tsx +415 -0
  202. package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
  203. package/src/components/traffic/TrafficGraph.tsx +1546 -0
  204. package/src/components/traffic/TrafficView.tsx +1213 -0
  205. package/src/components/traffic/TrafficWizard.tsx +386 -0
  206. package/src/components/traffic/index.ts +3 -0
  207. package/src/components/ui/CodeViewer.tsx +8 -0
  208. package/src/components/ui/CommandPalette.tsx +460 -0
  209. package/src/components/ui/ConfirmDialog.tsx +1 -0
  210. package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
  211. package/src/components/ui/ErrorBoundary.tsx +46 -0
  212. package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
  213. package/src/components/ui/Markdown.tsx +108 -0
  214. package/src/components/ui/MetricsChart.tsx +1 -0
  215. package/src/components/ui/NamespaceSelector.tsx +436 -0
  216. package/src/components/ui/ResourceBar.tsx +1 -0
  217. package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
  218. package/src/components/ui/Toast.tsx +1 -0
  219. package/src/components/ui/Tooltip.tsx +1 -0
  220. package/src/components/ui/UpdateNotification.tsx +299 -0
  221. package/src/components/ui/YamlEditor.tsx +1 -0
  222. package/src/components/workload/WorkloadView.tsx +532 -0
  223. package/src/context/ConnectionContext.tsx +173 -0
  224. package/src/context/ContextSwitchContext.tsx +56 -0
  225. package/src/context/NavCustomization.tsx +62 -0
  226. package/src/context/ThemeContext.tsx +97 -0
  227. package/src/contexts/CapabilitiesContext.tsx +130 -0
  228. package/src/hooks/useAnimatedUnmount.ts +1 -0
  229. package/src/hooks/useDesktopDownload.ts +41 -0
  230. package/src/hooks/useEventSource.ts +262 -0
  231. package/src/hooks/useFavorites.ts +69 -0
  232. package/src/hooks/useKeyboardShortcuts.tsx +7 -0
  233. package/src/hooks/useRefreshAnimation.ts +1 -0
  234. package/src/index.css +243 -0
  235. package/src/index.ts +17 -0
  236. package/src/main.tsx +158 -0
  237. package/src/types/gitops.ts +2 -0
  238. package/src/types.ts +3 -0
  239. package/src/utils/animation.ts +2 -0
  240. package/src/utils/badge-colors.ts +2 -0
  241. package/src/utils/context-name.ts +2 -0
  242. package/src/utils/desktop-download.ts +66 -0
  243. package/src/utils/desktop-open-folder.ts +21 -0
  244. package/src/utils/format.ts +2 -0
  245. package/src/utils/log-format.ts +12 -0
  246. package/src/utils/navigation.ts +23 -0
  247. package/src/utils/resource-hierarchy.ts +2 -0
  248. package/src/utils/resource-icons.ts +2 -0
  249. package/src/utils/skeleton-yaml.ts +2 -0
  250. package/src/utils/traffic-colors.ts +54 -0
  251. 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