@skyhook-io/radar-app 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/package.json +80 -0
- package/src/App.tsx +1538 -0
- package/src/RadarApp.tsx +145 -0
- package/src/api/apiResources.ts +28 -0
- package/src/api/client.ts +2583 -0
- package/src/api/config.ts +116 -0
- package/src/api/traffic.ts +139 -0
- package/src/components/ConnectionErrorView.tsx +272 -0
- package/src/components/ContextSwitcher.tsx +481 -0
- package/src/components/DebugOverlay.tsx +94 -0
- package/src/components/UserMenu.tsx +87 -0
- package/src/components/audit/AuditSettingsDialog.tsx +162 -0
- package/src/components/audit/AuditView.tsx +123 -0
- package/src/components/cost/CostTrendChart.tsx +388 -0
- package/src/components/cost/CostView.tsx +545 -0
- package/src/components/dock/BottomDock.tsx +96 -0
- package/src/components/dock/DockContext.tsx +11 -0
- package/src/components/dock/LocalTerminalTab.tsx +22 -0
- package/src/components/dock/LogsTab.tsx +26 -0
- package/src/components/dock/NodeTerminalTab.tsx +50 -0
- package/src/components/dock/TerminalTab.tsx +42 -0
- package/src/components/dock/TrafficFlowListTab.tsx +18 -0
- package/src/components/dock/WorkloadLogsTab.tsx +23 -0
- package/src/components/dock/index.ts +2 -0
- package/src/components/gitops/GitOpsActions.tsx +1 -0
- package/src/components/gitops/GitOpsStatusBadge.tsx +1 -0
- package/src/components/gitops/ManagedResourcesList.tsx +1 -0
- package/src/components/gitops/SyncCountdown.tsx +1 -0
- package/src/components/gitops/index.ts +4 -0
- package/src/components/helm/ChartBrowser.tsx +580 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +774 -0
- package/src/components/helm/HelmView.tsx +475 -0
- package/src/components/helm/InstallWizard.tsx +1060 -0
- package/src/components/helm/ManifestDiffViewer.tsx +91 -0
- package/src/components/helm/ManifestViewer.tsx +61 -0
- package/src/components/helm/OwnedResources.tsx +465 -0
- package/src/components/helm/RevisionHistory.tsx +167 -0
- package/src/components/helm/ValuesDiffPreview.tsx +190 -0
- package/src/components/helm/ValuesViewer.tsx +365 -0
- package/src/components/helm/helm-utils.ts +37 -0
- package/src/components/home/ActivitySummary.tsx +262 -0
- package/src/components/home/CertificateHealthCard.tsx +105 -0
- package/src/components/home/ClusterHealthCard.tsx +483 -0
- package/src/components/home/CostCard.tsx +112 -0
- package/src/components/home/HealthRing.tsx +1 -0
- package/src/components/home/HelmSummary.tsx +129 -0
- package/src/components/home/HomeView.tsx +224 -0
- package/src/components/home/MCPSetupDialog.tsx +417 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +109 -0
- package/src/components/home/TopologyPreview.tsx +219 -0
- package/src/components/home/TrafficSummary.tsx +154 -0
- package/src/components/logs/JsonLogLine.tsx +1 -0
- package/src/components/logs/LogCore.tsx +2 -0
- package/src/components/logs/LogsViewer.tsx +44 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +40 -0
- package/src/components/logs/useLogBuffer.ts +2 -0
- package/src/components/logs/useLogSearch.ts +1 -0
- package/src/components/portforward/PortForwardButton.tsx +375 -0
- package/src/components/portforward/PortForwardManager.tsx +871 -0
- package/src/components/resource/PrometheusCharts.tsx +687 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +214 -0
- package/src/components/resources/ImageFilesystemModal.tsx +745 -0
- package/src/components/resources/PodFilesystemModal.tsx +407 -0
- package/src/components/resources/ResourceDetailDrawer.tsx +43 -0
- package/src/components/resources/ResourcesView.tsx +190 -0
- package/src/components/resources/drawer-components.tsx +1 -0
- package/src/components/resources/file-browser-utils.ts +35 -0
- package/src/components/resources/renderers/AlertRenderer.tsx +1 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +17 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRenderer.tsx +1 -0
- package/src/components/resources/renderers/CertificateRequestRenderer.tsx +1 -0
- package/src/components/resources/renderers/ChallengeRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +1 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/EventRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/GatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +1 -0
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +1 -0
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +1 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/JobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +1 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +1 -0
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeServiceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +1 -0
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/LeaseRenderer.tsx +1 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +44 -0
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +1 -0
- package/src/components/resources/renderers/OrderRenderer.tsx +1 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +1 -0
- package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/PodRenderer.tsx +94 -0
- package/src/components/resources/renderers/PriorityClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +1 -0
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +1 -0
- package/src/components/resources/renderers/RoleRenderer.tsx +1 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +1 -0
- package/src/components/resources/renderers/RuntimeClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/SbomReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretRenderer.tsx +1 -0
- package/src/components/resources/renderers/SecretStoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +1 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +26 -0
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/StorageClassRenderer.tsx +1 -0
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +1 -0
- package/src/components/resources/renderers/VPARenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +1 -0
- package/src/components/resources/renderers/VeleroVSLRenderer.tsx +1 -0
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +1 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +1 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +52 -0
- package/src/components/resources/renderers/argo-cells.tsx +1 -0
- package/src/components/resources/renderers/certmanager-cells.tsx +1 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +1 -0
- package/src/components/resources/renderers/eso-cells.tsx +1 -0
- package/src/components/resources/renderers/flux-cells.tsx +1 -0
- package/src/components/resources/renderers/index.ts +91 -0
- package/src/components/resources/renderers/istio-cells.tsx +1 -0
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -0
- package/src/components/resources/renderers/keda-cells.tsx +1 -0
- package/src/components/resources/renderers/knative-cells.tsx +1 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +1 -0
- package/src/components/resources/renderers/prometheus-cells.tsx +1 -0
- package/src/components/resources/renderers/traefik-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-cells.tsx +1 -0
- package/src/components/resources/renderers/trivy-shared.tsx +1 -0
- package/src/components/resources/renderers/velero-cells.tsx +1 -0
- package/src/components/resources/resource-utils-argo.ts +2 -0
- package/src/components/resources/resource-utils-certmanager.ts +2 -0
- package/src/components/resources/resource-utils-cnpg.ts +2 -0
- package/src/components/resources/resource-utils-eso.ts +2 -0
- package/src/components/resources/resource-utils-flux.ts +2 -0
- package/src/components/resources/resource-utils-istio.ts +2 -0
- package/src/components/resources/resource-utils-karpenter.ts +2 -0
- package/src/components/resources/resource-utils-keda.ts +2 -0
- package/src/components/resources/resource-utils-knative.ts +2 -0
- package/src/components/resources/resource-utils-kyverno.ts +2 -0
- package/src/components/resources/resource-utils-prometheus.ts +2 -0
- package/src/components/resources/resource-utils-traefik.ts +1 -0
- package/src/components/resources/resource-utils-trivy.ts +2 -0
- package/src/components/resources/resource-utils-velero.ts +2 -0
- package/src/components/resources/resource-utils.ts +5 -0
- package/src/components/settings/SettingsDialog.tsx +537 -0
- package/src/components/shared/CreateResourceDialog.tsx +17 -0
- package/src/components/shared/EditableYamlView.tsx +24 -0
- package/src/components/shared/LargeClusterNamespacePicker.tsx +70 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +31 -0
- package/src/components/timeline/DiffViewer.tsx +1 -0
- package/src/components/timeline/TimelineList.tsx +69 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1308 -0
- package/src/components/timeline/TimelineView.tsx +157 -0
- package/src/components/timeline/shared.tsx +1 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +571 -0
- package/src/components/traffic/TrafficFlowList.tsx +415 -0
- package/src/components/traffic/TrafficFlowListContext.tsx +68 -0
- package/src/components/traffic/TrafficGraph.tsx +1546 -0
- package/src/components/traffic/TrafficView.tsx +1213 -0
- package/src/components/traffic/TrafficWizard.tsx +386 -0
- package/src/components/traffic/index.ts +3 -0
- package/src/components/ui/CodeViewer.tsx +8 -0
- package/src/components/ui/CommandPalette.tsx +460 -0
- package/src/components/ui/ConfirmDialog.tsx +1 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +619 -0
- package/src/components/ui/ErrorBoundary.tsx +46 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +1 -0
- package/src/components/ui/Markdown.tsx +108 -0
- package/src/components/ui/MetricsChart.tsx +1 -0
- package/src/components/ui/NamespaceSelector.tsx +436 -0
- package/src/components/ui/ResourceBar.tsx +1 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +301 -0
- package/src/components/ui/Toast.tsx +1 -0
- package/src/components/ui/Tooltip.tsx +1 -0
- package/src/components/ui/UpdateNotification.tsx +299 -0
- package/src/components/ui/YamlEditor.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +532 -0
- package/src/context/ConnectionContext.tsx +173 -0
- package/src/context/ContextSwitchContext.tsx +56 -0
- package/src/context/NavCustomization.tsx +62 -0
- package/src/context/ThemeContext.tsx +97 -0
- package/src/contexts/CapabilitiesContext.tsx +130 -0
- package/src/hooks/useAnimatedUnmount.ts +1 -0
- package/src/hooks/useDesktopDownload.ts +41 -0
- package/src/hooks/useEventSource.ts +262 -0
- package/src/hooks/useFavorites.ts +69 -0
- package/src/hooks/useKeyboardShortcuts.tsx +7 -0
- package/src/hooks/useRefreshAnimation.ts +1 -0
- package/src/index.css +243 -0
- package/src/index.ts +17 -0
- package/src/main.tsx +158 -0
- package/src/types/gitops.ts +2 -0
- package/src/types.ts +3 -0
- package/src/utils/animation.ts +2 -0
- package/src/utils/badge-colors.ts +2 -0
- package/src/utils/context-name.ts +2 -0
- package/src/utils/desktop-download.ts +66 -0
- package/src/utils/desktop-open-folder.ts +21 -0
- package/src/utils/format.ts +2 -0
- package/src/utils/log-format.ts +12 -0
- package/src/utils/navigation.ts +23 -0
- package/src/utils/resource-hierarchy.ts +2 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.ts +2 -0
- package/src/utils/traffic-colors.ts +54 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -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
|
+
}
|