Files
tyto/backend/internal/collectors/processes.go
vikingowl a2504c1327 feat: rename project to Tyto with owl branding
- Rename project from system-monitor to Tyto (barn owl themed)
- Update Go module name and all import paths
- Update Docker container names (tyto-backend, tyto-frontend)
- Update localStorage keys (tyto-settings, tyto-hosts)
- Create barn owl SVG favicon and PWA icons (192, 512)
- Update header with owl logo icon
- Update manifest.json and app.html with Tyto branding

Named after Tyto alba, the barn owl — nature's silent, watchful guardian

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 06:36:01 +01:00

330 lines
8.3 KiB
Go

package collectors
import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"tyto/internal/models"
)
type ProcessCollector struct {
procPath string
pageSize int64
totalMem uint64
numCPU int
}
func NewProcessCollector(procPath string) *ProcessCollector {
pageSize := int64(os.Getpagesize())
// Get total memory for percentage calculation
var totalMem uint64
data, err := os.ReadFile(filepath.Join(procPath, "meminfo"))
if err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "MemTotal:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
val, _ := strconv.ParseUint(fields[1], 10, 64)
totalMem = val * 1024 // kB to bytes
}
break
}
}
}
// Count CPUs
numCPU := 1
cpuData, err := os.ReadFile(filepath.Join(procPath, "stat"))
if err == nil {
for _, line := range strings.Split(string(cpuData), "\n") {
if strings.HasPrefix(line, "cpu") && !strings.HasPrefix(line, "cpu ") {
numCPU++
}
}
}
return &ProcessCollector{
procPath: procPath,
pageSize: pageSize,
totalMem: totalMem,
numCPU: numCPU,
}
}
func (c *ProcessCollector) Collect() (models.ProcessStats, error) {
stats := models.ProcessStats{
TopByCPU: []models.ProcessInfo{},
TopByMemory: []models.ProcessInfo{},
}
entries, err := os.ReadDir(c.procPath)
if err != nil {
return stats, err
}
processes := make([]models.ProcessInfo, 0)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue
}
proc, err := c.readProcess(pid)
if err != nil {
continue
}
processes = append(processes, proc)
}
stats.Total = len(processes)
// Sort by CPU and get top 10
sort.Slice(processes, func(i, j int) bool {
return processes[i].CPUPercent > processes[j].CPUPercent
})
topCount := min(10, len(processes))
stats.TopByCPU = make([]models.ProcessInfo, topCount)
copy(stats.TopByCPU, processes[:topCount])
// Sort by memory and get top 10
sort.Slice(processes, func(i, j int) bool {
return processes[i].MemoryMB > processes[j].MemoryMB
})
topCount = min(10, len(processes))
stats.TopByMemory = make([]models.ProcessInfo, topCount)
copy(stats.TopByMemory, processes[:topCount])
return stats, nil
}
func (c *ProcessCollector) GetProcessDetail(pid int) (models.ProcessDetail, error) {
detail := models.ProcessDetail{PID: pid}
pidPath := filepath.Join(c.procPath, strconv.Itoa(pid))
// Read /proc/[pid]/stat
statData, err := os.ReadFile(filepath.Join(pidPath, "stat"))
if err != nil {
return detail, err
}
statStr := string(statData)
nameStart := strings.Index(statStr, "(")
nameEnd := strings.LastIndex(statStr, ")")
if nameStart == -1 || nameEnd == -1 {
return detail, err
}
detail.Name = statStr[nameStart+1 : nameEnd]
fields := strings.Fields(statStr[nameEnd+2:])
if len(fields) < 22 {
return detail, err
}
detail.State = fields[0]
detail.StateDesc = stateDescription(fields[0])
detail.PPID, _ = strconv.Atoi(fields[1])
detail.Nice, _ = strconv.Atoi(fields[16])
detail.Threads, _ = strconv.Atoi(fields[17])
// Memory
rss, _ := strconv.ParseInt(fields[21], 10, 64)
vsize, _ := strconv.ParseUint(fields[20], 10, 64)
detail.MemoryRSS = uint64(rss * c.pageSize)
detail.MemoryVMS = vsize
detail.MemoryMB = float64(detail.MemoryRSS) / (1024 * 1024)
// CPU time
utime, _ := strconv.ParseUint(fields[11], 10, 64)
stime, _ := strconv.ParseUint(fields[12], 10, 64)
totalTicks := utime + stime
totalSecs := float64(totalTicks) / 100 // CLK_TCK usually 100
hours := int(totalSecs) / 3600
mins := (int(totalSecs) % 3600) / 60
secs := int(totalSecs) % 60
detail.CPUTime = formatDuration(hours, mins, secs)
// CPU percent
uptimeData, _ := os.ReadFile(filepath.Join(c.procPath, "uptime"))
if len(uptimeData) > 0 {
uptimeFields := strings.Fields(string(uptimeData))
if len(uptimeFields) >= 1 {
uptime, _ := strconv.ParseFloat(uptimeFields[0], 64)
starttime, _ := strconv.ParseUint(fields[19], 10, 64)
processUptime := uptime - (float64(starttime) / 100)
if processUptime > 0 {
detail.CPUPercent = (totalSecs / processUptime) * 100
}
}
}
// Start time - calculate from boot time and starttime
starttime, _ := strconv.ParseUint(fields[19], 10, 64)
bootTimeData, _ := os.ReadFile(filepath.Join(c.procPath, "stat"))
for _, line := range strings.Split(string(bootTimeData), "\n") {
if strings.HasPrefix(line, "btime ") {
btime, _ := strconv.ParseInt(strings.Fields(line)[1], 10, 64)
startSec := btime + int64(starttime/100)
detail.StartTime = formatTimestamp(startSec)
break
}
}
// Cmdline
cmdlineData, _ := os.ReadFile(filepath.Join(pidPath, "cmdline"))
detail.Cmdline = strings.ReplaceAll(string(cmdlineData), "\x00", " ")
detail.Cmdline = strings.TrimSpace(detail.Cmdline)
if detail.Cmdline == "" {
detail.Cmdline = "[" + detail.Name + "]"
}
// User from /proc/[pid]/status
statusData, _ := os.ReadFile(filepath.Join(pidPath, "status"))
for _, line := range strings.Split(string(statusData), "\n") {
if strings.HasPrefix(line, "Uid:") {
fields := strings.Fields(line)
if len(fields) >= 2 {
uid := fields[1]
detail.User = lookupUser(uid)
}
break
}
}
// Open files count
fdPath := filepath.Join(pidPath, "fd")
if entries, err := os.ReadDir(fdPath); err == nil {
detail.OpenFiles = len(entries)
}
return detail, nil
}
func stateDescription(state string) string {
switch state {
case "R":
return "Running"
case "S":
return "Sleeping"
case "D":
return "Disk Sleep"
case "Z":
return "Zombie"
case "T":
return "Stopped"
case "t":
return "Tracing Stop"
case "X":
return "Dead"
case "I":
return "Idle"
default:
return state
}
}
func formatDuration(h, m, s int) string {
if h > 0 {
return strconv.Itoa(h) + "h " + strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s"
}
if m > 0 {
return strconv.Itoa(m) + "m " + strconv.Itoa(s) + "s"
}
return strconv.Itoa(s) + "s"
}
func formatTimestamp(unix int64) string {
// Simple date format - just return the timestamp
// The frontend can format it properly
return strconv.FormatInt(unix, 10)
}
func lookupUser(uid string) string {
// Try to read /etc/passwd to map UID to username
data, err := os.ReadFile("/etc/passwd")
if err != nil {
return "uid:" + uid
}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Split(line, ":")
if len(fields) >= 3 && fields[2] == uid {
return fields[0]
}
}
return "uid:" + uid
}
func (c *ProcessCollector) readProcess(pid int) (models.ProcessInfo, error) {
proc := models.ProcessInfo{PID: pid}
pidPath := filepath.Join(c.procPath, strconv.Itoa(pid))
// Read /proc/[pid]/stat
statData, err := os.ReadFile(filepath.Join(pidPath, "stat"))
if err != nil {
return proc, err
}
// Parse stat file - name is in parentheses, can contain spaces
statStr := string(statData)
nameStart := strings.Index(statStr, "(")
nameEnd := strings.LastIndex(statStr, ")")
if nameStart == -1 || nameEnd == -1 {
return proc, err
}
proc.Name = statStr[nameStart+1 : nameEnd]
// Fields after the name
fields := strings.Fields(statStr[nameEnd+2:])
if len(fields) < 22 {
return proc, err
}
proc.State = fields[0]
// RSS (resident set size) is field 23 (index 21 after name)
rss, _ := strconv.ParseInt(fields[21], 10, 64)
memBytes := rss * c.pageSize
proc.MemoryMB = float64(memBytes) / (1024 * 1024)
// CPU time (utime + stime) - simplified, not actual percentage
// For accurate CPU%, we'd need to track over time like we do for system CPU
utime, _ := strconv.ParseUint(fields[11], 10, 64)
stime, _ := strconv.ParseUint(fields[12], 10, 64)
// Read uptime to calculate CPU percentage
uptimeData, err := os.ReadFile(filepath.Join(c.procPath, "uptime"))
if err == nil {
uptimeFields := strings.Fields(string(uptimeData))
if len(uptimeFields) >= 1 {
uptime, _ := strconv.ParseFloat(uptimeFields[0], 64)
// Get process start time (field 21, starttime in clock ticks)
starttime, _ := strconv.ParseUint(fields[19], 10, 64)
// Clock ticks per second (usually 100)
clkTck := uint64(100)
totalTime := float64(utime + stime) / float64(clkTck)
processUptime := uptime - (float64(starttime) / float64(clkTck))
if processUptime > 0 {
proc.CPUPercent = (totalTime / processUptime) * 100
}
}
}
return proc, nil
}