Initial Proxmox dashboard with host and guest metrics
This commit is contained in:
286
cpu.go
Normal file
286
cpu.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cpuInfoPath = "/proc/cpuinfo"
|
||||
procStatPath = "/proc/stat"
|
||||
)
|
||||
|
||||
type cpuCollector struct {
|
||||
mu sync.Mutex
|
||||
cpuInfoPath string
|
||||
procStatPath string
|
||||
static CPUStatic
|
||||
previous cpuTimes
|
||||
}
|
||||
|
||||
type CPUStatic struct {
|
||||
Model string `json:"model"`
|
||||
Sockets int `json:"sockets"`
|
||||
PhysicalCores int `json:"physicalCores"`
|
||||
LogicalCPUs int `json:"logicalCpus"`
|
||||
}
|
||||
|
||||
type CPUMetrics struct {
|
||||
CPUStatic
|
||||
UsagePercent float64 `json:"usagePercent"`
|
||||
FrequencyMHz float64 `json:"frequencyMhz"`
|
||||
Load1 float64 `json:"load1"`
|
||||
Load5 float64 `json:"load5"`
|
||||
Load15 float64 `json:"load15"`
|
||||
Uptime uint64 `json:"uptimeSeconds"`
|
||||
Temperatures []Temperature `json:"temperatures"`
|
||||
CollectedAt time.Time `json:"collectedAt"`
|
||||
}
|
||||
|
||||
type Temperature struct {
|
||||
Label string `json:"label"`
|
||||
Celsius float64 `json:"celsius"`
|
||||
}
|
||||
|
||||
type cpuTimes struct {
|
||||
total uint64
|
||||
idle uint64
|
||||
}
|
||||
|
||||
func newCPUCollector(infoPath, statPath string) (*cpuCollector, error) {
|
||||
static, err := readCPUStatic(infoPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previous, err := readCPUTimes(statPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cpuCollector{
|
||||
cpuInfoPath: infoPath, procStatPath: statPath,
|
||||
static: static, previous: previous,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *cpuCollector) collect() (CPUMetrics, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
current, err := readCPUTimes(c.procStatPath)
|
||||
if err != nil {
|
||||
return CPUMetrics{}, err
|
||||
}
|
||||
|
||||
var usage float64
|
||||
totalDelta := current.total - c.previous.total
|
||||
idleDelta := current.idle - c.previous.idle
|
||||
if totalDelta > 0 {
|
||||
usage = float64(totalDelta-idleDelta) / float64(totalDelta) * 100
|
||||
}
|
||||
c.previous = current
|
||||
|
||||
frequency, _ := averageFrequencyMHz(c.cpuInfoPath)
|
||||
load1, load5, load15, _ := readLoadAverage()
|
||||
uptime, _ := readUptime()
|
||||
|
||||
return CPUMetrics{
|
||||
CPUStatic: c.static, UsagePercent: usage, FrequencyMHz: frequency,
|
||||
Load1: load1, Load5: load5, Load15: load15, Uptime: uptime,
|
||||
Temperatures: readTemperatures(), CollectedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readCPUStatic(path string) (CPUStatic, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return CPUStatic{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var model string
|
||||
logical := 0
|
||||
physicalIDs := map[string]struct{}{}
|
||||
cores := map[string]struct{}{}
|
||||
physicalID := "0"
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.TrimSpace(line) == "" {
|
||||
physicalID = "0"
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
key, value = strings.TrimSpace(key), strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "processor":
|
||||
logical++
|
||||
case "model name", "Hardware":
|
||||
if model == "" {
|
||||
model = value
|
||||
}
|
||||
case "physical id":
|
||||
physicalID = value
|
||||
physicalIDs[value] = struct{}{}
|
||||
case "core id":
|
||||
cores[physicalID+":"+value] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return CPUStatic{}, err
|
||||
}
|
||||
if model == "" {
|
||||
return CPUStatic{}, fmt.Errorf("модель CPU не найдена в %s", path)
|
||||
}
|
||||
if len(physicalIDs) == 0 {
|
||||
physicalIDs["0"] = struct{}{}
|
||||
}
|
||||
physicalCores := len(cores)
|
||||
if physicalCores == 0 {
|
||||
physicalCores = logical
|
||||
}
|
||||
return CPUStatic{
|
||||
Model: model, Sockets: len(physicalIDs),
|
||||
PhysicalCores: physicalCores, LogicalCPUs: logical,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cpuModel(path string) (string, error) {
|
||||
info, err := readCPUStatic(path)
|
||||
return info.Model, err
|
||||
}
|
||||
|
||||
func readCPUTimes(path string) (cpuTimes, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
if !scanner.Scan() {
|
||||
return cpuTimes{}, fmt.Errorf("пустой файл %s", path)
|
||||
}
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return cpuTimes{}, fmt.Errorf("неожиданный формат %s", path)
|
||||
}
|
||||
var values []uint64
|
||||
for _, field := range fields[1:] {
|
||||
value, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return cpuTimes{}, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
var total uint64
|
||||
for _, value := range values {
|
||||
total += value
|
||||
}
|
||||
idle := values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
return cpuTimes{total: total, idle: idle}, nil
|
||||
}
|
||||
|
||||
func averageFrequencyMHz(path string) (float64, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
var sum float64
|
||||
var count int
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
key, value, found := strings.Cut(scanner.Text(), ":")
|
||||
if found && strings.TrimSpace(key) == "cpu MHz" {
|
||||
mhz, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err == nil {
|
||||
sum += mhz
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return 0, fmt.Errorf("частота CPU недоступна")
|
||||
}
|
||||
return sum / float64(count), scanner.Err()
|
||||
}
|
||||
|
||||
func readLoadAverage() (float64, float64, float64, error) {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return 0, 0, 0, fmt.Errorf("неожиданный формат /proc/loadavg")
|
||||
}
|
||||
one, err1 := strconv.ParseFloat(fields[0], 64)
|
||||
five, err2 := strconv.ParseFloat(fields[1], 64)
|
||||
fifteen, err3 := strconv.ParseFloat(fields[2], 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return 0, 0, 0, fmt.Errorf("не удалось прочитать load average")
|
||||
}
|
||||
return one, five, fifteen, nil
|
||||
}
|
||||
|
||||
func readUptime() (uint64, error) {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seconds, err := strconv.ParseFloat(strings.Fields(string(data))[0], 64)
|
||||
return uint64(seconds), err
|
||||
}
|
||||
|
||||
func readTemperatures() []Temperature {
|
||||
var result []Temperature
|
||||
paths, _ := filepath.Glob("/sys/class/hwmon/hwmon*/temp*_input")
|
||||
for _, path := range paths {
|
||||
hwmonDir := filepath.Dir(path)
|
||||
chipData, _ := os.ReadFile(filepath.Join(hwmonDir, "name"))
|
||||
chip := strings.TrimSpace(string(chipData))
|
||||
if !isCPUTemperatureChip(chip) {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
|
||||
if err != nil || raw <= 0 {
|
||||
continue
|
||||
}
|
||||
label := filepath.Base(path)
|
||||
labelPath := strings.TrimSuffix(path, "_input") + "_label"
|
||||
if data, err := os.ReadFile(labelPath); err == nil {
|
||||
label = strings.TrimSpace(string(data))
|
||||
}
|
||||
if chip != "" {
|
||||
label = chip + " · " + label
|
||||
}
|
||||
result = append(result, Temperature{Label: label, Celsius: raw / 1000})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isCPUTemperatureChip(name string) bool {
|
||||
switch name {
|
||||
case "coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user