Pulse/internal/hostmetrics/collector.go
rcourtman cc3c0187a0 feat: AI features, agent improvements, and host monitoring enhancements
AI Chat Integration:
- Multi-provider support (Anthropic, OpenAI, Ollama)
- Streaming responses with markdown rendering
- Agent command execution for remote troubleshooting
- Context-aware conversations with host/container metadata

Agent Updates:
- Add --enable-proxmox flag for automatic PVE/PBS token setup
- Improve auto-update with semver comparison (prevents downgrades)
- Add updatedFrom tracking to report previous version after update
- Reduce initial update check delay from 30s to 5s
- Add agent version column to Hosts page table

Host Metrics:
- Add DiskIO stats collection (read/write bytes, ops, time)
- Improve disk filtering to exclude Docker overlay mounts
- Add RAID array monitoring via mdadm
- Enhanced temperature sensor parsing

Frontend:
- New Agent Version column on Hosts overview table
- Improved node modal with agent-first installation flow
- Add DiskIO display in host drawer
- Better responsive handling for metric bars
2025-12-05 10:37:02 +00:00

314 lines
8.3 KiB
Go

package hostmetrics
import (
"context"
"fmt"
"sort"
"strings"
"time"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters"
gocpu "github.com/shirou/gopsutil/v4/cpu"
godisk "github.com/shirou/gopsutil/v4/disk"
goload "github.com/shirou/gopsutil/v4/load"
gomem "github.com/shirou/gopsutil/v4/mem"
gonet "github.com/shirou/gopsutil/v4/net"
)
// System call wrappers for testing
var (
cpuCounts = gocpu.CountsWithContext
cpuPercent = gocpu.PercentWithContext
loadAvg = goload.AvgWithContext
virtualMemory = gomem.VirtualMemoryWithContext
diskPartitions = godisk.PartitionsWithContext
diskUsage = godisk.UsageWithContext
diskIOCounters = godisk.IOCountersWithContext
netInterfaces = gonet.InterfacesWithContext
netIOCounters = gonet.IOCountersWithContext
)
// Snapshot represents a host resource utilisation sample.
type Snapshot struct {
CPUUsagePercent float64
CPUCount int
LoadAverage []float64
Memory agentshost.MemoryMetric
Disks []agentshost.Disk
DiskIO []agentshost.DiskIO
Network []agentshost.NetworkInterface
}
// Collect gathers a point-in-time snapshot of host resource utilisation.
func Collect(ctx context.Context) (Snapshot, error) {
collectCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var snapshot Snapshot
if cpuCount, err := cpuCounts(collectCtx, true); err == nil {
snapshot.CPUCount = cpuCount
}
if cpuUsage, err := collectCPUUsage(collectCtx); err == nil {
snapshot.CPUUsagePercent = cpuUsage
}
if loadAvg, err := loadAvg(collectCtx); err == nil && loadAvg != nil {
snapshot.LoadAverage = []float64{loadAvg.Load1, loadAvg.Load5, loadAvg.Load15}
}
memStats, err := virtualMemory(collectCtx)
if err != nil {
return Snapshot{}, fmt.Errorf("memory stats: %w", err)
}
swapUsed := int64(0)
if memStats.SwapTotal > memStats.SwapFree {
swapUsed = int64(memStats.SwapTotal - memStats.SwapFree)
}
snapshot.Memory = agentshost.MemoryMetric{
TotalBytes: int64(memStats.Total),
UsedBytes: int64(memStats.Used),
FreeBytes: int64(memStats.Free),
Usage: memStats.UsedPercent,
SwapTotal: int64(memStats.SwapTotal),
SwapUsed: swapUsed,
}
snapshot.Disks = collectDisks(collectCtx)
snapshot.DiskIO = collectDiskIO(collectCtx)
snapshot.Network = collectNetwork(collectCtx)
return snapshot, nil
}
func collectCPUUsage(ctx context.Context) (float64, error) {
percentages, err := cpuPercent(ctx, time.Second, false)
if err != nil {
return 0, err
}
if len(percentages) == 0 {
return 0, nil
}
usage := percentages[0]
if usage < 0 {
usage = 0
}
if usage > 100 {
usage = 100
}
return usage, nil
}
func collectDisks(ctx context.Context) []agentshost.Disk {
partitions, err := diskPartitions(ctx, true)
if err != nil {
return nil
}
disks := make([]agentshost.Disk, 0, len(partitions))
seen := make(map[string]struct{}, len(partitions))
zfsDatasets := make([]zfsDatasetUsage, 0)
for _, part := range partitions {
if part.Mountpoint == "" {
continue
}
if _, ok := seen[part.Mountpoint]; ok {
continue
}
seen[part.Mountpoint] = struct{}{}
usage, err := diskUsage(ctx, part.Mountpoint)
if err != nil {
continue
}
if usage.Total == 0 {
continue
}
if strings.EqualFold(part.Fstype, "zfs") || strings.EqualFold(part.Fstype, "fuse.zfs") {
pool := zfsPoolFromDevice(part.Device)
if pool == "" {
continue
}
if fsfilters.ShouldIgnoreReadOnlyFilesystem(part.Fstype, usage.Total, usage.Used) {
continue
}
zfsDatasets = append(zfsDatasets, zfsDatasetUsage{
Pool: pool,
Dataset: part.Device,
Mountpoint: part.Mountpoint,
Total: usage.Total,
Used: usage.Used,
Free: usage.Free,
})
continue
}
// Skip filesystems that shouldn't be counted toward disk usage:
// - Read-only filesystems (squashfs, erofs, iso9660) - always report near-full
// - Virtual/pseudo filesystems (tmpfs, devtmpfs, cgroup, etc.)
// - Container overlay paths (Docker/Podman layers on ZFS, including TrueNAS .ix-apps)
// See issues #505, #690, #718, #790.
if shouldSkip, _ := fsfilters.ShouldSkipFilesystem(part.Fstype, part.Mountpoint, usage.Total, usage.Used); shouldSkip {
continue
}
disks = append(disks, agentshost.Disk{
Device: part.Device,
Mountpoint: part.Mountpoint,
Filesystem: part.Fstype,
Type: part.Fstype,
TotalBytes: int64(usage.Total),
UsedBytes: int64(usage.Used),
FreeBytes: int64(usage.Free),
Usage: usage.UsedPercent,
})
}
disks = append(disks, summarizeZFSPools(ctx, zfsDatasets)...)
sort.Slice(disks, func(i, j int) bool { return disks[i].Mountpoint < disks[j].Mountpoint })
return disks
}
func collectNetwork(ctx context.Context) []agentshost.NetworkInterface {
ifaces, err := netInterfaces(ctx)
if err != nil {
return nil
}
ioCounters, err := netIOCounters(ctx, true)
if err != nil {
ioCounters = nil
}
ioMap := make(map[string]gonet.IOCountersStat, len(ioCounters))
for _, stat := range ioCounters {
ioMap[stat.Name] = stat
}
interfaces := make([]agentshost.NetworkInterface, 0, len(ifaces))
for _, iface := range ifaces {
if len(iface.Addrs) == 0 {
continue
}
if isLoopback(iface.Flags) {
continue
}
addresses := make([]string, 0, len(iface.Addrs))
for _, addr := range iface.Addrs {
if addr.Addr != "" {
addresses = append(addresses, addr.Addr)
}
}
if len(addresses) == 0 {
continue
}
counter := ioMap[iface.Name]
ifaceEntry := agentshost.NetworkInterface{
Name: iface.Name,
MAC: iface.HardwareAddr,
Addresses: addresses,
RXBytes: counter.BytesRecv,
TXBytes: counter.BytesSent,
}
interfaces = append(interfaces, ifaceEntry)
}
sort.Slice(interfaces, func(i, j int) bool { return interfaces[i].Name < interfaces[j].Name })
return interfaces
}
func isLoopback(flags []string) bool {
for _, flag := range flags {
if strings.EqualFold(flag, "loopback") {
return true
}
}
return false
}
// collectDiskIO gathers I/O statistics for physical block devices.
// Only reports whole disks (nvme0n1, sda), not partitions (nvme0n1p1, sda1).
func collectDiskIO(ctx context.Context) []agentshost.DiskIO {
counters, err := diskIOCounters(ctx)
if err != nil {
return nil
}
devices := make([]agentshost.DiskIO, 0, len(counters))
for name, stats := range counters {
// Skip partitions - only report whole devices
if isPartition(name) {
continue
}
// Skip loop devices and ram disks
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") {
continue
}
// Skip device-mapper and md devices (report at physical level)
if strings.HasPrefix(name, "dm-") {
continue
}
devices = append(devices, agentshost.DiskIO{
Device: name,
ReadBytes: stats.ReadBytes,
WriteBytes: stats.WriteBytes,
ReadOps: stats.ReadCount,
WriteOps: stats.WriteCount,
ReadTime: stats.ReadTime,
WriteTime: stats.WriteTime,
IOTime: stats.IoTime,
})
}
sort.Slice(devices, func(i, j int) bool { return devices[i].Device < devices[j].Device })
return devices
}
// isPartition returns true if the device name looks like a partition
// e.g., sda1, nvme0n1p1, vda2
func isPartition(name string) bool {
// NVMe partitions: nvme0n1p1, nvme0n1p2
if strings.Contains(name, "n") && strings.Contains(name, "p") {
// Check if it ends with pN where N is a digit
idx := strings.LastIndex(name, "p")
if idx > 0 && idx < len(name)-1 {
rest := name[idx+1:]
if len(rest) > 0 && rest[0] >= '0' && rest[0] <= '9' {
return true
}
}
}
// Traditional partitions: sda1, vda2, hda1
if len(name) > 2 {
last := name[len(name)-1]
if last >= '0' && last <= '9' {
// Check if second-to-last is a letter (sda1) or also a digit (sda10)
secondLast := name[len(name)-2]
if (secondLast >= 'a' && secondLast <= 'z') || (secondLast >= '0' && secondLast <= '9') {
// Exclude things like "md0" (whole device) - check for common prefixes
if strings.HasPrefix(name, "sd") || strings.HasPrefix(name, "vd") ||
strings.HasPrefix(name, "hd") || strings.HasPrefix(name, "xvd") {
return true
}
}
}
}
// ZFS devices: zd0p1, zd16p1
if strings.HasPrefix(name, "zd") && strings.Contains(name, "p") {
return true
}
return false
}