package backend import ( "context" "os/exec" "sort" "strconv" "strings" "time" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/mem" ps "github.com/shirou/gopsutil/v3/process" "github.com/wailsapp/wails/v2/pkg/runtime" ) type SysInfo struct { ctx context.Context cancel context.CancelFunc } func NewSysInfo(ctx context.Context) *SysInfo { cctx, cancel := context.WithCancel(ctx) s := &SysInfo{ctx: cctx, cancel: cancel} go s.startEmitter() return s } 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.CollectOnce() // attach GPU global stats name, tot, used, util := queryGPU(s.ctx) if name != "" { info.GPUName = name info.GPUTotal = tot info.GPUUsed = used info.GPUUtil = util // attach per-process GPU stats info.GPUProcesses = queryGPUProcesses(s.ctx) } 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"` } // 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"` } // CollectOnce gathers a snapshot of system metrics. func (s *SysInfo) CollectOnce() (*Metrics, error) { cpuPercents, _ := cpu.PercentWithContext(s.ctx, 0, false) vm, _ := mem.VirtualMemory() // Collect ALL processes, then sort and keep top 50 procs, _ := ps.Processes() 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 } list = append(list, ProcessInfo{PID: p.Pid, Name: name, CPU: cpuPct, Mem: memBytes}) } // 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] } return &Metrics{ CPUPercent: cpuVal, TotalMem: vm.Total, FreeMem: vm.Available, Processes: list, Timestamp: time.Now().UnixMilli(), GPUName: "", GPUTotal: 0, GPUUsed: 0, GPUUtil: 0, }, 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.CollectOnce() } // 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} 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 }