// 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" ) // parserVersion - increment when parsing logic changes. const parserVersion = "1.0.0" 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) // 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/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"): parseVarsToMap(content, storageBySlot, 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) } } // 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)`).FindStringSubmatch(content); len(m) >= 3 { size := parseFloat(m[1]) unit := m[2] var sizeMB int switch unit { case "Ki": sizeMB = int(size / 1024) case "Mi": sizeMB = int(size) case "Gi": sizeMB = int(size * 1024) case "Ti": 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 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 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 := time.Parse("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 }