// Package xigmanas provides parser for XigmaNAS diagnostic dumps. package xigmanas import ( "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 = "2.0.0" func init() { parser.Register(&Parser{}) } // Parser implements VendorParser for XigmaNAS logs. type Parser struct{} func (p *Parser) Name() string { return "XigmaNAS Parser" } func (p *Parser) Vendor() string { return "xigmanas" } func (p *Parser) Version() string { return parserVersion } // Detect checks if files contain typical XigmaNAS markers. func (p *Parser) Detect(files []parser.ExtractedFile) int { confidence := 0 for _, f := range files { path := strings.ToLower(f.Path) content := strings.ToLower(string(f.Content)) if strings.Contains(path, "xigmanas") || strings.HasSuffix(path, "dmesg") { confidence += 20 } if strings.Contains(content, `loader_brand="xigmanas"`) { confidence += 70 } if strings.Contains(content, "xigmanas kernel build") { confidence += 35 } if strings.Contains(content, "system uptime:") && strings.Contains(content, "routing tables:") { confidence += 20 } if strings.Contains(content, "s.m.a.r.t. [/dev/") { confidence += 10 } if confidence >= 100 { return 100 } } if confidence > 100 { return 100 } return confidence } // Parse parses XigmaNAS logs 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), }, } content := joinFileContents(files) if strings.TrimSpace(content) == "" { return result, nil } parseSystemInfo(content, result) parseCPU(content, result) parseMemory(content, result) parseUptime(content, result) parseZFSState(content, result) parseStorageAndSMART(content, result) return result, nil } func joinFileContents(files []parser.ExtractedFile) string { var b strings.Builder for _, f := range files { b.Write(f.Content) b.WriteString("\n") } return b.String() } func parseSystemInfo(content string, result *models.AnalysisResult) { if m := regexp.MustCompile(`(?m)^Version:\s*\n-+\s*\n([^\n]+)`).FindStringSubmatch(content); len(m) == 2 { result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ DeviceName: "XigmaNAS", Version: strings.TrimSpace(m[1]), }) } if m := regexp.MustCompile(`(?m)^smbios\.bios\.version="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ DeviceName: "System BIOS", Version: strings.TrimSpace(m[1]), }) } board := models.BoardInfo{} if m := regexp.MustCompile(`(?m)^smbios\.system\.maker="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { board.Manufacturer = strings.TrimSpace(m[1]) } if m := regexp.MustCompile(`(?m)^smbios\.system\.product="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { board.ProductName = strings.TrimSpace(m[1]) } if m := regexp.MustCompile(`(?m)^smbios\.system\.serial="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { board.SerialNumber = strings.TrimSpace(m[1]) } if m := regexp.MustCompile(`(?m)^smbios\.system\.uuid="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 { board.UUID = strings.TrimSpace(m[1]) } result.Hardware.BoardInfo = board } func parseCPU(content string, result *models.AnalysisResult) { var cores, threads int if m := regexp.MustCompile(`(?m)^FreeBSD/SMP:\s+\d+\s+package\(s\)\s+x\s+(\d+)\s+core\(s\)`).FindStringSubmatch(content); len(m) == 2 { cores = parseInt(m[1]) threads = cores } seen := map[string]struct{}{} cpuRe := regexp.MustCompile(`(?m)^CPU:\s+(.+?)\s+\(([\d.]+)-MHz`) for _, m := range cpuRe.FindAllStringSubmatch(content, -1) { model := strings.TrimSpace(m[1]) if _, ok := seen[model]; ok { continue } seen[model] = struct{}{} result.Hardware.CPUs = append(result.Hardware.CPUs, models.CPU{ Socket: len(result.Hardware.CPUs), Model: model, Cores: cores, Threads: threads, FrequencyMHz: int(parseFloat(m[2])), }) } } func parseMemory(content string, result *models.AnalysisResult) { if m := regexp.MustCompile(`(?m)^real memory\s*=\s*\d+\s+\((\d+)\s+MB\)`).FindStringSubmatch(content); len(m) == 2 { result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{ Slot: "system", Present: true, SizeMB: parseInt(m[1]), Type: "DRAM", Status: "ok", }) return } // Fallback for logs that only have active/inactive breakdown. if m := regexp.MustCompile(`(?m)^Mem:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 { totalMB := 0 tokenRe := regexp.MustCompile(`(\d+)M`) for _, t := range tokenRe.FindAllStringSubmatch(m[1], -1) { totalMB += parseInt(t[1]) } if totalMB > 0 { result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{ Slot: "system", Present: true, SizeMB: totalMB, Type: "DRAM", Status: "estimated", }) } } } func parseUptime(content string, result *models.AnalysisResult) { upRe := regexp.MustCompile(`(?m)^(\d+:\d+(?:AM|PM))\s+up\s+(.+?),\s+(\d+)\s+users?,\s+load averages?:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)$`) m := upRe.FindStringSubmatch(content) if len(m) != 7 { return } result.Events = append(result.Events, models.Event{ Timestamp: time.Now(), Source: "System", EventType: "Uptime", Severity: models.SeverityInfo, Description: "System uptime and load averages parsed", RawData: "time=" + m[1] + "; uptime=" + m[2] + "; users=" + m[3] + "; load=" + m[4] + "," + m[5] + "," + m[6], }) } func parseZFSState(content string, result *models.AnalysisResult) { m := regexp.MustCompile(`(?m)^state:\s+([A-Z]+)$`).FindStringSubmatch(content) if len(m) != 2 { return } state := m[1] severity := models.SeverityInfo if state != "ONLINE" { severity = models.SeverityWarning } result.Events = append(result.Events, models.Event{ Timestamp: time.Now(), Source: "ZFS", EventType: "Pool State", Severity: severity, Description: "ZFS pool state: " + state, RawData: state, }) } func parseStorageAndSMART(content string, result *models.AnalysisResult) { type smartInfo struct { model string serial string firmware string health string tempC int capacityB int64 } storageBySlot := make(map[string]*models.Storage) scsiRe := regexp.MustCompile(`(?m)^<([^>]+)>\s+at\s+scbus\d+\s+target\s+\d+\s+lun\s+\d+\s+\(([^,]+),([^)]+)\)$`) for _, m := range scsiRe.FindAllStringSubmatch(content, -1) { slot := strings.TrimSpace(m[3]) model, fw := splitModelAndFirmware(strings.TrimSpace(m[1])) entry := &models.Storage{ Slot: slot, Type: guessStorageType(slot), Model: model, Firmware: fw, Present: true, Interface: "SCSI/SATA", } storageBySlot[slot] = entry } smartBySlot := make(map[string]smartInfo) sectionRe := regexp.MustCompile(`(?m)^S\.M\.A\.R\.T\.\s+\[(/dev/[^\]]+)\]:\s*\n-+\n`) sections := sectionRe.FindAllStringSubmatchIndex(content, -1) for i, sec := range sections { // sec indexes: // [0]=full start, [1]=full end, [2]=capture 1 start, [3]=capture 1 end if len(sec) < 4 { continue } slot := strings.TrimPrefix(strings.TrimSpace(content[sec[2]:sec[3]]), "/dev/") bodyStart := sec[1] bodyEnd := len(content) if i+1 < len(sections) { bodyEnd = sections[i+1][0] } body := content[bodyStart:bodyEnd] info := smartInfo{ model: findFirst(body, `(?m)^Device Model:\s+(.+)$`), serial: findFirst(body, `(?m)^Serial Number:\s+(.+)$`), firmware: findFirst(body, `(?m)^Firmware Version:\s+(.+)$`), health: findFirst(body, `(?m)^SMART overall-health self-assessment test result:\s+(.+)$`), } info.capacityB = parseCapacityBytes(findFirst(body, `(?m)^User Capacity:\s+([\d,]+)\s+bytes`)) if t := findFirst(body, `(?m)^\s*194\s+Temperature_Celsius.*?-\s+(\d+)(?:\s|\()`); t != "" { info.tempC = parseInt(t) } smartBySlot[slot] = info if info.tempC > 0 { status := "ok" if info.health != "" && !strings.EqualFold(info.health, "PASSED") { status = "warning" } result.Sensors = append(result.Sensors, models.SensorReading{ Name: "disk_temp_" + slot, Type: "temperature", Value: float64(info.tempC), Unit: "C", Status: status, RawValue: strconv.Itoa(info.tempC), }) } if info.health != "" && !strings.EqualFold(info.health, "PASSED") { result.Events = append(result.Events, models.Event{ Timestamp: time.Now(), Source: "SMART", EventType: "Disk Health", Severity: models.SeverityWarning, Description: "SMART health is not PASSED for " + slot, RawData: info.health, }) } } // Merge SMART data into storage entries and add missing entries. for slot, info := range smartBySlot { s := storageBySlot[slot] if s == nil { s = &models.Storage{ Slot: slot, Type: guessStorageType(slot), Present: true, Interface: "SATA", } storageBySlot[slot] = s } if s.Model == "" && info.model != "" { s.Model = info.model } if info.serial != "" { s.SerialNumber = info.serial } if s.Firmware == "" && info.firmware != "" { s.Firmware = info.firmware } if info.capacityB > 0 { s.SizeGB = int(info.capacityB / 1_000_000_000) } } for _, s := range storageBySlot { result.Hardware.Storage = append(result.Hardware.Storage, *s) } } func splitModelAndFirmware(raw string) (string, string) { fields := strings.Fields(raw) if len(fields) < 2 { return raw, "" } last := fields[len(fields)-1] // Firmware token is usually compact (e.g. GKAOAB0A, 1.00). if regexp.MustCompile(`^[A-Za-z0-9._-]{2,12}$`).MatchString(last) { return strings.TrimSpace(strings.Join(fields[:len(fields)-1], " ")), last } return raw, "" } func guessStorageType(slot string) string { switch { case strings.HasPrefix(slot, "cd"): return "optical" case strings.HasPrefix(slot, "da"), strings.HasPrefix(slot, "ada"): return "hdd" default: return "unknown" } } func findFirst(content, expr string) string { m := regexp.MustCompile(expr).FindStringSubmatch(content) if len(m) != 2 { return "" } return strings.TrimSpace(m[1]) } func parseCapacityBytes(s string) int64 { clean := strings.ReplaceAll(strings.TrimSpace(s), ",", "") if clean == "" { return 0 } v, err := strconv.ParseInt(clean, 10, 64) if err != nil { return 0 } return v } 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 }