package xfusion import ( "fmt" "strconv" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) // ── FRU ────────────────────────────────────────────────────────────────────── // parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo. // The file contains IPMI FRU blocks separated by "FRU Device Description" header lines. func parseFRUInfo(content []byte, result *models.AnalysisResult) { type fruBlock struct { header string fields map[string]string } var blocks []fruBlock var current *fruBlock for _, line := range strings.Split(string(content), "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "FRU Device Description") { if current != nil { blocks = append(blocks, *current) } current = &fruBlock{header: trimmed, fields: make(map[string]string)} continue } if current == nil { continue } idx := strings.Index(trimmed, " : ") if idx < 0 { continue } key := strings.TrimSpace(trimmed[:idx]) val := strings.TrimSpace(trimmed[idx+3:]) if key != "" && current.fields[key] == "" { current.fields[key] = val } } if current != nil { blocks = append(blocks, *current) } for _, b := range blocks { f := b.fields fru := models.FRUInfo{ Description: extractFRUHeaderDesc(b.header), Manufacturer: firstNonEmpty(f["Board Manufacturer"], f["Product Manufacturer"]), ProductName: f["Product Name"], SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), MfgDate: f["Board Mfg. Date"], } if fru.Description != "" || fru.ProductName != "" || fru.SerialNumber != "" { result.FRU = append(result.FRU, fru) } } // Set BoardInfo from the mainboard block (ID 0). for _, b := range blocks { hdr := strings.ToLower(b.header) if strings.Contains(hdr, "id 0") || strings.Contains(hdr, "mainboard") { f := b.fields result.Hardware.BoardInfo = models.BoardInfo{ Manufacturer: firstNonEmpty(f["Product Manufacturer"], f["Board Manufacturer"]), ProductName: firstNonEmpty(f["Product Name"], f["Board Product Name"]), SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), } break } } } func extractFRUHeaderDesc(header string) string { // "FRU Device Description : Builtin FRU Device (ID 0, Mainboard)" idx := strings.Index(header, " : ") if idx >= 0 { return strings.TrimSpace(header[idx+3:]) } return header } func firstNonEmpty(vals ...string) string { for _, v := range vals { v = strings.TrimSpace(v) if v != "" { return v } } return "" } // ── Sensors ─────────────────────────────────────────────────────────────────── // parseSensorInfo parses the pipe-delimited IPMI sensor table from sensor_info.txt. // Columns: sensor id | sensor name | value | unit | status | thresholds... func parseSensorInfo(content []byte) []models.SensorReading { var sensors []models.SensorReading inTable := false for _, line := range strings.Split(string(content), "\n") { if strings.Contains(line, "sensor id") && strings.Contains(line, "sensor name") { inTable = true continue } if inTable && strings.HasPrefix(strings.TrimSpace(line), "**") { // "*** Detailed Voltage Object Information ***" signals end of main table inTable = false continue } if !inTable || !strings.Contains(line, "|") { continue } parts := strings.Split(line, "|") if len(parts) < 5 { continue } name := strings.TrimSpace(parts[1]) valueStr := strings.TrimSpace(parts[2]) unit := strings.TrimSpace(parts[3]) status := strings.TrimSpace(parts[4]) if name == "" || valueStr == "na" || unit == "discrete" || unit == "unspecified" { continue } value, err := strconv.ParseFloat(valueStr, 64) if err != nil { continue } sensors = append(sensors, models.SensorReading{ Name: name, Type: sensorType(name, unit), Value: value, Unit: mapSensorUnit(unit), RawValue: valueStr, Status: status, }) } return sensors } func mapSensorUnit(u string) string { switch strings.ToLower(strings.TrimSpace(u)) { case "degrees c": return "C" case "volts": return "V" case "watts": return "W" case "rpm": return "RPM" case "amps": return "A" default: return u } } func sensorType(name, unit string) string { u := strings.ToLower(unit) n := strings.ToLower(name) switch { case strings.Contains(u, "degrees"): return "temperature" case strings.Contains(u, "volts"): return "voltage" case strings.Contains(u, "watts"): return "power" case strings.Contains(u, "rpm") || strings.Contains(n, "fan") && strings.Contains(n, "speed"): return "fan" case strings.Contains(u, "amps"): return "current" default: return "" } } // ── CPU ─────────────────────────────────────────────────────────────────────── // parseCPUInfo parses the comma-separated cpu_info file. // Columns: slot, presence, model, processorID, cores, threads, flags, L1, L2, L3, partNum, devName, location, SN func parseCPUInfo(content []byte) []models.CPU { var cpus []models.CPU lines := strings.Split(string(content), "\n") for i, line := range lines { if i == 0 { // skip header continue } line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) < 6 { continue } slot := strings.TrimSpace(parts[0]) if !strings.HasPrefix(strings.ToLower(slot), "cpu") { continue } if strings.ToLower(strings.TrimSpace(parts[1])) != "present" { continue } socketNum := 0 fmt.Sscanf(strings.ToLower(slot), "cpu%d", &socketNum) model := strings.TrimSpace(parts[2]) cores := 0 fmt.Sscanf(strings.TrimSpace(parts[4]), "%d", &cores) threads := 0 fmt.Sscanf(strings.TrimSpace(parts[5]), "%d", &threads) l1, l2, l3 := 0, 0, 0 if len(parts) >= 10 { l1 = parseCacheSizeKB(parts[7]) l2 = parseCacheSizeKB(parts[8]) l3 = parseCacheSizeKB(parts[9]) } sn := "" if len(parts) >= 14 { sn = strings.TrimSpace(parts[13]) } cpus = append(cpus, models.CPU{ Socket: socketNum, Model: model, Cores: cores, Threads: threads, L1CacheKB: l1, L2CacheKB: l2, L3CacheKB: l3, SerialNumber: sn, Status: "ok", }) } return cpus } func parseCacheSizeKB(s string) int { var n int fmt.Sscanf(strings.TrimSpace(s), "%d", &n) return n } // ── Memory ──────────────────────────────────────────────────────────────────── // parseMemInfo parses the comma-separated mem_info file. // Columns: slot, location, dimmName, manufacturer, size, maxSpeed, curSpeed, type, SN, voltage, rank, bitWidth, tech, bom, partNum, ..., health func parseMemInfo(content []byte) []models.MemoryDIMM { var dimms []models.MemoryDIMM lines := strings.Split(string(content), "\n") for i, line := range lines { if i == 0 { continue } line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) < 9 { continue } sn := strings.TrimSpace(parts[8]) if strings.ToLower(sn) == "no dimm" || sn == "" { continue } slot := strings.TrimSpace(parts[0]) location := strings.TrimSpace(parts[1]) manufacturer := strings.TrimSpace(parts[3]) if strings.ToLower(manufacturer) == "unknown" { manufacturer = "" } sizeMB := 0 fmt.Sscanf(strings.TrimSpace(parts[4]), "%d MB", &sizeMB) maxSpeedMHz := 0 fmt.Sscanf(strings.TrimSpace(parts[5]), "%d MT/s", &maxSpeedMHz) curSpeedMHz := 0 fmt.Sscanf(strings.TrimSpace(parts[6]), "%d MT/s", &curSpeedMHz) memType := strings.TrimSpace(parts[7]) if strings.ToLower(memType) == "unknown" { memType = "" } ranks := 0 if len(parts) >= 11 { fmt.Sscanf(strings.TrimSpace(parts[10]), "%d rank", &ranks) } partNum := "" if len(parts) >= 15 { v := strings.TrimSpace(parts[14]) if strings.ToLower(v) != "no dimm" && strings.ToLower(v) != "unknown" { partNum = v } } status := "ok" if len(parts) >= 22 { if s := strings.TrimSpace(parts[21]); strings.ToLower(s) != "ok" && s != "" { status = strings.ToLower(s) } } dimms = append(dimms, models.MemoryDIMM{ Slot: slot, Location: location, Present: true, SizeMB: sizeMB, Type: memType, MaxSpeedMHz: maxSpeedMHz, CurrentSpeedMHz: curSpeedMHz, Manufacturer: manufacturer, SerialNumber: sn, PartNumber: partNum, Ranks: ranks, Status: status, }) } return dimms } // ── Card Info (GPU + NIC) ───────────────────────────────────────────────────── // parseCardInfo parses card_info file, extracting GPU and NIC entries. // The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table. func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) { sections := splitPipeSections(content) // Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info type pcieEntry struct { bdf string vendorID int deviceID int desc string } slotPCIe := make(map[string]pcieEntry) for _, row := range sections["pcie card info"] { slot := strings.TrimSpace(row["slot"]) seg := parseHexInt(row["segment number"]) bus := parseHexInt(row["bus number"]) dev := parseHexInt(row["device number"]) fn := parseHexInt(row["function number"]) slotPCIe[slot] = pcieEntry{ bdf: fmt.Sprintf("%04x:%02x:%02x.%d", seg, bus, dev, fn), vendorID: parseHexInt(row["vender id"]), deviceID: parseHexInt(row["device id"]), desc: strings.TrimSpace(row["card desc"]), } } // GPU Card Info: slot, name, manufacturer, serialNum, firmVer, SBE/DBE counts for _, row := range sections["gpu card info"] { slot := strings.TrimSpace(row["slot"]) name := strings.TrimSpace(row["name"]) manufacturer := strings.TrimSpace(row["manufacturer"]) serial := strings.TrimSpace(row["serialnum"]) firmware := strings.TrimSpace(row["firmver"]) sbeCount, dbeCount := 0, 0 fmt.Sscanf(strings.TrimSpace(row["sbe"]), "%d", &sbeCount) fmt.Sscanf(strings.TrimSpace(row["dbe"]), "%d", &dbeCount) pcie := slotPCIe[slot] gpu := models.GPU{ Slot: slot, Model: name, Manufacturer: manufacturer, SerialNumber: serial, Firmware: firmware, BDF: pcie.bdf, VendorID: pcie.vendorID, DeviceID: pcie.deviceID, Status: "ok", } if dbeCount > 0 { gpu.Status = "warning" } gpus = append(gpus, gpu) } // OCP Card Info: NIC cards for i, row := range sections["ocp card info"] { desc := strings.TrimSpace(row["card desc"]) sn := strings.TrimSpace(row["serialnumber"]) nics = append(nics, models.NIC{ Name: fmt.Sprintf("OCP%d", i+1), Model: desc, SerialNumber: sn, }) } return gpus, nics } // splitPipeSections parses a multi-section file where each section starts with a // plain header line (no "|") ending in "Info" or "info", followed by a pipe-table. // Returns a map from lowercased section name → rows (each row is a map of lowercase header → value). func splitPipeSections(content []byte) map[string][]map[string]string { result := make(map[string][]map[string]string) var sectionName string var headers []string for _, line := range strings.Split(string(content), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if !strings.Contains(trimmed, "|") { if strings.HasSuffix(strings.ToLower(trimmed), "info") { sectionName = strings.ToLower(trimmed) headers = nil } continue } parts := strings.Split(line, "|") if len(parts) < 2 { continue } cols := make([]string, len(parts)) for i, p := range parts { cols[i] = strings.TrimSpace(p) } if headers == nil { headers = make([]string, len(cols)) for i, h := range cols { headers[i] = strings.ToLower(h) } continue } row := make(map[string]string, len(headers)) for i, h := range headers { if i < len(cols) { row[h] = cols[i] } } result[sectionName] = append(result[sectionName], row) } return result } func parseHexInt(s string) int { s = strings.TrimSpace(s) s = strings.TrimPrefix(strings.ToLower(s), "0x") n, _ := strconv.ParseInt(s, 16, 64) return int(n) } // ── PSU ─────────────────────────────────────────────────────────────────────── // parsePSUInfo parses the pipe-delimited psu_info.txt. // Columns: Slot | presence | Manufacturer | Type | SN | Version | Rated Power | InputMode | PartNum | DeviceName | Vin | ... func parsePSUInfo(content []byte) []models.PSU { var psus []models.PSU var headers []string for _, line := range strings.Split(string(content), "\n") { if !strings.Contains(line, "|") { continue } parts := strings.Split(line, "|") cols := make([]string, len(parts)) for i, p := range parts { cols[i] = strings.TrimSpace(p) } if headers == nil { headers = make([]string, len(cols)) for i, h := range cols { headers[i] = strings.ToLower(h) } continue } row := make(map[string]string, len(headers)) for i, h := range headers { if i < len(cols) { row[h] = cols[i] } } if strings.ToLower(row["presence"]) != "present" { continue } wattage := 0 fmt.Sscanf(row["rated power"], "%d", &wattage) inputVoltage := 0.0 fmt.Sscanf(row["vin"], "%f", &inputVoltage) psus = append(psus, models.PSU{ Slot: row["slot"], Present: true, Model: row["type"], Vendor: row["manufacturer"], SerialNumber: row["sn"], PartNumber: row["partnum"], Firmware: row["version"], WattageW: wattage, InputType: row["inputmode"], InputVoltage: inputVoltage, Status: "ok", }) } return psus } // ── Storage ─────────────────────────────────────────────────────────────────── // parseStorageControllerInfo parses RAID_Controller_Info.txt and adds firmware entries. func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) { // File may contain multiple controller blocks; parse key:value pairs from each. // We only look at the first occurrence of each key (first controller). text := string(content) blocks := strings.Split(text, "RAID Controller #") for _, block := range blocks[1:] { // skip pre-block preamble fields := parseKeyValueBlock([]byte(block)) name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"]) firmware := fields["Firmware Version"] if name != "" && firmware != "" { result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ DeviceName: name, Description: fields["Controller Name"], Version: firmware, }) } } } // parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file. func parseDiskInfo(content []byte) *models.Storage { fields := parseKeyValueBlock(content) model := fields["Model"] sn := fields["Serial Number"] if model == "" && sn == "" { return nil } sizeGB := 0 var capFloat float64 if _, err := fmt.Sscanf(fields["Capacity"], "%f GB", &capFloat); err == nil { sizeGB = int(capFloat) } var wearPct *int if wearStr := fields["Remnant Media Wearout"]; wearStr != "" { var pct int if _, err := fmt.Sscanf(wearStr, "%d%%", &pct); err == nil { wearPct = &pct } } status := "ok" if h := strings.ToLower(fields["Health Status"]); h != "" && h != "normal" { status = h } return &models.Storage{ Slot: firstNonEmpty(fields["Device Name"], fields["ID"]), Type: fields["Media Type"], Model: model, SizeGB: sizeGB, SerialNumber: sn, Manufacturer: fields["Manufacturer"], Firmware: fields["Firmware Version"], Interface: fields["Interface Type"], Present: true, RemainingEndurancePct: wearPct, Status: status, } } // parseKeyValueBlock parses "Key (spaces) : Value" lines from a text block. func parseKeyValueBlock(content []byte) map[string]string { result := make(map[string]string) for _, line := range strings.Split(string(content), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "=") || strings.HasPrefix(line, "-") { continue } idx := strings.Index(line, " : ") if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+3:]) if key != "" && result[key] == "" { result[key] = val } } return result } // ── Events ──────────────────────────────────────────────────────────────────── // parseMaintenanceLog parses the iBMC maintenance_log file. // Line format: "YYYY-MM-DD HH:MM:SS LEVEL : CODE,description" func parseMaintenanceLog(content []byte) []models.Event { var events []models.Event for _, line := range strings.Split(string(content), "\n") { line = strings.TrimSpace(line) if len(line) < 20 { continue } ts, err := time.Parse("2006-01-02 15:04:05", line[:19]) if err != nil || ts.Year() <= 1970 { continue // skip epoch-0 boot artifacts } rest := strings.TrimSpace(line[19:]) sepIdx := strings.Index(rest, " : ") if sepIdx < 0 { sepIdx = strings.Index(rest, ": ") if sepIdx < 0 { continue } } else { sepIdx++ // skip leading space for " : " } levelStr := strings.TrimSpace(rest[:sepIdx-1]) body := strings.TrimSpace(rest[sepIdx+2:]) code := body description := "" if ci := strings.Index(body, ","); ci >= 0 { code = body[:ci] description = strings.TrimSpace(body[ci+1:]) } var severity models.Severity switch strings.ToUpper(levelStr) { case "WARN", "WARNING": severity = models.SeverityWarning case "ERROR", "ERR", "CRIT", "CRITICAL": severity = models.SeverityCritical default: severity = models.SeverityInfo } events = append(events, models.Event{ Timestamp: ts, Source: "ibmc", EventType: code, Severity: severity, Description: description, RawData: line, }) } return events } // ── unused import guard ─────────────────────────────────────────────────────── var _ = parser.ExtractedFile{}