490 lines
15 KiB
Go
490 lines
15 KiB
Go
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"`
|
|
NetConns int `json:"net_conns"`
|
|
}
|
|
|
|
// 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
|
|
SMUtil uint32 `json:"sm_util"` // SM (CUDA core) utilization %
|
|
}
|
|
|
|
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()
|
|
|
|
// Aggregate network connections per PID in one call
|
|
netConnByPid := make(map[int32]int)
|
|
if allConns, err := psnet.Connections("all"); err == nil {
|
|
for _, c := range allConns {
|
|
if c.Pid > 0 {
|
|
netConnByPid[c.Pid]++
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
NetConns: netConnByPid[p.Pid],
|
|
})
|
|
}
|
|
// 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 {
|
|
// -s mu: SM utilization + memory (fb)
|
|
// columns: gpuIdx pid type sm% mem% enc% dec% fbMB ccpmMB command...
|
|
cmd := exec.CommandContext(ctx, "nvidia-smi", "pmon", "-s", "mu", "-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, sm%, mem%, enc%, dec%, fbMB, ccpmMB, name...]
|
|
if len(fields) < 9 {
|
|
continue
|
|
}
|
|
pid64, err := strconv.ParseInt(fields[1], 10, 32)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sm64, _ := strconv.ParseUint(fields[3], 10, 32)
|
|
vram, _ := strconv.ParseUint(fields[7], 10, 64)
|
|
name := strings.Join(fields[9:], " ")
|
|
if name == "" {
|
|
name = fields[len(fields)-1]
|
|
}
|
|
gp := GPUProcessInfo{
|
|
PID: int32(pid64),
|
|
Name: name,
|
|
Type: fields[2],
|
|
VRAM: vram,
|
|
SMUtil: uint32(sm64),
|
|
}
|
|
// 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
|
|
} |