Files
CLICD/backend/internal/api/host.go
T

1708 lines
46 KiB
Go

package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"clicd/internal/lxc"
)
type HostInfo struct {
CPU CpuInfo `json:"cpu"`
RAM MemoryInfo `json:"ram"`
Disk DiskInfo `json:"disk"`
Network NetworkInfo `json:"network"`
DiskIO DiskIOInfo `json:"disk_io"`
Load LoadInfo `json:"load"`
}
type HostProbeReport struct {
GeneratedAt string `json:"generated_at"`
Hostname string `json:"hostname"`
Kernel string `json:"kernel"`
OS string `json:"os"`
CPU HostCPUProbe `json:"cpu"`
Memory HostMemoryProbe `json:"memory"`
Disks []HostDiskProbe `json:"disks"`
NetworkInterfaces []HostNICProbe `json:"network_interfaces"`
PublicIPv4 []string `json:"public_ipv4"`
IPv4Addresses []HostIPProbe `json:"ipv4_addresses"`
IPv4Prefixes []HostIPv4PrefixProbe `json:"ipv4_prefixes"`
IPv6Addresses []HostIPProbe `json:"ipv6_addresses"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
Gateways []HostGatewayProbe `json:"gateways"`
GPUs []HostGPUProbe `json:"gpus"`
Runtime HostRuntimeProbe `json:"runtime"`
System HostSystemProbe `json:"system"`
Environment []HostEnvCheck `json:"environment"`
}
type HostCPUProbe struct {
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
Architecture string `json:"architecture"`
Flags []string `json:"flags"`
HasIntegratedGPU bool `json:"has_integrated_gpu"`
Virtualization bool `json:"virtualization"`
VirtualizationKey string `json:"virtualization_key"`
}
type HostMemoryProbe struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
Modules []HostMemoryModule `json:"modules"`
}
type HostMemoryModule struct {
Locator string `json:"locator"`
Size string `json:"size"`
Type string `json:"type"`
Speed string `json:"speed"`
Manufacturer string `json:"manufacturer"`
PartNumber string `json:"part_number"`
SerialNumber string `json:"serial_number"`
}
type HostDiskProbe struct {
Name string `json:"name"`
Path string `json:"path"`
Model string `json:"model"`
Serial string `json:"serial"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type"`
Virtual bool `json:"virtual"`
Rotational bool `json:"rotational"`
Mountpoints []string `json:"mountpoints"`
Health string `json:"health"`
HealthDetail string `json:"health_detail"`
SMART HostDiskSMARTProbe `json:"smart"`
}
type HostDiskSMARTProbe struct {
Available bool `json:"available"`
LifeUsedPercent *int `json:"life_used_percent,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"`
PowerCycleCount int64 `json:"power_cycle_count,omitempty"`
ReadDataBytes uint64 `json:"read_data_bytes,omitempty"`
WrittenDataBytes uint64 `json:"written_data_bytes,omitempty"`
ReadCommands uint64 `json:"read_commands,omitempty"`
WriteCommands uint64 `json:"write_commands,omitempty"`
WearLevelingCount string `json:"wear_leveling_count,omitempty"`
EraseCount string `json:"erase_count,omitempty"`
MediaErrors uint64 `json:"media_errors,omitempty"`
}
type HostNICProbe struct {
Name string `json:"name"`
MAC string `json:"mac"`
State string `json:"state"`
SpeedMbps int `json:"speed_mbps"`
Driver string `json:"driver"`
Model string `json:"model"`
IPv4 []HostIPProbe `json:"ipv4"`
IPv6 []HostIPProbe `json:"ipv6"`
}
type HostIPProbe struct {
Interface string `json:"interface"`
Address string `json:"address"`
PrefixLen int `json:"prefix_len"`
Scope string `json:"scope"`
Gateway string `json:"gateway,omitempty"`
}
type HostIPv4PrefixProbe struct {
Interface string `json:"interface"`
Address string `json:"address"`
Prefix string `json:"prefix"`
PrefixLen int `json:"prefix_len"`
SubnetMask string `json:"subnet_mask"`
Gateway string `json:"gateway"`
Source string `json:"source"`
}
type HostGatewayProbe struct {
Family string `json:"family"`
Interface string `json:"interface"`
Gateway string `json:"gateway"`
}
type HostGPUProbe struct {
Name string `json:"name"`
Vendor string `json:"vendor"`
Driver string `json:"driver"`
Type string `json:"type"`
}
type HostRuntimeProbe struct {
LXCAvailable bool `json:"lxc_available"`
KVMAvailable bool `json:"kvm_available"`
DevKVM bool `json:"dev_kvm"`
NestedVirtualization bool `json:"nested_virtualization"`
NestedDetail string `json:"nested_detail"`
SupportMode string `json:"support_mode"`
}
type HostSystemProbe struct {
UptimeSeconds int64 `json:"uptime_seconds"`
UptimeText string `json:"uptime_text"`
ProcessCount int `json:"process_count"`
}
type HostEnvCheck struct {
Key string `json:"key"`
Label string `json:"label"`
OK bool `json:"ok"`
Required bool `json:"required"`
Detail string `json:"detail"`
}
type LoadInfo struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type CpuInfo struct {
Cores int `json:"cores"`
Usage float64 `json:"usage_pct"`
}
type MemoryInfo struct {
TotalMB int64 `json:"total_mb"`
UsedMB int64 `json:"used_mb"`
FreeMB int64 `json:"free_mb"`
}
type DiskInfo struct {
TotalGB float64 `json:"total_gb"`
UsedGB float64 `json:"used_gb"`
FreeGB float64 `json:"free_gb"`
}
type NetworkInfo struct {
RXBytes uint64 `json:"rx_bytes"`
TXBytes uint64 `json:"tx_bytes"`
RXBps float64 `json:"rx_bps"`
TXBps float64 `json:"tx_bps"`
PublicIPv4 string `json:"public_ipv4"`
PublicIPv4Interface string `json:"public_ipv4_interface"`
PublicIPv4Addresses []lxc.PublicIPInfo `json:"public_ipv4_addresses"`
PublicIPv6 string `json:"public_ipv6"`
PublicIPv6Interface string `json:"public_ipv6_interface"`
IPv6Prefixes []lxc.IPv6PrefixInfo `json:"ipv6_prefixes"`
}
type DiskIOInfo struct {
ReadBytes uint64 `json:"read_bytes"`
WriteBytes uint64 `json:"write_bytes"`
ReadBps float64 `json:"read_bps"`
WriteBps float64 `json:"write_bps"`
}
var hostCPUMu sync.Mutex
var lastHostCPU cpuTimes
var hostIOMu sync.Mutex
var lastHostIO hostIOSample
type cpuTimes struct {
Total uint64
Idle uint64
}
type hostIOSample struct {
RXBytes uint64
TXBytes uint64
ReadBytes uint64
WriteBytes uint64
At int64
}
func getHostInfo() HostInfo {
info := HostInfo{
CPU: CpuInfo{Cores: runtime.NumCPU()},
}
info.RAM = getMemoryInfo()
info.Disk = getDiskInfo()
info.CPU.Usage = getCPUUsage()
info.Network, info.DiskIO = getHostRates()
info.Load = getLoadInfo()
return info
}
func getMemoryInfo() MemoryInfo {
f, err := os.Open("/proc/meminfo")
if err != nil {
return MemoryInfo{TotalMB: 0, UsedMB: 0, FreeMB: 0}
}
defer f.Close()
var total, available, free int64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseInt(fields[1], 10, 64)
switch fields[0] {
case "MemTotal:":
total = val / 1024
case "MemAvailable:":
available = val / 1024
case "MemFree:":
free = val / 1024
}
}
used := total - available
if available == 0 {
used = total - free
}
return MemoryInfo{
TotalMB: total,
UsedMB: used,
FreeMB: available,
}
}
func getDiskInfo() DiskInfo {
if info, ok := getRootDiskInfo(); ok {
return info
}
{
// Try command-based fallback
cmd := exec.Command("df", "-BG", "/")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) >= 2 {
fields := strings.Fields(lines[1])
if len(fields) >= 4 {
total, _ := parseSizeGBf(fields[1])
used, _ := parseSizeGBf(fields[2])
free, _ := parseSizeGBf(fields[3])
return DiskInfo{TotalGB: total, UsedGB: used, FreeGB: free}
}
}
}
return DiskInfo{}
}
}
func getCPUUsage() float64 {
current, err := readCPUTimes()
if err != nil {
return 0
}
hostCPUMu.Lock()
defer hostCPUMu.Unlock()
if lastHostCPU.Total == 0 {
lastHostCPU = current
return 0
}
totalDelta := current.Total - lastHostCPU.Total
idleDelta := current.Idle - lastHostCPU.Idle
lastHostCPU = current
if totalDelta == 0 {
return 0
}
usage := (1 - float64(idleDelta)/float64(totalDelta)) * 100
if usage < 0 {
return 0
}
if usage > 100 {
return 100
}
return usage
}
func readCPUTimes() (cpuTimes, error) {
f, err := os.Open("/proc/stat")
if err != nil {
return cpuTimes{}, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return cpuTimes{}, scanner.Err()
}
fields := strings.Fields(scanner.Text())
if len(fields) < 8 || fields[0] != "cpu" {
return cpuTimes{}, nil
}
var values []uint64
for _, field := range fields[1:] {
value, _ := strconv.ParseUint(field, 10, 64)
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 parseSizeGB(s string) (int64, error) {
s = strings.TrimSuffix(s, "G")
s = strings.TrimSpace(s)
val, err := strconv.ParseInt(s, 10, 64)
return val, err
}
func parseSizeGBf(s string) (float64, error) {
s = strings.TrimSuffix(s, "G")
s = strings.TrimSpace(s)
val, err := strconv.ParseFloat(s, 64)
return val, err
}
func getHostRates() (NetworkInfo, DiskIOInfo) {
rx, tx := readHostNetworkBytes()
readBytes, writeBytes := readHostDiskBytes()
now := unixNano()
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
publicIPv4 := lxc.DetectPublicIPv4()
network.PublicIPv4 = publicIPv4.Address
network.PublicIPv4Interface = publicIPv4.Interface
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
network.IPv6Prefixes = lxc.DetectHostPublicIPv6Prefixes()
if len(network.IPv6Prefixes) > 0 {
network.PublicIPv6 = network.IPv6Prefixes[0].Address
network.PublicIPv6Interface = network.IPv6Prefixes[0].Interface
}
diskIO := DiskIOInfo{ReadBytes: readBytes, WriteBytes: writeBytes}
hostIOMu.Lock()
defer hostIOMu.Unlock()
if lastHostIO.At == 0 {
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
return network, diskIO
}
elapsed := float64(now-lastHostIO.At) / 1_000_000_000
if elapsed > 0 {
if rx >= lastHostIO.RXBytes {
network.RXBps = float64(rx-lastHostIO.RXBytes) / elapsed
}
if tx >= lastHostIO.TXBytes {
network.TXBps = float64(tx-lastHostIO.TXBytes) / elapsed
}
if readBytes >= lastHostIO.ReadBytes {
diskIO.ReadBps = float64(readBytes-lastHostIO.ReadBytes) / elapsed
}
if writeBytes >= lastHostIO.WriteBytes {
diskIO.WriteBps = float64(writeBytes-lastHostIO.WriteBytes) / elapsed
}
}
lastHostIO = hostIOSample{RXBytes: rx, TXBytes: tx, ReadBytes: readBytes, WriteBytes: writeBytes, At: now}
return network, diskIO
}
func readHostNetworkBytes() (uint64, uint64) {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return 0, 0
}
var rx, tx uint64
for _, entry := range entries {
name := entry.Name()
if name == "lo" {
continue
}
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
}
return rx, tx
}
func readHostDiskBytes() (uint64, uint64) {
f, err := os.Open("/proc/diskstats")
if err != nil {
return 0, 0
}
defer f.Close()
var readSectors, writeSectors uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 14 {
continue
}
device := fields[2]
if strings.HasPrefix(device, "loop") ||
strings.HasPrefix(device, "ram") ||
strings.HasPrefix(device, "fd") ||
strings.HasPrefix(device, "sr") {
continue
}
read, _ := strconv.ParseUint(fields[5], 10, 64)
write, _ := strconv.ParseUint(fields[9], 10, 64)
readSectors += read
writeSectors += write
}
return readSectors * 512, writeSectors * 512
}
func readUintFile(path string) uint64 {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
value, _ := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64)
return value
}
func unixNano() int64 {
return time.Now().UnixNano()
}
func getLoadInfo() LoadInfo {
f, err := os.Open("/proc/loadavg")
if err != nil {
return LoadInfo{}
}
defer f.Close()
scanner := bufio.NewScanner(f)
if !scanner.Scan() {
return LoadInfo{}
}
fields := strings.Fields(scanner.Text())
if len(fields) < 3 {
return LoadInfo{}
}
load1, _ := strconv.ParseFloat(fields[0], 64)
load5, _ := strconv.ParseFloat(fields[1], 64)
load15, _ := strconv.ParseFloat(fields[2], 64)
return LoadInfo{Load1: load1, Load5: load5, Load15: load15}
}
func HandleHostReport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
return
}
if !requireScope(w, r, "host:read") {
return
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: getHostProbeReport()})
}
func getHostProbeReport() HostProbeReport {
host, _ := os.Hostname()
mem := getMemoryInfo()
report := HostProbeReport{
GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
Hostname: host,
Kernel: strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-srmo")),
OS: detectOSRelease(),
CPU: detectHostCPUProbe(),
Memory: HostMemoryProbe{TotalMB: mem.TotalMB, UsedMB: mem.UsedMB, FreeMB: mem.FreeMB, Modules: detectMemoryModules()},
Disks: detectHostDisks(),
NetworkInterfaces: detectHostNICs(),
PublicIPv4: detectAllPublicIPv4(),
IPv6Prefixes: lxc.DetectHostPublicIPv6Prefixes(),
Gateways: detectGateways(),
GPUs: detectGPUs(),
System: detectSystemProbe(),
Environment: detectHostEnvironment(),
}
report.IPv6Addresses = collectIPv6Addresses(report.NetworkInterfaces)
report.IPv4Addresses = collectIPv4Addresses(report.NetworkInterfaces)
report.IPv4Prefixes = detectPublicIPv4Prefixes(report.NetworkInterfaces, report.Gateways)
report.Runtime = detectRuntimeProbe(report.Environment)
report.CPU.HasIntegratedGPU = hasIntegratedGPU(report.GPUs)
return report
}
func detectOSRelease() string {
values := readKeyValueFile("/etc/os-release", "=")
if pretty := trimOSReleaseValue(values["PRETTY_NAME"]); pretty != "" {
return pretty
}
if name := trimOSReleaseValue(values["NAME"]); name != "" {
return name
}
return strings.TrimSpace(runCommandOutput(2*time.Second, "uname", "-o"))
}
func trimOSReleaseValue(value string) string {
return strings.Trim(strings.TrimSpace(value), `"`)
}
func detectHostCPUProbe() HostCPUProbe {
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
seenFlags := map[string]bool{}
for _, line := range strings.Split(string(data), "\n") {
fields := strings.SplitN(line, ":", 2)
if len(fields) != 2 {
continue
}
key := strings.TrimSpace(fields[0])
value := strings.TrimSpace(fields[1])
switch key {
case "model name", "Hardware", "Processor":
if probe.Model == "" {
probe.Model = value
}
case "cpu cores":
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
probe.Cores = cores
}
case "flags", "Features":
for _, flag := range strings.Fields(value) {
if flag == "vmx" || flag == "svm" {
probe.Virtualization = true
probe.VirtualizationKey = flag
}
if !seenFlags[flag] {
seenFlags[flag] = true
probe.Flags = append(probe.Flags, flag)
}
}
}
}
sort.Strings(probe.Flags)
}
if probe.Model == "" {
probe.Model = "Unknown"
}
return probe
}
func detectMemoryModules() []HostMemoryModule {
if !commandExists("dmidecode") {
return nil
}
out := runCommandOutput(4*time.Second, "dmidecode", "-t", "memory")
modules := make([]HostMemoryModule, 0)
var current HostMemoryModule
inDevice := false
flush := func() {
if !inDevice {
return
}
if current.Size != "" && !strings.EqualFold(current.Size, "No Module Installed") {
modules = append(modules, current)
}
current = HostMemoryModule{}
}
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(line, "Memory Device") {
flush()
inDevice = true
continue
}
if !inDevice {
continue
}
parts := strings.SplitN(strings.TrimSpace(line), ":", 2)
if len(parts) != 2 {
continue
}
key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
switch key {
case "Locator":
current.Locator = value
case "Size":
current.Size = value
case "Type":
current.Type = value
case "Speed":
current.Speed = value
case "Manufacturer":
current.Manufacturer = value
case "Part Number":
current.PartNumber = value
case "Serial Number":
current.SerialNumber = value
}
}
flush()
return modules
}
func detectHostDisks() []HostDiskProbe {
disks := make([]HostDiskProbe, 0)
entries, err := os.ReadDir("/sys/block")
if err != nil {
return disks
}
mounts := detectMountpointsByDevice()
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "fd") || strings.HasPrefix(name, "sr") {
continue
}
base := filepath.Join("/sys/block", name)
path := "/dev/" + name
model := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/model"), filepath.Join(base, "device/name")))
vendor := strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/vendor")))
virtual := isVirtualBlockDevice(name, model, vendor)
disk := HostDiskProbe{
Name: name,
Path: path,
Model: model,
Serial: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "device/serial"), filepath.Join(base, "serial"))),
SizeBytes: readUintFile(filepath.Join(base, "size")) * 512,
Type: detectDiskType(base, name, virtual),
Virtual: virtual,
Rotational: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1",
Mountpoints: mounts[name],
}
if !virtual {
disk.SMART = detectDiskSMART(path)
}
disk.Health = disk.SMARTHealth()
disk.HealthDetail = disk.SMARTDetail()
disks = append(disks, disk)
}
sort.Slice(disks, func(i, j int) bool { return disks[i].Name < disks[j].Name })
return disks
}
func detectDiskType(base, name string, virtual bool) string {
if virtual {
return "Virtual"
}
if strings.HasPrefix(name, "nvme") {
return "NVMe"
}
if strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "queue/rotational"))) == "1" {
return "HDD"
}
return "SSD"
}
func isVirtualBlockDevice(name, model, vendor string) bool {
lower := strings.ToLower(strings.TrimSpace(name + " " + model + " " + vendor))
if strings.HasPrefix(name, "vd") || strings.HasPrefix(name, "xvd") {
return true
}
for _, token := range []string{
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
"amazon elastic block store", "google persistentdisk", "microsoft",
} {
if strings.Contains(lower, token) {
return true
}
}
return false
}
func (disk HostDiskProbe) SMARTHealth() string {
if disk.Virtual {
return "virtual"
}
if disk.SMART.Available && disk.Health != "" {
return disk.Health
}
return disk.SMART.Health()
}
func (disk HostDiskProbe) SMARTDetail() string {
if disk.Virtual {
return "虚拟磁盘,真实 SMART/寿命/通电数据需在物理宿主机查看"
}
return disk.SMART.Detail()
}
func (smart HostDiskSMARTProbe) Health() string {
if !smart.Available {
return "unknown"
}
if smart.MediaErrors > 0 {
return "failed"
}
return "ok"
}
func (smart HostDiskSMARTProbe) Detail() string {
if !smart.Available {
return "smartctl not installed or no SMART output"
}
parts := []string{"SMART passed"}
if smart.LifeUsedPercent != nil {
parts = append(parts, fmt.Sprintf("寿命已用 %d%%", *smart.LifeUsedPercent))
}
if smart.PowerOnHours > 0 {
parts = append(parts, fmt.Sprintf("通电 %dh", smart.PowerOnHours))
}
if smart.WrittenDataBytes > 0 {
parts = append(parts, fmt.Sprintf("写入 %s", formatBytesText(smart.WrittenDataBytes)))
}
if smart.ReadDataBytes > 0 {
parts = append(parts, fmt.Sprintf("读取 %s", formatBytesText(smart.ReadDataBytes)))
}
if smart.MediaErrors > 0 {
parts = append(parts, fmt.Sprintf("介质错误 %d", smart.MediaErrors))
}
return strings.Join(parts, " | ")
}
func detectDiskSMART(path string) HostDiskSMARTProbe {
smart := HostDiskSMARTProbe{}
if !commandExists("smartctl") {
return smart
}
out := runCommandOutput(8*time.Second, "smartctl", "-a", "-j", path)
if strings.TrimSpace(out) == "" {
return smart
}
var data smartctlOutput
if err := json.Unmarshal([]byte(out), &data); err != nil {
return smart
}
smart.Available = true
smart.PowerOnHours = data.PowerOnTime.Hours
smart.PowerCycleCount = data.PowerCycleCount
if data.NVMe.PowerOnHours > 0 {
smart.PowerOnHours = uint64ToInt64(data.NVMe.PowerOnHours)
}
if data.NVMe.PowerCycles > 0 {
smart.PowerCycleCount = uint64ToInt64(data.NVMe.PowerCycles)
}
if data.NVMe.PercentageUsed > 0 {
if used, ok := smartPercentToInt(data.NVMe.PercentageUsed); ok {
smart.LifeUsedPercent = &used
}
}
smart.ReadDataBytes = data.NVMe.DataUnitsRead * 512000
smart.WrittenDataBytes = data.NVMe.DataUnitsWritten * 512000
smart.ReadCommands = data.NVMe.HostReadCommands
smart.WriteCommands = data.NVMe.HostWriteCommands
smart.MediaErrors = data.NVMe.MediaErrors
parseATAAttributes(&smart, data.ATASmartAttributes.Table)
if data.SmartStatus != nil && !data.SmartStatus.Passed {
smart.MediaErrors++
}
return smart
}
type smartctlOutput struct {
SmartStatus *struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
PowerOnTime struct {
Hours int64 `json:"hours"`
} `json:"power_on_time"`
PowerCycleCount int64 `json:"power_cycle_count"`
ATASmartAttributes struct {
Table []smartctlAttribute `json:"table"`
} `json:"ata_smart_attributes"`
NVMe struct {
PercentageUsed uint64 `json:"percentage_used"`
DataUnitsRead uint64 `json:"data_units_read"`
DataUnitsWritten uint64 `json:"data_units_written"`
HostReadCommands uint64 `json:"host_reads"`
HostWriteCommands uint64 `json:"host_writes"`
PowerOnHours uint64 `json:"power_on_hours"`
PowerCycles uint64 `json:"power_cycles"`
MediaErrors uint64 `json:"media_errors"`
} `json:"nvme_smart_health_information_log"`
}
type smartctlAttribute struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Raw struct {
Value json.Number `json:"value"`
String string `json:"string"`
} `json:"raw"`
}
func parseATAAttributes(smart *HostDiskSMARTProbe, attrs []smartctlAttribute) {
for _, attr := range attrs {
name := normalizeSMARTAttrName(attr.Name)
raw := smartAttrRawUint(attr)
rawText := smartAttrRawText(attr)
switch name {
case "poweronhours":
if smart.PowerOnHours == 0 {
if parsed, ok := smartAttrRawInt64(attr); ok {
smart.PowerOnHours = parsed
}
}
case "powercyclecount":
if smart.PowerCycleCount == 0 {
if parsed, ok := smartAttrRawInt64(attr); ok {
smart.PowerCycleCount = parsed
}
}
case "totallbaswritten":
if smart.WrittenDataBytes == 0 {
smart.WrittenDataBytes = raw * 512
}
case "totallbasread":
if smart.ReadDataBytes == 0 {
smart.ReadDataBytes = raw * 512
}
case "hostwrites32mib":
if smart.WrittenDataBytes == 0 {
smart.WrittenDataBytes = raw * 32 * 1024 * 1024
}
case "hostreads32mib":
if smart.ReadDataBytes == 0 {
smart.ReadDataBytes = raw * 32 * 1024 * 1024
}
case "hostwritecommands":
smart.WriteCommands = raw
case "hostreadcommands":
smart.ReadCommands = raw
case "wearlevelingcount":
smart.WearLevelingCount = rawText
if smart.LifeUsedPercent == nil && attr.Value > 0 && attr.Value <= 100 {
used := 100 - attr.Value
if used < 0 {
used = 0
}
smart.LifeUsedPercent = &used
}
case "percentlifetimeremain", "mediawearoutindicator":
if smart.LifeUsedPercent == nil {
remaining := attr.Value
if raw > 0 && raw <= 100 {
if parsed, ok := smartPercentToInt(raw); ok {
remaining = parsed
}
}
used := 100 - remaining
if used < 0 {
used = 0
}
if used <= 100 {
smart.LifeUsedPercent = &used
}
}
case "percentageused":
if smart.LifeUsedPercent == nil && raw <= 255 {
if used, ok := smartPercentToInt(raw); ok {
smart.LifeUsedPercent = &used
}
}
case "erasefailcounttotal", "erasecount", "nandwrites", "programfailcnttotal":
if smart.EraseCount == "" && rawText != "" {
smart.EraseCount = rawText
}
case "mediaerrors":
smart.MediaErrors = raw
}
}
}
func uint64ToInt64(value uint64) int64 {
if value > 9223372036854775807 {
return 0
}
return int64(value)
}
func smartPercentToInt(value uint64) (int, bool) {
if value > 2147483647 {
return 0, false
}
return int(value), true
}
func normalizeSMARTAttrName(name string) string {
name = strings.ToLower(name)
var b strings.Builder
for _, ch := range name {
if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' {
b.WriteRune(ch)
}
}
return b.String()
}
func smartAttrRawText(attr smartctlAttribute) string {
if attr.Raw.String != "" {
return attr.Raw.String
}
if attr.Raw.Value != "" {
return attr.Raw.Value.String()
}
return ""
}
func smartAttrRawInt64(attr smartctlAttribute) (int64, bool) {
text := smartAttrRawText(attr)
if value, err := strconv.ParseInt(text, 10, 64); err == nil {
return value, true
}
digits := firstUintText(text)
if digits == "" {
return 0, false
}
value, err := strconv.ParseInt(digits, 10, 64)
if err != nil {
return 0, false
}
return value, true
}
func smartAttrRawUint(attr smartctlAttribute) uint64 {
text := smartAttrRawText(attr)
if value, err := strconv.ParseUint(text, 10, 64); err == nil {
return value
}
digits := firstUintText(text)
if digits == "" {
return 0
}
value, _ := strconv.ParseUint(digits, 10, 64)
return value
}
func firstUintText(value string) string {
start := -1
for i, ch := range value {
if ch >= '0' && ch <= '9' {
if start < 0 {
start = i
}
continue
}
if start >= 0 {
return value[start:i]
}
}
if start >= 0 {
return value[start:]
}
return ""
}
func detectMountpointsByDevice() map[string][]string {
result := map[string][]string{}
f, err := os.Open("/proc/mounts")
if err != nil {
return result
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 || !strings.HasPrefix(fields[0], "/dev/") {
continue
}
dev := strings.TrimPrefix(filepath.Base(fields[0]), "/dev/")
parent := diskParentName(dev)
result[parent] = append(result[parent], fields[1])
}
return result
}
func diskParentName(dev string) string {
for _, suffix := range []string{"p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"} {
if strings.HasSuffix(dev, suffix) && strings.HasPrefix(dev, "nvme") {
return strings.TrimSuffix(dev, suffix)
}
}
for len(dev) > 0 && dev[len(dev)-1] >= '0' && dev[len(dev)-1] <= '9' {
dev = dev[:len(dev)-1]
}
return dev
}
func detectHostNICs() []HostNICProbe {
entries, err := os.ReadDir("/sys/class/net")
if err != nil {
return nil
}
ipv4, ipv6 := detectInterfaceIPs()
nics := make([]HostNICProbe, 0)
for _, entry := range entries {
name := entry.Name()
if name == "lo" {
continue
}
base := filepath.Join("/sys/class/net", name)
speed, _ := strconv.Atoi(strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "speed"))))
nic := HostNICProbe{
Name: name,
MAC: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "address"))),
State: strings.TrimSpace(readFirstExistingFile(filepath.Join(base, "operstate"))),
SpeedMbps: speed,
Driver: strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("basename $(readlink -f /sys/class/net/%s/device/driver 2>/dev/null) 2>/dev/null", shellQuoteSimple(name)))),
Model: detectNICModel(name),
IPv4: ipv4[name],
IPv6: ipv6[name],
}
nics = append(nics, nic)
}
sort.Slice(nics, func(i, j int) bool { return nics[i].Name < nics[j].Name })
return nics
}
func detectNICModel(name string) string {
out := runCommandOutput(2*time.Second, "sh", "-c", fmt.Sprintf("lspci -D 2>/dev/null | grep -iE 'ethernet|network' | head -n 1 || true"))
if out != "" {
return strings.TrimSpace(out)
}
return strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "device", "uevent")))
}
func detectInterfaceIPs() (map[string][]HostIPProbe, map[string][]HostIPProbe) {
ipv4 := map[string][]HostIPProbe{}
ipv6 := map[string][]HostIPProbe{}
for _, family := range []struct {
arg string
dst map[string][]HostIPProbe
}{{"-4", ipv4}, {"-6", ipv6}} {
out := runCommandOutput(3*time.Second, "ip", "-o", family.arg, "addr", "show")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
iface := strings.TrimSuffix(fields[1], ":")
addr := fields[3]
ip, network, err := net.ParseCIDR(addr)
if err != nil || ip == nil || network == nil {
continue
}
ones, _ := network.Mask.Size()
scope := ""
for i, field := range fields {
if field == "scope" && i+1 < len(fields) {
scope = fields[i+1]
}
}
family.dst[iface] = append(family.dst[iface], HostIPProbe{
Interface: iface,
Address: ip.String(),
PrefixLen: ones,
Scope: scope,
})
}
}
return ipv4, ipv6
}
func detectAllPublicIPv4() []string {
seen := map[string]bool{}
result := make([]string, 0)
if pub := lxc.DetectPublicIPv4(); pub.Address != "" {
if ip := net.ParseIP(pub.Address); isPublicIPv4(ip) {
seen[pub.Address] = true
result = append(result, pub.Address)
}
}
out := runCommandOutput(3*time.Second, "ip", "-o", "-4", "addr", "show", "scope", "global")
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
ip, _, err := net.ParseCIDR(fields[3])
if err != nil || ip == nil {
continue
}
if !isPublicIPv4(ip) {
continue
}
value := ip.String()
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
return result
}
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
for _, ip := range nic.IPv4 {
parsed := net.ParseIP(ip.Address)
if isPublicIPv4(parsed) {
result = append(result, ip)
}
}
}
return result
}
func detectPublicIPv4Prefixes(nics []HostNICProbe, gateways []HostGatewayProbe) []HostIPv4PrefixProbe {
result := make([]HostIPv4PrefixProbe, 0)
seen := map[string]bool{}
gatewayByIface := map[string]string{}
for _, gateway := range gateways {
if gateway.Family == "ipv4" && gateway.Interface != "" && gateway.Gateway != "" {
gatewayByIface[gateway.Interface] = gateway.Gateway
}
}
add := func(item HostIPv4PrefixProbe) {
if item.Prefix == "" || item.Interface == "" {
return
}
key := item.Interface + "|" + item.Prefix
if seen[key] {
return
}
seen[key] = true
result = append(result, item)
}
for _, nic := range nics {
for _, ip := range nic.IPv4 {
parsed := net.ParseIP(ip.Address)
if !isPublicIPv4(parsed) || ip.PrefixLen <= 0 || ip.PrefixLen > 32 {
continue
}
prefix, subnet := ipv4PrefixAndMask(parsed, ip.PrefixLen)
add(HostIPv4PrefixProbe{
Interface: nic.Name,
Address: ip.Address,
Prefix: prefix,
PrefixLen: ip.PrefixLen,
SubnetMask: subnet,
Gateway: gatewayByIface[nic.Name],
Source: "address",
})
}
}
for _, item := range detectIPv4RoutePrefixes(gatewayByIface) {
add(item)
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Interface == result[j].Interface {
return result[i].Prefix < result[j].Prefix
}
return result[i].Interface < result[j].Interface
})
return result
}
func detectIPv4RoutePrefixes(gatewayByIface map[string]string) []HostIPv4PrefixProbe {
out := runCommandOutput(3*time.Second, "ip", "-4", "route", "show")
result := make([]HostIPv4PrefixProbe, 0)
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] == "default" {
continue
}
_, network, err := net.ParseCIDR(fields[0])
if err != nil || network == nil || network.IP.To4() == nil {
continue
}
if !isPublicIPv4(network.IP) {
continue
}
iface := ""
gateway := ""
src := ""
for i, field := range fields {
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
if field == "via" && i+1 < len(fields) {
gateway = fields[i+1]
}
if field == "src" && i+1 < len(fields) {
src = fields[i+1]
}
}
if iface == "" || isContainerLikeInterfaceName(iface) {
continue
}
ones, bits := network.Mask.Size()
if bits != 32 || ones <= 0 || ones > 32 {
continue
}
if gateway == "" {
gateway = gatewayByIface[iface]
}
prefix, subnet := ipv4PrefixAndMask(network.IP, ones)
result = append(result, HostIPv4PrefixProbe{
Interface: iface,
Address: src,
Prefix: prefix,
PrefixLen: ones,
SubnetMask: subnet,
Gateway: gateway,
Source: "route",
})
}
return result
}
func ipv4PrefixAndMask(ip net.IP, prefixLen int) (string, string) {
v4 := ip.To4()
if v4 == nil {
return "", ""
}
mask := net.CIDRMask(prefixLen, 32)
network := v4.Mask(mask)
subnet := fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3])
return fmt.Sprintf("%s/%d", network.String(), prefixLen), subnet
}
func isPublicIPv4(ip net.IP) bool {
ip = ip.To4()
if ip == nil {
return false
}
return !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsMulticast() && !ip.IsUnspecified()
}
func isContainerLikeInterfaceName(iface string) bool {
prefixes := []string{"lo", "lxc", "docker", "br-", "veth", "virbr", "cni", "flannel", "cali", "kube", "dummy", "ifb"}
for _, prefix := range prefixes {
if iface == prefix || strings.HasPrefix(iface, prefix) {
return true
}
}
return false
}
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
result := make([]HostIPProbe, 0)
for _, nic := range nics {
for _, ip := range nic.IPv6 {
if ip.Scope == "global" {
result = append(result, ip)
}
}
}
return result
}
func detectGateways() []HostGatewayProbe {
gateways := make([]HostGatewayProbe, 0)
for _, item := range []struct {
family string
args []string
}{{"ipv4", []string{"-4", "route", "show", "default"}}, {"ipv6", []string{"-6", "route", "show", "default"}}} {
out := runCommandOutput(3*time.Second, "ip", item.args...)
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
gw := ""
iface := ""
for i, field := range fields {
if field == "via" && i+1 < len(fields) {
gw = fields[i+1]
}
if field == "dev" && i+1 < len(fields) {
iface = fields[i+1]
}
}
if gw != "" || iface != "" {
gateways = append(gateways, HostGatewayProbe{Family: item.family, Interface: iface, Gateway: gw})
}
}
}
return gateways
}
func detectGPUs() []HostGPUProbe {
gpus := make([]HostGPUProbe, 0)
out := runCommandOutput(3*time.Second, "sh", "-c", "lspci -nnk 2>/dev/null | grep -iEA3 'vga|3d|display' || true")
var current *HostGPUProbe
for _, line := range strings.Split(out, "\n") {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "vga") || strings.Contains(lower, "3d controller") || strings.Contains(lower, "display controller") {
gpus = append(gpus, HostGPUProbe{Name: trimmed, Vendor: detectGPUVendor(trimmed), Type: detectGPUType(trimmed)})
current = &gpus[len(gpus)-1]
continue
}
if current != nil && strings.HasPrefix(trimmed, "Kernel driver in use:") {
current.Driver = strings.TrimSpace(strings.TrimPrefix(trimmed, "Kernel driver in use:"))
}
}
return gpus
}
func detectGPUVendor(value string) string {
lower := strings.ToLower(value)
switch {
case strings.Contains(lower, "intel"):
return "Intel"
case strings.Contains(lower, "nvidia"):
return "NVIDIA"
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
return "AMD"
default:
return "Unknown"
}
}
func detectGPUType(value string) string {
lower := strings.ToLower(value)
if strings.Contains(lower, "intel") {
return "integrated"
}
return "discrete"
}
func hasIntegratedGPU(gpus []HostGPUProbe) bool {
for _, gpu := range gpus {
if gpu.Type == "integrated" {
return true
}
}
return false
}
func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
devKVM := fileExists("/dev/kvm")
nested, detail := detectNestedVirtualization()
lxcOK := envCheckOK(env, "lxc-create")
probe := HostRuntimeProbe{
LXCAvailable: lxcOK,
KVMAvailable: devKVM && envCheckOK(env, "virsh"),
DevKVM: devKVM,
NestedVirtualization: nested,
NestedDetail: detail,
SupportMode: "unsupported",
}
if probe.KVMAvailable {
probe.SupportMode = "kvm_lxc"
} else if probe.LXCAvailable {
probe.SupportMode = "lxc_only"
}
return probe
}
func detectNestedVirtualization() (bool, string) {
paths := []string{
"/sys/module/kvm_intel/parameters/nested",
"/sys/module/kvm_amd/parameters/nested",
}
for _, path := range paths {
value := strings.TrimSpace(readFirstExistingFile(path))
if value == "" {
continue
}
enabled := strings.EqualFold(value, "Y") || value == "1"
return enabled, filepath.Base(filepath.Dir(filepath.Dir(path))) + "=" + value
}
if fileExists("/dev/kvm") {
return true, "/dev/kvm present"
}
return false, "no kvm nested parameter or /dev/kvm"
}
func detectSystemProbe() HostSystemProbe {
uptime := int64(0)
if data, err := os.ReadFile("/proc/uptime"); err == nil {
first := strings.Fields(string(data))
if len(first) > 0 {
value, _ := strconv.ParseFloat(first[0], 64)
uptime = int64(value)
}
}
return HostSystemProbe{
UptimeSeconds: uptime,
UptimeText: formatDurationText(uptime),
ProcessCount: countProcesses(),
}
}
func detectHostEnvironment() []HostEnvCheck {
checks := []HostEnvCheck{
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
commandCheck("lxc-start", "LXC 启动工具", true, "lxc-start", ""),
commandCheck("iptables", "iptables 网络规则", true, "iptables", ""),
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""),
commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
certbotCheck(),
}
checks = append(checks, HostEnvCheck{Key: "dev-kvm", Label: "/dev/kvm 硬件虚拟化", OK: fileExists("/dev/kvm"), Required: false, Detail: boolDetail(fileExists("/dev/kvm"))})
checks = append(checks, HostEnvCheck{Key: "ipv4-forward", Label: "IPv4 转发", OK: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward")) == "1", Required: true, Detail: strings.TrimSpace(readFirstExistingFile("/proc/sys/net/ipv4/ip_forward"))})
checks = append(checks, HostEnvCheck{Key: "lxcfs", Label: "lxcfs 服务", OK: serviceActive("lxcfs"), Required: false, Detail: serviceDetail("lxcfs")})
checks = append(checks, HostEnvCheck{Key: "libvirt", Label: "libvirt 服务", OK: serviceActive("libvirtd") || serviceActive("virtqemud"), Required: false, Detail: firstNonEmpty(serviceDetail("libvirtd"), serviceDetail("virtqemud"))})
return checks
}
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
ok := commandExists(cmd)
detail := "missing"
if ok {
detail = commandVersionDetail(cmd)
if detail == "" {
detail = "installed"
}
} else if fallback != "" {
detail = fallback
}
return HostEnvCheck{Key: key, Label: label, OK: ok, Required: required, Detail: detail}
}
func commandVersionDetail(cmd string) string {
switch cmd {
case "ip":
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", "ip -V 2>&1 | head -n 1"))
default:
return strings.TrimSpace(runCommandOutput(2*time.Second, "sh", "-c", cmd+" --version 2>&1 | head -n 1"))
}
}
func certbotCheck() HostEnvCheck {
check := HostEnvCheck{Key: "certbot", Label: "Certbot 证书工具 >= 5.4", Required: false, Detail: "missing"}
if !commandExists("certbot") {
return check
}
detail := strings.TrimSpace(runCommandOutput(3*time.Second, "certbot", "--version"))
if detail == "" {
detail = strings.TrimSpace(runCommandOutput(3*time.Second, "sh", "-c", "certbot --version 2>&1 | head -n 1"))
}
if detail == "" {
detail = "installed, version unknown"
}
check.Detail = detail
version := extractCertbotVersion(detail)
check.OK = certbotVersionAtLeast(version, 5, 4)
if version == "" {
check.Detail = detail + " (version unknown, need >= 5.4)"
} else if !check.OK {
check.Detail = detail + " (need >= 5.4)"
}
return check
}
func extractCertbotVersion(output string) string {
for _, field := range strings.Fields(output) {
field = strings.Trim(field, "vV,;:()[]{}")
if field == "" || field[0] < '0' || field[0] > '9' {
continue
}
return field
}
return ""
}
func certbotVersionAtLeast(version string, minMajor, minMinor int) bool {
parts := strings.Split(version, ".")
if len(parts) < 2 {
return false
}
major, err := strconv.Atoi(numericPrefix(parts[0]))
if err != nil {
return false
}
minor, err := strconv.Atoi(numericPrefix(parts[1]))
if err != nil {
return false
}
if major != minMajor {
return major > minMajor
}
return minor >= minMinor
}
func numericPrefix(value string) string {
var b strings.Builder
for _, r := range value {
if r < '0' || r > '9' {
break
}
b.WriteRune(r)
}
return b.String()
}
func envCheckOK(checks []HostEnvCheck, key string) bool {
for _, check := range checks {
if check.Key == key {
return check.OK
}
}
return false
}
func serviceActive(name string) bool {
if commandExists("systemctl") {
return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name)) == "active"
}
if commandExists("rc-service") {
return strings.Contains(runCommandOutput(2*time.Second, "rc-service", name, "status"), "started")
}
return false
}
func serviceDetail(name string) string {
if commandExists("systemctl") {
return strings.TrimSpace(runCommandOutput(2*time.Second, "systemctl", "is-active", name))
}
if commandExists("rc-service") {
return strings.TrimSpace(runCommandOutput(2*time.Second, "rc-service", name, "status"))
}
return "unknown"
}
func readKeyValueFile(path, sep string) map[string]string {
result := map[string]string{}
data, err := os.ReadFile(path)
if err != nil {
return result
}
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, sep, 2)
if len(parts) == 2 {
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
return result
}
func readFirstExistingFile(paths ...string) string {
for _, path := range paths {
data, err := os.ReadFile(path)
if err == nil {
return string(data)
}
}
return ""
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func runCommandOutput(timeout time.Duration, name string, args ...string) string {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil && len(out) == 0 {
return ""
}
return strings.TrimSpace(string(out))
}
func shortCommandDetail(value string) string {
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
if len(lines) > 4 {
lines = lines[:4]
}
return strings.Join(lines, " | ")
}
func shellQuoteSimple(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func formatDurationText(seconds int64) string {
days := seconds / 86400
seconds %= 86400
hours := seconds / 3600
seconds %= 3600
minutes := seconds / 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
return fmt.Sprintf("%dh %dm", hours, minutes)
}
func formatBytesText(value uint64) string {
if value == 0 {
return "0 B"
}
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
next := float64(value)
index := 0
for next >= 1024 && index < len(units)-1 {
next /= 1024
index++
}
if index == 0 {
return fmt.Sprintf("%d %s", value, units[index])
}
return fmt.Sprintf("%.1f %s", next, units[index])
}
func countProcesses() int {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
count := 0
for _, entry := range entries {
if _, err := strconv.Atoi(entry.Name()); err == nil {
count++
}
}
return count
}
func boolDetail(ok bool) string {
if ok {
return "available"
}
return "missing"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && value != "inactive" && value != "unknown" {
return value
}
}
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}