package backend import ( "context" "os/exec" "sort" "strconv" "strings" "sync" "time" "github.com/shirou/gopsutil/v3/cpu" psdisk "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" ps "github.com/shirou/gopsutil/v3/process" "github.com/wailsapp/wails/v2/pkg/runtime" ) type SysInfo struct { ctx context.Context cancel context.CancelFunc mu sync.Mutex lastProcIO map[int32][2]uint64 // pid -> [readBytes, writeBytes] lastDiskIO [2]uint64 // [totalReadBytes, totalWriteBytes] lastNetIO [2]uint64 // [bytesRecv, bytesSent] lastTime time.Time } func NewSysInfo(ctx context.Context) *SysInfo { cctx, cancel := context.WithCancel(ctx) s := &SysInfo{ ctx: cctx, cancel: cancel, lastProcIO: make(map[int32][2]uint64), } go s.startEmitter() return s } // NewSysInfoHeadless creates a SysInfo for use without a Wails GUI context. // Use Snapshot() to collect metrics; no Wails events are emitted. func NewSysInfoHeadless(ctx context.Context) *SysInfo { cctx, cancel := context.WithCancel(ctx) return &SysInfo{ ctx: cctx, cancel: cancel, lastProcIO: make(map[int32][2]uint64), } } func (s *SysInfo) startEmitter() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-s.ctx.Done(): return case <-ticker.C: info, _ := s.Snapshot() runtime.EventsEmit(s.ctx, "metrics", info) } } } type ProcessInfo struct { PID int32 `json:"pid"` Name string `json:"name"` CPU float64 `json:"cpu"` Mem uint64 `json:"mem"` ReadBps uint64 `json:"read_bps"` WriteBps uint64 `json:"write_bps"` } // GPUProcessInfo holds per-process GPU stats from nvidia-smi pmon. type GPUProcessInfo struct { PID int32 `json:"pid"` Name string `json:"name"` Type string `json:"type"` // "C", "G", or "C+G" VRAM uint64 `json:"vram_mb"` // framebuffer memory in MB RAM uint64 `json:"ram"` // resident set size in bytes } type Metrics struct { CPUPercent float64 `json:"cpu_percent"` TotalMem uint64 `json:"total_mem"` FreeMem uint64 `json:"free_mem"` Processes []ProcessInfo `json:"processes"` Timestamp int64 `json:"timestamp"` GPUName string `json:"gpu_name,omitempty"` GPUTotal uint64 `json:"gpu_total_mem,omitempty"` GPUUsed uint64 `json:"gpu_used_mem,omitempty"` GPUUtil float64 `json:"gpu_util_percent,omitempty"` GPUProcesses []GPUProcessInfo `json:"gpu_processes,omitempty"` DiskReadBps uint64 `json:"disk_read_bps"` DiskWriteBps uint64 `json:"disk_write_bps"` NetRecvBps uint64 `json:"net_recv_bps"` NetSendBps uint64 `json:"net_send_bps"` } // CollectOnce gathers a snapshot of system metrics. func (s *SysInfo) CollectOnce() (*Metrics, error) { now := time.Now() // Snapshot previous IO state s.mu.Lock() elapsed := 0.0 if !s.lastTime.IsZero() { elapsed = now.Sub(s.lastTime).Seconds() } prevProcIO := s.lastProcIO prevDiskIO := s.lastDiskIO prevNetIO := s.lastNetIO s.mu.Unlock() cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false) vm, _ := mem.VirtualMemory() // Collect all processes with disk I/O rates procs, _ := ps.Processes() newProcIO := make(map[int32][2]uint64, len(procs)) var list []ProcessInfo for _, p := range procs { name, _ := p.Name() cpuPct, _ := p.CPUPercent() memInfo, _ := p.MemoryInfo() var memBytes uint64 if memInfo != nil { memBytes = memInfo.RSS } var readBps, writeBps uint64 if ioStats, err := p.IOCounters(); err == nil { newProcIO[p.Pid] = [2]uint64{ioStats.ReadBytes, ioStats.WriteBytes} if elapsed > 0 { if prev, ok := prevProcIO[p.Pid]; ok { if ioStats.ReadBytes >= prev[0] { readBps = uint64(float64(ioStats.ReadBytes-prev[0]) / elapsed) } if ioStats.WriteBytes >= prev[1] { writeBps = uint64(float64(ioStats.WriteBytes-prev[1]) / elapsed) } } } } list = append(list, ProcessInfo{ PID: p.Pid, Name: name, CPU: cpuPct, Mem: memBytes, ReadBps: readBps, WriteBps: writeBps, }) } // Sort: primary by CPU desc, secondary by Mem desc sort.Slice(list, func(i, j int) bool { if list[i].CPU != list[j].CPU { return list[i].CPU > list[j].CPU } return list[i].Mem > list[j].Mem }) var cpuVal float64 if len(cpuPercents) > 0 { cpuVal = cpuPercents[0] } // System-wide disk I/O rates var diskReadBps, diskWriteBps uint64 var newDiskIO [2]uint64 if diskCounters, err := psdisk.IOCounters(); err == nil { for _, d := range diskCounters { newDiskIO[0] += d.ReadBytes newDiskIO[1] += d.WriteBytes } if elapsed > 0 && (prevDiskIO[0] > 0 || prevDiskIO[1] > 0) { if newDiskIO[0] >= prevDiskIO[0] { diskReadBps = uint64(float64(newDiskIO[0]-prevDiskIO[0]) / elapsed) } if newDiskIO[1] >= prevDiskIO[1] { diskWriteBps = uint64(float64(newDiskIO[1]-prevDiskIO[1]) / elapsed) } } } // System-wide network I/O rates var netRecvBps, netSendBps uint64 var newNetIO [2]uint64 if netCounters, err := psnet.IOCounters(false); err == nil && len(netCounters) > 0 { newNetIO[0] = netCounters[0].BytesRecv newNetIO[1] = netCounters[0].BytesSent if elapsed > 0 && (prevNetIO[0] > 0 || prevNetIO[1] > 0) { if newNetIO[0] >= prevNetIO[0] { netRecvBps = uint64(float64(newNetIO[0]-prevNetIO[0]) / elapsed) } if newNetIO[1] >= prevNetIO[1] { netSendBps = uint64(float64(newNetIO[1]-prevNetIO[1]) / elapsed) } } } // Update state under lock s.mu.Lock() s.lastTime = now s.lastProcIO = newProcIO s.lastDiskIO = newDiskIO s.lastNetIO = newNetIO s.mu.Unlock() return &Metrics{ CPUPercent: cpuVal, TotalMem: vm.Total, FreeMem: vm.Available, Processes: list, Timestamp: time.Now().UnixMilli(), DiskReadBps: diskReadBps, DiskWriteBps: diskWriteBps, NetRecvBps: netRecvBps, NetSendBps: netSendBps, }, nil } // queryGPUProcesses lists all processes currently using the GPU via nvidia-smi pmon. func queryGPUProcesses(ctx context.Context) []GPUProcessInfo { cmd := exec.CommandContext(ctx, "nvidia-smi", "pmon", "-s", "m", "-c", "1") out, err := cmd.Output() if err != nil { return nil } var result []GPUProcessInfo for _, line := range strings.Split(string(out), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } fields := strings.Fields(line) // expected: [gpuIdx, pid, type, fbMB, ccpmMB, name...] if len(fields) < 5 { continue } pid64, err := strconv.ParseInt(fields[1], 10, 32) if err != nil { continue } vram, _ := strconv.ParseUint(fields[3], 10, 64) name := "" if len(fields) >= 6 { name = strings.Join(fields[5:], " ") } else { name = fields[len(fields)-1] } gp := GPUProcessInfo{ PID: int32(pid64), Name: name, Type: fields[2], VRAM: vram, } // look up RAM (RSS) for this process if p, err := ps.NewProcess(int32(pid64)); err == nil { if mi, err := p.MemoryInfoWithContext(ctx); err == nil && mi != nil { gp.RAM = mi.RSS } } result = append(result, gp) } return result } // try to query GPU info via nvidia-smi; returns name, totalMB, usedMB, utilPercent func queryGPU(ctx context.Context) (string, uint64, uint64, float64) { // Use nvidia-smi if available cmd := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=name,memory.total,memory.used,utilization.gpu", "--format=csv,noheader,nounits") out, err := cmd.Output() if err != nil { return "", 0, 0, 0 } line := strings.TrimSpace(string(out)) if line == "" { return "", 0, 0, 0 } // fields: name, totalMB, usedMB, util parts := strings.Split(line, ",") if len(parts) < 4 { return "", 0, 0, 0 } name := strings.TrimSpace(parts[0]) totStr := strings.TrimSpace(parts[1]) usedStr := strings.TrimSpace(parts[2]) utilStr := strings.TrimSpace(parts[3]) tot, _ := strconv.ParseUint(strings.Fields(totStr)[0], 10, 64) used, _ := strconv.ParseUint(strings.Fields(usedStr)[0], 10, 64) utilVal, _ := strconv.ParseFloat(strings.Fields(utilStr)[0], 64) return name, tot * 1024 * 1024, used * 1024 * 1024, utilVal } // GetMetrics is an exported method callable from frontend. func (s *SysInfo) GetMetrics() (*Metrics, error) { return s.Snapshot() } // Snapshot collects a complete metrics snapshot including GPU data. func (s *SysInfo) Snapshot() (*Metrics, error) { m, err := s.CollectOnce() if err != nil { return nil, err } name, tot, used, util := queryGPU(s.ctx) if name != "" { m.GPUName = name m.GPUTotal = tot m.GPUUsed = used m.GPUUtil = util m.GPUProcesses = queryGPUProcesses(s.ctx) } return m, nil } // ProcessDetail contains all available details about a single process. type ProcessDetail struct { PID int32 `json:"pid"` Name string `json:"name"` Exe string `json:"exe"` Cmdline string `json:"cmdline"` Cwd string `json:"cwd"` Status string `json:"status"` Username string `json:"username"` CreatedAt int64 `json:"created_at"` // unix ms ParentPID int32 `json:"parent_pid"` ParentName string `json:"parent_name"` Nice int32 `json:"nice"` NumThreads int32 `json:"num_threads"` NumFDs int32 `json:"num_fds"` CPUPercent float64 `json:"cpu_percent"` RSS uint64 `json:"rss"` VMS uint64 `json:"vms"` Swap uint64 `json:"swap"` MemPercent float32 `json:"mem_percent"` ReadBytes uint64 `json:"read_bytes"` WriteBytes uint64 `json:"write_bytes"` ReadOps uint64 `json:"read_ops"` WriteOps uint64 `json:"write_ops"` OpenFilesCount int `json:"open_files_count"` ConnCount int `json:"conn_count"` } // FetchProcessDetail collects all available details for a given PID. // Fields that can't be read (e.g. permission denied) are left at zero/empty. func FetchProcessDetail(pid int32) (*ProcessDetail, error) { p, err := ps.NewProcess(pid) if err != nil { return nil, err } d := &ProcessDetail{PID: pid} if v, e := p.Name(); e == nil { d.Name = v } if v, e := p.Exe(); e == nil { d.Exe = v } if v, e := p.Cmdline(); e == nil { d.Cmdline = v } if v, e := p.Cwd(); e == nil { d.Cwd = v } if vv, e := p.Status(); e == nil && len(vv) > 0 { d.Status = vv[0] } if v, e := p.Username(); e == nil { d.Username = v } if v, e := p.CreateTime(); e == nil { d.CreatedAt = v } if v, e := p.Ppid(); e == nil { d.ParentPID = v if d.ParentPID > 0 { if pp, err2 := ps.NewProcess(d.ParentPID); err2 == nil { if pname, err2 := pp.Name(); err2 == nil { d.ParentName = pname } } } } if v, e := p.Nice(); e == nil { d.Nice = v } if v, e := p.NumThreads(); e == nil { d.NumThreads = v } if v, e := p.NumFDs(); e == nil { d.NumFDs = v } if v, e := p.CPUPercent(); e == nil { d.CPUPercent = v } if v, e := p.MemoryInfo(); e == nil && v != nil { d.RSS = v.RSS d.VMS = v.VMS d.Swap = v.Swap } if v, e := p.MemoryPercent(); e == nil { d.MemPercent = v } if v, e := p.IOCounters(); e == nil && v != nil { d.ReadBytes = v.ReadBytes d.WriteBytes = v.WriteBytes d.ReadOps = v.ReadCount d.WriteOps = v.WriteCount } if v, e := p.OpenFiles(); e == nil { d.OpenFilesCount = len(v) } if v, e := p.Connections(); e == nil { d.ConnCount = len(v) } return d, nil } // KillProcess sends SIGTERM to the process. Use force=true to send SIGKILL. func KillProcess(pid int32, force bool) error { p, err := ps.NewProcess(pid) if err != nil { return err } if force { return p.Kill() } return p.Terminate() } // CollectFull gathers a complete metrics snapshot including GPU data. // Unlike NewSysInfo, it does not start a background emitter and does not // require a Wails runtime context — safe to call from server mode. func CollectFull(ctx context.Context) (*Metrics, error) { s := &SysInfo{ctx: ctx, lastProcIO: make(map[int32][2]uint64)} m, err := s.CollectOnce() if err != nil { return nil, err } name, tot, used, util := queryGPU(ctx) if name != "" { m.GPUName = name m.GPUTotal = tot m.GPUUsed = used m.GPUUtil = util m.GPUProcesses = queryGPUProcesses(ctx) } return m, nil } // GetManPage returns the plain-text man page for the given command name. // Returns an empty string if man is not installed or the page does not exist. func GetManPage(name string) string { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "man", "-P", "cat", name) out, err := cmd.Output() if err != nil { return "" } // Strip nroff backspace-based bold/underline formatting (char + backspace + char) raw := []byte(out) cleaned := make([]byte, 0, len(raw)) for i := 0; i < len(raw); i++ { if i+1 < len(raw) && raw[i+1] == '\b' { i++ // skip formatting char and backspace; the real char follows } else { cleaned = append(cleaned, raw[i]) } } text := strings.TrimSpace(string(cleaned)) if len(text) > 12000 { text = text[:12000] + "\n…[truncated]" } return text }