1041 lines
28 KiB
Go
1041 lines
28 KiB
Go
// Package unraid provides parser for Unraid diagnostics archives.
|
|
package unraid
|
|
|
|
import (
|
|
"bufio"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
|
)
|
|
|
|
// parserVersion - increment when parsing logic changes.
|
|
const parserVersion = "1.2"
|
|
|
|
func init() {
|
|
parser.Register(&Parser{})
|
|
}
|
|
|
|
// Parser implements VendorParser for Unraid diagnostics.
|
|
type Parser struct{}
|
|
|
|
func (p *Parser) Name() string { return "Unraid Parser" }
|
|
func (p *Parser) Vendor() string { return "unraid" }
|
|
func (p *Parser) Version() string { return parserVersion }
|
|
|
|
// Detect checks if files contain typical Unraid markers.
|
|
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
|
confidence := 0
|
|
hasUnraidVersion := false
|
|
hasDiagnosticsDir := false
|
|
hasVarsParity := false
|
|
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
content := string(f.Content)
|
|
|
|
// Check for unraid version file
|
|
if strings.Contains(path, "unraid-") && strings.HasSuffix(path, ".txt") {
|
|
hasUnraidVersion = true
|
|
confidence += 40
|
|
}
|
|
|
|
// Check for Unraid-specific directories
|
|
if strings.Contains(path, "diagnostics-") &&
|
|
(strings.Contains(path, "/system/") ||
|
|
strings.Contains(path, "/smart/") ||
|
|
strings.Contains(path, "/config/")) {
|
|
hasDiagnosticsDir = true
|
|
if confidence < 60 {
|
|
confidence += 20
|
|
}
|
|
}
|
|
|
|
// Check file content for Unraid markers
|
|
if strings.Contains(content, "Unraid kernel build") {
|
|
confidence += 50
|
|
}
|
|
|
|
// Check for vars.txt with disk array info
|
|
if strings.Contains(path, "vars.txt") && strings.Contains(content, "[parity]") {
|
|
hasVarsParity = true
|
|
confidence += 30
|
|
}
|
|
|
|
if confidence >= 100 {
|
|
return 100
|
|
}
|
|
}
|
|
|
|
// Boost confidence if we see multiple key indicators together
|
|
if hasUnraidVersion && (hasDiagnosticsDir || hasVarsParity) {
|
|
confidence += 20
|
|
}
|
|
|
|
if confidence > 100 {
|
|
return 100
|
|
}
|
|
return confidence
|
|
}
|
|
|
|
// Parse parses Unraid diagnostics and returns normalized data.
|
|
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
|
result := &models.AnalysisResult{
|
|
Events: make([]models.Event, 0),
|
|
FRU: make([]models.FRUInfo, 0),
|
|
Sensors: make([]models.SensorReading, 0),
|
|
Hardware: &models.HardwareConfig{
|
|
Firmware: make([]models.FirmwareInfo, 0),
|
|
CPUs: make([]models.CPU, 0),
|
|
Memory: make([]models.MemoryDIMM, 0),
|
|
Storage: make([]models.Storage, 0),
|
|
},
|
|
}
|
|
|
|
// Track storage by slot to avoid duplicates
|
|
storageBySlot := make(map[string]*models.Storage)
|
|
hasDetailedMemory := false
|
|
ethtoolByIface := make(map[string]ethtoolInfo)
|
|
ethtoolByBDF := make(map[string]ethtoolInfo)
|
|
ifconfigByIface := make(map[string]ifconfigInfo)
|
|
|
|
// Parse different file types
|
|
for _, f := range files {
|
|
path := strings.ToLower(f.Path)
|
|
content := string(f.Content)
|
|
|
|
switch {
|
|
case strings.Contains(path, "unraid-") && strings.HasSuffix(path, ".txt"):
|
|
parseVersionFile(content, result)
|
|
|
|
case strings.HasSuffix(path, "/system/lscpu.txt") || strings.HasSuffix(path, "\\system\\lscpu.txt"):
|
|
parseLsCPU(content, result)
|
|
|
|
case strings.HasSuffix(path, "/system/motherboard.txt") || strings.HasSuffix(path, "\\system\\motherboard.txt"):
|
|
parseMotherboard(content, result)
|
|
|
|
case strings.HasSuffix(path, "/system/memory.txt") || strings.HasSuffix(path, "\\system\\memory.txt"):
|
|
parseMemory(content, result)
|
|
|
|
case strings.HasSuffix(path, "/system/meminfo.txt") || strings.HasSuffix(path, "\\system\\meminfo.txt"):
|
|
if parseMemoryDIMMs(content, result) > 0 {
|
|
hasDetailedMemory = true
|
|
}
|
|
|
|
case strings.HasSuffix(path, "/system/ifconfig.txt") || strings.HasSuffix(path, "\\system\\ifconfig.txt"):
|
|
parseIfconfig(content, ifconfigByIface)
|
|
|
|
case strings.HasSuffix(path, "/system/ethtool.txt") || strings.HasSuffix(path, "\\system\\ethtool.txt"):
|
|
parseEthtool(content, ethtoolByIface, ethtoolByBDF)
|
|
|
|
case strings.HasSuffix(path, "/system/lspci.txt") || strings.HasSuffix(path, "\\system\\lspci.txt"):
|
|
parseLSPCI(content, ifconfigByIface, ethtoolByIface, ethtoolByBDF, result)
|
|
|
|
case strings.HasSuffix(path, "/system/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"):
|
|
parseVarsToMap(content, storageBySlot, result)
|
|
parseHostIdentityFromVars(content, result)
|
|
|
|
case strings.Contains(path, "/smart/") && strings.HasSuffix(path, ".txt"):
|
|
parseSMARTFileToMap(content, f.Path, storageBySlot, result)
|
|
|
|
case strings.HasSuffix(path, "/logs/syslog.txt") || strings.HasSuffix(path, "\\logs\\syslog.txt"):
|
|
parseSyslog(content, result)
|
|
}
|
|
}
|
|
|
|
if hasDetailedMemory {
|
|
filtered := make([]models.MemoryDIMM, 0, len(result.Hardware.Memory))
|
|
for _, dimm := range result.Hardware.Memory {
|
|
if strings.EqualFold(strings.TrimSpace(dimm.Slot), "system") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, dimm)
|
|
}
|
|
result.Hardware.Memory = filtered
|
|
}
|
|
|
|
// Convert storage map to slice
|
|
for _, disk := range storageBySlot {
|
|
result.Hardware.Storage = append(result.Hardware.Storage, *disk)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseVersionFile(content string, result *models.AnalysisResult) {
|
|
lines := strings.Split(content, "\n")
|
|
if len(lines) > 0 {
|
|
version := strings.TrimSpace(lines[0])
|
|
if version != "" {
|
|
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
|
DeviceName: "Unraid OS",
|
|
Version: version,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseLsCPU(content string, result *models.AnalysisResult) {
|
|
// Normalize line endings
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
|
|
var cpu models.CPU
|
|
cpu.Socket = 0 // Default to socket 0
|
|
|
|
// Parse CPU model - handle multiple spaces
|
|
if m := regexp.MustCompile(`(?m)^Model name:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
cpu.Model = strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// Parse CPU(s) - total thread count
|
|
if m := regexp.MustCompile(`(?m)^CPU\(s\):\s+(\d+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
cpu.Threads = parseInt(m[1])
|
|
}
|
|
|
|
// Parse cores per socket
|
|
if m := regexp.MustCompile(`(?m)^Core\(s\) per socket:\s+(\d+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
cpu.Cores = parseInt(m[1])
|
|
}
|
|
|
|
// Parse CPU max MHz
|
|
if m := regexp.MustCompile(`(?m)^CPU max MHz:\s+([\d.]+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
cpu.FrequencyMHz = int(parseFloat(m[1]))
|
|
}
|
|
|
|
// If no max MHz, try current MHz
|
|
if cpu.FrequencyMHz == 0 {
|
|
if m := regexp.MustCompile(`(?m)^CPU MHz:\s+([\d.]+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
cpu.FrequencyMHz = int(parseFloat(m[1]))
|
|
}
|
|
}
|
|
|
|
// Only add if we got at least the model
|
|
if cpu.Model != "" {
|
|
result.Hardware.CPUs = append(result.Hardware.CPUs, cpu)
|
|
}
|
|
}
|
|
|
|
func parseMotherboard(content string, result *models.AnalysisResult) {
|
|
var board models.BoardInfo
|
|
|
|
// Parse manufacturer from dmidecode output
|
|
lines := strings.Split(content, "\n")
|
|
inBIOSSection := false
|
|
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if strings.Contains(trimmed, "BIOS Information") {
|
|
inBIOSSection = true
|
|
continue
|
|
}
|
|
|
|
if inBIOSSection {
|
|
if strings.HasPrefix(trimmed, "Vendor:") {
|
|
parts := strings.SplitN(trimmed, ":", 2)
|
|
if len(parts) == 2 {
|
|
board.Manufacturer = strings.TrimSpace(parts[1])
|
|
}
|
|
} else if strings.HasPrefix(trimmed, "Version:") {
|
|
parts := strings.SplitN(trimmed, ":", 2)
|
|
if len(parts) == 2 {
|
|
biosVersion := strings.TrimSpace(parts[1])
|
|
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
|
DeviceName: "System BIOS",
|
|
Version: biosVersion,
|
|
})
|
|
}
|
|
} else if strings.HasPrefix(trimmed, "Release Date:") {
|
|
// Could extract BIOS date if needed
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract product name from first line
|
|
if len(lines) > 0 {
|
|
firstLine := strings.TrimSpace(lines[0])
|
|
if firstLine != "" {
|
|
board.ProductName = firstLine
|
|
}
|
|
}
|
|
|
|
result.Hardware.BoardInfo = board
|
|
}
|
|
|
|
func parseMemory(content string, result *models.AnalysisResult) {
|
|
// Parse memory from free output
|
|
// Example: Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi
|
|
if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti|KB|MB|GB|TB)`).FindStringSubmatch(content); len(m) >= 3 {
|
|
size := parseFloat(m[1])
|
|
unit := strings.ToUpper(m[2])
|
|
|
|
var sizeMB int
|
|
switch unit {
|
|
case "KI", "KB":
|
|
sizeMB = int(size / 1024)
|
|
case "MI", "MB":
|
|
sizeMB = int(size)
|
|
case "GI", "GB":
|
|
sizeMB = int(size * 1024)
|
|
case "TI", "TB":
|
|
sizeMB = int(size * 1024 * 1024)
|
|
}
|
|
|
|
if sizeMB > 0 {
|
|
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
|
|
Slot: "system",
|
|
Present: true,
|
|
SizeMB: sizeMB,
|
|
Type: "DRAM",
|
|
Status: "ok",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseMemoryDIMMs(content string, result *models.AnalysisResult) int {
|
|
blocks := strings.Split(content, "Handle ")
|
|
added := 0
|
|
for _, block := range blocks {
|
|
b := strings.TrimSpace(block)
|
|
if b == "" || !strings.Contains(b, "DMI type 17") || !strings.Contains(b, "Memory Device") {
|
|
continue
|
|
}
|
|
|
|
sizeRaw := extractFieldValue(b, "Size:")
|
|
if sizeRaw == "" || strings.Contains(strings.ToLower(sizeRaw), "no module installed") {
|
|
continue
|
|
}
|
|
|
|
sizeMB := parseDIMMSizeMB(sizeRaw)
|
|
if sizeMB <= 0 {
|
|
continue
|
|
}
|
|
|
|
slot := extractFieldValue(b, "Locator:")
|
|
if slot == "" {
|
|
slot = extractFieldValue(b, "Bank Locator:")
|
|
}
|
|
if slot == "" {
|
|
slot = "dimm"
|
|
}
|
|
|
|
dimm := models.MemoryDIMM{
|
|
Slot: slot,
|
|
Location: extractFieldValue(b, "Bank Locator:"),
|
|
Present: true,
|
|
SizeMB: sizeMB,
|
|
Type: extractFieldValue(b, "Type:"),
|
|
MaxSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Speed:")),
|
|
CurrentSpeedMHz: parseSpeedMTs(extractFieldValue(b, "Configured Memory Speed:")),
|
|
Manufacturer: strings.TrimSpace(extractFieldValue(b, "Manufacturer:")),
|
|
SerialNumber: strings.TrimSpace(extractFieldValue(b, "Serial Number:")),
|
|
PartNumber: strings.TrimSpace(extractFieldValue(b, "Part Number:")),
|
|
Ranks: parseInt(extractFieldValue(b, "Rank:")),
|
|
Status: "ok",
|
|
}
|
|
if dimm.Type == "" || strings.EqualFold(dimm.Type, "Unknown") {
|
|
dimm.Type = "DRAM"
|
|
}
|
|
if dimm.CurrentSpeedMHz == 0 {
|
|
dimm.CurrentSpeedMHz = dimm.MaxSpeedMHz
|
|
}
|
|
|
|
result.Hardware.Memory = append(result.Hardware.Memory, dimm)
|
|
added++
|
|
}
|
|
return added
|
|
}
|
|
|
|
func extractFieldValue(block, key string) string {
|
|
for _, line := range strings.Split(block, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, key) {
|
|
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseDIMMSizeMB(s string) int {
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
parts := strings.Fields(s)
|
|
if len(parts) < 2 {
|
|
return 0
|
|
}
|
|
v := parseFloat(parts[0])
|
|
switch parts[1] {
|
|
case "KB", "KIB":
|
|
return int(v / 1024)
|
|
case "MB", "MIB":
|
|
return int(v)
|
|
case "GB", "GIB":
|
|
return int(v * 1024)
|
|
case "TB", "TIB":
|
|
return int(v * 1024 * 1024)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func parseSpeedMTs(s string) int {
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
re := regexp.MustCompile(`(\d+)\s*MT/S`)
|
|
if m := re.FindStringSubmatch(s); len(m) == 2 {
|
|
return parseInt(m[1])
|
|
}
|
|
return 0
|
|
}
|
|
|
|
type ethtoolInfo struct {
|
|
Interface string
|
|
BusInfo string
|
|
Driver string
|
|
Firmware string
|
|
SpeedMbps int
|
|
LinkUp bool
|
|
}
|
|
|
|
type ifconfigInfo struct {
|
|
Interface string
|
|
State string
|
|
Addresses []string
|
|
}
|
|
|
|
func parseIfconfig(content string, out map[string]ifconfigInfo) {
|
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
iface := strings.Split(fields[0], "@")[0]
|
|
if iface == "" || strings.HasPrefix(iface, "lo") || strings.HasPrefix(iface, "docker") || strings.HasPrefix(iface, "veth") {
|
|
continue
|
|
}
|
|
state := fields[1]
|
|
addrs := make([]string, 0, 2)
|
|
for _, f := range fields[2:] {
|
|
if strings.Contains(f, ".") || strings.Contains(f, ":") {
|
|
addrs = append(addrs, f)
|
|
}
|
|
}
|
|
out[iface] = ifconfigInfo{
|
|
Interface: iface,
|
|
State: state,
|
|
Addresses: addrs,
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseEthtool(content string, byIface, byBDF map[string]ethtoolInfo) {
|
|
sections := strings.Split(content, "--------------------------------")
|
|
for _, sec := range sections {
|
|
s := strings.TrimSpace(sec)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
var info ethtoolInfo
|
|
for _, line := range strings.Split(s, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(t, "Settings for "):
|
|
info.Interface = strings.TrimSuffix(strings.TrimPrefix(t, "Settings for "), ":")
|
|
case strings.HasPrefix(t, "driver:"):
|
|
info.Driver = strings.TrimSpace(strings.TrimPrefix(t, "driver:"))
|
|
case strings.HasPrefix(t, "firmware-version:"):
|
|
info.Firmware = strings.TrimSpace(strings.TrimPrefix(t, "firmware-version:"))
|
|
case strings.HasPrefix(t, "bus-info:"):
|
|
info.BusInfo = normalizeBDF(strings.TrimSpace(strings.TrimPrefix(t, "bus-info:")))
|
|
case strings.HasPrefix(t, "Speed:"):
|
|
info.SpeedMbps = parseSpeedMbps(strings.TrimSpace(strings.TrimPrefix(t, "Speed:")))
|
|
case strings.HasPrefix(t, "Link detected:"):
|
|
info.LinkUp = strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(t, "Link detected:")), "yes")
|
|
}
|
|
}
|
|
if info.Interface != "" {
|
|
byIface[info.Interface] = info
|
|
}
|
|
if info.BusInfo != "" {
|
|
byBDF[info.BusInfo] = info
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseLSPCI(
|
|
content string,
|
|
iface map[string]ifconfigInfo,
|
|
ethtoolByIface map[string]ethtoolInfo,
|
|
ethtoolByBDF map[string]ethtoolInfo,
|
|
result *models.AnalysisResult,
|
|
) {
|
|
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
|
lspciLineRe := regexp.MustCompile(`^([0-9a-fA-F:.]+)\s+(.+?)\s+\[[0-9a-fA-F]{4}\]:\s+(.+?)\s+\[([0-9a-fA-F]{4}):([0-9a-fA-F]{4})\]`)
|
|
|
|
hasPCIe := make(map[string]bool)
|
|
hasAdapter := make(map[string]bool)
|
|
|
|
for _, line := range lines {
|
|
m := lspciLineRe.FindStringSubmatch(strings.TrimSpace(line))
|
|
if len(m) != 6 {
|
|
continue
|
|
}
|
|
|
|
bdf := normalizeBDF(m[1])
|
|
class := strings.TrimSpace(m[2])
|
|
desc := strings.TrimSpace(m[3])
|
|
vendorID := parseHexID(m[4])
|
|
deviceID := parseHexID(m[5])
|
|
|
|
if bdf == "" {
|
|
continue
|
|
}
|
|
|
|
if isInterestingPCIClass(class) && !hasPCIe[bdf] {
|
|
vendor := pciids.VendorName(vendorID)
|
|
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, models.PCIeDevice{
|
|
Slot: bdf,
|
|
BDF: bdf,
|
|
DeviceClass: class,
|
|
Description: desc,
|
|
VendorID: vendorID,
|
|
DeviceID: deviceID,
|
|
Manufacturer: vendor,
|
|
Status: "ok",
|
|
})
|
|
hasPCIe[bdf] = true
|
|
}
|
|
|
|
if !isNICClass(class) || hasAdapter[bdf] {
|
|
continue
|
|
}
|
|
|
|
etInfo := ethtoolByBDF[bdf]
|
|
if etInfo.Interface == "" {
|
|
for _, it := range ethtoolByIface {
|
|
if normalizeBDF(it.BusInfo) == bdf {
|
|
etInfo = it
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if etInfo.Driver == "bonding" {
|
|
continue
|
|
}
|
|
|
|
model := desc
|
|
if devName := pciids.DeviceName(vendorID, deviceID); devName != "" {
|
|
model = devName
|
|
}
|
|
vendor := pciids.VendorName(vendorID)
|
|
if vendor == "" {
|
|
vendor = firstWords(desc, 2)
|
|
}
|
|
|
|
slot := etInfo.Interface
|
|
if slot == "" {
|
|
slot = bdf
|
|
}
|
|
status := "ok"
|
|
if etInfo.Interface != "" && !etInfo.LinkUp {
|
|
status = "warning"
|
|
} else if etInfo.Interface != "" {
|
|
if ifInfo, ok := iface[etInfo.Interface]; ok && !strings.EqualFold(ifInfo.State, "UP") {
|
|
status = "warning"
|
|
}
|
|
}
|
|
|
|
adapter := models.NetworkAdapter{
|
|
Slot: slot,
|
|
Location: bdf,
|
|
Present: true,
|
|
Model: model,
|
|
Vendor: vendor,
|
|
VendorID: vendorID,
|
|
DeviceID: deviceID,
|
|
Firmware: strings.TrimSpace(etInfo.Firmware),
|
|
PortCount: 1,
|
|
Status: status,
|
|
}
|
|
result.Hardware.NetworkAdapters = append(result.Hardware.NetworkAdapters, adapter)
|
|
|
|
result.Hardware.NetworkCards = append(result.Hardware.NetworkCards, models.NIC{
|
|
Name: slot,
|
|
Model: model,
|
|
SpeedMbps: etInfo.SpeedMbps,
|
|
})
|
|
|
|
hasAdapter[bdf] = true
|
|
}
|
|
}
|
|
|
|
func isNICClass(class string) bool {
|
|
c := strings.ToLower(strings.TrimSpace(class))
|
|
return strings.Contains(c, "ethernet controller") || strings.Contains(c, "network controller")
|
|
}
|
|
|
|
func isInterestingPCIClass(class string) bool {
|
|
c := strings.ToLower(strings.TrimSpace(class))
|
|
if isNICClass(c) {
|
|
return true
|
|
}
|
|
switch {
|
|
case strings.Contains(c, "scsi storage controller"),
|
|
strings.Contains(c, "sata controller"),
|
|
strings.Contains(c, "raid bus controller"),
|
|
strings.Contains(c, "vga compatible controller"),
|
|
strings.Contains(c, "3d controller"),
|
|
strings.Contains(c, "display controller"),
|
|
strings.Contains(c, "non-volatile memory controller"),
|
|
strings.Contains(c, "processing accelerators"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func parseHexID(s string) int {
|
|
v, err := strconv.ParseInt(strings.TrimSpace(s), 16, 32)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int(v)
|
|
}
|
|
|
|
func parseSpeedMbps(s string) int {
|
|
s = strings.TrimSpace(strings.ToUpper(s))
|
|
if s == "" || s == "UNKNOWN!" {
|
|
return 0
|
|
}
|
|
if m := regexp.MustCompile(`(\d+)MB/S`).FindStringSubmatch(s); len(m) == 2 {
|
|
return parseInt(m[1])
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizeBDF(bdf string) string {
|
|
bdf = strings.TrimSpace(strings.ToLower(bdf))
|
|
if bdf == "" {
|
|
return ""
|
|
}
|
|
if strings.Count(bdf, ":") == 1 {
|
|
return "0000:" + bdf
|
|
}
|
|
return bdf
|
|
}
|
|
|
|
func firstWords(s string, n int) string {
|
|
parts := strings.Fields(strings.TrimSpace(s))
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
if len(parts) < n {
|
|
n = len(parts)
|
|
}
|
|
return strings.Join(parts[:n], " ")
|
|
}
|
|
|
|
func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
|
|
// Normalize line endings
|
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
|
|
|
// Parse PHP-style array from vars.txt
|
|
// Extract only the first "disks" section to avoid duplicates
|
|
disksStart := strings.Index(content, "disks\n(")
|
|
if disksStart == -1 {
|
|
return
|
|
}
|
|
|
|
// Find the end of this disks array (look for next top-level key or end)
|
|
remaining := content[disksStart:]
|
|
endPattern := regexp.MustCompile(`(?m)^[a-z_]+\n\(`)
|
|
endMatches := endPattern.FindAllStringIndex(remaining, -1)
|
|
|
|
var disksSection string
|
|
if len(endMatches) > 1 {
|
|
// Use second match as end (first match is "disks" itself)
|
|
disksSection = remaining[:endMatches[1][0]]
|
|
} else {
|
|
disksSection = remaining
|
|
}
|
|
|
|
// Look for disk entries within this section only
|
|
diskRe := regexp.MustCompile(`(?m)^\s+\[(disk\d+|parity|cache\d*)\]\s+=>\s+Array`)
|
|
matches := diskRe.FindAllStringSubmatch(disksSection, -1)
|
|
|
|
seen := make(map[string]bool)
|
|
for _, match := range matches {
|
|
if len(match) < 2 {
|
|
continue
|
|
}
|
|
diskName := match[1]
|
|
|
|
// Skip if already processed
|
|
if seen[diskName] {
|
|
continue
|
|
}
|
|
seen[diskName] = true
|
|
|
|
// Find the section for this disk
|
|
diskSection := extractDiskSection(disksSection, diskName)
|
|
if diskSection == "" {
|
|
continue
|
|
}
|
|
|
|
var disk models.Storage
|
|
disk.Slot = diskName
|
|
|
|
// Parse disk properties
|
|
if m := regexp.MustCompile(`\[device\]\s*=>\s*(\w+)`).FindStringSubmatch(diskSection); len(m) == 2 {
|
|
disk.Interface = "SATA (" + m[1] + ")"
|
|
}
|
|
|
|
if m := regexp.MustCompile(`\[id\]\s*=>\s*([^\n]+)`).FindStringSubmatch(diskSection); len(m) == 2 {
|
|
idValue := strings.TrimSpace(m[1])
|
|
// Only use if it's not empty or a placeholder
|
|
if idValue != "" && !strings.Contains(idValue, "=>") {
|
|
disk.Model = idValue
|
|
}
|
|
}
|
|
|
|
if m := regexp.MustCompile(`\[size\]\s*=>\s*(\d+)`).FindStringSubmatch(diskSection); len(m) == 2 {
|
|
sizeKB := parseInt(m[1])
|
|
if sizeKB > 0 {
|
|
disk.SizeGB = sizeKB / (1024 * 1024) // Convert KB to GB
|
|
}
|
|
}
|
|
|
|
if m := regexp.MustCompile(`\[temp\]\s*=>\s*(\d+)`).FindStringSubmatch(diskSection); len(m) == 2 {
|
|
temp := parseInt(m[1])
|
|
if temp > 0 {
|
|
result.Sensors = append(result.Sensors, models.SensorReading{
|
|
Name: diskName + "_temp",
|
|
Type: "temperature",
|
|
Value: float64(temp),
|
|
Unit: "C",
|
|
Status: getTempStatus(temp),
|
|
RawValue: strconv.Itoa(temp),
|
|
})
|
|
}
|
|
}
|
|
|
|
if m := regexp.MustCompile(`\[fsType\]\s*=>\s*(\w+)`).FindStringSubmatch(diskSection); len(m) == 2 {
|
|
fsType := m[1]
|
|
if fsType != "" && fsType != "auto" {
|
|
disk.Type = fsType
|
|
}
|
|
}
|
|
|
|
disk.Present = true
|
|
|
|
// Only add/merge disks with meaningful data
|
|
if disk.Model != "" && disk.SizeGB > 0 {
|
|
// Check if we already have this disk from SMART files
|
|
if existing, ok := storageBySlot[diskName]; ok {
|
|
// Merge vars.txt data into existing entry, preferring SMART data
|
|
if existing.Model == "" && disk.Model != "" {
|
|
existing.Model = disk.Model
|
|
}
|
|
if existing.SizeGB == 0 && disk.SizeGB > 0 {
|
|
existing.SizeGB = disk.SizeGB
|
|
}
|
|
if existing.Type == "" && disk.Type != "" {
|
|
existing.Type = disk.Type
|
|
}
|
|
if existing.Interface == "" && disk.Interface != "" {
|
|
existing.Interface = disk.Interface
|
|
}
|
|
// vars.txt doesn't have serial/firmware, so don't overwrite from SMART
|
|
} else {
|
|
// New disk not in SMART data
|
|
storageBySlot[diskName] = &disk
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseHostIdentityFromVars(content string, result *models.AnalysisResult) {
|
|
if result == nil || result.Hardware == nil {
|
|
return
|
|
}
|
|
serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber)
|
|
if isUsableHostIdentifier(serial) {
|
|
return
|
|
}
|
|
|
|
flashGUID := findVarValue(content, "flashGUID")
|
|
regGUID := findVarValue(content, "regGUID")
|
|
rawUUID := findVarValue(content, "uuid")
|
|
|
|
candidates := []string{flashGUID, regGUID, rawUUID}
|
|
for _, candidate := range candidates {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if !isUsableHostIdentifier(candidate) {
|
|
continue
|
|
}
|
|
result.Hardware.BoardInfo.SerialNumber = candidate
|
|
if result.Hardware.BoardInfo.UUID == "" && candidate == rawUUID {
|
|
result.Hardware.BoardInfo.UUID = candidate
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
func findVarValue(content, key string) string {
|
|
re := regexp.MustCompile(`(?m)^\s*\[` + regexp.QuoteMeta(key) + `\]\s*=>\s*(.+?)\s*$`)
|
|
if m := re.FindStringSubmatch(content); len(m) == 2 {
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isUsableHostIdentifier(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return false
|
|
}
|
|
l := strings.ToLower(v)
|
|
if l == "n/a" || l == "unknown" || l == "none" || l == "not available" {
|
|
return false
|
|
}
|
|
// Unraid may redact GUID values as 1... or 1..7 in diagnostics.
|
|
if strings.Contains(v, "...") || strings.Contains(v, "..") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func extractDiskSection(content, diskName string) string {
|
|
// Find the start of this disk's array section
|
|
startPattern := regexp.MustCompile(`(?m)^\s+\[` + regexp.QuoteMeta(diskName) + `\]\s+=>\s+Array\s*\n\s+\(`)
|
|
startIdx := startPattern.FindStringIndex(content)
|
|
if startIdx == nil {
|
|
return ""
|
|
}
|
|
|
|
// Find the end (next disk or end of disks array)
|
|
endPattern := regexp.MustCompile(`(?m)^\s+\)`)
|
|
remainingContent := content[startIdx[1]:]
|
|
endIdx := endPattern.FindStringIndex(remainingContent)
|
|
|
|
if endIdx == nil {
|
|
return remainingContent
|
|
}
|
|
|
|
return remainingContent[:endIdx[0]]
|
|
}
|
|
|
|
func parseSMARTFileToMap(content, filePath string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
|
|
// Extract disk name from filename
|
|
// Example: ST4000NM000B-2TF100_WX103EC9-20260205-2333 disk1 (sdi).txt
|
|
diskName := ""
|
|
if m := regexp.MustCompile(`(disk\d+|parity|cache\d*)`).FindStringSubmatch(filePath); len(m) > 0 {
|
|
diskName = m[1]
|
|
}
|
|
if diskName == "" {
|
|
return
|
|
}
|
|
|
|
var disk models.Storage
|
|
disk.Slot = diskName
|
|
|
|
// Parse device model
|
|
if m := regexp.MustCompile(`(?m)^Device Model:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
disk.Model = strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// Parse serial number
|
|
if m := regexp.MustCompile(`(?m)^Serial Number:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
disk.SerialNumber = strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// Parse firmware version
|
|
if m := regexp.MustCompile(`(?m)^Firmware Version:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
disk.Firmware = strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// Parse capacity
|
|
if m := regexp.MustCompile(`(?m)^User Capacity:\s+([\d,]+)\s+bytes`).FindStringSubmatch(content); len(m) == 2 {
|
|
capacityStr := strings.ReplaceAll(m[1], ",", "")
|
|
if capacity, err := strconv.ParseInt(capacityStr, 10, 64); err == nil {
|
|
disk.SizeGB = int(capacity / 1_000_000_000)
|
|
}
|
|
}
|
|
|
|
// Parse rotation rate
|
|
if m := regexp.MustCompile(`(?m)^Rotation Rate:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
rateStr := strings.TrimSpace(m[1])
|
|
if strings.Contains(strings.ToLower(rateStr), "solid state") {
|
|
disk.Type = "ssd"
|
|
} else {
|
|
disk.Type = "hdd"
|
|
}
|
|
}
|
|
|
|
// Parse SATA version for interface
|
|
if m := regexp.MustCompile(`(?m)^SATA Version is:\s+(.+?)(?:,|$)`).FindStringSubmatch(content); len(m) == 2 {
|
|
disk.Interface = strings.TrimSpace(m[1])
|
|
}
|
|
|
|
// Parse SMART health
|
|
if m := regexp.MustCompile(`(?m)^SMART overall-health self-assessment test result:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
|
health := strings.TrimSpace(m[1])
|
|
if !strings.EqualFold(health, "PASSED") {
|
|
result.Events = append(result.Events, models.Event{
|
|
Timestamp: time.Now(),
|
|
Source: "SMART",
|
|
EventType: "Disk Health",
|
|
Severity: models.SeverityWarning,
|
|
Description: "SMART health check failed for " + diskName,
|
|
RawData: health,
|
|
})
|
|
}
|
|
}
|
|
|
|
disk.Present = true
|
|
|
|
// Only add/merge if we got meaningful data
|
|
if disk.Model != "" || disk.SerialNumber != "" {
|
|
// Check if we already have this disk from vars.txt
|
|
if existing, ok := storageBySlot[diskName]; ok {
|
|
// Merge SMART data into existing entry
|
|
if existing.Model == "" && disk.Model != "" {
|
|
existing.Model = disk.Model
|
|
}
|
|
if existing.SerialNumber == "" && disk.SerialNumber != "" {
|
|
existing.SerialNumber = disk.SerialNumber
|
|
}
|
|
if existing.Firmware == "" && disk.Firmware != "" {
|
|
existing.Firmware = disk.Firmware
|
|
}
|
|
if existing.SizeGB == 0 && disk.SizeGB > 0 {
|
|
existing.SizeGB = disk.SizeGB
|
|
}
|
|
if existing.Type == "" && disk.Type != "" {
|
|
existing.Type = disk.Type
|
|
}
|
|
if existing.Interface == "" && disk.Interface != "" {
|
|
existing.Interface = disk.Interface
|
|
}
|
|
} else {
|
|
// New disk not in vars.txt
|
|
storageBySlot[diskName] = &disk
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseSyslog(content string, result *models.AnalysisResult) {
|
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
lineCount := 0
|
|
maxLines := 100 // Limit parsing to avoid too many events
|
|
|
|
for scanner.Scan() && lineCount < maxLines {
|
|
line := scanner.Text()
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse syslog line
|
|
// Example: Feb 5 23:33:01 box3 kernel: Linux version 6.12.54-Unraid
|
|
timestamp, message, severity := parseSyslogLine(line)
|
|
|
|
result.Events = append(result.Events, models.Event{
|
|
Timestamp: timestamp,
|
|
Source: "syslog",
|
|
EventType: "System Log",
|
|
Severity: severity,
|
|
Description: message,
|
|
RawData: line,
|
|
})
|
|
|
|
lineCount++
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
result.Events = append(result.Events, models.Event{
|
|
Timestamp: time.Now(),
|
|
Source: "syslog",
|
|
EventType: "System Log",
|
|
Severity: models.SeverityWarning,
|
|
Description: "syslog scan error",
|
|
RawData: err.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
func parseSyslogLine(line string) (time.Time, string, models.Severity) {
|
|
// Simple syslog parser
|
|
// Format: Feb 5 23:33:01 hostname process[pid]: message
|
|
timestamp := time.Now()
|
|
message := line
|
|
severity := models.SeverityInfo
|
|
|
|
// Try to parse timestamp
|
|
syslogRe := regexp.MustCompile(`^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+(.+)$`)
|
|
if m := syslogRe.FindStringSubmatch(line); len(m) == 3 {
|
|
timeStr := m[1]
|
|
message = m[2]
|
|
|
|
// Parse timestamp (add current year)
|
|
year := time.Now().Year()
|
|
if ts, err := parser.ParseInDefaultArchiveLocation("Jan 2 15:04:05 2006", timeStr+" "+strconv.Itoa(year)); err == nil {
|
|
timestamp = ts
|
|
}
|
|
}
|
|
|
|
// Classify severity
|
|
lowerMsg := strings.ToLower(message)
|
|
switch {
|
|
case strings.Contains(lowerMsg, "panic"),
|
|
strings.Contains(lowerMsg, "fatal"),
|
|
strings.Contains(lowerMsg, "critical"):
|
|
severity = models.SeverityCritical
|
|
|
|
case strings.Contains(lowerMsg, "error"),
|
|
strings.Contains(lowerMsg, "warning"),
|
|
strings.Contains(lowerMsg, "failed"):
|
|
severity = models.SeverityWarning
|
|
|
|
default:
|
|
severity = models.SeverityInfo
|
|
}
|
|
|
|
return timestamp, message, severity
|
|
}
|
|
|
|
func getTempStatus(temp int) string {
|
|
switch {
|
|
case temp >= 60:
|
|
return "critical"
|
|
case temp >= 50:
|
|
return "warning"
|
|
default:
|
|
return "ok"
|
|
}
|
|
}
|
|
|
|
func parseInt(s string) int {
|
|
v, _ := strconv.Atoi(strings.TrimSpace(s))
|
|
return v
|
|
}
|
|
|
|
func parseFloat(s string) float64 {
|
|
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
return v
|
|
}
|