Files
lucemon/backend/sysinfo.go
Jonathan Atta 9ba9b80b8b feat: add Card and Progress components, utility functions, and improve styles
- Introduced Card component with CardHeader, CardTitle, and CardContent subcomponents for better UI structure.
- Added Progress component to visually represent progress with customizable thresholds.
- Created utility function `cn` for conditional class names using clsx and tailwind-merge.
- Updated styles to integrate Tailwind CSS and improved base styles for better consistency.
- Configured Tailwind CSS for dark mode and extended theme with custom fonts and animations.
- Added TypeScript configuration for better type safety and module resolution.
- Enhanced Vite configuration with path aliasing for cleaner imports.
- Updated Wails backend definitions to include new process detail and GPU process info structures.
2026-03-11 17:09:25 +01:00

307 lines
8.9 KiB
Go

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"`
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 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()
}