Files
deploy-felhom-compose/controller/internal/system/info_linux.go
T
admin ad4c005e01 v0.27.3: Use real system memory everywhere, add monitoring memory bar
Deploy page, pre-start check, and deploy validation now use actual
/proc/meminfo usage instead of declared mem_request sums. New
GetMemoryMB() helper for lightweight real-time memory reads. Monitoring
page gains a stacked memory distribution bar showing per-container
usage, OS overhead, and free memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:06:03 +01:00

249 lines
5.7 KiB
Go

//go:build linux
package system
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
)
// GetInfo reads system memory, disk, CPU, load, and temperature info.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped.
// cpuCollector provides the latest CPU usage sample; may be nil.
func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
info := SystemInfo{}
// --- Memory from /proc/meminfo ---
readMemInfo(&info)
// --- Root filesystem disk usage ---
readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
// --- HDD disk usage (if configured) ---
if hddPath != "" {
info.HDDConfigured = true
readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
}
// --- Load average ---
readLoadAvg(&info)
// --- Temperature ---
readTemperature(&info)
// --- CPU from collector ---
if cpuCollector != nil {
info.CPUPercent = cpuCollector.CPUPercent()
}
return info
}
// GetTotalMemoryMB reads total system memory from /proc/meminfo.
func GetTotalMemoryMB() (int, error) {
info := SystemInfo{}
readMemInfo(&info)
if info.TotalMemMB == 0 {
return 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
}
return int(info.TotalMemMB), nil
}
// GetMemoryMB returns total and used system memory in MB from /proc/meminfo.
func GetMemoryMB() (totalMB, usedMB int, err error) {
info := SystemInfo{}
readMemInfo(&info)
if info.TotalMemMB == 0 {
return 0, 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
}
return int(info.TotalMemMB), int(info.UsedMemMB), nil
}
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
var totalKB, availKB uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.HasPrefix(line, "MemTotal:"):
totalKB = parseMemLine(line)
case strings.HasPrefix(line, "MemAvailable:"):
availKB = parseMemLine(line)
}
if totalKB > 0 && availKB > 0 {
break
}
}
if totalKB > 0 {
info.TotalMemMB = totalKB / 1024
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100
}
}
// parseMemLine extracts the kB value from a /proc/meminfo line like "MemTotal: 16384000 kB"
func parseMemLine(line string) uint64 {
parts := strings.SplitN(line, ":", 2)
if len(parts) < 2 {
return 0
}
valStr := strings.TrimSpace(parts[1])
valStr = strings.TrimSuffix(valStr, " kB")
valStr = strings.TrimSpace(valStr)
var val uint64
for _, c := range valStr {
if c >= '0' && c <= '9' {
val = val*10 + uint64(c-'0')
}
}
return val
}
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return
}
bsize := uint64(stat.Bsize)
total := stat.Blocks * bsize
avail := stat.Bavail * bsize
used := total - (stat.Bfree * bsize)
const gb = 1024 * 1024 * 1024
*totalGB = float64(total) / gb
*usedGB = float64(used) / gb
*availGB = float64(avail) / gb
if total > 0 {
*percent = float64(used) / float64(total) * 100
}
}
// readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
func readLoadAvg(info *SystemInfo) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return
}
fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15)
}
// readTemperature reads CPU/SoC temperature from thermal zones.
// Tries /host/sys first (Docker mount), then /sys (native).
func readTemperature(info *SystemInfo) {
prefixes := []string{"/host/sys", "/sys"}
for _, prefix := range prefixes {
if readThermalZones(prefix, info) {
return
}
}
// Fallback: try hwmon
for _, prefix := range prefixes {
if readHwmon(prefix, info) {
return
}
}
}
func readThermalZones(sysPrefix string, info *SystemInfo) bool {
pattern := filepath.Join(sysPrefix, "class", "thermal", "thermal_zone*", "temp")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return false
}
sort.Strings(matches)
var maxTemp float64
var maxSource string
for _, tempPath := range matches {
data, err := os.ReadFile(tempPath)
if err != nil {
continue
}
var milliDeg int64
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
continue
}
temp := float64(milliDeg) / 1000.0
// Read the type file for the label
zoneDir := filepath.Dir(tempPath)
typePath := filepath.Join(zoneDir, "type")
typeData, err := os.ReadFile(typePath)
source := strings.TrimSpace(string(typeData))
if err != nil || source == "" {
source = filepath.Base(zoneDir)
}
if temp > maxTemp {
maxTemp = temp
maxSource = source
}
}
if maxTemp > 0 {
info.TemperatureCelsius = maxTemp
info.TemperatureSource = maxSource
return true
}
return false
}
func readHwmon(sysPrefix string, info *SystemInfo) bool {
pattern := filepath.Join(sysPrefix, "class", "hwmon", "hwmon*", "temp1_input")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return false
}
var maxTemp float64
var maxSource string
for _, tempPath := range matches {
data, err := os.ReadFile(tempPath)
if err != nil {
continue
}
var milliDeg int64
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
continue
}
temp := float64(milliDeg) / 1000.0
source := filepath.Base(filepath.Dir(tempPath))
if temp > maxTemp {
maxTemp = temp
maxSource = source
}
}
if maxTemp > 0 {
info.TemperatureCelsius = maxTemp
info.TemperatureSource = maxSource
return true
}
return false
}