diff --git a/agent/network.go b/agent/network.go index 9b58489a6..f164e0eea 100644 --- a/agent/network.go +++ b/agent/network.go @@ -87,6 +87,9 @@ func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis) a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond) } + + // connection states per interface + a.updateConnectionStats(systemStats) } func (a *Agent) initializeNetIoStats() { @@ -257,3 +260,152 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool { return false } } + +// updateConnectionStats collects TCP/UDP connection state information per network interface +func (a *Agent) updateConnectionStats(systemStats *system.Stats) { + // Get all connections (TCP and UDP, IPv4 and IPv6) + connections, err := psutilNet.Connections("all") + if err != nil { + slog.Debug("Error getting network connections", "err", err) + return + } + + // Initialize the map if needed + if systemStats.NetConnections == nil { + systemStats.NetConnections = make(map[string]*system.NetConnectionStats) + } + + // Create stats for each valid network interface + for ifaceName := range a.netInterfaces { + stats := &system.NetConnectionStats{} + systemStats.NetConnections[ifaceName] = stats + } + + // Also track connections without specific interface (aggregate) + aggregateStats := &system.NetConnectionStats{} + + // Process each connection + for _, conn := range connections { + // Determine which interface this connection belongs to + // For simplicity, we'll use the local address to determine interface + ifaceName := a.getInterfaceForAddress(conn.Laddr.IP) + + // Update the appropriate stats + var targetStats *system.NetConnectionStats + if ifaceName != "" { + if stats, exists := systemStats.NetConnections[ifaceName]; exists { + targetStats = stats + } + } + + // Always update aggregate stats + a.incrementConnectionState(aggregateStats, conn) + + // Update per-interface stats if we found a matching interface + if targetStats != nil { + a.incrementConnectionState(targetStats, conn) + } + } + + // Store aggregate stats under special key + systemStats.NetConnections["_total"] = aggregateStats +} + +// getInterfaceForAddress determines which network interface an IP address belongs to +func (a *Agent) getInterfaceForAddress(ipAddr string) string { + if ipAddr == "" || ipAddr == "0.0.0.0" || ipAddr == "::" || ipAddr == "127.0.0.1" || ipAddr == "::1" { + return "" + } + + // Get all network interfaces + interfaces, err := psutilNet.Interfaces() + if err != nil { + return "" + } + + // Find matching interface by checking addresses + for _, iface := range interfaces { + for _, addr := range iface.Addrs { + if addr.Addr == ipAddr { + // Check if this interface is in our valid interfaces list + if _, exists := a.netInterfaces[iface.Name]; exists { + return iface.Name + } + } + } + } + + return "" +} + +// incrementConnectionState increments the appropriate counter based on connection type and state +func (a *Agent) incrementConnectionState(stats *system.NetConnectionStats, conn psutilNet.ConnectionStat) { + // Detect IPv6 by checking if the address contains colons + isIPv6 := strings.Contains(conn.Laddr.IP, ":") + + if conn.Type == 1 { // SOCK_STREAM = TCP (but might also be Unix sockets) + // Skip Unix domain sockets - they have "NONE" status and no IP address + if conn.Status == "NONE" || conn.Status == "" && conn.Laddr.IP == "" { + return + } + + stats.Total++ + + if isIPv6 { + stats.TCP6Total++ + switch conn.Status { + case "ESTABLISHED": + stats.TCP6Established++ + case "LISTEN": + stats.TCP6Listen++ + case "TIME_WAIT", "TIME-WAIT": + stats.TCP6TimeWait++ + case "CLOSE_WAIT", "CLOSE-WAIT": + stats.TCP6CloseWait++ + case "SYN_SENT", "SYN-SENT": + stats.TCP6SynSent++ + case "SYN_RECV", "SYN-RECV", "SYN_RCVD": + stats.TCP6SynRecv++ + case "FIN_WAIT1", "FIN-WAIT-1", "FIN_WAIT_1": + stats.TCP6FinWait1++ + case "FIN_WAIT2", "FIN-WAIT-2", "FIN_WAIT_2": + stats.TCP6FinWait2++ + case "CLOSING": + stats.TCP6Closing++ + case "LAST_ACK", "LAST-ACK": + stats.TCP6LastAck++ + } + } else { + stats.TCPTotal++ + switch conn.Status { + case "ESTABLISHED": + stats.TCPEstablished++ + case "LISTEN": + stats.TCPListen++ + case "TIME_WAIT", "TIME-WAIT": + stats.TCPTimeWait++ + case "CLOSE_WAIT", "CLOSE-WAIT": + stats.TCPCloseWait++ + case "SYN_SENT", "SYN-SENT": + stats.TCPSynSent++ + case "SYN_RECV", "SYN-RECV", "SYN_RCVD": + stats.TCPSynRecv++ + case "FIN_WAIT1", "FIN-WAIT-1", "FIN_WAIT_1": + stats.TCPFinWait1++ + case "FIN_WAIT2", "FIN-WAIT-2", "FIN_WAIT_2": + stats.TCPFinWait2++ + case "CLOSING": + stats.TCPClosing++ + case "LAST_ACK", "LAST-ACK": + stats.TCPLastAck++ + } + } + } else if conn.Type == 2 { // SOCK_DGRAM = UDP + stats.Total++ + if isIPv6 { + stats.UDP6Count++ + } else { + stats.UDPCount++ + } + } +} diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 0649666fc..232e12a0a 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -40,14 +40,15 @@ type Stats struct { Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"27,keyasint,omitzero"` // [sent bytes, recv bytes] // TODO: remove other load fields in future release in favor of load avg array - LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` - Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] - MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` - NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] - DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] - MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] - CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] - CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] + LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` + Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] + MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"` + NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] + DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] + MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] + CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] + CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] + NetConnections map[string]*NetConnectionStats `json:"nc,omitempty" cbor:"35,keyasint,omitempty"` // per-interface connection states } // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. @@ -106,6 +107,36 @@ type NetIoStats struct { Name string } +// NetConnectionStats tracks TCP/UDP connection states per interface +type NetConnectionStats struct { + TCPEstablished uint32 `json:"te" cbor:"0,keyasint"` // Active TCP connections + TCPListen uint32 `json:"tl" cbor:"1,keyasint"` // TCP listening sockets + TCPTimeWait uint32 `json:"tw" cbor:"2,keyasint"` // TCP TIME_WAIT state + TCPCloseWait uint32 `json:"tcw" cbor:"3,keyasint"` // TCP CLOSE_WAIT state + TCPSynSent uint32 `json:"ts" cbor:"4,keyasint"` // TCP SYN_SENT state + TCPSynRecv uint32 `json:"tsr" cbor:"5,keyasint"` // TCP SYN_RECV state + TCPFinWait1 uint32 `json:"tf1" cbor:"6,keyasint"` // TCP FIN_WAIT1 state + TCPFinWait2 uint32 `json:"tf2" cbor:"7,keyasint"` // TCP FIN_WAIT2 state + TCPClosing uint32 `json:"tcl" cbor:"8,keyasint"` // TCP CLOSING state + TCPLastAck uint32 `json:"tla" cbor:"9,keyasint"` // TCP LAST_ACK state + UDPCount uint32 `json:"u" cbor:"10,keyasint"` // UDP socket count + TCPTotal uint32 `json:"tt" cbor:"11,keyasint"` // Total TCP connections + Total uint32 `json:"t" cbor:"12,keyasint"` // Total connections (TCP + UDP) + // IPv6-specific stats + TCP6Established uint32 `json:"te6" cbor:"13,keyasint"` // Active TCP6 connections + TCP6Listen uint32 `json:"tl6" cbor:"14,keyasint"` // TCP6 listening sockets + TCP6TimeWait uint32 `json:"tw6" cbor:"15,keyasint"` // TCP6 TIME_WAIT state + TCP6CloseWait uint32 `json:"tcw6" cbor:"16,keyasint"` // TCP6 CLOSE_WAIT state + TCP6SynSent uint32 `json:"ts6" cbor:"17,keyasint"` // TCP6 SYN_SENT state + TCP6SynRecv uint32 `json:"tsr6" cbor:"18,keyasint"` // TCP6 SYN_RECV state + TCP6FinWait1 uint32 `json:"tf16" cbor:"19,keyasint"` // TCP6 FIN_WAIT1 state + TCP6FinWait2 uint32 `json:"tf26" cbor:"20,keyasint"` // TCP6 FIN_WAIT2 state + TCP6Closing uint32 `json:"tcl6" cbor:"21,keyasint"` // TCP6 CLOSING state + TCP6LastAck uint32 `json:"tla6" cbor:"22,keyasint"` // TCP6 LAST_ACK state + UDP6Count uint32 `json:"u6" cbor:"23,keyasint"` // UDP6 socket count + TCP6Total uint32 `json:"tt6" cbor:"24,keyasint"` // Total TCP6 connections +} + type Os = uint8 const ( diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts index c619ffb89..198dbcaad 100644 --- a/internal/site/src/components/charts/hooks.ts +++ b/internal/site/src/components/charts/hooks.ts @@ -122,4 +122,166 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) { })) }, } +} + +// Connection stats data points for charting - main view (TCP & UDP totals) +export function useConnectionStatsMain() { + return { + tcp: { + label: "TCP", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tt ?? 0, + color: "hsl(220, 70%, 50%)", + opacity: 0.3, + }, + tcp6: { + label: "TCP6", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tt6 ?? 0, + color: "hsl(250, 70%, 50%)", + opacity: 0.3, + }, + udp: { + label: "UDP", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.u ?? 0, + color: "hsl(180, 70%, 50%)", + opacity: 0.3, + }, + udp6: { + label: "UDP6", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.u6 ?? 0, + color: "hsl(160, 70%, 50%)", + opacity: 0.3, + }, + } +} + +// Connection stats data points for detailed sheet (all TCP states) +export function useConnectionStatsDetailed() { + return { + established: { + label: "Established", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.te ?? 0, + color: "hsl(142, 70%, 50%)", + opacity: 0.3, + }, + listening: { + label: "Listening", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tl ?? 0, + color: "hsl(280, 70%, 50%)", + opacity: 0.3, + }, + timeWait: { + label: "Time Wait", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tw ?? 0, + color: "hsl(30, 70%, 50%)", + opacity: 0.3, + }, + closeWait: { + label: "Close Wait", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tcw ?? 0, + color: "hsl(0, 70%, 50%)", + opacity: 0.3, + }, + finWait1: { + label: "FIN Wait 1", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tf1 ?? 0, + color: "hsl(320, 70%, 50%)", + opacity: 0.3, + }, + finWait2: { + label: "FIN Wait 2", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tf2 ?? 0, + color: "hsl(260, 70%, 50%)", + opacity: 0.3, + }, + synSent: { + label: "SYN Sent", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.ts ?? 0, + color: "hsl(200, 70%, 50%)", + opacity: 0.3, + }, + synRecv: { + label: "SYN Recv", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tsr ?? 0, + color: "hsl(160, 70%, 50%)", + opacity: 0.3, + }, + closing: { + label: "Closing", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tcl ?? 0, + color: "hsl(340, 70%, 50%)", + opacity: 0.3, + }, + lastAck: { + label: "Last ACK", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tla ?? 0, + color: "hsl(380, 70%, 50%)", + opacity: 0.3, + }, + } +} + +// IPv6 Connection stats data points for detailed sheet (all TCP6 states) +export function useConnectionStatsIPv6() { + return { + established: { + label: "Established", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.te6 ?? 0, + color: "hsl(142, 70%, 50%)", + opacity: 0.3, + }, + listening: { + label: "Listening", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tl6 ?? 0, + color: "hsl(280, 70%, 50%)", + opacity: 0.3, + }, + timeWait: { + label: "Time Wait", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tw6 ?? 0, + color: "hsl(30, 70%, 50%)", + opacity: 0.3, + }, + closeWait: { + label: "Close Wait", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tcw6 ?? 0, + color: "hsl(0, 70%, 50%)", + opacity: 0.3, + }, + finWait1: { + label: "FIN Wait 1", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tf16 ?? 0, + color: "hsl(320, 70%, 50%)", + opacity: 0.3, + }, + finWait2: { + label: "FIN Wait 2", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tf26 ?? 0, + color: "hsl(260, 70%, 50%)", + opacity: 0.3, + }, + synSent: { + label: "SYN Sent", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.ts6 ?? 0, + color: "hsl(200, 70%, 50%)", + opacity: 0.3, + }, + synRecv: { + label: "SYN Recv", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tsr6 ?? 0, + color: "hsl(160, 70%, 50%)", + opacity: 0.3, + }, + closing: { + label: "Closing", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tcl6 ?? 0, + color: "hsl(340, 70%, 50%)", + opacity: 0.3, + }, + lastAck: { + label: "Last ACK", + dataKey: ({ stats }: SystemStatsRecord) => stats?.nc?.["_total"]?.tla6 ?? 0, + color: "hsl(380, 70%, 50%)", + opacity: 0.3, + }, + } } \ No newline at end of file diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 8feec0200..5b51539db 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -18,7 +18,7 @@ import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart import ContainerChart from "@/components/charts/container-chart" import DiskChart from "@/components/charts/disk-chart" import GpuPowerChart from "@/components/charts/gpu-power-chart" -import { useContainerChartConfigs } from "@/components/charts/hooks" +import { useContainerChartConfigs, useConnectionStatsMain } from "@/components/charts/hooks" import LoadAverageChart from "@/components/charts/load-average-chart" import MemChart from "@/components/charts/mem-chart" import SwapChart from "@/components/charts/swap-chart" @@ -73,6 +73,7 @@ import { Separator } from "../ui/separator" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import NetworkSheet from "./system/network-sheet" import CpuCoresSheet from "./system/cpu-sheet" +import ConnectionSheet from "./system/connection-sheet" import LineChartDefault from "../charts/line-chart" import { pinnedAxisDomain } from "../ui/chart" @@ -457,6 +458,8 @@ export default memo(function SystemDetail({ id }: { id: string }) { const hasGpuData = lastGpuVals.length > 0 const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined) const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined) + const connectionStats = useConnectionStatsMain() + const hasConnectionData = systemStats.at(-1)?.stats?.nc !== undefined let translatedStatus: string = system.status if (system.status === SystemStatus.Up) { @@ -798,6 +801,26 @@ export default memo(function SystemDetail({ id }: { id: string }) { )} + {/* TCP & UDP Connections chart */} + {hasConnectionData && ( + } + > + val.toLocaleString()} + contentFormatter={({ value }) => value.toLocaleString()} + /> + + )} + {/* Temperature chart */} {systemStats.at(-1)?.stats.t && (
0 + + if (sheetOpen && !hasOpened.current) { + hasOpened.current = true + } + + // Don't show button if no connection data is available + if (!latestStats) { + return null + } + + return ( + + {t`TCP connection states over time`} + + + + {hasOpened.current && ( + + + + val.toLocaleString()} + contentFormatter={({ value }) => value.toLocaleString()} + /> + + + {hasIPv6Data && ( + + val.toLocaleString()} + contentFormatter={({ value }) => value.toLocaleString()} + /> + + )} + + )} + + ) +}) diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 682e69609..b0e623cce 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -159,6 +159,61 @@ export interface SystemStats { bat?: [number, BatteryState] /** network interfaces [upload bytes, download bytes, total upload bytes, total download bytes] */ ni?: Record + /** network connections per interface */ + nc?: Record +} + +export interface NetConnectionStats { + /** TCP established connections */ + te: number + /** TCP listening sockets */ + tl: number + /** TCP TIME_WAIT state */ + tw: number + /** TCP CLOSE_WAIT state */ + tcw: number + /** TCP SYN_SENT state */ + ts: number + /** TCP SYN_RECV state */ + tsr: number + /** TCP FIN_WAIT1 state */ + tf1: number + /** TCP FIN_WAIT2 state */ + tf2: number + /** TCP CLOSING state */ + tcl: number + /** TCP LAST_ACK state */ + tla: number + /** UDP socket count */ + u: number + /** Total TCP connections */ + tt: number + /** Total connections (TCP + UDP) */ + t: number + /** TCP6 established connections */ + te6: number + /** TCP6 listening sockets */ + tl6: number + /** TCP6 TIME_WAIT state */ + tw6: number + /** TCP6 CLOSE_WAIT state */ + tcw6: number + /** TCP6 SYN_SENT state */ + ts6: number + /** TCP6 SYN_RECV state */ + tsr6: number + /** TCP6 FIN_WAIT1 state */ + tf16: number + /** TCP6 FIN_WAIT2 state */ + tf26: number + /** TCP6 CLOSING state */ + tcl6: number + /** TCP6 LAST_ACK state */ + tla6: number + /** UDP6 socket count */ + u6: number + /** Total TCP6 connections */ + tt6: number } export interface GPUData {