@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
@@ -0,0 +1,1213 @@
1
+ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
+ import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
+ import { useTrafficSources, useTrafficFlows, useTrafficConnect, useSetTrafficSource } from '../../api/traffic'
4
+ import { useClusterInfo } from '../../api/client'
5
+ import type { TrafficWizardState, AggregatedFlow } from '../../types'
6
+ import { TrafficWizard } from './TrafficWizard'
7
+ import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
8
+ import { TrafficFilterSidebar } from './TrafficFilterSidebar'
9
+ import { TrafficFlowListProvider } from './TrafficFlowListContext'
10
+ import { Loader2, RefreshCw, Filter, Plug, ChevronDown, List } from 'lucide-react'
11
+ import { clsx } from 'clsx'
12
+ import { useQueryClient } from '@tanstack/react-query'
13
+ import { useDock } from '../dock'
14
+
15
+ // Addon types for filtering
16
+ export type AddonMode = 'show' | 'group' | 'hide'
17
+
18
+ // Cluster addons that can be grouped/hidden (infrastructure, not traffic-flow)
19
+ const CLUSTER_ADDON_NAMESPACES = new Set([
20
+ // Certificate management
21
+ 'cert-manager',
22
+ // Secrets management
23
+ 'external-secrets',
24
+ 'sealed-secrets',
25
+ 'vault',
26
+ // Backup
27
+ 'velero',
28
+ // Monitoring & metrics
29
+ 'gmp-system',
30
+ 'gmp-public',
31
+ 'datadog',
32
+ 'monitoring',
33
+ 'observability',
34
+ 'opencost',
35
+ 'prometheus',
36
+ 'grafana',
37
+ 'kube-state-metrics',
38
+ // Logging
39
+ 'loki',
40
+ 'logging',
41
+ 'fluentd',
42
+ 'fluentbit',
43
+ // DNS
44
+ 'external-dns',
45
+ // Autoscaling
46
+ 'cluster-autoscaler',
47
+ 'karpenter',
48
+ 'keda',
49
+ // GitOps & CI/CD
50
+ 'argocd',
51
+ 'argo-rollouts',
52
+ 'argo-workflows',
53
+ 'flux-system',
54
+ // Policy
55
+ 'gatekeeper-system',
56
+ // Config management
57
+ 'reloader',
58
+ // Database operators
59
+ 'cloud-native-pg',
60
+ 'cnpg-system',
61
+ 'postgres-operator',
62
+ 'mysql-operator',
63
+ 'redis-operator',
64
+ ])
65
+
66
+ // Addon workload names (for detection when namespace isn't enough)
67
+ const CLUSTER_ADDON_NAMES = new Set([
68
+ 'coredns',
69
+ 'metrics-server',
70
+ 'cluster-autoscaler',
71
+ 'kube-dns',
72
+ 'kube-state-metrics',
73
+ 'reloader',
74
+ ])
75
+
76
+ // Traffic-flow related addons that should NEVER be grouped/hidden
77
+ // These are essential for understanding traffic patterns
78
+ const TRAFFIC_FLOW_NAMESPACES = new Set([
79
+ 'ingress-nginx',
80
+ 'nginx-ingress',
81
+ 'traefik',
82
+ 'contour',
83
+ 'kong',
84
+ 'ambassador',
85
+ 'emissary',
86
+ 'haproxy-ingress',
87
+ 'istio-system',
88
+ 'istio-ingress',
89
+ 'linkerd',
90
+ 'consul',
91
+ 'envoy-gateway-system',
92
+ 'gateway-system',
93
+ ])
94
+
95
+ const TRAFFIC_FLOW_NAMES = new Set([
96
+ 'ingress-nginx-controller',
97
+ 'nginx-ingress-controller',
98
+ 'traefik',
99
+ 'contour',
100
+ 'envoy',
101
+ 'kong',
102
+ 'ambassador',
103
+ 'istio-ingressgateway',
104
+ 'istio-proxy',
105
+ 'linkerd-proxy',
106
+ ])
107
+
108
+ // Check if an endpoint is a cluster addon (can be grouped/hidden)
109
+ // Exported for use in TrafficGraph
110
+ export function isClusterAddon(name: string, namespace: string | undefined): boolean {
111
+ // Never treat traffic-flow addons as regular addons
112
+ if (namespace && TRAFFIC_FLOW_NAMESPACES.has(namespace)) return false
113
+ if (TRAFFIC_FLOW_NAMES.has(name)) return false
114
+
115
+ // Check namespace-based addons
116
+ if (namespace && CLUSTER_ADDON_NAMESPACES.has(namespace)) return true
117
+
118
+ // Check name-based addons
119
+ if (CLUSTER_ADDON_NAMES.has(name)) return true
120
+
121
+ // Check for common addon naming patterns
122
+ if (name.includes('prometheus') || name.includes('grafana') ||
123
+ name.includes('datadog') || name.includes('fluentd') ||
124
+ name.includes('metrics-server') || name.includes('coredns')) {
125
+ return true
126
+ }
127
+
128
+ return false
129
+ }
130
+
131
+ // System namespaces to hide by default
132
+ const SYSTEM_NAMESPACES = new Set([
133
+ 'kube-system',
134
+ 'kube-public',
135
+ 'kube-node-lease',
136
+ 'cert-manager',
137
+ 'caretta',
138
+ 'cilium',
139
+ 'calico-system',
140
+ 'tigera-operator',
141
+ 'gatekeeper-system',
142
+ 'argo-rollouts',
143
+ 'argocd',
144
+ 'flux-system',
145
+ 'monitoring',
146
+ 'observability',
147
+ 'istio-system',
148
+ 'linkerd',
149
+ // Phase 1.1: Additional infrastructure namespaces
150
+ 'node', // Node-level traffic (often 35%+ of flows)
151
+ 'gmp-system', // GKE Managed Prometheus
152
+ 'gmp-public', // GKE Managed Prometheus public
153
+ 'datadog', // Datadog monitoring
154
+ 'opencost', // OpenCost
155
+ 'external-dns', // External DNS controller
156
+ 'ingress-nginx', // NGINX Ingress Controller
157
+ 'traefik', // Traefik
158
+ 'velero', // Velero backup
159
+ 'vault', // HashiCorp Vault
160
+ 'external-secrets', // External Secrets Operator
161
+ ])
162
+
163
+ // Detect internal load balancer IPs (appear as "external" but are internal)
164
+ function isInternalLoadBalancer(name: string): boolean {
165
+ // GKE internal LB IPs (10.x.x.x range)
166
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true
167
+ // AWS internal LB pattern (172.16-31.x.x)
168
+ if (/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(name)) return true
169
+ // Azure internal LB pattern
170
+ if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(name)) return true
171
+ return false
172
+ }
173
+
174
+ // Patterns for external service aggregation (Phase 4.2)
175
+ const EXTERNAL_SERVICE_PATTERNS: { pattern: RegExp; display: string; category: string }[] = [
176
+ { pattern: /.*\.mongodb\.net\.?$/, display: 'MongoDB Atlas', category: 'database' },
177
+ { pattern: /.*\.mongodb\.com\.?$/, display: 'MongoDB Atlas', category: 'database' },
178
+ { pattern: /.*\.redis\.cloud\.?$/, display: 'Redis Cloud', category: 'database' },
179
+ { pattern: /.*\.rds\.amazonaws\.com\.?$/, display: 'AWS RDS', category: 'database' },
180
+ { pattern: /.*\.amazonaws\.com\.?$/, display: 'AWS Services', category: 'cloud' },
181
+ { pattern: /.*\.googleapis\.com\.?$/, display: 'Google APIs', category: 'cloud' },
182
+ // GCE VM patterns - various formats (IP.bc.googleusercontent.com, with/without trailing dot)
183
+ { pattern: /[\d.-]+\.bc\.googleusercontent\.com\.?$/i, display: 'GCE VMs', category: 'cloud' },
184
+ { pattern: /.*\.googleusercontent\.com\.?$/i, display: 'Google Cloud', category: 'cloud' },
185
+ { pattern: /.*\.azure\.com\.?$/, display: 'Azure Services', category: 'cloud' },
186
+ { pattern: /.*\.blob\.core\.windows\.net\.?$/, display: 'Azure Blob', category: 'cloud' },
187
+ { pattern: /.*\.sentry\.io\.?$/, display: 'Sentry', category: 'monitoring' },
188
+ { pattern: /.*\.datadoghq\.com\.?$/, display: 'Datadog', category: 'monitoring' },
189
+ { pattern: /.*\.stripe\.com\.?$/, display: 'Stripe', category: 'payment' },
190
+ { pattern: /.*\.auth0\.com\.?$/, display: 'Auth0', category: 'auth' },
191
+ { pattern: /.*\.okta\.com\.?$/, display: 'Okta', category: 'auth' },
192
+ { pattern: /.*\.sendgrid\.net\.?$/, display: 'SendGrid', category: 'email' },
193
+ { pattern: /.*\.mailgun\.org\.?$/, display: 'Mailgun', category: 'email' },
194
+ { pattern: /.*\.slack\.com\.?$/, display: 'Slack', category: 'messaging' },
195
+ { pattern: /.*\.twilio\.com\.?$/, display: 'Twilio', category: 'messaging' },
196
+ ]
197
+
198
+ // Port-based service detection (when hostname doesn't give enough info)
199
+ const PORT_SERVICE_MAP: Record<number, { name: string; category: string }> = {
200
+ 27017: { name: 'MongoDB', category: 'database' },
201
+ 27018: { name: 'MongoDB', category: 'database' },
202
+ 5432: { name: 'PostgreSQL', category: 'database' },
203
+ 3306: { name: 'MySQL', category: 'database' },
204
+ 6379: { name: 'Redis', category: 'database' },
205
+ 9042: { name: 'Cassandra', category: 'database' },
206
+ 9200: { name: 'Elasticsearch', category: 'database' },
207
+ 9300: { name: 'Elasticsearch', category: 'database' },
208
+ 443: { name: 'HTTPS', category: 'web' },
209
+ 80: { name: 'HTTP', category: 'web' },
210
+ 8080: { name: 'HTTP', category: 'web' },
211
+ 8443: { name: 'HTTPS', category: 'web' },
212
+ 5672: { name: 'RabbitMQ', category: 'messaging' },
213
+ 9092: { name: 'Kafka', category: 'messaging' },
214
+ 4222: { name: 'NATS', category: 'messaging' },
215
+ 11211: { name: 'Memcached', category: 'cache' },
216
+ 25: { name: 'SMTP', category: 'email' },
217
+ 587: { name: 'SMTP', category: 'email' },
218
+ 53: { name: 'DNS', category: 'infra' },
219
+ 22: { name: 'SSH', category: 'infra' },
220
+ }
221
+
222
+ // Get aggregated display name for external services (considers both hostname and port)
223
+ function getExternalServiceName(name: string, port?: number): { name: string; aggregated: boolean; category?: string } {
224
+ // Check for port-based service first (more reliable than hostname guessing)
225
+ const portService = port ? PORT_SERVICE_MAP[port] : undefined
226
+
227
+ // Try hostname patterns
228
+ for (const { pattern, display, category } of EXTERNAL_SERVICE_PATTERNS) {
229
+ if (pattern.test(name)) {
230
+ // If we also have port info, combine them for clarity (e.g., "MongoDB (GCE VMs)")
231
+ if (portService && display !== portService.name) {
232
+ return { name: `${portService.name} (${display})`, aggregated: true, category: portService.category }
233
+ }
234
+ return { name: display, aggregated: true, category }
235
+ }
236
+ }
237
+
238
+ // If hostname doesn't match but we have a known port, aggregate by service type
239
+ if (portService) {
240
+ return { name: portService.name, aggregated: true, category: portService.category }
241
+ }
242
+
243
+ return { name, aggregated: false }
244
+ }
245
+
246
+
247
+ // Cilium reserved identities (internal infrastructure traffic)
248
+ const CILIUM_RESERVED_IDENTITIES = new Set([
249
+ 'host', // Node-level traffic
250
+ 'health', // Cilium health probes
251
+ 'init', // Initialization identity
252
+ 'unmanaged', // Unmanaged endpoints
253
+ ])
254
+
255
+ // Check if an address is IPv6 link-local or multicast (infrastructure noise)
256
+ function isIPv6Infrastructure(name: string): boolean {
257
+ // Link-local (fe80::/10)
258
+ if (name.toLowerCase().startsWith('fe80:')) return true
259
+ // Multicast (ff00::/8) - includes ff02::2 (all routers), ff02::1 (all nodes), etc.
260
+ if (name.toLowerCase().startsWith('ff0')) return true
261
+ return false
262
+ }
263
+
264
+ // Check if an endpoint is a system/infrastructure component
265
+ function isSystemEndpoint(name: string, namespace: string | undefined, kind: string): boolean {
266
+ // System namespaces
267
+ if (namespace && SYSTEM_NAMESPACES.has(namespace)) {
268
+ return true
269
+ }
270
+
271
+ // Cilium reserved identities (show up as External kind with reserved names)
272
+ if (kind === 'External' && CILIUM_RESERVED_IDENTITIES.has(name)) {
273
+ return true
274
+ }
275
+
276
+ // IPv6 link-local and multicast addresses (infrastructure noise)
277
+ if (isIPv6Infrastructure(name)) {
278
+ return true
279
+ }
280
+
281
+ // Node-level traffic
282
+ if (kind === 'node' || kind === 'Node') {
283
+ return true
284
+ }
285
+
286
+ // Cloud metadata services (AWS, GCE, Azure)
287
+ if (name.startsWith('169.254.') || name === 'instance-data.ec2.internal') {
288
+ return true
289
+ }
290
+ if (name === 'metadata.google.internal' || name === 'metadata.google.internal.') {
291
+ return true
292
+ }
293
+ if (name === 'metadata.azure.com' || name.endsWith('.metadata.azure.com')) {
294
+ return true
295
+ }
296
+
297
+ // Localhost / loopback traffic (within-pod communication, health checks)
298
+ if (name === '127.0.0.1' || name === 'localhost' || name.startsWith('127.')) {
299
+ return true
300
+ }
301
+
302
+ // 0.0.0.0 - binding address, not a real destination
303
+ if (name === '0.0.0.0') {
304
+ return true
305
+ }
306
+
307
+ // Kubernetes API server in default namespace
308
+ if (namespace === 'default' && name === 'kubernetes') {
309
+ return true
310
+ }
311
+
312
+ // IP-based names (internal cluster IPs)
313
+ if (/^\d{1,3}-\d{1,3}-\d{1,3}-\d{1,3}\./.test(name)) {
314
+ return true
315
+ }
316
+
317
+ // EC2 instance hostnames
318
+ if (/^ec2-\d+-\d+-\d+-\d+\./.test(name) || /^ip-\d+-\d+-\d+-\d+\./.test(name)) {
319
+ return true
320
+ }
321
+
322
+ // Internal load balancer IPs that appear as "external"
323
+ if (kind === 'External' && isInternalLoadBalancer(name)) {
324
+ return true
325
+ }
326
+
327
+ return false
328
+ }
329
+
330
+ // Helper to check if endpoint is external (case-insensitive)
331
+ function isExternal(kind: string): boolean {
332
+ return kind.toLowerCase() === 'external'
333
+ }
334
+
335
+ interface TrafficViewProps {
336
+ namespaces: string[]
337
+ }
338
+
339
+ export function TrafficView({ namespaces }: TrafficViewProps) {
340
+ const [wizardState, setWizardState] = useState<TrafficWizardState>('detecting')
341
+ const [timeRange, setTimeRange] = useState<string>('5m')
342
+ const [hideSystem, setHideSystem] = useState(true)
343
+ const [hideExternal, setHideExternal] = useState(false)
344
+ const [minConnections, setMinConnections] = useState(0)
345
+ const [showNamespaceGroups, setShowNamespaceGroups] = useState(true)
346
+ const [aggregateExternal, setAggregateExternal] = useState(true)
347
+ const [detectServices, setDetectServices] = useState(true)
348
+ const [collapseInternet, setCollapseInternet] = useState(true)
349
+ const [addonMode, setAddonMode] = useState<AddonMode>('show')
350
+ const [graphSelection, setGraphSelection] = useState<TrafficGraphSelection | null>(null)
351
+ const dock = useDock()
352
+
353
+ // Dock: offset past sidebar, close flows tab on unmount
354
+ const flowsTabIdRef = useRef<string | null>(null)
355
+ useEffect(() => {
356
+ dock.setLeftOffset(288)
357
+ return () => {
358
+ dock.setLeftOffset(0)
359
+ // Close the flows tab when leaving traffic view
360
+ if (flowsTabIdRef.current) {
361
+ dock.removeTab(flowsTabIdRef.current)
362
+ flowsTabIdRef.current = null
363
+ }
364
+ }
365
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
366
+
367
+ const [hiddenNamespaces, setHiddenNamespaces] = useState<Set<string>>(new Set())
368
+ // L7 filters (Hubble-only)
369
+ const [l7Protocol, setL7Protocol] = useState<string>('all')
370
+ const [l7Methods, setL7Methods] = useState<Set<string>>(new Set())
371
+ const [l7StatusRanges, setL7StatusRanges] = useState<Set<string>>(new Set())
372
+ const [l7Verdicts, setL7Verdicts] = useState<Set<string>>(new Set())
373
+ const [dnsPattern, setDnsPattern] = useState('')
374
+ const [isConnecting, setIsConnecting] = useState(false)
375
+ const [connectionError, setConnectionError] = useState<string | null>(null)
376
+ const queryClient = useQueryClient()
377
+ const connectMutation = useTrafficConnect()
378
+ const setSourceMutation = useSetTrafficSource()
379
+ const hasAutoConnectedRef = useRef(false)
380
+ const [sourcePickerOpen, setSourcePickerOpen] = useState(false)
381
+ const sourcePickerRef = useRef<HTMLDivElement>(null)
382
+
383
+ // Track cluster context to reset state on cluster change
384
+ const { data: clusterInfo } = useClusterInfo()
385
+ const lastClusterRef = useRef<string | null>(null)
386
+
387
+ // Reset state when cluster context changes
388
+ useEffect(() => {
389
+ const currentCluster = clusterInfo?.context || null
390
+ if (lastClusterRef.current !== null && lastClusterRef.current !== currentCluster) {
391
+ // Cluster changed - reset wizard state and invalidate traffic queries
392
+ setWizardState('detecting')
393
+ setConnectionError(null)
394
+ hasAutoConnectedRef.current = false
395
+ queryClient.invalidateQueries({ queryKey: ['traffic-sources'] })
396
+ queryClient.invalidateQueries({ queryKey: ['traffic-flows'] })
397
+ queryClient.invalidateQueries({ queryKey: ['traffic-connection'] })
398
+ }
399
+ lastClusterRef.current = currentCluster
400
+ }, [clusterInfo?.context, queryClient])
401
+
402
+ // Close source picker on outside click (capture phase to beat ReactFlow)
403
+ useEffect(() => {
404
+ if (!sourcePickerOpen) return
405
+ const handler = (e: MouseEvent) => {
406
+ if (sourcePickerRef.current && !sourcePickerRef.current.contains(e.target as Node)) {
407
+ setSourcePickerOpen(false)
408
+ }
409
+ }
410
+ document.addEventListener('mousedown', handler, true)
411
+ return () => document.removeEventListener('mousedown', handler, true)
412
+ }, [sourcePickerOpen])
413
+
414
+ const {
415
+ data: sourcesData,
416
+ isLoading: sourcesLoading,
417
+ refetch: refetchSources,
418
+ } = useTrafficSources()
419
+
420
+ const {
421
+ data: flowsData,
422
+ isLoading: flowsLoading,
423
+ isFetching: flowsFetching,
424
+ refetch: refetchFlowsRaw,
425
+ } = useTrafficFlows({
426
+ namespaces,
427
+ since: timeRange,
428
+ // Only fetch flows when connected (not connecting and no connection error)
429
+ enabled: wizardState === 'ready' && !isConnecting && !connectionError,
430
+ })
431
+ const [refetchFlows, isRefreshAnimating] = useRefreshAnimation(refetchFlowsRaw)
432
+
433
+ // Auto-retry when flows return with warning but no data (e.g., port-forward not ready yet)
434
+ useEffect(() => {
435
+ if (flowsData?.warning && (!flowsData.aggregated || flowsData.aggregated.length === 0) && !flowsFetching) {
436
+ const timer = setTimeout(() => refetchFlowsRaw(), 2000)
437
+ return () => clearTimeout(timer)
438
+ }
439
+ }, [flowsData, flowsFetching, refetchFlowsRaw])
440
+
441
+ // Filter flows based on user preferences
442
+ // Note: namespace filtering is done server-side via the global namespace selector
443
+ const filteredFlows = useMemo<AggregatedFlow[]>(() => {
444
+ if (!flowsData?.aggregated) return []
445
+
446
+ return flowsData.aggregated.filter(flow => {
447
+ const sourceIsSystem = isSystemEndpoint(flow.source.name, flow.source.namespace, flow.source.kind)
448
+ const destIsSystem = isSystemEndpoint(flow.destination.name, flow.destination.namespace, flow.destination.kind)
449
+
450
+ // If hiding system, skip flows where EITHER endpoint is a system component
451
+ if (hideSystem && (sourceIsSystem || destIsSystem)) {
452
+ return false
453
+ }
454
+
455
+ // Always filter out non-useful traffic (regardless of hideSystem setting)
456
+ const isAlwaysFiltered = (name: string) =>
457
+ // Cloud metadata services
458
+ name === 'metadata.google.internal' ||
459
+ name === 'metadata.google.internal.' ||
460
+ name.startsWith('169.254.') ||
461
+ name === 'instance-data.ec2.internal' ||
462
+ // Loopback / bind addresses - not real traffic
463
+ name === 'localhost' ||
464
+ name === '127.0.0.1' ||
465
+ name.startsWith('127.') ||
466
+ name === '0.0.0.0'
467
+
468
+ if (isAlwaysFiltered(flow.source.name) || isAlwaysFiltered(flow.destination.name)) {
469
+ return false
470
+ }
471
+
472
+ // If hiding external, skip flows with external endpoints
473
+ if (hideExternal) {
474
+ if (isExternal(flow.source.kind) || isExternal(flow.destination.kind)) {
475
+ return false
476
+ }
477
+ }
478
+
479
+ // Addon mode: hide
480
+ if (addonMode === 'hide') {
481
+ const sourceIsAddon = isClusterAddon(flow.source.name, flow.source.namespace)
482
+ const destIsAddon = isClusterAddon(flow.destination.name, flow.destination.namespace)
483
+ if (sourceIsAddon || destIsAddon) {
484
+ return false
485
+ }
486
+ }
487
+
488
+ // Connection threshold filter
489
+ if (flow.connections < minConnections) {
490
+ return false
491
+ }
492
+
493
+ // Filter by hidden namespaces - hide flow if EITHER endpoint is in a hidden namespace
494
+ if (hiddenNamespaces.size > 0) {
495
+ const sourceNs = flow.source.namespace
496
+ const destNs = flow.destination.namespace
497
+ if (sourceNs && hiddenNamespaces.has(sourceNs)) return false
498
+ if (destNs && hiddenNamespaces.has(destNs)) return false
499
+ }
500
+
501
+ // Protocol filter
502
+ if (l7Protocol === 'HTTP' && flow.l7Protocol !== 'HTTP') return false
503
+ if (l7Protocol === 'DNS' && flow.l7Protocol !== 'DNS') return false
504
+ if (l7Protocol === 'TCP' && flow.l7Protocol) return false // TCP = no L7
505
+
506
+ // L7 sub-filters (only apply when active)
507
+ if (l7Methods.size > 0) {
508
+ if (!flow.topHTTPPaths?.some(p => l7Methods.has(p.method))) return false
509
+ }
510
+ if (l7StatusRanges.size > 0) {
511
+ if (!flow.httpStatusCounts || !Array.from(l7StatusRanges).some(r => (flow.httpStatusCounts?.[r] ?? 0) > 0)) return false
512
+ }
513
+ if (l7Verdicts.size > 0) {
514
+ if (!flow.verdictCounts || !Array.from(l7Verdicts).some(v => (flow.verdictCounts?.[v] ?? 0) > 0)) return false
515
+ }
516
+ if (dnsPattern) {
517
+ const pattern = dnsPattern.toLowerCase()
518
+ if (!flow.topDNSQueries?.some(q => q.query.toLowerCase().includes(pattern))) return false
519
+ }
520
+
521
+ return true
522
+ })
523
+ }, [flowsData?.aggregated, hideSystem, hideExternal, minConnections, hiddenNamespaces, addonMode, l7Protocol, l7Methods, l7StatusRanges, l7Verdicts, dnsPattern])
524
+
525
+ // Filter raw flows with the same base filters (for list view)
526
+ const filteredRawFlows = useMemo(() => {
527
+ if (!flowsData?.flows) return []
528
+ return flowsData.flows.filter(flow => {
529
+ const sourceIsSystem = isSystemEndpoint(flow.source.name, flow.source.namespace, flow.source.kind)
530
+ const destIsSystem = isSystemEndpoint(flow.destination.name, flow.destination.namespace, flow.destination.kind)
531
+ if (hideSystem && (sourceIsSystem || destIsSystem)) return false
532
+
533
+ const isAlwaysFiltered = (name: string) =>
534
+ name === 'metadata.google.internal' || name === 'metadata.google.internal.' ||
535
+ name.startsWith('169.254.') || name === 'instance-data.ec2.internal' ||
536
+ name === 'localhost' || name === '127.0.0.1' || name.startsWith('127.') || name === '0.0.0.0'
537
+ if (isAlwaysFiltered(flow.source.name) || isAlwaysFiltered(flow.destination.name)) return false
538
+
539
+ if (hideExternal && (isExternal(flow.source.kind) || isExternal(flow.destination.kind))) return false
540
+
541
+ if (addonMode === 'hide') {
542
+ if (isClusterAddon(flow.source.name, flow.source.namespace) || isClusterAddon(flow.destination.name, flow.destination.namespace)) return false
543
+ }
544
+
545
+ if (hiddenNamespaces.size > 0) {
546
+ if (flow.source.namespace && hiddenNamespaces.has(flow.source.namespace)) return false
547
+ if (flow.destination.namespace && hiddenNamespaces.has(flow.destination.namespace)) return false
548
+ }
549
+
550
+ // Protocol filter
551
+ if (l7Protocol === 'HTTP' && flow.l7Protocol !== 'HTTP') return false
552
+ if (l7Protocol === 'DNS' && flow.l7Protocol !== 'DNS') return false
553
+ if (l7Protocol === 'TCP' && flow.l7Protocol) return false
554
+
555
+ // L7 sub-filters on individual flow fields
556
+ if (l7Methods.size > 0) {
557
+ if (!flow.httpMethod || !l7Methods.has(flow.httpMethod)) return false
558
+ }
559
+ if (l7StatusRanges.size > 0) {
560
+ if (!flow.httpStatus) return false
561
+ const bucket = `${Math.floor(flow.httpStatus / 100)}xx`
562
+ if (!l7StatusRanges.has(bucket)) return false
563
+ }
564
+ if (l7Verdicts.size > 0) {
565
+ if (!flow.verdict || !l7Verdicts.has(flow.verdict)) return false
566
+ }
567
+ if (dnsPattern) {
568
+ if (!flow.dnsQuery || !flow.dnsQuery.toLowerCase().includes(dnsPattern.toLowerCase())) return false
569
+ }
570
+
571
+ return true
572
+ })
573
+ }, [flowsData?.flows, hideSystem, hideExternal, hiddenNamespaces, addonMode, l7Protocol, l7Methods, l7StatusRanges, l7Verdicts, dnsPattern])
574
+
575
+ // Apply graph selection to filter raw flows for the list panel
576
+ const listFlows = useMemo(() => {
577
+ if (!graphSelection) return filteredRawFlows
578
+ if (graphSelection.type === 'node' && graphSelection.nodeId) {
579
+ const id = graphSelection.nodeId
580
+ return filteredRawFlows.filter(f => {
581
+ const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
582
+ const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
583
+ return srcId === id || dstId === id
584
+ })
585
+ }
586
+ if (graphSelection.type === 'edge' && graphSelection.sourceId && graphSelection.destId) {
587
+ return filteredRawFlows.filter(f => {
588
+ const srcId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
589
+ const dstId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
590
+ // Match either direction (request goes A→B, response goes B→A)
591
+ return (srcId === graphSelection.sourceId && dstId === graphSelection.destId) ||
592
+ (srcId === graphSelection.destId && dstId === graphSelection.sourceId)
593
+ })
594
+ }
595
+ return filteredRawFlows
596
+ }, [filteredRawFlows, graphSelection])
597
+
598
+ // Open flow list in the bottom dock
599
+ const openFlowListDock = useCallback(() => {
600
+ const id = dock.addTab({ type: 'traffic-flows', title: 'Traffic Flows' })
601
+ flowsTabIdRef.current = id
602
+ }, [dock])
603
+
604
+ // Auto-open flows dock when Hubble raw flows are available
605
+ const hasAutoOpenedFlowsRef = useRef(false)
606
+ useEffect(() => {
607
+ if (flowsData?.flows && flowsData.flows.length > 0 && !hasAutoOpenedFlowsRef.current) {
608
+ hasAutoOpenedFlowsRef.current = true
609
+ openFlowListDock()
610
+ }
611
+ }, [flowsData?.flows, openFlowListDock])
612
+
613
+ // Show L7 filters only when flows actually contain L7 data
614
+ const hasL7Data = useMemo(() => {
615
+ if (!flowsData?.aggregated) return false
616
+ return flowsData.aggregated.some(f => f.l7Protocol || f.topHTTPPaths || f.topDNSQueries)
617
+ }, [flowsData?.aggregated])
618
+
619
+ // Toggle L7 filter helpers
620
+ const toggleL7Method = useCallback((method: string) => {
621
+ setL7Methods(prev => { const next = new Set(prev); next.has(method) ? next.delete(method) : next.add(method); return next })
622
+ }, [])
623
+ const toggleL7StatusRange = useCallback((range: string) => {
624
+ setL7StatusRanges(prev => { const next = new Set(prev); next.has(range) ? next.delete(range) : next.add(range); return next })
625
+ }, [])
626
+ const toggleL7Verdict = useCallback((verdict: string) => {
627
+ setL7Verdicts(prev => { const next = new Set(prev); next.has(verdict) ? next.delete(verdict) : next.add(verdict); return next })
628
+ }, [])
629
+
630
+ // Toggle namespace visibility
631
+ const toggleNamespace = useCallback((ns: string) => {
632
+ setHiddenNamespaces(prev => {
633
+ const next = new Set(prev)
634
+ if (next.has(ns)) {
635
+ next.delete(ns)
636
+ } else {
637
+ next.add(ns)
638
+ }
639
+ return next
640
+ })
641
+ }, [])
642
+
643
+ // Process flows for external service aggregation (Phase 4.2)
644
+ // Also tracks service categories for coloring external nodes
645
+ const { processedFlows, serviceCategories } = useMemo<{
646
+ processedFlows: AggregatedFlow[]
647
+ serviceCategories: Map<string, string>
648
+ }>(() => {
649
+ const categories = new Map<string, string>()
650
+
651
+ // Helper to get service info (optionally using port-based detection)
652
+ const getServiceInfo = (name: string, port: number) => {
653
+ return getExternalServiceName(name, detectServices ? port : undefined)
654
+ }
655
+
656
+ if (!aggregateExternal) {
657
+ // Even without aggregation, detect service categories for coloring (destinations only)
658
+ filteredFlows.forEach(flow => {
659
+ if (isExternal(flow.destination.kind)) {
660
+ const info = getServiceInfo(flow.destination.name, flow.port)
661
+ if (info.category) {
662
+ categories.set(flow.destination.name, info.category)
663
+ }
664
+ }
665
+ // Don't apply port-based detection to sources - port tells us the destination service
666
+ })
667
+ return { processedFlows: filteredFlows, serviceCategories: categories }
668
+ }
669
+
670
+ // Aggregate flows to the same external service
671
+ const aggregatedMap = new Map<string, AggregatedFlow>()
672
+
673
+ filteredFlows.forEach(flow => {
674
+ // Only aggregate destinations based on port/hostname - sources keep their original name
675
+ // Port-based detection (MongoDB:27017) only makes sense for destinations
676
+ const sourceAgg = isExternal(flow.source.kind)
677
+ ? getExternalServiceName(flow.source.name) // No port - hostname patterns only
678
+ : { name: flow.source.name, aggregated: false }
679
+ const destAgg = isExternal(flow.destination.kind)
680
+ ? getServiceInfo(flow.destination.name, flow.port) // Full detection with port
681
+ : { name: flow.destination.name, aggregated: false }
682
+
683
+ // Track categories for coloring (destinations only - sources don't get port-based categories)
684
+ if (destAgg.category) categories.set(destAgg.name, destAgg.category)
685
+
686
+ // Create a unique key for the aggregated flow (without port since we aggregate by service)
687
+ const sourceKey = flow.source.namespace
688
+ ? `${flow.source.namespace}/${sourceAgg.name}`
689
+ : sourceAgg.name
690
+ const destKey = flow.destination.namespace
691
+ ? `${flow.destination.namespace}/${destAgg.name}`
692
+ : destAgg.name
693
+ // Group by service name, not by port (all MongoDB connections become one edge)
694
+ const key = `${sourceKey}->${destKey}`
695
+
696
+ const existing = aggregatedMap.get(key)
697
+ if (existing) {
698
+ // Merge connections and bytes
699
+ existing.connections += flow.connections
700
+ existing.bytesSent += flow.bytesSent
701
+ existing.bytesRecv += flow.bytesRecv
702
+ existing.flowCount += flow.flowCount
703
+ if (flow.requestCount) {
704
+ existing.requestCount = (existing.requestCount || 0) + flow.requestCount
705
+ }
706
+ if (flow.errorCount) {
707
+ existing.errorCount = (existing.errorCount || 0) + flow.errorCount
708
+ }
709
+ } else {
710
+ // Create new aggregated flow with modified names
711
+ aggregatedMap.set(key, {
712
+ ...flow,
713
+ source: sourceAgg.aggregated
714
+ ? { ...flow.source, name: sourceAgg.name }
715
+ : flow.source,
716
+ destination: destAgg.aggregated
717
+ ? { ...flow.destination, name: destAgg.name }
718
+ : flow.destination,
719
+ })
720
+ }
721
+ })
722
+
723
+ return { processedFlows: Array.from(aggregatedMap.values()), serviceCategories: categories }
724
+ }, [filteredFlows, aggregateExternal, detectServices])
725
+
726
+ // Collapse inbound internet traffic (external sources → internal destinations)
727
+ const internetCollapsedFlows = useMemo<AggregatedFlow[]>(() => {
728
+ if (!collapseInternet) return processedFlows
729
+
730
+ // Group flows where external sources connect to internal destinations
731
+ const internetFlowsMap = new Map<string, AggregatedFlow>() // destKey -> aggregated flow
732
+ const nonInternetFlows: AggregatedFlow[] = []
733
+
734
+ processedFlows.forEach(flow => {
735
+ const sourceIsExternal = isExternal(flow.source.kind)
736
+ const destIsInternal = !isExternal(flow.destination.kind)
737
+
738
+ // Only collapse external → internal flows (inbound internet traffic)
739
+ if (sourceIsExternal && destIsInternal) {
740
+ // Create a key based on destination + port
741
+ const destKey = flow.destination.namespace
742
+ ? `${flow.destination.namespace}/${flow.destination.name}:${flow.port}`
743
+ : `${flow.destination.name}:${flow.port}`
744
+
745
+ const existing = internetFlowsMap.get(destKey)
746
+ if (existing) {
747
+ // Merge into existing "Internet" flow
748
+ existing.connections += flow.connections
749
+ existing.bytesSent += flow.bytesSent
750
+ existing.bytesRecv += flow.bytesRecv
751
+ existing.flowCount += flow.flowCount
752
+ } else {
753
+ // Create new "Internet" → destination flow
754
+ internetFlowsMap.set(destKey, {
755
+ ...flow,
756
+ source: {
757
+ name: 'Internet',
758
+ namespace: '',
759
+ kind: 'Internet',
760
+ },
761
+ })
762
+ }
763
+ } else {
764
+ nonInternetFlows.push(flow)
765
+ }
766
+ })
767
+
768
+ return [...nonInternetFlows, ...Array.from(internetFlowsMap.values())]
769
+ }, [processedFlows, collapseInternet])
770
+
771
+ // When grouping addons:
772
+ // 1. Aggregate internet → addon into single edge to group
773
+ // 2. Aggregate addon → kubernetes into single edge from group
774
+ const finalFlows = useMemo<AggregatedFlow[]>(() => {
775
+ if (addonMode !== 'group') return internetCollapsedFlows
776
+
777
+ // Track totals for aggregated edges
778
+ let addonInternetTotal = 0
779
+ let addonToK8sTotal = 0
780
+ const processedFlows: AggregatedFlow[] = []
781
+
782
+ // Check if destination is the kubernetes API server
783
+ const isKubernetesAPI = (name: string, namespace: string | undefined) => {
784
+ return name === 'kubernetes' && (!namespace || namespace === 'default')
785
+ }
786
+
787
+ internetCollapsedFlows.forEach(flow => {
788
+ const sourceIsAddon = isClusterAddon(flow.source.name, flow.source.namespace)
789
+ const destIsAddon = isClusterAddon(flow.destination.name, flow.destination.namespace)
790
+ const sourceIsInternet = flow.source.kind === 'Internet'
791
+ const destIsK8sAPI = isKubernetesAPI(flow.destination.name, flow.destination.namespace)
792
+
793
+ // Internet → Addon: aggregate into single edge to group
794
+ if (sourceIsInternet && destIsAddon) {
795
+ addonInternetTotal += flow.connections
796
+ processedFlows.push({
797
+ ...flow,
798
+ source: {
799
+ name: 'addon-internet',
800
+ namespace: '',
801
+ kind: 'SkipEdge', // Create addon node but skip individual edge
802
+ },
803
+ })
804
+ }
805
+ // Addon → Kubernetes API: aggregate into single edge from group
806
+ else if (sourceIsAddon && destIsK8sAPI) {
807
+ addonToK8sTotal += flow.connections
808
+ processedFlows.push({
809
+ ...flow,
810
+ destination: {
811
+ ...flow.destination,
812
+ kind: 'SkipEdge', // Create kubernetes node but skip individual edge
813
+ },
814
+ })
815
+ }
816
+ else {
817
+ processedFlows.push(flow)
818
+ }
819
+ })
820
+
821
+ // Add virtual flow for Internet → Addon Group edge
822
+ if (addonInternetTotal > 0) {
823
+ processedFlows.push({
824
+ source: {
825
+ name: 'addon-internet',
826
+ namespace: '',
827
+ kind: 'AddonInternet',
828
+ },
829
+ destination: {
830
+ name: 'addon-group-target',
831
+ namespace: '',
832
+ kind: 'AddonGroupTarget',
833
+ },
834
+ protocol: 'tcp',
835
+ port: 0,
836
+ connections: addonInternetTotal,
837
+ bytesSent: 0,
838
+ bytesRecv: 0,
839
+ flowCount: 1,
840
+ lastSeen: new Date().toISOString(),
841
+ })
842
+ }
843
+
844
+ // Add virtual flow for Addon Group → Kubernetes edge
845
+ if (addonToK8sTotal > 0) {
846
+ processedFlows.push({
847
+ source: {
848
+ name: 'addon-group-source',
849
+ namespace: '',
850
+ kind: 'AddonGroupSource',
851
+ },
852
+ destination: {
853
+ name: 'kubernetes',
854
+ namespace: 'default',
855
+ kind: 'Service',
856
+ },
857
+ protocol: 'tcp',
858
+ port: 443,
859
+ connections: addonToK8sTotal,
860
+ bytesSent: 0,
861
+ bytesRecv: 0,
862
+ flowCount: 1,
863
+ lastSeen: new Date().toISOString(),
864
+ })
865
+ }
866
+
867
+ return processedFlows
868
+ }, [internetCollapsedFlows, addonMode])
869
+
870
+ // Stats for display
871
+ const flowStats = useMemo(() => {
872
+ const total = flowsData?.aggregated?.length || 0
873
+ const filtered = filteredFlows.length
874
+ const shown = finalFlows.length
875
+ const hidden = total - filtered
876
+ const aggregated = filtered - shown
877
+ return { total, filtered, shown, hidden, aggregated }
878
+ }, [flowsData?.aggregated?.length, filteredFlows.length, finalFlows.length])
879
+
880
+ // Compute hot path threshold (top 10% of connections)
881
+ const hotPathThreshold = useMemo(() => {
882
+ if (finalFlows.length === 0) return 0
883
+ const connectionCounts = finalFlows.map(f => f.connections).sort((a, b) => b - a)
884
+ const topTenPercentIndex = Math.max(0, Math.floor(connectionCounts.length * 0.1) - 1)
885
+ return connectionCounts[topTenPercentIndex] || connectionCounts[0] || 0
886
+ }, [finalFlows])
887
+
888
+ // Extract unique namespaces with node counts (from filtered flows, excluding namespace filter itself)
889
+ // This shows only namespaces that pass other filters (hideSystem, hideExternal, minConnections)
890
+ const namespacesWithCounts = useMemo(() => {
891
+ const nsCounts = new Map<string, Set<string>>() // namespace -> set of node names
892
+
893
+ // Use flows filtered by everything EXCEPT namespace filter
894
+ const flows = (flowsData?.aggregated || []).filter(flow => {
895
+ const sourceIsSystem = isSystemEndpoint(flow.source.name, flow.source.namespace, flow.source.kind)
896
+ const destIsSystem = isSystemEndpoint(flow.destination.name, flow.destination.namespace, flow.destination.kind)
897
+
898
+ if (hideSystem && (sourceIsSystem || destIsSystem)) {
899
+ return false
900
+ }
901
+
902
+ if (hideExternal) {
903
+ if (isExternal(flow.source.kind) || isExternal(flow.destination.kind)) {
904
+ return false
905
+ }
906
+ }
907
+
908
+ if (flow.connections < minConnections) {
909
+ return false
910
+ }
911
+
912
+ return true
913
+ })
914
+
915
+ flows.forEach(flow => {
916
+ // Count source nodes
917
+ if (flow.source.namespace && flow.source.kind.toLowerCase() !== 'external') {
918
+ if (!nsCounts.has(flow.source.namespace)) {
919
+ nsCounts.set(flow.source.namespace, new Set())
920
+ }
921
+ nsCounts.get(flow.source.namespace)!.add(flow.source.name)
922
+ }
923
+ // Count destination nodes
924
+ if (flow.destination.namespace && flow.destination.kind.toLowerCase() !== 'external') {
925
+ if (!nsCounts.has(flow.destination.namespace)) {
926
+ nsCounts.set(flow.destination.namespace, new Set())
927
+ }
928
+ nsCounts.get(flow.destination.namespace)!.add(flow.destination.name)
929
+ }
930
+ })
931
+
932
+ return Array.from(nsCounts.entries()).map(([name, nodes]) => ({
933
+ name,
934
+ nodeCount: nodes.size,
935
+ }))
936
+ }, [flowsData?.aggregated, hideSystem, hideExternal, minConnections])
937
+
938
+ // Determine wizard state based on sources detection
939
+ useEffect(() => {
940
+ if (sourcesLoading) {
941
+ setWizardState('detecting')
942
+ return
943
+ }
944
+
945
+ if (!sourcesData) {
946
+ setWizardState('not_found')
947
+ return
948
+ }
949
+
950
+ // Only consider sources with status 'available' as ready
951
+ const availableSources = sourcesData.detected.filter(s => s.status === 'available')
952
+ if (availableSources.length > 0) {
953
+ setWizardState('ready')
954
+ } else {
955
+ setWizardState('not_found')
956
+ }
957
+ }, [sourcesData, sourcesLoading])
958
+
959
+ // Shared connection handler — used by auto-connect and retry buttons
960
+ const handleConnect = useCallback(() => {
961
+ setIsConnecting(true)
962
+ setConnectionError(null)
963
+ queryClient.removeQueries({ queryKey: ['traffic-flows'] })
964
+
965
+ connectMutation.mutate(undefined, {
966
+ onSuccess: (data) => {
967
+ setIsConnecting(false)
968
+ if (!data.connected && data.error) {
969
+ setConnectionError(data.error)
970
+ hasAutoConnectedRef.current = false // allow retry
971
+ }
972
+ },
973
+ onError: (error) => {
974
+ setIsConnecting(false)
975
+ setConnectionError(error.message)
976
+ hasAutoConnectedRef.current = false // allow retry
977
+ },
978
+ })
979
+ }, [connectMutation, queryClient])
980
+
981
+ // Auto-connect when source is detected
982
+ useEffect(() => {
983
+ if (wizardState === 'ready' && !hasAutoConnectedRef.current && !isConnecting) {
984
+ hasAutoConnectedRef.current = true
985
+ handleConnect()
986
+ }
987
+ }, [wizardState, isConnecting, handleConnect])
988
+
989
+ // Show wizard if no traffic source detected
990
+ if (wizardState !== 'ready') {
991
+ return (
992
+ <TrafficWizard
993
+ state={wizardState}
994
+ setState={setWizardState}
995
+ sourcesData={sourcesData}
996
+ sourcesLoading={sourcesLoading}
997
+ onRefetch={refetchSources}
998
+ />
999
+ )
1000
+ }
1001
+
1002
+ return (
1003
+ <TrafficFlowListProvider flows={listFlows} graphSelection={graphSelection} clearSelection={() => setGraphSelection(null)}>
1004
+ <div className="flex h-full w-full">
1005
+ {/* Sidebar */}
1006
+ <TrafficFilterSidebar
1007
+ hideSystem={hideSystem}
1008
+ setHideSystem={setHideSystem}
1009
+ hideExternal={hideExternal}
1010
+ setHideExternal={setHideExternal}
1011
+ minConnections={minConnections}
1012
+ setMinConnections={setMinConnections}
1013
+ showNamespaceGroups={showNamespaceGroups}
1014
+ setShowNamespaceGroups={setShowNamespaceGroups}
1015
+ collapseInternet={collapseInternet}
1016
+ setCollapseInternet={setCollapseInternet}
1017
+ addonMode={addonMode}
1018
+ setAddonMode={setAddonMode}
1019
+ aggregateExternal={aggregateExternal}
1020
+ setAggregateExternal={setAggregateExternal}
1021
+ detectServices={detectServices}
1022
+ setDetectServices={setDetectServices}
1023
+ timeRange={timeRange}
1024
+ setTimeRange={setTimeRange}
1025
+ isHubble={sourcesData?.active === 'hubble' && hasL7Data}
1026
+ l7Protocol={l7Protocol}
1027
+ setL7Protocol={setL7Protocol}
1028
+ l7Methods={l7Methods}
1029
+ onToggleL7Method={toggleL7Method}
1030
+ l7StatusRanges={l7StatusRanges}
1031
+ onToggleL7StatusRange={toggleL7StatusRange}
1032
+ l7Verdicts={l7Verdicts}
1033
+ onToggleL7Verdict={toggleL7Verdict}
1034
+ dnsPattern={dnsPattern}
1035
+ setDnsPattern={setDnsPattern}
1036
+ namespaces={namespacesWithCounts}
1037
+ hiddenNamespaces={hiddenNamespaces}
1038
+ onToggleNamespace={toggleNamespace}
1039
+ />
1040
+
1041
+ {/* Main content area */}
1042
+ <div className="flex-1 relative min-w-0">
1043
+ {/* Floating controls — overlaid on graph like topology view */}
1044
+ {(() => {
1045
+ const availableSources = sourcesData?.detected.filter(s => s.status === 'available') || []
1046
+ const activeName = sourcesData?.active
1047
+ const activeSource = availableSources.find(s => s.name === activeName) || availableSources[0]
1048
+
1049
+ const handleSwitchSource = (name: string) => {
1050
+ if (name === activeSource?.name) { setSourcePickerOpen(false); return }
1051
+ setSourcePickerOpen(false)
1052
+ setIsConnecting(true)
1053
+ setConnectionError(null)
1054
+ hasAutoConnectedRef.current = true
1055
+ setSourceMutation.mutate(name, {
1056
+ onSuccess: () => {
1057
+ queryClient.invalidateQueries({ queryKey: ['traffic-sources'] })
1058
+ connectMutation.mutate(undefined, {
1059
+ onSuccess: (data) => {
1060
+ setIsConnecting(false)
1061
+ if (!data.connected && data.error) setConnectionError(data.error)
1062
+ queryClient.invalidateQueries({ queryKey: ['traffic-flows'] })
1063
+ },
1064
+ onError: (error) => { setIsConnecting(false); setConnectionError(error.message) },
1065
+ })
1066
+ },
1067
+ onError: (error) => { setIsConnecting(false); setConnectionError(error.message) },
1068
+ })
1069
+ }
1070
+
1071
+ return (
1072
+ <>
1073
+ {/* Top-left: source status pill */}
1074
+ <div className="absolute top-3 left-3 z-10 flex items-center gap-2">
1075
+ {activeSource && (
1076
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[11px]">
1077
+ {isConnecting ? (
1078
+ <>
1079
+ <Loader2 className="h-3 w-3 animate-spin text-blue-400" />
1080
+ <span className="text-blue-400">Connecting...</span>
1081
+ </>
1082
+ ) : connectionError ? (
1083
+ <>
1084
+ <span className="w-2 h-2 rounded-full bg-yellow-500" />
1085
+ <span className="text-theme-text-secondary">{activeSource.name}</span>
1086
+ <button onClick={handleConnect} className="text-yellow-500 hover:text-yellow-400 font-medium">retry</button>
1087
+ </>
1088
+ ) : (
1089
+ <>
1090
+ <span className="w-2 h-2 rounded-full bg-green-500" />
1091
+ {availableSources.length > 1 ? (
1092
+ <div className="relative" ref={sourcePickerRef}>
1093
+ <button onClick={() => setSourcePickerOpen(!sourcePickerOpen)} className="flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary">
1094
+ {activeSource.name} <ChevronDown className="h-3 w-3" />
1095
+ </button>
1096
+ {sourcePickerOpen && (
1097
+ <div className="absolute top-full left-0 mt-1 z-50 bg-theme-surface border border-theme-border rounded-md shadow-lg py-1 min-w-[120px]">
1098
+ {availableSources.map(source => (
1099
+ <button key={source.name} onClick={() => handleSwitchSource(source.name)}
1100
+ className={clsx('w-full text-left px-3 py-1 text-xs hover:bg-theme-hover capitalize', source.name === activeSource.name && 'text-blue-400')}>
1101
+ {source.name}
1102
+ </button>
1103
+ ))}
1104
+ </div>
1105
+ )}
1106
+ </div>
1107
+ ) : (
1108
+ <span className="text-theme-text-secondary">{activeSource.name}</span>
1109
+ )}
1110
+ </>
1111
+ )}
1112
+ </div>
1113
+ )}
1114
+ </div>
1115
+
1116
+ {/* Top-right: stats + actions */}
1117
+ <div className="absolute top-3 right-3 z-10 flex items-center gap-2">
1118
+ {flowsData?.flows && flowsData.flows.length > 0 && (
1119
+ <button onClick={openFlowListDock}
1120
+ className="flex items-center gap-1 px-2 py-1 text-[10px] rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-theme-text-secondary hover:text-theme-text-primary transition-colors"
1121
+ title="Open flow list in dock">
1122
+ <List className="w-3 h-3" /> Flows
1123
+ </button>
1124
+ )}
1125
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary">
1126
+ {flowStats.shown}/{flowStats.total}
1127
+ <button onClick={refetchFlows} disabled={flowsLoading || isRefreshAnimating}
1128
+ className={clsx('p-0.5 rounded hover:text-theme-text-primary transition-colors', (flowsLoading || isRefreshAnimating) && 'opacity-50')}>
1129
+ {flowsLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className={clsx('h-3 w-3', isRefreshAnimating && 'animate-spin')} />}
1130
+ </button>
1131
+ </div>
1132
+ </div>
1133
+ </>
1134
+ )
1135
+ })()}
1136
+
1137
+ {isConnecting || (flowsFetching && finalFlows.length === 0) ? (
1138
+ <div className="absolute inset-0 flex items-center justify-center">
1139
+ <div className="flex items-center gap-2 text-theme-text-secondary">
1140
+ <Loader2 className="h-5 w-5 animate-spin" />
1141
+ <span>{isConnecting ? 'Connecting to traffic source...' : 'Loading traffic data...'}</span>
1142
+ </div>
1143
+ </div>
1144
+ ) : finalFlows.length > 0 ? (
1145
+ <TrafficGraph
1146
+ flows={finalFlows}
1147
+ hotPathThreshold={hotPathThreshold}
1148
+ showNamespaceGroups={showNamespaceGroups}
1149
+ serviceCategories={serviceCategories}
1150
+ addonMode={addonMode}
1151
+ trafficSource={sourcesData?.active || ''}
1152
+ onSelectionChange={setGraphSelection}
1153
+ />
1154
+ ) : connectionError ? (
1155
+ <div className="absolute inset-0 flex items-center justify-center">
1156
+ <div className="text-center space-y-3">
1157
+ <Plug className="h-12 w-12 text-yellow-500 mx-auto" />
1158
+ <p className="text-theme-text-secondary">Connection failed</p>
1159
+ <p className="text-xs text-theme-text-tertiary max-w-md">
1160
+ {connectionError}
1161
+ </p>
1162
+ <button
1163
+ onClick={handleConnect}
1164
+ className="px-3 py-1.5 text-sm btn-brand rounded"
1165
+ >
1166
+ Retry Connection
1167
+ </button>
1168
+ </div>
1169
+ </div>
1170
+ ) : (
1171
+ <div className="absolute inset-0 flex items-center justify-center">
1172
+ <div className="text-center space-y-2">
1173
+ <Filter className="h-12 w-12 text-theme-text-tertiary mx-auto" />
1174
+ {flowStats.total > 0 && flowStats.shown === 0 ? (
1175
+ <>
1176
+ <p className="text-theme-text-secondary">All traffic is filtered out</p>
1177
+ <p className="text-xs text-theme-text-tertiary">
1178
+ {flowStats.total} flows hidden by current filters.
1179
+ <button
1180
+ onClick={() => {
1181
+ setHideSystem(false)
1182
+ setHideExternal(false)
1183
+ setMinConnections(0)
1184
+ }}
1185
+ className="ml-1 text-blue-400 hover:underline"
1186
+ >
1187
+ Show all
1188
+ </button>
1189
+ </p>
1190
+ </>
1191
+ ) : flowsData?.warning ? (
1192
+ <>
1193
+ <p className="text-theme-text-secondary">Unable to fetch traffic data</p>
1194
+ <p className="text-xs text-yellow-500 max-w-md">
1195
+ {flowsData.warning}
1196
+ </p>
1197
+ </>
1198
+ ) : (
1199
+ <>
1200
+ <p className="text-theme-text-secondary">No traffic observed</p>
1201
+ <p className="text-xs text-theme-text-tertiary">
1202
+ Traffic will appear here once connections are made between services
1203
+ </p>
1204
+ </>
1205
+ )}
1206
+ </div>
1207
+ </div>
1208
+ )}
1209
+ </div>
1210
+ </div>
1211
+ </TrafficFlowListProvider>
1212
+ )
1213
+ }