2862 lines
76 KiB
Go
2862 lines
76 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
smartgo "github.com/anatol/smart.go"
|
|
"github.com/klauspost/cpuid"
|
|
lmsensors "github.com/mt-inside/go-lmsensors"
|
|
"github.com/shirou/gopsutil/v4/cpu"
|
|
"github.com/shirou/gopsutil/v4/disk"
|
|
"github.com/shirou/gopsutil/v4/host"
|
|
"github.com/shirou/gopsutil/v4/mem"
|
|
gopsnet "github.com/shirou/gopsutil/v4/net"
|
|
smbios "github.com/u-root/u-root/pkg/smbios"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ==========================================
|
|
// 1. TYPES & CONFIGURATION
|
|
// ==========================================
|
|
|
|
// ConfigStructure représente le fichier config.yaml distant
|
|
type Config struct {
|
|
ConfigVersion int `yaml:"config_version"`
|
|
Backend BackendConfig `yaml:"backend"`
|
|
Runtime RuntimeConfig `yaml:"runtime"`
|
|
Logging LoggingConfig `yaml:"logging"`
|
|
Payload PayloadConfig `yaml:"payload"`
|
|
Benchmarks BenchmarkConfig `yaml:"benchmarks"`
|
|
Collection CollectionConfig `yaml:"collection"`
|
|
RawInfo RawInfoConfig `yaml:"raw_info"`
|
|
}
|
|
|
|
type BackendConfig struct {
|
|
URL string `yaml:"url"`
|
|
Auth AuthConfig `yaml:"auth"`
|
|
HTTP HTTPConfig `yaml:"http"`
|
|
}
|
|
|
|
type AuthConfig struct {
|
|
Mode string `yaml:"mode"`
|
|
EnvVar string `yaml:"env_var"`
|
|
}
|
|
|
|
type HTTPConfig struct {
|
|
TimeoutS int `yaml:"timeout_s"`
|
|
Retries int `yaml:"retries"`
|
|
RetryBackoffS int `yaml:"retry_backoff_s"`
|
|
TLSVerify bool `yaml:"verify"`
|
|
}
|
|
|
|
type RuntimeConfig struct {
|
|
MaxTotalRuntime int `yaml:"max_total_runtime_s"`
|
|
CommandTimeout int `yaml:"command_timeout_s"`
|
|
TempDir string `yaml:"temp_dir"`
|
|
Locale string `yaml:"locale"`
|
|
PathPrepend []string `yaml:"path_prepend"`
|
|
}
|
|
|
|
type LoggingConfig struct {
|
|
Level string `yaml:"level"`
|
|
Progress bool `yaml:"progress"`
|
|
ShowCommandLines bool `yaml:"show_command_lines"`
|
|
CaptureRawOutput bool `yaml:"capture_raw_output"`
|
|
RawOutputMaxKB int `yaml:"raw_output_max_kb"`
|
|
SavePayloadToTmp bool `yaml:"save_payload_to_tmp"`
|
|
PayloadTmpDir string `yaml:"payload_tmp_dir"`
|
|
PayloadFilenamePrefix string `yaml:"payload_filename_prefix"`
|
|
InteractiveDebugPause bool `yaml:"interactive_pause_on_debug"`
|
|
}
|
|
|
|
type PayloadConfig struct {
|
|
Dir string `yaml:"dir"`
|
|
Prefix string `yaml:"prefix"`
|
|
Always bool `yaml:"always_save"`
|
|
}
|
|
|
|
type BenchmarkConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Weights map[string]float64 `yaml:"weights"`
|
|
CPU BenchmarkTool `yaml:"cpu_sysbench"`
|
|
Memory BenchmarkTool `yaml:"memory_sysbench"`
|
|
Disk BenchmarkTool `yaml:"disk_fio"`
|
|
Network BenchmarkTool `yaml:"network_iperf3"`
|
|
}
|
|
|
|
type BenchmarkTool struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Tool string `yaml:"tool"`
|
|
Server string `yaml:"server"`
|
|
Port int `yaml:"port"`
|
|
Params map[string]interface{} `yaml:"params"`
|
|
Safety map[string]interface{} `yaml:"safety"`
|
|
}
|
|
|
|
type CollectionConfig struct {
|
|
System CollectionGroup `yaml:"system"`
|
|
CPU CollectionGroup `yaml:"cpu"`
|
|
RAM CollectionGroup `yaml:"ram"`
|
|
Storage CollectionGroup `yaml:"storage"`
|
|
Network CollectionGroup `yaml:"network"`
|
|
GPU CollectionGroup `yaml:"gpu"`
|
|
Audio CollectionGroup `yaml:"audio"`
|
|
PCI CollectionGroup `yaml:"pci_devices"`
|
|
USB CollectionGroup `yaml:"usb_devices"`
|
|
}
|
|
|
|
type CollectionGroup struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
}
|
|
|
|
type RawInfoConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Include []string `yaml:"include"`
|
|
MaxSizeKB int `yaml:"max_size_kb"`
|
|
}
|
|
|
|
// FinalPayload est la structure JSON envoyée au backend
|
|
type FinalPayload struct {
|
|
DeviceIdentifier string `json:"device_identifier"`
|
|
BenchScriptVersion string `json:"bench_script_version"`
|
|
BenchClientVersion string `json:"bench_client_version"`
|
|
Hardware Hardware `json:"hardware"`
|
|
Results Results `json:"results"`
|
|
RawInfo map[string]string `json:"raw_info,omitempty"`
|
|
}
|
|
|
|
type Hardware struct {
|
|
CPU CPUDetails `json:"cpu"`
|
|
RAM RAMDetails `json:"ram"`
|
|
GPU GPUDetails `json:"gpu"`
|
|
Storage StorageDetails `json:"storage"`
|
|
PCIDevices []PCIInfo `json:"pci_devices"`
|
|
USBDevices []USBInfo `json:"usb_devices"`
|
|
NetworkShares []NetworkShare `json:"network_shares"`
|
|
Network NetworkDetails `json:"network"`
|
|
Motherboard MotherboardInfo `json:"motherboard"`
|
|
OS OSInfo `json:"os"`
|
|
Sensors SensorInfo `json:"sensors"`
|
|
Audio AudioInfo `json:"audio"`
|
|
RawInfo map[string]string `json:"raw_info"`
|
|
}
|
|
|
|
type CPUDetails struct {
|
|
Vendor string `json:"vendor"`
|
|
Model string `json:"model"`
|
|
Cores int `json:"cores"`
|
|
Threads int `json:"threads"`
|
|
BaseFreqGHz float64 `json:"base_freq_ghz"`
|
|
MaxFreqGHz float64 `json:"max_freq_ghz"`
|
|
CacheL1KB int `json:"cache_l1_kb"`
|
|
CacheL2KB int `json:"cache_l2_kb"`
|
|
CacheL3KB int `json:"cache_l3_kb"`
|
|
Flags []string `json:"flags"`
|
|
Microarchitecture string `json:"microarchitecture"`
|
|
TDPW *float64 `json:"tdp_w"`
|
|
}
|
|
|
|
type RAMDetails struct {
|
|
TotalMB int `json:"total_mb"`
|
|
UsedMB int `json:"used_mb"`
|
|
FreeMB int `json:"free_mb"`
|
|
SharedMB int `json:"shared_mb"`
|
|
SlotsTotal int `json:"slots_total"`
|
|
SlotsUsed int `json:"slots_used"`
|
|
MaxCapacityMB int `json:"max_capacity_mb"`
|
|
ECC bool `json:"ecc"`
|
|
Layout []MemoryModule `json:"layout"`
|
|
}
|
|
|
|
type MemoryModule struct {
|
|
Slot string `json:"slot"`
|
|
SizeMB int `json:"size_mb"`
|
|
Type string `json:"type"`
|
|
SpeedMHz int `json:"speed_mhz"`
|
|
ConfiguredMHz int `json:"configured_speed_mhz"`
|
|
FormFactor string `json:"form_factor"`
|
|
TypeDetail string `json:"type_detail"`
|
|
Rank string `json:"rank"`
|
|
Manufacturer string `json:"manufacturer"`
|
|
PartNumber string `json:"part_number"`
|
|
}
|
|
|
|
type GPUDetails struct {
|
|
Vendor string `json:"vendor"`
|
|
Model string `json:"model"`
|
|
DriverVersion string `json:"driver_version"`
|
|
MemoryDedicatedMB *int `json:"memory_dedicated_mb"`
|
|
MemorySharedMB *int `json:"memory_shared_mb"`
|
|
APISupport []string `json:"api_support"`
|
|
}
|
|
|
|
type StorageDetails struct {
|
|
Devices []StorageDevice `json:"devices"`
|
|
Partitions []PartitionInfo `json:"partitions"`
|
|
}
|
|
|
|
type StorageDevice struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Interface string `json:"interface"`
|
|
CapacityGB float64 `json:"capacity_gb"`
|
|
Vendor string `json:"vendor"`
|
|
Model string `json:"model"`
|
|
Serial string `json:"serial"`
|
|
SmartHealth string `json:"smart_health"`
|
|
Temperature int `json:"temperature_c"`
|
|
}
|
|
|
|
type PartitionInfo struct {
|
|
Name string `json:"name"`
|
|
MountPoint string `json:"mount_point"`
|
|
FSType string `json:"fs_type"`
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsedGB *float64 `json:"used_gb"`
|
|
FreeGB *float64 `json:"free_gb"`
|
|
}
|
|
|
|
type PCIInfo struct {
|
|
Slot string `json:"slot"`
|
|
Class string `json:"class"`
|
|
Vendor string `json:"vendor"`
|
|
Device string `json:"device"`
|
|
}
|
|
|
|
type USBInfo struct {
|
|
Bus string `json:"bus"`
|
|
Device string `json:"device"`
|
|
VendorID string `json:"vendor_id"`
|
|
ProductID string `json:"product_id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type NetworkShare struct {
|
|
Protocol string `json:"protocol"`
|
|
Source string `json:"source"`
|
|
MountPoint string `json:"mount_point"`
|
|
FSType string `json:"fs_type"`
|
|
Options string `json:"options"`
|
|
TotalGB *float64 `json:"total_gb"`
|
|
UsedGB *float64 `json:"used_gb"`
|
|
FreeGB *float64 `json:"free_gb"`
|
|
}
|
|
|
|
type NetworkDetails struct {
|
|
Interfaces []NetworkInterface `json:"interfaces"`
|
|
}
|
|
|
|
type NetworkInterface struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
MAC string `json:"mac"`
|
|
IP string `json:"ip"`
|
|
SpeedMbps *int `json:"speed_mbps"`
|
|
Driver string `json:"driver"`
|
|
SSID string `json:"ssid"`
|
|
WakeOnLAN bool `json:"wake_on_lan"`
|
|
}
|
|
|
|
type MotherboardInfo struct {
|
|
Vendor string `json:"vendor"`
|
|
Model string `json:"model"`
|
|
BIOSVendor string `json:"bios_vendor"`
|
|
BIOSVersion string `json:"bios_version"`
|
|
BIOSDate string `json:"bios_date"`
|
|
}
|
|
|
|
type OSInfo struct {
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
KernelVersion string `json:"kernel_version"`
|
|
Architecture string `json:"architecture"`
|
|
SessionType string `json:"session_type"`
|
|
DisplayServer string `json:"display_server"`
|
|
ScreenResolution string `json:"screen_resolution"`
|
|
LastBootTime string `json:"last_boot_time"`
|
|
UptimeSeconds float64 `json:"uptime_seconds"`
|
|
BatteryPercentage *int `json:"battery_percentage"`
|
|
BatteryStatus string `json:"battery_status"`
|
|
BatteryHealth string `json:"battery_health"`
|
|
Hostname string `json:"hostname"`
|
|
VirtualizationType string `json:"virtualization_type"`
|
|
DesktopEnvironment string `json:"desktop_environment"`
|
|
}
|
|
|
|
type SensorInfo struct {
|
|
CPUTempC *float64 `json:"cpu_temp_c"`
|
|
DiskTempsC map[string]float64 `json:"disk_temps_c"`
|
|
Sensors []SensorReading `json:"sensors"`
|
|
}
|
|
|
|
type SensorReading struct {
|
|
Chip string `json:"chip"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Value float64 `json:"value"`
|
|
Unit string `json:"unit"`
|
|
Alarm bool `json:"alarm"`
|
|
}
|
|
|
|
type AudioInfo struct {
|
|
Hardware AudioHardware `json:"hardware"`
|
|
Software AudioSoftware `json:"software"`
|
|
}
|
|
|
|
type AudioHardware struct {
|
|
PCIAudioDevices []string `json:"pci_audio_devices"`
|
|
AlsaPlayback string `json:"alsa_playback"`
|
|
AlsaCapture string `json:"alsa_capture"`
|
|
PCMDevices []string `json:"pcm_devices"`
|
|
}
|
|
|
|
type AudioSoftware struct {
|
|
Backend string `json:"backend"`
|
|
ServerName string `json:"server_name"`
|
|
ServerVersion string `json:"server_version"`
|
|
DefaultSink *string `json:"default_sink"`
|
|
DefaultSource *string `json:"default_source"`
|
|
Sinks []string `json:"sinks"`
|
|
Sources []string `json:"sources"`
|
|
}
|
|
|
|
type Results struct {
|
|
CPU CPUResult `json:"cpu"`
|
|
Memory MemResult `json:"memory"`
|
|
Disk DiskResult `json:"disk"`
|
|
Network NetResult `json:"network"`
|
|
GPU GPUResult `json:"gpu"`
|
|
GlobalScore float64 `json:"global_score"`
|
|
}
|
|
|
|
type CPUResult struct {
|
|
EventsPerSec float64 `json:"events_per_sec"`
|
|
EventsPerSecSingle float64 `json:"events_per_sec_single"`
|
|
EventsPerSecMulti float64 `json:"events_per_sec_multi"`
|
|
DurationSec float64 `json:"duration_s"`
|
|
Score float64 `json:"score"`
|
|
ScoreSingle float64 `json:"score_single"`
|
|
ScoreMulti float64 `json:"score_multi"`
|
|
}
|
|
|
|
type MemResult struct {
|
|
Throughput float64 `json:"throughput_mib_s"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type DiskResult struct {
|
|
ReadMBs float64 `json:"read_mb_s"`
|
|
WriteMBs float64 `json:"write_mb_s"`
|
|
IOPSRead float64 `json:"iops_read"`
|
|
IOPSWrite float64 `json:"iops_write"`
|
|
LatencyMs float64 `json:"latency_ms"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type DirectDiskMetrics struct {
|
|
WriteMBs float64
|
|
ReadMBs float64
|
|
}
|
|
|
|
type CacheSummary struct {
|
|
Sizes map[int]int
|
|
Counts map[int]int
|
|
}
|
|
|
|
type NetResult struct {
|
|
Upload float64 `json:"upload_mbps"`
|
|
Download float64 `json:"download_mbps"`
|
|
PingMs float64 `json:"ping_ms"`
|
|
JitterMs *float64 `json:"jitter_ms"`
|
|
PacketLossPct *float64 `json:"packet_loss_percent"`
|
|
Score float64 `json:"score"`
|
|
}
|
|
|
|
type GPUResult struct {
|
|
GLMark2Score *float64 `json:"glmark2_score"`
|
|
Score *float64 `json:"score"`
|
|
}
|
|
|
|
// ==========================================
|
|
// 2. GLOBALS & UTILS
|
|
// ==========================================
|
|
|
|
var (
|
|
cfg Config
|
|
debug = false
|
|
dryRun = false
|
|
)
|
|
|
|
const benchScriptVersion = "1.6.6"
|
|
const benchClientVersion = "1.0.2"
|
|
|
|
var (
|
|
sensorInitOnce sync.Once
|
|
sensorInitErr error
|
|
)
|
|
|
|
var (
|
|
sizeValueRegex = regexp.MustCompile(`(?i)^([\d\.]+)\s*([kmgtp]i?)?b?$`)
|
|
quotedStringRegexp = regexp.MustCompile(`"([^"]+)"`)
|
|
sysbenchEventsRegex = regexp.MustCompile(`events per second:\s+([\d\.]+)`)
|
|
sysbenchTimeRegex = regexp.MustCompile(`total time:\s+([\d\.]+)s`)
|
|
)
|
|
|
|
var (
|
|
debugLogFile *os.File
|
|
debugLogOnce sync.Once
|
|
debugLogErr error
|
|
debugLogMu sync.Mutex
|
|
)
|
|
|
|
const debugLogFilename = "debug.log"
|
|
|
|
func debugf(format string, args ...interface{}) {
|
|
if debug {
|
|
message := fmt.Sprintf("[DEBUG] "+format+"\n", args...)
|
|
fmt.Print(message)
|
|
if err := appendDebugLog(message); err != nil {
|
|
fmt.Printf("[WARN] impossible d'écrire dans %s: %v\n", debugLogFilename, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// safeRun exécute une commande avec timeout et gestion d'erreur
|
|
func safeRun(ctx context.Context, name string, args ...string) (string, error) {
|
|
if debug {
|
|
debugf("Exec: %s %v", name, args)
|
|
}
|
|
if dryRun {
|
|
return "DRY RUN OUTPUT", nil
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %s", err, stderr.String())
|
|
}
|
|
return stdout.String(), nil
|
|
}
|
|
|
|
func appendDebugLog(message string) error {
|
|
if err := ensureDebugLog(); err != nil {
|
|
return err
|
|
}
|
|
debugLogMu.Lock()
|
|
defer debugLogMu.Unlock()
|
|
_, err := debugLogFile.WriteString(message)
|
|
return err
|
|
}
|
|
|
|
func ensureDebugLog() error {
|
|
debugLogOnce.Do(func() {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
debugLogErr = err
|
|
return
|
|
}
|
|
path := filepath.Join(wd, debugLogFilename)
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o777)
|
|
if err != nil {
|
|
debugLogErr = err
|
|
return
|
|
}
|
|
if err := file.Chmod(0o777); err != nil {
|
|
// ignore error
|
|
}
|
|
debugLogFile = file
|
|
})
|
|
return debugLogErr
|
|
}
|
|
|
|
func closeDebugLog() {
|
|
if debugLogFile != nil {
|
|
_ = debugLogFile.Close()
|
|
}
|
|
}
|
|
|
|
func collectOSInfo(ctx context.Context) OSInfo {
|
|
info := OSInfo{}
|
|
|
|
if h, err := host.InfoWithContext(ctx); err == nil {
|
|
info.Name = h.Platform
|
|
info.Version = h.PlatformVersion
|
|
info.KernelVersion = h.KernelVersion
|
|
info.Architecture = h.KernelArch
|
|
info.Hostname = h.Hostname
|
|
info.VirtualizationType = h.VirtualizationSystem
|
|
if h.VirtualizationSystem != "" && h.VirtualizationRole != "" {
|
|
info.VirtualizationType = fmt.Sprintf("%s-%s", h.VirtualizationSystem, h.VirtualizationRole)
|
|
}
|
|
info.UptimeSeconds = float64(h.Uptime)
|
|
}
|
|
|
|
if host, err := os.Hostname(); err == nil {
|
|
info.Hostname = host
|
|
}
|
|
|
|
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "=", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
key := parts[0]
|
|
value := strings.Trim(parts[1], "\"")
|
|
switch key {
|
|
case "NAME":
|
|
info.Name = value
|
|
case "VERSION":
|
|
info.Version = value
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := safeRun(ctx, "uname", "-r"); err == nil {
|
|
info.KernelVersion = strings.TrimSpace(out)
|
|
}
|
|
if out, err := safeRun(ctx, "uname", "-m"); err == nil {
|
|
info.Architecture = strings.TrimSpace(out)
|
|
}
|
|
|
|
if vt, err := safeRun(ctx, "systemd-detect-virt"); err == nil {
|
|
vt = strings.TrimSpace(vt)
|
|
if vt == "none" {
|
|
info.VirtualizationType = "none"
|
|
} else if vt != "" {
|
|
info.VirtualizationType = vt
|
|
}
|
|
}
|
|
|
|
info.SessionType = os.Getenv("XDG_SESSION_TYPE")
|
|
if info.SessionType == "" {
|
|
if sessionID := os.Getenv("XDG_SESSION_ID"); sessionID != "" {
|
|
if out, err := safeRun(ctx, "loginctl", "show-session", sessionID, "-p", "Type"); err == nil {
|
|
info.SessionType = strings.TrimPrefix(strings.TrimSpace(out), "Type=")
|
|
}
|
|
}
|
|
}
|
|
|
|
info.DisplayServer = info.SessionType
|
|
info.DesktopEnvironment = os.Getenv("XDG_CURRENT_DESKTOP")
|
|
|
|
if out, err := safeRun(ctx, "xrandr"); err == nil {
|
|
if strings.Contains(out, "current") {
|
|
if parts := strings.Split(out, "\n")[0]; strings.Contains(parts, "current") {
|
|
fields := strings.Split(parts, ",")
|
|
for _, f := range fields {
|
|
f = strings.TrimSpace(f)
|
|
if strings.HasPrefix(f, "current") {
|
|
info.ScreenResolution = strings.TrimPrefix(f, "current ")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := safeRun(ctx, "xrandr", "--current"); err == nil && info.ScreenResolution == "" {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.Contains(line, "*") && strings.Contains(line, "+") {
|
|
info.ScreenResolution = strings.TrimSpace(strings.Fields(line)[0])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := safeRun(ctx, "who", "-b"); err == nil {
|
|
info.LastBootTime = strings.TrimSpace(strings.ReplaceAll(out, "system boot", ""))
|
|
}
|
|
|
|
if uptimeRaw, err := os.ReadFile("/proc/uptime"); err == nil {
|
|
if parts := strings.Fields(string(uptimeRaw)); len(parts) > 0 {
|
|
if val, err := strconv.ParseFloat(parts[0], 64); err == nil {
|
|
info.UptimeSeconds = val
|
|
}
|
|
}
|
|
}
|
|
|
|
info.BatteryPercentage = parseBatteryInt("/sys/class/power_supply/BAT0/capacity")
|
|
info.BatteryStatus = parseBatteryString("/sys/class/power_supply/BAT0/status")
|
|
info.BatteryHealth = parseBatteryString("/sys/class/power_supply/BAT0/health")
|
|
|
|
return info
|
|
}
|
|
|
|
func parseBatteryInt(path string) *int {
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
if val, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
|
return &val
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseBatteryString(path string) string {
|
|
if data, err := os.ReadFile(path); err == nil {
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func collectCPUInfo(ctx context.Context) CPUDetails {
|
|
info := CPUDetails{}
|
|
cpuInfos, err := cpu.InfoWithContext(ctx)
|
|
if err != nil || len(cpuInfos) == 0 {
|
|
return info
|
|
}
|
|
first := cpuInfos[0]
|
|
info.Vendor = first.VendorID
|
|
info.Model = first.ModelName
|
|
|
|
if cores, err := cpu.CountsWithContext(ctx, false); err == nil && cores > 0 {
|
|
info.Cores = cores
|
|
} else if int(first.Cores) > 0 {
|
|
info.Cores = int(first.Cores)
|
|
}
|
|
if info.Cores == 0 {
|
|
info.Cores = len(cpuInfos)
|
|
}
|
|
|
|
if threads, err := cpu.CountsWithContext(ctx, true); err == nil && threads > 0 {
|
|
info.Threads = threads
|
|
} else {
|
|
info.Threads = len(cpuInfos)
|
|
}
|
|
|
|
info.Flags = first.Flags
|
|
if lscpuOut, err := safeRun(ctx, "lscpu"); err == nil {
|
|
l1, l2, l3 := parseLscpuCacheSizes(lscpuOut)
|
|
if l1 > 0 {
|
|
info.CacheL1KB = l1
|
|
}
|
|
if l2 > 0 {
|
|
info.CacheL2KB = l2
|
|
}
|
|
if l3 > 0 {
|
|
info.CacheL3KB = l3
|
|
}
|
|
}
|
|
cacheSummary := collectCacheSizes(ctx, info.Cores)
|
|
if info.CacheL1KB == 0 && cacheSummary.Sizes[1] > 0 {
|
|
info.CacheL1KB = cacheSummary.Sizes[1]
|
|
}
|
|
if info.CacheL2KB == 0 && cacheSummary.Sizes[2] > 0 {
|
|
info.CacheL2KB = cacheSummary.Sizes[2]
|
|
}
|
|
if info.CacheL3KB == 0 && cacheSummary.Sizes[3] > 0 {
|
|
info.CacheL3KB = cacheSummary.Sizes[3]
|
|
}
|
|
applyCpuidCacheInfo(&info, cacheSummary)
|
|
info.Microarchitecture = detectMicroarchitecture(first.ModelName, first.Family)
|
|
info.BaseFreqGHz, info.MaxFreqGHz = determineCPUFreqs(ctx, first)
|
|
return info
|
|
}
|
|
|
|
func collectCacheSizes(ctx context.Context, physicalCores int) CacheSummary {
|
|
cacheDir := "/sys/devices/system/cpu/cpu0/cache"
|
|
entries, err := os.ReadDir(cacheDir)
|
|
summary := CacheSummary{
|
|
Sizes: map[int]int{},
|
|
Counts: map[int]int{},
|
|
}
|
|
if err != nil {
|
|
return summary
|
|
}
|
|
for _, entry := range entries {
|
|
if !strings.HasPrefix(entry.Name(), "index") {
|
|
continue
|
|
}
|
|
levelPath := filepath.Join(cacheDir, entry.Name(), "level")
|
|
level := readIntFromFile(levelPath)
|
|
if level <= 0 {
|
|
continue
|
|
}
|
|
sizePath := filepath.Join(cacheDir, entry.Name(), "size")
|
|
sizeKB := parseCacheSize(readStringFromFile(sizePath))
|
|
if sizeKB > 0 {
|
|
summary.Sizes[level] += sizeKB
|
|
summary.Counts[level]++
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func applyCpuidCacheInfo(info *CPUDetails, summary CacheSummary) {
|
|
cache := cpuid.CPU.Cache
|
|
if cache.L1I < 0 && cache.L1D < 0 && cache.L2 < 0 && cache.L3 < 0 {
|
|
return
|
|
}
|
|
cores := info.Cores
|
|
if cores <= 0 {
|
|
cores = cpuid.CPU.PhysicalCores
|
|
if cores <= 0 {
|
|
cores = 1
|
|
}
|
|
}
|
|
if summary.Counts == nil {
|
|
summary.Counts = map[int]int{}
|
|
}
|
|
l1Bytes := 0
|
|
if cache.L1I > 0 {
|
|
l1Bytes += cache.L1I
|
|
}
|
|
if cache.L1D > 0 {
|
|
l1Bytes += cache.L1D
|
|
}
|
|
if l1Bytes > 0 && info.CacheL1KB == 0 {
|
|
info.CacheL1KB = int(int64(l1Bytes) * int64(cores) / 1024)
|
|
}
|
|
if cache.L2 > 0 && info.CacheL2KB == 0 {
|
|
l2Slices := summary.Counts[2]
|
|
if l2Slices <= 0 {
|
|
l2Slices = cores
|
|
}
|
|
info.CacheL2KB = maxInt(info.CacheL2KB, int((int64(cache.L2)*int64(l2Slices))/1024))
|
|
}
|
|
if cache.L3 > 0 && info.CacheL3KB == 0 {
|
|
l3Slices := summary.Counts[3]
|
|
if l3Slices <= 0 {
|
|
l3Slices = 1
|
|
}
|
|
info.CacheL3KB = maxInt(info.CacheL3KB, int((int64(cache.L3)*int64(l3Slices))/1024))
|
|
}
|
|
}
|
|
|
|
func parseLscpuCacheSizes(output string) (l1, l2, l3 int) {
|
|
l1d := parseCacheValue(output, "L1d")
|
|
l1i := parseCacheValue(output, "L1i")
|
|
l2 = parseCacheValue(output, "L2")
|
|
l3 = parseCacheValue(output, "L3")
|
|
if l1d > 0 || l1i > 0 {
|
|
l1 = l1d + l1i
|
|
}
|
|
return
|
|
}
|
|
|
|
func parseCacheValue(output, label string) int {
|
|
re := regexp.MustCompile(fmt.Sprintf(`(?m)^Cache\s+%s\s*:\s*([\d\.]+)\s*([KMGT]iB?|B|K|M|G)`, regexp.QuoteMeta(label)))
|
|
matches := re.FindStringSubmatch(output)
|
|
if len(matches) != 3 && len(matches) != 4 {
|
|
return 0
|
|
}
|
|
value := matches[1]
|
|
unit := matches[len(matches)-1]
|
|
num, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
switch strings.ToUpper(unit) {
|
|
case "KIB", "KB", "K":
|
|
return int(num)
|
|
case "MIB", "MB", "M":
|
|
return int(num * 1024)
|
|
case "GIB", "GB", "G":
|
|
return int(num * 1024 * 1024)
|
|
default:
|
|
return int(num)
|
|
}
|
|
}
|
|
|
|
var (
|
|
dmidecodeCurrentSpeedRE = regexp.MustCompile(`(?m)^Current Speed:\s+([\d\.]+)\s+MHz`)
|
|
dmidecodeMaxSpeedRE = regexp.MustCompile(`(?m)^Max Speed:\s+([\d\.]+)\s+MHz`)
|
|
microarchitectureMap = map[string]string{
|
|
"25": "Zen",
|
|
}
|
|
)
|
|
|
|
func detectMicroarchitecture(modelName string, family string) string {
|
|
if strings.Contains(strings.ToLower(modelName), "ryzen") {
|
|
return "Zen"
|
|
}
|
|
if value, ok := microarchitectureMap[family]; ok {
|
|
return value
|
|
}
|
|
if family != "" {
|
|
return family
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func determineCPUFreqs(ctx context.Context, info cpu.InfoStat) (float64, float64) {
|
|
base := info.Mhz / 1000
|
|
max := info.Mhz / 1000
|
|
if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"); val > 0 {
|
|
max = float64(val) / 1_000_000
|
|
}
|
|
if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq"); val > 0 && base == 0 {
|
|
base = float64(val) / 1_000_000
|
|
}
|
|
if out, err := safeRun(ctx, "dmidecode", "-t", "4"); err == nil {
|
|
if curSpeed := parseDMIDecodeSpeed(out, dmidecodeCurrentSpeedRE); curSpeed > 0 {
|
|
base = curSpeed / 1000
|
|
}
|
|
if maxSpeed := parseDMIDecodeSpeed(out, dmidecodeMaxSpeedRE); maxSpeed > 0 {
|
|
max = maxSpeed / 1000
|
|
}
|
|
}
|
|
return round2(base), round2(max)
|
|
}
|
|
|
|
func parseDMIDecodeSpeed(output string, re *regexp.Regexp) float64 {
|
|
if match := re.FindStringSubmatch(output); len(match) == 2 {
|
|
if val, err := strconv.ParseFloat(match[1], 64); err == nil {
|
|
return val
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func readFrequencyFromFile(path string) int64 {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
val := strings.TrimSpace(string(data))
|
|
if parsed, err := strconv.ParseInt(val, 10, 64); err == nil {
|
|
return parsed
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseCacheSize(value string) int {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
units := []struct {
|
|
suffix string
|
|
multiplier int
|
|
}{
|
|
{"KIB", 1},
|
|
{"KB", 1},
|
|
{"K", 1},
|
|
{"MIB", 1024},
|
|
{"MB", 1024},
|
|
{"M", 1024},
|
|
{"GIB", 1024 * 1024},
|
|
{"GB", 1024 * 1024},
|
|
{"G", 1024 * 1024},
|
|
}
|
|
upper := strings.ToUpper(value)
|
|
multiplier := 1
|
|
for _, unit := range units {
|
|
if strings.HasSuffix(upper, unit.suffix) {
|
|
value = strings.TrimSpace(value[:len(value)-len(unit.suffix)])
|
|
multiplier = unit.multiplier
|
|
break
|
|
}
|
|
}
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
num, err := strconv.ParseFloat(value, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int(num * float64(multiplier))
|
|
}
|
|
|
|
func readIntFromFile(path string) int {
|
|
value := readStringFromFile(path)
|
|
if value == "" {
|
|
return 0
|
|
}
|
|
if parsed, err := strconv.Atoi(value); err == nil {
|
|
return parsed
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func readStringFromFile(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func collectRAMInfo(ctx context.Context) RAMDetails {
|
|
res := RAMDetails{}
|
|
if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil {
|
|
res.TotalMB = int(vm.Total / (1024 * 1024))
|
|
res.UsedMB = int(vm.Used / (1024 * 1024))
|
|
res.FreeMB = int(vm.Available / (1024 * 1024))
|
|
res.SharedMB = int(vm.Shared / (1024 * 1024))
|
|
}
|
|
|
|
slotsTotal, slotsUsed, maxCapacity, ecc, layout := collectSMBIOSMemory()
|
|
if len(layout) > 0 {
|
|
correctSlots := len(layout)
|
|
if slotsTotal == 0 || slotsTotal < correctSlots || slotsTotal > correctSlots*4 {
|
|
debugf("Correction RAM slots_total=%d => %d (layout=%d)", slotsTotal, correctSlots, len(layout))
|
|
slotsTotal = correctSlots
|
|
}
|
|
if slotsUsed == 0 {
|
|
slotsUsed = len(layout)
|
|
}
|
|
}
|
|
if slotsTotal > 0 {
|
|
res.SlotsTotal = slotsTotal
|
|
}
|
|
if slotsUsed > 0 {
|
|
res.SlotsUsed = slotsUsed
|
|
}
|
|
if maxCapacity > 0 {
|
|
res.MaxCapacityMB = maxCapacity
|
|
}
|
|
res.ECC = ecc
|
|
res.Layout = layout
|
|
|
|
if res.MaxCapacityMB == 0 {
|
|
debugf("RAM fallback max capacity: SMBIOS=0 => utiliser total=%dMB", res.TotalMB)
|
|
res.MaxCapacityMB = res.TotalMB
|
|
}
|
|
if res.SlotsTotal == 0 && len(layout) > 0 {
|
|
debugf("RAM fallback slots total: SMBIOS=0 => %d modules détectés", len(layout))
|
|
res.SlotsTotal = len(layout)
|
|
}
|
|
if res.SlotsUsed == 0 && len(layout) > 0 {
|
|
debugf("RAM fallback slots used: SMBIOS=0 => %d modules détectés", len(layout))
|
|
res.SlotsUsed = len(layout)
|
|
}
|
|
debugf("RAM result: max=%dMB slotsTotal=%d slotsUsed=%d layout=%d modules", res.MaxCapacityMB, res.SlotsTotal, res.SlotsUsed, len(layout))
|
|
return res
|
|
}
|
|
|
|
const smbiosTableTypePhysicalMemoryArray smbios.TableType = 16
|
|
|
|
func collectSMBIOSMemory() (slotsTotal, slotsUsed, maxCapacityMB int, ecc bool, layout []MemoryModule) {
|
|
info, err := smbios.FromSysfs()
|
|
if err != nil {
|
|
debugf("Lecture SMBIOS impossible : %v", err)
|
|
return
|
|
}
|
|
debugf("SMBIOS détecté : %s", info)
|
|
|
|
slotsTotal, ecc, maxCapacityMB = parseSMBIOSPhysicalMemoryArray(info)
|
|
modules, err := info.GetMemoryDevices()
|
|
if err != nil {
|
|
debugf("Impossible de lire les modules mémoire SMBIOS : %v", err)
|
|
return
|
|
}
|
|
|
|
for _, md := range modules {
|
|
module := MemoryModule{
|
|
Slot: md.DeviceLocator,
|
|
Type: md.Type.String(),
|
|
TypeDetail: md.TypeDetail.String(),
|
|
FormFactor: md.FormFactor.String(),
|
|
Manufacturer: md.Manufacturer,
|
|
PartNumber: md.PartNumber,
|
|
SpeedMHz: int(md.Speed),
|
|
ConfiguredMHz: int(md.ConfiguredSpeed),
|
|
Rank: memoryRank(md.Attributes),
|
|
}
|
|
if size := md.GetSizeBytes(); size > 0 {
|
|
module.SizeMB = int(size / (1024 * 1024))
|
|
slotsUsed++
|
|
}
|
|
debugf("Module SMBIOS détecté : %s %+v", module.Slot, module)
|
|
layout = append(layout, module)
|
|
}
|
|
|
|
if slotsTotal == 0 {
|
|
slotsTotal = len(layout)
|
|
}
|
|
if slotsUsed == 0 {
|
|
slotsUsed = len(layout)
|
|
}
|
|
debugf("Mémoire SMBIOS : slots_total=%d slots_used=%d ecc=%t max=%dMB", slotsTotal, slotsUsed, ecc, maxCapacityMB)
|
|
return
|
|
}
|
|
|
|
func parseSMBIOSPhysicalMemoryArray(info *smbios.Info) (slotsTotal int, ecc bool, maxCapacityMB int) {
|
|
for _, table := range info.Tables {
|
|
if table.Type != smbiosTableTypePhysicalMemoryArray {
|
|
continue
|
|
}
|
|
if val, err := table.GetByteAt(13); err == nil && val > 0 {
|
|
debugf("[SMBIOS RAW] Handle=%s slots_byte=0x%X (%d)", table.Handle, val, val)
|
|
slotsTotal = int(val)
|
|
}
|
|
if val, err := table.GetByteAt(6); err == nil {
|
|
ecc = val != 0 && val != 3
|
|
}
|
|
if dw, err := table.GetDWordAt(7); err == nil {
|
|
if dw == 0x80000000 && table.Len() >= 0x10 {
|
|
if ext, err := table.GetDWordAt(12); err == nil && ext > 0 {
|
|
maxCapacityMB = int(ext / (1024 * 1024))
|
|
}
|
|
} else if dw > 0 {
|
|
maxCapacityMB = int(dw / 1024)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
return
|
|
}
|
|
|
|
func memoryRank(attrs uint8) string {
|
|
rank := attrs & 0x0f
|
|
if rank == 0 {
|
|
return "Unknown"
|
|
}
|
|
return strconv.Itoa(int(rank))
|
|
}
|
|
|
|
func collectGPUInfo(ctx context.Context) GPUDetails {
|
|
info := GPUDetails{}
|
|
if out, err := safeRun(ctx, "lspci"); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.Contains(line, "VGA compatible controller") || strings.Contains(line, "3D controller") {
|
|
parts := strings.SplitN(line, ":", 3)
|
|
if len(parts) >= 3 {
|
|
desc := strings.TrimSpace(parts[2])
|
|
info.Model = desc
|
|
info.Vendor = strings.Split(desc, " ")[0]
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := safeRun(ctx, "nvidia-smi", "--query-gpu=name,driver_version,memory.total", "--format=csv,noheader,nounits"); err == nil {
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
if len(lines) > 0 {
|
|
fields := strings.Split(lines[0], ",")
|
|
if len(fields) >= 3 {
|
|
info.Model = strings.TrimSpace(fields[0])
|
|
info.DriverVersion = strings.TrimSpace(fields[1])
|
|
if v := atoi(strings.TrimSpace(fields[2])); v > 0 {
|
|
info.MemoryDedicatedMB = &v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return info
|
|
}
|
|
|
|
func collectStorageInfo(ctx context.Context) StorageDetails {
|
|
detail := StorageDetails{}
|
|
partitions, err := disk.PartitionsWithContext(ctx, true)
|
|
if err != nil {
|
|
return detail
|
|
}
|
|
|
|
deviceMap := map[string]*StorageDevice{}
|
|
for _, part := range partitions {
|
|
if shouldSkipPartition(part) {
|
|
continue
|
|
}
|
|
var usage *disk.UsageStat
|
|
if part.Mountpoint != "" {
|
|
if u, err := disk.UsageWithContext(ctx, part.Mountpoint); err == nil {
|
|
usage = u
|
|
}
|
|
}
|
|
|
|
record := PartitionInfo{
|
|
Name: part.Device,
|
|
MountPoint: part.Mountpoint,
|
|
FSType: part.Fstype,
|
|
TotalGB: bytesToGB(int64(usageTotal(usage))),
|
|
}
|
|
if usage != nil {
|
|
record.UsedGB = floatPtr(bytesToGB(int64(usage.Used)))
|
|
record.FreeGB = floatPtr(bytesToGB(int64(usage.Free)))
|
|
}
|
|
detail.Partitions = append(detail.Partitions, record)
|
|
|
|
devName := deriveDeviceRoot(part.Device)
|
|
if !isPhysicalDevice(devName) {
|
|
continue
|
|
}
|
|
if _, ok := deviceMap[devName]; !ok {
|
|
deviceMap[devName] = &StorageDevice{
|
|
Name: devName,
|
|
Type: guessDiskType(devName),
|
|
}
|
|
}
|
|
if usage != nil {
|
|
deviceMap[devName].CapacityGB = bytesToGB(int64(usage.Total))
|
|
}
|
|
}
|
|
|
|
populateDevicesFromLsblk(ctx, deviceMap)
|
|
|
|
for _, dev := range deviceMap {
|
|
annotateSmartDevice(dev)
|
|
}
|
|
|
|
if len(deviceMap) > 0 {
|
|
names := make([]string, 0, len(deviceMap))
|
|
for name := range deviceMap {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
detail.Devices = append(detail.Devices, *deviceMap[name])
|
|
}
|
|
}
|
|
|
|
return detail
|
|
}
|
|
|
|
func usageTotal(stats *disk.UsageStat) int64 {
|
|
if stats == nil {
|
|
return 0
|
|
}
|
|
return int64(stats.Total)
|
|
}
|
|
|
|
func guessDiskType(device string) string {
|
|
lower := strings.ToLower(device)
|
|
switch {
|
|
case strings.Contains(lower, "nvme"):
|
|
return "ssd"
|
|
case strings.Contains(lower, "sd"):
|
|
return "ssd"
|
|
case strings.Contains(lower, "hd"):
|
|
return "hdd"
|
|
default:
|
|
return "disk"
|
|
}
|
|
}
|
|
|
|
func populateDevicesFromLsblk(ctx context.Context, deviceMap map[string]*StorageDevice) {
|
|
if len(deviceMap) == 0 {
|
|
// still want to capture drives even if no partitions were added
|
|
}
|
|
raw, err := safeRun(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,TRAN,VENDOR,MODEL,SERIAL,SIZE")
|
|
if err != nil {
|
|
debugf("lsblk JSON échoue: %v", err)
|
|
return
|
|
}
|
|
var output lsblkOutput
|
|
if err := json.Unmarshal([]byte(raw), &output); err != nil {
|
|
debugf("lsblk JSON parse échoue: %v", err)
|
|
return
|
|
}
|
|
for _, entry := range output.BlockDevices {
|
|
addLsblkDevice(entry, deviceMap)
|
|
}
|
|
}
|
|
|
|
func addLsblkDevice(entry lsblkDevice, deviceMap map[string]*StorageDevice) {
|
|
if entry.Type == "" {
|
|
for _, child := range entry.Children {
|
|
addLsblkDevice(child, deviceMap)
|
|
}
|
|
return
|
|
}
|
|
if entry.Type == "disk" {
|
|
path := "/dev/" + entry.Name
|
|
if !isPhysicalDevice(path) || isIgnoredDevice(path) {
|
|
return
|
|
}
|
|
dev := deviceMap[path]
|
|
if dev == nil {
|
|
dev = &StorageDevice{Name: path}
|
|
deviceMap[path] = dev
|
|
}
|
|
if entry.Tran != "" {
|
|
dev.Interface = strings.ToLower(entry.Tran)
|
|
}
|
|
if entry.Vendor != "" {
|
|
dev.Vendor = entry.Vendor
|
|
}
|
|
if entry.Model != "" {
|
|
dev.Model = entry.Model
|
|
}
|
|
if entry.Serial != "" {
|
|
dev.Serial = entry.Serial
|
|
}
|
|
if sizeBytes, err := entry.Size.Int64(); err == nil && sizeBytes > 0 {
|
|
dev.CapacityGB = float64(sizeBytes) / (1024 * 1024 * 1024)
|
|
}
|
|
}
|
|
for _, child := range entry.Children {
|
|
addLsblkDevice(child, deviceMap)
|
|
}
|
|
}
|
|
|
|
type lsblkOutput struct {
|
|
BlockDevices []lsblkDevice `json:"blockdevices"`
|
|
}
|
|
|
|
type lsblkDevice struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Tran string `json:"tran"`
|
|
Vendor string `json:"vendor"`
|
|
Model string `json:"model"`
|
|
Serial string `json:"serial"`
|
|
Size json.Number `json:"size"`
|
|
Children []lsblkDevice `json:"children"`
|
|
}
|
|
|
|
func deriveDeviceRoot(device string) string {
|
|
if device == "" {
|
|
return device
|
|
}
|
|
root := device
|
|
if strings.HasPrefix(device, "/dev/") {
|
|
if idx := strings.LastIndex(device, "p"); idx > strings.LastIndex(device, "/") {
|
|
if _, err := strconv.Atoi(device[idx+1:]); err == nil {
|
|
candidate := device[:idx]
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
candidate := strings.TrimRightFunc(device, unicode.IsDigit)
|
|
if candidate != "" && candidate != device {
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func shouldSkipPartition(part disk.PartitionStat) bool {
|
|
if part.Device == "" {
|
|
return true
|
|
}
|
|
lowerFs := strings.ToLower(part.Fstype)
|
|
if lowerFs != "" {
|
|
if _, ok := pseudoFSTypes[lowerFs]; ok {
|
|
return true
|
|
}
|
|
}
|
|
if strings.Contains(lowerFs, "nsfs") {
|
|
return true
|
|
}
|
|
if part.Mountpoint != "" {
|
|
switch {
|
|
case strings.HasPrefix(part.Mountpoint, "/run/docker"),
|
|
strings.HasPrefix(part.Mountpoint, "/sys"),
|
|
strings.HasPrefix(part.Mountpoint, "/proc"),
|
|
strings.HasPrefix(part.Mountpoint, "/dev"):
|
|
return true
|
|
}
|
|
}
|
|
if isIgnoredDevice(part.Device) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isPhysicalDevice(device string) bool {
|
|
device = strings.TrimSpace(device)
|
|
if device == "" || !strings.HasPrefix(device, "/dev/") {
|
|
return false
|
|
}
|
|
normalized := strings.TrimPrefix(device, "/dev/")
|
|
normalized = strings.TrimSuffix(normalized, "p")
|
|
lower := strings.ToLower(normalized)
|
|
physicalPrefixes := []string{"sd", "hd", "vd", "nvme", "mmcblk", "xvd", "mapper", "dm-"}
|
|
for _, prefix := range physicalPrefixes {
|
|
if strings.HasPrefix(lower, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isIgnoredDevice(device string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(device))
|
|
ignored := []string{"/dev/loop", "/dev/ram", "/dev/fd", "/dev/sr", "/dev/mapper", "/dev/nbd", "none"}
|
|
for _, prefix := range ignored {
|
|
if strings.HasPrefix(lower, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type mountEntry struct {
|
|
MountPoint string
|
|
FSName string
|
|
Source string
|
|
}
|
|
|
|
func isPseudoMountType(name string) bool {
|
|
_, ok := mountPseudoTypes[strings.ToLower(name)]
|
|
return ok
|
|
}
|
|
|
|
func chooseBenchDir() (string, mountEntry) {
|
|
mounts := parseProcMounts()
|
|
candidates := []string{cfg.Runtime.TempDir, "/var/tmp", os.TempDir()}
|
|
var fallback mountEntry
|
|
for _, base := range candidates {
|
|
if base == "" {
|
|
continue
|
|
}
|
|
absPath, err := filepath.Abs(base)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(absPath, 0o755); err != nil {
|
|
continue
|
|
}
|
|
entry := findMountForPath(absPath, mounts)
|
|
if entry.MountPoint != "" {
|
|
if !isPseudoMountType(entry.FSName) && isPhysicalMount(entry) {
|
|
bench := filepath.Join(absPath, "bench-client")
|
|
if err := os.MkdirAll(bench, 0o755); err != nil {
|
|
continue
|
|
}
|
|
return bench, entry
|
|
}
|
|
if fallback.MountPoint == "" {
|
|
fallback = entry
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, entry := range mounts {
|
|
if entry.MountPoint == "" || isPseudoMountType(entry.FSName) {
|
|
continue
|
|
}
|
|
if !isPhysicalMount(entry) {
|
|
if fallback.MountPoint == "" {
|
|
fallback = entry
|
|
}
|
|
continue
|
|
}
|
|
bench := filepath.Join(entry.MountPoint, "bench-client")
|
|
if err := os.MkdirAll(bench, 0o755); err != nil {
|
|
continue
|
|
}
|
|
return bench, entry
|
|
}
|
|
|
|
fallbackDir := filepath.Join(os.TempDir(), "bench-client")
|
|
_ = os.MkdirAll(fallbackDir, 0o755)
|
|
return fallbackDir, fallback
|
|
}
|
|
|
|
func parseProcMounts() []mountEntry {
|
|
data, err := os.ReadFile("/proc/mounts")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
entries := make([]mountEntry, 0, len(lines))
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
entries = append(entries, mountEntry{
|
|
Source: decodeMountField(fields[0]),
|
|
MountPoint: decodeMountField(fields[1]),
|
|
FSName: fields[2],
|
|
})
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return len(entries[i].MountPoint) > len(entries[j].MountPoint)
|
|
})
|
|
return entries
|
|
}
|
|
|
|
func findMountForPath(path string, entries []mountEntry) mountEntry {
|
|
path = filepath.Clean(path)
|
|
var best mountEntry
|
|
bestLen := -1
|
|
for _, entry := range entries {
|
|
mp := entry.MountPoint
|
|
if mp == "" {
|
|
continue
|
|
}
|
|
if mp != "/" && !strings.HasPrefix(path, mp) {
|
|
continue
|
|
}
|
|
if len(mp) > bestLen {
|
|
best = entry
|
|
bestLen = len(mp)
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func isPhysicalMount(entry mountEntry) bool {
|
|
return entry.Source != "" && isPhysicalDevice(entry.Source)
|
|
}
|
|
|
|
func decodeMountField(field string) string {
|
|
return strings.ReplaceAll(field, "\\040", " ")
|
|
}
|
|
|
|
var pseudoFSTypes = map[string]struct{}{
|
|
"tmpfs": {},
|
|
"sysfs": {},
|
|
"proc": {},
|
|
"devtmpfs": {},
|
|
"cgroup": {},
|
|
"cgroup2": {},
|
|
"devpts": {},
|
|
"securityfs": {},
|
|
"squashfs": {},
|
|
"overlay": {},
|
|
}
|
|
|
|
var mountPseudoTypes = map[string]struct{}{
|
|
"tmpfs": {},
|
|
"overlay": {},
|
|
"squashfs": {},
|
|
"ramfs": {},
|
|
"devtmpfs": {},
|
|
"cgroup": {},
|
|
"cgroup2": {},
|
|
"devpts": {},
|
|
"securityfs": {},
|
|
"tracefs": {},
|
|
"fusectl": {},
|
|
"debugfs": {},
|
|
}
|
|
|
|
func annotateSmartDevice(dev *StorageDevice) {
|
|
if dev == nil || dev.Name == "" {
|
|
return
|
|
}
|
|
handle, err := smartgo.Open(dev.Name)
|
|
if err != nil {
|
|
debugf("SMART open %s impossible: %v", dev.Name, err)
|
|
dev.SmartHealth = "UNAVAILABLE"
|
|
return
|
|
}
|
|
defer handle.Close()
|
|
|
|
health := "UNKNOWN"
|
|
if attrs, err := handle.ReadGenericAttributes(); err == nil {
|
|
health = "PASSED"
|
|
if attrs.Temperature > 0 {
|
|
dev.Temperature = int(attrs.Temperature)
|
|
}
|
|
} else {
|
|
debugf("SMART lecture générique %s échouée: %v", dev.Name, err)
|
|
health = fmt.Sprintf("FAILED: %v", err)
|
|
}
|
|
|
|
var vendor, model, serial string
|
|
|
|
switch specific := handle.(type) {
|
|
case *smartgo.SataDevice:
|
|
if id, err := specific.Identify(); err == nil {
|
|
model = strings.TrimSpace(id.ModelNumber())
|
|
serial = strings.TrimSpace(id.SerialNumber())
|
|
if vendor == "" {
|
|
vendor = guessVendorFromModel(model)
|
|
}
|
|
} else {
|
|
debugf("SMART Identify SATA %s échoué: %v", dev.Name, err)
|
|
}
|
|
case *smartgo.NVMeDevice:
|
|
if ctrl, _, err := specific.Identify(); err == nil {
|
|
model = strings.TrimSpace(ctrl.ModelNumber())
|
|
serial = strings.TrimSpace(ctrl.SerialNumber())
|
|
if vendor == "" {
|
|
vendor = guessVendorFromModel(model)
|
|
}
|
|
} else {
|
|
debugf("SMART Identify NVMe %s échoué: %v", dev.Name, err)
|
|
}
|
|
case *smartgo.ScsiDevice:
|
|
if inq, err := specific.Inquiry(); err == nil {
|
|
vendor = strings.TrimSpace(string(bytes.TrimSpace(inq.VendorIdent[:])))
|
|
model = strings.TrimSpace(string(bytes.TrimSpace(inq.ProductIdent[:])))
|
|
} else {
|
|
debugf("SMART Inquiry SCSI %s échouée: %v", dev.Name, err)
|
|
}
|
|
if serialNum, err := specific.SerialNumber(); err == nil && serialNum != "" {
|
|
serial = strings.TrimSpace(serialNum)
|
|
} else if err != nil {
|
|
debugf("SMART Serial SCSI %s échouée: %v", dev.Name, err)
|
|
}
|
|
default:
|
|
debugf("SMART type inconnu pour %s", dev.Name)
|
|
}
|
|
|
|
if vendor != "" && dev.Vendor == "" {
|
|
dev.Vendor = vendor
|
|
}
|
|
if model != "" {
|
|
dev.Model = model
|
|
}
|
|
if serial != "" {
|
|
dev.Serial = serial
|
|
}
|
|
dev.SmartHealth = health
|
|
}
|
|
|
|
func guessVendorFromModel(model string) string {
|
|
if model == "" {
|
|
return ""
|
|
}
|
|
fields := strings.Fields(model)
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
return fields[0]
|
|
}
|
|
|
|
func bytesToGB(value int64) float64 {
|
|
return float64(value) / (1024 * 1024 * 1024)
|
|
}
|
|
|
|
func collectPCIDevices(ctx context.Context) []PCIInfo {
|
|
devices := []PCIInfo{}
|
|
out, err := safeRun(ctx, "lspci", "-mm")
|
|
if err != nil {
|
|
return devices
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
slot := ""
|
|
rest := line
|
|
if idx := strings.Index(line, " "); idx >= 0 {
|
|
slot = line[:idx]
|
|
rest = line[idx+1:]
|
|
}
|
|
slot = strings.TrimSpace(slot)
|
|
if slot == "" {
|
|
continue
|
|
}
|
|
matches := quotedStringRegexp.FindAllString(rest, -1)
|
|
if len(matches) < 3 {
|
|
continue
|
|
}
|
|
class := strings.Trim(matches[0], `"`)
|
|
vendor := strings.Trim(matches[1], `"`)
|
|
device := strings.Trim(matches[2], `"`)
|
|
devices = append(devices, PCIInfo{
|
|
Slot: slot,
|
|
Class: class,
|
|
Vendor: vendor,
|
|
Device: device,
|
|
})
|
|
}
|
|
return devices
|
|
}
|
|
|
|
func collectUSBDevices(ctx context.Context) []USBInfo {
|
|
var devices []USBInfo
|
|
out, err := safeRun(ctx, "lsusb")
|
|
if err != nil {
|
|
return devices
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
bus := parts[1]
|
|
device := strings.TrimSuffix(parts[3], ":")
|
|
idParts := strings.Split(parts[5], ":")
|
|
vendorID := ""
|
|
productID := ""
|
|
if len(idParts) == 2 {
|
|
vendorID = idParts[0]
|
|
productID = idParts[1]
|
|
}
|
|
name := strings.Join(parts[6:], " ")
|
|
devices = append(devices, USBInfo{
|
|
Bus: bus,
|
|
Device: device,
|
|
VendorID: vendorID,
|
|
ProductID: productID,
|
|
Name: name,
|
|
})
|
|
}
|
|
return devices
|
|
}
|
|
|
|
func collectNetworkShares(ctx context.Context) []NetworkShare {
|
|
shares := []NetworkShare{}
|
|
data, err := os.ReadFile("/proc/mounts")
|
|
if err != nil {
|
|
return shares
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
if line = strings.TrimSpace(line); line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
fs := strings.ToLower(parts[2])
|
|
if !isNetworkFSType(fs) {
|
|
continue
|
|
}
|
|
share := NetworkShare{
|
|
Protocol: fs,
|
|
Source: parts[0],
|
|
MountPoint: parts[1],
|
|
FSType: fs,
|
|
Options: parts[3],
|
|
}
|
|
if sz, used, free := dfStats(ctx, parts[1]); sz > 0 {
|
|
share.TotalGB = floatPtr(sz)
|
|
if used != 0 {
|
|
share.UsedGB = floatPtr(used)
|
|
}
|
|
if free != 0 {
|
|
share.FreeGB = floatPtr(free)
|
|
}
|
|
}
|
|
shares = append(shares, share)
|
|
}
|
|
return shares
|
|
}
|
|
|
|
func isNetworkFSType(fs string) bool {
|
|
networkFS := []string{"nfs", "nfs4", "cifs", "smbfs", "fuse.cifs", "fuse.smbfs", "afp", "afpfs", "fuse.afpfs", "sshfs", "fuse.sshfs", "ftpfs", "curlftpfs", "fuse.ftpfs", "davfs", "davfs2", "fuse.webdavfs"}
|
|
for _, candidate := range networkFS {
|
|
if fs == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func dfStats(ctx context.Context, mountPoint string) (total, used, free float64) {
|
|
out, err := safeRun(ctx, "df", "-k", mountPoint)
|
|
if err != nil {
|
|
return 0, 0, 0
|
|
}
|
|
lines := strings.Split(string(out), "\n")
|
|
if len(lines) < 2 {
|
|
return 0, 0, 0
|
|
}
|
|
fields := strings.Fields(lines[1])
|
|
if len(fields) < 5 {
|
|
return 0, 0, 0
|
|
}
|
|
total = kbToGB(atoi(fields[1]))
|
|
used = kbToGB(atoi(fields[2]))
|
|
free = kbToGB(atoi(fields[3]))
|
|
return total, used, free
|
|
}
|
|
|
|
func kbToGB(value int) float64 {
|
|
return float64(value) / (1024 * 1024)
|
|
}
|
|
|
|
func floatPtr(v float64) *float64 {
|
|
return &v
|
|
}
|
|
|
|
func collectNetworkInfo(ctx context.Context) NetworkDetails {
|
|
details := NetworkDetails{}
|
|
ifaces, err := gopsnet.InterfacesWithContext(ctx)
|
|
if err != nil {
|
|
return details
|
|
}
|
|
for _, iface := range ifaces {
|
|
ifaceName := iface.Name
|
|
if isLoopbackInterface(iface) || isVirtualInterface(ifaceName) {
|
|
continue
|
|
}
|
|
netType := guessNetworkType(ifaceName)
|
|
if netType != "ethernet" && netType != "wifi" {
|
|
continue
|
|
}
|
|
n := NetworkInterface{
|
|
Name: ifaceName,
|
|
MAC: iface.HardwareAddr,
|
|
Type: netType,
|
|
}
|
|
for _, addr := range iface.Addrs {
|
|
ip := addr.Addr
|
|
if idx := strings.Index(ip, "/"); idx >= 0 {
|
|
ip = ip[:idx]
|
|
}
|
|
if ip == "" {
|
|
continue
|
|
}
|
|
n.IP = ip
|
|
break
|
|
}
|
|
speed, driver, wake := getInterfaceStats(ctx, ifaceName)
|
|
if speed != nil {
|
|
n.SpeedMbps = speed
|
|
}
|
|
n.Driver = driver
|
|
n.WakeOnLAN = wake
|
|
if netType == "wifi" {
|
|
if ssid := getWirelessSSID(ctx, ifaceName); ssid != "" {
|
|
n.SSID = ssid
|
|
}
|
|
}
|
|
details.Interfaces = append(details.Interfaces, n)
|
|
}
|
|
return details
|
|
}
|
|
|
|
func guessNetworkType(name string) string {
|
|
switch {
|
|
case strings.HasPrefix(name, "en"), strings.HasPrefix(name, "eth"):
|
|
return "ethernet"
|
|
case strings.HasPrefix(name, "wl"):
|
|
return "wifi"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func isLoopbackInterface(iface gopsnet.InterfaceStat) bool {
|
|
for _, flag := range iface.Flags {
|
|
if strings.EqualFold(flag, "loopback") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isVirtualInterface(name string) bool {
|
|
name = strings.ToLower(name)
|
|
virtualPrefixes := []string{
|
|
"lo", "br-", "docker", "veth", "virbr", "vmnet",
|
|
"vmbr", "vboxnet", "tap", "tun", "zerotier", "wg",
|
|
}
|
|
for _, prefix := range virtualPrefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getInterfaceStats(ctx context.Context, iface string) (*int, string, bool) {
|
|
var speed *int
|
|
driver := ""
|
|
wake := false
|
|
|
|
if out, err := safeRun(ctx, "ethtool", iface); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Speed:") {
|
|
if val := parseSpeedValue(line); val != nil {
|
|
speed = val
|
|
}
|
|
}
|
|
if strings.HasPrefix(line, "Wake-on:") {
|
|
wake = parseWakeOnValue(line)
|
|
}
|
|
}
|
|
}
|
|
|
|
if out, err := safeRun(ctx, "ethtool", "-i", iface); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "driver:") {
|
|
driver = strings.TrimSpace(strings.TrimPrefix(line, "driver:"))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return speed, driver, wake
|
|
}
|
|
|
|
func parseSpeedValue(line string) *int {
|
|
if idx := strings.Index(line, ":"); idx >= 0 {
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
value = strings.TrimSuffix(value, "Mb/s")
|
|
value = strings.TrimSpace(value)
|
|
if strings.EqualFold(value, "unknown!") || value == "" {
|
|
return nil
|
|
}
|
|
if parsed, err := strconv.Atoi(value); err == nil {
|
|
return &parsed
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseWakeOnValue(line string) bool {
|
|
if idx := strings.Index(line, ":"); idx >= 0 {
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
return strings.Contains(strings.ToLower(value), "g")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getWirelessSSID(ctx context.Context, iface string) string {
|
|
if out, err := safeRun(ctx, "iwgetid", iface, "-r"); err == nil {
|
|
if ssid := strings.TrimSpace(out); ssid != "" {
|
|
return ssid
|
|
}
|
|
}
|
|
if out, err := safeRun(ctx, "iw", iface, "link"); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "SSID:") {
|
|
return strings.TrimSpace(strings.TrimPrefix(line, "SSID:"))
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func collectMotherboardInfo(ctx context.Context) MotherboardInfo {
|
|
info := MotherboardInfo{}
|
|
if base, err := safeRun(ctx, "dmidecode", "-t", "2"); err == nil {
|
|
for _, line := range strings.Split(base, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "Manufacturer:"):
|
|
info.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Manufacturer:"))
|
|
case strings.HasPrefix(line, "Product Name:"):
|
|
info.Model = strings.TrimSpace(strings.TrimPrefix(line, "Product Name:"))
|
|
}
|
|
}
|
|
}
|
|
if bios, err := safeRun(ctx, "dmidecode", "-t", "0"); err == nil {
|
|
for _, line := range strings.Split(bios, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "Vendor:"):
|
|
info.BIOSVendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor:"))
|
|
case strings.HasPrefix(line, "Version:"):
|
|
info.BIOSVersion = strings.TrimSpace(strings.TrimPrefix(line, "Version:"))
|
|
case strings.HasPrefix(line, "Release Date:"):
|
|
info.BIOSDate = strings.TrimSpace(strings.TrimPrefix(line, "Release Date:"))
|
|
}
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func collectSensors(ctx context.Context) SensorInfo {
|
|
info := SensorInfo{
|
|
DiskTempsC: make(map[string]float64),
|
|
Sensors: []SensorReading{},
|
|
}
|
|
|
|
if err := ensureSensorsInitialized(); err == nil {
|
|
if system, err := lmsensors.Get(); err == nil {
|
|
for chipID, chip := range system.Chips {
|
|
chipType := strings.ToLower(chip.Type)
|
|
for _, sensor := range chip.Sensors {
|
|
reading := SensorReading{
|
|
Chip: chipID,
|
|
Name: sensor.Name,
|
|
Type: strings.ToLower(sensor.SensorType.String()),
|
|
Value: sensor.Value,
|
|
Unit: sensor.Unit,
|
|
Alarm: sensor.Alarm,
|
|
}
|
|
info.Sensors = append(info.Sensors, reading)
|
|
|
|
if reading.Type == "temperature" && info.CPUTempC == nil && isCpuSensor(chipType, reading.Name) {
|
|
val := reading.Value
|
|
info.CPUTempC = &val
|
|
}
|
|
if reading.Type == "temperature" && isDiskSensor(chipType, reading.Name) {
|
|
info.DiskTempsC[fmt.Sprintf("%s:%s", chipID, reading.Name)] = reading.Value
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
debugf("lecture go-lmsensors échoue: %v", err)
|
|
}
|
|
} else {
|
|
debugf("initialisation go-lmsensors échoue: %v", err)
|
|
}
|
|
|
|
fillThermalFallback(&info)
|
|
return info
|
|
}
|
|
|
|
func ensureSensorsInitialized() error {
|
|
sensorInitOnce.Do(func() {
|
|
sensorInitErr = lmsensors.Init()
|
|
})
|
|
return sensorInitErr
|
|
}
|
|
|
|
func fillThermalFallback(info *SensorInfo) {
|
|
if info.DiskTempsC == nil {
|
|
info.DiskTempsC = make(map[string]float64)
|
|
}
|
|
if zones, err := os.ReadDir("/sys/class/thermal"); err == nil {
|
|
for _, zone := range zones {
|
|
zonePath := filepath.Join("/sys/class/thermal", zone.Name())
|
|
kind, err := os.ReadFile(filepath.Join(zonePath, "type"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
kindStr := strings.TrimSpace(string(kind))
|
|
tempRaw, err := os.ReadFile(filepath.Join(zonePath, "temp"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
tempVal := atoi(strings.TrimSpace(string(tempRaw)))
|
|
if tempVal == 0 {
|
|
continue
|
|
}
|
|
tempC := float64(tempVal) / 1000
|
|
lower := strings.ToLower(kindStr)
|
|
if info.CPUTempC == nil && (strings.Contains(lower, "package") || strings.Contains(lower, "cpu")) {
|
|
val := tempC
|
|
info.CPUTempC = &val
|
|
}
|
|
if strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") {
|
|
info.DiskTempsC[kindStr] = tempC
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func isCpuSensor(chipType, name string) bool {
|
|
lower := strings.ToLower(name)
|
|
return strings.Contains(lower, "cpu") || strings.Contains(lower, "package") || strings.Contains(chipType, "core") || strings.Contains(chipType, "cpu")
|
|
}
|
|
|
|
func isDiskSensor(chipType, name string) bool {
|
|
lower := strings.ToLower(name)
|
|
return strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") || strings.Contains(chipType, "nvme") || strings.Contains(chipType, "sata")
|
|
}
|
|
|
|
func collectAudioInfo(ctx context.Context) AudioInfo {
|
|
info := AudioInfo{
|
|
Hardware: AudioHardware{},
|
|
Software: AudioSoftware{},
|
|
}
|
|
if out, err := safeRun(ctx, "lspci"); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.Contains(line, "Audio device") {
|
|
info.Hardware.PCIAudioDevices = append(info.Hardware.PCIAudioDevices, strings.TrimSpace(line))
|
|
}
|
|
}
|
|
}
|
|
if out, err := safeRun(ctx, "aplay", "-l"); err == nil {
|
|
info.Hardware.AlsaPlayback = out
|
|
}
|
|
if out, err := safeRun(ctx, "arecord", "-l"); err == nil {
|
|
info.Hardware.AlsaCapture = out
|
|
}
|
|
if out, err := safeRun(ctx, "aplay", "-L"); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
info.Hardware.PCMDevices = append(info.Hardware.PCMDevices, line)
|
|
}
|
|
}
|
|
}
|
|
if out, err := safeRun(ctx, "pactl", "info"); err == nil {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "Server Name:"):
|
|
info.Software.ServerName = strings.TrimSpace(strings.TrimPrefix(line, "Server Name:"))
|
|
case strings.HasPrefix(line, "Server Version:"):
|
|
info.Software.ServerVersion = strings.TrimSpace(strings.TrimPrefix(line, "Server Version:"))
|
|
case strings.HasPrefix(line, "Default Sink:"):
|
|
value := strings.TrimSpace(strings.TrimPrefix(line, "Default Sink:"))
|
|
info.Software.DefaultSink = &value
|
|
case strings.HasPrefix(line, "Default Source:"):
|
|
value := strings.TrimSpace(strings.TrimPrefix(line, "Default Source:"))
|
|
info.Software.DefaultSource = &value
|
|
}
|
|
}
|
|
if strings.Contains(out, "PipeWire") {
|
|
info.Software.Backend = "pipewire"
|
|
} else if strings.Contains(out, "PulseAudio") {
|
|
info.Software.Backend = "pulseaudio"
|
|
} else {
|
|
info.Software.Backend = "alsa"
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func collectRawInfo(ctx context.Context, include []string, maxKB int) map[string]string {
|
|
raw := make(map[string]string)
|
|
if maxKB <= 0 {
|
|
maxKB = 5120
|
|
}
|
|
for _, item := range include {
|
|
item = strings.ToLower(item)
|
|
var cmd []string
|
|
switch item {
|
|
case "lscpu":
|
|
cmd = []string{"lscpu"}
|
|
case "lsblk":
|
|
cmd = []string{"lsblk", "-a"}
|
|
case "dmidecode":
|
|
cmd = []string{"dmidecode", "-t", "0,1,2,16,17"}
|
|
default:
|
|
continue
|
|
}
|
|
if len(cmd) == 0 {
|
|
continue
|
|
}
|
|
out, err := safeRun(ctx, cmd[0], cmd[1:]...)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
raw[item] = truncateOutput(out, maxKB)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func truncateOutput(s string, maxKB int) string {
|
|
if len(s) <= maxKB*1024 {
|
|
return s
|
|
}
|
|
return s[:maxKB*1024]
|
|
}
|
|
func atoi(value string) int {
|
|
value = strings.TrimSpace(strings.Split(value, " ")[0])
|
|
if res, err := strconv.Atoi(value); err == nil {
|
|
return res
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseMHz(value string) float64 {
|
|
if f, err := strconv.ParseFloat(strings.TrimSpace(strings.Split(value, " ")[0]), 64); err == nil {
|
|
return f / 1000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func parseSizeToKB(value string) int {
|
|
value = strings.TrimSpace(value)
|
|
if idx := strings.Index(value, "("); idx >= 0 {
|
|
value = strings.TrimSpace(value[:idx])
|
|
}
|
|
match := sizeValueRegex.FindStringSubmatch(value)
|
|
if len(match) < 2 {
|
|
return 0
|
|
}
|
|
num, err := strconv.ParseFloat(match[1], 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
unit := strings.ToLower(match[2])
|
|
multiplier := 1.0
|
|
switch unit {
|
|
case "k", "kb", "kib":
|
|
multiplier = 1
|
|
case "m", "mb", "mib":
|
|
multiplier = 1024
|
|
case "g", "gb", "gib":
|
|
multiplier = 1024 * 1024
|
|
case "t", "tb", "tib":
|
|
multiplier = 1024 * 1024 * 1024
|
|
default:
|
|
multiplier = 1
|
|
}
|
|
return int(num * multiplier)
|
|
}
|
|
|
|
func printProgress(step, total int, name, status string) {
|
|
// Simple affichage ANSI compatible
|
|
statusColor := "\033[32m" // Vert
|
|
if status == "WARN" || status == "SKIPPED" {
|
|
statusColor = "\033[33m" // Jaune
|
|
} else if strings.Contains(status, "ERROR") {
|
|
statusColor = "\033[31m" // Rouge
|
|
}
|
|
reset := "\033[0m"
|
|
fmt.Printf("[%d/%d] %-20s %s%s%s\n", step, total, name, statusColor, status, reset)
|
|
}
|
|
|
|
// ==========================================
|
|
// 3. CONFIG LOADER
|
|
// ==========================================
|
|
|
|
func loadConfig(url string) error {
|
|
var configData []byte
|
|
var err error
|
|
wd, wdErr := os.Getwd()
|
|
localPath := ""
|
|
|
|
if wdErr == nil {
|
|
localPath = filepath.Join(wd, "config.yaml")
|
|
if info, statErr := os.Stat(localPath); statErr == nil && !info.IsDir() {
|
|
configData, err = os.ReadFile(localPath)
|
|
if err == nil {
|
|
fmt.Printf("INFO: Configuration locale chargée depuis %s.\n", localPath)
|
|
debugf("Contenu de %s :\n%s", localPath, string(configData))
|
|
goto parse
|
|
}
|
|
fmt.Printf("WARN: Impossible de lire %s (%v), tentative fallback...\n", localPath, err)
|
|
} else {
|
|
debugf("Aucun config.yaml local (%s) : %v", localPath, statErr)
|
|
}
|
|
} else {
|
|
debugf("Impossible de déterminer le dossier d'exécution pour chercher config.yaml : %v", wdErr)
|
|
}
|
|
|
|
// Tentative HTTP
|
|
if url != "" {
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
configData, err = io.ReadAll(resp.Body)
|
|
if err == nil {
|
|
fmt.Println("INFO: Configuration distante chargée.")
|
|
goto parse
|
|
}
|
|
}
|
|
fmt.Printf("WARN: Impossible de charger la config distante (%s), tentative fallback...\n", err)
|
|
}
|
|
|
|
// Fallback local (ou simulation pour cet exemple)
|
|
// Dans le réel, on lirait /var/cache/bench-client/config.yaml
|
|
fmt.Println("INFO: Utilisation de la configuration embarquée (fallback).")
|
|
configData = []byte(defaultConfigYAML) // Utilisation d'une constante pour la démo
|
|
|
|
parse:
|
|
err = yaml.Unmarshal(configData, &cfg)
|
|
if err == nil {
|
|
debugf("Config chargée : %+v", cfg)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ==========================================
|
|
// 4. COLLECTORS
|
|
// ==========================================
|
|
|
|
// ==========================================
|
|
// 5. BENCHMARKS
|
|
// ==========================================
|
|
|
|
func runCPUBench(ctx context.Context) CPUResult {
|
|
res := CPUResult{}
|
|
if !cfg.Benchmarks.CPU.Enabled {
|
|
return res
|
|
}
|
|
singleEvents, singleDuration := runSysbenchCPU(ctx, "1")
|
|
multiThreads := resolveThreadCount(ctx)
|
|
multiEvents, multiDuration := runSysbenchCPU(ctx, multiThreads)
|
|
res.EventsPerSecSingle = round2(singleEvents)
|
|
res.EventsPerSecMulti = round2(multiEvents)
|
|
res.EventsPerSec = round2(averagePositive(singleEvents, multiEvents))
|
|
res.DurationSec = round2(singleDuration + multiDuration)
|
|
res.ScoreSingle = res.EventsPerSecSingle
|
|
res.ScoreMulti = res.EventsPerSecMulti
|
|
res.Score = res.EventsPerSec
|
|
return res
|
|
}
|
|
|
|
func runSysbenchCPU(ctx context.Context, threads string) (float64, float64) {
|
|
args := []string{"cpu", fmt.Sprintf("--threads=%s", threads), "--time=10", "run"}
|
|
out, err := safeRun(ctx, "sysbench", args...)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
return parseSysbenchCPU(out)
|
|
}
|
|
|
|
func parseSysbenchCPU(output string) (float64, float64) {
|
|
var events, duration float64
|
|
if matches := sysbenchEventsRegex.FindStringSubmatch(output); len(matches) > 1 {
|
|
events, _ = strconv.ParseFloat(matches[1], 64)
|
|
}
|
|
if matches := sysbenchTimeRegex.FindStringSubmatch(output); len(matches) > 1 {
|
|
duration, _ = strconv.ParseFloat(matches[1], 64)
|
|
}
|
|
return events, duration
|
|
}
|
|
|
|
func resolveThreadCount(ctx context.Context) string {
|
|
if v := os.Getenv("GOMAXPROCS"); v != "" {
|
|
return v
|
|
}
|
|
if out, err := safeRun(ctx, "nproc"); err == nil {
|
|
return strings.TrimSpace(out)
|
|
}
|
|
return "4"
|
|
}
|
|
|
|
func averagePositive(values ...float64) float64 {
|
|
var sum float64
|
|
var count int
|
|
for _, v := range values {
|
|
if v > 0 {
|
|
sum += v
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
return sum / float64(count)
|
|
}
|
|
|
|
func maxFloat(a, b float64) float64 {
|
|
if b > a {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
func runMemBench(ctx context.Context) MemResult {
|
|
res := MemResult{}
|
|
if !cfg.Benchmarks.Memory.Enabled {
|
|
return res
|
|
}
|
|
args := []string{"memory", "--memory-block-size=1K", "--memory-total-size=1G", "run"}
|
|
if out, err := safeRun(ctx, "sysbench", args...); err == nil {
|
|
if matches := regexp.MustCompile(`([\d\.]+)\s+MiB/sec`).FindStringSubmatch(out); len(matches) > 1 {
|
|
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
|
res.Throughput = round2(val)
|
|
res.Score = res.Throughput
|
|
}
|
|
} else if matches := regexp.MustCompile(`transferred:\s+([\d\.]+)\s+MiB`).FindStringSubmatch(out); len(matches) > 1 {
|
|
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
|
res.Throughput = round2(val)
|
|
res.Score = res.Throughput
|
|
}
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func runDiskBench(ctx context.Context) DiskResult {
|
|
res := DiskResult{}
|
|
if !cfg.Benchmarks.Disk.Enabled {
|
|
return res
|
|
}
|
|
benchDir, mountInfo := chooseBenchDir()
|
|
debugf("[3/6] Bench disque cible : %s (mount=%s fstype=%s source=%s)", benchDir, mountInfo.MountPoint, mountInfo.FSName, mountInfo.Source)
|
|
|
|
directMetrics := runDirectDiskTests(ctx, benchDir, mountInfo)
|
|
res.ReadMBs = directMetrics.ReadMBs
|
|
res.WriteMBs = directMetrics.WriteMBs
|
|
res.Score = averagePositive(res.ReadMBs, res.WriteMBs)
|
|
|
|
tmpFile := filepath.Join(benchDir, fmt.Sprintf("bench-%d.fio", time.Now().UnixNano()))
|
|
defer os.Remove(tmpFile)
|
|
args := []string{
|
|
"--name=bench", "--ioengine=libaio", "--rw=randrw", "--bs=4k",
|
|
"--direct=1", "--size=500M", "--numjobs=1", "--runtime=30",
|
|
"--time_based", "--output-format=json", "--filename=" + tmpFile,
|
|
}
|
|
out, err := safeRun(ctx, "fio", args...)
|
|
if err != nil {
|
|
return res
|
|
}
|
|
var fioJSON map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out), &fioJSON); err != nil {
|
|
return res
|
|
}
|
|
if jobs, ok := fioJSON["jobs"].([]interface{}); ok && len(jobs) > 0 {
|
|
job := jobs[0].(map[string]interface{})
|
|
if read, ok := job["read"].(map[string]interface{}); ok {
|
|
res.ReadMBs = maxFloat(res.ReadMBs, convertBW(read["bw"]))
|
|
res.IOPSRead = convertFloat(read["iops"])
|
|
if hist, ok := read["clat_ns"].(map[string]interface{}); ok {
|
|
res.LatencyMs = convertFloat(hist["mean"]) / 1000000
|
|
}
|
|
}
|
|
if write, ok := job["write"].(map[string]interface{}); ok {
|
|
res.WriteMBs = maxFloat(res.WriteMBs, convertBW(write["bw"]))
|
|
res.IOPSWrite = convertFloat(write["iops"])
|
|
}
|
|
}
|
|
res.Score = round2(averagePositive(res.ReadMBs, res.WriteMBs))
|
|
res.ReadMBs = round2(res.ReadMBs)
|
|
res.WriteMBs = round2(res.WriteMBs)
|
|
res.LatencyMs = round2(res.LatencyMs)
|
|
res.IOPSRead = round2(res.IOPSRead)
|
|
res.IOPSWrite = round2(res.IOPSWrite)
|
|
debugf("[3/6] Bench disque final -> read=%.2f MB/s write=%.2f MB/s score=%.2f", res.ReadMBs, res.WriteMBs, res.Score)
|
|
return res
|
|
}
|
|
|
|
func convertBW(value interface{}) float64 {
|
|
if v, ok := value.(float64); ok {
|
|
return v / 1024
|
|
}
|
|
if num, ok := value.(json.Number); ok {
|
|
if parsed, err := num.Float64(); err == nil {
|
|
return parsed / 1024
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if b > a {
|
|
return b
|
|
}
|
|
return a
|
|
}
|
|
|
|
func runDirectDiskTests(ctx context.Context, benchDir string, mountInfo mountEntry) DirectDiskMetrics {
|
|
if benchDir == "" {
|
|
return DirectDiskMetrics{}
|
|
}
|
|
debugf("[3/6] Bench disque direct sur %s (mnt=%s fs=%s)", mountInfo.Source, mountInfo.MountPoint, mountInfo.FSName)
|
|
tests := []struct {
|
|
suffix string
|
|
sizeMB int64
|
|
}{
|
|
{"500mb", 500},
|
|
{"1gb", 1024},
|
|
}
|
|
|
|
var cleanup []string
|
|
var totalWriteMB, totalReadMB float64
|
|
var totalWriteDur, totalReadDur time.Duration
|
|
|
|
for _, test := range tests {
|
|
path := filepath.Join(benchDir, fmt.Sprintf("direct-%s.bin", test.suffix))
|
|
cleanup = append(cleanup, path)
|
|
debugf("[3/6] Bench direct : écriture+lecture %s (%d MB)", path, test.sizeMB)
|
|
writeDur, readDur, err := benchmarkFile(ctx, path, test.sizeMB)
|
|
if err != nil {
|
|
debugf("[3/6] Bench direct %s échoué: %v", path, err)
|
|
continue
|
|
}
|
|
totalWriteMB += float64(test.sizeMB)
|
|
totalReadMB += float64(test.sizeMB)
|
|
totalWriteDur += writeDur
|
|
totalReadDur += readDur
|
|
}
|
|
|
|
const chunkCount = 50
|
|
const chunkSizeMB = 1
|
|
for idx := 1; idx <= chunkCount; idx++ {
|
|
path := filepath.Join(benchDir, fmt.Sprintf("direct-chunk-%02d.bin", idx))
|
|
cleanup = append(cleanup, path)
|
|
debugf("[3/6] Bench direct chunk %d/%d (%s)", idx, chunkCount, path)
|
|
writeDur, readDur, err := benchmarkFile(ctx, path, chunkSizeMB)
|
|
if err != nil {
|
|
debugf("[3/6] Bench direct chunk %s échoué: %v", path, err)
|
|
continue
|
|
}
|
|
totalWriteMB += float64(chunkSizeMB)
|
|
totalReadMB += float64(chunkSizeMB)
|
|
totalWriteDur += writeDur
|
|
totalReadDur += readDur
|
|
}
|
|
|
|
cleanupPaths(cleanup)
|
|
metrics := DirectDiskMetrics{
|
|
WriteMBs: round2(safeThroughput(totalWriteMB, totalWriteDur)),
|
|
ReadMBs: round2(safeThroughput(totalReadMB, totalReadDur)),
|
|
}
|
|
debugf("[3/6] Bench disque direct résumé -> write=%.2f MB/s read=%.2f MB/s", metrics.WriteMBs, metrics.ReadMBs)
|
|
return metrics
|
|
}
|
|
|
|
func cleanupPaths(paths []string) {
|
|
for _, path := range paths {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
debugf("Impossible de supprimer %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func benchmarkFile(ctx context.Context, path string, sizeMB int64) (time.Duration, time.Duration, error) {
|
|
if sizeMB <= 0 {
|
|
return 0, 0, fmt.Errorf("taille invalide %d", sizeMB)
|
|
}
|
|
totalBytes := int64(sizeMB) * 1024 * 1024
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
// ignore
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
writeBuf := make([]byte, 64*1024)
|
|
fillBuffer(writeBuf)
|
|
var written int64
|
|
start := time.Now()
|
|
for written < totalBytes {
|
|
if err := ctx.Err(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
toWrite := int64(len(writeBuf))
|
|
if totalBytes-written < toWrite {
|
|
toWrite = totalBytes - written
|
|
}
|
|
if _, err := file.Write(writeBuf[:toWrite]); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
written += toWrite
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
writeDur := time.Since(start)
|
|
|
|
if err := file.Close(); err != nil {
|
|
return writeDur, 0, err
|
|
}
|
|
|
|
readFile, err := os.Open(path)
|
|
if err != nil {
|
|
return writeDur, 0, err
|
|
}
|
|
defer readFile.Close()
|
|
|
|
readBuf := make([]byte, 64*1024)
|
|
start = time.Now()
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return writeDur, 0, err
|
|
}
|
|
if _, err := readFile.Read(readBuf); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
return writeDur, 0, err
|
|
}
|
|
}
|
|
readDur := time.Since(start)
|
|
return writeDur, readDur, nil
|
|
}
|
|
|
|
func safeThroughput(mb float64, duration time.Duration) float64 {
|
|
if duration <= 0 || mb <= 0 {
|
|
return 0
|
|
}
|
|
return mb / duration.Seconds()
|
|
}
|
|
|
|
func fillBuffer(buf []byte) {
|
|
for i := range buf {
|
|
buf[i] = byte(i % 256)
|
|
}
|
|
}
|
|
|
|
func round2(v float64) float64 {
|
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|
return v
|
|
}
|
|
return math.Round(v*100) / 100
|
|
}
|
|
func convertFloat(value interface{}) float64 {
|
|
if v, ok := value.(float64); ok {
|
|
return v
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func runNetBench(ctx context.Context) NetResult {
|
|
res := NetResult{}
|
|
if !cfg.Benchmarks.Network.Enabled {
|
|
return res
|
|
}
|
|
duration := 10
|
|
if params := cfg.Benchmarks.Network.Params; params != nil {
|
|
if raw, ok := params["duration_s"]; ok {
|
|
switch v := raw.(type) {
|
|
case int:
|
|
duration = v
|
|
case float64:
|
|
duration = int(v)
|
|
case string:
|
|
if parsed, err := strconv.Atoi(v); err == nil {
|
|
duration = parsed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
args := []string{
|
|
"-c", cfg.Benchmarks.Network.Server,
|
|
"-p", strconv.Itoa(cfg.Benchmarks.Network.Port),
|
|
"-J", "-t", strconv.Itoa(duration),
|
|
}
|
|
out, err := safeRun(ctx, "iperf3", args...)
|
|
if err != nil {
|
|
return res
|
|
}
|
|
var iperfJSON map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out), &iperfJSON); err != nil {
|
|
return res
|
|
}
|
|
if end, ok := iperfJSON["end"].(map[string]interface{}); ok {
|
|
if sumSent, ok := end["sum_sent"].(map[string]interface{}); ok {
|
|
if val := convertFloat(sumSent["bits_per_second"]); val > 0 {
|
|
res.Upload = round2(val / 1000 / 1000)
|
|
}
|
|
if val := convertFloat(sumSent["jitter_ms"]); val > 0 {
|
|
res.JitterMs = floatPtr(round2(val))
|
|
}
|
|
if val := convertFloat(sumSent["lost_percent"]); val > 0 {
|
|
res.PacketLossPct = floatPtr(round2(val))
|
|
}
|
|
}
|
|
if sumRecv, ok := end["sum_received"].(map[string]interface{}); ok {
|
|
if val := convertFloat(sumRecv["bits_per_second"]); val > 0 {
|
|
res.Download = round2(val / 1000 / 1000)
|
|
}
|
|
}
|
|
}
|
|
res.Score = round2(averagePositive(res.Upload, res.Download))
|
|
// Ping test
|
|
if cfg.Benchmarks.Network.Server != "" {
|
|
if ping := measurePing(ctx, cfg.Benchmarks.Network.Server); ping > 0 {
|
|
res.PingMs = round2(ping)
|
|
}
|
|
}
|
|
return res
|
|
}
|
|
|
|
func measurePing(ctx context.Context, target string) float64 {
|
|
out, err := safeRun(ctx, "ping", "-c", "1", "-W", "1", target)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
re := regexp.MustCompile(`time=([\d\.]+)\s*ms`)
|
|
if matches := re.FindStringSubmatch(out); len(matches) > 1 {
|
|
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
|
|
return val
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ==========================================
|
|
// 6. MAIN ORCHESTRATOR
|
|
// ==========================================
|
|
|
|
const defaultConfigYAML = `
|
|
config_version: 1
|
|
runtime:
|
|
max_total_runtime_s: 480
|
|
command_timeout_s: 30
|
|
temp_dir: "/tmp"
|
|
backend:
|
|
url: "http://10.0.0.50:8007/api/benchmark"
|
|
logging:
|
|
level: info
|
|
progress: true
|
|
show_command_lines: false
|
|
capture_raw_output: true
|
|
raw_output_max_kb: 5120
|
|
save_payload_to_tmp: true
|
|
payload_tmp_dir: "/tmp"
|
|
payload_filename_prefix: "bench_payload_"
|
|
interactive_pause_on_debug: true
|
|
payload:
|
|
dir: "."
|
|
prefix: "bench_payload_"
|
|
always_save: true
|
|
collection:
|
|
system: { enabled: true }
|
|
cpu: { enabled: true }
|
|
ram: { enabled: true }
|
|
storage: { enabled: true }
|
|
network: { enabled: true }
|
|
raw_info:
|
|
enabled: true
|
|
include: [lscpu, lsblk, dmidecode]
|
|
max_size_kb: 5120
|
|
benchmarks:
|
|
enabled: true
|
|
weights: { cpu: 0.4, memory: 0.2, disk: 0.2, network: 0.1, gpu: 0.1 }
|
|
cpu_sysbench:
|
|
enabled: true
|
|
params: { cpu_max_prime: 20000 }
|
|
memory_sysbench:
|
|
enabled: true
|
|
params: { total_size: "1G" }
|
|
disk_fio:
|
|
enabled: true
|
|
safety: { max_runtime_s: 60 }
|
|
network_iperf3:
|
|
enabled: true
|
|
server: "127.0.0.1" # Default pour test
|
|
port: 5201
|
|
params: { duration_s: 10 }
|
|
`
|
|
|
|
func calculateScore(h Hardware, r Results) float64 {
|
|
// Normalisation arbitraire pour l'exemple
|
|
// CPU: assume 2000 events/sec is 100 pts
|
|
cpuScore := ((r.CPU.ScoreSingle + r.CPU.ScoreMulti) / 2) / 20
|
|
memScore := r.Memory.Throughput / 100 // assume 1000 MB/s is 100 pts
|
|
diskScore := r.Disk.Score / 10 // assume 500 MB/s is 50 pts
|
|
netScore := r.Network.Score / 10 // assume 1000 Mbps is 100 pts
|
|
gpuScore := 0.0
|
|
if r.GPU.Score != nil {
|
|
gpuScore = *r.GPU.Score
|
|
}
|
|
|
|
w := cfg.Benchmarks.Weights
|
|
debugf("Poids benchmarks : %+v", w)
|
|
debugf("Scores normalisés : cpu=%.2f mem=%.2f disk=%.2f net=%.2f", cpuScore, memScore, diskScore, netScore)
|
|
return (cpuScore*w["cpu"] + memScore*w["memory"] + diskScore*w["disk"] + netScore*w["network"] + gpuScore*w["gpu"]) * 100
|
|
}
|
|
|
|
func persistPayload(data []byte) string {
|
|
cacheDir := cfg.Payload.Dir
|
|
if cacheDir == "" {
|
|
if wd, err := os.Getwd(); err == nil {
|
|
cacheDir = wd
|
|
} else {
|
|
fmt.Printf("WARN: impossible de déterminer le dossier d'exécution : %v\n", err)
|
|
cacheDir = os.TempDir()
|
|
}
|
|
}
|
|
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
|
fmt.Printf("WARN: impossible de créer %s : %v\n", cacheDir, err)
|
|
return ""
|
|
}
|
|
prefix := cfg.Payload.Prefix
|
|
if prefix == "" {
|
|
prefix = cfg.Logging.PayloadFilenamePrefix
|
|
}
|
|
if prefix == "" {
|
|
prefix = "bench_payload_"
|
|
}
|
|
filename := prefix + "last.json"
|
|
path := filepath.Join(cacheDir, filename)
|
|
if err := os.WriteFile(path, data, 0o777); err != nil {
|
|
fmt.Printf("WARN: impossible d'écrire %s : %v\n", path, err)
|
|
return ""
|
|
}
|
|
debugf("Payload sauvegardé au format JSON dans %s (%d octets)", path, len(data))
|
|
return path
|
|
}
|
|
|
|
func main() {
|
|
// Flags
|
|
configURL := flag.String("config-url", "", "URL de la config distante")
|
|
flag.BoolVar(&debug, "debug", false, "Mode debug")
|
|
flag.BoolVar(&dryRun, "dry-run", false, "Simulation d'exécution")
|
|
flag.Parse()
|
|
defer closeDebugLog()
|
|
|
|
// 1. Chargement Config
|
|
fmt.Println("[1/6] Configuration...")
|
|
if err := loadConfig(*configURL); err != nil {
|
|
fmt.Printf("FATAL: Erreur chargement config: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
printProgress(1, 6, "Configuration", "OK")
|
|
debugf("Configuration complète : %+v", cfg)
|
|
|
|
// 2. Contexte global
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Runtime.MaxTotalRuntime)*time.Second)
|
|
defer cancel()
|
|
|
|
// 3. Collecte Info
|
|
fmt.Println("[2/6] Collecte informations système...")
|
|
debugf("[2/6] Début collecte CPU")
|
|
cpuDetails := collectCPUInfo(ctx)
|
|
debugf("[2/6] Détails CPU: %+v", cpuDetails)
|
|
|
|
debugf("[2/6] Début collecte RAM")
|
|
ramDetails := collectRAMInfo(ctx)
|
|
debugf("[2/6] Détails RAM: %+v", ramDetails)
|
|
|
|
debugf("[2/6] Début collecte GPU")
|
|
gpuDetails := collectGPUInfo(ctx)
|
|
debugf("[2/6] Détails GPU: %+v", gpuDetails)
|
|
|
|
debugf("[2/6] Début collecte stockage")
|
|
storageDetails := collectStorageInfo(ctx)
|
|
debugf("[2/6] Détails stockage: %+v", storageDetails)
|
|
|
|
debugf("[2/6] Début collecte PCI devices")
|
|
pciDetails := collectPCIDevices(ctx)
|
|
debugf("[2/6] Détails PCI: %+v", pciDetails)
|
|
|
|
debugf("[2/6] Début collecte USB devices")
|
|
usbDetails := collectUSBDevices(ctx)
|
|
debugf("[2/6] Détails USB: %+v", usbDetails)
|
|
|
|
debugf("[2/6] Début collecte partages réseau")
|
|
networkShares := collectNetworkShares(ctx)
|
|
debugf("[2/6] Détails partages réseau: %+v", networkShares)
|
|
|
|
debugf("[2/6] Début collecte interfaces réseau")
|
|
networkDetails := collectNetworkInfo(ctx)
|
|
debugf("[2/6] Détails réseau: %+v", networkDetails)
|
|
|
|
debugf("[2/6] Début collecte carte mère")
|
|
motherboardInfo := collectMotherboardInfo(ctx)
|
|
debugf("[2/6] Détails carte mère: %+v", motherboardInfo)
|
|
|
|
debugf("[2/6] Début collecte OS")
|
|
osInfo := collectOSInfo(ctx)
|
|
debugf("[2/6] Détails OS: %+v", osInfo)
|
|
|
|
debugf("[2/6] Début collecte capteurs")
|
|
sensorInfo := collectSensors(ctx)
|
|
debugf("[2/6] Détails capteurs: %+v", sensorInfo)
|
|
|
|
debugf("[2/6] Début collecte audio")
|
|
audioInfo := collectAudioInfo(ctx)
|
|
debugf("[2/6] Détails audio: %+v", audioInfo)
|
|
|
|
hw := Hardware{
|
|
CPU: cpuDetails,
|
|
RAM: ramDetails,
|
|
GPU: gpuDetails,
|
|
Storage: storageDetails,
|
|
PCIDevices: pciDetails,
|
|
USBDevices: usbDetails,
|
|
NetworkShares: networkShares,
|
|
Network: networkDetails,
|
|
Motherboard: motherboardInfo,
|
|
OS: osInfo,
|
|
Sensors: sensorInfo,
|
|
Audio: audioInfo,
|
|
}
|
|
if cfg.Logging.CaptureRawOutput {
|
|
hw.RawInfo = collectRawInfo(ctx, cfg.RawInfo.Include, cfg.RawInfo.MaxSizeKB)
|
|
}
|
|
printProgress(2, 6, "Collecte", "OK")
|
|
debugf("Matériel collecté : %+v", hw)
|
|
|
|
// 4. Benchmarks
|
|
fmt.Println("[3/6] Exécution Benchmarks...")
|
|
debugf("[3/6] Benchmark CPU démarré")
|
|
cpuResult := runCPUBench(ctx)
|
|
debugf("[3/6] Résultat CPU: %+v", cpuResult)
|
|
|
|
debugf("[3/6] Benchmark mémoire démarré")
|
|
memResult := runMemBench(ctx)
|
|
debugf("[3/6] Résultat mémoire: %+v", memResult)
|
|
|
|
debugf("[3/6] Benchmark disque démarré")
|
|
diskResult := runDiskBench(ctx)
|
|
debugf("[3/6] Résultat disque: %+v", diskResult)
|
|
|
|
debugf("[3/6] Benchmark réseau démarré")
|
|
netResult := runNetBench(ctx)
|
|
debugf("[3/6] Résultat réseau: %+v", netResult)
|
|
|
|
results := Results{
|
|
CPU: cpuResult,
|
|
Memory: memResult,
|
|
Disk: diskResult,
|
|
Network: netResult,
|
|
}
|
|
printProgress(3, 6, "Benchmarks", "OK")
|
|
debugf("Résultats des benchmarks : %+v", results)
|
|
|
|
// 5. Score
|
|
fmt.Println("[4/6] Calcul Score...")
|
|
results.GlobalScore = calculateScore(hw, results)
|
|
printProgress(4, 6, "Score", fmt.Sprintf("%.2f", results.GlobalScore))
|
|
debugf("Score global calculé : %.2f", results.GlobalScore)
|
|
|
|
// 6. Payload & Envoi
|
|
rawData := hw.RawInfo
|
|
if rawData == nil {
|
|
rawData = make(map[string]string)
|
|
}
|
|
if debug {
|
|
rawData["debug"] = "active"
|
|
}
|
|
payload := FinalPayload{
|
|
DeviceIdentifier: hw.OS.Hostname,
|
|
BenchScriptVersion: benchScriptVersion,
|
|
BenchClientVersion: benchClientVersion,
|
|
Hardware: hw,
|
|
Results: results,
|
|
RawInfo: rawData,
|
|
}
|
|
|
|
jsonData, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
fmt.Printf("FATAL: JSON error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
printProgress(5, 6, "JSON", "OK")
|
|
debugf("Payload JSON (%d octets) : %s", len(jsonData), string(jsonData))
|
|
|
|
fmt.Println("[6/6] Envoi Backend...")
|
|
if !dryRun {
|
|
token, tokenSet := os.LookupEnv("API_TOKEN")
|
|
if !tokenSet {
|
|
token = "test_hardware_perf"
|
|
fmt.Println("INFO: API_TOKEN absent, envoi avec token de test 'test_hardware_perf'.")
|
|
} else {
|
|
debugf("API_TOKEN fourni via l'environnement.")
|
|
}
|
|
|
|
req, _ := http.NewRequest("POST", cfg.Backend.URL, bytes.NewBuffer(jsonData))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
printProgress(6, 6, "Envoi HTTP", "ERROR")
|
|
fmt.Printf("Erreur: %v\n", err)
|
|
} else {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
printProgress(6, 6, "Envoi HTTP", "OK")
|
|
} else {
|
|
printProgress(6, 6, "Envoi HTTP", fmt.Sprintf("FAIL %d", resp.StatusCode))
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("DRY RUN: Payload non envoyé.")
|
|
fmt.Println(string(jsonData))
|
|
}
|
|
if path := persistPayload(jsonData); path != "" {
|
|
fmt.Printf("INFO: Payload enregistré dans %s\n", path)
|
|
}
|
|
}
|