Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions agent/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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++
}
}
}
47 changes: 39 additions & 8 deletions internal/entities/system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
Expand Down
162 changes: 162 additions & 0 deletions internal/site/src/components/charts/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
Loading