- 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>
330 lines
8.3 KiB
Go
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
|
|
}
|