// Package cmd provides command implementations for the Unraid Management Agent.
package cmd
import (
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/services"
)
// Boot represents the boot command that starts the Unraid Management Agent.
type Boot struct{}
// Run executes the boot command by creating and running the orchestrator.
func (b *Boot) Run(ctx *domain.Context) error {
return services.CreateOrchestrator(ctx).Run()
}
package dto
import "time"
// NUTStatus contains detailed NUT (Network UPS Tools) status information.
// This provides more comprehensive UPS data than the basic UPSStatus struct.
type NUTStatus struct {
// Connection and detection info
Connected bool `json:"connected"`
DeviceName string `json:"device_name"` // e.g., "ups"
Host string `json:"host"` // e.g., "localhost" or remote IP
Driver string `json:"driver"` // e.g., "usbhid-ups"
DriverState string `json:"driver_state"` // e.g., "quiet", "running"
// UPS identification
Manufacturer string `json:"manufacturer"`
Model string `json:"model"`
Serial string `json:"serial"`
Type string `json:"type"` // e.g., "ups"
// UPS status
Status string `json:"status"` // e.g., "OL" (Online), "OB" (On Battery)
StatusText string `json:"status_text"` // Human-readable status
Alarms []string `json:"alarms"` // Active alarms if any
BeeperStatus string `json:"beeper_status"` // e.g., "enabled", "disabled"
TestResult string `json:"test_result"` // Last test result
TestResultDate string `json:"test_result_date"`
// Battery info
BatteryCharge float64 `json:"battery_charge_percent"`
BatteryChargeLow float64 `json:"battery_charge_low_percent"`
BatteryChargeWarning float64 `json:"battery_charge_warning_percent"`
BatteryRuntime int `json:"battery_runtime_seconds"`
BatteryRuntimeLow int `json:"battery_runtime_low_seconds"`
BatteryVoltage float64 `json:"battery_voltage"`
BatteryVoltageNominal float64 `json:"battery_voltage_nominal"`
BatteryType string `json:"battery_type"` // e.g., "PbAcid"
BatteryStatus string `json:"battery_status"`
BatteryMfrDate string `json:"battery_mfr_date"`
// Input power
InputVoltage float64 `json:"input_voltage"`
InputVoltageNominal float64 `json:"input_voltage_nominal"`
InputFrequency float64 `json:"input_frequency"`
InputTransferHigh float64 `json:"input_transfer_high"`
InputTransferLow float64 `json:"input_transfer_low"`
InputCurrent float64 `json:"input_current"`
// Output power
OutputVoltage float64 `json:"output_voltage"`
OutputFrequency float64 `json:"output_frequency"`
OutputCurrent float64 `json:"output_current"`
// Load and power
LoadPercent float64 `json:"load_percent"`
RealPower float64 `json:"realpower_watts"`
RealPowerNominal float64 `json:"realpower_nominal_watts"`
ApparentPower float64 `json:"apparent_power_va"`
ApparentPowerNominal float64 `json:"apparent_power_nominal_va"`
// Timing
DelayShutdown int `json:"delay_shutdown_seconds"`
DelayStart int `json:"delay_start_seconds"`
TimerShutdown int `json:"timer_shutdown"`
TimerStart int `json:"timer_start"`
// Driver info
DriverVersion string `json:"driver_version"`
DriverVersionData string `json:"driver_version_data"`
DriverVersionUSB string `json:"driver_version_usb"`
ProductID string `json:"product_id"`
VendorID string `json:"vendor_id"`
// Raw variables for advanced users
RawVariables map[string]string `json:"raw_variables,omitempty"`
// Metadata
Timestamp time.Time `json:"timestamp"`
}
// NUTConfig represents the NUT plugin configuration from nut-dw.cfg
type NUTConfig struct {
ServiceEnabled bool `json:"service_enabled"`
Mode string `json:"mode"` // "standalone", "netserver", "netclient"
UPSName string `json:"ups_name"` // e.g., "ups"
Driver string `json:"driver"` // e.g., "usbhid-ups"
Port string `json:"port"` // e.g., "auto"
IPAddress string `json:"ip_address"` // For netclient mode
PollInterval int `json:"poll_interval"` // Seconds between polls
ShutdownMode string `json:"shutdown_mode"` // e.g., "sec_timer", "fsd"
BatteryLevel int `json:"battery_level"` // Low battery threshold
RuntimeValue int `json:"runtime_value"` // Low runtime threshold
Timeout int `json:"timeout"` // Shutdown timeout
}
// NUTDevice represents a single NUT UPS device
type NUTDevice struct {
Name string `json:"name"`
Description string `json:"description"`
Available bool `json:"available"`
}
// NUTResponse is the complete response for the /api/v1/nut endpoint
type NUTResponse struct {
Installed bool `json:"installed"` // Is NUT plugin installed?
Running bool `json:"running"` // Is NUT service running?
Config *NUTConfig `json:"config,omitempty"`
Devices []NUTDevice `json:"devices,omitempty"`
Status *NUTStatus `json:"status,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// NUTStatusText converts NUT status codes to human-readable text
func NUTStatusText(status string) string {
statusMap := map[string]string{
"OL": "Online",
"OB": "On Battery",
"LB": "Low Battery",
"HB": "High Battery",
"RB": "Replace Battery",
"CHRG": "Charging",
"DISCHRG": "Discharging",
"BYPASS": "Bypass",
"CAL": "Calibrating",
"OFF": "Offline",
"OVER": "Overloaded",
"TRIM": "Trimming Voltage",
"BOOST": "Boosting Voltage",
"FSD": "Forced Shutdown",
}
// Handle multiple status codes (e.g., "OL CHRG")
if text, ok := statusMap[status]; ok {
return text
}
return status
}
package lib
import (
"fmt"
"strconv"
"strings"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
)
// ParseDmidecodeType parses dmidecode output for a specific type
// Returns a map of sections, where each section is a map of key-value pairs
func ParseDmidecodeType(typeNum string) ([]map[string]string, error) {
output, err := ExecCommandOutput("dmidecode", "-t", typeNum)
if err != nil {
return nil, fmt.Errorf("failed to execute dmidecode: %w", err)
}
return parseDmidecodeOutput(output), nil
}
// parseDmidecodeOutput parses dmidecode output into sections
func parseDmidecodeOutput(output string) []map[string]string {
var sections []map[string]string
var currentSection map[string]string
var currentKey string
lines := strings.Split(output, "\n")
for _, line := range lines {
// Skip empty lines and header lines
if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "SMBIOS") || strings.HasPrefix(line, "Getting") {
continue
}
// New section starts with non-indented line
if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
if len(currentSection) > 0 {
sections = append(sections, currentSection)
}
currentSection = make(map[string]string)
currentKey = ""
continue
}
// Parse key-value pairs
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, ":") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
currentSection[key] = value
currentKey = key
}
} else if currentKey != "" {
// Continuation of previous value (multi-line)
if existing, ok := currentSection[currentKey]; ok {
currentSection[currentKey] = existing + " " + trimmed
}
}
}
// Add last section
if len(currentSection) > 0 {
sections = append(sections, currentSection)
}
return sections
}
// ParseBIOSInfo parses BIOS information from dmidecode type 0
func ParseBIOSInfo() (*dto.BIOSInfo, error) {
sections, err := ParseDmidecodeType("0")
if err != nil {
return nil, err
}
if len(sections) == 0 {
return nil, fmt.Errorf("no BIOS information found")
}
section := sections[0]
bios := &dto.BIOSInfo{
Vendor: section["Vendor"],
Version: section["Version"],
ReleaseDate: section["Release Date"],
Address: section["Address"],
RuntimeSize: section["Runtime Size"],
ROMSize: section["ROM Size"],
Revision: section["BIOS Revision"],
}
// Parse characteristics
if chars, ok := section["Characteristics"]; ok {
bios.Characteristics = strings.Split(chars, ",")
for i := range bios.Characteristics {
bios.Characteristics[i] = strings.TrimSpace(bios.Characteristics[i])
}
}
return bios, nil
}
// ParseBaseboardInfo parses baseboard information from dmidecode type 2
func ParseBaseboardInfo() (*dto.BaseboardInfo, error) {
sections, err := ParseDmidecodeType("2")
if err != nil {
return nil, err
}
if len(sections) == 0 {
return nil, fmt.Errorf("no baseboard information found")
}
section := sections[0]
baseboard := &dto.BaseboardInfo{
Manufacturer: section["Manufacturer"],
ProductName: section["Product Name"],
Version: section["Version"],
SerialNumber: section["Serial Number"],
AssetTag: section["Asset Tag"],
LocationInChassis: section["Location In Chassis"],
Type: section["Type"],
}
// Parse features
if features, ok := section["Features"]; ok {
baseboard.Features = strings.Split(features, ",")
for i := range baseboard.Features {
baseboard.Features[i] = strings.TrimSpace(baseboard.Features[i])
}
}
return baseboard, nil
}
// ParseCPUInfo parses CPU information from dmidecode type 4
func ParseCPUInfo() (*dto.CPUHardwareInfo, error) {
sections, err := ParseDmidecodeType("4")
if err != nil {
return nil, err
}
if len(sections) == 0 {
return nil, fmt.Errorf("no CPU information found")
}
section := sections[0]
cpu := &dto.CPUHardwareInfo{
SocketDesignation: section["Socket Designation"],
Family: section["Family"],
Manufacturer: section["Manufacturer"],
Signature: section["Signature"],
Voltage: section["Voltage"],
Status: section["Status"],
Upgrade: section["Upgrade"],
SerialNumber: section["Serial Number"],
AssetTag: section["Asset Tag"],
PartNumber: section["Part Number"],
}
// Parse integer fields
if val, ok := section["External Clock"]; ok {
if mhz, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(val), " MHz")); err == nil {
cpu.ExternalClock = mhz
}
}
if val, ok := section["Max Speed"]; ok {
if mhz, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(val), " MHz")); err == nil {
cpu.MaxSpeed = mhz
}
}
if val, ok := section["Current Speed"]; ok {
if mhz, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(val), " MHz")); err == nil {
cpu.CurrentSpeed = mhz
}
}
if val, ok := section["Core Enabled"]; ok {
if cores, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
cpu.CoreEnabled = cores
}
}
if val, ok := section["Thread Count"]; ok {
if threads, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
cpu.ThreadCount = threads
}
}
// Parse flags
if flags, ok := section["Flags"]; ok {
cpu.Flags = strings.Fields(flags)
}
// Parse characteristics
if chars, ok := section["Characteristics"]; ok {
cpu.Characteristics = strings.Split(chars, ",")
for i := range cpu.Characteristics {
cpu.Characteristics[i] = strings.TrimSpace(cpu.Characteristics[i])
}
}
return cpu, nil
}
// ParseCPUCacheInfo parses CPU cache information from dmidecode type 7
func ParseCPUCacheInfo() ([]dto.CPUCacheInfo, error) {
sections, err := ParseDmidecodeType("7")
if err != nil {
return nil, err
}
var caches []dto.CPUCacheInfo
for _, section := range sections {
cache := dto.CPUCacheInfo{
SocketDesignation: section["Socket Designation"],
Configuration: section["Configuration"],
OperationalMode: section["Operational Mode"],
Location: section["Location"],
InstalledSize: section["Installed Size"],
MaximumSize: section["Maximum Size"],
InstalledSRAMType: section["Installed SRAM Type"],
ErrorCorrectionType: section["Error Correction Type"],
SystemType: section["System Type"],
Associativity: section["Associativity"],
}
// Parse level from socket designation (e.g., "L1-Cache", "L2-Cache")
switch {
case strings.Contains(cache.SocketDesignation, "L1"):
cache.Level = 1
case strings.Contains(cache.SocketDesignation, "L2"):
cache.Level = 2
case strings.Contains(cache.SocketDesignation, "L3"):
cache.Level = 3
}
// Parse supported SRAM types
if types, ok := section["Supported SRAM Types"]; ok {
cache.SupportedSRAMTypes = strings.Split(types, ",")
for i := range cache.SupportedSRAMTypes {
cache.SupportedSRAMTypes[i] = strings.TrimSpace(cache.SupportedSRAMTypes[i])
}
}
caches = append(caches, cache)
}
return caches, nil
}
// ParseMemoryArrayInfo parses memory array information from dmidecode type 16
func ParseMemoryArrayInfo() (*dto.MemoryArrayInfo, error) {
sections, err := ParseDmidecodeType("16")
if err != nil {
return nil, err
}
if len(sections) == 0 {
return nil, fmt.Errorf("no memory array information found")
}
section := sections[0]
memArray := &dto.MemoryArrayInfo{
Location: section["Location"],
Use: section["Use"],
ErrorCorrectionType: section["Error Correction Type"],
MaximumCapacity: section["Maximum Capacity"],
}
// Parse number of devices
if val, ok := section["Number Of Devices"]; ok {
if num, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
memArray.NumberOfDevices = num
}
}
return memArray, nil
}
// ParseMemoryDevices parses memory device information from dmidecode type 17
func ParseMemoryDevices() ([]dto.MemoryDeviceInfo, error) {
sections, err := ParseDmidecodeType("17")
if err != nil {
return nil, err
}
var devices []dto.MemoryDeviceInfo
for _, section := range sections {
// Skip empty slots
if section["Size"] == "No Module Installed" || section["Size"] == "" {
continue
}
device := dto.MemoryDeviceInfo{
Locator: section["Locator"],
BankLocator: section["Bank Locator"],
Size: section["Size"],
FormFactor: section["Form Factor"],
Type: section["Type"],
TypeDetail: section["Type Detail"],
Speed: section["Speed"],
Manufacturer: section["Manufacturer"],
SerialNumber: section["Serial Number"],
AssetTag: section["Asset Tag"],
PartNumber: section["Part Number"],
ConfiguredSpeed: section["Configured Memory Speed"],
MinimumVoltage: section["Minimum Voltage"],
MaximumVoltage: section["Maximum Voltage"],
ConfiguredVoltage: section["Configured Voltage"],
}
// Parse integer fields
if val, ok := section["Rank"]; ok {
if rank, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
device.Rank = rank
}
}
if val, ok := section["Data Width"]; ok {
if width, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(val), " bits")); err == nil {
device.DataWidth = width
}
}
if val, ok := section["Total Width"]; ok {
if width, err := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(val), " bits")); err == nil {
device.TotalWidth = width
}
}
devices = append(devices, device)
}
return devices, nil
}
package lib
import (
"fmt"
"strconv"
"strings"
)
// EthtoolInfo contains parsed ethtool information for a network interface
type EthtoolInfo struct {
SupportedPorts []string
SupportedLinkModes []string
SupportedPauseFrame string
SupportsAutoNeg bool
SupportedFECModes []string
AdvertisedLinkModes []string
AdvertisedPauseFrame string
AdvertisedAutoNeg bool
AdvertisedFECModes []string
Duplex string
AutoNegotiation string
Port string
PHYAD int
Transceiver string
MDIX string
SupportsWakeOn []string
WakeOn string
MessageLevel string
LinkDetected bool
MTU int
}
// ParseEthtool parses ethtool output for a network interface
func ParseEthtool(ifName string) (*EthtoolInfo, error) {
// Check if ethtool is available
if !CommandExists("ethtool") {
return nil, fmt.Errorf("ethtool command not found")
}
output, err := ExecCommandOutput("ethtool", ifName)
if err != nil {
return nil, fmt.Errorf("failed to execute ethtool: %w", err)
}
info := &EthtoolInfo{}
lines := strings.Split(output, "\n")
var inSupportedLinkModes bool
var inAdvertisedLinkModes bool
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Parse key-value pairs
if strings.Contains(trimmed, ":") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
inSupportedLinkModes, inAdvertisedLinkModes = parseEthtoolKeyValue(
info, key, value, inSupportedLinkModes, inAdvertisedLinkModes,
)
} else {
// Handle multi-line values (link modes)
if inSupportedLinkModes && trimmed != "" {
info.SupportedLinkModes = append(info.SupportedLinkModes, trimmed)
} else if inAdvertisedLinkModes && trimmed != "" {
info.AdvertisedLinkModes = append(info.AdvertisedLinkModes, trimmed)
}
}
}
return info, nil
}
// parseEthtoolKeyValue parses a single key-value pair from ethtool output
func parseEthtoolKeyValue(info *EthtoolInfo, key, value string, inSupported, inAdvertised bool) (bool, bool) {
switch key {
case "Supported ports":
info.SupportedPorts = parseListValue(value)
case "Supported link modes":
inSupported = true
inAdvertised = false
if value != "" {
info.SupportedLinkModes = append(info.SupportedLinkModes, value)
}
case "Supported pause frame use":
info.SupportedPauseFrame = value
case "Supports auto-negotiation":
info.SupportsAutoNeg = value == "Yes"
case "Supported FEC modes":
info.SupportedFECModes = parseListValue(value)
case "Advertised link modes":
inAdvertised = true
inSupported = false
if value != "" {
info.AdvertisedLinkModes = append(info.AdvertisedLinkModes, value)
}
case "Advertised pause frame use":
info.AdvertisedPauseFrame = value
case "Advertised auto-negotiation":
info.AdvertisedAutoNeg = value == "Yes"
case "Advertised FEC modes":
info.AdvertisedFECModes = parseListValue(value)
case "Speed":
// Speed is already parsed elsewhere, skip
case "Duplex":
info.Duplex = value
case "Auto-negotiation":
info.AutoNegotiation = value
case "Port":
info.Port = value
case "PHYAD":
if phyad, err := strconv.Atoi(value); err == nil {
info.PHYAD = phyad
}
case "Transceiver":
info.Transceiver = value
case "MDI-X":
info.MDIX = value
case "Supports Wake-on":
info.SupportsWakeOn = parseWakeOnFlags(value)
case "Wake-on":
info.WakeOn = value
case "Current message level":
info.MessageLevel = value
case "Link detected":
info.LinkDetected = value == "yes"
}
return inSupported, inAdvertised
}
// parseListValue parses a comma-separated or space-separated list
func parseListValue(value string) []string {
if value == "" || value == "Not reported" {
return nil
}
var result []string
if strings.Contains(value, ",") {
parts := strings.Split(value, ",")
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
} else {
parts := strings.Fields(value)
result = append(result, parts...)
}
return result
}
// parseWakeOnFlags parses Wake-on-LAN flags
func parseWakeOnFlags(value string) []string {
if value == "" || value == "Not supported" {
return nil
}
var flags []string
for _, char := range value {
switch char {
case 'p':
flags = append(flags, "PHY activity")
case 'u':
flags = append(flags, "Unicast")
case 'm':
flags = append(flags, "Multicast")
case 'b':
flags = append(flags, "Broadcast")
case 'a':
flags = append(flags, "ARP")
case 'g':
flags = append(flags, "MagicPacket")
case 's':
flags = append(flags, "SecureOn password")
case 'd':
flags = append(flags, "Disabled")
}
}
return flags
}
// Package lib provides utility functions for parsing, validation, and shell command execution.
package lib
import (
"fmt"
"gopkg.in/ini.v1"
)
// ParseINIFile parses an INI file and returns a map
func ParseINIFile(path string) (map[string]string, error) {
cfg, err := ini.Load(path)
if err != nil {
return nil, fmt.Errorf("failed to parse INI file %s: %w", path, err)
}
result := make(map[string]string)
// Get the default section (unnamed section)
defaultSection := cfg.Section("")
for _, key := range defaultSection.Keys() {
result[key.Name()] = key.String()
}
return result, nil
}
// GetINIValue gets a value from INI file with default
func GetINIValue(iniData map[string]string, key string, defaultValue string) string {
if value, ok := iniData[key]; ok {
return value
}
return defaultValue
}
package lib
import (
"bufio"
"context"
"fmt"
"os/exec"
"time"
)
// ExecCommand executes a shell command with timeout
func ExecCommand(command string, args ...string) ([]string, error) {
return ExecCommandWithTimeout(60*time.Second, command, args...)
}
// ExecCommandWithTimeout executes a command with a specific timeout
func ExecCommandWithTimeout(timeout time.Duration, command string, args ...string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start command: %w", err)
}
var lines []string
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return lines, fmt.Errorf("error reading output: %w", err)
}
if err := cmd.Wait(); err != nil {
// Check if it was a timeout
if ctx.Err() == context.DeadlineExceeded {
return lines, fmt.Errorf("command timed out after %v", timeout)
}
return lines, fmt.Errorf("command failed: %w", err)
}
return lines, nil
}
// ExecCommandOutput executes a command and returns combined output
func ExecCommandOutput(command string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, command, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("command failed: %w", err)
}
return string(output), nil
}
// CommandExists checks if a command exists in PATH
func CommandExists(command string) bool {
_, err := exec.LookPath(command)
return err == nil
}
// Package testutil provides test utilities and mocks for unit testing.
package testutil
import (
"os"
"path/filepath"
"testing"
)
// TempDir creates a temporary directory and returns its path and a cleanup function.
func TempDir(t *testing.T) (string, func()) {
t.Helper()
dir, err := os.MkdirTemp("", "unraid-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
return dir, func() {
//nolint:gosec,errcheck // G104: Cleanup in tests - errors are acceptable
_ = os.RemoveAll(dir)
}
}
// WriteFile writes content to a file in the given directory.
func WriteFile(t *testing.T, dir, filename, content string) string {
t.Helper()
path := filepath.Join(dir, filename)
//nolint:gosec // G301: Test directory permissions - 0755 is acceptable for tests
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("Failed to create directory: %v", err)
}
//nolint:gosec // G306: Test file permissions - 0644 is acceptable for tests
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write file %s: %v", path, err)
}
return path
}
// ReadFileContent reads file content or fails the test.
func ReadFileContent(t *testing.T, path string) string {
t.Helper()
//nolint:gosec // G304: Test utility - path comes from test code, not user input
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("Failed to read file %s: %v", path, err)
}
return string(data)
}
// SampleProcMeminfo returns sample /proc/meminfo content.
func SampleProcMeminfo() string {
return `MemTotal: 32653968 kB
MemFree: 15234568 kB
MemAvailable: 20123456 kB
Buffers: 512000 kB
Cached: 4876900 kB
SwapCached: 0 kB
Active: 8765432 kB
Inactive: 5432100 kB
`
}
// SampleProcStat returns sample /proc/stat content.
func SampleProcStat() string {
return `cpu 10132153 290696 3084719 46828483 16683 0 25195 0 0 0
cpu0 1292830 36410 386526 5765120 3479 0 11149 0 0 0
cpu1 1291881 36252 385618 5764888 2500 0 3146 0 0 0
cpu2 1291758 36194 385598 5764817 2413 0 2674 0 0 0
cpu3 1291737 36194 385572 5764808 2396 0 2339 0 0 0
intr 2063079 0 9 0 0 0 0 4 0 1 0 0 0 156 0 0 0 0 0 0 0 0 0 0 0 0
ctxt 123456789
btime 1609459200
processes 123456
procs_running 2
procs_blocked 0
`
}
// SampleProcUptime returns sample /proc/uptime content.
func SampleProcUptime() string {
return `12345.67 98765.43`
}
// SampleProcCPUInfo returns sample /proc/cpuinfo content.
func SampleProcCPUInfo() string {
return `processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 158
model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
stepping : 10
microcode : 0xea
cpu MHz : 3700.000
cache size : 12288 KB
physical id : 0
siblings : 12
core id : 0
cpu cores : 6
apicid : 0
initial apicid : 0
fpu : yes
fpu_exception : yes
cpuid level : 22
wp : yes
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti ssbd ibrs ibpb stibp tpr_shadow vnmi flexpriority ept vpid ept_ad fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx rdseed adx smap clflushopt intel_pt xsaveopt xsavec xgetbv1 xsaves dtherm ida arat pln pts hwp hwp_notify hwp_act_window hwp_epp md_clear flush_l1d
vmx flags : vnmi preemption_timer invvpid ept_x_only ept_ad ept_1gb flexpriority tsc_offset vtpr mtf vapic ept vpid unrestricted_guest ple shadow_vmcs pml ept_mode_based_exec
bugs : cpu_meltdown spectre_v1 spectre_v2 spec_store_bypass l1tf mds swapgs itlb_multihit
bogomips : 7399.70
clflush size : 64
cache_alignment : 64
address sizes : 39 bits physical, 48 bits virtual
power management:
processor : 1
vendor_id : GenuineIntel
cpu family : 6
model : 158
model name : Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz
stepping : 10
cpu MHz : 3700.000
physical id : 0
siblings : 12
core id : 1
cpu cores : 6
flags : fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush dts acpi mmx fxsr sse sse2 ss ht tm pbe syscall nx pdpe1gb rdtscp lm constant_tsc art arch_perfmon pebs bts rep_good nopl xtopology nonstop_tsc cpuid aperfmperf pni pclmulqdq dtes64 monitor ds_cpl vmx smx est tm2 ssse3 sdbg fma cx16 xtpr pdcm pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm abm 3dnowprefetch cpuid_fault epb invpcid_single pti ssbd ibrs ibpb stibp
bogomips : 7399.70
`
}
// SampleDmidecodeOutput returns sample dmidecode output for BIOS (type 0).
func SampleDmidecodeOutput() string {
return `# dmidecode 3.3
Getting SMBIOS data from sysfs.
SMBIOS 3.1.1 present.
Handle 0x0000, DMI type 0, 26 bytes
BIOS Information
Vendor: American Megatrends Inc.
Version: 1.80
Release Date: 05/17/2019
Address: 0xF0000
Runtime Size: 64 kB
ROM Size: 16 MB
Characteristics:
PCI is supported
BIOS is upgradeable
BIOS Revision: 5.13
`
}
// SampleEthtoolOutput returns sample ethtool output.
func SampleEthtoolOutput() string {
return `Settings for eth0:
Supported ports: [ TP ]
Supported link modes: 10baseT/Half 10baseT/Full
100baseT/Half 100baseT/Full
1000baseT/Full
Supported pause frame use: Symmetric
Supports auto-negotiation: Yes
Supported FEC modes: Not reported
Advertised link modes: 10baseT/Half 10baseT/Full
100baseT/Half 100baseT/Full
1000baseT/Full
Advertised pause frame use: Symmetric
Advertised auto-negotiation: Yes
Advertised FEC modes: Not reported
Speed: 1000Mb/s
Duplex: Full
Auto-negotiation: on
Port: Twisted Pair
PHYAD: 1
Transceiver: internal
MDI-X: off (auto)
Supports Wake-on: pumbg
Wake-on: g
Current message level: 0x00000007 (7)
drv probe link
Link detected: yes
`
}
// SampleINIFile returns sample INI file content.
func SampleINIFile() string {
return `version="7.2.0"
name="Tower"
timeZone="America/Los_Angeles"
port=80
localMaster=yes
flashGUID=1234-5678-9ABC-DEF0
`
}
// SampleSensorsOutput returns sample sensors -u output.
func SampleSensorsOutput() string {
return `coretemp-isa-0000
Adapter: ISA adapter
Core 0:
temp2_input: 45.000
temp2_max: 100.000
temp2_crit: 100.000
Core 1:
temp3_input: 46.000
temp3_max: 100.000
temp3_crit: 100.000
MB Temp:
temp1_input: 38.000
nct6792-isa-0a20
Adapter: ISA adapter
fan1:
fan1_input: 1200.000
fan2:
fan2_input: 800.000
`
}
// SampleDockerPSOutput returns sample docker ps --format json output.
func SampleDockerPSOutput() string {
return `{"ID":"abc123","Names":"nginx","Image":"nginx:latest","State":"running","Status":"Up 2 hours"}
{"ID":"def456","Names":"redis","Image":"redis:alpine","State":"running","Status":"Up 1 hour"}`
}
// SampleVirshListOutput returns sample virsh list --all output.
func SampleVirshListOutput() string {
return ` Id Name State
-----------------------------
1 ubuntu20 running
- windows10 shut off
- debian11 shut off
`
}
// SampleArrayINI returns sample array configuration.
func SampleArrayINI() string {
return `mdState=STARTED
mdNumDisks=4
mdNumParity=1
sbSynced="Mon Jan 1 00:00:01 2024 18645 MB/s + 38044 MB/s"
sbSynced2=0
`
}
// SampleDisksINI returns sample disks configuration.
func SampleDisksINI() string {
return `[disk1]
name=disk1
device=sda
id=WDC_WD40EFAX-68JH4N1_WD-WX11D80D1234
size=4000787030016
status=DISK_OK
temp=35
[disk2]
name=disk2
device=sdb
id=WDC_WD40EFAX-68JH4N1_WD-WX11D80D5678
size=4000787030016
status=DISK_OK
temp=36
`
}
// SampleNetworkINI returns sample network configuration.
func SampleNetworkINI() string {
return `[eth0]
NAME=eth0
IPADDR=192.168.1.100
NETMASK=255.255.255.0
GATEWAY=192.168.1.1
DNS_SERVER1=8.8.8.8
DNS_SERVER2=8.8.4.4
`
}
// SampleSharesINI returns sample shares configuration.
func SampleSharesINI() string {
return `[appdata]
name=appdata
comment=Application Data
allocator=highwater
splitLevel=
include=disk1,disk2
exclude=
useCache=yes
[media]
name=media
comment=Media Files
allocator=highwater
splitLevel=
include=
exclude=
useCache=no
`
}
// SampleNvidiaSMIOutput returns sample nvidia-smi output.
func SampleNvidiaSMIOutput() string {
return `==============NVSMI LOG==============
Timestamp : Thu Jan 1 00:00:00 2024
Driver Version : 535.154.05
CUDA Version : 12.2
Attached GPUs : 1
GPU 00000000:01:00.0
Product Name : NVIDIA GeForce RTX 3080
Product Brand : GeForce
GPU UUID : GPU-12345678-1234-1234-1234-123456789abc
Fan Speed : 45 %
Temperature
GPU Current Temp : 55 C
GPU Shutdown Temp : 98 C
GPU Max Operating Temp : 93 C
Power Readings
Power Draw : 120.50 W
Power Limit : 320.00 W
Memory Usage
Total : 10240 MiB
Used : 2048 MiB
Free : 8192 MiB
Utilization
Gpu : 25 %
Memory : 20 %
`
}
// SampleUPSOutput returns sample apcaccess output.
func SampleUPSOutput() string {
return `APC : 001,034,0856
DATE : 2024-01-01 00:00:00 +0000
HOSTNAME : tower
VERSION : 3.14.14
UPSNAME : Back-UPS RS 1500
CABLE : USB Cable
DRIVER : USB UPS Driver
UPSMODE : Stand Alone
STARTTIME: 2024-01-01 00:00:00 +0000
MODEL : Back-UPS RS 1500MS
STATUS : ONLINE
LINEV : 120.0 Volts
LOADPCT : 25.0 Percent
BCHARGE : 100.0 Percent
TIMELEFT : 45.0 Minutes
MBATTCHG : 5 Percent
MINTIMEL : 3 Minutes
MAXTIME : 0 Seconds
OUTPUTV : 120.0 Volts
SENSE : Medium
DWAKE : -1 Seconds
DSHUTD : 0 Seconds
LOTRANS : 88.0 Volts
HITRANS : 139.0 Volts
ALARMDEL : 30 Seconds
BATTV : 27.1 Volts
LASTXFER : Automatic or explicit self test
NUMXFERS : 0
TONBATT : 0 Seconds
CUMONBATT: 0 Seconds
XOFFBATT : N/A
SELFTEST : NO
STATFLAG : 0x05000008
SERIALNO : 1B2345C67890
BATTDATE : 2023-01-15
NOMINV : 120 Volts
NOMBATTV : 24.0 Volts
NOMPOWER : 900 Watts
FIRMWARE : 928.a9 .D USB FW:a9
END APC : 2024-01-01 00:00:00 +0000
`
}
// SampleZFSPoolOutput returns sample zpool list output.
func SampleZFSPoolOutput() string {
return `NAME SIZE ALLOC FREE CKPOINT EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
pool1 3.62T 1.21T 2.41T - - 5% 33% 1.00x ONLINE -
pool2 7.27T 3.50T 3.77T - - 10% 48% 1.00x ONLINE -
`
}
// SampleZFSDatasetOutput returns sample zfs list output.
func SampleZFSDatasetOutput() string {
return `NAME USED AVAIL REFER MOUNTPOINT
pool1 1.21T 2.30T 96K /mnt/pool1
pool1/data 500G 2.30T 500G /mnt/pool1/data
pool1/backup 720G 2.30T 720G /mnt/pool1/backup
`
}
package lib
import (
"fmt"
"math"
"os"
"strconv"
"strings"
)
// FileExists checks if a file exists
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ReadFile reads entire file contents
func ReadFile(path string) (string, error) {
//nolint:gosec // G304: Path is from trusted sources (system files, config files), not user input
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", path, err)
}
return string(data), nil
}
// ReadLines reads a file and returns lines
func ReadLines(path string) ([]string, error) {
content, err := ReadFile(path)
if err != nil {
return nil, err
}
return strings.Split(content, "\n"), nil
}
// ParseFloat safely parses a float from string
func ParseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}
// ParseInt safely parses an integer from string
func ParseInt(s string) int {
i, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return i
}
// ParseUint64 safely parses uint64 from string
func ParseUint64(s string) uint64 {
i, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0
}
return i
}
// Round rounds a float to nearest integer
func Round(f float64) int {
if f < 0 {
return int(f - 0.5)
}
return int(f + 0.5)
}
// RoundFloat rounds a float to n decimal places
func RoundFloat(f float64, decimals int) float64 {
multiplier := math.Pow(10, float64(decimals))
return math.Round(f*multiplier) / multiplier
}
// ParseKeyValue parses "key=value" format
func ParseKeyValue(line string) (string, string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return "", ""
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
return key, value
}
// ParseKeyValueMap parses multiple key=value lines into a map
func ParseKeyValueMap(lines []string) map[string]string {
result := make(map[string]string)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value := ParseKeyValue(line)
if key != "" {
result[key] = value
}
}
return result
}
// BytesToGB converts bytes to gigabytes
func BytesToGB(bytes uint64) float64 {
return float64(bytes) / 1024 / 1024 / 1024
}
// BytesToMB converts bytes to megabytes
func BytesToMB(bytes uint64) float64 {
return float64(bytes) / 1024 / 1024
}
// GBToBytes converts gigabytes to bytes
func GBToBytes(gb float64) uint64 {
return uint64(gb * 1024 * 1024 * 1024)
}
// MBToBytes converts megabytes to bytes
func MBToBytes(mb float64) uint64 {
return uint64(mb * 1024 * 1024)
}
// KBToBytes converts kilobytes to bytes
func KBToBytes(kb float64) uint64 {
return uint64(kb * 1024)
}
package lib
import (
"fmt"
"regexp"
"strings"
)
var (
// Docker container IDs are either 12 or 64 hexadecimal characters
containerIDShortRegex = regexp.MustCompile(`^[a-f0-9]{12}$`)
containerIDFullRegex = regexp.MustCompile(`^[a-f0-9]{64}$`)
// VM names: alphanumeric, spaces, hyphens, underscores, dots (max 253 chars)
// Based on common VM naming practices (allows spaces for user-friendly names)
vmNameRegex = regexp.MustCompile(`^[a-zA-Z0-9 _.-]{1,253}$`)
// Disk IDs: common Linux disk naming patterns
// Examples: sda, sdb1, nvme0n1, nvme0n1p1, md0, loop0
diskIDRegex = regexp.MustCompile(`^(sd[a-z]|nvme[0-9]+n[0-9]+|md[0-9]+|loop[0-9]+)(p?[0-9]+)?$`)
// Share names: alphanumeric, hyphens, underscores (max 255 chars)
// Must not contain path separators or parent directory references
shareNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,255}$`)
// User script names: alphanumeric, hyphens, underscores, dots (max 255 chars)
// Must not contain path separators or parent directory references
userScriptNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,255}$`)
)
// ValidateContainerID validates a Docker container ID format
// Accepts both short (12 chars) and full (64 chars) hexadecimal IDs
func ValidateContainerID(id string) error {
if id == "" {
return fmt.Errorf("container ID cannot be empty")
}
// Convert to lowercase for validation
id = strings.ToLower(id)
// Check if it matches either short or full format
if containerIDShortRegex.MatchString(id) || containerIDFullRegex.MatchString(id) {
return nil
}
return fmt.Errorf("invalid container ID format: must be 12 or 64 hexadecimal characters")
}
// ValidateVMName validates a virtual machine name
// Allows alphanumeric characters, spaces, hyphens, underscores, and dots
// Maximum length: 253 characters (DNS hostname limit)
func ValidateVMName(name string) error {
if name == "" {
return fmt.Errorf("VM name cannot be empty")
}
if len(name) > 253 {
return fmt.Errorf("VM name too long: maximum 253 characters, got %d", len(name))
}
if !vmNameRegex.MatchString(name) {
return fmt.Errorf("invalid VM name format: must contain only alphanumeric characters, spaces, hyphens, underscores, and dots")
}
// Additional checks for common issues
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf("invalid VM name: cannot start or end with hyphen")
}
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
return fmt.Errorf("invalid VM name: cannot start or end with dot")
}
return nil
}
// ValidateDiskID validates a disk identifier
// Supports common Linux disk naming patterns
func ValidateDiskID(id string) error {
if id == "" {
return fmt.Errorf("disk ID cannot be empty")
}
if !diskIDRegex.MatchString(id) {
return fmt.Errorf("invalid disk ID format: must match Linux disk naming pattern (e.g., sda, nvme0n1, md0)")
}
return nil
}
// ValidateShareName validates an Unraid share name
// Prevents path traversal attacks by ensuring the name contains only safe characters
// and does not contain path separators or parent directory references
func ValidateShareName(name string) error {
if name == "" {
return fmt.Errorf("share name cannot be empty")
}
if len(name) > 255 {
return fmt.Errorf("share name too long: maximum 255 characters, got %d", len(name))
}
// Check for parent directory references first (more specific check)
if strings.Contains(name, "..") {
return fmt.Errorf("invalid share name: cannot contain parent directory references")
}
// Check for path separators
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("invalid share name: cannot contain path separators")
}
// Validate against regex pattern (alphanumeric, hyphens, underscores only)
if !shareNameRegex.MatchString(name) {
return fmt.Errorf("invalid share name format: must contain only alphanumeric characters, hyphens, and underscores")
}
// Additional checks for common issues
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf("invalid share name: cannot start or end with hyphen")
}
return nil
}
// ValidateNonEmpty validates that a string is not empty or whitespace-only
func ValidateNonEmpty(value, fieldName string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s cannot be empty", fieldName)
}
return nil
}
// ValidateMaxLength validates that a string does not exceed maximum length
func ValidateMaxLength(value, fieldName string, maxLength int) error {
if len(value) > maxLength {
return fmt.Errorf("%s too long: maximum %d characters, got %d", fieldName, maxLength, len(value))
}
return nil
}
// ValidateUserScriptName validates a user script name
// Prevents path traversal attacks by ensuring the name contains only safe characters
// and does not contain path separators or parent directory references
func ValidateUserScriptName(name string) error {
if name == "" {
return fmt.Errorf("user script name cannot be empty")
}
if len(name) > 255 {
return fmt.Errorf("user script name too long: maximum 255 characters, got %d", len(name))
}
// Check for parent directory references first (more specific check)
if strings.Contains(name, "..") {
return fmt.Errorf("invalid user script name: cannot contain parent directory references")
}
// Check for path separators
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("invalid user script name: cannot contain path separators")
}
// Check for absolute paths
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") {
return fmt.Errorf("invalid user script name: cannot be an absolute path")
}
// Validate against regex pattern (alphanumeric, hyphens, underscores, dots only)
if !userScriptNameRegex.MatchString(name) {
return fmt.Errorf("invalid user script name format: must contain only alphanumeric characters, hyphens, underscores, and dots")
}
// Additional checks for common issues
if strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return fmt.Errorf("invalid user script name: cannot start or end with hyphen")
}
if strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
return fmt.Errorf("invalid user script name: cannot start or end with dot")
}
return nil
}
// ValidateLogFilename validates a log filename
// Prevents path traversal attacks (CWE-22) by ensuring the filename contains only safe characters
// and does not contain path separators or parent directory references
func ValidateLogFilename(name string) bool {
if name == "" {
return false
}
if len(name) > 255 {
return false
}
// Check for parent directory references (CWE-22 path traversal)
if strings.Contains(name, "..") {
return false
}
// Check for path separators (only allow forward slashes for plugin log paths like "plugin/file.log")
if strings.Contains(name, "\\") {
return false
}
// Check for absolute paths
if strings.HasPrefix(name, "/") {
return false
}
// Check for null bytes (CWE-158)
if strings.Contains(name, "\x00") {
return false
}
return true
}
// Package logger provides structured logging functionality with color-coded output and log rotation.
package logger
import (
"fmt"
"log"
)
// LogLevel represents the logging verbosity level
type LogLevel int
const (
// LevelDebug enables all logging including debug messages
LevelDebug LogLevel = iota
// LevelInfo enables info, warning, and error messages
LevelInfo
// LevelWarning enables warning and error messages only
LevelWarning
// LevelError enables error messages only
LevelError
)
var currentLevel = LevelWarning // Default to WARNING level for production
// Color codes for terminal output
const (
ColorReset = "\033[0m"
ColorRed = "\033[31m"
ColorGreen = "\033[32m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorPurple = "\033[35m"
ColorCyan = "\033[36m"
ColorWhite = "\033[37m"
)
// SetLevel sets the global logging level
func SetLevel(level LogLevel) {
currentLevel = level
}
// GetLevel returns the current logging level
func GetLevel() LogLevel {
return currentLevel
}
// Info logs informational messages in blue
func Info(format string, v ...interface{}) {
if currentLevel <= LevelInfo {
log.Printf(ColorBlue+format+ColorReset, v...)
}
}
// Success logs success messages in green
func Success(format string, v ...interface{}) {
if currentLevel <= LevelInfo {
log.Printf(ColorGreen+format+ColorReset, v...)
}
}
// Warning logs warning messages in yellow
func Warning(format string, v ...interface{}) {
if currentLevel <= LevelWarning {
log.Printf(ColorYellow+"WARNING: "+format+ColorReset, v...)
}
}
// Error logs error messages in red
func Error(format string, v ...interface{}) {
if currentLevel <= LevelError {
log.Printf(ColorRed+"ERROR: "+format+ColorReset, v...)
}
}
// Debug logs debug messages in cyan (only if debug level is enabled)
func Debug(format string, v ...interface{}) {
if currentLevel <= LevelDebug {
log.Printf(ColorCyan+"DEBUG: "+format+ColorReset, v...)
}
}
// Fatal logs fatal error and exits
func Fatal(format string, v ...interface{}) {
log.Fatalf(ColorRed+"FATAL: "+format+ColorReset, v...)
}
// Plain logs without color
func Plain(format string, v ...interface{}) {
log.Printf(format, v...)
}
// Blue alias for Info
func Blue(format string, v ...interface{}) {
Info(format, v...)
}
// Yellow alias for Warning
func Yellow(format string, v ...interface{}) {
Warning(format, v...)
}
// Green alias for Success
func Green(format string, v ...interface{}) {
Success(format, v...)
}
// LightGreen logs in light green
func LightGreen(format string, v ...interface{}) {
if currentLevel <= LevelInfo {
log.Printf("\033[92m"+format+ColorReset, v...)
}
}
// Printf is a wrapper for standard log.Printf
func Printf(format string, v ...interface{}) {
if currentLevel <= LevelInfo {
log.Printf(format, v...)
}
}
// Println is a wrapper for standard log.Println
func Println(v ...interface{}) {
if currentLevel <= LevelInfo {
log.Println(v...)
}
}
// Sprintf formats and returns a string
func Sprintf(format string, v ...interface{}) string {
return fmt.Sprintf(format, v...)
}
// Package api provides HTTP REST API handlers and WebSocket functionality for the Unraid Management Agent.
package api
import (
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
"github.com/ruaan-deysel/unraid-management-agent/daemon/services/collectors"
"github.com/ruaan-deysel/unraid-management-agent/daemon/services/controllers"
)
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleSystem(w http.ResponseWriter, _ *http.Request) {
// Get latest system info from cache
s.cacheMutex.RLock()
info := s.systemCache
s.cacheMutex.RUnlock()
if info == nil {
info = &dto.SystemInfo{
Hostname: "unknown",
Version: s.ctx.Version,
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, info)
}
// handleSystemReboot initiates a system reboot
func (s *Server) handleSystemReboot(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: System reboot requested")
systemCtrl := controllers.NewSystemController(s.ctx)
err := systemCtrl.Reboot()
if err != nil {
logger.Error("API: Failed to initiate reboot: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to initiate reboot: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Server reboot initiated",
Timestamp: time.Now(),
})
}
// handleSystemShutdown initiates a system shutdown
func (s *Server) handleSystemShutdown(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: System shutdown requested")
systemCtrl := controllers.NewSystemController(s.ctx)
err := systemCtrl.Shutdown()
if err != nil {
logger.Error("API: Failed to initiate shutdown: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to initiate shutdown: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Server shutdown initiated",
Timestamp: time.Now(),
})
}
func (s *Server) handleArray(w http.ResponseWriter, _ *http.Request) {
// Get latest array status from cache
s.cacheMutex.RLock()
status := s.arrayCache
s.cacheMutex.RUnlock()
if status == nil {
status = &dto.ArrayStatus{
State: "unknown",
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, status)
}
func (s *Server) handleDisks(w http.ResponseWriter, _ *http.Request) {
// Get latest disk list from cache
s.cacheMutex.RLock()
disks := s.disksCache
s.cacheMutex.RUnlock()
if disks == nil {
disks = []dto.DiskInfo{}
}
respondJSON(w, http.StatusOK, disks)
}
func (s *Server) handleDisk(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
diskID := vars["id"]
logger.Debug("API: Getting disk info for %s", diskID)
s.cacheMutex.RLock()
disks := s.disksCache
s.cacheMutex.RUnlock()
// Find disk by ID
for _, disk := range disks {
if disk.ID == diskID || disk.Device == diskID || disk.Name == diskID {
respondJSON(w, http.StatusOK, disk)
return
}
}
// Disk not found
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("Disk not found: %s", diskID),
Timestamp: time.Now(),
})
}
func (s *Server) handleShares(w http.ResponseWriter, _ *http.Request) {
// Get latest share list from cache
s.cacheMutex.RLock()
shares := s.sharesCache
s.cacheMutex.RUnlock()
if shares == nil {
shares = []dto.ShareInfo{}
}
respondJSON(w, http.StatusOK, shares)
}
func (s *Server) handleDockerList(w http.ResponseWriter, _ *http.Request) {
// Get latest container list from cache
s.cacheMutex.RLock()
containers := s.dockerCache
s.cacheMutex.RUnlock()
if containers == nil {
containers = []dto.ContainerInfo{}
}
respondJSON(w, http.StatusOK, containers)
}
func (s *Server) handleDockerInfo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
containerID := vars["id"]
logger.Debug("API: Getting container info for %s", containerID)
s.cacheMutex.RLock()
containers := s.dockerCache
s.cacheMutex.RUnlock()
// Find container by ID or name
for _, container := range containers {
if container.ID == containerID || container.Name == containerID {
respondJSON(w, http.StatusOK, container)
return
}
}
// Container not found
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("Container not found: %s", containerID),
Timestamp: time.Now(),
})
}
func (s *Server) handleVMList(w http.ResponseWriter, _ *http.Request) {
// Get latest VM list from cache
s.cacheMutex.RLock()
vms := s.vmsCache
s.cacheMutex.RUnlock()
if vms == nil {
vms = []dto.VMInfo{}
}
respondJSON(w, http.StatusOK, vms)
}
func (s *Server) handleVMInfo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
vmID := vars["id"]
logger.Debug("API: Getting VM info for %s", vmID)
s.cacheMutex.RLock()
vms := s.vmsCache
s.cacheMutex.RUnlock()
// Find VM by ID or name
for _, vm := range vms {
if vm.ID == vmID || vm.Name == vmID {
respondJSON(w, http.StatusOK, vm)
return
}
}
// VM not found
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("VM not found: %s", vmID),
Timestamp: time.Now(),
})
}
func (s *Server) handleUPS(w http.ResponseWriter, _ *http.Request) {
// Get latest UPS status from cache
s.cacheMutex.RLock()
ups := s.upsCache
s.cacheMutex.RUnlock()
if ups == nil {
ups = &dto.UPSStatus{
Connected: false,
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, ups)
}
func (s *Server) handleNUT(w http.ResponseWriter, _ *http.Request) {
// Get latest NUT status from cache
s.cacheMutex.RLock()
nut := s.nutCache
s.cacheMutex.RUnlock()
if nut == nil {
nut = &dto.NUTResponse{
Installed: false,
Running: false,
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, nut)
}
func (s *Server) handleGPU(w http.ResponseWriter, _ *http.Request) {
// Get latest GPU metrics from cache
s.cacheMutex.RLock()
gpus := s.gpuCache
s.cacheMutex.RUnlock()
if gpus == nil {
gpus = []*dto.GPUMetrics{}
}
respondJSON(w, http.StatusOK, gpus)
}
func (s *Server) handleNetwork(w http.ResponseWriter, _ *http.Request) {
// Get latest network interfaces from cache
s.cacheMutex.RLock()
interfaces := s.networkCache
s.cacheMutex.RUnlock()
if interfaces == nil {
interfaces = []dto.NetworkInfo{}
}
respondJSON(w, http.StatusOK, interfaces)
}
// Generic Docker operation handler to reduce code duplication
//
//nolint:dupl // Similar to handleVMOperation but serves different purpose (Docker vs VM)
func (s *Server) handleDockerOperation(w http.ResponseWriter, r *http.Request, operation string, operationFunc func(string) error) {
vars := mux.Vars(r)
containerID := vars["id"]
// Validate container ID format
if err := lib.ValidateContainerID(containerID); err != nil {
logger.Warning("Invalid container ID for %s operation: %s - %v", operation, containerID, err)
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: err.Error(),
Timestamp: time.Now(),
})
return
}
logger.Info("%s container %s", operation, containerID)
if err := operationFunc(containerID); err != nil {
logger.Error("Failed to %s container %s: %v", operation, containerID, err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to %s container: %v", operation, err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: fmt.Sprintf("Container %s", operation),
Timestamp: time.Now(),
})
}
// Generic VM operation handler to reduce code duplication
//
//nolint:dupl // Similar to handleDockerOperation but serves different purpose (VM vs Docker)
func (s *Server) handleVMOperation(w http.ResponseWriter, r *http.Request, operation string, operationFunc func(string) error) {
vars := mux.Vars(r)
vmName := vars["name"]
// Validate VM name format
if err := lib.ValidateVMName(vmName); err != nil {
logger.Warning("Invalid VM name for %s operation: %s - %v", operation, vmName, err)
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: err.Error(),
Timestamp: time.Now(),
})
return
}
logger.Info("%s VM %s", operation, vmName)
if err := operationFunc(vmName); err != nil {
logger.Error("Failed to %s VM %s: %v", operation, vmName, err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to %s VM: %v", operation, err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: fmt.Sprintf("VM %s", operation),
Timestamp: time.Now(),
})
}
// Docker control handlers
func (s *Server) handleDockerStart(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewDockerController()
s.handleDockerOperation(w, r, "started", controller.Start)
}
func (s *Server) handleDockerStop(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewDockerController()
s.handleDockerOperation(w, r, "stopped", controller.Stop)
}
func (s *Server) handleDockerRestart(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewDockerController()
s.handleDockerOperation(w, r, "restarted", controller.Restart)
}
func (s *Server) handleDockerPause(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewDockerController()
s.handleDockerOperation(w, r, "paused", controller.Pause)
}
func (s *Server) handleDockerUnpause(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewDockerController()
s.handleDockerOperation(w, r, "unpaused", controller.Unpause)
}
// VM control handlers
func (s *Server) handleVMStart(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "started", controller.Start)
}
func (s *Server) handleVMStop(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "stopped", controller.Stop)
}
func (s *Server) handleVMRestart(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "restarted", controller.Restart)
}
func (s *Server) handleVMPause(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "paused", controller.Pause)
}
func (s *Server) handleVMResume(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "resumed", controller.Resume)
}
func (s *Server) handleVMHibernate(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "hibernated", controller.Hibernate)
}
func (s *Server) handleVMForceStop(w http.ResponseWriter, r *http.Request) {
controller := controllers.NewVMController()
s.handleVMOperation(w, r, "force stopped", controller.ForceStop)
}
// Array control handlers
func (s *Server) handleArrayStart(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: Starting array")
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.StartArray()
if err != nil {
logger.Error("API: Failed to start array: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to start array: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Array started successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleArrayStop(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: Stopping array")
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.StopArray()
if err != nil {
logger.Error("API: Failed to stop array: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to stop array: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Array stopped successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleParityCheckStart(w http.ResponseWriter, r *http.Request) {
// Read optional 'correcting' parameter from query
correcting := r.URL.Query().Get("correcting") == "true"
logger.Info("API: Starting parity check (correcting: %v)", correcting)
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.StartParityCheck(correcting)
if err != nil {
logger.Error("API: Failed to start parity check: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to start parity check: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Parity check started successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleParityCheckStop(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: Stopping parity check")
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.StopParityCheck()
if err != nil {
logger.Error("API: Failed to stop parity check: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to stop parity check: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Parity check stopped successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleParityCheckPause(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: Pausing parity check")
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.PauseParityCheck()
if err != nil {
logger.Error("API: Failed to pause parity check: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to pause parity check: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Parity check paused successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleParityCheckResume(w http.ResponseWriter, _ *http.Request) {
logger.Info("API: Resuming parity check")
arrayCtrl := controllers.NewArrayController(s.ctx)
err := arrayCtrl.ResumeParityCheck()
if err != nil {
logger.Error("API: Failed to resume parity check: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to resume parity check: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Parity check resumed successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleParityCheckHistory(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting parity check history")
parityCollector := collectors.NewParityCollector()
history, err := parityCollector.GetParityHistory()
if err != nil {
logger.Error("API: Failed to get parity check history: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get parity check history: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, history)
}
// Configuration handlers
func (s *Server) handleShareConfig(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
shareName := vars["name"]
logger.Debug("API: Getting share config for %s", shareName)
// Validate share name to prevent path traversal attacks
if err := lib.ValidateShareName(shareName); err != nil {
logger.Error("API: Invalid share name: %v", err)
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: fmt.Sprintf("Invalid share name: %v", err),
Timestamp: time.Now(),
})
return
}
configCollector := collectors.NewConfigCollector()
config, err := configCollector.GetShareConfig(shareName)
if err != nil {
logger.Error("API: Failed to get share config: %v", err)
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get share config: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, config)
}
func (s *Server) handleNetworkConfig(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
interfaceName := vars["interface"]
logger.Debug("API: Getting network config for %s", interfaceName)
configCollector := collectors.NewConfigCollector()
config, err := configCollector.GetNetworkConfig(interfaceName)
if err != nil {
logger.Error("API: Failed to get network config: %v", err)
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get network config: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, config)
}
func (s *Server) handleSystemSettings(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting system settings")
configCollector := collectors.NewConfigCollector()
settings, err := configCollector.GetSystemSettings()
if err != nil {
logger.Error("API: Failed to get system settings: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get system settings: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, settings)
}
func (s *Server) handleDockerSettings(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting Docker settings")
configCollector := collectors.NewConfigCollector()
settings, err := configCollector.GetDockerSettings()
if err != nil {
logger.Error("API: Failed to get Docker settings: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get Docker settings: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, settings)
}
func (s *Server) handleVMSettings(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting VM settings")
configCollector := collectors.NewConfigCollector()
settings, err := configCollector.GetVMSettings()
if err != nil {
logger.Error("API: Failed to get VM settings: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get VM settings: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, settings)
}
func (s *Server) handleDiskSettings(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting disk settings")
configCollector := collectors.NewConfigCollector()
settings, err := configCollector.GetDiskSettings()
if err != nil {
logger.Error("API: Failed to get disk settings: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to get disk settings: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, settings)
}
func (s *Server) handleUpdateShareConfig(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
shareName := vars["name"]
logger.Info("API: Updating share config for %s", shareName)
// Validate share name to prevent path traversal attacks
if err := lib.ValidateShareName(shareName); err != nil {
logger.Error("API: Invalid share name: %v", err)
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: fmt.Sprintf("Invalid share name: %v", err),
Timestamp: time.Now(),
})
return
}
var config dto.ShareConfig
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: fmt.Sprintf("Invalid request body: %v", err),
Timestamp: time.Now(),
})
return
}
// Ensure name matches URL parameter
config.Name = shareName
configCollector := collectors.NewConfigCollector()
if err := configCollector.UpdateShareConfig(&config); err != nil {
logger.Error("API: Failed to update share config: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to update share config: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "Share config updated successfully",
Timestamp: time.Now(),
})
}
func (s *Server) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
logger.Info("API: Updating system settings")
var settings dto.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: fmt.Sprintf("Invalid request body: %v", err),
Timestamp: time.Now(),
})
return
}
configCollector := collectors.NewConfigCollector()
if err := configCollector.UpdateSystemSettings(&settings); err != nil {
logger.Error("API: Failed to update system settings: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to update system settings: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, dto.Response{
Success: true,
Message: "System settings updated successfully",
Timestamp: time.Now(),
})
}
// handleUserScripts returns a list of all available user scripts
func (s *Server) handleUserScripts(w http.ResponseWriter, _ *http.Request) {
scripts, err := controllers.ListUserScripts()
if err != nil {
logger.Error("API: Failed to list user scripts: %v", err)
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: fmt.Sprintf("Failed to list user scripts: %v", err),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, scripts)
}
// handleUserScriptExecute executes a user script
func (s *Server) handleUserScriptExecute(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
scriptName := vars["name"]
// Parse request body for execution options
var req dto.UserScriptExecuteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Use defaults if no body provided
req.Background = true
req.Wait = false
}
// Execute the script
response, err := controllers.ExecuteUserScript(scriptName, req.Background, req.Wait)
if err != nil {
logger.Error("API: Failed to execute user script %s: %v", scriptName, err)
respondJSON(w, http.StatusInternalServerError, response)
return
}
respondJSON(w, http.StatusOK, response)
}
// Hardware endpoints
func (s *Server) handleHardwareFull(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil {
hardware = &dto.HardwareInfo{
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, hardware)
}
func (s *Server) handleHardwareBIOS(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || hardware.BIOS == nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "BIOS information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.BIOS)
}
func (s *Server) handleHardwareBaseboard(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || hardware.Baseboard == nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "Baseboard information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.Baseboard)
}
func (s *Server) handleHardwareCPU(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || hardware.CPU == nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "CPU hardware information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.CPU)
}
func (s *Server) handleHardwareCache(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || len(hardware.Cache) == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "CPU cache information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.Cache)
}
func (s *Server) handleHardwareMemoryArray(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || hardware.MemoryArray == nil {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "Memory array information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.MemoryArray)
}
func (s *Server) handleHardwareMemoryDevices(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
hardware := s.hardwareCache
s.cacheMutex.RUnlock()
if hardware == nil || len(hardware.MemoryDevices) == 0 {
respondJSON(w, http.StatusNotFound, map[string]string{"error": "Memory device information not available"})
return
}
respondJSON(w, http.StatusOK, hardware.MemoryDevices)
}
func (s *Server) handleRegistration(w http.ResponseWriter, _ *http.Request) {
logger.Debug("API: Getting registration information")
s.cacheMutex.RLock()
registration := s.registrationCache
s.cacheMutex.RUnlock()
if registration == nil {
registration = &dto.Registration{
Type: "unknown",
State: "invalid",
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, registration)
}
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
logger.Debug("API: Getting logs")
// Get query parameters
path := r.URL.Query().Get("path")
linesParam := r.URL.Query().Get("lines")
startParam := r.URL.Query().Get("start")
// If no path specified, list all available logs
if path == "" {
logs := s.listLogFiles()
respondJSON(w, http.StatusOK, map[string]interface{}{"logs": logs})
return
}
// Get log content with optional pagination
content, err := s.getLogContent(path, linesParam, startParam)
if err != nil {
respondJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
respondJSON(w, http.StatusOK, content)
}
// handleLogFile retrieves a specific log file by filename
// This provides a cleaner REST endpoint for accessing known log files
func (s *Server) handleLogFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
filename := vars["filename"]
logger.Debug("API: Getting log file: %s", filename)
// Validate filename to prevent directory traversal (CWE-22)
if !lib.ValidateLogFilename(filename) {
respondJSON(w, http.StatusBadRequest, dto.Response{
Success: false,
Message: "Invalid filename",
Timestamp: time.Now(),
})
return
}
// Find the log file in our known log paths
logs := s.listLogFiles()
var foundPath string
for _, log := range logs {
// Match by filename (base name) or full name (for plugin logs)
if log.Name == filename || filepath.Base(log.Path) == filename {
foundPath = log.Path
break
}
}
if foundPath == "" {
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("Log file not found: %s", filename),
Timestamp: time.Now(),
})
return
}
// Get optional query parameters for pagination
linesParam := r.URL.Query().Get("lines")
startParam := r.URL.Query().Get("start")
// Get log content
content, err := s.getLogContent(foundPath, linesParam, startParam)
if err != nil {
respondJSON(w, http.StatusInternalServerError, dto.Response{
Success: false,
Message: err.Error(),
Timestamp: time.Now(),
})
return
}
respondJSON(w, http.StatusOK, content)
}
// Helper function to respond with JSON
func respondJSON(w http.ResponseWriter, status int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
logger.Error("Failed to encode JSON response: %v", err)
}
}
// Helper function to respond with error
func respondWithError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
// handleNotifications returns all notifications with overview
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request) {
s.cacheMutex.RLock()
notificationList := s.notificationsCache
s.cacheMutex.RUnlock()
if notificationList == nil {
notificationList = &dto.NotificationList{
Overview: dto.NotificationOverview{
Unread: dto.NotificationCounts{},
Archive: dto.NotificationCounts{},
},
Notifications: []dto.Notification{},
Timestamp: time.Now(),
}
}
// Filter by importance if specified
importance := r.URL.Query().Get("importance")
if importance != "" {
filtered := []dto.Notification{}
for _, n := range notificationList.Notifications {
if n.Importance == importance {
filtered = append(filtered, n)
}
}
notificationList.Notifications = filtered
}
respondJSON(w, http.StatusOK, notificationList)
}
// handleNotificationsUnread returns only unread notifications
func (s *Server) handleNotificationsUnread(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
notificationList := s.notificationsCache
s.cacheMutex.RUnlock()
if notificationList == nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"notifications": []dto.Notification{},
"count": 0,
})
return
}
unread := []dto.Notification{}
for _, n := range notificationList.Notifications {
if n.Type == "unread" {
unread = append(unread, n)
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"notifications": unread,
"count": len(unread),
})
}
// handleNotificationsArchive returns only archived notifications
func (s *Server) handleNotificationsArchive(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
notificationList := s.notificationsCache
s.cacheMutex.RUnlock()
if notificationList == nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"notifications": []dto.Notification{},
"count": 0,
})
return
}
archived := []dto.Notification{}
for _, n := range notificationList.Notifications {
if n.Type == "archive" {
archived = append(archived, n)
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"notifications": archived,
"count": len(archived),
})
}
// handleNotificationsOverview returns only the overview counts
func (s *Server) handleNotificationsOverview(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
notificationList := s.notificationsCache
s.cacheMutex.RUnlock()
if notificationList == nil {
respondJSON(w, http.StatusOK, dto.NotificationOverview{
Unread: dto.NotificationCounts{},
Archive: dto.NotificationCounts{},
})
return
}
respondJSON(w, http.StatusOK, notificationList.Overview)
}
// handleNotificationByID returns a specific notification by ID
func (s *Server) handleNotificationByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
s.cacheMutex.RLock()
notificationList := s.notificationsCache
s.cacheMutex.RUnlock()
if notificationList == nil {
respondWithError(w, http.StatusNotFound, "Notification not found")
return
}
for _, n := range notificationList.Notifications {
if n.ID == id {
respondJSON(w, http.StatusOK, n)
return
}
}
respondWithError(w, http.StatusNotFound, "Notification not found")
}
// handleCreateNotification creates a new notification
func (s *Server) handleCreateNotification(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Subject string `json:"subject"`
Description string `json:"description"`
Importance string `json:"importance"`
Link string `json:"link"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Title == "" {
respondWithError(w, http.StatusBadRequest, "Title is required")
return
}
if req.Importance == "" {
req.Importance = "info"
}
if err := controllers.CreateNotification(req.Title, req.Subject, req.Description, req.Importance, req.Link); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusCreated, map[string]string{"message": "Notification created successfully"})
}
// handleArchiveNotification archives a specific notification
func (s *Server) handleArchiveNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
if err := controllers.ArchiveNotification(id); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Notification archived successfully"})
}
// handleUnarchiveNotification unarchives a specific notification
func (s *Server) handleUnarchiveNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
if err := controllers.UnarchiveNotification(id); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Notification unarchived successfully"})
}
// handleDeleteNotification deletes a specific notification
func (s *Server) handleDeleteNotification(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
// Check if notification is in archive
isArchived := r.URL.Query().Get("archived") == "true"
if err := controllers.DeleteNotification(id, isArchived); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Notification deleted successfully"})
}
// handleArchiveAllNotifications archives all unread notifications
func (s *Server) handleArchiveAllNotifications(w http.ResponseWriter, _ *http.Request) {
if err := controllers.ArchiveAllNotifications(); err != nil {
respondWithError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "All notifications archived successfully"})
}
// handleUnassignedDevices returns all unassigned devices and remote shares
func (s *Server) handleUnassignedDevices(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
defer s.cacheMutex.RUnlock()
if s.unassignedCache == nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"devices": []interface{}{},
"remote_shares": []interface{}{},
"timestamp": time.Now(),
})
return
}
respondJSON(w, http.StatusOK, s.unassignedCache)
}
// handleUnassignedDevicesList returns only unassigned devices (no remote shares)
func (s *Server) handleUnassignedDevicesList(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
defer s.cacheMutex.RUnlock()
if s.unassignedCache == nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"devices": []interface{}{},
"timestamp": time.Now(),
})
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"devices": s.unassignedCache.Devices,
"timestamp": s.unassignedCache.Timestamp,
})
}
// handleUnassignedRemoteShares returns only remote shares (no devices)
func (s *Server) handleUnassignedRemoteShares(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
defer s.cacheMutex.RUnlock()
if s.unassignedCache == nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"remote_shares": []interface{}{},
"timestamp": time.Now(),
})
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"remote_shares": s.unassignedCache.RemoteShares,
"timestamp": s.unassignedCache.Timestamp,
})
}
// ============================================================================
// ZFS Handlers
// ============================================================================
// handleZFSPools returns all ZFS pools
func (s *Server) handleZFSPools(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
pools := s.zfsPoolsCache
s.cacheMutex.RUnlock()
if pools == nil {
pools = []dto.ZFSPool{}
}
respondJSON(w, http.StatusOK, pools)
}
// handleZFSPool returns a specific ZFS pool by name
func (s *Server) handleZFSPool(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
poolName := vars["name"]
s.cacheMutex.RLock()
pools := s.zfsPoolsCache
s.cacheMutex.RUnlock()
// Find pool by name
for _, pool := range pools {
if pool.Name == poolName {
respondJSON(w, http.StatusOK, pool)
return
}
}
// Pool not found
respondJSON(w, http.StatusNotFound, dto.Response{
Success: false,
Message: fmt.Sprintf("ZFS pool not found: %s", poolName),
Timestamp: time.Now(),
})
}
// handleZFSDatasets returns all ZFS datasets
func (s *Server) handleZFSDatasets(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
datasets := s.zfsDatasetsCache
s.cacheMutex.RUnlock()
if datasets == nil {
datasets = []dto.ZFSDataset{}
}
respondJSON(w, http.StatusOK, datasets)
}
// handleZFSSnapshots returns all ZFS snapshots
func (s *Server) handleZFSSnapshots(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
snapshots := s.zfsSnapshotsCache
s.cacheMutex.RUnlock()
if snapshots == nil {
snapshots = []dto.ZFSSnapshot{}
}
respondJSON(w, http.StatusOK, snapshots)
}
// handleZFSARC returns ZFS ARC statistics
func (s *Server) handleZFSARC(w http.ResponseWriter, _ *http.Request) {
s.cacheMutex.RLock()
arcStats := s.zfsARCStatsCache
s.cacheMutex.RUnlock()
if arcStats == nil {
arcStats = &dto.ZFSARCStats{
Timestamp: time.Now(),
}
}
respondJSON(w, http.StatusOK, arcStats)
}
// handleCollectorsStatus returns the status of all collectors including enabled/disabled state
func (s *Server) handleCollectorsStatus(w http.ResponseWriter, _ *http.Request) {
// Define all collectors with their names and interval references
type collectorDef struct {
name string
interval int
}
collectors := []collectorDef{
{"system", s.ctx.Intervals.System},
{"array", s.ctx.Intervals.Array},
{"disk", s.ctx.Intervals.Disk},
{"docker", s.ctx.Intervals.Docker},
{"vm", s.ctx.Intervals.VM},
{"ups", s.ctx.Intervals.UPS},
{"nut", s.ctx.Intervals.NUT},
{"gpu", s.ctx.Intervals.GPU},
{"shares", s.ctx.Intervals.Shares},
{"network", s.ctx.Intervals.Network},
{"hardware", s.ctx.Intervals.Hardware},
{"zfs", s.ctx.Intervals.ZFS},
{"notification", s.ctx.Intervals.Notification},
{"registration", s.ctx.Intervals.Registration},
{"unassigned", s.ctx.Intervals.Unassigned},
}
var statuses []dto.CollectorStatus
enabledCount := 0
disabledCount := 0
for _, c := range collectors {
enabled := c.interval > 0
status := "running"
if !enabled {
status = "disabled"
disabledCount++
} else {
enabledCount++
}
statuses = append(statuses, dto.CollectorStatus{
Name: c.name,
Enabled: enabled,
Interval: c.interval,
Status: status,
})
}
respondJSON(w, http.StatusOK, dto.CollectorsStatusResponse{
Collectors: statuses,
Total: len(collectors),
EnabledCount: enabledCount,
DisabledCount: disabledCount,
Timestamp: time.Now(),
})
}
package api
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// Common log file locations on Unraid
// Expanded to match Unraid GraphQL API coverage per issue #28
var commonLogPaths = []string{
// Core system logs
"/var/log/syslog",
"/var/log/dmesg",
"/var/log/messages",
"/var/log/cron",
"/var/log/debug",
"/var/log/btmp",
"/var/log/lastlog",
"/var/log/wtmp",
// Unraid-specific logs
"/var/log/docker.log",
"/var/log/libvirt/libvirtd.log",
"/var/log/unraid-management-agent.log",
"/var/log/graphql-api.log",
"/var/log/unraid-api.log",
"/var/log/recycle.log",
"/var/log/dhcplog",
"/var/log/pkgtools/script.log",
"/var/log/mover.log",
// UPS logs
"/var/log/apcupsd.events",
"/var/log/nohup.out",
// Web server logs
"/var/log/nginx/error.log",
"/var/log/nginx/access.log",
// VFS and share logs
"/var/log/vfsd.log",
"/var/log/smbd.log",
"/var/log/nfsd.log",
// Plugin and system logs
"/var/log/plugins",
"/var/log/samba/log.smbd",
"/var/log/samba/log.nmbd",
}
// listLogFiles returns a list of available log files
func (s *Server) listLogFiles() []dto.LogFile {
var logs []dto.LogFile
// Check common log paths
for _, path := range commonLogPaths {
if info, err := os.Stat(path); err == nil {
logs = append(logs, dto.LogFile{
Name: filepath.Base(path),
Path: path,
Size: info.Size(),
ModifiedAt: info.ModTime(),
})
}
}
// Check plugin logs
pluginLogsDir := "/boot/config/plugins"
if entries, err := os.ReadDir(pluginLogsDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
logsPath := filepath.Join(pluginLogsDir, entry.Name(), "logs")
if logEntries, err := os.ReadDir(logsPath); err == nil {
for _, logEntry := range logEntries {
if !logEntry.IsDir() && strings.HasSuffix(logEntry.Name(), ".log") {
fullPath := filepath.Join(logsPath, logEntry.Name())
if info, err := os.Stat(fullPath); err == nil {
logs = append(logs, dto.LogFile{
Name: fmt.Sprintf("%s/%s", entry.Name(), logEntry.Name()),
Path: fullPath,
Size: info.Size(),
ModifiedAt: info.ModTime(),
})
}
}
}
}
}
}
}
return logs
}
// getLogContent retrieves log file content with optional pagination
func (s *Server) getLogContent(path, linesParam, startParam string) (*dto.LogFileContent, error) {
// Validate path (prevent directory traversal)
if strings.Contains(path, "..") {
return nil, fmt.Errorf("invalid path: directory traversal not allowed")
}
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, fmt.Errorf("log file not found: %s", path)
}
// Read file
file, err := os.Open(path) // #nosec G304 - path is validated above
if err != nil {
logger.Error("Failed to open log file %s: %v", path, err)
return nil, fmt.Errorf("failed to open log file: %v", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Error("Failed to close log file %s: %v", path, err)
}
}()
// Read all lines
var allLines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
allLines = append(allLines, scanner.Text())
}
if err := scanner.Err(); err != nil {
logger.Error("Failed to read log file %s: %v", path, err)
return nil, fmt.Errorf("failed to read log file: %v", err)
}
totalLines := len(allLines)
// Parse pagination parameters
var startLine, numLines int
startSpecified := startParam != ""
linesSpecified := linesParam != ""
if startSpecified {
if val, err := strconv.Atoi(startParam); err == nil {
startLine = val
}
}
if linesSpecified {
if val, err := strconv.Atoi(linesParam); err == nil {
numLines = val
}
}
// Default: return all lines if no pagination specified
if !linesSpecified && !startSpecified {
return &dto.LogFileContent{
Path: path,
Content: strings.Join(allLines, "\n"),
Lines: allLines,
TotalLines: totalLines,
LinesReturned: totalLines,
StartLine: 0,
EndLine: totalLines,
}, nil
}
// If only lines specified (no start), return last N lines (tail behavior)
if linesSpecified && !startSpecified {
if numLines > totalLines {
numLines = totalLines
}
startLine = totalLines - numLines
}
// Validate and adjust range
if startLine < 0 {
startLine = 0
}
if startLine >= totalLines {
return &dto.LogFileContent{
Path: path,
Content: "",
Lines: []string{},
TotalLines: totalLines,
LinesReturned: 0,
StartLine: startLine,
EndLine: startLine,
}, nil
}
endLine := startLine + numLines
if endLine > totalLines {
endLine = totalLines
}
selectedLines := allLines[startLine:endLine]
return &dto.LogFileContent{
Path: path,
Content: strings.Join(selectedLines, "\n"),
Lines: selectedLines,
TotalLines: totalLines,
LinesReturned: len(selectedLines),
StartLine: startLine,
EndLine: endLine,
}, nil
}
package api
import (
"net/http"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
logger.Debug("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
logger.Debug("Completed in %v", time.Since(start))
})
}
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.Error("Panic recovered: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
package api
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// Server represents the HTTP API server that handles REST endpoints and WebSocket connections.
// It maintains an in-memory cache of data from collectors and broadcasts updates to WebSocket clients.
type Server struct {
ctx *domain.Context
httpServer *http.Server
router *mux.Router
wsHub *WSHub
cancelCtx context.Context
cancelFunc context.CancelFunc
// Cache for latest data from collectors
cacheMutex sync.RWMutex
systemCache *dto.SystemInfo
arrayCache *dto.ArrayStatus
disksCache []dto.DiskInfo
sharesCache []dto.ShareInfo
dockerCache []dto.ContainerInfo
vmsCache []dto.VMInfo
upsCache *dto.UPSStatus
gpuCache []*dto.GPUMetrics
networkCache []dto.NetworkInfo
hardwareCache *dto.HardwareInfo
registrationCache *dto.Registration
notificationsCache *dto.NotificationList
unassignedCache *dto.UnassignedDeviceList
zfsPoolsCache []dto.ZFSPool
zfsDatasetsCache []dto.ZFSDataset
zfsSnapshotsCache []dto.ZFSSnapshot
zfsARCStatsCache *dto.ZFSARCStats
nutCache *dto.NUTResponse
}
// NewServer creates a new API server instance with the given context.
// It initializes the HTTP router, WebSocket hub, and sets up all API routes.
func NewServer(ctx *domain.Context) *Server {
cancelCtx, cancelFunc := context.WithCancel(context.Background())
s := &Server{
ctx: ctx,
router: mux.NewRouter(),
wsHub: NewWSHub(),
cancelCtx: cancelCtx,
cancelFunc: cancelFunc,
}
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
// Apply middleware
s.router.Use(corsMiddleware)
s.router.Use(loggingMiddleware)
s.router.Use(recoveryMiddleware)
api := s.router.PathPrefix("/api/v1").Subrouter()
// Health check
api.HandleFunc("/health", s.handleHealth).Methods("GET")
// Monitoring endpoints
api.HandleFunc("/system", s.handleSystem).Methods("GET")
api.HandleFunc("/array", s.handleArray).Methods("GET")
api.HandleFunc("/disks", s.handleDisks).Methods("GET")
api.HandleFunc("/disks/{id}", s.handleDisk).Methods("GET")
api.HandleFunc("/shares", s.handleShares).Methods("GET")
api.HandleFunc("/docker", s.handleDockerList).Methods("GET")
api.HandleFunc("/docker/{id}", s.handleDockerInfo).Methods("GET")
api.HandleFunc("/vm", s.handleVMList).Methods("GET")
api.HandleFunc("/vm/{id}", s.handleVMInfo).Methods("GET")
api.HandleFunc("/ups", s.handleUPS).Methods("GET")
api.HandleFunc("/nut", s.handleNUT).Methods("GET")
api.HandleFunc("/gpu", s.handleGPU).Methods("GET")
// System control endpoints
api.HandleFunc("/system/reboot", s.handleSystemReboot).Methods("POST")
api.HandleFunc("/system/shutdown", s.handleSystemShutdown).Methods("POST")
api.HandleFunc("/network", s.handleNetwork).Methods("GET")
// ZFS endpoints
api.HandleFunc("/zfs/pools", s.handleZFSPools).Methods("GET")
api.HandleFunc("/zfs/pools/{name}", s.handleZFSPool).Methods("GET")
api.HandleFunc("/zfs/datasets", s.handleZFSDatasets).Methods("GET")
api.HandleFunc("/zfs/snapshots", s.handleZFSSnapshots).Methods("GET")
api.HandleFunc("/zfs/arc", s.handleZFSARC).Methods("GET")
// Hardware endpoints
api.HandleFunc("/hardware/full", s.handleHardwareFull).Methods("GET")
api.HandleFunc("/hardware/bios", s.handleHardwareBIOS).Methods("GET")
api.HandleFunc("/hardware/baseboard", s.handleHardwareBaseboard).Methods("GET")
api.HandleFunc("/hardware/cpu", s.handleHardwareCPU).Methods("GET")
api.HandleFunc("/hardware/cache", s.handleHardwareCache).Methods("GET")
api.HandleFunc("/hardware/memory-array", s.handleHardwareMemoryArray).Methods("GET")
api.HandleFunc("/hardware/memory-devices", s.handleHardwareMemoryDevices).Methods("GET")
// Control endpoints
api.HandleFunc("/docker/{id}/start", s.handleDockerStart).Methods("POST")
api.HandleFunc("/docker/{id}/stop", s.handleDockerStop).Methods("POST")
api.HandleFunc("/docker/{id}/restart", s.handleDockerRestart).Methods("POST")
api.HandleFunc("/docker/{id}/pause", s.handleDockerPause).Methods("POST")
api.HandleFunc("/docker/{id}/unpause", s.handleDockerUnpause).Methods("POST")
api.HandleFunc("/vm/{name}/start", s.handleVMStart).Methods("POST")
api.HandleFunc("/vm/{name}/stop", s.handleVMStop).Methods("POST")
api.HandleFunc("/vm/{name}/restart", s.handleVMRestart).Methods("POST")
api.HandleFunc("/vm/{name}/pause", s.handleVMPause).Methods("POST")
api.HandleFunc("/vm/{name}/resume", s.handleVMResume).Methods("POST")
api.HandleFunc("/vm/{name}/hibernate", s.handleVMHibernate).Methods("POST")
api.HandleFunc("/vm/{name}/force-stop", s.handleVMForceStop).Methods("POST")
// Array control endpoints
api.HandleFunc("/array/start", s.handleArrayStart).Methods("POST")
api.HandleFunc("/array/stop", s.handleArrayStop).Methods("POST")
api.HandleFunc("/array/parity-check/start", s.handleParityCheckStart).Methods("POST")
api.HandleFunc("/array/parity-check/stop", s.handleParityCheckStop).Methods("POST")
api.HandleFunc("/array/parity-check/pause", s.handleParityCheckPause).Methods("POST")
api.HandleFunc("/array/parity-check/resume", s.handleParityCheckResume).Methods("POST")
api.HandleFunc("/array/parity-check/history", s.handleParityCheckHistory).Methods("GET")
// Configuration endpoints (read-only)
api.HandleFunc("/shares/{name}/config", s.handleShareConfig).Methods("GET")
api.HandleFunc("/network/{interface}/config", s.handleNetworkConfig).Methods("GET")
api.HandleFunc("/settings/system", s.handleSystemSettings).Methods("GET")
api.HandleFunc("/settings/docker", s.handleDockerSettings).Methods("GET")
api.HandleFunc("/settings/vm", s.handleVMSettings).Methods("GET")
api.HandleFunc("/settings/disks", s.handleDiskSettings).Methods("GET")
// Configuration endpoints (write)
api.HandleFunc("/shares/{name}/config", s.handleUpdateShareConfig).Methods("POST")
api.HandleFunc("/settings/system", s.handleUpdateSystemSettings).Methods("POST")
// User Scripts endpoints
api.HandleFunc("/user-scripts", s.handleUserScripts).Methods("GET")
api.HandleFunc("/user-scripts/{name}/execute", s.handleUserScriptExecute).Methods("POST")
// Registration/License endpoint
api.HandleFunc("/registration", s.handleRegistration).Methods("GET")
// Log file endpoints
api.HandleFunc("/logs", s.handleLogs).Methods("GET")
api.HandleFunc("/logs/{filename}", s.handleLogFile).Methods("GET")
// Notification endpoints (monitoring)
api.HandleFunc("/notifications", s.handleNotifications).Methods("GET")
api.HandleFunc("/notifications/unread", s.handleNotificationsUnread).Methods("GET")
api.HandleFunc("/notifications/archive", s.handleNotificationsArchive).Methods("GET")
api.HandleFunc("/notifications/overview", s.handleNotificationsOverview).Methods("GET")
api.HandleFunc("/notifications/{id}", s.handleNotificationByID).Methods("GET")
// Notification endpoints (control)
api.HandleFunc("/notifications", s.handleCreateNotification).Methods("POST")
api.HandleFunc("/notifications/{id}/archive", s.handleArchiveNotification).Methods("POST")
api.HandleFunc("/notifications/{id}/unarchive", s.handleUnarchiveNotification).Methods("POST")
api.HandleFunc("/notifications/{id}", s.handleDeleteNotification).Methods("DELETE")
api.HandleFunc("/notifications/archive/all", s.handleArchiveAllNotifications).Methods("POST")
// Unassigned Devices endpoints (monitoring)
api.HandleFunc("/unassigned", s.handleUnassignedDevices).Methods("GET")
api.HandleFunc("/unassigned/devices", s.handleUnassignedDevicesList).Methods("GET")
api.HandleFunc("/unassigned/remote-shares", s.handleUnassignedRemoteShares).Methods("GET")
// Collectors status endpoint
api.HandleFunc("/collectors/status", s.handleCollectorsStatus).Methods("GET")
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket)
}
// StartSubscriptions initializes event subscriptions and WebSocket hub
// This should be called before collectors start to avoid race conditions
func (s *Server) StartSubscriptions() {
logger.Info("Starting API server subscriptions...")
// Start WebSocket hub
go s.wsHub.Run(s.cancelCtx)
// Subscribe to events and update cache
go s.subscribeToEvents(s.cancelCtx)
// Broadcast events to WebSocket clients
go s.broadcastEvents(s.cancelCtx)
logger.Info("API server subscriptions started")
}
// StartHTTP starts the HTTP server
func (s *Server) StartHTTP() error {
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", s.ctx.Port),
Handler: s.router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
logger.Info("HTTP server listening on %s", s.httpServer.Addr)
return s.httpServer.ListenAndServe()
}
// Start starts both subscriptions and HTTP server (legacy method)
func (s *Server) Start() error {
s.StartSubscriptions()
return s.StartHTTP()
}
// Stop gracefully shuts down the API server.
// It cancels all background goroutines and shuts down the HTTP server with a 5-second timeout.
func (s *Server) Stop() {
// Cancel all background goroutines
s.cancelFunc()
// Shutdown HTTP server with timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(ctx); err != nil {
logger.Error("Server shutdown error: %v", err)
}
}
func (s *Server) subscribeToEvents(ctx context.Context) {
// Subscribe to specific events to update cache
logger.Info("Cache: Subscribing to event topics...")
ch := s.ctx.Hub.Sub(
"system_update",
"array_status_update",
"disk_list_update",
"share_list_update",
"container_list_update",
"vm_list_update",
"ups_status_update",
"nut_status_update",
"gpu_metrics_update",
"network_list_update",
"hardware_update",
"registration_update",
"notifications_update",
"unassigned_devices_update",
"zfs_pools_update",
"zfs_datasets_update",
"zfs_snapshots_update",
"zfs_arc_stats_update",
)
logger.Info("Cache: Subscription ready, waiting for events...")
for {
select {
case <-ctx.Done():
logger.Info("Cache subscription stopping due to context cancellation")
s.ctx.Hub.Unsub(ch)
return
case msg := <-ch:
// Update cache based on message type
switch v := msg.(type) {
case *dto.SystemInfo:
s.cacheMutex.Lock()
s.systemCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated system info - CPU: %.1f%%, RAM: %.1f%%", v.CPUUsage, v.RAMUsage)
case *dto.ArrayStatus:
s.cacheMutex.Lock()
s.arrayCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated array status - state=%s, disks=%d", v.State, v.NumDisks)
case []dto.DiskInfo:
s.cacheMutex.Lock()
s.disksCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated disk list - count=%d", len(v))
case []dto.ShareInfo:
s.cacheMutex.Lock()
s.sharesCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated share list - count=%d", len(v))
case []*dto.ContainerInfo:
// Convert pointer slice to value slice for cache
containers := make([]dto.ContainerInfo, len(v))
for i, c := range v {
containers[i] = *c
}
s.cacheMutex.Lock()
s.dockerCache = containers
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated container list - count=%d", len(v))
case []*dto.VMInfo:
// Convert pointer slice to value slice for cache
vms := make([]dto.VMInfo, len(v))
for i, vm := range v {
vms[i] = *vm
}
s.cacheMutex.Lock()
s.vmsCache = vms
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated VM list - count=%d", len(v))
case *dto.UPSStatus:
s.cacheMutex.Lock()
s.upsCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated UPS status - %s", v.Status)
case *dto.NUTResponse:
s.cacheMutex.Lock()
s.nutCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated NUT status - installed=%t, running=%t", v.Installed, v.Running)
case []*dto.GPUMetrics:
s.cacheMutex.Lock()
s.gpuCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated GPU metrics - count=%d", len(v))
case []dto.NetworkInfo:
s.cacheMutex.Lock()
s.networkCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated network list - count=%d", len(v))
case *dto.HardwareInfo:
s.cacheMutex.Lock()
s.hardwareCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated hardware info - BIOS: %s, Baseboard: %s",
func() string {
if v.BIOS != nil {
return v.BIOS.Vendor
}
return "N/A"
}(),
func() string {
if v.Baseboard != nil {
return v.Baseboard.Manufacturer
}
return "N/A"
}())
case *dto.Registration:
s.cacheMutex.Lock()
s.registrationCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated registration info - type=%s, state=%s", v.Type, v.State)
case *dto.NotificationList:
s.cacheMutex.Lock()
s.notificationsCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated notifications - unread=%d, archived=%d",
v.Overview.Unread.Total, v.Overview.Archive.Total)
case *dto.UnassignedDeviceList:
s.cacheMutex.Lock()
s.unassignedCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated unassigned devices - devices=%d, remote_shares=%d",
len(v.Devices), len(v.RemoteShares))
case []dto.ZFSPool:
s.cacheMutex.Lock()
s.zfsPoolsCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated ZFS pools - count=%d", len(v))
case []dto.ZFSDataset:
s.cacheMutex.Lock()
s.zfsDatasetsCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated ZFS datasets - count=%d", len(v))
case []dto.ZFSSnapshot:
s.cacheMutex.Lock()
s.zfsSnapshotsCache = v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated ZFS snapshots - count=%d", len(v))
case dto.ZFSARCStats:
s.cacheMutex.Lock()
s.zfsARCStatsCache = &v
s.cacheMutex.Unlock()
logger.Debug("Cache: Updated ZFS ARC stats - hit_ratio=%.2f%%", v.HitRatioPct)
default:
logger.Warning("Cache: Received unknown event type: %T", msg)
}
}
}
}
func (s *Server) broadcastEvents(ctx context.Context) {
// Subscribe to all event topics for WebSocket broadcasting
ch := s.ctx.Hub.Sub(
"system_update",
"array_status_update",
"disk_list_update",
"share_list_update",
"container_list_update",
"vm_list_update",
"ups_status_update",
"nut_status_update",
"gpu_metrics_update",
"network_list_update",
"hardware_update",
)
for {
select {
case <-ctx.Done():
logger.Info("WebSocket broadcast stopping due to context cancellation")
s.ctx.Hub.Unsub(ch)
return
case msg := <-ch:
s.wsHub.Broadcast(msg)
}
}
}
package api
import (
"context"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool {
return true // Allow all origins
},
}
// WSHub manages WebSocket client connections and broadcasts messages to all connected clients.
// It handles client registration, unregistration, and message broadcasting in a thread-safe manner.
type WSHub struct {
clients map[*WSClient]bool
broadcast chan interface{}
register chan *WSClient
unregister chan *WSClient
mu sync.RWMutex
}
// WSClient represents a single WebSocket client connection.
// It maintains the connection to the hub, the WebSocket connection, and a send channel for outgoing messages.
type WSClient struct {
hub *WSHub
conn *websocket.Conn
send chan dto.WSEvent
}
// NewWSHub creates and initializes a new WebSocket hub.
// The hub is ready to accept client connections and broadcast messages.
func NewWSHub() *WSHub {
return &WSHub{
clients: make(map[*WSClient]bool),
broadcast: make(chan interface{}, constants.WSBufferSize),
register: make(chan *WSClient),
unregister: make(chan *WSClient),
}
}
// Run starts the WebSocket hub's main event loop.
// It handles client registration, unregistration, and message broadcasting until the context is cancelled.
func (h *WSHub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
logger.Info("WebSocket hub stopping due to context cancellation")
// Close all client connections
h.mu.Lock()
for client := range h.clients {
close(client.send)
delete(h.clients, client)
}
h.mu.Unlock()
return
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
logger.Debug("WebSocket client connected")
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
logger.Debug("WebSocket client disconnected")
}
h.mu.Unlock()
case message := <-h.broadcast:
h.mu.RLock()
event := dto.WSEvent{
Event: "update",
Timestamp: time.Now(),
Data: message,
}
for client := range h.clients {
select {
case client.send <- event:
default:
close(client.send)
delete(h.clients, client)
}
}
h.mu.RUnlock()
}
}
}
// Broadcast sends a message to all connected WebSocket clients.
// The message is wrapped in a WSEvent and sent asynchronously to each client.
func (h *WSHub) Broadcast(message interface{}) {
h.broadcast <- message
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("WebSocket upgrade error: %v", err)
return
}
client := &WSClient{
hub: s.wsHub,
conn: conn,
send: make(chan dto.WSEvent, constants.WSBufferSize),
}
client.hub.register <- client
go client.writePump()
go client.readPump()
}
func (c *WSClient) writePump() {
ticker := time.NewTicker(time.Duration(constants.WSPingInterval) * time.Second)
defer func() {
ticker.Stop()
if err := c.conn.Close(); err != nil {
logger.Debug("Error closing WebSocket connection in writePump: %v", err)
}
}()
for {
select {
case event, ok := <-c.send:
if !ok {
// Channel closed, send close message
if err := c.conn.WriteMessage(websocket.CloseMessage, []byte{}); err != nil {
logger.Debug("Error writing close message: %v", err)
}
return
}
if err := c.conn.WriteJSON(event); err != nil {
return
}
case <-ticker.C:
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
func (c *WSClient) readPump() {
defer func() {
c.hub.unregister <- c
if err := c.conn.Close(); err != nil {
logger.Debug("Error closing WebSocket connection in readPump: %v", err)
}
}()
if err := c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
logger.Warning("Error setting initial read deadline: %v", err)
return
}
c.conn.SetPongHandler(func(string) error {
if err := c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)); err != nil {
logger.Debug("Error setting read deadline in pong handler: %v", err)
}
return nil
})
for {
_, _, err := c.conn.ReadMessage()
if err != nil {
break
}
}
}
// Package collectors provides data collection services for Unraid system resources.
package collectors
import (
"context"
"strconv"
"strings"
"syscall"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
"gopkg.in/ini.v1"
)
// ArrayCollector collects Unraid array status information including state, parity status, and disk assignments.
// It publishes array status updates to the event bus at regular intervals.
type ArrayCollector struct {
ctx *domain.Context
}
// NewArrayCollector creates a new array status collector with the given context.
func NewArrayCollector(ctx *domain.Context) *ArrayCollector {
return &ArrayCollector{ctx: ctx}
}
// Start begins the array collector's periodic data collection.
// It runs in a goroutine and publishes array status updates at the specified interval until the context is cancelled.
func (c *ArrayCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting array collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Array collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Array collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Array collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers current array status information and publishes it to the event bus.
// It reads array state from Unraid's mdcmd command and var.ini configuration file.
func (c *ArrayCollector) Collect() {
logger.Debug("Collecting array data...")
logger.Debug("TRACE: About to call collectArrayStatus()")
// Collect array status
arrayStatus, err := c.collectArrayStatus()
logger.Debug("TRACE: Returned from collectArrayStatus, err=%v", err)
if err != nil {
logger.Error("Array: Failed to collect array status: %v", err)
return
}
logger.Debug("Array: Successfully collected, publishing event")
// Publish event
c.ctx.Hub.Pub(arrayStatus, "array_status_update")
logger.Debug("Array: Published array_status_update event - state=%s, disks=%d", arrayStatus.State, arrayStatus.NumDisks)
}
func (c *ArrayCollector) collectArrayStatus() (*dto.ArrayStatus, error) {
defer func() {
if r := recover(); r != nil {
logger.Error("Array: PANIC during collection: %v", r)
}
}()
logger.Debug("Array: Starting collection from %s", constants.VarIni)
status := &dto.ArrayStatus{
Timestamp: time.Now(),
}
// Parse var.ini for array information
cfg, err := ini.Load(constants.VarIni)
if err != nil {
logger.Error("Array: Failed to load file: %v", err)
return nil, err
}
logger.Debug("Array: File loaded successfully")
// Get the default section (unnamed section)
section := cfg.Section("")
// Array state
if section.HasKey("mdState") {
status.State = strings.Trim(section.Key("mdState").String(), `"`)
} else {
status.State = "unknown"
}
// Number of disks
if section.HasKey("mdNumDisks") {
numDisks := strings.Trim(section.Key("mdNumDisks").String(), `"`)
logger.Debug("Array: Found mdNumDisks=%s", numDisks)
if n, err := strconv.Atoi(numDisks); err == nil {
status.NumDisks = n
logger.Debug("Array: Parsed mdNumDisks=%d", n)
} else {
logger.Error("Array: Failed to parse mdNumDisks: %v", err)
}
} else {
logger.Warning("Array: mdNumDisks not found in file")
}
// Count parity disks from disks.ini
status.NumParityDisks = c.countParityDisks()
// Calculate data disks: total disks minus parity disks
// mdNumDisks includes all array disks (data + parity), excluding cache/flash
status.NumDataDisks = status.NumDisks - status.NumParityDisks
logger.Debug("Array: Calculated NumDataDisks=%d (total=%d - parity=%d)",
status.NumDataDisks, status.NumDisks, status.NumParityDisks)
// Parity validity - check if parity sync has completed and has no errors
// sbSynced contains a timestamp when parity was last synced, or "0" if never synced
// sbSyncErrs contains the number of errors from the last parity check
parityValid := false
if section.HasKey("sbSynced") {
sbSynced := strings.Trim(section.Key("sbSynced").String(), `"`)
// If sbSynced is a non-zero number (timestamp), parity has been synced
if sbSynced != "0" && sbSynced != "" {
parityValid = true
}
}
// Check for parity errors - if there are any errors, parity is not valid
if section.HasKey("sbSyncErrs") {
sbSyncErrs := strings.Trim(section.Key("sbSyncErrs").String(), `"`)
if n, err := strconv.Atoi(sbSyncErrs); err == nil && n > 0 {
parityValid = false
}
}
// Only mark parity as valid if we have at least one parity disk
if status.NumParityDisks > 0 {
status.ParityValid = parityValid
} else {
status.ParityValid = false
}
// Parity check status - need to check multiple fields to detect state properly
// Key fields:
// - mdResyncPos: Current position in parity operation (>0 means operation in progress)
// - mdResyncDt: Delta time (0 = paused, >0 = running)
// - mdResyncSize: Total size for calculating progress
// - sbSyncAction: Type of parity operation (e.g., "check P", "check NOCORRECT")
var mdResyncPos, mdResyncSize uint64
var mdResyncDt int64
if section.HasKey("mdResyncPos") {
posStr := strings.Trim(section.Key("mdResyncPos").String(), `"`)
if pos, err := strconv.ParseUint(posStr, 10, 64); err == nil {
mdResyncPos = pos
}
}
if section.HasKey("mdResyncSize") {
sizeStr := strings.Trim(section.Key("mdResyncSize").String(), `"`)
if size, err := strconv.ParseUint(sizeStr, 10, 64); err == nil {
mdResyncSize = size
}
}
if section.HasKey("mdResyncDt") {
dtStr := strings.Trim(section.Key("mdResyncDt").String(), `"`)
if dt, err := strconv.ParseInt(dtStr, 10, 64); err == nil {
mdResyncDt = dt
}
}
// Determine parity check status based on mdResyncPos and mdResyncDt
// - mdResyncPos > 0 AND mdResyncDt = 0 → PAUSED
// - mdResyncPos > 0 AND mdResyncDt > 0 → RUNNING (check, correct, etc.)
// - mdResyncPos = 0 → IDLE (no active operation)
if mdResyncPos > 0 {
// There is an active parity operation
if mdResyncDt == 0 {
// Operation is paused
status.ParityCheckStatus = "paused"
} else {
// Operation is running - get the action type
if section.HasKey("sbSyncAction") {
action := strings.Trim(section.Key("sbSyncAction").String(), `"`)
// Map common action values to user-friendly status
switch {
case strings.Contains(strings.ToLower(action), "check"):
status.ParityCheckStatus = "running"
case strings.Contains(strings.ToLower(action), "clear"):
status.ParityCheckStatus = "clearing"
case strings.Contains(strings.ToLower(action), "recon"):
status.ParityCheckStatus = "reconstructing"
default:
status.ParityCheckStatus = "running"
}
} else {
status.ParityCheckStatus = "running"
}
}
// Calculate progress percentage
if mdResyncSize > 0 {
status.ParityCheckProgress = float64(mdResyncPos) / float64(mdResyncSize) * 100.0
// Clamp to 0-100 range
if status.ParityCheckProgress > 100 {
status.ParityCheckProgress = 100
}
}
logger.Debug("Array: Parity operation detected - pos=%d, size=%d, dt=%d, status=%s, progress=%.2f%%",
mdResyncPos, mdResyncSize, mdResyncDt, status.ParityCheckStatus, status.ParityCheckProgress)
} else {
// No active parity operation
status.ParityCheckStatus = ""
status.ParityCheckProgress = 0
}
// Get array size information from /mnt/user filesystem
// /mnt/user is the shfs (Unraid user share filesystem) that represents the entire array
c.enrichWithArraySize(status)
logger.Debug("Array: Parsed status - state=%s, disks=%d, parity=%v, used=%.1f%%",
status.State, status.NumDisks, status.ParityValid, status.UsedPercent)
return status, nil
}
// enrichWithArraySize gets total array size and usage from /mnt/user
func (c *ArrayCollector) enrichWithArraySize(status *dto.ArrayStatus) {
// Use syscall.Statfs to get filesystem statistics for /mnt/user
var stat syscall.Statfs_t
if err := syscall.Statfs("/mnt/user", &stat); err != nil {
logger.Debug("Array: Failed to get /mnt/user stats: %v", err)
return
}
// Calculate sizes in bytes (safe conversion - Bsize is always positive)
//nolint:gosec // G115: Bsize is always positive on Linux systems
bsize := uint64(stat.Bsize)
totalBytes := stat.Blocks * bsize
freeBytes := stat.Bfree * bsize
usedBytes := totalBytes - freeBytes
status.TotalBytes = totalBytes
status.FreeBytes = freeBytes
// Calculate usage percentage
if totalBytes > 0 {
status.UsedPercent = float64(usedBytes) / float64(totalBytes) * 100
}
logger.Debug("Array: Size - total=%d bytes (%.2f TB), used=%.1f%%",
totalBytes, float64(totalBytes)/(1024*1024*1024*1024), status.UsedPercent)
}
// countParityDisks counts the number of parity disks from disks.ini
func (c *ArrayCollector) countParityDisks() int {
// Parse disks.ini to count active parity disks
cfg, err := ini.Load(constants.DisksIni)
if err != nil {
logger.Debug("Array: Failed to load disks.ini: %v", err)
return 0
}
parityCount := 0
// Iterate through all sections in disks.ini
for _, section := range cfg.Sections() {
// Check if this section has type="Parity" and is active
if section.HasKey("type") && section.HasKey("status") {
diskType := strings.Trim(section.Key("type").String(), `"`)
diskStatus := strings.Trim(section.Key("status").String(), `"`)
// Only count parity disks that are active (not disabled)
// DISK_NP_DSBL = Not Present/Disabled, DISK_NP = Not Present, DISK_DSBL = Disabled
if diskType == "Parity" && diskStatus != "DISK_NP_DSBL" && diskStatus != "DISK_NP" && diskStatus != "DISK_DSBL" {
parityCount++
logger.Debug("Array: Found active parity disk in section [%s] with status=%s", section.Name(), diskStatus)
} else if diskType == "Parity" {
logger.Debug("Array: Skipping disabled/missing parity disk in section [%s] with status=%s", section.Name(), diskStatus)
}
}
}
logger.Debug("Array: Counted %d active parity disk(s) from disks.ini", parityCount)
return parityCount
}
package collectors
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// ConfigCollector collects configuration data
type ConfigCollector struct{}
// NewConfigCollector creates a new config collector
func NewConfigCollector() *ConfigCollector {
return &ConfigCollector{}
}
// GetShareConfig reads share configuration from /boot/config/shares/{name}.cfg
func (c *ConfigCollector) GetShareConfig(shareName string) (*dto.ShareConfig, error) {
// Validate share name to prevent path traversal
if err := validateShareName(shareName); err != nil {
return nil, err
}
configPath := fmt.Sprintf("/boot/config/shares/%s.cfg", shareName)
logger.Debug("Config: Reading share config from %s", configPath)
// #nosec G304 - Path is validated by validateShareName() to prevent path traversal
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("share config not found: %s", shareName)
}
return nil, fmt.Errorf("failed to open share config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing share config file: %v", err)
}
}()
config := &dto.ShareConfig{
Name: shareName,
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "shareComment":
config.Comment = value
case "shareAllocator":
config.Allocator = value
case "shareFloor":
config.Floor = value
case "shareSplitLevel":
config.SplitLevel = value
case "shareInclude":
if value != "" {
config.IncludeDisks = strings.Split(value, ",")
}
case "shareExclude":
if value != "" {
config.ExcludeDisks = strings.Split(value, ",")
}
case "shareUseCache":
config.UseCache = value
case "shareExport":
config.Export = value
case "shareSecurity":
config.Security = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading share config: %w", err)
}
return config, nil
}
// GetNetworkConfig reads network configuration from /boot/config/network.cfg
func (c *ConfigCollector) GetNetworkConfig(interfaceName string) (*dto.NetworkConfig, error) {
configPath := "/boot/config/network.cfg"
logger.Debug("Config: Reading network config from %s", configPath)
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("network config not found")
}
return nil, fmt.Errorf("failed to open network config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing network config file: %v", err)
}
}()
config := &dto.NetworkConfig{
Interface: interfaceName,
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
inSection := false
currentInterface := ""
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Check for section header
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentInterface = strings.Trim(line, "[]")
inSection = (currentInterface == interfaceName)
continue
}
if !inSection {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "TYPE":
config.Type = value
case "IPADDR":
config.IPAddress = value
case "NETMASK":
config.Netmask = value
case "GATEWAY":
config.Gateway = value
case "BONDING_MODE":
config.BondingMode = value
case "BONDING_SLAVES":
if value != "" {
config.BondSlaves = strings.Split(value, " ")
}
case "BRIDGE_MEMBERS":
if value != "" {
config.BridgeMembers = strings.Split(value, " ")
}
case "VLAN_ID":
if vlanID, err := strconv.Atoi(value); err == nil {
config.VLANID = vlanID
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading network config: %w", err)
}
if config.Type == "" {
return nil, fmt.Errorf("interface not found: %s", interfaceName)
}
return config, nil
}
// GetSystemSettings reads system settings from /boot/config/ident.cfg
func (c *ConfigCollector) GetSystemSettings() (*dto.SystemSettings, error) {
configPath := "/boot/config/ident.cfg"
logger.Debug("Config: Reading system settings from %s", configPath)
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("system config not found")
}
return nil, fmt.Errorf("failed to open system config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing system config file: %v", err)
}
}()
settings := &dto.SystemSettings{
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "NAME":
settings.ServerName = value
case "COMMENT":
settings.Description = value
case "MODEL":
settings.Model = value
case "TIMEZONE":
settings.Timezone = value
case "DATE_FORMAT":
settings.DateFormat = value
case "TIME_FORMAT":
settings.TimeFormat = value
case "SECURITY":
settings.SecurityMode = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading system config: %w", err)
}
return settings, nil
}
// GetDockerSettings reads Docker settings from /boot/config/docker.cfg
func (c *ConfigCollector) GetDockerSettings() (*dto.DockerSettings, error) {
configPath := "/boot/config/docker.cfg"
logger.Debug("Config: Reading Docker settings from %s", configPath)
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return &dto.DockerSettings{
Enabled: false,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("failed to open Docker config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing Docker config file: %v", err)
}
}()
settings := &dto.DockerSettings{
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "DOCKER_ENABLED":
settings.Enabled = (value == "yes" || value == "true" || value == "1")
case "DOCKER_IMAGE_FILE":
settings.ImagePath = value
case "DOCKER_DEFAULT_NETWORK":
settings.DefaultNetwork = value
case "DOCKER_CUSTOM_NETWORKS":
if value != "" {
settings.CustomNetworks = strings.Split(value, ",")
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading Docker config: %w", err)
}
return settings, nil
}
// GetVMSettings reads VM settings from /boot/config/domain.cfg
func (c *ConfigCollector) GetVMSettings() (*dto.VMSettings, error) {
configPath := "/boot/config/domain.cfg"
logger.Debug("Config: Reading VM settings from %s", configPath)
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return &dto.VMSettings{
Enabled: false,
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("failed to open VM config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing VM config file: %v", err)
}
}()
settings := &dto.VMSettings{
DefaultSettings: make(map[string]string),
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "SERVICE":
settings.Enabled = (value == "enable" || value == "enabled")
case "PCI_DEVICES":
if value != "" {
settings.PCIDevices = strings.Split(value, ",")
}
case "USB_DEVICES":
if value != "" {
settings.USBDevices = strings.Split(value, ",")
}
default:
// Store other settings in default settings map
settings.DefaultSettings[key] = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading VM config: %w", err)
}
return settings, nil
}
// UpdateShareConfig writes share configuration to /boot/config/shares/{name}.cfg
func (c *ConfigCollector) UpdateShareConfig(config *dto.ShareConfig) error {
// Validate share name to prevent path traversal
if err := validateShareName(config.Name); err != nil {
return err
}
configPath := fmt.Sprintf("/boot/config/shares/%s.cfg", config.Name)
logger.Info("Config: Writing share config to %s", configPath)
// Create backup
backupPath := configPath + ".bak"
if _, err := os.Stat(configPath); err == nil {
if err := os.Rename(configPath, backupPath); err != nil {
logger.Error("Config: Failed to create backup: %v", err)
}
}
// #nosec G304 - Path is validated by validateShareName() to prevent path traversal
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create share config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing share config file: %v", err)
}
}()
// Write configuration
if config.Comment != "" {
if _, err := fmt.Fprintf(file, "shareComment=\"%s\"\n", config.Comment); err != nil {
return fmt.Errorf("failed to write shareComment: %w", err)
}
}
if config.Allocator != "" {
if _, err := fmt.Fprintf(file, "shareAllocator=\"%s\"\n", config.Allocator); err != nil {
return fmt.Errorf("failed to write shareAllocator: %w", err)
}
}
if config.Floor != "" {
if _, err := fmt.Fprintf(file, "shareFloor=\"%s\"\n", config.Floor); err != nil {
return fmt.Errorf("failed to write shareFloor: %w", err)
}
}
if config.SplitLevel != "" {
if _, err := fmt.Fprintf(file, "shareSplitLevel=\"%s\"\n", config.SplitLevel); err != nil {
return fmt.Errorf("failed to write shareSplitLevel: %w", err)
}
}
if len(config.IncludeDisks) > 0 {
if _, err := fmt.Fprintf(file, "shareInclude=\"%s\"\n", strings.Join(config.IncludeDisks, ",")); err != nil {
return fmt.Errorf("failed to write shareInclude: %w", err)
}
}
if len(config.ExcludeDisks) > 0 {
if _, err := fmt.Fprintf(file, "shareExclude=\"%s\"\n", strings.Join(config.ExcludeDisks, ",")); err != nil {
return fmt.Errorf("failed to write shareExclude: %w", err)
}
}
if config.UseCache != "" {
if _, err := fmt.Fprintf(file, "shareUseCache=\"%s\"\n", config.UseCache); err != nil {
return fmt.Errorf("failed to write shareUseCache: %w", err)
}
}
if config.Export != "" {
if _, err := fmt.Fprintf(file, "shareExport=\"%s\"\n", config.Export); err != nil {
return fmt.Errorf("failed to write shareExport: %w", err)
}
}
if config.Security != "" {
if _, err := fmt.Fprintf(file, "shareSecurity=\"%s\"\n", config.Security); err != nil {
return fmt.Errorf("failed to write shareSecurity: %w", err)
}
}
logger.Info("Config: Share config written successfully")
return nil
}
// UpdateSystemSettings writes system settings to /boot/config/ident.cfg
func (c *ConfigCollector) UpdateSystemSettings(settings *dto.SystemSettings) error {
configPath := "/boot/config/ident.cfg"
logger.Info("Config: Writing system settings to %s", configPath)
// Create backup
backupPath := configPath + ".bak"
if _, err := os.Stat(configPath); err == nil {
if err := os.Rename(configPath, backupPath); err != nil {
logger.Error("Config: Failed to create backup: %v", err)
}
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create system config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing system config file: %v", err)
}
}()
// Write configuration
if settings.ServerName != "" {
if _, err := fmt.Fprintf(file, "NAME=\"%s\"\n", settings.ServerName); err != nil {
return fmt.Errorf("failed to write NAME: %w", err)
}
}
if settings.Description != "" {
if _, err := fmt.Fprintf(file, "COMMENT=\"%s\"\n", settings.Description); err != nil {
return fmt.Errorf("failed to write COMMENT: %w", err)
}
}
if settings.Model != "" {
if _, err := fmt.Fprintf(file, "MODEL=\"%s\"\n", settings.Model); err != nil {
return fmt.Errorf("failed to write MODEL: %w", err)
}
}
if settings.Timezone != "" {
if _, err := fmt.Fprintf(file, "TIMEZONE=\"%s\"\n", settings.Timezone); err != nil {
return fmt.Errorf("failed to write TIMEZONE: %w", err)
}
}
if settings.DateFormat != "" {
if _, err := fmt.Fprintf(file, "DATE_FORMAT=\"%s\"\n", settings.DateFormat); err != nil {
return fmt.Errorf("failed to write DATE_FORMAT: %w", err)
}
}
if settings.TimeFormat != "" {
if _, err := fmt.Fprintf(file, "TIME_FORMAT=\"%s\"\n", settings.TimeFormat); err != nil {
return fmt.Errorf("failed to write TIME_FORMAT: %w", err)
}
}
if settings.SecurityMode != "" {
if _, err := fmt.Fprintf(file, "SECURITY=\"%s\"\n", settings.SecurityMode); err != nil {
return fmt.Errorf("failed to write SECURITY: %w", err)
}
}
logger.Info("Config: System settings written successfully")
return nil
}
// GetDiskSettings reads disk settings from /boot/config/disk.cfg
func (c *ConfigCollector) GetDiskSettings() (*dto.DiskSettings, error) {
configPath := "/boot/config/disk.cfg"
logger.Debug("Config: Reading disk settings from %s", configPath)
file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("disk config not found")
}
return nil, fmt.Errorf("failed to open disk config: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing disk config file: %v", err)
}
}()
settings := &dto.DiskSettings{
Timestamp: time.Now(),
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "spindownDelay":
if delay, err := strconv.Atoi(value); err == nil {
settings.SpindownDelay = delay
}
case "startArray":
settings.StartArray = (value == "yes" || value == "true" || value == "1")
case "spinupGroups":
settings.SpinupGroups = (value == "yes" || value == "true" || value == "1")
case "shutdownTimeout":
if timeout, err := strconv.Atoi(value); err == nil {
settings.ShutdownTimeout = timeout
}
case "defaultFsType":
settings.DefaultFsType = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading disk config: %w", err)
}
return settings, nil
}
// validateShareName validates a share name to prevent path traversal attacks
// Share names should contain only safe characters and no path separators
func validateShareName(name string) error {
if name == "" {
return fmt.Errorf("share name cannot be empty")
}
if len(name) > 255 {
return fmt.Errorf("share name too long: maximum 255 characters, got %d", len(name))
}
// Check for parent directory references first (most specific attack)
if strings.Contains(name, "..") {
return fmt.Errorf("invalid share name: parent directory references not allowed")
}
// Check for absolute paths
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, "\\") {
return fmt.Errorf("invalid share name: absolute paths not allowed")
}
// Check for path separators (both Unix and Windows)
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("invalid share name: path separators not allowed")
}
// Additional security: ensure the resolved path stays within the shares directory
const sharesDir = "/boot/config/shares"
cleanPath := filepath.Clean(filepath.Join(sharesDir, name+".cfg"))
if !strings.HasPrefix(cleanPath, sharesDir) {
return fmt.Errorf("invalid share name: path escapes shares directory")
}
return nil
}
package collectors
import (
"bufio"
"context"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// DiskCollector collects detailed information about all disks in the Unraid system.
// It gathers disk metrics, SMART data, temperature, and usage statistics for array and cache disks.
type DiskCollector struct {
ctx *domain.Context
}
// NewDiskCollector creates a new disk information collector with the given context.
func NewDiskCollector(ctx *domain.Context) *DiskCollector {
return &DiskCollector{ctx: ctx}
}
// Start begins the disk collector's periodic data collection.
// It runs in a goroutine and publishes disk information updates at the specified interval until the context is cancelled.
func (c *DiskCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting disk collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Disk collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Disk collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Disk collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers detailed disk information and publishes it to the event bus.
// It collects data from multiple sources including lsblk, smartctl, and Unraid configuration files.
func (c *DiskCollector) Collect() {
logger.Debug("Collecting disk data...")
// Collect disk information
disks, err := c.collectDisks()
if err != nil {
logger.Error("Disk: Failed to collect disk data: %v", err)
return
}
logger.Debug("Disk: Successfully collected %d disks, publishing event", len(disks))
// Publish event
c.ctx.Hub.Pub(disks, "disk_list_update")
logger.Debug("Disk: Published disk_list_update event with %d disks", len(disks))
}
func (c *DiskCollector) collectDisks() ([]dto.DiskInfo, error) {
logger.Debug("Disk: Starting collection from %s", constants.DisksIni)
// Parse disks.ini
disks, err := c.parseDisksINI()
if err != nil {
return nil, err
}
// Enhance each disk with additional stats
c.enrichDisks(disks)
logger.Debug("Disk: Parsed %d disks successfully", len(disks))
// Collect Docker vDisk information
if dockerVDisk := c.collectDockerVDisk(); dockerVDisk != nil {
disks = append(disks, *dockerVDisk)
logger.Debug("Disk: Added Docker vDisk to collection")
}
// Collect Log filesystem information
if logFS := c.collectLogFilesystem(); logFS != nil {
disks = append(disks, *logFS)
logger.Debug("Disk: Added Log filesystem to collection")
}
return disks, nil
}
// parseDisksINI parses the disks.ini file and returns a slice of DiskInfo
func (c *DiskCollector) parseDisksINI() ([]dto.DiskInfo, error) {
file, err := os.Open(constants.DisksIni)
if err != nil {
logger.Error("Disk: Failed to open file: %v", err)
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing disk file: %v", err)
}
}()
logger.Debug("Disk: File opened successfully")
var disks []dto.DiskInfo
scanner := bufio.NewScanner(file)
var currentDisk *dto.DiskInfo
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Check for section header: ["diskname"]
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// Save previous disk if exists
if currentDisk != nil {
disks = append(disks, *currentDisk)
}
// Start new disk
currentDisk = &dto.DiskInfo{
Timestamp: time.Now(),
}
continue
}
// Parse key=value pairs
if currentDisk != nil && strings.Contains(line, "=") {
c.parseDiskKeyValue(currentDisk, line)
}
}
// Save last disk
if currentDisk != nil {
disks = append(disks, *currentDisk)
}
if err := scanner.Err(); err != nil {
logger.Error("Disk: Scanner error: %v", err)
return disks, err
}
return disks, nil
}
// parseDiskKeyValue parses a single key=value line from disks.ini
func (c *DiskCollector) parseDiskKeyValue(disk *dto.DiskInfo, line string) {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
return
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "name":
disk.Name = value
case "device":
disk.Device = value
case "id":
disk.ID = value
case "status":
disk.Status = value
case "size":
if size, err := strconv.ParseUint(value, 10, 64); err == nil {
disk.Size = size * 512 // Convert sectors to bytes
}
case "temp":
// Temperature might be "*" if spun down, or empty, or a number
// Unraid uses "*" to indicate disk is spun down (no temperature available)
if value == "*" || value == "" {
// Temperature unavailable - disk is likely spun down
// Keep Temperature at 0 (default) and we'll set SpinState appropriately
logger.Debug("Disk: Device %s temperature unavailable (value='%s'), likely spun down", disk.Device, value)
} else {
if temp, err := strconv.ParseFloat(value, 64); err == nil {
disk.Temperature = temp
}
}
case "numErrors":
if errors, err := strconv.Atoi(value); err == nil {
disk.SMARTErrors = errors
}
case "spindownDelay":
if delay, err := strconv.Atoi(value); err == nil {
disk.SpindownDelay = delay
}
case "format":
disk.FileSystem = value
}
}
// enrichDisks enhances each disk with additional statistics
func (c *DiskCollector) enrichDisks(disks []dto.DiskInfo) {
for i := range disks {
// Get I/O statistics
c.enrichWithIOStats(&disks[i])
// Get SMART attributes (if device is available)
if disks[i].Device != "" {
c.enrichWithSMARTData(&disks[i])
}
// Get mount information
c.enrichWithMountInfo(&disks[i])
// Get disk role
c.enrichWithRole(&disks[i])
// Get spin state
if disks[i].Device != "" {
c.enrichWithSpinState(&disks[i])
}
}
}
// enrichWithIOStats adds I/O statistics from /sys/block
func (c *DiskCollector) enrichWithIOStats(disk *dto.DiskInfo) {
if disk.Device == "" {
return
}
// Read from /sys/block/{device}/stat
statPath := "/sys/block/" + disk.Device + "/stat"
//nolint:gosec // G304: Path is constructed from /sys/block system directory, device name from trusted source
data, err := os.ReadFile(statPath)
if err != nil {
return // Device might be spun down or not available
}
fields := strings.Fields(string(data))
if len(fields) < 11 {
return
}
// Parse fields (see Documentation/block/stat.txt in Linux kernel)
// read I/Os, read merges, read sectors, read ticks,
// write I/Os, write merges, write sectors, write ticks,
// in_flight, io_ticks, time_in_queue
if readOps, err := strconv.ParseUint(fields[0], 10, 64); err == nil {
disk.ReadOps = readOps
}
if readSectors, err := strconv.ParseUint(fields[2], 10, 64); err == nil {
disk.ReadBytes = readSectors * 512 // Sectors to bytes
}
if writeOps, err := strconv.ParseUint(fields[4], 10, 64); err == nil {
disk.WriteOps = writeOps
}
if writeSectors, err := strconv.ParseUint(fields[6], 10, 64); err == nil {
disk.WriteBytes = writeSectors * 512 // Sectors to bytes
}
if ioTicks, err := strconv.ParseUint(fields[9], 10, 64); err == nil {
// io_ticks is in milliseconds, calculate utilization
// This is a cumulative value, would need previous sample for rate
disk.IOUtilization = float64(ioTicks) / 10.0 // Rough estimate
}
}
// isUSBDevice checks if a device is a USB device by examining its sysfs path
func (c *DiskCollector) isUSBDevice(device string) bool {
// Read the device's sysfs path to determine if it's USB
sysfsPath := "/sys/block/" + device + "/device"
// Read the symlink and resolve it to the full path
devicePath, err := os.Readlink(sysfsPath)
if err != nil {
// If we can't read the symlink, assume it's not USB
return false
}
// Resolve the relative path to an absolute path
// The symlink is relative (e.g., ../../../6:0:0:0), so we need to resolve it
fullPath, err := lib.ExecCommand("readlink", "-f", sysfsPath)
if err != nil || len(fullPath) == 0 {
// If we can't resolve the path, fall back to checking the relative path
fullPath = []string{devicePath}
}
// USB devices have "usb" in their full device path
// Example: /sys/devices/pci0000:00/0000:00:14.0/usb1/1-10/1-10:1.0/host6/target6:0:0/6:0:0:0
fullPathStr := strings.Join(fullPath, "")
isUSB := strings.Contains(fullPathStr, "/usb")
if isUSB {
logger.Debug("Disk: Device %s detected as USB device (path: %s)", device, fullPathStr)
}
return isUSB
}
// isBootDrive checks if a device is the Unraid boot drive by checking mount points
func (c *DiskCollector) isBootDrive(device string) bool {
// Read /proc/mounts to check if this device is mounted at /boot
file, err := os.Open("/proc/mounts")
if err != nil {
return false
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing /proc/mounts: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) >= 2 {
// Check if this device is mounted at /boot
// Example: /dev/sda1 /boot vfat ...
if strings.Contains(fields[0], device) && fields[1] == "/boot" {
logger.Debug("Disk: Device %s detected as boot drive (mounted at /boot)", device)
return true
}
}
}
return false
}
// isNVMeDevice checks if a device is an NVMe device
func (c *DiskCollector) isNVMeDevice(device string) bool {
// NVMe devices have "nvme" in their device name
// Example: nvme0n1, nvme1n1, etc.
isNVMe := strings.Contains(device, "nvme")
if isNVMe {
logger.Debug("Disk: Device %s detected as NVMe device", device)
}
return isNVMe
}
// enrichWithSMARTData adds SMART attributes using smartctl
func (c *DiskCollector) enrichWithSMARTData(disk *dto.DiskInfo) {
devicePath := "/dev/" + disk.Device
// Check if device exists
if _, err := os.Stat(devicePath); err != nil {
return
}
// Default to UNKNOWN if we can't read SMART data
disk.SMARTStatus = "UNKNOWN"
// Check if this is a USB flash drive (like the Unraid boot drive)
// USB flash drives typically don't support SMART monitoring
if c.isUSBDevice(disk.Device) {
if c.isBootDrive(disk.Device) {
logger.Debug("Disk: Skipping SMART check for %s (USB boot drive)", disk.Device)
} else {
logger.Debug("Disk: Skipping SMART check for %s (USB flash drive)", disk.Device)
}
// Keep status as UNKNOWN for USB flash drives
return
}
// Detect device type for optimized SMART collection
isNVMe := c.isNVMeDevice(disk.Device)
var lines []string
var err error
if isNVMe {
// NVMe drives don't support standby mode, so we skip the -n standby flag
// Use smartctl -H directly for NVMe drives
logger.Debug("Disk: Collecting SMART data for NVMe device %s (no standby check)", disk.Device)
lines, err = lib.ExecCommand("smartctl", "-H", devicePath)
} else {
// SATA/SAS drives support standby mode
// Run smartctl with -n standby to avoid waking up spun-down disks
// The -n standby flag tells smartctl to skip the check if the disk is in standby mode
// This preserves Unraid's disk spin-down functionality
// Exit codes:
// 0 = Success, disk is active, SMART data retrieved
// 2 = Disk is in standby/sleep mode, check skipped (disk NOT woken up)
// Other = Error accessing disk
logger.Debug("Disk: Collecting SMART data for SATA/SAS device %s (with standby check)", disk.Device)
lines, err = lib.ExecCommand("smartctl", "-n", "standby", "-H", devicePath)
}
if err != nil {
// Check if this is a "disk in standby" error (exit code 2)
// In this case, we preserve the disk's spun-down state and skip SMART check
// The SMART status will remain as the last known value or UNKNOWN
logger.Debug("Disk: Skipping SMART check for %s (disk may be in standby mode): %v", disk.Device, err)
return
}
logger.Debug("Disk: Successfully retrieved SMART health for %s", disk.Device)
for _, line := range lines {
line = strings.TrimSpace(line)
// Parse SMART health status (SATA/SAS drives)
// Example: "SMART overall-health self-assessment test result: PASSED"
if strings.Contains(line, "SMART overall-health self-assessment test result:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
status := strings.TrimSpace(parts[1])
disk.SMARTStatus = strings.ToUpper(status)
logger.Debug("Disk: Parsed SATA/SAS SMART status for %s: %s", disk.Device, disk.SMARTStatus)
}
}
// Parse SMART health status (NVMe drives)
// Example: "SMART Health Status: OK"
if strings.Contains(line, "SMART Health Status:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
status := strings.TrimSpace(parts[1])
// Normalize NVMe "OK" to "PASSED" for consistency
if strings.ToUpper(status) == "OK" {
disk.SMARTStatus = "PASSED"
} else {
disk.SMARTStatus = strings.ToUpper(status)
}
logger.Debug("Disk: Parsed NVMe SMART status for %s: %s (original: %s)", disk.Device, disk.SMARTStatus, status)
}
}
}
}
// enrichWithMountInfo adds mount point and usage information
func (c *DiskCollector) enrichWithMountInfo(disk *dto.DiskInfo) {
if disk.Name == "" {
return
}
// Read /proc/mounts to find mount point
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return
}
// For Unraid array disks, the mount point is /mnt/diskN where N is the disk number
// The device in /proc/mounts is /dev/mdNp1 (e.g., /dev/md1p1 for disk1)
// For cache/flash, it's the actual device (e.g., /dev/nvme0n1p1, /dev/sda1)
var mountPoint string
lines := strings.Split(string(data), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
// Check if mount point matches /mnt/{diskname}
expectedMountPoint := "/mnt/" + disk.Name
if fields[1] == expectedMountPoint {
mountPoint = fields[1]
break
}
// Also check for direct device match (for cache, flash, etc.)
if disk.Device != "" {
devicePath := "/dev/" + disk.Device
if fields[0] == devicePath || strings.HasPrefix(fields[0], devicePath) {
mountPoint = fields[1]
break
}
}
}
if mountPoint == "" {
return
}
disk.MountPoint = mountPoint
// Get filesystem statistics using statfs
var stat syscall.Statfs_t
if err := syscall.Statfs(disk.MountPoint, &stat); err == nil {
// Calculate sizes in bytes (safe conversion - Bsize is always positive)
//nolint:gosec // G115: Bsize is always positive on Linux systems
bsize := uint64(stat.Bsize)
totalBytes := stat.Blocks * bsize
freeBytes := stat.Bfree * bsize
usedBytes := totalBytes - freeBytes
disk.Used = usedBytes
disk.Free = freeBytes
// Calculate usage percentage
if totalBytes > 0 {
disk.UsagePercent = float64(usedBytes) / float64(totalBytes) * 100
}
}
}
// enrichWithRole determines the disk role (parity, parity2, data, cache, pool)
func (c *DiskCollector) enrichWithRole(disk *dto.DiskInfo) {
// Determine role based on disk name/ID
name := strings.ToLower(disk.Name)
id := strings.ToLower(disk.ID)
switch {
case strings.Contains(name, "parity2") || strings.Contains(id, "parity2"):
disk.Role = "parity2"
case strings.Contains(name, "parity") || strings.Contains(id, "parity"):
disk.Role = "parity"
case strings.Contains(name, "cache") || strings.Contains(id, "cache"):
disk.Role = "cache"
case strings.Contains(name, "pool") || strings.Contains(id, "pool"):
disk.Role = "pool"
case strings.Contains(name, "disk") || strings.Contains(id, "disk"):
disk.Role = "data"
default:
disk.Role = "unknown"
}
}
// enrichWithSpinState checks the current spin state of the disk
func (c *DiskCollector) enrichWithSpinState(disk *dto.DiskInfo) {
devicePath := "/dev/" + disk.Device
// Check if device exists
if _, err := os.Stat(devicePath); err != nil {
disk.SpinState = "unknown"
return
}
// Determine spin state from temperature reading
// In Unraid's disks.ini, temp="*" indicates disk is spun down
// If temperature is 0, this usually means the disk was spun down when parsed
// A temperature > 0 indicates the disk is active/spinning
if disk.Temperature > 0 {
// Disk has a valid temperature reading - it must be active
disk.SpinState = "active"
} else {
// Temperature is 0 or unavailable - disk is likely in standby
// This could be because:
// 1. The disk is spun down (temp="*" in disks.ini)
// 2. Temperature couldn't be read (SMART not available)
disk.SpinState = "standby"
}
logger.Debug("Disk: Device %s spin state determined as '%s' (temp=%.1f)",
disk.Device, disk.SpinState, disk.Temperature)
}
// collectDockerVDisk collects Docker vDisk usage information
func (c *DiskCollector) collectDockerVDisk() *dto.DiskInfo {
// Check if Docker mount point exists
dockerMountPoint := "/var/lib/docker"
if _, err := os.Stat(dockerMountPoint); err != nil {
logger.Debug("Docker mount point not found: %v", err)
return nil
}
// Get filesystem statistics using statfs
var stat syscall.Statfs_t
if err := syscall.Statfs(dockerMountPoint, &stat); err != nil {
logger.Debug("Failed to get Docker vDisk stats: %v", err)
return nil
}
// Calculate sizes in bytes (safe conversion - Bsize is always positive)
//nolint:gosec // G115: Bsize is always positive on Linux systems
bsize := uint64(stat.Bsize)
totalBytes := stat.Blocks * bsize
freeBytes := stat.Bfree * bsize
usedBytes := totalBytes - freeBytes
// Calculate usage percentage
var usagePercent float64
if totalBytes > 0 {
usagePercent = float64(usedBytes) / float64(totalBytes) * 100
}
// Try to find the actual vDisk file path
vdiskPath := c.findDockerVDiskPath()
// Determine filesystem type
filesystem := c.getFilesystemType(dockerMountPoint)
dockerVDisk := &dto.DiskInfo{
ID: "docker_vdisk",
Name: "Docker vDisk",
Role: "docker_vdisk",
Size: totalBytes,
Used: usedBytes,
Free: freeBytes,
UsagePercent: usagePercent,
MountPoint: dockerMountPoint,
FileSystem: filesystem,
Status: "DISK_OK",
Timestamp: time.Now(),
}
// Add vDisk path if found
if vdiskPath != "" {
dockerVDisk.Device = vdiskPath
}
return dockerVDisk
}
// findDockerVDiskPath attempts to locate the Docker vDisk file
func (c *DiskCollector) findDockerVDiskPath() string {
// Common Docker vDisk locations on Unraid
possiblePaths := []string{
"/mnt/user/system/docker/docker.vdisk",
"/mnt/cache/system/docker/docker.vdisk",
"/var/lib/docker.img",
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
// getFilesystemType determines the filesystem type for a mount point
func (c *DiskCollector) getFilesystemType(mountPoint string) string {
// Read /proc/mounts to find filesystem type
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return "unknown"
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 3 {
// fields[1] is mount point, fields[2] is filesystem type
if fields[1] == mountPoint {
return fields[2]
}
}
}
return "unknown"
}
// collectLogFilesystem collects Log filesystem usage information
func (c *DiskCollector) collectLogFilesystem() *dto.DiskInfo {
// Check if log mount point exists
logMountPoint := "/var/log"
if _, err := os.Stat(logMountPoint); err != nil {
logger.Debug("Log mount point not found: %v", err)
return nil
}
// Get filesystem statistics using statfs
var stat syscall.Statfs_t
if err := syscall.Statfs(logMountPoint, &stat); err != nil {
logger.Debug("Failed to get Log filesystem stats: %v", err)
return nil
}
// Calculate sizes in bytes (safe conversion - Bsize is always positive)
//nolint:gosec // G115: Bsize is always positive on Linux systems
bsize := uint64(stat.Bsize)
totalBytes := stat.Blocks * bsize
freeBytes := stat.Bfree * bsize
usedBytes := totalBytes - freeBytes
// Calculate usage percentage
var usagePercent float64
if totalBytes > 0 {
usagePercent = float64(usedBytes) / float64(totalBytes) * 100
}
// Determine filesystem type
filesystem := c.getFilesystemType(logMountPoint)
// Determine device name from /proc/mounts
deviceName := c.getDeviceForMountPoint(logMountPoint)
logFS := &dto.DiskInfo{
ID: "log_filesystem",
Name: "Log",
Role: "log",
Device: deviceName,
Size: totalBytes,
Used: usedBytes,
Free: freeBytes,
UsagePercent: usagePercent,
MountPoint: logMountPoint,
FileSystem: filesystem,
Status: "DISK_OK",
Timestamp: time.Now(),
}
return logFS
}
// getDeviceForMountPoint finds the device name for a given mount point
func (c *DiskCollector) getDeviceForMountPoint(mountPoint string) string {
// Read /proc/mounts to find device
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return "unknown"
}
lines := strings.Split(string(data), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) >= 2 {
// fields[0] is device, fields[1] is mount point
if fields[1] == mountPoint {
return fields[0]
}
}
}
return "unknown"
}
package collectors
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// DockerCollector collects information about Docker containers running on the Unraid system.
// It gathers container status, resource usage, network information, and configuration details.
type DockerCollector struct {
ctx *domain.Context
}
// NewDockerCollector creates a new Docker container collector with the given context.
func NewDockerCollector(ctx *domain.Context) *DockerCollector {
return &DockerCollector{ctx: ctx}
}
// Start begins the Docker collector's periodic data collection.
// It runs in a goroutine and publishes container information updates at the specified interval until the context is cancelled.
func (c *DockerCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting docker collector (interval: %v)", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Docker collector stopping due to context cancellation")
return
case <-ticker.C:
c.Collect()
}
}
}
// Collect gathers Docker container information and publishes it to the event bus.
// It uses the Docker CLI to inspect all containers and extract detailed information.
func (c *DockerCollector) Collect() {
logger.Debug("Collecting docker data...")
// Check if docker is available
if !lib.CommandExists("docker") {
logger.Warning("Docker command not found, skipping collection")
return
}
// Collect container information
containers, err := c.collectContainers()
if err != nil {
logger.Error("Failed to collect containers: %v", err)
return
}
// Publish event
c.ctx.Hub.Pub(containers, "container_list_update")
logger.Debug("Published container_list_update event with %d containers", len(containers))
}
func (c *DockerCollector) collectContainers() ([]*dto.ContainerInfo, error) {
// Get container list with JSON format
output, err := lib.ExecCommandOutput("docker", "ps", "-a", "--format", "{{json .}}")
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
if strings.TrimSpace(output) == "" {
return []*dto.ContainerInfo{}, nil
}
lines := strings.Split(strings.TrimSpace(output), "\n")
containers := make([]*dto.ContainerInfo, 0, len(lines))
for _, line := range lines {
if line == "" {
continue
}
var psOutput struct {
ID string `json:"ID"`
Image string `json:"Image"`
Names string `json:"Names"`
State string `json:"State"`
Status string `json:"Status"`
Ports string `json:"Ports"`
}
if err := json.Unmarshal([]byte(line), &psOutput); err != nil {
logger.Warning("Failed to parse container JSON", "error", err)
continue
}
container := &dto.ContainerInfo{
ID: psOutput.ID,
Name: strings.TrimPrefix(psOutput.Names, "/"),
Image: psOutput.Image,
State: strings.ToLower(psOutput.State),
Status: psOutput.Status,
Ports: c.parsePorts(psOutput.Ports),
Timestamp: time.Now(),
}
// Get enhanced container details using docker inspect
if details, err := c.getContainerDetails(container.ID); err == nil {
container.Version = details.Version
container.NetworkMode = details.NetworkMode
container.IPAddress = details.IPAddress
container.PortMappings = details.PortMappings
container.VolumeMappings = details.VolumeMappings
container.RestartPolicy = details.RestartPolicy
container.Uptime = details.Uptime
}
containers = append(containers, container)
}
// Get stats for all running containers in a single command (power optimization)
// This reduces process spawns from N (one per container) to 1
runningIDs := make([]string, 0)
containerMap := make(map[string]*dto.ContainerInfo)
for _, container := range containers {
if container.State == "running" {
runningIDs = append(runningIDs, container.ID)
containerMap[container.ID] = container
}
}
if len(runningIDs) > 0 {
allStats, err := c.getAllContainerStats(runningIDs)
if err == nil {
for id, stats := range allStats {
if container, ok := containerMap[id]; ok {
container.CPUPercent = stats.CPUPercent
container.MemoryUsage = stats.MemoryUsage
container.MemoryLimit = stats.MemoryLimit
container.NetworkRX = stats.NetworkRX
container.NetworkTX = stats.NetworkTX
container.MemoryDisplay = c.formatMemoryDisplay(stats.MemoryUsage, stats.MemoryLimit)
}
}
}
}
return containers, nil
}
type containerStats struct {
CPUPercent float64
MemoryUsage uint64
MemoryLimit uint64
NetworkRX uint64
NetworkTX uint64
}
// getAllContainerStats gets stats for all running containers in a single docker stats call
// This is much more power-efficient than calling docker stats per container
func (c *DockerCollector) getAllContainerStats(containerIDs []string) (map[string]*containerStats, error) {
// Get stats for all containers in one command
args := append([]string{"stats", "--no-stream", "--format", "{{json .}}"}, containerIDs...)
output, err := lib.ExecCommandOutput("docker", args...)
if err != nil {
return nil, err
}
result := make(map[string]*containerStats)
lines := strings.Split(strings.TrimSpace(output), "\n")
for _, line := range lines {
if line == "" {
continue
}
var statsOutput struct {
Container string `json:"Container"`
ID string `json:"ID"`
CPUPerc string `json:"CPUPerc"`
MemUsage string `json:"MemUsage"`
MemPerc string `json:"MemPerc"`
NetIO string `json:"NetIO"`
}
if err := json.Unmarshal([]byte(line), &statsOutput); err != nil {
logger.Warning("Failed to parse container stats JSON: %v", err)
continue
}
stats := &containerStats{}
// Parse CPU percentage (e.g., "0.50%")
if cpuStr := strings.TrimSuffix(statsOutput.CPUPerc, "%"); cpuStr != "" {
if cpu, err := strconv.ParseFloat(cpuStr, 64); err == nil {
stats.CPUPercent = cpu
}
}
// Parse memory usage (e.g., "1.5GiB / 8GiB")
if parts := strings.Split(statsOutput.MemUsage, " / "); len(parts) == 2 {
stats.MemoryUsage = c.parseSize(parts[0])
stats.MemoryLimit = c.parseSize(parts[1])
}
// Parse network I/O (e.g., "1.2MB / 3.4MB")
if parts := strings.Split(statsOutput.NetIO, " / "); len(parts) == 2 {
stats.NetworkRX = c.parseSize(parts[0])
stats.NetworkTX = c.parseSize(parts[1])
}
// Use container ID to match back
result[statsOutput.ID] = stats
}
return result, nil
}
func (c *DockerCollector) parseSize(sizeStr string) uint64 {
sizeStr = strings.TrimSpace(sizeStr)
if sizeStr == "" || sizeStr == "0B" {
return 0
}
// Extract number and unit
var value float64
var unit string
// Try to parse with unit
if n, err := fmt.Sscanf(sizeStr, "%f%s", &value, &unit); n >= 1 && err == nil {
unit = strings.ToUpper(unit)
multiplier := uint64(1)
switch {
case strings.HasPrefix(unit, "K"):
multiplier = 1024
case strings.HasPrefix(unit, "M"):
multiplier = 1024 * 1024
case strings.HasPrefix(unit, "G"):
multiplier = 1024 * 1024 * 1024
case strings.HasPrefix(unit, "T"):
multiplier = 1024 * 1024 * 1024 * 1024
}
return uint64(value * float64(multiplier))
}
return 0
}
func (c *DockerCollector) parsePorts(portsStr string) []dto.PortMapping {
if portsStr == "" {
return []dto.PortMapping{}
}
ports := []dto.PortMapping{}
parts := strings.Split(portsStr, ", ")
for _, part := range parts {
// Format examples:
// "0.0.0.0:8080->80/tcp"
// "80/tcp"
if strings.Contains(part, "->") {
// Has port mapping
mappingParts := strings.Split(part, "->")
if len(mappingParts) == 2 {
// Extract public port
publicPart := mappingParts[0]
if colonIdx := strings.LastIndex(publicPart, ":"); colonIdx >= 0 {
publicPort, _ := strconv.Atoi(publicPart[colonIdx+1:])
// Extract private port and type
privatePart := mappingParts[1]
if slashIdx := strings.Index(privatePart, "/"); slashIdx >= 0 {
privatePort, _ := strconv.Atoi(privatePart[:slashIdx])
portType := privatePart[slashIdx+1:]
ports = append(ports, dto.PortMapping{
PrivatePort: privatePort,
PublicPort: publicPort,
Type: portType,
})
}
}
}
} else if strings.Contains(part, "/") {
// Just exposed port, no mapping
if slashIdx := strings.Index(part, "/"); slashIdx >= 0 {
port, _ := strconv.Atoi(part[:slashIdx])
portType := part[slashIdx+1:]
ports = append(ports, dto.PortMapping{
PrivatePort: port,
PublicPort: 0,
Type: portType,
})
}
}
}
return ports
}
type containerDetails struct {
Version string
NetworkMode string
IPAddress string
PortMappings []string
VolumeMappings []dto.VolumeMapping
RestartPolicy string
Uptime string
}
// getContainerDetails retrieves detailed container information using docker inspect
func (c *DockerCollector) getContainerDetails(containerID string) (*containerDetails, error) {
output, err := lib.ExecCommandOutput("docker", "inspect", containerID)
if err != nil {
return nil, err
}
var inspectOutput []struct {
Config struct {
Image string `json:"Image"`
} `json:"Config"`
NetworkSettings struct {
Networks map[string]struct {
IPAddress string `json:"IPAddress"`
} `json:"Networks"`
} `json:"NetworkSettings"`
HostConfig struct {
NetworkMode string `json:"NetworkMode"`
RestartPolicy struct {
Name string `json:"Name"`
} `json:"RestartPolicy"`
PortBindings map[string][]struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
} `json:"PortBindings"`
Binds []string `json:"Binds"`
} `json:"HostConfig"`
State struct {
StartedAt string `json:"StartedAt"`
} `json:"State"`
}
if err := json.Unmarshal([]byte(output), &inspectOutput); err != nil {
return nil, err
}
if len(inspectOutput) == 0 {
return nil, fmt.Errorf("no inspect data returned")
}
inspect := inspectOutput[0]
details := &containerDetails{}
// Extract version from image tag
imageParts := strings.Split(inspect.Config.Image, ":")
if len(imageParts) > 1 {
details.Version = imageParts[1]
} else {
details.Version = "latest"
}
// Network mode
details.NetworkMode = inspect.HostConfig.NetworkMode
// IP Address (get first available)
for _, network := range inspect.NetworkSettings.Networks {
if network.IPAddress != "" {
details.IPAddress = network.IPAddress
break
}
}
// Port mappings
portMappings := []string{}
for containerPort, bindings := range inspect.HostConfig.PortBindings {
for _, binding := range bindings {
if binding.HostPort != "" {
portMappings = append(portMappings, fmt.Sprintf("%s:%s", binding.HostPort, containerPort))
}
}
}
details.PortMappings = portMappings
// Volume mappings
volumeMappings := []dto.VolumeMapping{}
for _, bind := range inspect.HostConfig.Binds {
parts := strings.Split(bind, ":")
if len(parts) >= 2 {
mode := "rw"
if len(parts) >= 3 {
mode = parts[2]
}
volumeMappings = append(volumeMappings, dto.VolumeMapping{
HostPath: parts[0],
ContainerPath: parts[1],
Mode: mode,
})
}
}
details.VolumeMappings = volumeMappings
// Restart policy
details.RestartPolicy = inspect.HostConfig.RestartPolicy.Name
if details.RestartPolicy == "" {
details.RestartPolicy = "no"
}
// Calculate uptime
if inspect.State.StartedAt != "" {
startTime, err := time.Parse(time.RFC3339Nano, inspect.State.StartedAt)
if err == nil {
uptime := time.Since(startTime)
details.Uptime = c.formatUptime(uptime)
}
}
return details, nil
}
// formatUptime formats a duration into a human-readable uptime string
func (c *DockerCollector) formatUptime(d time.Duration) string {
days := int(d.Hours() / 24)
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}
// formatMemoryDisplay formats memory usage as "used / limit"
func (c *DockerCollector) formatMemoryDisplay(used, limit uint64) string {
if limit == 0 {
return "0 / 0"
}
usedMB := float64(used) / (1024 * 1024)
limitMB := float64(limit) / (1024 * 1024)
if limitMB >= 1024 {
usedGB := usedMB / 1024
limitGB := limitMB / 1024
return fmt.Sprintf("%.2f GB / %.2f GB", usedGB, limitGB)
}
return fmt.Sprintf("%.2f MB / %.2f MB", usedMB, limitMB)
}
package collectors
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// GPUCollector collects GPU metrics from NVIDIA, AMD, and Intel GPUs.
// It gathers temperature, utilization, memory usage, and power consumption data.
type GPUCollector struct {
ctx *domain.Context
}
// NewGPUCollector creates a new GPU metrics collector with the given context.
func NewGPUCollector(ctx *domain.Context) *GPUCollector {
return &GPUCollector{ctx: ctx}
}
// Start begins the GPU collector's periodic data collection.
// It runs in a goroutine and publishes GPU metrics updates at the specified interval until the context is cancelled.
func (c *GPUCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting gpu collector (interval: %v)", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("GPU collector stopping due to context cancellation")
return
case <-ticker.C:
c.Collect()
}
}
}
// Collect gathers GPU metrics from all available GPUs and publishes them to the event bus.
// It detects and collects data from NVIDIA, AMD, and Intel GPUs using vendor-specific tools.
func (c *GPUCollector) Collect() {
logger.Debug("Collecting gpu data...")
// Collect GPU metrics from all available GPU types
gpuMetrics := make([]*dto.GPUMetrics, 0)
// Try Intel iGPU
logger.Debug("Attempting Intel GPU collection...")
intelGPUs, err := c.collectIntelGPU()
logger.Debug("Intel GPU collection returned: gpus=%d, err=%v", len(intelGPUs), err)
if err == nil && len(intelGPUs) > 0 {
gpuMetrics = append(gpuMetrics, intelGPUs...)
logger.Debug("Collected %d Intel GPU(s)", len(intelGPUs))
} else if err != nil {
logger.Debug("Intel GPU collection failed: %v", err)
}
// Try NVIDIA GPU
if lib.CommandExists("nvidia-smi") {
if nvidiaGPUs, err := c.collectNvidiaGPU(); err == nil && len(nvidiaGPUs) > 0 {
gpuMetrics = append(gpuMetrics, nvidiaGPUs...)
logger.Debug("Collected %d NVIDIA GPU(s)", len(nvidiaGPUs))
}
}
// Try AMD GPU (radeontop or rocm-smi)
if lib.CommandExists("radeontop") || lib.CommandExists("rocm-smi") {
if amdGPUs, err := c.collectAMDGPU(); err == nil && len(amdGPUs) > 0 {
gpuMetrics = append(gpuMetrics, amdGPUs...)
logger.Debug("Collected %d AMD GPU(s)", len(amdGPUs))
}
}
if len(gpuMetrics) == 0 {
logger.Debug("No GPUs detected or no monitoring tools available")
return
}
// Publish event
c.ctx.Hub.Pub(gpuMetrics, "gpu_metrics_update")
logger.Debug("Published gpu_metrics_update event for %d total GPU(s)", len(gpuMetrics))
}
// Intel GPU collection using intel_gpu_top
func (c *GPUCollector) collectIntelGPU() ([]*dto.GPUMetrics, error) {
logger.Debug("Intel GPU: Starting Intel GPU detection")
// First check if Intel GPU exists using lspci
output, err := lib.ExecCommandOutput("lspci", "-Dmm")
if err != nil {
logger.Debug("Intel GPU: lspci query failed: %v", err)
return nil, fmt.Errorf("lspci query failed: %w", err)
}
logger.Debug("Intel GPU: Got lspci output, searching for Intel VGA")
// Collect ALL Intel GPUs (not just the first one)
type intelGPUInfo struct {
PCIID string
Model string
}
intelGPUs := make([]intelGPUInfo, 0)
for _, line := range strings.Split(output, "\n") {
if (strings.Contains(line, "VGA") || strings.Contains(line, "Display")) && strings.Contains(line, "Intel Corporation") {
logger.Debug("Intel GPU: Found Intel GPU line: %s", line)
// Parse PCI ID and model name using a more robust approach
// Format: "0000:00:02.0" "VGA compatible controller" "Intel Corporation" "CoffeeLake-S GT2 [UHD Graphics 630]" -p00 "ASRock Incorporation" "Device 3e92"
var pciID, model string
// Extract PCI ID (everything before first quote)
firstQuote := strings.Index(line, "\"")
if firstQuote > 0 {
pciID = strings.TrimSpace(line[:firstQuote])
}
// Extract all quoted strings using regex
// Format: "VGA compatible controller" "Intel Corporation" "CoffeeLake-S GT2 [UHD Graphics 630]" -p00 "ASRock Incorporation" "Device 3e92"
// Indices: [0]=class, [1]=vendor, [2]=device_name, [3]=subsys_vendor, [4]=subsys_device
re := regexp.MustCompile(`"([^"]*)"`)
matches := re.FindAllStringSubmatch(line, -1)
// The 3rd quoted string (index 2) is the device name
if len(matches) >= 3 {
fullModel := matches[2][1] // matches[2][0] is the full match with quotes, [1] is the captured group
logger.Debug("Intel GPU: Full model string: %s", fullModel)
// Extract just the marketing name from brackets if present
if strings.Contains(fullModel, "[") {
start := strings.Index(fullModel, "[")
end := strings.Index(fullModel, "]")
if start != -1 && end != -1 && end > start {
model = strings.TrimSpace(fullModel[start+1 : end])
}
} else {
// No brackets, use the full model name
model = fullModel
}
logger.Debug("Intel GPU: Parsed - ID: %s, Model: %s", pciID, model)
} else {
logger.Debug("Intel GPU: Failed to parse model name from line (found %d quoted strings, need at least 3)", len(matches))
}
if pciID != "" && model != "" {
intelGPUs = append(intelGPUs, intelGPUInfo{PCIID: pciID, Model: model})
}
// REMOVED: break statement - continue searching for more Intel GPUs
}
}
if len(intelGPUs) == 0 {
logger.Debug("Intel GPU: No Intel GPU found in lspci output")
return nil, fmt.Errorf("no Intel GPU found")
}
logger.Debug("Intel GPU: Found %d Intel GPU(s)", len(intelGPUs))
// Check if intel_gpu_top is available
if !lib.CommandExists("intel_gpu_top") {
logger.Debug("Intel GPU: intel_gpu_top command not found")
return nil, fmt.Errorf("intel_gpu_top not found")
}
logger.Debug("Intel GPU: intel_gpu_top found, collecting metrics for each GPU")
// Collect metrics for each Intel GPU
gpuMetrics := make([]*dto.GPUMetrics, 0, len(intelGPUs))
for idx, intelGPU := range intelGPUs {
gpu := c.collectSingleIntelGPU(intelGPU.PCIID, intelGPU.Model, idx)
if gpu != nil {
gpuMetrics = append(gpuMetrics, gpu)
}
}
if len(gpuMetrics) == 0 {
return nil, fmt.Errorf("failed to collect metrics for any Intel GPU")
}
return gpuMetrics, nil
}
// collectSingleIntelGPU collects metrics for a single Intel GPU
func (c *GPUCollector) collectSingleIntelGPU(pciID, model string, index int) *dto.GPUMetrics {
logger.Debug("Intel GPU: Collecting metrics for GPU %d (%s)", index, pciID)
// Run intel_gpu_top in JSON mode with 1 sample for power efficiency
// Reduced timeout from 5s to 2s and samples from 2 to 1 to minimize CPU usage (Issue #8)
// Note: intel_gpu_top auto-detects Intel GPU, doesn't support -d flag for specific device
cmdOutput, err := lib.ExecCommandOutput("timeout", "2", "intel_gpu_top", "-J", "-s", "500", "-n", "1")
switch {
case err != nil && len(cmdOutput) == 0:
logger.Debug("Intel GPU: intel_gpu_top query failed with no output: %v", err)
return nil
case err != nil:
logger.Debug("Intel GPU: intel_gpu_top timed out (expected), got %d bytes output", len(cmdOutput))
default:
logger.Debug("Intel GPU: Got output from intel_gpu_top (%d bytes)", len(cmdOutput))
}
// Parse JSON output - intel_gpu_top returns malformed JSON array with -n 2
stdout := strings.TrimSpace(cmdOutput)
stdout = strings.ReplaceAll(stdout, "\n", "")
stdout = strings.ReplaceAll(stdout, "\t", "")
// Find the first { and match its closing }
startIdx := strings.Index(stdout, "{")
if startIdx == -1 {
logger.Debug("Intel GPU: No JSON object found in output")
return nil
}
// Simple brace matching to find the complete first JSON object
braceCount := 0
endIdx := -1
for i := startIdx; i < len(stdout); i++ {
if stdout[i] == '{' {
braceCount++
} else if stdout[i] == '}' {
braceCount--
if braceCount == 0 {
endIdx = i + 1
break
}
}
}
if endIdx == -1 {
logger.Debug("Intel GPU: Incomplete JSON object in output")
return nil
}
sampleJSON := stdout[startIdx:endIdx]
logger.Debug("Intel GPU: Extracted JSON object of %d chars", len(sampleJSON))
// Parse the sample
var intelData map[string]interface{}
if err := json.Unmarshal([]byte(sampleJSON), &intelData); err != nil {
logger.Debug("Intel GPU: Failed to parse sample: %v", err)
return nil
}
logger.Debug("Intel GPU: Successfully parsed sample for GPU %d", index)
gpu := &dto.GPUMetrics{
Available: true,
Index: index,
PCIID: pciID,
Vendor: "intel",
Name: "Intel " + model,
Timestamp: time.Now(),
}
// Extract driver version from modinfo
if driverVersion, err := c.getIntelDriverVersion(); err == nil {
gpu.DriverVersion = driverVersion
}
// Extract utilization from engines
if engines, ok := intelData["engines"].(map[string]interface{}); ok {
// Sum up all engine utilizations for overall GPU usage
totalUtil := 0.0
engineCount := 0
for engineName, engineData := range engines {
if engineMap, ok := engineData.(map[string]interface{}); ok {
if busy, ok := engineMap["busy"].(float64); ok {
totalUtil += busy
engineCount++
logger.Debug("Intel GPU engine %s: %.2f%%", engineName, busy)
}
}
}
if engineCount > 0 {
gpu.UtilizationGPU = totalUtil / float64(engineCount)
}
}
// Extract power consumption (GPU power, not package power)
if power, ok := intelData["power"].(map[string]interface{}); ok {
if gpuPower, ok := power["GPU"].(float64); ok {
gpu.PowerDraw = gpuPower
logger.Debug("Intel GPU power: %.3f W", gpuPower)
}
}
// Extract memory info (Note: Intel iGPU shares system RAM, intel_gpu_top doesn't report memory usage)
// The "memory" field is not present in intel_gpu_top JSON output for integrated GPUs
if memory, ok := intelData["memory"].(map[string]interface{}); ok {
if total, ok := memory["total"].(float64); ok {
gpu.MemoryTotal = uint64(total)
}
if shared, ok := memory["shared"].(float64); ok {
gpu.MemoryUsed = uint64(shared)
}
if gpu.MemoryTotal > 0 {
gpu.UtilizationMemory = float64(gpu.MemoryUsed) / float64(gpu.MemoryTotal) * 100
}
}
// Intel iGPU typically doesn't report temperature via intel_gpu_top or sysfs hwmon
// Most Intel integrated GPUs don't expose temperature sensors
if temp, err := c.getIntelGPUTemp(); err == nil {
gpu.Temperature = temp
}
// For Intel iGPUs, add CPU temperature as they share the die with the CPU
// This provides useful thermal information since iGPUs don't have dedicated temp sensors
if cpuTemp, err := c.getCPUTemp(); err == nil {
gpu.CPUTemperature = cpuTemp
logger.Debug("Intel GPU: CPU temperature: %.1f°C", cpuTemp)
}
return gpu
}
// Get Intel GPU temperature from sysfs
func (c *GPUCollector) getIntelGPUTemp() (float64, error) {
// Intel iGPU temp is usually in hwmon under i915
output, err := lib.ExecCommandOutput("bash", "-c", "cat /sys/class/drm/card*/device/hwmon/hwmon*/temp1_input 2>/dev/null | head -1")
if err != nil || output == "" {
return 0, fmt.Errorf("failed to read Intel GPU temperature")
}
tempMilliC, err := strconv.ParseFloat(strings.TrimSpace(output), 64)
if err != nil {
return 0, err
}
// Convert from millidegrees to degrees
return tempMilliC / 1000.0, nil
}
// Get CPU temperature from coretemp hwmon
// This is useful for Intel iGPUs since they share the die with the CPU
func (c *GPUCollector) getCPUTemp() (float64, error) {
// Try to find coretemp hwmon device
// Look for hwmon device with name "coretemp"
output, err := lib.ExecCommandOutput("bash", "-c", "for d in /sys/class/hwmon/hwmon*; do if [ -f $d/name ] && grep -q coretemp $d/name 2>/dev/null; then cat $d/temp1_input 2>/dev/null && exit 0; fi; done")
if err != nil || output == "" {
return 0, fmt.Errorf("failed to read CPU temperature from coretemp")
}
tempMilliC, err := strconv.ParseFloat(strings.TrimSpace(output), 64)
if err != nil {
return 0, err
}
// Convert from millidegrees to degrees
// temp1 is typically the package temperature (overall CPU temp)
return tempMilliC / 1000.0, nil
}
// Get Intel GPU driver version from modinfo
func (c *GPUCollector) getIntelDriverVersion() (string, error) {
// Get vermagic from modinfo i915 (contains kernel version)
output, err := lib.ExecCommandOutput("modinfo", "i915")
if err != nil {
return "", fmt.Errorf("modinfo i915 failed: %w", err)
}
// Parse vermagic line: "vermagic: 6.12.24-Unraid SMP preempt mod_unload"
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "vermagic:") {
// Extract kernel version from vermagic
parts := strings.Fields(line)
if len(parts) >= 2 {
// Return kernel version (e.g., "6.12.24-Unraid")
return parts[1], nil
}
}
}
return "", fmt.Errorf("failed to parse driver version from modinfo")
}
// NVIDIA GPU collection using nvidia-smi
func (c *GPUCollector) collectNvidiaGPU() ([]*dto.GPUMetrics, error) {
// Query nvidia-smi with CSV output for easy parsing
// Added: pci.bus_id, uuid, fan.speed
output, err := lib.ExecCommandOutput(
"nvidia-smi",
"--query-gpu=index,pci.bus_id,uuid,name,temperature.gpu,utilization.gpu,memory.used,memory.total,power.draw,fan.speed",
"--format=csv,noheader,nounits",
)
if err != nil {
return nil, fmt.Errorf("nvidia-smi query failed: %w", err)
}
// Parse CSV output
reader := csv.NewReader(strings.NewReader(output))
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("failed to parse CSV output: %w", err)
}
gpus := make([]*dto.GPUMetrics, 0, len(records))
for _, record := range records {
if len(record) < 10 {
continue
}
gpu := &dto.GPUMetrics{
Available: true,
Vendor: "nvidia",
Timestamp: time.Now(),
}
// Index
if idx, err := strconv.Atoi(strings.TrimSpace(record[0])); err == nil {
gpu.Index = idx
}
// PCI Bus ID
gpu.PCIID = strings.TrimSpace(record[1])
// UUID
gpu.UUID = strings.TrimSpace(record[2])
// Name
gpu.Name = strings.TrimSpace(record[3])
// Temperature (°C)
if temp, err := strconv.ParseFloat(strings.TrimSpace(record[4]), 64); err == nil {
gpu.Temperature = temp
}
// Utilization (%)
if util, err := strconv.ParseFloat(strings.TrimSpace(record[5]), 64); err == nil {
gpu.UtilizationGPU = util
}
// Memory Used (MiB)
if memUsed, err := strconv.ParseFloat(strings.TrimSpace(record[6]), 64); err == nil {
gpu.MemoryUsed = uint64(memUsed * 1024 * 1024) // Convert MiB to bytes
}
// Memory Total (MiB)
if memTotal, err := strconv.ParseFloat(strings.TrimSpace(record[7]), 64); err == nil {
gpu.MemoryTotal = uint64(memTotal * 1024 * 1024) // Convert MiB to bytes
if gpu.MemoryTotal > 0 {
gpu.UtilizationMemory = float64(gpu.MemoryUsed) / float64(gpu.MemoryTotal) * 100
}
}
// Power Draw (W)
if power, err := strconv.ParseFloat(strings.TrimSpace(record[8]), 64); err == nil {
gpu.PowerDraw = power
}
// Fan Speed (%)
if fanSpeed, err := strconv.ParseFloat(strings.TrimSpace(record[9]), 64); err == nil {
gpu.FanSpeed = fanSpeed
}
// Get driver version (same for all GPUs, only query once)
if len(gpus) == 0 {
if driverVersion, err := c.getNvidiaDriverVersion(); err == nil {
gpu.DriverVersion = driverVersion
}
} else {
gpu.DriverVersion = gpus[0].DriverVersion
}
gpus = append(gpus, gpu)
}
return gpus, nil
}
// getNvidiaDriverVersion gets NVIDIA driver version
func (c *GPUCollector) getNvidiaDriverVersion() (string, error) {
output, err := lib.ExecCommandOutput("nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader")
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
// AMD GPU collection using radeontop (broader AMD GPU compatibility)
func (c *GPUCollector) collectAMDGPU() ([]*dto.GPUMetrics, error) {
// Try radeontop first (supports consumer Radeon GPUs)
if lib.CommandExists("radeontop") {
return c.collectAMDGPUWithRadeontop()
}
// Fallback to rocm-smi for datacenter GPUs
if lib.CommandExists("rocm-smi") {
return c.collectAMDGPUWithROCm()
}
return nil, fmt.Errorf("neither radeontop nor rocm-smi found")
}
// collectAMDGPUWithRadeontop uses radeontop for consumer AMD GPUs
func (c *GPUCollector) collectAMDGPUWithRadeontop() ([]*dto.GPUMetrics, error) {
// First, detect AMD GPUs using lspci
output, err := lib.ExecCommandOutput("lspci", "-Dmm")
if err != nil {
return nil, fmt.Errorf("lspci query failed: %w", err)
}
type amdGPUInfo struct {
PCIID string
Model string
}
amdGPUs := make([]amdGPUInfo, 0)
for _, line := range strings.Split(output, "\n") {
if (strings.Contains(line, "VGA") || strings.Contains(line, "Display")) &&
(strings.Contains(line, "AMD") || strings.Contains(line, "Advanced Micro Devices") || strings.Contains(line, "ATI")) {
var pciID, model string
// Extract PCI ID
firstQuote := strings.Index(line, "\"")
if firstQuote > 0 {
pciID = strings.TrimSpace(line[:firstQuote])
}
// Extract model name
re := regexp.MustCompile(`"([^"]*)"`)
matches := re.FindAllStringSubmatch(line, -1)
if len(matches) >= 3 {
fullModel := matches[2][1]
// Extract marketing name from brackets if present
if strings.Contains(fullModel, "[") {
start := strings.Index(fullModel, "[")
end := strings.Index(fullModel, "]")
if start != -1 && end != -1 && end > start {
model = strings.TrimSpace(fullModel[start+1 : end])
}
} else {
model = fullModel
}
}
if pciID != "" && model != "" {
amdGPUs = append(amdGPUs, amdGPUInfo{PCIID: pciID, Model: model})
}
}
}
if len(amdGPUs) == 0 {
return nil, fmt.Errorf("no AMD GPU found")
}
logger.Debug("AMD GPU: Found %d AMD GPU(s)", len(amdGPUs))
// Collect metrics for each AMD GPU
gpuMetrics := make([]*dto.GPUMetrics, 0, len(amdGPUs))
for idx, amdGPU := range amdGPUs {
gpu := c.collectSingleAMDGPU(amdGPU.PCIID, amdGPU.Model, idx)
if gpu != nil {
gpuMetrics = append(gpuMetrics, gpu)
}
}
return gpuMetrics, nil
}
// collectSingleAMDGPU collects metrics for a single AMD GPU using radeontop
func (c *GPUCollector) collectSingleAMDGPU(pciID, model string, index int) *dto.GPUMetrics {
logger.Debug("AMD GPU: Collecting metrics for GPU %d (%s)", index, pciID)
// Run radeontop with dump mode: radeontop -d - -l 1
// Output format: bus 0000:01:00.0, gpu 45.00%, ee 0.00%, vgt 0.00%, ta 0.00%, tc 0.00%, sx 0.00%, sh 0.00%, spi 0.00%, sc 0.00%, pa 0.00%, db 0.00%, cb 0.00%, vram 15.00% 1234mb, gtt 5.00% 123mb, mclk 100.00% 1.750ghz, sclk 50.00% 1.200ghz
cmdOutput, err := lib.ExecCommandOutput("timeout", "3", "radeontop", "-d", "-", "-l", "1")
if err != nil && len(cmdOutput) == 0 {
logger.Debug("AMD GPU: radeontop query failed: %v", err)
return nil
}
gpu := &dto.GPUMetrics{
Available: true,
Index: index,
PCIID: pciID,
Vendor: "amd",
Name: "AMD " + model,
Timestamp: time.Now(),
}
// Parse radeontop output
// Example: bus 0000:03:00.0, gpu 12.34%, vram 25.50% 2048mb, sclk 50.00% 1.200ghz
output := strings.TrimSpace(cmdOutput)
if output != "" {
// Extract GPU utilization
if matches := regexp.MustCompile(`gpu\s+([\d.]+)%`).FindStringSubmatch(output); len(matches) > 1 {
if util, err := strconv.ParseFloat(matches[1], 64); err == nil {
gpu.UtilizationGPU = util
}
}
// Extract VRAM usage: "vram 15.00% 1234mb"
if matches := regexp.MustCompile(`vram\s+([\d.]+)%\s+([\d]+)mb`).FindStringSubmatch(output); len(matches) > 2 {
if vramPercent, err := strconv.ParseFloat(matches[1], 64); err == nil {
gpu.UtilizationMemory = vramPercent
}
if vramUsedMB, err := strconv.ParseUint(matches[2], 10, 64); err == nil {
gpu.MemoryUsed = vramUsedMB * 1024 * 1024 // Convert MB to bytes
// Calculate total from percentage
if gpu.UtilizationMemory > 0 {
gpu.MemoryTotal = uint64(float64(gpu.MemoryUsed) / (gpu.UtilizationMemory / 100.0))
}
}
}
}
// Get temperature from sysfs
if temp, err := c.getAMDGPUTemp(index); err == nil {
gpu.Temperature = temp
}
// Get fan speed from sysfs (discrete GPUs only)
if fanRPM, fanMaxRPM, err := c.getAMDGPUFanSpeed(index); err == nil {
gpu.FanRPM = fanRPM
gpu.FanMaxRPM = fanMaxRPM
}
// Get driver version
if driverVersion, err := c.getAMDDriverVersion(); err == nil {
gpu.DriverVersion = driverVersion
}
return gpu
}
// getAMDGPUTemp gets AMD GPU temperature from sysfs
func (c *GPUCollector) getAMDGPUTemp(cardIndex int) (float64, error) {
// AMD GPU temp is in hwmon
output, err := lib.ExecCommandOutput("bash", "-c", fmt.Sprintf("cat /sys/class/drm/card%d/device/hwmon/hwmon*/temp1_input 2>/dev/null | head -1", cardIndex))
if err != nil || output == "" {
return 0, fmt.Errorf("failed to read AMD GPU temperature")
}
tempMilliC, err := strconv.ParseFloat(strings.TrimSpace(output), 64)
if err != nil {
return 0, err
}
return tempMilliC / 1000.0, nil
}
// getAMDGPUFanSpeed gets AMD GPU fan speed from sysfs (discrete GPUs only)
func (c *GPUCollector) getAMDGPUFanSpeed(cardIndex int) (int, int, error) {
// Read current fan RPM
rpmOutput, err := lib.ExecCommandOutput("bash", "-c", fmt.Sprintf("cat /sys/class/drm/card%d/device/hwmon/hwmon*/fan1_input 2>/dev/null | head -1", cardIndex))
if err != nil || rpmOutput == "" {
return 0, 0, fmt.Errorf("fan speed not available (integrated GPU or no fan sensor)")
}
rpm, err := strconv.Atoi(strings.TrimSpace(rpmOutput))
if err != nil {
return 0, 0, err
}
// Read max fan RPM
maxRPMOutput, err := lib.ExecCommandOutput("bash", "-c", fmt.Sprintf("cat /sys/class/drm/card%d/device/hwmon/hwmon*/fan1_max 2>/dev/null | head -1", cardIndex))
maxRPM := 0
if err == nil && maxRPMOutput != "" {
if val, err := strconv.Atoi(strings.TrimSpace(maxRPMOutput)); err == nil {
maxRPM = val
}
}
return rpm, maxRPM, nil
}
// getAMDDriverVersion gets AMD driver version
func (c *GPUCollector) getAMDDriverVersion() (string, error) {
output, err := lib.ExecCommandOutput("modinfo", "amdgpu")
if err != nil {
return "", err
}
// Parse modinfo output for version
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "version:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1]), nil
}
}
}
return "", fmt.Errorf("failed to parse driver version")
}
// collectAMDGPUWithROCm uses rocm-smi for datacenter AMD GPUs (fallback)
func (c *GPUCollector) collectAMDGPUWithROCm() ([]*dto.GPUMetrics, error) {
// Query rocm-smi with JSON output
output, err := lib.ExecCommandOutput("rocm-smi", "--showid", "--showtemp", "--showuse", "--showmeminfo", "vram", "--json")
if err != nil {
return nil, fmt.Errorf("rocm-smi query failed: %w", err)
}
var rocmData map[string]interface{}
if err := json.Unmarshal([]byte(output), &rocmData); err != nil {
return nil, fmt.Errorf("failed to parse rocm-smi JSON: %w", err)
}
gpus := make([]*dto.GPUMetrics, 0)
index := 0
// Parse each GPU
for gpuID, gpuDataInterface := range rocmData {
if !strings.HasPrefix(gpuID, "card") {
continue
}
gpuData, ok := gpuDataInterface.(map[string]interface{})
if !ok {
continue
}
gpu := &dto.GPUMetrics{
Available: true,
Index: index,
Vendor: "amd",
Timestamp: time.Now(),
}
// Get GPU name/model
if cardSeries, ok := gpuData["Card series"].(string); ok {
gpu.Name = "AMD " + cardSeries
}
// Get temperature
if temp, ok := gpuData["Temperature (Sensor edge) (C)"].(float64); ok {
gpu.Temperature = temp
}
// Get GPU utilization
if util, ok := gpuData["GPU use (%)"].(float64); ok {
gpu.UtilizationGPU = util
}
// Get memory info
if memUsed, ok := gpuData["VRAM Total Used Memory (B)"].(float64); ok {
gpu.MemoryUsed = uint64(memUsed)
}
if memTotal, ok := gpuData["VRAM Total Memory (B)"].(float64); ok {
gpu.MemoryTotal = uint64(memTotal)
if gpu.MemoryTotal > 0 {
gpu.UtilizationMemory = float64(gpu.MemoryUsed) / float64(gpu.MemoryTotal) * 100
}
}
// Get driver version
if index == 0 {
if driverVersion, err := c.getAMDDriverVersion(); err == nil {
gpu.DriverVersion = driverVersion
}
}
gpus = append(gpus, gpu)
index++
}
return gpus, nil
}
package collectors
import (
"context"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// HardwareCollector collects detailed hardware information using dmidecode.
// It gathers BIOS, baseboard, CPU, cache, and memory information from the system's DMI tables.
type HardwareCollector struct {
ctx *domain.Context
}
// NewHardwareCollector creates a new hardware information collector with the given context.
func NewHardwareCollector(ctx *domain.Context) *HardwareCollector {
return &HardwareCollector{ctx: ctx}
}
// Start begins the hardware collector's periodic data collection.
// It runs in a goroutine and publishes hardware information updates at the specified interval until the context is cancelled.
func (c *HardwareCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting hardware collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Hardware collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Hardware collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Hardware collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers hardware information from DMI tables and publishes it to the event bus.
// It uses dmidecode to extract BIOS, baseboard, CPU, cache, and memory device information.
func (c *HardwareCollector) Collect() {
logger.Debug("Collecting hardware data...")
// Collect hardware information
hardwareInfo, err := c.collectHardwareInfo()
if err != nil {
logger.Error("Hardware: Failed to collect hardware data: %v", err)
return
}
logger.Debug("Hardware: Successfully collected hardware info, publishing event")
// Publish event
c.ctx.Hub.Pub(hardwareInfo, "hardware_update")
logger.Debug("Hardware: Published hardware_update event")
}
func (c *HardwareCollector) collectHardwareInfo() (*dto.HardwareInfo, error) {
info := &dto.HardwareInfo{
Timestamp: time.Now(),
}
// Check if dmidecode is available
if !lib.CommandExists("dmidecode") {
logger.Warning("dmidecode command not found, skipping hardware collection")
return info, nil
}
// Collect BIOS information
if bios, err := lib.ParseBIOSInfo(); err == nil {
info.BIOS = bios
logger.Debug("Hardware: Collected BIOS info - Vendor: %s, Version: %s", bios.Vendor, bios.Version)
} else {
logger.Debug("Hardware: Failed to collect BIOS info: %v", err)
}
// Collect baseboard information
if baseboard, err := lib.ParseBaseboardInfo(); err == nil {
info.Baseboard = baseboard
logger.Debug("Hardware: Collected baseboard info - Manufacturer: %s, Product: %s", baseboard.Manufacturer, baseboard.ProductName)
} else {
logger.Debug("Hardware: Failed to collect baseboard info: %v", err)
}
// Collect CPU hardware information
if cpu, err := lib.ParseCPUInfo(); err == nil {
info.CPU = cpu
logger.Debug("Hardware: Collected CPU hardware info - Socket: %s, Manufacturer: %s", cpu.SocketDesignation, cpu.Manufacturer)
} else {
logger.Debug("Hardware: Failed to collect CPU hardware info: %v", err)
}
// Collect CPU cache information
if caches, err := lib.ParseCPUCacheInfo(); err == nil {
info.Cache = caches
logger.Debug("Hardware: Collected %d CPU cache levels", len(caches))
} else {
logger.Debug("Hardware: Failed to collect CPU cache info: %v", err)
}
// Collect memory array information
if memArray, err := lib.ParseMemoryArrayInfo(); err == nil {
info.MemoryArray = memArray
logger.Debug("Hardware: Collected memory array info - Max Capacity: %s, Devices: %d", memArray.MaximumCapacity, memArray.NumberOfDevices)
} else {
logger.Debug("Hardware: Failed to collect memory array info: %v", err)
}
// Collect memory device information
if memDevices, err := lib.ParseMemoryDevices(); err == nil {
info.MemoryDevices = memDevices
logger.Debug("Hardware: Collected %d memory devices", len(memDevices))
} else {
logger.Debug("Hardware: Failed to collect memory devices: %v", err)
}
return info, nil
}
package collectors
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// NetworkCollector collects network interface information including status, speed, and statistics.
// It gathers data from network interfaces, bonds, bridges, and VLANs.
type NetworkCollector struct {
ctx *domain.Context
}
// NewNetworkCollector creates a new network interface collector with the given context.
func NewNetworkCollector(ctx *domain.Context) *NetworkCollector {
return &NetworkCollector{ctx: ctx}
}
// Start begins the network collector's periodic data collection.
// It runs in a goroutine and publishes network interface updates at the specified interval until the context is cancelled.
func (c *NetworkCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting network collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Network collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Network collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Network collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers network interface information and publishes it to the event bus.
// It collects data from /sys/class/net and uses ethtool for detailed interface information.
func (c *NetworkCollector) Collect() {
logger.Debug("Collecting network data...")
// Collect network interfaces
interfaces, err := c.collectNetworkInterfaces()
if err != nil {
logger.Error("Network: Failed to collect interface data: %v", err)
return
}
logger.Debug("Network: Successfully collected %d interfaces, publishing event", len(interfaces))
// Publish event
c.ctx.Hub.Pub(interfaces, "network_list_update")
logger.Debug("Network: Published network_list_update event with %d interfaces", len(interfaces))
}
func (c *NetworkCollector) collectNetworkInterfaces() ([]dto.NetworkInfo, error) {
logger.Debug("Network: Starting collection from /proc/net/dev and /sys/class/net")
var interfaces []dto.NetworkInfo
// Parse /proc/net/dev for bandwidth stats
stats, err := c.parseNetDev()
if err != nil {
logger.Error("Network: Failed to parse /proc/net/dev: %v", err)
return nil, err
}
// Get interface details from /sys/class/net
for ifName, ifStats := range stats {
// Skip loopback
if ifName == "lo" {
continue
}
netInfo := dto.NetworkInfo{
Name: ifName,
BytesReceived: ifStats.BytesReceived,
BytesSent: ifStats.BytesSent,
PacketsReceived: ifStats.PacketsReceived,
PacketsSent: ifStats.PacketsSent,
ErrorsReceived: ifStats.ErrorsReceived,
ErrorsSent: ifStats.ErrorsSent,
Timestamp: time.Now(),
}
// Get MAC address
netInfo.MACAddress = c.getMACAddress(ifName)
// Get IP address
netInfo.IPAddress = c.getIPAddress(ifName)
// Get link speed
netInfo.Speed = c.getLinkSpeed(ifName)
// Get operational state
netInfo.State = c.getOperState(ifName)
// Get ethtool information (enhanced network details)
c.enrichWithEthtool(&netInfo, ifName)
interfaces = append(interfaces, netInfo)
}
logger.Debug("Network: Parsed %d interfaces successfully", len(interfaces))
return interfaces, nil
}
type netStats struct {
BytesReceived uint64
PacketsReceived uint64
ErrorsReceived uint64
BytesSent uint64
PacketsSent uint64
ErrorsSent uint64
}
func (c *NetworkCollector) parseNetDev() (map[string]netStats, error) {
file, err := os.Open("/proc/net/dev")
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing network stats file: %v", err)
}
}()
stats := make(map[string]netStats)
scanner := bufio.NewScanner(file)
// Skip header lines
scanner.Scan()
scanner.Scan()
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 2 {
continue
}
ifName := strings.TrimSpace(parts[0])
fields := strings.Fields(parts[1])
if len(fields) < 16 {
continue
}
stats[ifName] = netStats{
BytesReceived: parseUint64(fields[0]),
PacketsReceived: parseUint64(fields[1]),
ErrorsReceived: parseUint64(fields[2]),
BytesSent: parseUint64(fields[8]),
PacketsSent: parseUint64(fields[9]),
ErrorsSent: parseUint64(fields[10]),
}
}
return stats, scanner.Err()
}
func (c *NetworkCollector) getMACAddress(ifName string) string {
path := fmt.Sprintf("/sys/class/net/%s/address", ifName)
//nolint:gosec // G304: Path is constructed from /sys/class/net system directory, ifName from trusted source
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func (c *NetworkCollector) getIPAddress(ifName string) string {
// Use ip command to get IP address
output, err := lib.ExecCommandOutput("ip", "-4", "addr", "show", ifName)
if err != nil {
return ""
}
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "inet ") {
fields := strings.Fields(line)
if len(fields) >= 2 {
// Return IP without CIDR notation
ip := strings.Split(fields[1], "/")[0]
return ip
}
}
}
return ""
}
func (c *NetworkCollector) getLinkSpeed(ifName string) int {
path := fmt.Sprintf("/sys/class/net/%s/speed", ifName)
//nolint:gosec // G304: Path is constructed from /sys/class/net system directory, ifName from trusted source
data, err := os.ReadFile(path)
if err != nil {
return 0
}
speed, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return speed
}
func (c *NetworkCollector) getOperState(ifName string) string {
path := fmt.Sprintf("/sys/class/net/%s/operstate", ifName)
//nolint:gosec // G304: Path is constructed from /sys/class/net system directory, ifName from trusted source
data, err := os.ReadFile(path)
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(data))
}
func parseUint64(s string) uint64 {
val, _ := strconv.ParseUint(s, 10, 64)
return val
}
// enrichWithEthtool adds ethtool information to the network interface
func (c *NetworkCollector) enrichWithEthtool(netInfo *dto.NetworkInfo, ifName string) {
// Parse ethtool information
ethtoolInfo, err := lib.ParseEthtool(ifName)
if err != nil {
logger.Debug("Network: Failed to get ethtool info for %s: %v", ifName, err)
return
}
// Populate ethtool fields
netInfo.SupportedPorts = ethtoolInfo.SupportedPorts
netInfo.SupportedLinkModes = ethtoolInfo.SupportedLinkModes
netInfo.SupportedPauseFrame = ethtoolInfo.SupportedPauseFrame
netInfo.SupportsAutoNeg = ethtoolInfo.SupportsAutoNeg
netInfo.SupportedFECModes = ethtoolInfo.SupportedFECModes
netInfo.AdvertisedLinkModes = ethtoolInfo.AdvertisedLinkModes
netInfo.AdvertisedPauseFrame = ethtoolInfo.AdvertisedPauseFrame
netInfo.AdvertisedAutoNeg = ethtoolInfo.AdvertisedAutoNeg
netInfo.AdvertisedFECModes = ethtoolInfo.AdvertisedFECModes
netInfo.Duplex = ethtoolInfo.Duplex
netInfo.AutoNegotiation = ethtoolInfo.AutoNegotiation
netInfo.Port = ethtoolInfo.Port
netInfo.PHYAD = ethtoolInfo.PHYAD
netInfo.Transceiver = ethtoolInfo.Transceiver
netInfo.MDIX = ethtoolInfo.MDIX
netInfo.SupportsWakeOn = ethtoolInfo.SupportsWakeOn
netInfo.WakeOn = ethtoolInfo.WakeOn
netInfo.MessageLevel = ethtoolInfo.MessageLevel
netInfo.LinkDetected = ethtoolInfo.LinkDetected
netInfo.MTU = ethtoolInfo.MTU
logger.Debug("Network: Enriched %s with ethtool data - Duplex: %s, Link: %v", ifName, netInfo.Duplex, netInfo.LinkDetected)
}
package collectors
import (
"context"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
const (
notificationsDir = "/usr/local/emhttp/state/notifications"
notificationsArchiveDir = "/usr/local/emhttp/state/notifications/archive"
)
// NotificationCollector collects Unraid notifications
type NotificationCollector struct {
ctx *domain.Context
watcher *fsnotify.Watcher
}
// NewNotificationCollector creates a new notification collector
func NewNotificationCollector(ctx *domain.Context) *NotificationCollector {
return &NotificationCollector{ctx: ctx}
}
// Start begins collecting notification data
func (c *NotificationCollector) Start(ctx context.Context, interval time.Duration) {
defer func() {
if r := recover(); r != nil {
logger.Error("Notification collector panic: %v", r)
}
}()
// Initialize file watcher
var err error
c.watcher, err = fsnotify.NewWatcher()
if err != nil {
logger.Error("Failed to create file watcher: %v", err)
return
}
defer func() {
if err := c.watcher.Close(); err != nil {
logger.Error("Failed to close file watcher: %v", err)
}
}()
// Ensure directories exist
// #nosec G301 - Unraid standard permissions (0755 for directories)
if err := os.MkdirAll(notificationsDir, 0755); err != nil {
logger.Warning("Failed to create notifications directory: %v", err)
}
// #nosec G301 - Unraid standard permissions (0755 for directories)
if err := os.MkdirAll(notificationsArchiveDir, 0755); err != nil {
logger.Warning("Failed to create notifications archive directory: %v", err)
}
// Watch notification directories
if err := c.watcher.Add(notificationsDir); err != nil {
logger.Warning("Failed to watch notifications directory: %v", err)
}
if err := c.watcher.Add(notificationsArchiveDir); err != nil {
logger.Warning("Failed to watch notifications archive directory: %v", err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Initial collection
c.collect()
for {
select {
case <-ctx.Done():
logger.Info("Notification collector stopped")
return
case <-ticker.C:
c.collect()
case event := <-c.watcher.Events:
// Trigger immediate collection on file changes
if event.Op&fsnotify.Create == fsnotify.Create ||
event.Op&fsnotify.Remove == fsnotify.Remove ||
event.Op&fsnotify.Write == fsnotify.Write {
logger.Debug("Notification file change detected: %s", event.Name)
c.collect()
}
case err := <-c.watcher.Errors:
logger.Error("File watcher error: %v", err)
}
}
}
// collect gathers all notifications and publishes to event bus
func (c *NotificationCollector) collect() {
unread := c.collectNotifications(notificationsDir, "unread")
archived := c.collectNotifications(notificationsArchiveDir, "archive")
overview := c.calculateOverview(unread, archived)
notificationList := &dto.NotificationList{
Overview: overview,
Notifications: append(unread, archived...),
Timestamp: time.Now(),
}
c.ctx.Hub.Pub(notificationList, "notifications_update")
}
// collectNotifications reads all notification files from a directory
func (c *NotificationCollector) collectNotifications(dir string, notifType string) []dto.Notification {
files, err := os.ReadDir(dir)
if err != nil {
logger.Debug("Failed to read notifications directory %s: %v", dir, err)
return []dto.Notification{}
}
var notifications []dto.Notification
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".notify") {
continue
}
notification := c.parseNotificationFile(filepath.Join(dir, file.Name()), notifType)
if notification != nil {
notifications = append(notifications, *notification)
}
}
// Sort by timestamp descending (newest first)
sort.Slice(notifications, func(i, j int) bool {
return notifications[i].Timestamp.After(notifications[j].Timestamp)
})
return notifications
}
// parseNotificationFile parses a notification file and returns a Notification
func (c *NotificationCollector) parseNotificationFile(path string, notifType string) *dto.Notification {
content, err := os.ReadFile(path) // #nosec G304 - Path is from controlled directory scan
if err != nil {
logger.Debug("Failed to read notification file %s: %v", path, err)
return nil
}
notification := &dto.Notification{
ID: filepath.Base(path),
Type: notifType,
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "event":
notification.Title = value
case "subject":
notification.Subject = value
case "description":
notification.Description = value
case "importance":
notification.Importance = value
case "timestamp":
if ts, err := time.Parse("2006-01-02 15:04:05", value); err == nil {
notification.Timestamp = ts
notification.FormattedTimestamp = ts.Format(time.RFC3339)
}
case "link":
notification.Link = value
}
}
// If timestamp wasn't parsed, use file modification time
if notification.Timestamp.IsZero() {
if info, err := os.Stat(path); err == nil {
notification.Timestamp = info.ModTime()
notification.FormattedTimestamp = info.ModTime().Format(time.RFC3339)
}
}
return notification
}
// calculateOverview calculates notification counts by type and importance
func (c *NotificationCollector) calculateOverview(unread, archived []dto.Notification) dto.NotificationOverview {
return dto.NotificationOverview{
Unread: c.countByImportance(unread),
Archive: c.countByImportance(archived),
}
}
// countByImportance counts notifications by importance level
func (c *NotificationCollector) countByImportance(notifications []dto.Notification) dto.NotificationCounts {
counts := dto.NotificationCounts{}
for _, n := range notifications {
switch n.Importance {
case "alert":
counts.Alert++
case "warning":
counts.Warning++
case "info":
counts.Info++
}
}
counts.Total = counts.Alert + counts.Warning + counts.Info
return counts
}
package collectors
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// NUTCollector collects NUT (Network UPS Tools) status information.
// This collector provides detailed UPS data when the NUT plugin is installed.
type NUTCollector struct {
ctx *domain.Context
}
// NewNUTCollector creates a new NUT status collector with the given context.
func NewNUTCollector(ctx *domain.Context) *NUTCollector {
return &NUTCollector{ctx: ctx}
}
// Start begins the NUT collector's periodic data collection.
func (c *NUTCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting NUT collector (interval: %v)", interval)
// Run once immediately
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("NUT collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("NUT collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("NUT collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers NUT status information and publishes it to the event bus.
func (c *NUTCollector) Collect() {
logger.Debug("Collecting NUT data...")
response := &dto.NUTResponse{
Timestamp: time.Now(),
}
// Check if NUT plugin is installed
if _, err := os.Stat(constants.NutPluginDir); os.IsNotExist(err) {
response.Installed = false
c.ctx.Hub.Pub(response, "nut_status_update")
logger.Debug("NUT plugin not installed")
return
}
response.Installed = true
// Load NUT configuration
config, err := c.loadNUTConfig()
if err != nil {
logger.Warning("Failed to load NUT config: %v", err)
} else {
response.Config = config
}
// Check if NUT service is running
response.Running = c.isNUTRunning()
if !response.Running {
c.ctx.Hub.Pub(response, "nut_status_update")
logger.Debug("NUT service not running")
return
}
// Get list of UPS devices
devices, err := c.listDevices()
if err != nil {
logger.Warning("Failed to list NUT devices: %v", err)
} else {
response.Devices = devices
}
// Get detailed status for the first available device
if len(devices) > 0 {
status, err := c.collectStatus(devices[0].Name, c.getHostFromConfig(config))
if err != nil {
logger.Warning("Failed to collect NUT status: %v", err)
} else {
response.Status = status
}
}
c.ctx.Hub.Pub(response, "nut_status_update")
logger.Debug("Published nut_status_update event")
}
// loadNUTConfig reads the NUT plugin configuration file
func (c *NUTCollector) loadNUTConfig() (*dto.NUTConfig, error) {
file, err := os.Open(constants.NutPluginCfg)
if err != nil {
return nil, err
}
defer file.Close()
config := &dto.NUTConfig{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), "\"")
switch key {
case "SERVICE":
config.ServiceEnabled = value == "enable"
case "MODE":
config.Mode = value
case "NAME":
config.UPSName = value
case "DRIVER":
config.Driver = value
case "PORT":
config.Port = value
case "IPADDR":
config.IPAddress = value
case "POLL":
if poll, err := strconv.Atoi(value); err == nil {
config.PollInterval = poll
}
case "SHUTDOWN":
config.ShutdownMode = value
case "BATTERYLEVEL":
if level, err := strconv.Atoi(value); err == nil {
config.BatteryLevel = level
}
case "RTVALUE":
if rt, err := strconv.Atoi(value); err == nil {
config.RuntimeValue = rt
}
case "TIMEOUT":
if timeout, err := strconv.Atoi(value); err == nil {
config.Timeout = timeout
}
}
}
return config, scanner.Err()
}
// isNUTRunning checks if the NUT service is running
func (c *NUTCollector) isNUTRunning() bool {
// Check for PID file
if _, err := os.Stat(constants.NutPidFile); err == nil {
return true
}
// Also check if upsd process is running
output, err := lib.ExecCommandOutput("pgrep", "-x", "upsd")
if err == nil && strings.TrimSpace(output) != "" {
return true
}
return false
}
// listDevices returns a list of available NUT UPS devices
func (c *NUTCollector) listDevices() ([]dto.NUTDevice, error) {
if !lib.CommandExists("upsc") {
return nil, fmt.Errorf("upsc command not found")
}
output, err := lib.ExecCommandOutput("upsc", "-l", "localhost")
if err != nil {
// Try without host
output, err = lib.ExecCommandOutput("upsc", "-l")
if err != nil {
return nil, err
}
}
var devices []dto.NUTDevice
lines := strings.Split(strings.TrimSpace(output), "\n")
for _, line := range lines {
name := strings.TrimSpace(line)
if name == "" {
continue
}
devices = append(devices, dto.NUTDevice{
Name: name,
Description: fmt.Sprintf("UPS device: %s", name),
Available: true,
})
}
return devices, nil
}
// getHostFromConfig returns the host from NUT config, defaulting to localhost
func (c *NUTCollector) getHostFromConfig(config *dto.NUTConfig) string {
if config != nil && config.IPAddress != "" && config.IPAddress != "127.0.0.1" {
return config.IPAddress
}
return "localhost"
}
// collectStatus collects detailed status for a specific UPS device
func (c *NUTCollector) collectStatus(deviceName, host string) (*dto.NUTStatus, error) {
target := fmt.Sprintf("%s@%s", deviceName, host)
output, err := lib.ExecCommandOutput("upsc", target)
if err != nil {
return nil, fmt.Errorf("failed to query UPS %s: %w", target, err)
}
status := &dto.NUTStatus{
Connected: true,
DeviceName: deviceName,
Host: host,
RawVariables: make(map[string]string),
Timestamp: time.Now(),
}
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Store in raw variables
status.RawVariables[key] = value
// Parse specific fields
switch key {
// Driver info
case "driver.name":
status.Driver = value
case "driver.state":
status.DriverState = value
case "driver.version":
status.DriverVersion = value
case "driver.version.data":
status.DriverVersionData = value
case "driver.version.usb":
status.DriverVersionUSB = value
// Device identification
case "device.mfr", "ups.mfr":
status.Manufacturer = value
case "device.model", "ups.model":
status.Model = value
case "device.serial", "ups.serial":
status.Serial = value
case "device.type":
status.Type = value
case "ups.productid":
status.ProductID = value
case "ups.vendorid":
status.VendorID = value
// UPS status
case "ups.status":
status.Status = value
status.StatusText = dto.NUTStatusText(value)
case "ups.beeper.status":
status.BeeperStatus = value
case "ups.test.result":
status.TestResult = value
// Battery info
case "battery.charge":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryCharge = v
}
case "battery.charge.low":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryChargeLow = v
}
case "battery.charge.warning":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryChargeWarning = v
}
case "battery.runtime":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryRuntime = int(v)
}
case "battery.runtime.low":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryRuntimeLow = int(v)
}
case "battery.voltage":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryVoltage = v
}
case "battery.voltage.nominal":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryVoltageNominal = v
}
case "battery.type":
status.BatteryType = value
case "battery.status":
status.BatteryStatus = value
case "battery.mfr.date":
status.BatteryMfrDate = value
// Input power
case "input.voltage":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputVoltage = v
}
case "input.voltage.nominal":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputVoltageNominal = v
}
case "input.frequency":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputFrequency = v
}
case "input.transfer.high":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputTransferHigh = v
}
case "input.transfer.low":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputTransferLow = v
}
case "input.current":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.InputCurrent = v
}
// Output power
case "output.voltage":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.OutputVoltage = v
}
case "output.frequency":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.OutputFrequency = v
}
case "output.current":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.OutputCurrent = v
}
// Load and power
case "ups.load":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.LoadPercent = v
}
case "ups.realpower":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.RealPower = v
}
case "ups.realpower.nominal":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.RealPowerNominal = v
}
case "ups.power":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.ApparentPower = v
}
case "ups.power.nominal":
if v, err := strconv.ParseFloat(value, 64); err == nil {
status.ApparentPowerNominal = v
}
// Timing
case "ups.delay.shutdown":
if v, err := strconv.Atoi(value); err == nil {
status.DelayShutdown = v
}
case "ups.delay.start":
if v, err := strconv.Atoi(value); err == nil {
status.DelayStart = v
}
case "ups.timer.shutdown":
if v, err := strconv.Atoi(value); err == nil {
status.TimerShutdown = v
}
case "ups.timer.start":
if v, err := strconv.Atoi(value); err == nil {
status.TimerStart = v
}
}
}
// Calculate real power if not directly available
if status.RealPower == 0 && status.RealPowerNominal > 0 && status.LoadPercent > 0 {
status.RealPower = status.RealPowerNominal * status.LoadPercent / 100.0
}
// Calculate apparent power if not directly available
if status.ApparentPower == 0 && status.ApparentPowerNominal > 0 && status.LoadPercent > 0 {
status.ApparentPower = status.ApparentPowerNominal * status.LoadPercent / 100.0
}
return status, nil
}
package collectors
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
const parityLogPath = "/boot/config/parity-checks.log"
// ParityCollector collects parity check history
type ParityCollector struct{}
// NewParityCollector creates a new parity collector
func NewParityCollector() *ParityCollector {
return &ParityCollector{}
}
// GetParityHistory reads and parses the parity-checks.log file
func (c *ParityCollector) GetParityHistory() (*dto.ParityCheckHistory, error) {
logger.Debug("Parity: Reading parity check history from %s", parityLogPath)
file, err := os.Open(parityLogPath)
if err != nil {
if os.IsNotExist(err) {
logger.Debug("Parity: Parity log file does not exist: %s", parityLogPath)
return &dto.ParityCheckHistory{
Records: []dto.ParityCheckRecord{},
Timestamp: time.Now(),
}, nil
}
return nil, fmt.Errorf("failed to open parity log: %w", err)
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing parity log file: %v", err)
}
}()
var records []dto.ParityCheckRecord
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
record, err := c.parseLine(line)
if err != nil {
logger.Debug("Parity: Failed to parse line: %s - %v", line, err)
continue
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading parity log: %w", err)
}
logger.Debug("Parity: Found %d parity check records", len(records))
return &dto.ParityCheckHistory{
Records: records,
Timestamp: time.Now(),
}, nil
}
// parseLine parses a single line from parity-checks.log
// Format examples:
// Parity-Check|2024-11-30, 00:30:26 (Saturday)|10 TB|1 day, 4 hr, 1 min, 28 sec|99.1 MB/s|OK|1348756140
// Parity-Sync|2025-05-04, 07:55:41 (Sunday)|16 TB|9 min, 3 sec|Unavailable|Canceled|0
func (c *ParityCollector) parseLine(line string) (dto.ParityCheckRecord, error) {
parts := strings.Split(line, "|")
if len(parts) < 7 {
return dto.ParityCheckRecord{}, fmt.Errorf("invalid line format: expected 7 parts, got %d", len(parts))
}
record := dto.ParityCheckRecord{
Action: strings.TrimSpace(parts[0]),
}
// Parse date (format: "2024-11-30, 00:30:26 (Saturday)")
dateStr := strings.TrimSpace(parts[1])
// Remove day of week in parentheses
if idx := strings.Index(dateStr, "("); idx > 0 {
dateStr = strings.TrimSpace(dateStr[:idx])
}
date, err := time.Parse("2006-01-02, 15:04:05", dateStr)
if err != nil {
return dto.ParityCheckRecord{}, fmt.Errorf("failed to parse date '%s': %w", dateStr, err)
}
record.Date = date
// Parse size (format: "10 TB" or "16 TB")
sizeStr := strings.TrimSpace(parts[2])
size, err := c.parseSize(sizeStr)
if err != nil {
logger.Debug("Parity: Failed to parse size '%s': %v", sizeStr, err)
record.Size = 0
} else {
record.Size = size
}
// Parse duration (format: "1 day, 4 hr, 1 min, 28 sec" or "9 min, 3 sec")
durationStr := strings.TrimSpace(parts[3])
duration, err := c.parseDuration(durationStr)
if err != nil {
logger.Debug("Parity: Failed to parse duration '%s': %v", durationStr, err)
record.Duration = 0
} else {
record.Duration = duration
}
// Parse speed (format: "99.1 MB/s" or "Unavailable")
speedStr := strings.TrimSpace(parts[4])
if speedStr == "Unavailable" || speedStr == "" {
record.Speed = 0
} else {
speed, err := c.parseSpeed(speedStr)
if err != nil {
logger.Debug("Parity: Failed to parse speed '%s': %v", speedStr, err)
record.Speed = 0
} else {
record.Speed = speed
}
}
// Parse status (format: "OK", "Canceled", or error count like "3572342875")
statusStr := strings.TrimSpace(parts[5])
record.Status = statusStr
if statusStr != "OK" && statusStr != "Canceled" {
// Try to parse as error count
if errors, err := strconv.ParseInt(statusStr, 10, 64); err == nil {
record.Errors = errors
record.Status = fmt.Sprintf("%d errors", errors)
}
}
// Parse errors (last field - error count)
errorsStr := strings.TrimSpace(parts[6])
if errors, err := strconv.ParseInt(errorsStr, 10, 64); err == nil {
record.Errors = errors
}
return record, nil
}
// parseSize converts size string like "10 TB" to bytes
func (c *ParityCollector) parseSize(sizeStr string) (uint64, error) {
parts := strings.Fields(sizeStr)
if len(parts) != 2 {
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
}
value, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, fmt.Errorf("invalid size value: %s", parts[0])
}
unit := strings.ToUpper(parts[1])
var multiplier uint64
switch unit {
case "B":
multiplier = 1
case "KB":
multiplier = 1024
case "MB":
multiplier = 1024 * 1024
case "GB":
multiplier = 1024 * 1024 * 1024
case "TB":
multiplier = 1024 * 1024 * 1024 * 1024
default:
return 0, fmt.Errorf("unknown size unit: %s", unit)
}
return uint64(value * float64(multiplier)), nil
}
// parseDuration converts duration string like "1 day, 4 hr, 1 min, 28 sec" to seconds
func (c *ParityCollector) parseDuration(durationStr string) (int64, error) {
var totalSeconds int64
parts := strings.Split(durationStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
fields := strings.Fields(part)
if len(fields) != 2 {
continue
}
value, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
continue
}
unit := strings.ToLower(fields[1])
switch {
case strings.HasPrefix(unit, "day"):
totalSeconds += value * 86400
case strings.HasPrefix(unit, "hr") || strings.HasPrefix(unit, "hour"):
totalSeconds += value * 3600
case strings.HasPrefix(unit, "min"):
totalSeconds += value * 60
case strings.HasPrefix(unit, "sec"):
totalSeconds += value
}
}
return totalSeconds, nil
}
// parseSpeed converts speed string like "99.1 MB/s" to MB/s
func (c *ParityCollector) parseSpeed(speedStr string) (float64, error) {
// Remove " MB/s" suffix
speedStr = strings.TrimSuffix(speedStr, " MB/s")
speedStr = strings.TrimSpace(speedStr)
speed, err := strconv.ParseFloat(speedStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid speed value: %s", speedStr)
}
return speed, nil
}
package collectors
import (
"context"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
"gopkg.in/ini.v1"
)
// RegistrationCollector collects Unraid registration/license information
type RegistrationCollector struct {
ctx *domain.Context
}
// NewRegistrationCollector creates a new registration collector
func NewRegistrationCollector(ctx *domain.Context) *RegistrationCollector {
return &RegistrationCollector{ctx: ctx}
}
// Start begins collecting registration information at the specified interval
func (c *RegistrationCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting registration collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Registration collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Registration collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Registration collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers registration information
func (c *RegistrationCollector) Collect() {
logger.Debug("Collecting registration data...")
registration, err := c.collectRegistration()
if err != nil {
logger.Error("Registration: Failed to collect registration info: %v", err)
return
}
logger.Debug("Registration: Successfully collected, publishing event")
c.ctx.Hub.Pub(registration, "registration_update")
logger.Debug("Registration: Published registration_update event - type=%s, state=%s", registration.Type, registration.State)
}
// collectRegistration reads registration information from var.ini
func (c *RegistrationCollector) collectRegistration() (*dto.Registration, error) {
logger.Debug("Registration: Reading from %s", constants.VarIni)
registration := &dto.Registration{
Timestamp: time.Now(),
}
// Parse var.ini for registration information
cfg, err := ini.Load(constants.VarIni)
if err != nil {
logger.Error("Registration: Failed to load file: %v", err)
return nil, err
}
// Get the default section (unnamed section)
section := cfg.Section("")
// Registration type (regTy)
if section.HasKey("regTy") {
regType := strings.Trim(section.Key("regTy").String(), `"`)
registration.Type = strings.ToLower(regType)
} else {
registration.Type = "unknown"
}
// Registration GUID (regGUID)
if section.HasKey("regGUID") {
registration.GUID = strings.Trim(section.Key("regGUID").String(), `"`)
}
// Server name (NAME)
if section.HasKey("NAME") {
registration.ServerName = strings.Trim(section.Key("NAME").String(), `"`)
}
// Registration timestamp/expiration (regTm)
if section.HasKey("regTm") {
regTmStr := strings.Trim(section.Key("regTm").String(), `"`)
if timestamp, err := strconv.ParseInt(regTmStr, 10, 64); err == nil {
registration.Expiration = time.Unix(timestamp, 0)
registration.UpdateExpiration = time.Unix(timestamp, 0)
}
}
// Determine state based on expiration
switch {
case !registration.Expiration.IsZero():
if time.Now().After(registration.Expiration) {
registration.State = "expired"
} else {
registration.State = "valid"
}
case registration.Type == "trial":
registration.State = "trial"
case registration.Type == "lifetime" || registration.Type == "unleashed":
registration.State = "valid"
case registration.Type == "unknown":
registration.State = "invalid"
default:
registration.State = "valid"
}
logger.Debug("Registration: Parsed - type=%s, state=%s, server=%s",
registration.Type, registration.State, registration.ServerName)
return registration, nil
}
package collectors
import (
"bufio"
"context"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// ShareCollector collects information about Unraid user shares.
// It gathers share configuration, usage statistics, and disk allocation details.
type ShareCollector struct {
ctx *domain.Context
}
// NewShareCollector creates a new user share collector with the given context.
func NewShareCollector(ctx *domain.Context) *ShareCollector {
return &ShareCollector{ctx: ctx}
}
// Start begins the share collector's periodic data collection.
// It runs in a goroutine and publishes share information updates at the specified interval until the context is cancelled.
func (c *ShareCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting share collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Share collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Share collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("Share collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers user share information and publishes it to the event bus.
// It reads share configuration from /boot/config/shares/ and enriches with usage data from df command.
func (c *ShareCollector) Collect() {
logger.Debug("Collecting share data...")
// Collect share information
shares, err := c.collectShares()
if err != nil {
logger.Error("Share: Failed to collect share data: %v", err)
return
}
logger.Debug("Share: Successfully collected %d shares, publishing event", len(shares))
// Publish event
c.ctx.Hub.Pub(shares, "share_list_update")
logger.Debug("Share: Published share_list_update event with %d shares", len(shares))
}
func (c *ShareCollector) collectShares() ([]dto.ShareInfo, error) {
logger.Debug("Share: Starting collection from %s", constants.SharesIni)
var shares []dto.ShareInfo
// Parse shares.ini
file, err := os.Open(constants.SharesIni)
if err != nil {
logger.Error("Share: Failed to open file: %v", err)
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing share file: %v", err)
}
}()
logger.Debug("Share: File opened successfully")
scanner := bufio.NewScanner(file)
var currentShare *dto.ShareInfo
var currentShareName string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Check for section header: [shareName="appdata"]
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// Save previous share if exists
if currentShare != nil {
shares = append(shares, *currentShare)
}
// Extract share name from [shareName="appdata"]
if strings.Contains(line, "=") {
parts := strings.SplitN(line[1:len(line)-1], "=", 2)
if len(parts) == 2 {
currentShareName = strings.Trim(parts[1], `"`)
}
}
// Start new share
currentShare = &dto.ShareInfo{
Name: currentShareName,
}
continue
}
// Parse key=value pairs
if currentShare != nil && strings.Contains(line, "=") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), `"`)
switch key {
case "name":
// Use the name field from the INI file
currentShare.Name = value
case "size":
if size, err := strconv.ParseUint(value, 10, 64); err == nil {
currentShare.Total = size
}
case "free":
if free, err := strconv.ParseUint(value, 10, 64); err == nil {
currentShare.Free = free
}
case "used":
if used, err := strconv.ParseUint(value, 10, 64); err == nil {
currentShare.Used = used
}
}
}
}
// Save last share
if currentShare != nil {
shares = append(shares, *currentShare)
}
if err := scanner.Err(); err != nil {
logger.Error("Share: Scanner error: %v", err)
return shares, err
}
// Calculate total and usage percentage for each share
for i := range shares {
// If total is 0, calculate it from used + free
if shares[i].Total == 0 && (shares[i].Used > 0 || shares[i].Free > 0) {
shares[i].Total = shares[i].Used + shares[i].Free
}
// Calculate usage percentage
if shares[i].Total > 0 {
shares[i].UsagePercent = float64(shares[i].Used) / float64(shares[i].Total) * 100
}
// Set timestamp
shares[i].Timestamp = time.Now()
}
// Enrich shares with configuration data
configCollector := NewConfigCollector()
for i := range shares {
c.enrichShareWithConfig(&shares[i], configCollector)
}
logger.Debug("Share: Parsed %d shares successfully", len(shares))
return shares, nil
}
// enrichShareWithConfig enriches a share with configuration data
func (c *ShareCollector) enrichShareWithConfig(share *dto.ShareInfo, configCollector *ConfigCollector) {
config, err := configCollector.GetShareConfig(share.Name)
if err != nil {
logger.Debug("Share: Failed to get config for share %s: %v", share.Name, err)
// Set default values for shares without config
share.Storage = "unknown"
share.SMBExport = false
share.NFSExport = false
return
}
// Populate configuration fields
share.Comment = config.Comment
share.UseCache = config.UseCache
share.Security = config.Security
share.Storage = c.determineStorage(config.UseCache)
share.SMBExport = c.isSMBExported(config.Export, config.Security)
share.NFSExport = c.isNFSExported(config.Export)
logger.Debug("Share: Enriched %s - Storage: %s, SMB: %v, NFS: %v", share.Name, share.Storage, share.SMBExport, share.NFSExport)
}
// determineStorage determines storage location based on UseCache setting
func (c *ShareCollector) determineStorage(useCache string) string {
switch useCache {
case "no":
return "array"
case "only":
return "cache"
case "yes", "prefer":
return "cache+array"
default:
return "unknown"
}
}
// isSMBExported checks if share is exported via SMB
func (c *ShareCollector) isSMBExported(export string, security string) bool {
// If security is set, share is typically SMB exported
if security == "public" || security == "private" || security == "secure" {
return true
}
// Check export field for SMB indicators
if strings.Contains(export, "smb") || strings.Contains(export, "-e") {
return true
}
return false
}
// isNFSExported checks if share is exported via NFS
func (c *ShareCollector) isNFSExported(export string) bool {
// Check export field for NFS indicators
return strings.Contains(export, "nfs") || strings.Contains(export, "-n")
}
// Package collectors provides data collection services for system metrics.
package collectors
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// SystemCollector collects overall system information including CPU, memory, uptime, and temperatures.
// It provides high-level system metrics and status information.
type SystemCollector struct {
ctx *domain.Context
}
// NewSystemCollector creates a new system information collector with the given context.
func NewSystemCollector(ctx *domain.Context) *SystemCollector {
return &SystemCollector{ctx: ctx}
}
// Start begins the system collector's periodic data collection.
// It runs in a goroutine and publishes system information updates at the specified interval until the context is cancelled.
func (c *SystemCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting system collector (interval: %v)", interval)
// Run once immediately with panic recovery
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("System collector PANIC on startup: %v", r)
}
}()
c.Collect()
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("System collector stopping due to context cancellation")
return
case <-ticker.C:
func() {
defer func() {
if r := recover(); r != nil {
logger.Error("System collector PANIC in loop: %v", r)
}
}()
c.Collect()
}()
}
}
}
// Collect gathers system information and publishes it to the event bus.
// It collects CPU, memory, uptime, and temperature data from /proc and /sys filesystems.
func (c *SystemCollector) Collect() {
logger.Debug("Collecting system data...")
// Collect system info
systemInfo, err := c.collectSystemInfo()
if err != nil {
logger.Error("Failed to collect system info: %v", err)
return
}
// Publish event
c.ctx.Hub.Pub(systemInfo, "system_update")
logger.Debug("Published system_update event")
}
func (c *SystemCollector) collectSystemInfo() (*dto.SystemInfo, error) {
info := &dto.SystemInfo{}
// Get hostname
hostname, err := os.Hostname()
if err != nil {
logger.Warning("Failed to get hostname", "error", err)
info.Hostname = "unknown"
} else {
info.Hostname = hostname
}
// Get Unraid version
info.Version = c.getUnraidVersion()
// Get Management Agent version
info.AgentVersion = c.ctx.Version
// Get uptime
uptime, err := c.getUptime()
if err != nil {
logger.Warning("Failed to get uptime", "error", err)
} else {
info.Uptime = uptime
}
// Get CPU info
cpuPercent, err := c.getCPUInfo()
if err != nil {
logger.Warning("Failed to get CPU info", "error", err)
} else {
info.CPUUsage = cpuPercent
}
// Get CPU model and specs
cpuModel, cpuCores, cpuThreads, cpuMHz := c.getCPUSpecs()
info.CPUModel = cpuModel
info.CPUCores = cpuCores
info.CPUThreads = cpuThreads
info.CPUMHz = cpuMHz
// Get per-core CPU usage
perCoreUsage, err := c.getPerCoreCPUUsage()
if err != nil {
logger.Debug("Failed to get per-core CPU usage: %v", err)
} else {
info.CPUPerCore = perCoreUsage
}
// Get memory info
memUsed, memTotal, memFree, memBuffers, memCached, err := c.getMemoryInfo()
if err != nil {
logger.Warning("Failed to get memory info", "error", err)
} else {
info.RAMUsed = memUsed
info.RAMTotal = memTotal
info.RAMFree = memFree
info.RAMBuffers = memBuffers
info.RAMCached = memCached
if memTotal > 0 {
info.RAMUsage = float64(memUsed) / float64(memTotal) * 100
}
}
// Get server model and BIOS info
serverModel, biosVersion, biosDate := c.getSystemHardwareInfo()
info.ServerModel = serverModel
info.BIOSVersion = biosVersion
info.BIOSDate = biosDate
// Get temperatures
temperatures, err := c.getTemperatures()
if err != nil {
logger.Warning("Failed to get temperatures", "error", err)
} else {
// Extract CPU and motherboard temps if available
for name, temp := range temperatures {
nameLower := strings.ToLower(name)
// CPU temperature - look for Core temps, Package, or CPUTIN
if strings.Contains(nameLower, "core") || strings.Contains(nameLower, "package") || strings.Contains(nameLower, "cputin") {
if info.CPUTemp == 0 || temp > info.CPUTemp {
info.CPUTemp = temp
}
}
// Motherboard temperature - look for "MB Temp" or "MB_Temp" specifically from coretemp
// Ignore SYSTIN and AUXTIN as they often have bogus readings
if strings.Contains(nameLower, "mb_temp") {
// Sanity check: temperature should be reasonable (0-100°C)
if temp > 0 && temp < 100 {
info.MotherboardTemp = temp
}
}
}
}
// Get fan speeds
fans, err := c.getFans()
if err != nil {
logger.Warning("Failed to get fan speeds", "error", err)
} else {
info.Fans = fans
}
// Get virtualization features
info.HVMEnabled = c.isHVMEnabled()
info.IOMMUEnabled = c.isIOMMUEnabled()
// Get additional system information
info.OpenSSLVersion = c.getOpenSSLVersion()
info.KernelVersion = c.getKernelVersion()
info.ParityCheckSpeed = c.getParityCheckSpeed()
// Set timestamp
info.Timestamp = time.Now()
return info, nil
}
func (c *SystemCollector) getUptime() (int64, error) {
data, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0, err
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return 0, fmt.Errorf("invalid uptime format")
}
uptime, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0, err
}
return int64(uptime), nil
}
func (c *SystemCollector) getCPUInfo() (float64, error) {
// Get CPU usage by reading /proc/stat
cpuPercent, err := c.calculateCPUPercent()
if err != nil {
logger.Warning("Failed to calculate CPU percent", "error", err)
return 0, err
}
return cpuPercent, nil
}
func (c *SystemCollector) calculateCPUPercent() (float64, error) {
// Read first snapshot
stat1, err := c.readCPUStat()
if err != nil {
return 0, err
}
// Wait a short time
time.Sleep(100 * time.Millisecond)
// Read second snapshot
stat2, err := c.readCPUStat()
if err != nil {
return 0, err
}
// Calculate usage
total1 := stat1["user"] + stat1["nice"] + stat1["system"] + stat1["idle"] + stat1["iowait"] + stat1["irq"] + stat1["softirq"] + stat1["steal"]
total2 := stat2["user"] + stat2["nice"] + stat2["system"] + stat2["idle"] + stat2["iowait"] + stat2["irq"] + stat2["softirq"] + stat2["steal"]
idle1 := stat1["idle"] + stat1["iowait"]
idle2 := stat2["idle"] + stat2["iowait"]
totalDelta := total2 - total1
idleDelta := idle2 - idle1
if totalDelta == 0 {
return 0, nil
}
usage := (float64(totalDelta-idleDelta) / float64(totalDelta)) * 100
return usage, nil
}
func (c *SystemCollector) readCPUStat() (map[string]uint64, error) {
file, err := os.Open("/proc/stat")
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing CPU stat file: %v", err)
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "cpu ") {
fields := strings.Fields(line)
if len(fields) < 9 {
return nil, fmt.Errorf("invalid cpu stat format")
}
stat := make(map[string]uint64)
var err error
if stat["user"], err = strconv.ParseUint(fields[1], 10, 64); err != nil {
logger.Warning("Failed to parse CPU user stat: %v", err)
}
if stat["nice"], err = strconv.ParseUint(fields[2], 10, 64); err != nil {
logger.Warning("Failed to parse CPU nice stat: %v", err)
}
if stat["system"], err = strconv.ParseUint(fields[3], 10, 64); err != nil {
logger.Warning("Failed to parse CPU system stat: %v", err)
}
if stat["idle"], err = strconv.ParseUint(fields[4], 10, 64); err != nil {
logger.Warning("Failed to parse CPU idle stat: %v", err)
}
if stat["iowait"], err = strconv.ParseUint(fields[5], 10, 64); err != nil {
logger.Warning("Failed to parse CPU iowait stat: %v", err)
}
if stat["irq"], err = strconv.ParseUint(fields[6], 10, 64); err != nil {
logger.Warning("Failed to parse CPU irq stat: %v", err)
}
if stat["softirq"], err = strconv.ParseUint(fields[7], 10, 64); err != nil {
logger.Warning("Failed to parse CPU softirq stat: %v", err)
}
if stat["steal"], err = strconv.ParseUint(fields[8], 10, 64); err != nil {
logger.Warning("Failed to parse CPU steal stat: %v", err)
}
return stat, nil
}
}
return nil, fmt.Errorf("cpu line not found in /proc/stat")
}
func (c *SystemCollector) getMemoryInfo() (uint64, uint64, uint64, uint64, uint64, error) {
file, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0, 0, 0, 0, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing meminfo file: %v", err)
}
}()
var memTotal, memFree, memBuffers, memCached uint64
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
value, _ := strconv.ParseUint(fields[1], 10, 64)
value *= 1024 // Convert from KB to bytes
switch key {
case "MemTotal":
memTotal = value
case "MemFree":
memFree = value
case "Buffers":
memBuffers = value
case "Cached":
memCached = value
}
}
if err := scanner.Err(); err != nil {
return 0, 0, 0, 0, 0, err
}
// Calculate used memory (excluding buffers and cache)
memUsed := memTotal - memFree - memBuffers - memCached
// Calculate actual free (including buffers and cache)
memActualFree := memFree + memBuffers + memCached
return memUsed, memTotal, memActualFree, memBuffers, memCached, nil
}
func (c *SystemCollector) getTemperatures() (map[string]float64, error) {
// Try using sensors command first
output, err := lib.ExecCommandOutput("sensors", "-u")
if err == nil {
temperatures := c.parseSensorsOutput(output)
if len(temperatures) > 0 {
return temperatures, nil
}
}
// Fallback: try reading from /sys/class/hwmon
temperatures, err := c.readHwmonTemperatures()
if err != nil {
return nil, err
}
return temperatures, nil
}
func (c *SystemCollector) parseSensorsOutput(output string) map[string]float64 {
temperatures := make(map[string]float64)
lines := strings.Split(output, "\n")
var currentChip string
var currentLabel string
for _, line := range lines {
originalLine := line
line = strings.TrimSpace(line)
if line == "" {
continue
}
// New chip/adapter
if !strings.Contains(line, ":") && !strings.HasPrefix(originalLine, " ") {
currentChip = line
currentLabel = ""
continue
}
// Sensor label (e.g., "MB Temp:", "Core 0:", "SYSTIN:")
// These are lines that end with ":" and are not indented with spaces
if strings.HasSuffix(line, ":") && !strings.HasPrefix(originalLine, " ") && !strings.Contains(line, "_") {
currentLabel = strings.TrimSuffix(line, ":")
continue
}
// Temperature input line (indented with spaces)
if strings.Contains(line, "_input:") && currentChip != "" {
parts := strings.Split(line, ":")
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
valueStr := strings.TrimSpace(parts[1])
if value, err := strconv.ParseFloat(valueStr, 64); err == nil {
// Create a friendly name using label if available, otherwise use key
var name string
if currentLabel != "" {
name = fmt.Sprintf("%s_%s_%s", currentChip, currentLabel, key)
} else {
name = fmt.Sprintf("%s_%s", currentChip, key)
}
name = strings.ReplaceAll(name, " ", "_")
// sensors -u already outputs in degrees, no need to divide
temperatures[name] = value
}
}
}
}
return temperatures
}
func (c *SystemCollector) readHwmonTemperatures() (map[string]float64, error) {
temperatures := make(map[string]float64)
// Read from /sys/class/hwmon/hwmon*/temp*_input
for i := 0; i < 10; i++ {
for j := 1; j < 20; j++ {
path := fmt.Sprintf("/sys/class/hwmon/hwmon%d/temp%d_input", i, j)
//nolint:gosec // G304: Path is constructed from /sys/class/hwmon system directory with numeric indices
data, err := os.ReadFile(path)
if err != nil {
continue
}
value, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64)
if err != nil {
continue
}
// Try to get label
labelPath := fmt.Sprintf("/sys/class/hwmon/hwmon%d/temp%d_label", i, j)
//nolint:gosec // G304: Path is constructed from /sys/class/hwmon system directory with numeric indices
labelData, err := os.ReadFile(labelPath)
label := fmt.Sprintf("hwmon%d_temp%d", i, j)
if err == nil {
label = strings.TrimSpace(string(labelData))
}
temperatures[label] = value / 1000.0 // Convert from millidegrees
}
}
if len(temperatures) == 0 {
return nil, fmt.Errorf("no temperature sensors found")
}
return temperatures, nil
}
func (c *SystemCollector) getFans() ([]dto.FanInfo, error) {
fanMap := make(map[string]int)
// Try using sensors command first
output, err := lib.ExecCommandOutput("sensors", "-u")
if err == nil {
fanMap = c.parseFanSpeeds(output)
}
// If no fans found, try fallback
if len(fanMap) == 0 {
fanMap, err = c.readHwmonFanSpeeds()
if err != nil {
return nil, err
}
}
// Convert map to slice
fans := make([]dto.FanInfo, 0, len(fanMap))
for name, rpm := range fanMap {
fans = append(fans, dto.FanInfo{
Name: name,
RPM: rpm,
})
}
return fans, nil
}
func (c *SystemCollector) parseFanSpeeds(output string) map[string]int {
fanSpeeds := make(map[string]int)
lines := strings.Split(output, "\n")
var currentChip string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// New chip/adapter
if !strings.Contains(line, ":") && !strings.HasPrefix(line, " ") {
currentChip = line
continue
}
// Fan input line
if strings.Contains(line, "fan") && strings.Contains(line, "_input:") && currentChip != "" {
parts := strings.Split(line, ":")
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
valueStr := strings.TrimSpace(parts[1])
if value, err := strconv.Atoi(valueStr); err == nil {
name := fmt.Sprintf("%s_%s", currentChip, key)
name = strings.ReplaceAll(name, " ", "_")
fanSpeeds[name] = value
}
}
}
}
return fanSpeeds
}
func (c *SystemCollector) readHwmonFanSpeeds() (map[string]int, error) {
fanSpeeds := make(map[string]int)
// Read from /sys/class/hwmon/hwmon*/fan*_input
for i := 0; i < 10; i++ {
for j := 1; j < 20; j++ {
path := fmt.Sprintf("/sys/class/hwmon/hwmon%d/fan%d_input", i, j)
//nolint:gosec // G304: Path is constructed from /sys/class/hwmon system directory with numeric indices
data, err := os.ReadFile(path)
if err != nil {
continue
}
value, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
continue
}
// Try to get label
labelPath := fmt.Sprintf("/sys/class/hwmon/hwmon%d/fan%d_label", i, j)
//nolint:gosec // G304: Path is constructed from /sys/class/hwmon system directory with numeric indices
labelData, err := os.ReadFile(labelPath)
label := fmt.Sprintf("hwmon%d_fan%d", i, j)
if err == nil {
label = strings.TrimSpace(string(labelData))
}
fanSpeeds[label] = value
}
}
if len(fanSpeeds) == 0 {
return nil, fmt.Errorf("no fan sensors found")
}
return fanSpeeds, nil
}
// getCPUSpecs reads CPU model, cores, threads, and frequency from /proc/cpuinfo
func (c *SystemCollector) getCPUSpecs() (string, int, int, float64) {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return "Unknown", 0, 0, 0.0
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing cpuinfo file: %v", err)
}
}()
var cpuModel string
var cpuMHz float64
physicalIDs := make(map[string]bool)
processors := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "model name":
if cpuModel == "" {
cpuModel = value
}
case "cpu MHz":
if mhz, err := strconv.ParseFloat(value, 64); err == nil && cpuMHz == 0 {
cpuMHz = mhz
}
case "physical id":
physicalIDs[value] = true
case "processor":
processors++
}
}
cpuCores := len(physicalIDs)
if cpuCores == 0 {
cpuCores = 1 // Fallback to at least 1 core
}
return cpuModel, cpuCores, processors, cpuMHz
}
// getSystemHardwareInfo uses dmidecode to get server model and BIOS info
func (c *SystemCollector) getSystemHardwareInfo() (string, string, string) {
var serverModel, biosVersion, biosDate string
// Get system product name (server model)
if output, err := lib.ExecCommandOutput("dmidecode", "-s", "system-product-name"); err == nil {
serverModel = strings.TrimSpace(output)
}
// Get BIOS version
if output, err := lib.ExecCommandOutput("dmidecode", "-s", "bios-version"); err == nil {
biosVersion = strings.TrimSpace(output)
}
// Get BIOS release date
if output, err := lib.ExecCommandOutput("dmidecode", "-s", "bios-release-date"); err == nil {
biosDate = strings.TrimSpace(output)
}
return serverModel, biosVersion, biosDate
}
// getPerCoreCPUUsage calculates per-core CPU usage
func (c *SystemCollector) getPerCoreCPUUsage() (map[string]float64, error) {
// Read first snapshot
stat1, err := c.readPerCoreCPUStat()
if err != nil {
return nil, err
}
// Wait a short time
time.Sleep(100 * time.Millisecond)
// Read second snapshot
stat2, err := c.readPerCoreCPUStat()
if err != nil {
return nil, err
}
// Calculate usage per core
perCoreUsage := make(map[string]float64)
for core, values1 := range stat1 {
if values2, exists := stat2[core]; exists {
total1 := values1["user"] + values1["nice"] + values1["system"] + values1["idle"] + values1["iowait"] + values1["irq"] + values1["softirq"] + values1["steal"]
total2 := values2["user"] + values2["nice"] + values2["system"] + values2["idle"] + values2["iowait"] + values2["irq"] + values2["softirq"] + values2["steal"]
idle1 := values1["idle"] + values1["iowait"]
idle2 := values2["idle"] + values2["iowait"]
totalDelta := total2 - total1
idleDelta := idle2 - idle1
if totalDelta > 0 {
usage := (float64(totalDelta-idleDelta) / float64(totalDelta)) * 100
perCoreUsage[core] = usage
}
}
}
return perCoreUsage, nil
}
// readPerCoreCPUStat reads CPU statistics for each core from /proc/stat
func (c *SystemCollector) readPerCoreCPUStat() (map[string]map[string]uint64, error) {
file, err := os.Open("/proc/stat")
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
logger.Debug("Error closing per-core CPU stat file: %v", err)
}
}()
coreStats := make(map[string]map[string]uint64)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// Look for lines starting with "cpu" followed by a number (cpu0, cpu1, etc.)
if strings.HasPrefix(line, "cpu") && len(line) > 3 {
fields := strings.Fields(line)
if len(fields) < 9 {
continue
}
coreName := fields[0]
// Skip the aggregate "cpu" line
if coreName == "cpu" {
continue
}
stat := make(map[string]uint64)
var parseErr error
if stat["user"], parseErr = strconv.ParseUint(fields[1], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU user stat for %s: %v", coreName, parseErr)
}
if stat["nice"], parseErr = strconv.ParseUint(fields[2], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU nice stat for %s: %v", coreName, parseErr)
}
if stat["system"], parseErr = strconv.ParseUint(fields[3], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU system stat for %s: %v", coreName, parseErr)
}
if stat["idle"], parseErr = strconv.ParseUint(fields[4], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU idle stat for %s: %v", coreName, parseErr)
}
if stat["iowait"], parseErr = strconv.ParseUint(fields[5], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU iowait stat for %s: %v", coreName, parseErr)
}
if stat["irq"], parseErr = strconv.ParseUint(fields[6], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU irq stat for %s: %v", coreName, parseErr)
}
if stat["softirq"], parseErr = strconv.ParseUint(fields[7], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU softirq stat for %s: %v", coreName, parseErr)
}
if stat["steal"], parseErr = strconv.ParseUint(fields[8], 10, 64); parseErr != nil {
logger.Debug("Failed to parse per-core CPU steal stat for %s: %v", coreName, parseErr)
}
coreStats[coreName] = stat
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(coreStats) == 0 {
return nil, fmt.Errorf("no per-core CPU stats found")
}
return coreStats, nil
}
// isHVMEnabled checks if hardware virtualization (HVM) is enabled
// Checks for vmx (Intel) or svm (AMD) flags in /proc/cpuinfo
func (c *SystemCollector) isHVMEnabled() bool {
data, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return false
}
content := string(data)
// Check for Intel VT-x (vmx) or AMD-V (svm)
return strings.Contains(content, " vmx ") || strings.Contains(content, " svm ")
}
// isIOMMUEnabled checks if IOMMU is enabled
// Checks kernel command line and /sys/class/iommu/
func (c *SystemCollector) isIOMMUEnabled() bool {
// Check kernel command line for IOMMU parameters
cmdline, err := os.ReadFile("/proc/cmdline")
if err == nil {
content := string(cmdline)
if strings.Contains(content, "intel_iommu=on") || strings.Contains(content, "amd_iommu=on") {
return true
}
}
// Check if /sys/class/iommu/ exists and has entries
entries, err := os.ReadDir("/sys/class/iommu")
if err == nil && len(entries) > 0 {
return true
}
return false
}
// getOpenSSLVersion gets the OpenSSL version
func (c *SystemCollector) getOpenSSLVersion() string {
output, err := lib.ExecCommandOutput("openssl", "version")
if err != nil {
return ""
}
return strings.TrimSpace(output)
}
// getKernelVersion gets the kernel version
func (c *SystemCollector) getKernelVersion() string {
output, err := lib.ExecCommandOutput("uname", "-r")
if err != nil {
return ""
}
return strings.TrimSpace(output)
}
// getUnraidVersion gets the Unraid OS version
func (c *SystemCollector) getUnraidVersion() string {
// Try reading from /etc/unraid-version first
data, err := os.ReadFile("/etc/unraid-version")
if err == nil {
content := strings.TrimSpace(string(data))
// The file contains version="7.2.0" format
if strings.HasPrefix(content, "version=") {
version := strings.TrimPrefix(content, "version=")
version = strings.Trim(version, "\"")
return version
}
// If it's just the version number without the prefix
return content
}
// Fallback: try reading from /var/local/emhttp/var.ini
varIniPath := "/var/local/emhttp/var.ini"
varIniData, err := os.ReadFile(varIniPath)
if err == nil {
lines := strings.Split(string(varIniData), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "version=") {
version := strings.TrimPrefix(line, "version=")
version = strings.Trim(version, "\"")
return version
}
}
}
// If all else fails, return empty string
return ""
}
// getParityCheckSpeed gets the parity check speed from var.ini
func (c *SystemCollector) getParityCheckSpeed() string {
// Try to read from /var/local/emhttp/var.ini
data, err := os.ReadFile("/var/local/emhttp/var.ini")
if err != nil {
return ""
}
// Parse for sbSynced line which contains parity check speed
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "sbSynced=") {
// Extract the speed part (e.g., "18645 MB/s + 38044 MB/s")
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
value := strings.Trim(parts[1], "\"")
// Look for the speed pattern
if strings.Contains(value, "MB/s") {
return value
}
}
}
}
return ""
}
package collectors
import (
"context"
"encoding/json"
"os"
"os/exec"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// UnassignedCollector collects information about unassigned devices
type UnassignedCollector struct {
ctx *domain.Context
}
// NewUnassignedCollector creates a new unassigned devices collector
func NewUnassignedCollector(ctx *domain.Context) *UnassignedCollector {
return &UnassignedCollector{ctx: ctx}
}
// Start begins collecting unassigned device information
func (c *UnassignedCollector) Start(ctx context.Context, interval time.Duration) {
defer func() {
if r := recover(); r != nil {
logger.Error("Unassigned collector panic: %v", r)
}
}()
logger.Info("Starting unassigned devices collector (interval: %v)", interval)
// Initial collection
c.collect()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("Stopping unassigned devices collector")
return
case <-ticker.C:
c.collect()
}
}
}
// collect gathers unassigned device information
func (c *UnassignedCollector) collect() {
devices := c.collectUnassignedDevices()
remoteShares := c.collectRemoteShares()
deviceList := &dto.UnassignedDeviceList{
Devices: devices,
RemoteShares: remoteShares,
Timestamp: time.Now(),
}
// Publish event
c.ctx.Hub.Pub(deviceList, "unassigned_devices_update")
logger.Debug("Published unassigned devices update - devices=%d, remote_shares=%d",
len(devices), len(remoteShares))
}
// collectUnassignedDevices discovers and collects unassigned disk devices
func (c *UnassignedCollector) collectUnassignedDevices() []dto.UnassignedDevice {
// Check if plugin is installed
if !c.isPluginInstalled() {
logger.Debug("Unassigned Devices plugin not installed")
return []dto.UnassignedDevice{}
}
// Get array disks to filter them out
arrayDisks := c.getArrayDisks()
// Get all block devices
allDevices := c.getAllBlockDevices()
var unassignedDevices []dto.UnassignedDevice
for _, device := range allDevices {
// Skip if it's an array disk
if c.isArrayDisk(device, arrayDisks) {
continue
}
// Skip loop devices, md devices, zram, and partitions
if strings.HasPrefix(device, "loop") ||
strings.HasPrefix(device, "md") ||
strings.HasPrefix(device, "zram") ||
strings.Contains(device, "nvme0n1p") ||
(len(device) > 3 && device[3] >= '1' && device[3] <= '9') {
continue
}
unassignedDevice := c.getDeviceInfo(device)
if unassignedDevice != nil {
unassignedDevices = append(unassignedDevices, *unassignedDevice)
}
}
return unassignedDevices
}
// collectRemoteShares collects remote SMB/NFS/ISO shares
func (c *UnassignedCollector) collectRemoteShares() []dto.UnassignedRemoteShare {
if !c.isPluginInstalled() {
return []dto.UnassignedRemoteShare{}
}
var shares []dto.UnassignedRemoteShare
// Parse SMB mounts
smbShares := c.parseSMBMounts()
shares = append(shares, smbShares...)
// Parse ISO mounts
isoShares := c.parseISOMounts()
shares = append(shares, isoShares...)
return shares
}
// isPluginInstalled checks if the Unassigned Devices plugin is installed
func (c *UnassignedCollector) isPluginInstalled() bool {
_, err := os.Stat("/boot/config/plugins/unassigned.devices")
return err == nil
}
// getArrayDisks returns a map of array disk devices
func (c *UnassignedCollector) getArrayDisks() map[string]bool {
arrayDisks := make(map[string]bool)
// Read disks.ini file directly
data, err := os.ReadFile("/var/local/emhttp/disks.ini")
if err != nil {
logger.Debug("Failed to read disks.ini: %v", err)
return arrayDisks
}
// Parse the INI file to extract device names
lines := strings.Split(string(data), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if device, found := strings.CutPrefix(line, "device="); found {
device = strings.Trim(device, "\"")
if device != "" {
arrayDisks[device] = true
}
}
}
return arrayDisks
}
// getAllBlockDevices returns a list of all block device names
func (c *UnassignedCollector) getAllBlockDevices() []string {
cmd := exec.Command("lsblk", "-d", "-n", "-o", "NAME")
output, err := cmd.Output()
if err != nil {
logger.Error("Failed to list block devices: %v", err)
return []string{}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
return lines
}
// isArrayDisk checks if a device is part of the Unraid array
func (c *UnassignedCollector) isArrayDisk(device string, arrayDisks map[string]bool) bool {
return arrayDisks[device]
}
// getDeviceInfo retrieves detailed information about a device
func (c *UnassignedCollector) getDeviceInfo(device string) *dto.UnassignedDevice {
// Get device info using lsblk
cmd := exec.Command("lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,SERIAL,MODEL", "/dev/"+device) // #nosec G204 - device is validated from lsblk output
output, err := cmd.Output()
if err != nil {
logger.Debug("Failed to get info for device %s: %v", device, err)
return nil
}
var lsblkOutput struct {
BlockDevices []struct {
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"`
MountPoint string `json:"mountpoint"`
FSType string `json:"fstype"`
Label string `json:"label"`
Serial string `json:"serial"`
Model string `json:"model"`
Children []struct {
Name string `json:"name"`
Size string `json:"size"`
Type string `json:"type"`
MountPoint string `json:"mountpoint"`
FSType string `json:"fstype"`
Label string `json:"label"`
} `json:"children"`
} `json:"blockdevices"`
}
if err := json.Unmarshal(output, &lsblkOutput); err != nil {
logger.Debug("Failed to parse lsblk output for %s: %v", device, err)
return nil
}
if len(lsblkOutput.BlockDevices) == 0 {
return nil
}
blockDev := lsblkOutput.BlockDevices[0]
unassignedDevice := &dto.UnassignedDevice{
Device: blockDev.Name,
SerialNumber: blockDev.Serial,
Model: blockDev.Model,
Identification: blockDev.Model,
Status: "unmounted",
SpinState: "unknown",
AutoMount: false,
PassThrough: false,
DisableMount: false,
ScriptEnabled: false,
Timestamp: time.Now(),
}
// Process partitions
var partitions []dto.UnassignedPartition
for i, child := range blockDev.Children {
partition := dto.UnassignedPartition{
PartitionNumber: i + 1,
Label: child.Label,
FileSystem: child.FSType,
MountPoint: child.MountPoint,
ReadOnly: false,
SMBShare: false,
NFSShare: false,
Status: "unmounted",
}
if child.MountPoint != "" {
partition.Status = "mounted"
unassignedDevice.Status = "mounted"
// Get partition size info if mounted
c.getPartitionSizeInfo(&partition, child.MountPoint)
}
partitions = append(partitions, partition)
}
unassignedDevice.Partitions = partitions
return unassignedDevice
}
// getPartitionSizeInfo retrieves size information for a mounted partition
func (c *UnassignedCollector) getPartitionSizeInfo(partition *dto.UnassignedPartition, mountPoint string) {
cmd := exec.Command("df", "-B1", mountPoint)
output, err := cmd.Output()
if err != nil {
return
}
lines := strings.Split(string(output), "\n")
if len(lines) < 2 {
return
}
fields := strings.Fields(lines[1])
if len(fields) < 6 {
return
}
// Parse size, used, free
size := lib.ParseUint64(fields[1])
used := lib.ParseUint64(fields[2])
free := lib.ParseUint64(fields[3])
partition.Size = size
partition.Used = used
partition.Free = free
// Calculate usage percent
if size > 0 {
partition.UsagePercent = float64(used) / float64(size) * 100.0
}
}
// parseSMBMounts parses SMB mount configuration
func (c *UnassignedCollector) parseSMBMounts() []dto.UnassignedRemoteShare {
configPath := "/boot/config/plugins/unassigned.devices/samba_mount.cfg"
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return []dto.UnassignedRemoteShare{}
}
// For now, return empty list - full implementation would parse the config file
return []dto.UnassignedRemoteShare{}
}
// parseISOMounts parses ISO mount configuration
func (c *UnassignedCollector) parseISOMounts() []dto.UnassignedRemoteShare {
configPath := "/boot/config/plugins/unassigned.devices/iso_mount.cfg"
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return []dto.UnassignedRemoteShare{}
}
// Check if any ISO files are mounted
mounts, err := os.ReadFile("/proc/mounts")
if err != nil {
return []dto.UnassignedRemoteShare{}
}
var isoShares []dto.UnassignedRemoteShare
lines := strings.Split(string(mounts), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
// Check if it's an ISO mount (loop device mounted under /mnt/disks/)
if strings.HasPrefix(fields[0], "/dev/loop") && strings.HasPrefix(fields[1], "/mnt/disks/") {
share := dto.UnassignedRemoteShare{
Type: "iso",
Source: fields[0],
MountPoint: fields[1],
Status: "mounted",
ReadOnly: true,
AutoMount: false,
Timestamp: time.Now(),
}
// Get size info
c.getRemoteShareSizeInfo(&share, fields[1])
isoShares = append(isoShares, share)
}
}
return isoShares
}
// getRemoteShareSizeInfo retrieves size information for a remote share
func (c *UnassignedCollector) getRemoteShareSizeInfo(share *dto.UnassignedRemoteShare, mountPoint string) {
cmd := exec.Command("df", "-B1", mountPoint)
output, err := cmd.Output()
if err != nil {
return
}
lines := strings.Split(string(output), "\n")
if len(lines) < 2 {
return
}
fields := strings.Fields(lines[1])
if len(fields) < 6 {
return
}
// Parse size, used, free
size := lib.ParseUint64(fields[1])
used := lib.ParseUint64(fields[2])
free := lib.ParseUint64(fields[3])
share.Size = size
share.Used = used
share.Free = free
// Calculate usage percent
if size > 0 {
share.UsagePercent = float64(used) / float64(size) * 100.0
}
}
package collectors
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// UPSCollector collects UPS (Uninterruptible Power Supply) status information.
// It supports both apcupsd and NUT (Network UPS Tools) monitoring systems.
type UPSCollector struct {
ctx *domain.Context
}
// NewUPSCollector creates a new UPS status collector with the given context.
func NewUPSCollector(ctx *domain.Context) *UPSCollector {
return &UPSCollector{ctx: ctx}
}
// Start begins the UPS collector's periodic data collection.
// It runs in a goroutine and publishes UPS status updates at the specified interval until the context is cancelled.
func (c *UPSCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting ups collector (interval: %v)", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("UPS collector stopping due to context cancellation")
return
case <-ticker.C:
c.Collect()
}
}
}
// Collect gathers UPS status information and publishes it to the event bus.
// It attempts to collect data from apcupsd first, then falls back to NUT if apcupsd is not available.
func (c *UPSCollector) Collect() {
logger.Debug("Collecting ups data...")
// Try apcaccess first (APC UPS)
var upsData *dto.UPSStatus
var err error
if lib.CommandExists("apcaccess") {
upsData, err = c.collectAPC()
if err == nil {
c.ctx.Hub.Pub(upsData, "ups_status_update")
logger.Debug("Published ups_status_update event (APC)")
return
}
logger.Warning("Failed to collect APC UPS data", "error", err)
}
// Fallback to upsc (NUT - Network UPS Tools)
if lib.CommandExists("upsc") {
upsData, err = c.collectNUT()
if err == nil {
c.ctx.Hub.Pub(upsData, "ups_status_update")
logger.Debug("Published ups_status_update event (NUT)")
return
}
logger.Warning("Failed to collect NUT UPS data", "error", err)
}
// No UPS available
logger.Debug("No UPS detected or configured")
}
func (c *UPSCollector) collectAPC() (*dto.UPSStatus, error) {
output, err := lib.ExecCommandOutput("apcaccess")
if err != nil {
return nil, err
}
status := &dto.UPSStatus{
Connected: true,
Timestamp: time.Now(),
}
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "STATUS":
status.Status = value
case "LOADPCT":
if strings.HasSuffix(value, "Percent") {
value = strings.TrimSuffix(value, " Percent")
}
if load, err := strconv.ParseFloat(value, 64); err == nil {
status.LoadPercent = load
}
case "BCHARGE":
if strings.HasSuffix(value, "Percent") {
value = strings.TrimSuffix(value, " Percent")
}
if charge, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryCharge = charge
}
case "TIMELEFT":
if strings.HasSuffix(value, "Minutes") {
value = strings.TrimSuffix(value, " Minutes")
}
if minutes, err := strconv.ParseFloat(value, 64); err == nil {
status.RuntimeLeft = int(minutes * 60) // Convert minutes to seconds
}
case "NOMPOWER":
// Parse nominal power (e.g., "800 Watts")
if strings.HasSuffix(value, "Watts") {
value = strings.TrimSuffix(value, " Watts")
}
if power, err := strconv.ParseFloat(value, 64); err == nil {
status.NominalPower = power
}
case "LINEV":
if strings.HasSuffix(value, "Volts") {
value = strings.TrimSuffix(value, " Volts")
}
// InputVoltage field not in DTO, parsing for potential future use
_, _ = strconv.ParseFloat(value, 64)
case "BATTV":
if strings.HasSuffix(value, "Volts") {
value = strings.TrimSuffix(value, " Volts")
}
// BatteryVoltage field not in DTO, parsing for potential future use
_, _ = strconv.ParseFloat(value, 64)
case "MODEL":
status.Model = value
}
}
// Calculate actual power consumption from load percentage and nominal power
if status.NominalPower > 0 && status.LoadPercent > 0 {
status.PowerWatts = status.NominalPower * status.LoadPercent / 100.0
}
return status, nil
}
func (c *UPSCollector) collectNUT() (*dto.UPSStatus, error) {
// First, get list of UPS devices (try localhost first, then without host)
output, err := lib.ExecCommandOutput("upsc", "-l", "localhost")
if err != nil {
output, err = lib.ExecCommandOutput("upsc", "-l")
if err != nil {
return nil, err
}
}
devices := strings.Split(strings.TrimSpace(output), "\n")
if len(devices) == 0 || devices[0] == "" {
return nil, fmt.Errorf("no UPS devices found")
}
// Use first device with @localhost suffix for NUT protocol
device := devices[0] + "@localhost"
// Get device status
output, err = lib.ExecCommandOutput("upsc", device)
if err != nil {
return nil, err
}
status := &dto.UPSStatus{
Connected: true,
Timestamp: time.Now(),
}
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "ups.status":
status.Status = value
case "ups.load":
if load, err := strconv.ParseFloat(value, 64); err == nil {
status.LoadPercent = load
}
case "battery.charge":
if charge, err := strconv.ParseFloat(value, 64); err == nil {
status.BatteryCharge = charge
}
case "battery.runtime":
if seconds, err := strconv.ParseFloat(value, 64); err == nil {
status.RuntimeLeft = int(seconds) // Already in seconds
}
case "ups.power.nominal", "ups.realpower.nominal":
// Parse nominal power (usually in Watts)
if power, err := strconv.ParseFloat(value, 64); err == nil {
status.NominalPower = power
}
case "input.voltage":
// InputVoltage field not in DTO, parsing for potential future use
_, _ = strconv.ParseFloat(value, 64)
case "battery.voltage":
// BatteryVoltage field not in DTO, parsing for potential future use
_, _ = strconv.ParseFloat(value, 64)
case "device.model", "ups.model":
status.Model = value
}
}
// Calculate actual power consumption from load percentage and nominal power
if status.NominalPower > 0 && status.LoadPercent > 0 {
status.PowerWatts = status.NominalPower * status.LoadPercent / 100.0
}
return status, nil
}
package collectors
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// cpuStats holds CPU usage tracking data for a VM
type cpuStats struct {
guestCPUTime uint64 // Cumulative guest CPU time in nanoseconds
hostCPUTime uint64 // Cumulative host CPU time in clock ticks
timestamp time.Time // When this measurement was taken
}
// VMCollector collects information about virtual machines managed by libvirt/virsh.
// It gathers VM status, resource allocation, CPU usage, and configuration details.
type VMCollector struct {
ctx *domain.Context
cpuStatsMutex sync.RWMutex
previousStats map[string]*cpuStats // vmName -> previous CPU stats
}
// NewVMCollector creates a new virtual machine collector with the given context.
func NewVMCollector(ctx *domain.Context) *VMCollector {
return &VMCollector{
ctx: ctx,
previousStats: make(map[string]*cpuStats),
}
}
// Start begins the VM collector's periodic data collection.
// It runs in a goroutine and publishes VM information updates at the specified interval until the context is cancelled.
func (c *VMCollector) Start(ctx context.Context, interval time.Duration) {
logger.Info("Starting vm collector (interval: %v)", interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
logger.Info("VM collector stopping due to context cancellation")
return
case <-ticker.C:
c.Collect()
}
}
}
// Collect gathers virtual machine information and publishes it to the event bus.
// It uses virsh to query VM status and calculates CPU usage based on previous measurements.
func (c *VMCollector) Collect() {
logger.Debug("Collecting vm data...")
// Check if virsh is available
if !lib.CommandExists("virsh") {
logger.Warning("virsh command not found, skipping collection")
return
}
// Collect VM information
vms, err := c.collectVMs()
if err != nil {
logger.Error("Failed to collect VMs: %v", err)
return
}
// Publish event
c.ctx.Hub.Pub(vms, "vm_list_update")
logger.Debug("Published vm_list_update event with %d VMs", len(vms))
}
func (c *VMCollector) collectVMs() ([]*dto.VMInfo, error) {
// Get list of all VM names (one per line)
// This approach handles VM names with spaces correctly
output, err := lib.ExecCommandOutput("virsh", "list", "--all", "--name")
if err != nil {
return nil, fmt.Errorf("failed to list VMs: %w", err)
}
lines := strings.Split(output, "\n")
vms := make([]*dto.VMInfo, 0)
for _, line := range lines {
vmName := strings.TrimSpace(line)
if vmName == "" {
continue
}
// Get VM state
vmState, err := c.getVMState(vmName)
if err != nil {
logger.Warning("Failed to get state for VM %s: %v", vmName, err)
continue
}
// Get VM UUID (stable identifier for all VM states)
vmID := c.getVMID(vmName)
vm := &dto.VMInfo{
ID: vmID,
Name: vmName,
State: vmState,
Timestamp: time.Now(),
}
// Get detailed info for this VM
if info, err := c.getVMInfo(vmName); err == nil {
vm.CPUCount = info.CPUCount
vm.MemoryAllocated = info.MemoryAllocated
vm.Autostart = info.Autostart
vm.PersistentState = info.PersistentState
}
// Get memory usage if running
if strings.Contains(strings.ToLower(vmState), "running") {
if memUsed, err := c.getVMMemoryUsage(vmName); err == nil {
vm.MemoryUsed = memUsed
}
// Get CPU usage (pass number of vCPUs for percentage calculation)
if vm.CPUCount > 0 {
if guestCPU, hostCPU, err := c.getVMCPUUsage(vmName, vm.CPUCount); err == nil {
vm.GuestCPUPercent = guestCPU
vm.HostCPUPercent = hostCPU
} else {
logger.Debug("Failed to get CPU usage for VM %s: %v", vmName, err)
}
}
// Get disk I/O stats
if readBytes, writeBytes, err := c.getVMDiskIO(vmName); err == nil {
vm.DiskReadBytes = readBytes
vm.DiskWriteBytes = writeBytes
}
// Get network I/O stats
if rxBytes, txBytes, err := c.getVMNetworkIO(vmName); err == nil {
vm.NetworkRXBytes = rxBytes
vm.NetworkTXBytes = txBytes
}
} else {
// VM is not running, clear CPU stats history
c.clearCPUStats(vmName)
}
// Format memory display
vm.MemoryDisplay = c.formatMemoryDisplay(vm.MemoryUsed, vm.MemoryAllocated)
vms = append(vms, vm)
}
return vms, nil
}
// clearCPUStats removes CPU stats history for a VM (called when VM is shut off)
func (c *VMCollector) clearCPUStats(vmName string) {
c.cpuStatsMutex.Lock()
defer c.cpuStatsMutex.Unlock()
delete(c.previousStats, vmName)
}
type vmInfo struct {
CPUCount int
MemoryAllocated uint64
Autostart bool
PersistentState bool
}
// getVMState returns the state of a VM (e.g., "running", "shut off", "paused")
func (c *VMCollector) getVMState(vmName string) (string, error) {
output, err := lib.ExecCommandOutput("virsh", "domstate", vmName)
if err != nil {
return "", fmt.Errorf("failed to get VM state: %w", err)
}
return strings.TrimSpace(output), nil
}
// getVMID returns the UUID of a VM (stable identifier that works for all VM states)
func (c *VMCollector) getVMID(vmName string) string {
output, err := lib.ExecCommandOutput("virsh", "domuuid", vmName)
if err != nil {
// Fallback to using VM name as ID if UUID is not available
return vmName
}
uuid := strings.TrimSpace(output)
if uuid == "" {
// Fallback to using VM name as ID
return vmName
}
return uuid
}
func (c *VMCollector) getVMInfo(vmName string) (*vmInfo, error) {
output, err := lib.ExecCommandOutput("virsh", "dominfo", vmName)
if err != nil {
return nil, err
}
info := &vmInfo{}
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "CPU(s)":
if cpu, err := strconv.Atoi(value); err == nil {
info.CPUCount = cpu
}
case "Max memory":
// Value is in KiB
// Extract number before " KiB"
if memStr := strings.Fields(value); len(memStr) > 0 {
if mem, err := strconv.ParseUint(memStr[0], 10, 64); err == nil {
info.MemoryAllocated = mem * 1024 // Convert KiB to bytes
}
}
case "Autostart":
info.Autostart = strings.ToLower(value) == "enable"
case "Persistent":
info.PersistentState = strings.ToLower(value) == "yes"
}
}
return info, nil
}
func (c *VMCollector) getVMMemoryUsage(vmName string) (uint64, error) {
output, err := lib.ExecCommandOutput("virsh", "dommemstat", vmName)
if err != nil {
return 0, err
}
// Parse output for actual memory usage
// Format: "actual 4194304" (in KiB)
re := regexp.MustCompile(`actual\s+(\d+)`)
if matches := re.FindStringSubmatch(output); len(matches) > 1 {
if mem, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
return mem * 1024, nil // Convert KiB to bytes
}
}
// Fallback: look for rss (resident set size)
re = regexp.MustCompile(`rss\s+(\d+)`)
if matches := re.FindStringSubmatch(output); len(matches) > 1 {
if mem, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
return mem * 1024, nil // Convert KiB to bytes
}
}
return 0, nil
}
// getVMCPUUsage returns guest and host CPU usage percentages
func (c *VMCollector) getVMCPUUsage(vmName string, numVCPUs int) (float64, float64, error) {
currentTime := time.Now()
// Get guest CPU time from virsh domstats
guestCPUTime, err := c.getGuestCPUTime(vmName)
if err != nil {
return 0, 0, fmt.Errorf("failed to get guest CPU time: %w", err)
}
// Get host CPU time from QEMU process
hostCPUTime, err := c.getHostCPUTime(vmName)
if err != nil {
// Host CPU might not be available, log but don't fail
logger.Debug("Failed to get host CPU time for VM %s: %v", vmName, err)
hostCPUTime = 0
}
// Calculate percentages using historical data
c.cpuStatsMutex.Lock()
defer c.cpuStatsMutex.Unlock()
var guestCPUPercent, hostCPUPercent float64
if prevStats, exists := c.previousStats[vmName]; exists {
// Calculate time delta in seconds
timeDelta := currentTime.Sub(prevStats.timestamp).Seconds()
if timeDelta > 0 {
// Calculate guest CPU percentage
// Guest CPU time is in nanoseconds, convert to seconds
guestCPUDelta := float64(guestCPUTime-prevStats.guestCPUTime) / 1e9
guestCPUPercent = (guestCPUDelta / timeDelta / float64(numVCPUs)) * 100
// Clamp to valid range [0, 100]
if guestCPUPercent < 0 {
guestCPUPercent = 0
} else if guestCPUPercent > 100 {
guestCPUPercent = 100
}
// Calculate host CPU percentage if available
if hostCPUTime > 0 && prevStats.hostCPUTime > 0 {
// Host CPU time is in clock ticks, need to convert
// Clock ticks per second (typically 100 on Linux)
clockTicksPerSec := 100.0
hostCPUDelta := float64(hostCPUTime-prevStats.hostCPUTime) / clockTicksPerSec
hostCPUPercent = (hostCPUDelta / timeDelta) * 100
// Clamp to valid range [0, 100]
if hostCPUPercent < 0 {
hostCPUPercent = 0
} else if hostCPUPercent > 100 {
hostCPUPercent = 100
}
}
}
}
// Store current stats for next calculation
c.previousStats[vmName] = &cpuStats{
guestCPUTime: guestCPUTime,
hostCPUTime: hostCPUTime,
timestamp: currentTime,
}
return guestCPUPercent, hostCPUPercent, nil
}
// getGuestCPUTime returns cumulative guest CPU time in nanoseconds
func (c *VMCollector) getGuestCPUTime(vmName string) (uint64, error) {
output, err := lib.ExecCommandOutput("virsh", "domstats", vmName, "--cpu-total")
if err != nil {
return 0, err
}
// Parse cpu.time from output
// Format: "cpu.time=123456789"
re := regexp.MustCompile(`cpu\.time=(\d+)`)
matches := re.FindStringSubmatch(output)
if len(matches) < 2 {
return 0, fmt.Errorf("failed to parse cpu.time from domstats output")
}
cpuTime, err := strconv.ParseUint(matches[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse cpu time value: %w", err)
}
return cpuTime, nil
}
// getHostCPUTime returns cumulative host CPU time in clock ticks for the QEMU process
func (c *VMCollector) getHostCPUTime(vmName string) (uint64, error) {
// Get QEMU process PID
pid, err := c.getQEMUProcessPID(vmName)
if err != nil {
return 0, err
}
// Read /proc/[pid]/stat
output, err := lib.ExecCommandOutput("cat", fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
return 0, fmt.Errorf("failed to read /proc/%d/stat: %w", pid, err)
}
// Parse /proc/[pid]/stat
// Format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime ...
// We need utime (field 14) + stime (field 15)
fields := strings.Fields(output)
if len(fields) < 15 {
return 0, fmt.Errorf("unexpected /proc/stat format")
}
// utime is at index 13 (0-based), stime at index 14
utime, err := strconv.ParseUint(fields[13], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse utime: %w", err)
}
stime, err := strconv.ParseUint(fields[14], 10, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse stime: %w", err)
}
// Total CPU time = utime + stime
return utime + stime, nil
}
// getQEMUProcessPID returns the PID of the QEMU process for a VM
func (c *VMCollector) getQEMUProcessPID(vmName string) (int, error) {
// Use pgrep to find QEMU process with VM name
output, err := lib.ExecCommandOutput("pgrep", "-f", fmt.Sprintf("qemu.*guest=%s", vmName))
if err != nil {
return 0, fmt.Errorf("failed to find QEMU process for VM %s: %w", vmName, err)
}
pidStr := strings.TrimSpace(output)
if pidStr == "" {
return 0, fmt.Errorf("no QEMU process found for VM %s", vmName)
}
// If multiple PIDs, take the first one
pidStr = strings.Split(pidStr, "\n")[0]
pid, err := strconv.Atoi(pidStr)
if err != nil {
return 0, fmt.Errorf("failed to parse PID: %w", err)
}
return pid, nil
}
// getVMDiskIO returns disk read and write bytes
func (c *VMCollector) getVMDiskIO(vmName string) (uint64, uint64, error) {
// Get list of disk devices
output, err := lib.ExecCommandOutput("virsh", "domblklist", vmName)
if err != nil {
return 0, 0, err
}
var totalRead, totalWrite uint64
lines := strings.Split(output, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 2 || fields[0] == "Target" {
continue
}
device := fields[0]
stats, err := lib.ExecCommandOutput("virsh", "domblkstat", vmName, device)
if err != nil {
continue
}
// Parse read and write bytes
// Format: "rd_bytes 123456"
reRead := regexp.MustCompile(`rd_bytes\s+(\d+)`)
if matches := reRead.FindStringSubmatch(stats); len(matches) > 1 {
if bytes, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
totalRead += bytes
}
}
reWrite := regexp.MustCompile(`wr_bytes\s+(\d+)`)
if matches := reWrite.FindStringSubmatch(stats); len(matches) > 1 {
if bytes, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
totalWrite += bytes
}
}
}
return totalRead, totalWrite, nil
}
// getVMNetworkIO returns network RX and TX bytes
func (c *VMCollector) getVMNetworkIO(vmName string) (uint64, uint64, error) {
// Get list of network interfaces
output, err := lib.ExecCommandOutput("virsh", "domiflist", vmName)
if err != nil {
return 0, 0, err
}
var totalRX, totalTX uint64
lines := strings.Split(output, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 1 || fields[0] == "Interface" || fields[0] == "-" {
continue
}
iface := fields[0]
stats, err := lib.ExecCommandOutput("virsh", "domifstat", vmName, iface)
if err != nil {
continue
}
// Parse RX and TX bytes
// Format: "rx_bytes 123456"
reRX := regexp.MustCompile(`rx_bytes\s+(\d+)`)
if matches := reRX.FindStringSubmatch(stats); len(matches) > 1 {
if bytes, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
totalRX += bytes
}
}
reTX := regexp.MustCompile(`tx_bytes\s+(\d+)`)
if matches := reTX.FindStringSubmatch(stats); len(matches) > 1 {
if bytes, err := strconv.ParseUint(matches[1], 10, 64); err == nil {
totalTX += bytes
}
}
}
return totalRX, totalTX, nil
}
// formatMemoryDisplay formats memory usage as "used / allocated"
func (c *VMCollector) formatMemoryDisplay(used, allocated uint64) string {
if allocated == 0 {
return "0 / 0"
}
usedGB := float64(used) / (1024 * 1024 * 1024)
allocatedGB := float64(allocated) / (1024 * 1024 * 1024)
return fmt.Sprintf("%.2f GB / %.2f GB", usedGB, allocatedGB)
}
package collectors
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// ZFSCollector collects ZFS pool, dataset, and ARC statistics
type ZFSCollector struct {
ctx *domain.Context
}
// NewZFSCollector creates a new ZFS collector
func NewZFSCollector(ctx *domain.Context) *ZFSCollector {
return &ZFSCollector{ctx: ctx}
}
// Start begins the ZFS collection loop
func (c *ZFSCollector) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
logger.Info("ZFS collector started", "interval", interval)
// Collect immediately on start
c.collect()
for {
select {
case <-ctx.Done():
logger.Info("ZFS collector stopped")
return
case <-ticker.C:
c.collect()
}
}
}
// collect gathers all ZFS data and publishes events
func (c *ZFSCollector) collect() {
defer func() {
if r := recover(); r != nil {
logger.Error("ZFS collector panic recovered", "error", r)
}
}()
// Check if ZFS is available
if !c.isZFSAvailable() {
logger.Debug("ZFS not available, skipping collection")
return
}
// Collect pools
pools, err := c.collectPools()
if err != nil {
logger.Warning("Failed to collect ZFS pools", "error", err)
} else if len(pools) > 0 {
c.ctx.Hub.Pub(pools, "zfs_pools_update")
logger.Debug("Published ZFS pools update", "count", len(pools))
}
// Collect datasets
datasets, err := c.collectDatasets()
if err != nil {
logger.Warning("Failed to collect ZFS datasets", "error", err)
} else if len(datasets) > 0 {
c.ctx.Hub.Pub(datasets, "zfs_datasets_update")
logger.Debug("Published ZFS datasets update", "count", len(datasets))
}
// Collect snapshots
snapshots, err := c.collectSnapshots()
if err != nil {
logger.Warning("Failed to collect ZFS snapshots", "error", err)
} else if len(snapshots) > 0 {
c.ctx.Hub.Pub(snapshots, "zfs_snapshots_update")
logger.Debug("Published ZFS snapshots update", "count", len(snapshots))
}
// Collect ARC stats
arcStats, err := c.collectARCStats()
if err != nil {
logger.Warning("Failed to collect ZFS ARC stats", "error", err)
} else {
c.ctx.Hub.Pub(arcStats, "zfs_arc_stats_update")
logger.Debug("Published ZFS ARC stats update")
}
}
// isZFSAvailable checks if ZFS kernel module is loaded and binaries exist
func (c *ZFSCollector) isZFSAvailable() bool {
// Check if zpool binary exists
if _, err := os.Stat(constants.ZpoolBin); os.IsNotExist(err) {
return false
}
// Try to execute zpool list to verify ZFS is functional
_, err := lib.ExecCommandOutput(constants.ZpoolBin, "list", "-H")
return err == nil
}
// collectPools collects information about all ZFS pools
func (c *ZFSCollector) collectPools() ([]dto.ZFSPool, error) {
// Get list of pool names
output, err := lib.ExecCommandOutput(constants.ZpoolBin, "list", "-H", "-o", "name")
if err != nil {
return nil, fmt.Errorf("failed to list pools: %w", err)
}
poolNames := strings.Split(strings.TrimSpace(output), "\n")
if len(poolNames) == 0 || poolNames[0] == "" {
return []dto.ZFSPool{}, nil
}
pools := make([]dto.ZFSPool, 0, len(poolNames))
for _, name := range poolNames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
pool, err := c.collectPoolDetails(name)
if err != nil {
logger.Warning("Failed to collect pool details", "pool", name, "error", err)
continue
}
pools = append(pools, pool)
}
return pools, nil
}
// collectPoolDetails collects detailed information about a specific pool
func (c *ZFSCollector) collectPoolDetails(name string) (dto.ZFSPool, error) {
pool := dto.ZFSPool{
Name: name,
Timestamp: time.Now(),
}
// Get basic pool info (parseable format)
// Fields: name, size, allocated, free, fragmentation, capacity, dedupratio, health, altroot
output, err := lib.ExecCommandOutput(constants.ZpoolBin, "list", "-Hp", "-o",
"name,size,allocated,free,fragmentation,capacity,dedupratio,health,altroot", name)
if err != nil {
return pool, fmt.Errorf("failed to get pool info: %w", err)
}
// Parse tab-separated values
fields := strings.Split(strings.TrimSpace(output), "\t")
if len(fields) < 9 {
return pool, fmt.Errorf("unexpected pool info format: got %d fields", len(fields))
}
pool.SizeBytes, _ = strconv.ParseUint(fields[1], 10, 64)
pool.AllocatedBytes, _ = strconv.ParseUint(fields[2], 10, 64)
pool.FreeBytes, _ = strconv.ParseUint(fields[3], 10, 64)
// Parse fragmentation and capacity (can be "-" if not available)
if fields[4] != "-" {
pool.FragmentationPct, _ = strconv.ParseFloat(fields[4], 64)
}
if fields[5] != "-" {
pool.CapacityPct, _ = strconv.ParseFloat(fields[5], 64)
}
// Parse dedup ratio (format: "1.00x" or "1.00")
dedupStr := strings.TrimSuffix(fields[6], "x")
pool.DedupRatio, _ = strconv.ParseFloat(dedupStr, 64)
pool.Health = fields[7]
// Altroot (can be "-" if not set)
if fields[8] != "-" {
pool.Altroot = fields[8]
}
// Get pool properties for additional details
if err := c.enrichPoolProperties(&pool); err != nil {
logger.Warning("Failed to enrich pool properties", "pool", name, "error", err)
}
// Get pool status (vdevs, errors, scrub info)
if err := c.parsePoolStatus(&pool); err != nil {
logger.Warning("Failed to parse pool status", "pool", name, "error", err)
}
return pool, nil
}
// enrichPoolProperties adds additional properties from 'zpool get all'
func (c *ZFSCollector) enrichPoolProperties(pool *dto.ZFSPool) error {
output, err := lib.ExecCommandOutput(constants.ZpoolBin, "get", "-Hp", "-o", "property,value",
"guid,readonly,autoexpand,autotrim", pool.Name)
if err != nil {
return err
}
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
property := fields[0]
value := fields[1]
switch property {
case "guid":
pool.GUID = value
case "readonly":
pool.Readonly = value == "on"
case "autoexpand":
pool.Autoexpand = value == "on"
case "autotrim":
pool.Autotrim = value
}
}
return scanner.Err()
}
// parsePoolStatus parses 'zpool status' output for vdevs, errors, and scrub info
func (c *ZFSCollector) parsePoolStatus(pool *dto.ZFSPool) error {
output, err := lib.ExecCommandOutput(constants.ZpoolBin, "status", "-v", pool.Name)
if err != nil {
return err
}
scanner := bufio.NewScanner(strings.NewReader(output))
inConfig := false
var currentVdev *dto.ZFSVdev
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Parse state
if state, found := strings.CutPrefix(trimmed, "state:"); found {
pool.State = strings.TrimSpace(state)
}
// Parse scan/scrub info
if strings.HasPrefix(trimmed, "scan:") {
c.parseScanInfo(pool, trimmed)
}
// Parse errors line
if strings.HasPrefix(trimmed, "errors:") {
// Error summary is in the line, but individual errors are in vdev stats
continue
}
// Parse config section (vdev tree)
if strings.HasPrefix(trimmed, "config:") {
inConfig = true
continue
}
if inConfig && trimmed != "" && !strings.HasPrefix(trimmed, "NAME") {
// Parse vdev line
vdev := c.parseVdevLine(line)
if vdev != nil {
// Determine if this is a top-level vdev or a device
indent := len(line) - len(strings.TrimLeft(line, "\t "))
if indent <= 1 {
// Top-level vdev (pool itself)
pool.ReadErrors = vdev.ReadErrors
pool.WriteErrors = vdev.WriteErrors
pool.ChecksumErrors = vdev.ChecksumErrors
} else if indent <= 3 {
// Mid-level vdev (raidz, mirror, etc.)
if currentVdev != nil {
pool.VDEVs = append(pool.VDEVs, *currentVdev)
}
currentVdev = vdev
} else {
// Device within a vdev
if currentVdev != nil {
device := dto.ZFSDevice{
Name: vdev.Name,
State: vdev.State,
ReadErrors: vdev.ReadErrors,
WriteErrors: vdev.WriteErrors,
ChecksumErrors: vdev.ChecksumErrors,
}
currentVdev.Devices = append(currentVdev.Devices, device)
}
}
}
}
}
// Add last vdev if exists
if currentVdev != nil {
pool.VDEVs = append(pool.VDEVs, *currentVdev)
}
return scanner.Err()
}
// parseScanInfo parses scrub/resilver information from status output
func (c *ZFSCollector) parseScanInfo(pool *dto.ZFSPool, line string) {
// Example: "scan: scrub repaired 0B in 00:00:01 with 0 errors on Sun Nov 10 02:39:43 2025"
// Example: "scan: scrub in progress since Sun Nov 10 02:39:43 2025"
line = strings.TrimPrefix(line, "scan:")
line = strings.TrimSpace(line)
if strings.Contains(line, "in progress") {
pool.ScanStatus = "in progress"
pool.ScanState = "scanning"
} else if strings.Contains(line, "scrub repaired") {
pool.ScanStatus = "scrub completed"
pool.ScanState = "finished"
// Try to parse "with X errors"
if strings.Contains(line, "with") && strings.Contains(line, "errors") {
parts := strings.Split(line, "with")
if len(parts) > 1 {
errorPart := strings.TrimSpace(parts[1])
errorFields := strings.Fields(errorPart)
if len(errorFields) > 0 {
pool.ScanErrors, _ = strconv.Atoi(errorFields[0])
}
}
}
} else if strings.Contains(line, "resilver") {
pool.ScanStatus = "resilver in progress"
pool.ScanState = "scanning"
}
}
// parseVdevLine parses a single vdev line from zpool status output
// Format: "NAME STATE READ WRITE CKSUM"
// Example: " sdg1 ONLINE 0 0 0"
func (c *ZFSCollector) parseVdevLine(line string) *dto.ZFSVdev {
fields := strings.Fields(line)
if len(fields) < 5 {
return nil
}
vdev := &dto.ZFSVdev{
Name: fields[0],
State: fields[1],
}
// Determine vdev type based on name
if strings.Contains(vdev.Name, "raidz1") {
vdev.Type = "raidz1"
} else if strings.Contains(vdev.Name, "raidz2") {
vdev.Type = "raidz2"
} else if strings.Contains(vdev.Name, "raidz3") {
vdev.Type = "raidz3"
} else if strings.Contains(vdev.Name, "mirror") {
vdev.Type = "mirror"
} else if strings.Contains(vdev.Name, "spare") {
vdev.Type = "spare"
} else if strings.Contains(vdev.Name, "cache") {
vdev.Type = "cache"
} else if strings.Contains(vdev.Name, "log") {
vdev.Type = "log"
} else {
vdev.Type = "disk"
}
vdev.ReadErrors, _ = strconv.ParseUint(fields[2], 10, 64)
vdev.WriteErrors, _ = strconv.ParseUint(fields[3], 10, 64)
vdev.ChecksumErrors, _ = strconv.ParseUint(fields[4], 10, 64)
return vdev
}
// collectDatasets collects information about all ZFS datasets
func (c *ZFSCollector) collectDatasets() ([]dto.ZFSDataset, error) {
// Get all datasets across all pools
// Fields: name, type, used, available, referenced, compressratio, mountpoint, quota, reservation, compression, readonly
output, err := lib.ExecCommandOutput(constants.ZfsBin, "list", "-Hp", "-o",
"name,type,used,available,referenced,compressratio,mountpoint,quota,reservation,compression,readonly")
if err != nil {
return nil, fmt.Errorf("failed to list datasets: %w", err)
}
lines := strings.Split(strings.TrimSpace(output), "\n")
datasets := make([]dto.ZFSDataset, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
dataset := c.parseDatasetLine(line)
if dataset != nil {
datasets = append(datasets, *dataset)
}
}
return datasets, nil
}
// parseDatasetLine parses a single dataset line from zfs list output
func (c *ZFSCollector) parseDatasetLine(line string) *dto.ZFSDataset {
fields := strings.Split(line, "\t")
if len(fields) < 11 {
return nil
}
dataset := &dto.ZFSDataset{
Name: fields[0],
Type: fields[1],
Timestamp: time.Now(),
}
dataset.UsedBytes, _ = strconv.ParseUint(fields[2], 10, 64)
dataset.AvailableBytes, _ = strconv.ParseUint(fields[3], 10, 64)
dataset.ReferencedBytes, _ = strconv.ParseUint(fields[4], 10, 64)
// Parse compression ratio (format: "1.00x" or "1.00")
compressStr := strings.TrimSuffix(fields[5], "x")
dataset.CompressRatio, _ = strconv.ParseFloat(compressStr, 64)
if fields[6] != "-" {
dataset.Mountpoint = fields[6]
}
dataset.QuotaBytes, _ = strconv.ParseUint(fields[7], 10, 64)
dataset.ReservationBytes, _ = strconv.ParseUint(fields[8], 10, 64)
dataset.Compression = fields[9]
dataset.Readonly = fields[10] == "on"
return dataset
}
// collectSnapshots collects information about all ZFS snapshots
func (c *ZFSCollector) collectSnapshots() ([]dto.ZFSSnapshot, error) {
// Get all snapshots
// Fields: name, used, referenced, creation
output, err := lib.ExecCommandOutput(constants.ZfsBin, "list", "-t", "snapshot", "-Hp", "-o",
"name,used,referenced,creation")
if err != nil {
// No snapshots is not an error
if strings.Contains(err.Error(), "no datasets available") {
return []dto.ZFSSnapshot{}, nil
}
return nil, fmt.Errorf("failed to list snapshots: %w", err)
}
lines := strings.Split(strings.TrimSpace(output), "\n")
snapshots := make([]dto.ZFSSnapshot, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
snapshot := c.parseSnapshotLine(line)
if snapshot != nil {
snapshots = append(snapshots, *snapshot)
}
}
return snapshots, nil
}
// parseSnapshotLine parses a single snapshot line from zfs list output
func (c *ZFSCollector) parseSnapshotLine(line string) *dto.ZFSSnapshot {
fields := strings.Split(line, "\t")
if len(fields) < 4 {
return nil
}
// Parse snapshot name (format: dataset@snapshot)
parts := strings.Split(fields[0], "@")
if len(parts) != 2 {
return nil
}
snapshot := &dto.ZFSSnapshot{
Name: fields[0],
Dataset: parts[0],
Timestamp: time.Now(),
}
snapshot.UsedBytes, _ = strconv.ParseUint(fields[1], 10, 64)
snapshot.ReferencedBytes, _ = strconv.ParseUint(fields[2], 10, 64)
// Parse creation time (Unix timestamp)
creationUnix, _ := strconv.ParseInt(fields[3], 10, 64)
snapshot.CreationTime = time.Unix(creationUnix, 0)
return snapshot
}
// collectARCStats collects ZFS ARC (Adaptive Replacement Cache) statistics
func (c *ZFSCollector) collectARCStats() (dto.ZFSARCStats, error) {
stats := dto.ZFSARCStats{
Timestamp: time.Now(),
}
// Check if ARC stats file exists
if _, err := os.Stat(constants.ProcSPLARCStats); os.IsNotExist(err) {
return stats, fmt.Errorf("ARC stats file not found: %w", err)
}
// Read ARC stats file
file, err := os.Open(constants.ProcSPLARCStats)
if err != nil {
return stats, fmt.Errorf("failed to open ARC stats file: %w", err)
}
defer file.Close()
// Parse ARC stats (format: "name type data")
scanner := bufio.NewScanner(file)
arcData := make(map[string]uint64)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
name := fields[0]
// type is fields[1], but we don't need it
value, _ := strconv.ParseUint(fields[2], 10, 64)
arcData[name] = value
}
if err := scanner.Err(); err != nil {
return stats, fmt.Errorf("error reading ARC stats: %w", err)
}
// Extract relevant stats
stats.SizeBytes = arcData["size"]
stats.TargetSizeBytes = arcData["c"]
stats.MinSizeBytes = arcData["c_min"]
stats.MaxSizeBytes = arcData["c_max"]
stats.Hits = arcData["hits"]
stats.Misses = arcData["misses"]
// Calculate hit ratio
totalAccesses := stats.Hits + stats.Misses
if totalAccesses > 0 {
stats.HitRatioPct = (float64(stats.Hits) / float64(totalAccesses)) * 100.0
}
// MRU/MFU hit ratios (if available)
mruHits := arcData["mru_hits"]
mfuHits := arcData["mfu_hits"]
if mruHits > 0 || mfuHits > 0 {
mruTotal := mruHits + arcData["mru_ghost_hits"]
mfuTotal := mfuHits + arcData["mfu_ghost_hits"]
if mruTotal > 0 {
stats.MRUHitRatioPct = (float64(mruHits) / float64(mruTotal)) * 100.0
}
if mfuTotal > 0 {
stats.MFUHitRatioPct = (float64(mfuHits) / float64(mfuTotal)) * 100.0
}
}
// L2ARC stats (if available)
stats.L2SizeBytes = arcData["l2_size"]
stats.L2Hits = arcData["l2_hits"]
stats.L2Misses = arcData["l2_misses"]
return stats, nil
}
// Package controllers provides control operations for Unraid system resources.
package controllers
import (
"fmt"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// ArrayController provides control operations for the Unraid array.
// It handles array start/stop, parity check operations, and array management commands.
type ArrayController struct {
ctx *domain.Context
}
// NewArrayController creates a new array controller with the given context.
func NewArrayController(ctx *domain.Context) *ArrayController {
return &ArrayController{ctx: ctx}
}
// StartArray starts the Unraid array
func (c *ArrayController) StartArray() error {
logger.Info("Array: Starting array...")
// Use mdcmd to start the array
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "start")
if err != nil {
logger.Error("Array: Failed to start array: %v", err)
return fmt.Errorf("failed to start array: %w", err)
}
logger.Info("Array: Array started successfully")
return nil
}
// StopArray stops the Unraid array
func (c *ArrayController) StopArray() error {
logger.Info("Array: Stopping array...")
// Use mdcmd to stop the array
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "stop")
if err != nil {
logger.Error("Array: Failed to stop array: %v", err)
return fmt.Errorf("failed to stop array: %w", err)
}
logger.Info("Array: Array stopped successfully")
return nil
}
// StartParityCheck starts a parity check
func (c *ArrayController) StartParityCheck(correcting bool) error {
logger.Info("Array: Starting parity check (correcting: %v)...", correcting)
var mode string
if correcting {
mode = "check CORRECT"
} else {
mode = "check NOCORRECT"
}
// Use mdcmd to start parity check
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", mode)
if err != nil {
logger.Error("Array: Failed to start parity check: %v", err)
return fmt.Errorf("failed to start parity check: %w", err)
}
logger.Info("Array: Parity check started successfully")
return nil
}
// StopParityCheck stops a running parity check
func (c *ArrayController) StopParityCheck() error {
logger.Info("Array: Stopping parity check...")
// Use mdcmd to stop parity check
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "nocheck")
if err != nil {
logger.Error("Array: Failed to stop parity check: %v", err)
return fmt.Errorf("failed to stop parity check: %w", err)
}
logger.Info("Array: Parity check stopped successfully")
return nil
}
// PauseParityCheck pauses a running parity check
func (c *ArrayController) PauseParityCheck() error {
logger.Info("Array: Pausing parity check...")
// Use mdcmd to pause parity check
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "pause")
if err != nil {
logger.Error("Array: Failed to pause parity check: %v", err)
return fmt.Errorf("failed to pause parity check: %w", err)
}
logger.Info("Array: Parity check paused successfully")
return nil
}
// ResumeParityCheck resumes a paused parity check
func (c *ArrayController) ResumeParityCheck() error {
logger.Info("Array: Resuming parity check...")
// Use mdcmd to resume parity check
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "resume")
if err != nil {
logger.Error("Array: Failed to resume parity check: %v", err)
return fmt.Errorf("failed to resume parity check: %w", err)
}
logger.Info("Array: Parity check resumed successfully")
return nil
}
// SpinDownDisk spins down a specific disk
func (c *ArrayController) SpinDownDisk(diskName string) error {
logger.Info("Array: Spinning down disk %s...", diskName)
// Use mdcmd to spin down disk
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "spindown", diskName)
if err != nil {
logger.Error("Array: Failed to spin down disk %s: %v", diskName, err)
return fmt.Errorf("failed to spin down disk: %w", err)
}
logger.Info("Array: Disk %s spun down successfully", diskName)
return nil
}
// SpinUpDisk spins up a specific disk
func (c *ArrayController) SpinUpDisk(diskName string) error {
logger.Info("Array: Spinning up disk %s...", diskName)
// Use mdcmd to spin up disk
_, err := lib.ExecCommand("/usr/local/sbin/mdcmd", "spinup", diskName)
if err != nil {
logger.Error("Array: Failed to spin up disk %s: %v", diskName, err)
return fmt.Errorf("failed to spin up disk: %w", err)
}
logger.Info("Array: Disk %s spun up successfully", diskName)
return nil
}
package controllers
import (
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// DockerController provides control operations for Docker containers.
// It handles container lifecycle operations including start, stop, restart, pause, and unpause.
type DockerController struct{}
// NewDockerController creates a new Docker controller.
func NewDockerController() *DockerController {
return &DockerController{}
}
// Start starts a Docker container by ID or name.
func (dc *DockerController) Start(containerID string) error {
logger.Info("Starting Docker container: %s", containerID)
_, err := lib.ExecCommand(constants.DockerBin, "start", containerID)
return err
}
// Stop stops a Docker container by ID or name.
func (dc *DockerController) Stop(containerID string) error {
logger.Info("Stopping Docker container: %s", containerID)
_, err := lib.ExecCommand(constants.DockerBin, "stop", containerID)
return err
}
// Restart restarts a Docker container by ID or name.
func (dc *DockerController) Restart(containerID string) error {
logger.Info("Restarting Docker container: %s", containerID)
_, err := lib.ExecCommand(constants.DockerBin, "restart", containerID)
return err
}
// Pause pauses a running Docker container by ID or name.
func (dc *DockerController) Pause(containerID string) error {
logger.Info("Pausing Docker container: %s", containerID)
_, err := lib.ExecCommand(constants.DockerBin, "pause", containerID)
return err
}
// Unpause resumes a paused Docker container by ID or name.
func (dc *DockerController) Unpause(containerID string) error {
logger.Info("Unpausing Docker container: %s", containerID)
_, err := lib.ExecCommand(constants.DockerBin, "unpause", containerID)
return err
}
package controllers
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
const (
notificationsDir = "/usr/local/emhttp/state/notifications"
notificationsArchiveDir = "/usr/local/emhttp/state/notifications/archive"
)
// CreateNotification creates a new notification file
func CreateNotification(title, subject, description, importance, link string) error {
// Validate importance
if importance != "alert" && importance != "warning" && importance != "info" {
return fmt.Errorf("invalid importance level: %s (must be alert, warning, or info)", importance)
}
timestamp := time.Now()
sanitizedTitle := sanitizeFilename(title)
// Validate sanitized title to prevent path traversal
if err := validateFilename(sanitizedTitle); err != nil {
return fmt.Errorf("invalid title: %w", err)
}
filename := fmt.Sprintf("%s-%s.notify",
timestamp.Format("20060102-150405"),
sanitizedTitle)
path := filepath.Join(notificationsDir, filename)
// Verify the final path is within the notifications directory
cleanPath := filepath.Clean(path)
if !strings.HasPrefix(cleanPath, notificationsDir) {
return fmt.Errorf("invalid notification path: path escapes notifications directory")
}
content := fmt.Sprintf(`event="%s"
subject="%s"
description="%s"
importance="%s"
timestamp="%s"
link="%s"`,
title, subject, description, importance,
timestamp.Format("2006-01-02 15:04:05"), link)
// #nosec G306 - Notification files need to be readable by Unraid web UI (0644)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
logger.Error("Failed to create notification: %v", err)
return fmt.Errorf("failed to create notification: %w", err)
}
logger.Info("Created notification: %s", filename)
return nil
}
// ArchiveNotification moves a notification to the archive directory
func ArchiveNotification(id string) error {
// Validate notification ID to prevent path traversal
if err := validateNotificationID(id); err != nil {
return err
}
src := filepath.Join(notificationsDir, id)
dst := filepath.Join(notificationsArchiveDir, id)
// Check if source file exists
if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("notification not found: %s", id)
}
// Ensure archive directory exists
// #nosec G301 - Unraid standard permissions (0755 for directories)
if err := os.MkdirAll(notificationsArchiveDir, 0755); err != nil {
return fmt.Errorf("failed to create archive directory: %w", err)
}
if err := os.Rename(src, dst); err != nil {
logger.Error("Failed to archive notification %s: %v", id, err)
return fmt.Errorf("failed to archive notification: %w", err)
}
logger.Info("Archived notification: %s", id)
return nil
}
// UnarchiveNotification moves a notification from archive back to active
func UnarchiveNotification(id string) error {
// Validate notification ID to prevent path traversal
if err := validateNotificationID(id); err != nil {
return err
}
src := filepath.Join(notificationsArchiveDir, id)
dst := filepath.Join(notificationsDir, id)
// Check if source file exists
if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("archived notification not found: %s", id)
}
if err := os.Rename(src, dst); err != nil {
logger.Error("Failed to unarchive notification %s: %v", id, err)
return fmt.Errorf("failed to unarchive notification: %w", err)
}
logger.Info("Unarchived notification: %s", id)
return nil
}
// DeleteNotification deletes a notification file
func DeleteNotification(id string, isArchived bool) error {
// Validate notification ID to prevent path traversal
if err := validateNotificationID(id); err != nil {
return err
}
dir := notificationsDir
if isArchived {
dir = notificationsArchiveDir
}
path := filepath.Join(dir, id)
// Check if file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("notification not found: %s", id)
}
if err := os.Remove(path); err != nil {
logger.Error("Failed to delete notification %s: %v", id, err)
return fmt.Errorf("failed to delete notification: %w", err)
}
logger.Info("Deleted notification: %s", id)
return nil
}
// ArchiveAllNotifications archives all unread notifications
func ArchiveAllNotifications() error {
files, err := os.ReadDir(notificationsDir)
if err != nil {
return fmt.Errorf("failed to read notifications directory: %w", err)
}
// Ensure archive directory exists
// #nosec G301 - Unraid standard permissions (0755 for directories)
if err := os.MkdirAll(notificationsArchiveDir, 0755); err != nil {
return fmt.Errorf("failed to create archive directory: %w", err)
}
count := 0
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".notify") {
continue
}
src := filepath.Join(notificationsDir, file.Name())
dst := filepath.Join(notificationsArchiveDir, file.Name())
if err := os.Rename(src, dst); err != nil {
logger.Warning("Failed to archive %s: %v", file.Name(), err)
continue
}
count++
}
logger.Info("Archived %d notifications", count)
return nil
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(s string) string {
// Replace spaces with underscores
s = strings.ReplaceAll(s, " ", "_")
// Remove any character that's not alphanumeric, underscore, or hyphen
reg := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
s = reg.ReplaceAllString(s, "")
// Limit length
if len(s) > 50 {
s = s[:50]
}
return s
}
// validateFilename validates a filename to prevent path traversal attacks
// This is used after sanitizeFilename to ensure the sanitized result is safe
func validateFilename(filename string) error {
if filename == "" {
return fmt.Errorf("filename cannot be empty")
}
// Check for parent directory references
if strings.Contains(filename, "..") {
return fmt.Errorf("parent directory references not allowed")
}
// Check for absolute paths
if strings.HasPrefix(filename, "/") || strings.HasPrefix(filename, "\\") {
return fmt.Errorf("absolute paths not allowed")
}
// Check for path separators
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
return fmt.Errorf("path separators not allowed")
}
return nil
}
// validateNotificationID validates a notification ID to prevent path traversal attacks
// Notification IDs should be filenames only (no path separators or parent directory references)
func validateNotificationID(id string) error {
if id == "" {
return fmt.Errorf("notification ID cannot be empty")
}
// Check for parent directory references first (most specific attack)
if strings.Contains(id, "..") {
return fmt.Errorf("invalid notification ID: parent directory references not allowed")
}
// Check for absolute paths
if strings.HasPrefix(id, "/") || strings.HasPrefix(id, "\\") {
return fmt.Errorf("invalid notification ID: absolute paths not allowed")
}
// Check for path separators (both Unix and Windows)
if strings.Contains(id, "/") || strings.Contains(id, "\\") {
return fmt.Errorf("invalid notification ID: path separators not allowed")
}
// Validate file extension (must be .notify)
if !strings.HasSuffix(id, ".notify") {
return fmt.Errorf("invalid notification ID: must have .notify extension")
}
// Additional security: ensure the resolved path stays within the notifications directory
// This prevents symlink attacks and other edge cases
cleanPath := filepath.Clean(filepath.Join(notificationsDir, id))
if !strings.HasPrefix(cleanPath, notificationsDir) {
return fmt.Errorf("invalid notification ID: path escapes notifications directory")
}
return nil
}
// Package controllers provides control operations for Unraid system resources.
package controllers
import (
"fmt"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// SystemController provides control operations for the Unraid system.
// It handles system reboot and shutdown operations.
type SystemController struct {
ctx *domain.Context
}
// NewSystemController creates a new system controller with the given context.
func NewSystemController(ctx *domain.Context) *SystemController {
return &SystemController{ctx: ctx}
}
// Reboot initiates a system reboot.
// This will gracefully stop services and reboot the Unraid server.
func (c *SystemController) Reboot() error {
logger.Info("System: Initiating reboot...")
// Use the shutdown command with -r flag for reboot
// The command runs in background so we can return a response before reboot occurs
_, err := lib.ExecCommand("/sbin/shutdown", "-r", "now")
if err != nil {
logger.Error("System: Failed to initiate reboot: %v", err)
return fmt.Errorf("failed to initiate reboot: %w", err)
}
logger.Info("System: Reboot initiated successfully")
return nil
}
// Shutdown initiates a system shutdown.
// This will gracefully stop services and power off the Unraid server.
func (c *SystemController) Shutdown() error {
logger.Info("System: Initiating shutdown...")
// Use the shutdown command with -h flag for halt/poweroff
// The command runs in background so we can return a response before shutdown occurs
_, err := lib.ExecCommand("/sbin/shutdown", "-h", "now")
if err != nil {
logger.Error("System: Failed to initiate shutdown: %v", err)
return fmt.Errorf("failed to initiate shutdown: %w", err)
}
logger.Info("System: Shutdown initiated successfully")
return nil
}
package controllers
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/dto"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
const (
// UserScriptsBasePath is the base directory for user scripts
UserScriptsBasePath = "/boot/config/plugins/user.scripts/scripts"
)
// ListUserScripts returns a list of all available user scripts
func ListUserScripts() ([]dto.UserScriptInfo, error) {
scripts := []dto.UserScriptInfo{}
// Check if user scripts directory exists
if _, err := os.Stat(UserScriptsBasePath); os.IsNotExist(err) {
logger.Warning("User scripts directory does not exist: %s", UserScriptsBasePath)
return scripts, nil
}
// Read all subdirectories in the user scripts directory
entries, err := os.ReadDir(UserScriptsBasePath)
if err != nil {
return nil, fmt.Errorf("failed to read user scripts directory: %w", err)
}
for _, entry := range entries {
// Skip files and macOS metadata files
if !entry.IsDir() {
continue
}
scriptName := entry.Name()
scriptDir := filepath.Join(UserScriptsBasePath, scriptName)
scriptPath := filepath.Join(scriptDir, "script")
descriptionPath := filepath.Join(scriptDir, "description")
// Check if script file exists
scriptInfo, err := os.Stat(scriptPath)
if err != nil {
logger.Debug("Script file not found for %s: %v", scriptName, err)
continue
}
// Read description if it exists
description := ""
// #nosec G304 - descriptionPath is constructed from trusted userscripts directory
if descData, err := os.ReadFile(descriptionPath); err == nil {
description = strings.TrimSpace(string(descData))
}
// Check if script is executable (has read permission at minimum)
executable := scriptInfo.Mode().Perm()&0400 != 0
scripts = append(scripts, dto.UserScriptInfo{
Name: scriptName,
Description: description,
Path: scriptPath,
Executable: executable,
LastModified: scriptInfo.ModTime(),
})
}
logger.Debug("Found %d user scripts", len(scripts))
return scripts, nil
}
// ExecuteUserScript executes a user script with the specified options
func ExecuteUserScript(scriptName string, background bool, wait bool) (*dto.UserScriptExecuteResponse, error) {
// Validate script name to prevent path traversal
if err := lib.ValidateUserScriptName(scriptName); err != nil {
return &dto.UserScriptExecuteResponse{
Success: false,
Error: fmt.Sprintf("Invalid script name: %v", err),
}, err
}
// Build script path
scriptPath := filepath.Join(UserScriptsBasePath, scriptName, "script")
// Verify script exists
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return &dto.UserScriptExecuteResponse{
Success: false,
Error: fmt.Sprintf("Script not found: %s", scriptName),
}, fmt.Errorf("script not found: %s", scriptName)
}
// Execute script based on options
if background && !wait {
// Background execution - don't wait for completion
return executeScriptBackground(scriptPath, scriptName)
}
if wait {
// Wait for completion and return output
return executeScriptWait(scriptPath, scriptName)
}
// Default: background execution
return executeScriptBackground(scriptPath, scriptName)
}
// executeScriptBackground executes a script in the background
func executeScriptBackground(scriptPath string, scriptName string) (*dto.UserScriptExecuteResponse, error) {
// Execute script in background using bash with nohup to detach
// We use sh -c to run the command in background
_, err := lib.ExecCommand("sh", "-c", fmt.Sprintf("nohup bash %s > /dev/null 2>&1 &", scriptPath))
if err != nil {
logger.Error("Failed to execute user script %s in background: %v", scriptName, err)
return &dto.UserScriptExecuteResponse{
Success: false,
Error: fmt.Sprintf("Failed to execute script: %v", err),
}, err
}
logger.Info("User script %s started in background", scriptName)
return &dto.UserScriptExecuteResponse{
Success: true,
Message: fmt.Sprintf("Script %s started in background", scriptName),
}, nil
}
// executeScriptWait executes a script and waits for completion
func executeScriptWait(scriptPath string, scriptName string) (*dto.UserScriptExecuteResponse, error) {
// Execute script and wait for completion
startTime := time.Now()
lines, err := lib.ExecCommand("bash", scriptPath)
duration := time.Since(startTime)
// Join output lines
output := strings.Join(lines, "\n")
if err != nil {
logger.Error("User script %s failed after %v: %v", scriptName, duration, err)
return &dto.UserScriptExecuteResponse{
Success: false,
Error: fmt.Sprintf("Script execution failed: %v", err),
Output: output,
}, err
}
logger.Info("User script %s completed successfully in %v", scriptName, duration)
return &dto.UserScriptExecuteResponse{
Success: true,
Message: fmt.Sprintf("Script %s completed successfully", scriptName),
Output: output,
}, nil
}
package controllers
import (
"github.com/ruaan-deysel/unraid-management-agent/daemon/constants"
"github.com/ruaan-deysel/unraid-management-agent/daemon/lib"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// VMController provides control operations for virtual machines managed by libvirt.
// It handles VM lifecycle operations including start, stop, restart, pause, resume, hibernate, and force stop.
type VMController struct{}
// NewVMController creates a new VM controller.
func NewVMController() *VMController {
return &VMController{}
}
// Start starts a virtual machine by name.
func (vc *VMController) Start(vmName string) error {
logger.Info("Starting VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "start", vmName)
return err
}
// Stop gracefully shuts down a virtual machine by name.
func (vc *VMController) Stop(vmName string) error {
logger.Info("Stopping VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "shutdown", vmName)
return err
}
// Restart reboots a virtual machine by name.
func (vc *VMController) Restart(vmName string) error {
logger.Info("Restarting VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "reboot", vmName)
return err
}
// Pause suspends a running virtual machine by name.
func (vc *VMController) Pause(vmName string) error {
logger.Info("Pausing VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "suspend", vmName)
return err
}
// Resume resumes a paused virtual machine by name.
func (vc *VMController) Resume(vmName string) error {
logger.Info("Resuming VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "resume", vmName)
return err
}
// Hibernate saves the VM state to disk and stops it.
func (vc *VMController) Hibernate(vmName string) error {
logger.Info("Hibernating VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "managedsave", vmName)
return err
}
// ForceStop immediately terminates a virtual machine by name without graceful shutdown.
func (vc *VMController) ForceStop(vmName string) error {
logger.Info("Force stopping VM: %s", vmName)
_, err := lib.ExecCommand(constants.VirshBin, "destroy", vmName)
return err
}
// Package services provides the orchestration layer for managing collectors, API server, and application lifecycle.
package services
import (
"context"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
"github.com/ruaan-deysel/unraid-management-agent/daemon/services/api"
"github.com/ruaan-deysel/unraid-management-agent/daemon/services/collectors"
)
// Orchestrator coordinates the lifecycle of all collectors, API server, and handles graceful shutdown.
// It manages the initialization order, starts all components, and ensures proper cleanup on termination.
type Orchestrator struct {
ctx *domain.Context
}
// CreateOrchestrator creates a new orchestrator with the given context.
func CreateOrchestrator(ctx *domain.Context) *Orchestrator {
return &Orchestrator{ctx: ctx}
}
// Run starts all collectors and the API server, then waits for a termination signal.
// It ensures proper initialization order and handles graceful shutdown of all components.
func (o *Orchestrator) Run() error {
logger.Info("Starting Unraid Management Agent v%s", o.ctx.Version)
// Create cancellable context for all goroutines
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// WaitGroup to track all goroutines
var wg sync.WaitGroup
// Initialize API server FIRST so subscriptions are ready
apiServer := api.NewServer(o.ctx)
// Start API server subscriptions and WebSocket hub
apiServer.StartSubscriptions()
logger.Success("API server subscriptions ready")
// Small delay to ensure subscriptions are fully set up
time.Sleep(100 * time.Millisecond)
// Initialize collectors (only if enabled - interval > 0)
// Interval of 0 means the collector is disabled
enabledCount := 0
disabledCollectors := []string{}
// System collector
if o.ctx.Intervals.System > 0 {
systemCollector := collectors.NewSystemCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
systemCollector.Start(ctx, time.Duration(o.ctx.Intervals.System)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "system")
}
// Array collector
if o.ctx.Intervals.Array > 0 {
arrayCollector := collectors.NewArrayCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
arrayCollector.Start(ctx, time.Duration(o.ctx.Intervals.Array)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "array")
}
// Disk collector
if o.ctx.Intervals.Disk > 0 {
diskCollector := collectors.NewDiskCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
diskCollector.Start(ctx, time.Duration(o.ctx.Intervals.Disk)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "disk")
}
// Docker collector
if o.ctx.Intervals.Docker > 0 {
dockerCollector := collectors.NewDockerCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
dockerCollector.Start(ctx, time.Duration(o.ctx.Intervals.Docker)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "docker")
}
// VM collector
if o.ctx.Intervals.VM > 0 {
vmCollector := collectors.NewVMCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
vmCollector.Start(ctx, time.Duration(o.ctx.Intervals.VM)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "vm")
}
// UPS collector
if o.ctx.Intervals.UPS > 0 {
upsCollector := collectors.NewUPSCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
upsCollector.Start(ctx, time.Duration(o.ctx.Intervals.UPS)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "ups")
}
// NUT collector (separate from UPS - for NUT plugin users)
if o.ctx.Intervals.NUT > 0 {
nutCollector := collectors.NewNUTCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
nutCollector.Start(ctx, time.Duration(o.ctx.Intervals.NUT)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "nut")
}
// GPU collector
if o.ctx.Intervals.GPU > 0 {
gpuCollector := collectors.NewGPUCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
gpuCollector.Start(ctx, time.Duration(o.ctx.Intervals.GPU)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "gpu")
}
// Share collector
if o.ctx.Intervals.Shares > 0 {
shareCollector := collectors.NewShareCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
shareCollector.Start(ctx, time.Duration(o.ctx.Intervals.Shares)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "shares")
}
// Network collector
if o.ctx.Intervals.Network > 0 {
networkCollector := collectors.NewNetworkCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
networkCollector.Start(ctx, time.Duration(o.ctx.Intervals.Network)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "network")
}
// Hardware collector
if o.ctx.Intervals.Hardware > 0 {
hardwareCollector := collectors.NewHardwareCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
hardwareCollector.Start(ctx, time.Duration(o.ctx.Intervals.Hardware)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "hardware")
}
// Registration collector
if o.ctx.Intervals.Registration > 0 {
registrationCollector := collectors.NewRegistrationCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
registrationCollector.Start(ctx, time.Duration(o.ctx.Intervals.Registration)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "registration")
}
// Notification collector
if o.ctx.Intervals.Notification > 0 {
notificationCollector := collectors.NewNotificationCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
notificationCollector.Start(ctx, time.Duration(o.ctx.Intervals.Notification)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "notification")
}
// Unassigned collector
if o.ctx.Intervals.Unassigned > 0 {
unassignedCollector := collectors.NewUnassignedCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
unassignedCollector.Start(ctx, time.Duration(o.ctx.Intervals.Unassigned)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "unassigned")
}
// ZFS collector
if o.ctx.Intervals.ZFS > 0 {
zfsCollector := collectors.NewZFSCollector(o.ctx)
wg.Add(1)
go func() {
defer wg.Done()
zfsCollector.Start(ctx, time.Duration(o.ctx.Intervals.ZFS)*time.Second)
}()
enabledCount++
} else {
disabledCollectors = append(disabledCollectors, "zfs")
}
logger.Success("%d collectors started", enabledCount)
if len(disabledCollectors) > 0 {
logger.Info("Disabled collectors: %v", disabledCollectors)
}
// Start HTTP server
wg.Add(1)
go func() {
defer wg.Done()
if err := apiServer.StartHTTP(); err != nil {
logger.Error("API server error: %v", err)
}
}()
logger.Success("API server started on port %d", o.ctx.Port)
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigChan
logger.Warning("Received %s signal, shutting down...", sig)
// Graceful shutdown
// 1. Cancel context to stop all goroutines
cancel()
// 2. Stop API server (which also cancels its internal goroutines)
apiServer.Stop()
// 3. Wait for all goroutines to complete
logger.Info("Waiting for all goroutines to complete...")
wg.Wait()
logger.Info("Shutdown complete")
return nil
}
// Package main is the entry point for the Unraid Management Agent.
// It provides a REST API and WebSocket interface for monitoring and controlling Unraid systems.
package main
import (
"io"
"log"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/kong"
"github.com/cskr/pubsub"
"gopkg.in/natefinch/lumberjack.v2"
"github.com/ruaan-deysel/unraid-management-agent/daemon/cmd"
"github.com/ruaan-deysel/unraid-management-agent/daemon/domain"
"github.com/ruaan-deysel/unraid-management-agent/daemon/logger"
)
// Version is the application version, set at build time via ldflags.
var Version = "dev"
// validCollectorNames contains all valid collector names for validation
var validCollectorNames = map[string]bool{
"system": true,
"array": true,
"disk": true,
"docker": true,
"vm": true,
"ups": true,
"nut": true,
"gpu": true,
"shares": true,
"network": true,
"hardware": true,
"zfs": true,
"notification": true,
"registration": true,
"unassigned": true,
}
var cli struct {
LogsDir string `default:"/var/log" help:"directory to store logs"`
Port int `default:"8043" help:"HTTP server port"`
Debug bool `default:"false" help:"enable debug mode with stdout logging"`
LogLevel string `default:"warning" help:"log level: debug, info, warning, error"`
// Collector disable flag (alternative to setting interval=0)
DisableCollectors string `default:"" env:"UNRAID_DISABLE_COLLECTORS" help:"comma-separated list of collectors to disable (e.g., gpu,ups,zfs)"`
// Collection intervals (overridable via environment variables)
// Use 0 to disable a collector completely
// Maximum interval: 86400 seconds (24 hours)
IntervalSystem int `default:"15" env:"INTERVAL_SYSTEM" help:"system metrics interval (seconds, 0=disabled, max 86400)"`
IntervalArray int `default:"60" env:"INTERVAL_ARRAY" help:"array metrics interval (seconds, 0=disabled, max 86400)"`
IntervalDisk int `default:"300" env:"INTERVAL_DISK" help:"disk metrics interval (seconds, 0=disabled, max 86400)"`
IntervalDocker int `default:"30" env:"INTERVAL_DOCKER" help:"docker metrics interval (seconds, 0=disabled, max 86400)"`
IntervalVM int `default:"60" env:"INTERVAL_VM" help:"VM metrics interval (seconds, 0=disabled, max 86400)"`
IntervalUPS int `default:"60" env:"INTERVAL_UPS" help:"UPS metrics interval (seconds, 0=disabled, max 86400)"`
IntervalNUT int `default:"0" env:"INTERVAL_NUT" help:"NUT plugin metrics interval (seconds, 0=disabled, max 86400)"`
IntervalGPU int `default:"60" env:"INTERVAL_GPU" help:"GPU metrics interval (seconds, 0=disabled, max 86400)"`
IntervalShares int `default:"60" env:"INTERVAL_SHARES" help:"shares metrics interval (seconds, 0=disabled, max 86400)"`
IntervalNetwork int `default:"60" env:"INTERVAL_NETWORK" help:"network metrics interval (seconds, 0=disabled, max 86400)"`
IntervalHardware int `default:"600" env:"INTERVAL_HARDWARE" help:"hardware metrics interval (seconds, 0=disabled, max 86400)"`
IntervalZFS int `default:"300" env:"INTERVAL_ZFS" help:"ZFS metrics interval (seconds, 0=disabled, max 86400)"`
IntervalNotification int `default:"30" env:"INTERVAL_NOTIFICATION" help:"notification interval (seconds, 0=disabled, max 86400)"`
IntervalRegistration int `default:"600" env:"INTERVAL_REGISTRATION" help:"registration interval (seconds, 0=disabled, max 86400)"`
IntervalUnassigned int `default:"60" env:"INTERVAL_UNASSIGNED" help:"unassigned devices interval (seconds, 0=disabled, max 86400)"`
Boot cmd.Boot `cmd:"" default:"1" help:"start the management agent"`
}
func main() {
ctx := kong.Parse(&cli)
// Set log level based on CLI flag
switch strings.ToLower(cli.LogLevel) {
case "debug":
logger.SetLevel(logger.LevelDebug)
case "info":
logger.SetLevel(logger.LevelInfo)
case "warning", "warn":
logger.SetLevel(logger.LevelWarning)
case "error":
logger.SetLevel(logger.LevelError)
default:
logger.SetLevel(logger.LevelWarning)
}
// Set up logging
if cli.Debug {
// Debug mode: direct stdout/stderr with no buffering
log.SetOutput(os.Stdout)
log.SetFlags(log.LstdFlags | log.Lshortfile)
logger.SetLevel(logger.LevelDebug)
log.Println("Debug mode enabled - logging to stdout")
} else {
// Production mode: log rotation with 5MB max size, NO backups
fileLogger := &lumberjack.Logger{
Filename: filepath.Join(cli.LogsDir, "unraid-management-agent.log"),
MaxSize: 5, // 5 MB max file size
MaxBackups: 0, // No backup files - only keep current log
MaxAge: 0, // No age-based retention
Compress: false, // No compression
}
// Write to both file and stdout
multiWriter := io.MultiWriter(fileLogger, os.Stdout)
log.SetOutput(multiWriter)
}
log.Printf("Starting Unraid Management Agent v%s (log level: %s)", Version, cli.LogLevel)
// Parse disabled collectors from CLI/env and create a map
disabledCollectors := make(map[string]bool)
if cli.DisableCollectors != "" {
for _, name := range strings.Split(cli.DisableCollectors, ",") {
name = strings.TrimSpace(strings.ToLower(name))
if name == "" {
continue
}
if name == "system" {
log.Printf("WARNING: Cannot disable system collector (always required), ignoring")
continue
}
if !validCollectorNames[name] {
log.Printf("WARNING: Unknown collector name '%s' in disable list, ignoring", name)
continue
}
disabledCollectors[name] = true
log.Printf("Collector '%s' disabled via UNRAID_DISABLE_COLLECTORS", name)
}
}
// Helper function to get interval (returns 0 if collector is disabled)
getInterval := func(name string, cliInterval int) int {
if disabledCollectors[name] {
return 0
}
return cliInterval
}
// Create application context with intervals from CLI/env
appCtx := &domain.Context{
Config: domain.Config{
Version: Version,
Port: cli.Port,
},
Hub: pubsub.New(1024), // Buffer size for event bus
Intervals: domain.Intervals{
System: getInterval("system", cli.IntervalSystem),
Array: getInterval("array", cli.IntervalArray),
Disk: getInterval("disk", cli.IntervalDisk),
Docker: getInterval("docker", cli.IntervalDocker),
VM: getInterval("vm", cli.IntervalVM),
UPS: getInterval("ups", cli.IntervalUPS),
NUT: getInterval("nut", cli.IntervalNUT),
GPU: getInterval("gpu", cli.IntervalGPU),
Shares: getInterval("shares", cli.IntervalShares),
Network: getInterval("network", cli.IntervalNetwork),
Hardware: getInterval("hardware", cli.IntervalHardware),
ZFS: getInterval("zfs", cli.IntervalZFS),
Notification: getInterval("notification", cli.IntervalNotification),
Registration: getInterval("registration", cli.IntervalRegistration),
Unassigned: getInterval("unassigned", cli.IntervalUnassigned),
},
}
// Run the boot command
err := ctx.Run(appCtx)
ctx.FatalIfErrorf(err)
}