@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,1546 @@
1
+ import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
2
+ import {
3
+ ReactFlow,
4
+ Background,
5
+ Controls,
6
+ useNodesState,
7
+ useEdgesState,
8
+ useReactFlow,
9
+ MarkerType,
10
+ Handle,
11
+ Position,
12
+ type Node,
13
+ type Edge,
14
+ type NodeMouseHandler,
15
+ type EdgeMouseHandler,
16
+ } from '@xyflow/react'
17
+ import '@xyflow/react/dist/style.css'
18
+ import ELK from 'elkjs/lib/elk.bundled.js'
19
+ import type { AggregatedFlow } from '../../types'
20
+ import { clsx } from 'clsx'
21
+ import { X, ArrowRight, Globe, Server, Activity, Puzzle } from 'lucide-react'
22
+ import { isClusterAddon, type AddonMode } from './TrafficView'
23
+ import { SEVERITY_BADGE, SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
24
+ import { getNamespaceColor } from '../../utils/traffic-colors'
25
+
26
+ const elk = new ELK()
27
+
28
+ // ELK layout options for traffic graph
29
+ const elkOptions = {
30
+ 'elk.algorithm': 'layered',
31
+ 'elk.direction': 'RIGHT',
32
+ 'elk.spacing.nodeNode': '50',
33
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '150',
34
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '40',
35
+ 'elk.edgeRouting': 'ORTHOGONAL',
36
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
37
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
38
+ }
39
+
40
+ // Exported selection info for parent components (e.g., to filter flow list)
41
+ export interface TrafficGraphSelection {
42
+ type: 'node' | 'edge'
43
+ // For node: the node ID (ns/name or just name)
44
+ // For edge: source and destination IDs
45
+ nodeId?: string
46
+ sourceId?: string
47
+ destId?: string
48
+ port?: number
49
+ }
50
+
51
+ interface TrafficGraphProps {
52
+ flows: AggregatedFlow[]
53
+ hotPathThreshold?: number
54
+ showNamespaceGroups?: boolean
55
+ serviceCategories?: Map<string, string>
56
+ addonMode?: AddonMode
57
+ trafficSource?: string
58
+ onSelectionChange?: (selection: TrafficGraphSelection | null) => void
59
+ }
60
+
61
+ // Phase 2.1: Calculate edge width based on connection count (log scale)
62
+ function getEdgeWidth(connections: number): number {
63
+ // 1K -> 1.5px, 10K -> 2.5px, 100K -> 3.5px, 1M -> 4.5px, 10M -> 5.5px
64
+ return Math.min(6, Math.max(1.5, Math.log10(Math.max(connections, 1000)) - 1.5))
65
+ }
66
+
67
+ // Phase 2.2: Format connection counts for display
68
+ function formatConnections(count: number): string {
69
+ if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(1)}B`
70
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
71
+ if (count >= 1_000) return `${(count / 1_000).toFixed(0)}K`
72
+ return count.toString()
73
+ }
74
+
75
+ function formatLatency(ms: number): string {
76
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`
77
+ if (ms >= 1) return `${ms.toFixed(1)}ms`
78
+ return `${(ms * 1000).toFixed(0)}µs`
79
+ }
80
+
81
+ function latencyColor(ms: number): string {
82
+ if (ms > 500) return SEVERITY_TEXT.error
83
+ if (ms > 200) return SEVERITY_TEXT.warning
84
+ if (ms > 50) return SEVERITY_TEXT.info
85
+ return SEVERITY_TEXT.success
86
+ }
87
+
88
+ const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
89
+ '2xx': { bg: 'bg-emerald-500', text: SEVERITY_TEXT.success },
90
+ '3xx': { bg: 'bg-amber-500', text: SEVERITY_TEXT.neutral },
91
+ '4xx': { bg: 'bg-amber-500', text: SEVERITY_TEXT.warning },
92
+ '5xx': { bg: 'bg-red-500', text: SEVERITY_TEXT.error },
93
+ }
94
+
95
+ const VERDICT_BADGE: Record<string, string> = {
96
+ forwarded: SEVERITY_BADGE.success,
97
+ dropped: SEVERITY_BADGE.error,
98
+ error: SEVERITY_BADGE.warning,
99
+ }
100
+
101
+ function StatusDistributionBar({ counts }: { counts: Record<string, number> }) {
102
+ const total = Object.values(counts).reduce((a, b) => a + b, 0)
103
+ if (total === 0) return null
104
+ const order = ['2xx', '3xx', '4xx', '5xx']
105
+ return (
106
+ <div className="space-y-1">
107
+ <div className="flex h-2 rounded overflow-hidden gap-px">
108
+ {order.map(key => {
109
+ const count = counts[key] ?? 0
110
+ if (count === 0) return null
111
+ const pct = (count / total) * 100
112
+ return <div key={key} className={clsx('h-full', STATUS_COLORS[key]?.bg ?? 'bg-gray-500')} style={{ width: `${pct}%` }} />
113
+ })}
114
+ </div>
115
+ <div className="flex flex-wrap gap-2 text-[10px]">
116
+ {order.map(key => {
117
+ const count = counts[key] ?? 0
118
+ if (count === 0) return null
119
+ return (
120
+ <span key={key} className={STATUS_COLORS[key]?.text ?? 'text-gray-400'}>
121
+ {key}: {count}
122
+ </span>
123
+ )
124
+ })}
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ // Port info with connection count
131
+ interface PortInfo {
132
+ port: number
133
+ connections: number
134
+ }
135
+
136
+ interface TrafficNodeData extends Record<string, unknown> {
137
+ label: string
138
+ namespace?: string
139
+ kind: string
140
+ workload?: string
141
+ connections?: number
142
+ totalConnections?: number // Total connections for this node
143
+ namespaceColor?: string // Background color for namespace grouping
144
+ isHotPath?: boolean // Whether this node is on a hot path
145
+ isAddonNode?: boolean // Whether this is a cluster addon node
146
+ serviceCategory?: string // For external nodes: database, cloud, etc.
147
+ ports?: PortInfo[] // All inbound ports sorted by connection count
148
+ nodeHeight?: number // Dynamic height based on ports
149
+ connLabel?: string // Label for connections: "conn" or "req/s"
150
+ }
151
+
152
+ // Service category colors for external nodes
153
+ const SERVICE_CATEGORY_COLORS: Record<string, { bg: string; border: string; dot: string }> = {
154
+ database: { bg: 'bg-violet-500/20', border: 'border-violet-500/50', dot: 'bg-violet-500' },
155
+ cloud: { bg: 'bg-cyan-500/20', border: 'border-cyan-500/50', dot: 'bg-cyan-500' },
156
+ monitoring: { bg: 'bg-lime-500/20', border: 'border-lime-500/50', dot: 'bg-lime-500' },
157
+ payment: { bg: 'bg-emerald-500/20', border: 'border-emerald-500/50', dot: 'bg-emerald-500' },
158
+ auth: { bg: 'bg-amber-500/20', border: 'border-amber-500/50', dot: 'bg-amber-500' },
159
+ email: { bg: 'bg-pink-500/20', border: 'border-pink-500/50', dot: 'bg-pink-500' },
160
+ messaging: { bg: 'bg-purple-500/20', border: 'border-purple-500/50', dot: 'bg-purple-500' },
161
+ cache: { bg: 'bg-orange-500/20', border: 'border-orange-500/50', dot: 'bg-orange-500' },
162
+ infra: { bg: 'bg-slate-500/20', border: 'border-slate-500/50', dot: 'bg-slate-500' },
163
+ web: { bg: 'bg-blue-500/20', border: 'border-blue-500/50', dot: 'bg-blue-500' },
164
+ }
165
+
166
+ const NODE_WIDTH = 180
167
+ const NODE_BASE_HEIGHT = 56 // Base height without ports
168
+ const NODE_PORT_HEIGHT = 18 // Height per port row
169
+ const MAX_VISIBLE_PORTS = 4 // Maximum ports to show before "+N more"
170
+
171
+ // Custom node component
172
+ function TrafficNode({ data }: { data: TrafficNodeData }) {
173
+ const isExternal = data.kind.toLowerCase() === 'external'
174
+ const isInternet = data.kind === 'Internet'
175
+ const isAddonInternet = data.kind === 'AddonInternet' // Separate internet for addon traffic
176
+ const isAddon = data.kind === 'Addon'
177
+ const isAddonNode = data.isAddonNode // Node is part of addon group
178
+ const categoryColors = data.serviceCategory ? SERVICE_CATEGORY_COLORS[data.serviceCategory] : null
179
+ const hasNamespaceColor = !isExternal && !isInternet && !isAddon && !isAddonNode && !isAddonInternet && data.namespaceColor
180
+
181
+ return (
182
+ <div
183
+ className={clsx(
184
+ 'px-3 py-2 rounded-lg border shadow-sm relative transition-all',
185
+ isAddonInternet
186
+ ? 'bg-purple-500/30 border-purple-400/50' // Purple internet for addons (outside group)
187
+ : isInternet
188
+ ? 'bg-sky-500/20 border-sky-500/50'
189
+ : isAddon
190
+ ? 'bg-purple-500/20 border-purple-500/50'
191
+ : isAddonNode
192
+ ? 'bg-purple-900/60 border-purple-500/50'
193
+ : isExternal
194
+ ? categoryColors
195
+ ? `${categoryColors.bg} ${categoryColors.border}`
196
+ : 'bg-yellow-500/10 border-yellow-500/30'
197
+ : hasNamespaceColor
198
+ ? 'border-white/20'
199
+ : 'bg-theme-surface border-theme-border',
200
+ data.isHotPath && 'ring-2 ring-orange-500/50'
201
+ )}
202
+ style={{
203
+ width: NODE_WIDTH,
204
+ backgroundColor: hasNamespaceColor ? data.namespaceColor : undefined,
205
+ }}
206
+ >
207
+ {/* Handles for edge connections */}
208
+ <Handle type="target" position={Position.Left} className="!bg-gray-400 !w-2 !h-2" />
209
+ <Handle type="source" position={Position.Right} className="!bg-gray-400 !w-2 !h-2" />
210
+
211
+ <div className="flex items-center gap-2">
212
+ {isAddonInternet ? (
213
+ <Globe className="w-4 h-4 text-purple-400 shrink-0" />
214
+ ) : isInternet ? (
215
+ <Globe className="w-4 h-4 text-sky-400 shrink-0" />
216
+ ) : isAddon ? (
217
+ <div className="w-2 h-2 rounded-full shrink-0 bg-purple-500" />
218
+ ) : isAddonNode ? (
219
+ <div className="w-2 h-2 rounded-full shrink-0 bg-purple-400" />
220
+ ) : (
221
+ <div
222
+ className={clsx(
223
+ 'w-2 h-2 rounded-full shrink-0',
224
+ data.isHotPath
225
+ ? 'bg-orange-500'
226
+ : isExternal
227
+ ? categoryColors?.dot || 'bg-yellow-500'
228
+ : 'bg-green-500'
229
+ )}
230
+ />
231
+ )}
232
+ <div className="flex-1 min-w-0">
233
+ <div className={clsx(
234
+ 'text-sm font-medium truncate',
235
+ isAddonInternet ? 'text-purple-300' : isInternet ? 'text-sky-300' : (isAddon || isAddonNode) ? 'text-purple-200' : hasNamespaceColor ? 'text-white' : 'text-theme-text-primary'
236
+ )}>{data.label}</div>
237
+ {data.namespace ? (
238
+ <div className={clsx(
239
+ 'text-xs truncate',
240
+ (hasNamespaceColor || isAddonNode) ? 'text-white/70' : 'text-theme-text-tertiary'
241
+ )}>
242
+ {data.namespace}
243
+ </div>
244
+ ) : isAddonInternet ? (
245
+ <div className="text-xs text-purple-400/70 truncate">
246
+ Inbound traffic
247
+ </div>
248
+ ) : isInternet ? (
249
+ <div className="text-xs text-sky-400/70 truncate">
250
+ Inbound traffic
251
+ </div>
252
+ ) : isAddon ? (
253
+ <div className="text-xs text-purple-400/70 truncate">
254
+ Monitoring, logging, etc.
255
+ </div>
256
+ ) : isExternal && data.serviceCategory && (
257
+ <div className="text-xs text-theme-text-tertiary truncate capitalize">
258
+ {data.serviceCategory}
259
+ </div>
260
+ )}
261
+ </div>
262
+ </div>
263
+ {data.workload && data.workload !== data.label && (
264
+ <div className={clsx(
265
+ 'text-xs mt-1 truncate',
266
+ hasNamespaceColor ? 'text-white/70' : 'text-theme-text-tertiary'
267
+ )}>
268
+ {data.workload}
269
+ </div>
270
+ )}
271
+ {/* Ports section */}
272
+ {data.ports && data.ports.filter(p => p.port !== 0).length > 0 && (
273
+ <div className="mt-1.5 space-y-0.5">
274
+ {data.ports.filter(p => p.port !== 0).slice(0, MAX_VISIBLE_PORTS).map((portInfo) => (
275
+ <div key={portInfo.port} className="flex items-center justify-between gap-1 text-xs">
276
+ <span className={clsx(
277
+ 'font-mono',
278
+ (hasNamespaceColor || isAddonNode) ? 'text-cyan-300' : 'text-blue-600 dark:text-blue-300'
279
+ )}>
280
+ :{portInfo.port}
281
+ </span>
282
+ <span className={clsx(
283
+ 'truncate',
284
+ data.isHotPath
285
+ ? 'text-orange-400'
286
+ : (hasNamespaceColor || isAddonNode)
287
+ ? 'text-white/60'
288
+ : 'text-theme-text-tertiary'
289
+ )}>
290
+ {formatConnections(portInfo.connections)}
291
+ </span>
292
+ </div>
293
+ ))}
294
+ {data.ports.filter(p => p.port !== 0).length > MAX_VISIBLE_PORTS && (
295
+ <div className={clsx(
296
+ 'text-xs',
297
+ (hasNamespaceColor || isAddonNode) ? 'text-white/50' : 'text-theme-text-tertiary'
298
+ )}>
299
+ +{data.ports.filter(p => p.port !== 0).length - MAX_VISIBLE_PORTS} more
300
+ </div>
301
+ )}
302
+ </div>
303
+ )}
304
+ {/* Total connections (only if no ports shown, or all ports are 0) */}
305
+ {(!data.ports || data.ports.filter(p => p.port !== 0).length === 0) && data.totalConnections && data.totalConnections > 0 && (
306
+ <div className="mt-1">
307
+ <span className={clsx(
308
+ 'text-xs truncate',
309
+ data.isHotPath
310
+ ? 'text-orange-400 font-medium'
311
+ : (hasNamespaceColor || isAddonNode)
312
+ ? 'text-white/70'
313
+ : 'text-theme-text-tertiary'
314
+ )}>
315
+ {formatConnections(data.totalConnections)} {data.connLabel || 'conn'}
316
+ </span>
317
+ </div>
318
+ )}
319
+ </div>
320
+ )
321
+ }
322
+
323
+ // Legend component
324
+ function TrafficLegend() {
325
+ return (
326
+ <div className="absolute bottom-2 left-2 bg-theme-surface border border-theme-border rounded-lg p-2.5 text-xs z-10 shadow-lg max-w-xs">
327
+ <div className="font-medium text-theme-text-primary mb-2">Legend</div>
328
+ <div className="space-y-1.5">
329
+ <div className="flex items-center gap-2">
330
+ <svg width="24" height="8" className="shrink-0">
331
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#f97316" strokeWidth="2" strokeDasharray="4 2" />
332
+ </svg>
333
+ <span className="text-theme-text-secondary">Hot path (top 10%)</span>
334
+ </div>
335
+ <div className="flex items-center gap-2">
336
+ <svg width="24" height="8" className="shrink-0">
337
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#3b82f6" strokeWidth="2" />
338
+ </svg>
339
+ <span className="text-theme-text-secondary">HTTP / gRPC</span>
340
+ </div>
341
+ <div className="flex items-center gap-2">
342
+ <svg width="24" height="8" className="shrink-0">
343
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#06b6d4" strokeWidth="2" />
344
+ </svg>
345
+ <span className="text-theme-text-secondary">DNS</span>
346
+ </div>
347
+ <div className="flex items-center gap-2">
348
+ <svg width="24" height="8" className="shrink-0">
349
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#ef4444" strokeWidth="2" />
350
+ </svg>
351
+ <span className="text-theme-text-secondary">Errors (5xx)</span>
352
+ </div>
353
+ <div className="flex items-center gap-2">
354
+ <svg width="24" height="8" className="shrink-0">
355
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#ef4444" strokeWidth="2" strokeDasharray="6 3" />
356
+ </svg>
357
+ <span className="text-theme-text-secondary">Dropped</span>
358
+ </div>
359
+ <div className="flex items-center gap-2">
360
+ <svg width="24" height="8" className="shrink-0">
361
+ <line x1="0" y1="4" x2="24" y2="4" stroke="#6b7280" strokeWidth="2" />
362
+ </svg>
363
+ <span className="text-theme-text-secondary">TCP</span>
364
+ </div>
365
+ <div className="pt-1.5 border-t border-theme-border text-theme-text-tertiary italic">
366
+ Thicker = more traffic
367
+ </div>
368
+ </div>
369
+
370
+ </div>
371
+ )
372
+ }
373
+
374
+ // Selection state types
375
+ type SelectionType = 'node' | 'edge' | null
376
+ interface Selection {
377
+ type: SelectionType
378
+ id: string
379
+ data?: TrafficNodeData | EdgeData
380
+ }
381
+
382
+ interface EdgeData {
383
+ source: string
384
+ target: string
385
+ port: number
386
+ connections: number
387
+ protocol: string
388
+ flow?: AggregatedFlow
389
+ }
390
+
391
+ // Format bytes for display
392
+ function formatBytes(bytes: number): string {
393
+ if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`
394
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
395
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`
396
+ return `${bytes} B`
397
+ }
398
+
399
+ // Details panel component
400
+ function DetailsPanel({
401
+ selection,
402
+ onClose,
403
+ flows,
404
+ isIstio,
405
+ }: {
406
+ selection: Selection
407
+ onClose: () => void
408
+ flows: AggregatedFlow[]
409
+ isIstio: boolean
410
+ }) {
411
+ if (!selection) return null
412
+
413
+ const isNode = selection.type === 'node'
414
+ const nodeData = isNode ? (selection.data as TrafficNodeData) : null
415
+ const edgeData = !isNode ? (selection.data as EdgeData) : null
416
+
417
+ // Find related flows for node
418
+ const relatedFlows = isNode
419
+ ? flows.filter(f => {
420
+ const sourceId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
421
+ const destId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
422
+ return sourceId === selection.id || destId === selection.id
423
+ })
424
+ : []
425
+
426
+ // Categorize flows by direction
427
+ const incomingFlows = relatedFlows.filter(f => {
428
+ const destId = f.destination.namespace ? `${f.destination.namespace}/${f.destination.name}` : f.destination.name
429
+ return destId === selection.id
430
+ })
431
+ const outgoingFlows = relatedFlows.filter(f => {
432
+ const sourceId = f.source.namespace ? `${f.source.namespace}/${f.source.name}` : f.source.name
433
+ return sourceId === selection.id
434
+ })
435
+
436
+ // Compute aggregate stats for node
437
+ const nodeStats = isNode ? {
438
+ totalBytes: relatedFlows.reduce((sum, f) => sum + f.bytesSent + f.bytesRecv, 0),
439
+ protocols: relatedFlows.reduce((acc, f) => {
440
+ const proto = f.protocol?.toUpperCase() || 'TCP'
441
+ acc[proto] = (acc[proto] || 0) + f.connections
442
+ return acc
443
+ }, {} as Record<string, number>),
444
+ lastSeen: relatedFlows.reduce((latest, f) => {
445
+ if (!f.lastSeen) return latest
446
+ return !latest || f.lastSeen > latest ? f.lastSeen : latest
447
+ }, null as string | null),
448
+ flowCount: relatedFlows.reduce((sum, f) => sum + (f.flowCount || 1), 0),
449
+ totalRequests: relatedFlows.reduce((sum, f) => sum + (f.requestCount ?? 0), 0),
450
+ totalErrors: relatedFlows.reduce((sum, f) => sum + (f.errorCount ?? 0), 0),
451
+ l7Protocols: relatedFlows.reduce((acc, f) => {
452
+ if (f.l7Protocol) acc.add(f.l7Protocol)
453
+ return acc
454
+ }, new Set<string>()),
455
+ // Aggregate latency across edges (median of P50s, max of P95s)
456
+ latencyP50Ms: (() => {
457
+ const p50s = relatedFlows.map(f => f.latencyP50Ms).filter((v): v is number => v != null && v > 0)
458
+ if (p50s.length === 0) return undefined
459
+ p50s.sort((a, b) => a - b)
460
+ return p50s[Math.floor(p50s.length / 2)]
461
+ })(),
462
+ latencyP95Ms: (() => {
463
+ const p95s = relatedFlows.map(f => f.latencyP95Ms).filter((v): v is number => v != null && v > 0)
464
+ return p95s.length > 0 ? Math.max(...p95s) : undefined
465
+ })(),
466
+ // Aggregate HTTP status distribution
467
+ httpStatusCounts: relatedFlows.reduce((acc, f) => {
468
+ if (f.httpStatusCounts) {
469
+ for (const [k, v] of Object.entries(f.httpStatusCounts)) {
470
+ acc[k] = (acc[k] || 0) + v
471
+ }
472
+ }
473
+ return acc
474
+ }, {} as Record<string, number>),
475
+ // Aggregate verdict counts
476
+ verdictCounts: relatedFlows.reduce((acc, f) => {
477
+ if (f.verdictCounts) {
478
+ for (const [k, v] of Object.entries(f.verdictCounts)) {
479
+ acc[k] = (acc[k] || 0) + v
480
+ }
481
+ }
482
+ return acc
483
+ }, {} as Record<string, number>),
484
+ } : null
485
+
486
+ return (
487
+ <div className="absolute top-2 right-2 w-80 max-h-[calc(100%-1rem)] bg-theme-surface border border-theme-border rounded-lg shadow-xl overflow-hidden flex flex-col z-50">
488
+ {/* Header */}
489
+ <div className="flex items-center justify-between px-3 py-2 border-b border-theme-border bg-theme-elevated">
490
+ <div className="flex items-center gap-2">
491
+ {isNode ? (
492
+ nodeData?.kind === 'Internet' ? (
493
+ <Globe className="h-4 w-4 text-sky-400" />
494
+ ) : nodeData?.kind === 'Addon' ? (
495
+ <Server className="h-4 w-4 text-purple-400" />
496
+ ) : nodeData?.kind.toLowerCase() === 'external' ? (
497
+ <Globe className="h-4 w-4 text-yellow-500" />
498
+ ) : (
499
+ <Server className="h-4 w-4 text-blue-500" />
500
+ )
501
+ ) : (
502
+ <Activity className="h-4 w-4 text-green-500" />
503
+ )}
504
+ <span className="text-sm font-medium text-theme-text-primary">
505
+ {isNode ? (nodeData?.kind === 'Internet' ? 'Internet Traffic' : nodeData?.kind === 'Addon' ? 'Cluster Addons' : 'Service Details') : 'Connection Details'}
506
+ </span>
507
+ </div>
508
+ <button
509
+ onClick={onClose}
510
+ className="p-1 rounded hover:bg-theme-hover text-theme-text-secondary"
511
+ >
512
+ <X className="h-4 w-4" />
513
+ </button>
514
+ </div>
515
+
516
+ {/* Content */}
517
+ <div className="flex-1 overflow-y-auto p-3 space-y-3">
518
+ {isNode && nodeData && (
519
+ <>
520
+ {/* Node info */}
521
+ <div className="space-y-1">
522
+ <div className="text-sm font-medium text-theme-text-primary">{nodeData.label}</div>
523
+ {nodeData.namespace && (
524
+ <div className="text-xs text-theme-text-secondary">
525
+ Namespace: <span className="text-theme-text-primary">{nodeData.namespace}</span>
526
+ </div>
527
+ )}
528
+ <div className="text-xs text-theme-text-secondary">
529
+ Type: <span className={clsx(
530
+ 'px-1.5 py-0.5 rounded text-[10px]',
531
+ nodeData.kind === 'Internet'
532
+ ? 'bg-sky-500/20 text-sky-400'
533
+ : nodeData.kind === 'Addon'
534
+ ? 'bg-purple-500/20 text-purple-400'
535
+ : nodeData.kind.toLowerCase() === 'external'
536
+ ? 'bg-yellow-500/20 text-yellow-400'
537
+ : 'bg-blue-500/20 text-blue-400'
538
+ )}>{nodeData.kind === 'Addon' ? 'Cluster Addons' : nodeData.kind}</span>
539
+ </div>
540
+ {nodeData.workload && nodeData.workload !== nodeData.label && (
541
+ <div className="text-xs text-theme-text-secondary">
542
+ Workload: <span className="text-theme-text-primary">{nodeData.workload}</span>
543
+ </div>
544
+ )}
545
+ {nodeData.serviceCategory && (
546
+ <div className="text-xs text-theme-text-secondary">
547
+ Service: <span className="text-theme-text-primary capitalize">{nodeData.serviceCategory}</span>
548
+ </div>
549
+ )}
550
+ {nodeData.totalConnections && (
551
+ <div className="text-xs text-theme-text-secondary">
552
+ {isIstio ? 'Total request rate' : 'Total connections'}: <span className="text-theme-text-primary font-medium">
553
+ {formatConnections(nodeData.totalConnections)}{isIstio ? '/s' : ''}
554
+ </span>
555
+ </div>
556
+ )}
557
+ </div>
558
+
559
+ {/* Stats grid */}
560
+ {nodeStats && (nodeStats.totalBytes > 0 || nodeStats.lastSeen || nodeStats.totalRequests > 0) && (
561
+ <div className="grid grid-cols-2 gap-2">
562
+ {nodeStats.totalBytes > 0 && (
563
+ <div className="p-2 rounded bg-theme-elevated text-xs">
564
+ <div className="text-theme-text-tertiary">Data transferred</div>
565
+ <div className="text-theme-text-primary font-medium">{formatBytes(nodeStats.totalBytes)}</div>
566
+ </div>
567
+ )}
568
+ {nodeStats.flowCount > 1 && (
569
+ <div className="p-2 rounded bg-theme-elevated text-xs">
570
+ <div className="text-theme-text-tertiary">Raw flows</div>
571
+ <div className="text-theme-text-primary font-medium">{nodeStats.flowCount.toLocaleString()}</div>
572
+ </div>
573
+ )}
574
+ {nodeStats.totalRequests > 0 && (
575
+ <div className="p-2 rounded bg-theme-elevated text-xs">
576
+ <div className="text-theme-text-tertiary">Requests</div>
577
+ <div className="text-theme-text-primary font-medium">{formatConnections(nodeStats.totalRequests)}/s</div>
578
+ </div>
579
+ )}
580
+ {nodeStats.totalErrors > 0 && (
581
+ <div className="p-2 rounded bg-red-500/10 border border-red-500/30 text-xs">
582
+ <div className="text-red-400">Errors (5xx)</div>
583
+ <div className="text-red-400 font-medium">
584
+ {formatConnections(nodeStats.totalErrors)}/s
585
+ {nodeStats.totalRequests > 0 && (
586
+ <span className="text-red-300 ml-1">
587
+ ({((nodeStats.totalErrors / nodeStats.totalRequests) * 100).toFixed(1)}%)
588
+ </span>
589
+ )}
590
+ </div>
591
+ </div>
592
+ )}
593
+ {Object.keys(nodeStats.protocols).length > 0 && (
594
+ <div className="p-2 rounded bg-theme-elevated text-xs col-span-2">
595
+ <div className="text-theme-text-tertiary mb-1">Protocols</div>
596
+ <div className="flex flex-wrap gap-1.5">
597
+ {nodeStats.l7Protocols.size > 0 && Array.from(nodeStats.l7Protocols).map(proto => (
598
+ <span key={`l7-${proto}`} className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded badge', SEVERITY_BADGE.info)}>
599
+ <span className="font-medium">{proto}</span>
600
+ </span>
601
+ ))}
602
+ {Object.entries(nodeStats.protocols)
603
+ .sort((a, b) => b[1] - a[1])
604
+ .map(([proto, count]) => (
605
+ <span key={proto} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-theme-bg text-theme-text-secondary">
606
+ <span className="font-medium">{proto}</span>
607
+ <span className="text-theme-text-tertiary">{formatConnections(count)}</span>
608
+ </span>
609
+ ))}
610
+ </div>
611
+ </div>
612
+ )}
613
+ </div>
614
+ )}
615
+
616
+ {/* Node latency */}
617
+ {nodeStats?.latencyP50Ms && (
618
+ <div className="pt-1">
619
+ <div className="text-[10px] text-theme-text-tertiary mb-1">Latency</div>
620
+ <div className="flex gap-2 text-xs">
621
+ <span className={clsx('font-medium', latencyColor(nodeStats.latencyP50Ms))}>
622
+ P50: {formatLatency(nodeStats.latencyP50Ms)}
623
+ </span>
624
+ {nodeStats.latencyP95Ms && (
625
+ <span className={clsx('font-medium', latencyColor(nodeStats.latencyP95Ms))}>
626
+ P95: {formatLatency(nodeStats.latencyP95Ms)}
627
+ </span>
628
+ )}
629
+ </div>
630
+ </div>
631
+ )}
632
+
633
+ {/* Node HTTP status distribution */}
634
+ {nodeStats?.httpStatusCounts && Object.keys(nodeStats.httpStatusCounts).length > 0 && (
635
+ <div className="pt-1">
636
+ <div className="text-[10px] text-theme-text-tertiary mb-1">HTTP Status</div>
637
+ <StatusDistributionBar counts={nodeStats.httpStatusCounts} />
638
+ </div>
639
+ )}
640
+
641
+ {/* Node verdict summary */}
642
+ {nodeStats?.verdictCounts && (nodeStats.verdictCounts.dropped ?? 0) > 0 && (
643
+ <div className="pt-1">
644
+ <div className="flex flex-wrap gap-1">
645
+ {Object.entries(nodeStats.verdictCounts).map(([verdict, count]) => (
646
+ <span
647
+ key={verdict}
648
+ className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium',
649
+ VERDICT_BADGE[verdict] ?? SEVERITY_BADGE.neutral
650
+ )}
651
+ >
652
+ {verdict}: {count}
653
+ </span>
654
+ ))}
655
+ </div>
656
+ </div>
657
+ )}
658
+
659
+ {/* Incoming connections */}
660
+ {incomingFlows.length > 0 && (
661
+ <div className="space-y-1">
662
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
663
+ Incoming ({incomingFlows.length})
664
+ </div>
665
+ <div className="space-y-1.5 max-h-48 overflow-y-auto">
666
+ {incomingFlows
667
+ .sort((a, b) => b.connections - a.connections)
668
+ .map((flow, i) => (
669
+ <div key={i} className="text-xs p-2 rounded bg-theme-elevated space-y-1">
670
+ <div className="flex items-center gap-1.5">
671
+ <span className="text-theme-text-primary truncate flex-1">
672
+ {flow.source.name}
673
+ </span>
674
+ <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
675
+ {flow.port !== 0 && (
676
+ <span className="text-blue-400 font-mono">:{flow.port}</span>
677
+ )}
678
+ </div>
679
+ <div className="flex items-center gap-2 text-[10px]">
680
+ <span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
681
+ {flow.protocol || 'tcp'}
682
+ </span>
683
+ <span className="text-theme-text-secondary">
684
+ {formatConnections(flow.connections)} {isIstio ? 'req/s' : 'conn'}
685
+ </span>
686
+ {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
687
+ <span className="text-theme-text-tertiary">
688
+ {formatBytes(flow.bytesSent + flow.bytesRecv)}
689
+ </span>
690
+ )}
691
+ {flow.errorCount && flow.errorCount > 0 && (
692
+ <span className="text-red-400">
693
+ {formatConnections(flow.errorCount)} err
694
+ </span>
695
+ )}
696
+ </div>
697
+ </div>
698
+ ))}
699
+ </div>
700
+ </div>
701
+ )}
702
+
703
+ {/* Outgoing connections */}
704
+ {outgoingFlows.length > 0 && (
705
+ <div className="space-y-1">
706
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide">
707
+ Outgoing ({outgoingFlows.length})
708
+ </div>
709
+ <div className="space-y-1.5 max-h-48 overflow-y-auto">
710
+ {outgoingFlows
711
+ .sort((a, b) => b.connections - a.connections)
712
+ .map((flow, i) => (
713
+ <div key={i} className="text-xs p-2 rounded bg-theme-elevated space-y-1">
714
+ <div className="flex items-center gap-1.5">
715
+ <ArrowRight className="h-3 w-3 text-theme-text-tertiary shrink-0" />
716
+ <span className="text-theme-text-primary truncate flex-1">
717
+ {flow.destination.name}
718
+ </span>
719
+ {flow.port !== 0 && (
720
+ <span className="text-blue-400 font-mono">:{flow.port}</span>
721
+ )}
722
+ </div>
723
+ <div className="flex items-center gap-2 text-[10px]">
724
+ <span className="px-1 py-0.5 rounded bg-theme-bg text-theme-text-tertiary uppercase">
725
+ {flow.protocol || 'tcp'}
726
+ </span>
727
+ <span className="text-theme-text-secondary">
728
+ {formatConnections(flow.connections)} {isIstio ? 'req/s' : 'conn'}
729
+ </span>
730
+ {(flow.bytesSent > 0 || flow.bytesRecv > 0) && (
731
+ <span className="text-theme-text-tertiary">
732
+ {formatBytes(flow.bytesSent + flow.bytesRecv)}
733
+ </span>
734
+ )}
735
+ {flow.errorCount && flow.errorCount > 0 && (
736
+ <span className="text-red-400">
737
+ {formatConnections(flow.errorCount)} err
738
+ </span>
739
+ )}
740
+ </div>
741
+ </div>
742
+ ))}
743
+ </div>
744
+ </div>
745
+ )}
746
+ </>
747
+ )}
748
+
749
+ {!isNode && edgeData && (
750
+ <>
751
+ {/* Edge/Flow info */}
752
+ <div className="space-y-2">
753
+ <div className="flex items-center gap-2 text-sm">
754
+ <span className="text-theme-text-primary truncate">{edgeData.source.split('/').pop()}</span>
755
+ <ArrowRight className="h-4 w-4 text-theme-text-tertiary shrink-0" />
756
+ <span className="text-theme-text-primary truncate">{edgeData.target.split('/').pop()}</span>
757
+ </div>
758
+
759
+ <div className="grid grid-cols-2 gap-2 text-xs">
760
+ {edgeData.port !== 0 && (
761
+ <div className="p-2 rounded bg-theme-elevated">
762
+ <div className="text-theme-text-tertiary">Port</div>
763
+ <div className="text-theme-text-primary font-medium">{edgeData.port}</div>
764
+ </div>
765
+ )}
766
+ <div className="p-2 rounded bg-theme-elevated">
767
+ <div className="text-theme-text-tertiary">Protocol</div>
768
+ <div className="text-theme-text-primary font-medium uppercase">
769
+ {edgeData.flow?.l7Protocol
770
+ ? `${edgeData.flow.l7Protocol} / ${edgeData.protocol}`
771
+ : edgeData.protocol}
772
+ </div>
773
+ </div>
774
+ <div className="p-2 rounded bg-theme-elevated">
775
+ <div className="text-theme-text-tertiary">{isIstio ? 'Request Rate' : 'Connections'}</div>
776
+ <div className="text-theme-text-primary font-medium">
777
+ {formatConnections(edgeData.connections)}{isIstio ? '/s' : ''}
778
+ </div>
779
+ </div>
780
+ {edgeData.flow && (edgeData.flow.bytesSent > 0 || edgeData.flow.bytesRecv > 0) && (
781
+ <div className="p-2 rounded bg-theme-elevated">
782
+ <div className="text-theme-text-tertiary">Data</div>
783
+ <div className="text-theme-text-primary font-medium">
784
+ {formatBytes(edgeData.flow.bytesSent + edgeData.flow.bytesRecv)}
785
+ </div>
786
+ </div>
787
+ )}
788
+ {edgeData.flow?.requestCount && edgeData.flow.requestCount > 0 && (
789
+ <div className="p-2 rounded bg-theme-elevated">
790
+ <div className="text-theme-text-tertiary">Requests</div>
791
+ <div className="text-theme-text-primary font-medium">
792
+ {formatConnections(edgeData.flow.requestCount)}/s
793
+ </div>
794
+ </div>
795
+ )}
796
+ {edgeData.flow?.errorCount && edgeData.flow.errorCount > 0 && (
797
+ <div className="p-2 rounded bg-red-500/10 border border-red-500/30">
798
+ <div className="text-red-400">Errors (5xx)</div>
799
+ <div className="text-red-400 font-medium">
800
+ {formatConnections(edgeData.flow.errorCount)}/s
801
+ {edgeData.flow.requestCount && edgeData.flow.requestCount > 0 && (
802
+ <span className="text-red-300 ml-1">
803
+ ({((edgeData.flow.errorCount / edgeData.flow.requestCount) * 100).toFixed(1)}%)
804
+ </span>
805
+ )}
806
+ </div>
807
+ </div>
808
+ )}
809
+ </div>
810
+
811
+ {/* Latency percentiles */}
812
+ {edgeData.flow && (edgeData.flow.latencyP50Ms || edgeData.flow.latencyP95Ms || edgeData.flow.latencyP99Ms) ? (
813
+ <div className="pt-2 border-t border-theme-border">
814
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">Latency</div>
815
+ <div className="grid grid-cols-3 gap-1.5 text-xs">
816
+ {[
817
+ { label: 'P50', value: edgeData.flow.latencyP50Ms },
818
+ { label: 'P95', value: edgeData.flow.latencyP95Ms },
819
+ { label: 'P99', value: edgeData.flow.latencyP99Ms },
820
+ ].map(({ label, value }) => (
821
+ <div key={label} className="p-1.5 rounded bg-theme-elevated text-center">
822
+ <div className="text-theme-text-tertiary text-[9px]">{label}</div>
823
+ <div className={clsx('font-medium', latencyColor(value ?? 0))}>
824
+ {value ? formatLatency(value) : '—'}
825
+ </div>
826
+ </div>
827
+ ))}
828
+ </div>
829
+ </div>
830
+ ) : null}
831
+
832
+ {/* HTTP status distribution */}
833
+ {edgeData.flow?.httpStatusCounts && Object.keys(edgeData.flow.httpStatusCounts).length > 0 && (
834
+ <div className="pt-2 border-t border-theme-border">
835
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">HTTP Status</div>
836
+ <StatusDistributionBar counts={edgeData.flow.httpStatusCounts} />
837
+ </div>
838
+ )}
839
+
840
+ {/* Top HTTP paths */}
841
+ {edgeData.flow?.topHTTPPaths && edgeData.flow.topHTTPPaths.length > 0 && (
842
+ <div className="pt-2 border-t border-theme-border">
843
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">Top Paths</div>
844
+ <div className="space-y-1 max-h-40 overflow-y-auto">
845
+ {edgeData.flow.topHTTPPaths.map((p, i) => (
846
+ <div key={i} className="flex items-center gap-1.5 text-[10px]">
847
+ <span className={clsx('shrink-0 px-1 py-0.5 rounded badge font-medium', SEVERITY_BADGE.info)}>{p.method}</span>
848
+ <span className="text-theme-text-primary truncate flex-1" title={p.path}>{p.path || '/'}</span>
849
+ <span className="shrink-0 text-theme-text-secondary">{p.count}</span>
850
+ {p.avgMs ? <span className="shrink-0 text-theme-text-tertiary">{formatLatency(p.avgMs)}</span> : null}
851
+ {p.errorPct ? <span className={clsx('shrink-0', p.errorPct > 10 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>{p.errorPct.toFixed(0)}%err</span> : null}
852
+ </div>
853
+ ))}
854
+ </div>
855
+ </div>
856
+ )}
857
+
858
+ {/* Top DNS queries */}
859
+ {edgeData.flow?.topDNSQueries && edgeData.flow.topDNSQueries.length > 0 && (
860
+ <div className="pt-2 border-t border-theme-border">
861
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">DNS Queries</div>
862
+ <div className="space-y-1 max-h-40 overflow-y-auto">
863
+ {edgeData.flow.topDNSQueries.map((q, i) => (
864
+ <div key={i} className="flex items-center gap-1.5 text-[10px]">
865
+ <span className="text-theme-text-primary truncate flex-1" title={q.query}>{q.query}</span>
866
+ <span className="shrink-0 text-theme-text-secondary">{q.count}</span>
867
+ {q.nxCount ? <span className={clsx('shrink-0', SEVERITY_TEXT.warning)}>NX:{q.nxCount}</span> : null}
868
+ {q.avgTTL ? <span className="shrink-0 text-theme-text-tertiary">TTL:{q.avgTTL}s</span> : null}
869
+ </div>
870
+ ))}
871
+ </div>
872
+ </div>
873
+ )}
874
+
875
+ {/* Verdict breakdown */}
876
+ {edgeData.flow?.verdictCounts && Object.keys(edgeData.flow.verdictCounts).length > 1 && (
877
+ <div className="pt-2 border-t border-theme-border">
878
+ <div className="text-[10px] text-theme-text-tertiary mb-1.5">Verdicts</div>
879
+ <div className="flex flex-wrap gap-1">
880
+ {Object.entries(edgeData.flow.verdictCounts).map(([verdict, count]) => (
881
+ <span
882
+ key={verdict}
883
+ className={clsx('inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium',
884
+ verdict === 'forwarded' ? 'bg-green-500/20 text-green-300' :
885
+ verdict === 'dropped' ? 'bg-red-500/20 text-red-300' :
886
+ 'bg-orange-500/20 text-orange-300'
887
+ )}
888
+ >
889
+ {verdict}: {count}
890
+ </span>
891
+ ))}
892
+ </div>
893
+ {edgeData.flow.dropReasons && Object.keys(edgeData.flow.dropReasons).length > 0 && (
894
+ <div className="mt-1 space-y-0.5">
895
+ {Object.entries(edgeData.flow.dropReasons).map(([reason, count]) => (
896
+ <div key={reason} className={clsx('text-[9px] pl-1', SEVERITY_TEXT.error)}>
897
+ {reason}: {count}
898
+ </div>
899
+ ))}
900
+ </div>
901
+ )}
902
+ </div>
903
+ )}
904
+
905
+ {edgeData.flow && (
906
+ <div className="space-y-1 pt-2 border-t border-theme-border">
907
+ <div className="text-xs text-theme-text-secondary">
908
+ Source: <span className="text-theme-text-primary">{edgeData.flow.source.name}</span>
909
+ {edgeData.flow.source.namespace && (
910
+ <span className="text-theme-text-tertiary"> ({edgeData.flow.source.namespace})</span>
911
+ )}
912
+ </div>
913
+ <div className="text-xs text-theme-text-secondary">
914
+ Destination: <span className="text-theme-text-primary">{edgeData.flow.destination.name}</span>
915
+ {edgeData.flow.destination.namespace && (
916
+ <span className="text-theme-text-tertiary"> ({edgeData.flow.destination.namespace})</span>
917
+ )}
918
+ </div>
919
+ {edgeData.flow.destination.kind.toLowerCase() === 'external' && (
920
+ <div className="text-xs text-yellow-400 mt-1">
921
+ External service
922
+ </div>
923
+ )}
924
+ </div>
925
+ )}
926
+ </div>
927
+ </>
928
+ )}
929
+ </div>
930
+ </div>
931
+ )
932
+ }
933
+
934
+ // Group container node component for addon grouping
935
+ function AddonGroupNode({ data }: { data: { width: number; height: number } }) {
936
+ return (
937
+ <div
938
+ className="rounded-xl border-2 border-dashed border-purple-500/50 bg-purple-950/40 cursor-move"
939
+ style={{ width: data.width, height: data.height }}
940
+ >
941
+ {/* Handle for incoming edges (left side, vertically centered) */}
942
+ <Handle
943
+ type="target"
944
+ position={Position.Left}
945
+ className="!bg-purple-400 !w-3 !h-3 !border-2 !border-purple-600"
946
+ style={{ top: '50%' }}
947
+ />
948
+ {/* Handle for outgoing edges (right side) */}
949
+ <Handle
950
+ type="source"
951
+ position={Position.Right}
952
+ className="!bg-purple-400 !w-3 !h-3 !border-2 !border-purple-600"
953
+ style={{ top: '50%' }}
954
+ />
955
+ {/* Label at top */}
956
+ <div className="absolute top-2 left-3 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/30 border border-purple-500/40">
957
+ <Puzzle className="w-3.5 h-3.5 text-purple-400" />
958
+ <span className="text-xs font-medium text-purple-300">Cluster Addons</span>
959
+ </div>
960
+ </div>
961
+ )
962
+ }
963
+
964
+ const nodeTypes = {
965
+ traffic: TrafficNode,
966
+ addonGroup: AddonGroupNode,
967
+ }
968
+
969
+ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
970
+ const isIstio = trafficSource === 'istio'
971
+ const connLabel = isIstio ? 'req/s' : 'conn'
972
+ const [layoutedNodes, setLayoutedNodes] = useState<Node<TrafficNodeData>[]>([])
973
+ const [layoutedEdges, setLayoutedEdges] = useState<Edge[]>([])
974
+ const [selection, setSelection] = useState<Selection | null>(null)
975
+
976
+ // Store flow data by edge ID for click handler lookup
977
+ const flowByEdgeId = useMemo(() => {
978
+ const map = new Map<string, AggregatedFlow>()
979
+ flows.forEach(flow => {
980
+ const sourceId = flow.source.namespace
981
+ ? `${flow.source.namespace}/${flow.source.name}`
982
+ : flow.source.name
983
+ const destId = flow.destination.namespace
984
+ ? `${flow.destination.namespace}/${flow.destination.name}`
985
+ : flow.destination.name
986
+ const edgeId = `${sourceId}->${destId}:${flow.port}`
987
+ map.set(edgeId, flow)
988
+ })
989
+ return map
990
+ }, [flows])
991
+
992
+ // Build nodes and edges from flows
993
+ const { rawNodes, rawEdges, addonGroupEdge, addonGroupOutEdge } = useMemo(() => {
994
+ const nodeMap = new Map<string, Node<TrafficNodeData>>()
995
+ const edgeList: Edge[] = []
996
+ const connectionCounts = new Map<string, number>() // Count of edges per node
997
+ const totalConnections = new Map<string, number>() // Sum of connections per node
998
+ const hotNodes = new Set<string>() // Nodes on hot paths
999
+
1000
+ // Track primary port for each node (most common destination port)
1001
+ const nodePorts = new Map<string, Map<number, number>>() // nodeId -> port -> connection count
1002
+
1003
+ // Count connections per node
1004
+ flows.forEach((flow) => {
1005
+ const sourceId = flow.source.namespace
1006
+ ? `${flow.source.namespace}/${flow.source.name}`
1007
+ : flow.source.name
1008
+ const destId = flow.destination.namespace
1009
+ ? `${flow.destination.namespace}/${flow.destination.name}`
1010
+ : flow.destination.name
1011
+
1012
+ connectionCounts.set(sourceId, (connectionCounts.get(sourceId) || 0) + 1)
1013
+ connectionCounts.set(destId, (connectionCounts.get(destId) || 0) + 1)
1014
+
1015
+ // Sum total connections
1016
+ totalConnections.set(sourceId, (totalConnections.get(sourceId) || 0) + flow.connections)
1017
+ totalConnections.set(destId, (totalConnections.get(destId) || 0) + flow.connections)
1018
+
1019
+ // Track ports for destination nodes
1020
+ if (!nodePorts.has(destId)) nodePorts.set(destId, new Map())
1021
+ const portCounts = nodePorts.get(destId)!
1022
+ portCounts.set(flow.port, (portCounts.get(flow.port) || 0) + flow.connections)
1023
+
1024
+ // Track hot path nodes (Phase 2.3)
1025
+ if (flow.connections >= hotPathThreshold && hotPathThreshold > 0) {
1026
+ hotNodes.add(sourceId)
1027
+ hotNodes.add(destId)
1028
+ }
1029
+ })
1030
+
1031
+ // Get all ports for a node, sorted by connection count (descending)
1032
+ const getAllPorts = (nodeId: string): PortInfo[] => {
1033
+ const ports = nodePorts.get(nodeId)
1034
+ if (!ports || ports.size === 0) return []
1035
+ const portList: PortInfo[] = []
1036
+ ports.forEach((connections, port) => {
1037
+ portList.push({ port, connections })
1038
+ })
1039
+ // Sort by connection count descending
1040
+ return portList.sort((a, b) => b.connections - a.connections)
1041
+ }
1042
+
1043
+ // Calculate node height based on number of ports
1044
+ const getNodeHeight = (ports: PortInfo[]): number => {
1045
+ if (ports.length === 0) return NODE_BASE_HEIGHT
1046
+ const visiblePorts = Math.min(ports.length, MAX_VISIBLE_PORTS)
1047
+ const hasMore = ports.length > MAX_VISIBLE_PORTS
1048
+ return NODE_BASE_HEIGHT + (visiblePorts * NODE_PORT_HEIGHT) + (hasMore ? NODE_PORT_HEIGHT : 0)
1049
+ }
1050
+
1051
+ // Track if we have an edge to the addon group (for internet → group)
1052
+ let addonGroupEdge: { connections: number; flow: AggregatedFlow } | null = null
1053
+ // Track if we have an edge from the addon group (for group → kubernetes)
1054
+ let addonGroupOutEdge: { connections: number; flow: AggregatedFlow; targetId: string } | null = null
1055
+
1056
+ flows.forEach((flow) => {
1057
+ // Special case: AddonGroupTarget is a virtual node - edge goes to the group itself
1058
+ const isAddonGroupTarget = flow.destination.kind === 'AddonGroupTarget'
1059
+ // Special case: AddonGroupSource means edge comes from the group
1060
+ const isAddonGroupSource = flow.source.kind === 'AddonGroupSource'
1061
+ // Special case: SkipEdge means create the node but don't create an edge
1062
+ const skipEdgeSource = flow.source.kind === 'SkipEdge'
1063
+ const skipEdgeDest = flow.destination.kind === 'SkipEdge'
1064
+
1065
+ // Compute IDs
1066
+ const sourceId = flow.source.namespace
1067
+ ? `${flow.source.namespace}/${flow.source.name}`
1068
+ : flow.source.name
1069
+ const destId = flow.destination.namespace
1070
+ ? `${flow.destination.namespace}/${flow.destination.name}`
1071
+ : flow.destination.name
1072
+
1073
+ // Create source node (skip for SkipEdge and AddonGroupSource)
1074
+ if (!skipEdgeSource && !isAddonGroupSource && !nodeMap.has(sourceId)) {
1075
+ const isAddonInternet = flow.source.kind === 'AddonInternet'
1076
+ // AddonInternet stays OUTSIDE the group - it's a separate internet entry point
1077
+ const sourceIsAddon = addonMode === 'group' && isClusterAddon(flow.source.name, flow.source.namespace)
1078
+ // Source nodes don't have inbound ports displayed (they're the source)
1079
+ nodeMap.set(sourceId, {
1080
+ id: sourceId,
1081
+ type: 'traffic',
1082
+ position: { x: 0, y: 0 },
1083
+ data: {
1084
+ label: isAddonInternet ? 'Internet' : flow.source.name, // Display "Internet" for addon internet
1085
+ namespace: flow.source.namespace,
1086
+ kind: isAddonInternet ? 'AddonInternet' : flow.source.kind, // Keep AddonInternet kind for styling
1087
+ workload: flow.source.workload,
1088
+ connections: connectionCounts.get(sourceId),
1089
+ totalConnections: totalConnections.get(sourceId),
1090
+ namespaceColor: showNamespaceGroups && !sourceIsAddon ? getNamespaceColor(flow.source.namespace) : undefined,
1091
+ isHotPath: hotNodes.has(sourceId),
1092
+ isAddonNode: sourceIsAddon, // AddonInternet is NOT an addon node
1093
+ serviceCategory: flow.source.kind.toLowerCase() === 'external' ? serviceCategories?.get(flow.source.name) : undefined,
1094
+ nodeHeight: NODE_BASE_HEIGHT,
1095
+ connLabel,
1096
+ },
1097
+ })
1098
+ }
1099
+
1100
+ // For AddonGroupTarget, store for creating edge to group later
1101
+ if (isAddonGroupTarget) {
1102
+ addonGroupEdge = { connections: flow.connections, flow }
1103
+ return // Don't create node or regular edge
1104
+ }
1105
+
1106
+ // For AddonGroupSource, store for creating edge from group later
1107
+ if (isAddonGroupSource) {
1108
+ // We still need to create the destination node (kubernetes)
1109
+ if (!nodeMap.has(destId)) {
1110
+ const destPorts = getAllPorts(destId)
1111
+ nodeMap.set(destId, {
1112
+ id: destId,
1113
+ type: 'traffic',
1114
+ position: { x: 0, y: 0 },
1115
+ data: {
1116
+ label: flow.destination.name,
1117
+ namespace: flow.destination.namespace,
1118
+ kind: flow.destination.kind,
1119
+ workload: flow.destination.workload,
1120
+ connections: connectionCounts.get(destId),
1121
+ totalConnections: totalConnections.get(destId),
1122
+ namespaceColor: showNamespaceGroups ? getNamespaceColor(flow.destination.namespace) : undefined,
1123
+ isHotPath: hotNodes.has(destId),
1124
+ isAddonNode: false,
1125
+ serviceCategory: undefined,
1126
+ ports: destPorts,
1127
+ nodeHeight: getNodeHeight(destPorts),
1128
+ connLabel,
1129
+ },
1130
+ })
1131
+ }
1132
+ // Store for group edge creation
1133
+ addonGroupOutEdge = { connections: flow.connections, flow, targetId: destId }
1134
+ return
1135
+ }
1136
+
1137
+ // Create destination node (skip for SkipEdge dest - handled above or by source flow)
1138
+ if (!nodeMap.has(destId) && !skipEdgeDest) {
1139
+ const destIsAddon = addonMode === 'group' && isClusterAddon(flow.destination.name, flow.destination.namespace)
1140
+ const destPorts = getAllPorts(destId)
1141
+ nodeMap.set(destId, {
1142
+ id: destId,
1143
+ type: 'traffic',
1144
+ position: { x: 0, y: 0 },
1145
+ data: {
1146
+ label: flow.destination.name,
1147
+ namespace: flow.destination.namespace,
1148
+ kind: flow.destination.kind,
1149
+ workload: flow.destination.workload,
1150
+ connections: connectionCounts.get(destId),
1151
+ totalConnections: totalConnections.get(destId),
1152
+ namespaceColor: showNamespaceGroups && !destIsAddon ? getNamespaceColor(flow.destination.namespace) : undefined,
1153
+ isHotPath: hotNodes.has(destId),
1154
+ isAddonNode: destIsAddon,
1155
+ serviceCategory: flow.destination.kind.toLowerCase() === 'external' ? serviceCategories?.get(flow.destination.name) : undefined,
1156
+ ports: destPorts,
1157
+ nodeHeight: getNodeHeight(destPorts),
1158
+ connLabel,
1159
+ },
1160
+ })
1161
+ }
1162
+
1163
+ // Skip creating edge for SkipEdge flows (they're just for node creation)
1164
+ if (skipEdgeSource || skipEdgeDest) {
1165
+ return
1166
+ }
1167
+
1168
+ // Create edge with visual encoding (Phase 2.1, 2.2, 2.3)
1169
+ const edgeId = `${sourceId}->${destId}:${flow.port}`
1170
+ const isHotEdge = flow.connections >= hotPathThreshold && hotPathThreshold > 0
1171
+ const hasErrors = (flow.errorCount ?? 0) > 0
1172
+
1173
+ // Phase 2.3: Hot path styling (orange for hot, red for errors, blue for http/grpc, cyan for dns, gray for others)
1174
+ const hasDrops = (flow.verdictCounts?.dropped ?? 0) > 0
1175
+ const strokeColor = hasErrors
1176
+ ? '#ef4444' // red-500 for error flows
1177
+ : isHotEdge
1178
+ ? '#f97316' // orange-500
1179
+ : flow.l7Protocol === 'HTTP' || flow.l7Protocol === 'gRPC'
1180
+ ? '#3b82f6' // blue-500
1181
+ : flow.l7Protocol === 'DNS'
1182
+ ? '#06b6d4' // cyan-500
1183
+ : '#6b7280' // gray-500
1184
+
1185
+ // Phase 2.1: Edge width based on connection count
1186
+ const strokeWidth = getEdgeWidth(flow.connections)
1187
+
1188
+ // Phase 2.2: Edge label - connection count with unit suffix + L7 details
1189
+ const connStr = isIstio
1190
+ ? `${formatConnections(flow.connections)}/s`
1191
+ : formatConnections(flow.connections)
1192
+ const l7Label = flow.l7Protocol ? `${flow.l7Protocol} · ` : ''
1193
+ const latencyLabel = flow.latencyP50Ms ? ` · ${formatLatency(flow.latencyP50Ms)}` : ''
1194
+ const errorLabel = hasErrors
1195
+ ? ` · ${formatConnections(flow.errorCount ?? 0)} err`
1196
+ : ''
1197
+ const edgeLabel = `${l7Label}${connStr}${latencyLabel}${errorLabel}`
1198
+
1199
+ edgeList.push({
1200
+ id: edgeId,
1201
+ source: sourceId,
1202
+ target: destId,
1203
+ type: 'smoothstep',
1204
+ animated: isHotEdge, // Animate hot paths
1205
+ label: edgeLabel,
1206
+ labelBgStyle: {
1207
+ fill: hasErrors ? '#7f1d1d' : isHotEdge ? '#7c2d12' : '#1f2937', // red-900, orange-900, gray-800
1208
+ fillOpacity: 0.9,
1209
+ },
1210
+ labelStyle: {
1211
+ fontSize: 10,
1212
+ fill: hasErrors ? '#fecaca' : isHotEdge ? '#fed7aa' : '#d1d5db', // red-200, orange-200, gray-300
1213
+ fontWeight: (isHotEdge || hasErrors) ? 600 : 400,
1214
+ },
1215
+ style: {
1216
+ strokeWidth,
1217
+ stroke: strokeColor,
1218
+ ...(hasDrops && { strokeDasharray: '6 3' }),
1219
+ },
1220
+ markerEnd: {
1221
+ type: MarkerType.ArrowClosed,
1222
+ width: 16,
1223
+ height: 16,
1224
+ color: strokeColor,
1225
+ },
1226
+ })
1227
+ })
1228
+
1229
+ return {
1230
+ rawNodes: Array.from(nodeMap.values()),
1231
+ rawEdges: edgeList,
1232
+ addonGroupEdge, // Pass this for adding after group is created
1233
+ addonGroupOutEdge, // Pass this for adding group → kubernetes edge
1234
+ }
1235
+ }, [flows, hotPathThreshold, showNamespaceGroups, serviceCategories, addonMode, isIstio, connLabel])
1236
+
1237
+ // Apply ELK layout
1238
+ const applyLayout = useCallback(async () => {
1239
+ if (rawNodes.length === 0) {
1240
+ setLayoutedNodes([])
1241
+ setLayoutedEdges([])
1242
+ return
1243
+ }
1244
+
1245
+ const elkGraph = {
1246
+ id: 'root',
1247
+ layoutOptions: elkOptions,
1248
+ children: rawNodes.map(node => ({
1249
+ id: node.id,
1250
+ width: NODE_WIDTH,
1251
+ height: node.data.nodeHeight || NODE_BASE_HEIGHT,
1252
+ })),
1253
+ edges: rawEdges.map(edge => ({
1254
+ id: edge.id,
1255
+ sources: [edge.source],
1256
+ targets: [edge.target],
1257
+ })),
1258
+ }
1259
+
1260
+ // Store the addon group edge info for later
1261
+ const groupEdgeInfo = addonGroupEdge
1262
+ const groupOutEdgeInfo = addonGroupOutEdge
1263
+
1264
+ try {
1265
+ const layoutResult = await elk.layout(elkGraph)
1266
+
1267
+ // Apply positions from ELK to nodes
1268
+ let positionedNodes = rawNodes.map(node => {
1269
+ const elkNode = layoutResult.children?.find(n => n.id === node.id)
1270
+ return {
1271
+ ...node,
1272
+ position: {
1273
+ x: elkNode?.x || 0,
1274
+ y: elkNode?.y || 0,
1275
+ },
1276
+ }
1277
+ })
1278
+
1279
+ // If grouping addons, create a parent group node and set parentId on children
1280
+ let finalNodes: Node[] = positionedNodes
1281
+ if (addonMode === 'group') {
1282
+ const addonNodeIds = new Set(positionedNodes.filter(n => n.data.isAddonNode).map(n => n.id))
1283
+
1284
+ if (addonNodeIds.size > 0) {
1285
+ // Calculate bounding box of addon nodes
1286
+ const padding = 24
1287
+ const labelHeight = 28
1288
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
1289
+
1290
+ positionedNodes.forEach(node => {
1291
+ if (!addonNodeIds.has(node.id)) return
1292
+ const x = node.position.x
1293
+ const y = node.position.y
1294
+ const width = NODE_WIDTH
1295
+ const height = node.data.nodeHeight || NODE_BASE_HEIGHT
1296
+ minX = Math.min(minX, x)
1297
+ minY = Math.min(minY, y)
1298
+ maxX = Math.max(maxX, x + width)
1299
+ maxY = Math.max(maxY, y + height)
1300
+ })
1301
+
1302
+ const groupX = minX - padding
1303
+ const groupY = minY - padding - labelHeight
1304
+ const groupWidth = maxX - minX + padding * 2
1305
+ const groupHeight = maxY - minY + padding * 2 + labelHeight
1306
+
1307
+ // Create the group node
1308
+ const groupNode: Node = {
1309
+ id: 'addon-group',
1310
+ type: 'addonGroup',
1311
+ position: { x: groupX, y: groupY },
1312
+ data: {
1313
+ width: groupWidth,
1314
+ height: groupHeight,
1315
+ },
1316
+ style: { width: groupWidth, height: groupHeight },
1317
+ draggable: true,
1318
+ selectable: true,
1319
+ }
1320
+
1321
+ // Update addon nodes to be children of the group (positions relative to group)
1322
+ positionedNodes = positionedNodes.map(node => {
1323
+ if (addonNodeIds.has(node.id)) {
1324
+ return {
1325
+ ...node,
1326
+ parentId: 'addon-group',
1327
+ extent: 'parent' as const,
1328
+ position: {
1329
+ x: node.position.x - groupX,
1330
+ y: node.position.y - groupY,
1331
+ },
1332
+ }
1333
+ }
1334
+ return node
1335
+ })
1336
+
1337
+ finalNodes = [groupNode, ...positionedNodes]
1338
+ }
1339
+ }
1340
+
1341
+ // Build final edges list
1342
+ let finalEdges = [...rawEdges]
1343
+
1344
+ // Add edge from addon-internet to addon-group if we have one
1345
+ if (addonMode === 'group' && groupEdgeInfo) {
1346
+ const { connections } = groupEdgeInfo
1347
+ const sourceId = 'addon-internet'
1348
+ const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1349
+
1350
+ finalEdges.push({
1351
+ id: `${sourceId}->addon-group`,
1352
+ source: sourceId,
1353
+ target: 'addon-group',
1354
+ type: 'smoothstep',
1355
+ animated: isHotEdge,
1356
+ label: formatConnections(connections),
1357
+ labelBgStyle: {
1358
+ fill: '#581c87', // purple-900
1359
+ fillOpacity: 0.9,
1360
+ },
1361
+ labelStyle: {
1362
+ fontSize: 10,
1363
+ fill: '#e9d5ff', // purple-200
1364
+ fontWeight: 500,
1365
+ },
1366
+ style: {
1367
+ strokeWidth: getEdgeWidth(connections),
1368
+ stroke: '#a855f7', // purple-500
1369
+ },
1370
+ markerEnd: {
1371
+ type: MarkerType.ArrowClosed,
1372
+ width: 16,
1373
+ height: 16,
1374
+ color: '#a855f7',
1375
+ },
1376
+ })
1377
+ }
1378
+
1379
+ // Add edge from addon-group to kubernetes if we have one
1380
+ if (addonMode === 'group' && groupOutEdgeInfo) {
1381
+ const { connections, targetId } = groupOutEdgeInfo
1382
+ const isHotEdge = connections >= hotPathThreshold && hotPathThreshold > 0
1383
+
1384
+ finalEdges.push({
1385
+ id: `addon-group->${targetId}`,
1386
+ source: 'addon-group',
1387
+ target: targetId,
1388
+ type: 'smoothstep',
1389
+ animated: isHotEdge,
1390
+ label: formatConnections(connections),
1391
+ labelBgStyle: {
1392
+ fill: '#581c87', // purple-900
1393
+ fillOpacity: 0.9,
1394
+ },
1395
+ labelStyle: {
1396
+ fontSize: 10,
1397
+ fill: '#e9d5ff', // purple-200
1398
+ fontWeight: 500,
1399
+ },
1400
+ style: {
1401
+ strokeWidth: getEdgeWidth(connections),
1402
+ stroke: '#a855f7', // purple-500
1403
+ },
1404
+ markerEnd: {
1405
+ type: MarkerType.ArrowClosed,
1406
+ width: 16,
1407
+ height: 16,
1408
+ color: '#a855f7',
1409
+ },
1410
+ })
1411
+ }
1412
+
1413
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1414
+ setLayoutedNodes(finalNodes as any)
1415
+ setLayoutedEdges(finalEdges)
1416
+ } catch (error) {
1417
+ console.error('ELK layout error:', error)
1418
+ // Fallback to simple layout
1419
+ const positionedNodes = rawNodes.map((node, index) => ({
1420
+ ...node,
1421
+ position: {
1422
+ x: 100 + (index % 3) * 250,
1423
+ y: 50 + Math.floor(index / 3) * 100,
1424
+ },
1425
+ }))
1426
+ setLayoutedNodes(positionedNodes)
1427
+ setLayoutedEdges(rawEdges)
1428
+ }
1429
+ }, [rawNodes, rawEdges, addonMode, addonGroupEdge, addonGroupOutEdge, hotPathThreshold])
1430
+
1431
+ // Run layout when flows change
1432
+ useEffect(() => {
1433
+ applyLayout()
1434
+ }, [applyLayout])
1435
+
1436
+ const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes)
1437
+ const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges)
1438
+
1439
+ // Track if we need to fit view after layout
1440
+ const shouldFitViewRef = useRef(false)
1441
+ const prevFlowCountRef = useRef(flows.length)
1442
+
1443
+ // Update nodes and edges when layout changes
1444
+ useEffect(() => {
1445
+ // Check if flow count changed (filter/namespace change)
1446
+ if (flows.length !== prevFlowCountRef.current) {
1447
+ shouldFitViewRef.current = true
1448
+ prevFlowCountRef.current = flows.length
1449
+ }
1450
+ setNodes(layoutedNodes)
1451
+ setEdges(layoutedEdges)
1452
+ }, [layoutedNodes, layoutedEdges, setNodes, setEdges, flows.length])
1453
+
1454
+ // Click handlers
1455
+ const onNodeClick: NodeMouseHandler<Node<TrafficNodeData>> = useCallback((_event, node) => {
1456
+ setSelection({
1457
+ type: 'node',
1458
+ id: node.id,
1459
+ data: node.data,
1460
+ })
1461
+ onSelectionChange?.({ type: 'node', nodeId: node.id })
1462
+ }, [onSelectionChange])
1463
+
1464
+ const onEdgeClick: EdgeMouseHandler<Edge> = useCallback((_event, edge) => {
1465
+ const flow = flowByEdgeId.get(edge.id)
1466
+ setSelection({
1467
+ type: 'edge',
1468
+ id: edge.id,
1469
+ data: {
1470
+ source: edge.source,
1471
+ target: edge.target,
1472
+ port: flow?.port || 0,
1473
+ connections: flow?.connections || 0,
1474
+ protocol: flow?.protocol || 'tcp',
1475
+ flow,
1476
+ },
1477
+ })
1478
+ onSelectionChange?.({ type: 'edge', sourceId: edge.source, destId: edge.target, port: flow?.port })
1479
+ }, [flowByEdgeId, onSelectionChange])
1480
+
1481
+ const onPaneClick = useCallback(() => {
1482
+ setSelection(null)
1483
+ onSelectionChange?.(null)
1484
+ }, [onSelectionChange])
1485
+
1486
+ // FitView handler component - must be inside ReactFlow
1487
+ const FitViewOnChange = () => {
1488
+ const { fitView } = useReactFlow()
1489
+
1490
+ useEffect(() => {
1491
+ if (shouldFitViewRef.current && layoutedNodes.length > 0) {
1492
+ // Small delay to ensure nodes are rendered
1493
+ const timer = setTimeout(() => {
1494
+ fitView({ padding: 0.2, duration: 200 })
1495
+ shouldFitViewRef.current = false
1496
+ }, 50)
1497
+ return () => clearTimeout(timer)
1498
+ }
1499
+ }, [fitView, layoutedNodes])
1500
+
1501
+ return null
1502
+ }
1503
+
1504
+ return (
1505
+ <div className="w-full h-full relative">
1506
+ <ReactFlow
1507
+ nodes={nodes}
1508
+ edges={edges}
1509
+ onNodesChange={onNodesChange}
1510
+ onEdgesChange={onEdgesChange}
1511
+ onNodeClick={onNodeClick}
1512
+ onEdgeClick={onEdgeClick}
1513
+ onPaneClick={onPaneClick}
1514
+ nodeTypes={nodeTypes}
1515
+ defaultEdgeOptions={{
1516
+ type: 'smoothstep',
1517
+ style: { strokeWidth: 2, stroke: '#6b7280' },
1518
+ }}
1519
+ fitView
1520
+ fitViewOptions={{ padding: 0.2 }}
1521
+ proOptions={{ hideAttribution: true }}
1522
+ minZoom={0.1}
1523
+ maxZoom={2}
1524
+ edgesReconnectable={false}
1525
+ nodesConnectable={false}
1526
+ >
1527
+ <Background />
1528
+ <Controls />
1529
+ <FitViewOnChange />
1530
+ </ReactFlow>
1531
+
1532
+ {/* Legend */}
1533
+ <TrafficLegend />
1534
+
1535
+ {/* Details panel */}
1536
+ {selection && (
1537
+ <DetailsPanel
1538
+ selection={selection}
1539
+ onClose={() => setSelection(null)}
1540
+ flows={flows}
1541
+ isIstio={isIstio}
1542
+ />
1543
+ )}
1544
+ </div>
1545
+ )
1546
+ }