fix(agent): add fallback for CPU model detection on MIPS architectures (#2138)

gopsutil's cpu.Info() does not parse the 'cpu model' field from
/proc/cpuinfo, which is the only source of CPU model names on MIPS.
Add a fallback that reads /proc/cpuinfo directly and combines
'cpu model' (e.g. 'MIPS 1004Kc V2.15') with 'system type'
(e.g. 'MediaTek MT7621 ver:1 eco:3') for a complete identifier.

The fallback only triggers when gopsutil returns an empty ModelName,
so x86/ARM/other architectures are unaffected.
This commit is contained in:
Jan Dziąsło
2026-08-18 16:34:41 +02:00
committed by GitHub
parent 65a6f60304
commit 96beadc8c9
3 changed files with 142 additions and 0 deletions

View File

@@ -50,6 +50,9 @@ func generateFingerprint(hostname, cpuModel string) string {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
cpuModel = info[0].ModelName cpuModel = info[0].ModelName
} }
if cpuModel == "" {
cpuModel = getCpuModelFromCpuinfo()
}
} }
fingerprint = hostname + cpuModel fingerprint = hostname + cpuModel
} }

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
@@ -78,6 +79,12 @@ func (a *Agent) refreshSystemDetails() {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemDetails.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
// is the only source of the CPU model name on MIPS. Fall back to reading
// it directly when ModelName is empty.
if a.systemDetails.CpuModel == "" {
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
}
// cores / threads // cores / threads
cores, _ := cpu.Counts(false) cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU threads := hostInfo.NCPU
@@ -265,6 +272,59 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats return systemStats
} }
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
// on various embedded architectures.
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
// This is a fallback for architectures where gopsutil's cpu.Info() does not
// populate ModelName, most notably MIPS.
func getCpuModelFromCpuinfo() string {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return ""
}
defer file.Close()
return parseCpuModel(file)
}
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
// a combined CPU model string. It collects values from all matching keys
// and joins them with " / " when multiple are found.
func parseCpuModel(r io.Reader) string {
lines := readLines(r)
var parts []string
for _, key := range cpuModelFallbackKeys {
for _, line := range lines {
after, found := strings.CutPrefix(line, key)
if !found {
continue
}
after = strings.TrimSpace(after)
if len(after) < 2 || after[0] != ':' {
continue
}
if value := strings.TrimSpace(after[1:]); value != "" {
parts = append(parts, value)
break
}
}
}
return strings.Join(parts, " / ")
}
// readLines reads all lines from r into a slice.
func readLines(r io.Reader) []string {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may // calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero. // change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) { func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {

View File

@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"strings"
"testing" "testing"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -113,3 +114,81 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
assert.False(t, agent.detailsDirty) assert.False(t, agent.detailsDirty)
assert.Nil(t, original.Details) assert.Nil(t, original.Details)
} }
func TestParseCpuModel(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "MIPS with both cpu model and system type",
input: `system type : MediaTek MT7621 ver:1 eco:3
machine : ASUS RT-AX53U
processor : 0
cpu model : MIPS 1004Kc V2.15
BogoMIPS : 586.13
wait instruction : yes`,
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
},
{
name: "MIPS with different SoC",
input: `system type : Atheros AR7161 rev 2
machine : NETGEAR WNDR3700
processor : 0
cpu model : MIPS 24Kc V7.4
BogoMIPS : 452.19`,
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
},
{
name: "only system type when cpu model missing",
input: `system type : Broadcom BCM47xx
processor : 0
BogoMIPS : 296.11`,
expected: "Broadcom BCM47xx",
},
{
name: "only cpu model when system type missing",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "x86 cpuinfo returns empty",
input: `processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 142
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
stepping : 10`,
expected: "",
},
{
name: "empty input",
input: "",
expected: "",
},
{
name: "cpu model with extra whitespace",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "cpu model without value",
input: `processor : 0
cpu model :
BogoMIPS : 300.00`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseCpuModel(strings.NewReader(tt.input))
assert.Equal(t, tt.expected, result)
})
}
}