package exporter import ( "fmt" "net/url" "regexp" "sort" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" ) var cpuMicrocodeFirmwareRegex = regexp.MustCompile(`(?i)^cpu\d+\s+microcode$`) // ConvertToReanimator converts AnalysisResult to Reanimator export format func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, error) { if result == nil { return nil, fmt.Errorf("no data available for export") } if result.Hardware == nil { return nil, fmt.Errorf("no hardware data available for export") } if result.Hardware.BoardInfo.SerialNumber == "" { return nil, fmt.Errorf("board serial_number is required for Reanimator export") } // Determine target host (optional field) targetHost := inferTargetHost(result.TargetHost, result.Filename) collectedAt := formatRFC3339(result.CollectedAt) export := &ReanimatorExport{ Filename: result.Filename, SourceType: normalizeSourceType(result.SourceType), Protocol: normalizeProtocol(result.Protocol), TargetHost: targetHost, CollectedAt: collectedAt, Hardware: ReanimatorHardware{ Board: convertBoard(result.Hardware.BoardInfo), Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)), CPUs: dedupeCPUs(convertCPUs(result.Hardware.CPUs, collectedAt)), Memory: dedupeMemory(convertMemory(result.Hardware.Memory, collectedAt)), Storage: dedupeStorage(convertStorage(result.Hardware.Storage, collectedAt)), PCIeDevices: dedupePCIe(convertPCIeDevices(result.Hardware, collectedAt)), PowerSupplies: dedupePSUs(convertPowerSupplies(result.Hardware.PowerSupply, collectedAt)), }, } return export, nil } // formatRFC3339 formats time in RFC3339 format, returns current time if zero func formatRFC3339(t time.Time) string { if t.IsZero() { return time.Now().UTC().Format(time.RFC3339) } return t.UTC().Format(time.RFC3339) } // convertBoard converts BoardInfo to Reanimator format func convertBoard(board models.BoardInfo) ReanimatorBoard { return ReanimatorBoard{ Manufacturer: normalizeNullableString(board.Manufacturer), ProductName: normalizeNullableString(board.ProductName), SerialNumber: board.SerialNumber, PartNumber: board.PartNumber, UUID: board.UUID, } } // convertFirmware converts firmware information to Reanimator format func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware { if len(firmware) == 0 { return nil } result := make([]ReanimatorFirmware, 0, len(firmware)) for _, fw := range firmware { if isDeviceBoundFirmwareName(fw.DeviceName) { continue } result = append(result, ReanimatorFirmware{ DeviceName: fw.DeviceName, Version: fw.Version, }) } if len(result) == 0 { return nil } return result } func isDeviceBoundFirmwareName(name string) bool { n := strings.TrimSpace(strings.ToLower(name)) if n == "" { return false } if strings.HasPrefix(n, "gpu ") || strings.HasPrefix(n, "nvswitch ") || strings.HasPrefix(n, "nic ") || strings.HasPrefix(n, "hdd ") || strings.HasPrefix(n, "ssd ") || strings.HasPrefix(n, "nvme ") || strings.HasPrefix(n, "psu") { return true } return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name)) } // convertCPUs converts CPU information to Reanimator format func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU { if len(cpus) == 0 { return nil } result := make([]ReanimatorCPU, 0, len(cpus)) for _, cpu := range cpus { manufacturer := inferCPUManufacturer(cpu.Model) cpuStatus := normalizeStatus(cpu.Status, false) if strings.TrimSpace(cpu.Status) == "" { cpuStatus = "Unknown" } meta := buildStatusMeta( cpuStatus, cpu.StatusCheckedAt, cpu.StatusChangedAt, cpu.StatusAtCollect, cpu.StatusHistory, cpu.ErrorDescription, collectedAt, ) result = append(result, ReanimatorCPU{ Socket: cpu.Socket, Model: cpu.Model, Cores: cpu.Cores, Threads: cpu.Threads, FrequencyMHz: cpu.FrequencyMHz, MaxFrequencyMHz: cpu.MaxFreqMHz, Manufacturer: manufacturer, Status: cpuStatus, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } return result } // convertMemory converts memory modules to Reanimator format func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorMemory { if len(memory) == 0 { return nil } result := make([]ReanimatorMemory, 0, len(memory)) for _, mem := range memory { status := normalizeStatus(mem.Status, true) if strings.TrimSpace(mem.Status) == "" { if mem.Present { status = "OK" } else { status = "Empty" } } meta := buildStatusMeta( status, mem.StatusCheckedAt, mem.StatusChangedAt, mem.StatusAtCollect, mem.StatusHistory, mem.ErrorDescription, collectedAt, ) result = append(result, ReanimatorMemory{ Slot: mem.Slot, Location: mem.Location, Present: mem.Present, SizeMB: mem.SizeMB, Type: mem.Type, MaxSpeedMHz: mem.MaxSpeedMHz, CurrentSpeedMHz: mem.CurrentSpeedMHz, Manufacturer: mem.Manufacturer, SerialNumber: mem.SerialNumber, PartNumber: mem.PartNumber, Status: status, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } return result } // convertStorage converts storage devices to Reanimator format func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorStorage { if len(storage) == 0 { return nil } result := make([]ReanimatorStorage, 0, len(storage)) for _, stor := range storage { // Skip storage without serial number if stor.SerialNumber == "" { continue } status := inferStorageStatus(stor) if strings.TrimSpace(stor.Status) != "" { status = normalizeStatus(stor.Status, false) } meta := buildStatusMeta( status, stor.StatusCheckedAt, stor.StatusChangedAt, stor.StatusAtCollect, stor.StatusHistory, stor.ErrorDescription, collectedAt, ) result = append(result, ReanimatorStorage{ Slot: stor.Slot, Type: stor.Type, Model: stor.Model, SizeGB: stor.SizeGB, SerialNumber: stor.SerialNumber, Manufacturer: stor.Manufacturer, Firmware: stor.Firmware, Interface: stor.Interface, Present: stor.Present, Status: status, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } return result } // convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe { result := make([]ReanimatorPCIe, 0) gpuSlots := make(map[string]struct{}, len(hw.GPUs)) nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware) for _, gpu := range hw.GPUs { slot := strings.ToLower(strings.TrimSpace(gpu.Slot)) if slot != "" { gpuSlots[slot] = struct{}{} } } // Convert regular PCIe devices for _, pcie := range hw.PCIeDevices { slot := strings.ToLower(strings.TrimSpace(pcie.Slot)) if _, isDedicatedGPU := gpuSlots[slot]; isDedicatedGPU || isDisplayClass(pcie.DeviceClass) { // Skip GPU-like PCIe entries to avoid duplicates: // dedicated GPUs are exported from hw.GPUs with richer metadata. continue } serialNumber := normalizedSerial(pcie.SerialNumber) // Determine model (prefer PartNumber, fallback to DeviceClass) model := pcie.PartNumber if model == "" { model = pcie.DeviceClass } status := normalizeStatus(pcie.Status, false) firmware := "" if isNVSwitchPCIeDevice(pcie) { firmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)] } meta := buildStatusMeta( status, pcie.StatusCheckedAt, pcie.StatusChangedAt, pcie.StatusAtCollect, pcie.StatusHistory, pcie.ErrorDescription, collectedAt, ) result = append(result, ReanimatorPCIe{ Slot: pcie.Slot, VendorID: pcie.VendorID, DeviceID: pcie.DeviceID, BDF: pcie.BDF, DeviceClass: pcie.DeviceClass, Manufacturer: pcie.Manufacturer, Model: model, LinkWidth: pcie.LinkWidth, LinkSpeed: pcie.LinkSpeed, MaxLinkWidth: pcie.MaxLinkWidth, MaxLinkSpeed: pcie.MaxLinkSpeed, SerialNumber: serialNumber, Firmware: firmware, Status: status, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } // Convert GPUs as PCIe devices for _, gpu := range hw.GPUs { serialNumber := normalizedSerial(gpu.SerialNumber) // Determine device class deviceClass := "DisplayController" status := normalizeStatus(gpu.Status, false) meta := buildStatusMeta( status, gpu.StatusCheckedAt, gpu.StatusChangedAt, gpu.StatusAtCollect, gpu.StatusHistory, gpu.ErrorDescription, collectedAt, ) result = append(result, ReanimatorPCIe{ Slot: gpu.Slot, VendorID: gpu.VendorID, DeviceID: gpu.DeviceID, BDF: gpu.BDF, DeviceClass: deviceClass, Manufacturer: gpu.Manufacturer, Model: gpu.Model, LinkWidth: gpu.CurrentLinkWidth, LinkSpeed: gpu.CurrentLinkSpeed, MaxLinkWidth: gpu.MaxLinkWidth, MaxLinkSpeed: gpu.MaxLinkSpeed, SerialNumber: serialNumber, Firmware: gpu.Firmware, Status: status, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } // Convert network adapters as PCIe devices for _, nic := range hw.NetworkAdapters { if !nic.Present { continue } serialNumber := normalizedSerial(nic.SerialNumber) status := normalizeStatus(nic.Status, false) meta := buildStatusMeta( status, nic.StatusCheckedAt, nic.StatusChangedAt, nic.StatusAtCollect, nic.StatusHistory, nic.ErrorDescription, collectedAt, ) result = append(result, ReanimatorPCIe{ Slot: nic.Slot, VendorID: nic.VendorID, DeviceID: nic.DeviceID, BDF: "", DeviceClass: "NetworkController", Manufacturer: nic.Vendor, Model: nic.Model, LinkWidth: 0, LinkSpeed: "", MaxLinkWidth: 0, MaxLinkSpeed: "", SerialNumber: serialNumber, Firmware: nic.Firmware, Status: status, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } return result } func isNVSwitchPCIeDevice(pcie models.PCIeDevice) bool { deviceClass := strings.TrimSpace(pcie.DeviceClass) if strings.EqualFold(deviceClass, "NVSwitch") { return true } slot := normalizeNVSwitchSlotForLookup(pcie.Slot) return strings.HasPrefix(slot, "NVSWITCH") } func buildNVSwitchFirmwareBySlot(firmware []models.FirmwareInfo) map[string]string { result := make(map[string]string) for _, fw := range firmware { name := strings.TrimSpace(fw.DeviceName) if !strings.HasPrefix(strings.ToUpper(name), "NVSWITCH ") { continue } rest := strings.TrimSpace(name[len("NVSwitch "):]) if rest == "" { continue } slot := rest if idx := strings.Index(rest, " ("); idx > 0 { slot = strings.TrimSpace(rest[:idx]) } slot = normalizeNVSwitchSlotForLookup(slot) if slot == "" { continue } if _, exists := result[slot]; exists { continue } version := strings.TrimSpace(fw.Version) if version == "" { continue } result[slot] = version } return result } func normalizeNVSwitchSlotForLookup(slot string) string { normalized := strings.ToUpper(strings.TrimSpace(slot)) if strings.HasPrefix(normalized, "NVSWITCHNVSWITCH") { return "NVSWITCH" + strings.TrimPrefix(normalized, "NVSWITCHNVSWITCH") } return normalized } func isDisplayClass(deviceClass string) bool { class := strings.ToLower(strings.TrimSpace(deviceClass)) return strings.Contains(class, "display") || strings.Contains(class, "vga") || strings.Contains(class, "3d controller") } // convertPowerSupplies converts power supplies to Reanimator format func convertPowerSupplies(psus []models.PSU, collectedAt string) []ReanimatorPSU { if len(psus) == 0 { return nil } result := make([]ReanimatorPSU, 0, len(psus)) for _, psu := range psus { // Skip PSUs without serial number (if not present) if !psu.Present || psu.SerialNumber == "" { continue } status := normalizeStatus(psu.Status, false) meta := buildStatusMeta( status, psu.StatusCheckedAt, psu.StatusChangedAt, psu.StatusAtCollect, psu.StatusHistory, psu.ErrorDescription, collectedAt, ) result = append(result, ReanimatorPSU{ Slot: psu.Slot, Present: psu.Present, Model: psu.Model, Vendor: psu.Vendor, WattageW: psu.WattageW, SerialNumber: psu.SerialNumber, PartNumber: psu.PartNumber, Firmware: psu.Firmware, Status: status, InputType: psu.InputType, InputPowerW: psu.InputPowerW, OutputPowerW: psu.OutputPowerW, InputVoltage: psu.InputVoltage, StatusCheckedAt: meta.StatusCheckedAt, StatusChangedAt: meta.StatusChangedAt, StatusAtCollect: meta.StatusAtCollection, StatusHistory: meta.StatusHistory, ErrorDescription: meta.ErrorDescription, }) } return result } type convertedStatusMeta struct { StatusCheckedAt string StatusChangedAt string StatusAtCollection *ReanimatorStatusAtCollection StatusHistory []ReanimatorStatusHistoryEntry ErrorDescription string } func buildStatusMeta( currentStatus string, checkedAt time.Time, changedAt time.Time, statusAtCollection *models.StatusAtCollection, history []models.StatusHistoryEntry, errorDescription string, collectedAt string, ) convertedStatusMeta { meta := convertedStatusMeta{ StatusCheckedAt: formatOptionalRFC3339(checkedAt), StatusChangedAt: formatOptionalRFC3339(changedAt), ErrorDescription: strings.TrimSpace(errorDescription), } convertedHistory := make([]ReanimatorStatusHistoryEntry, 0, len(history)) for _, h := range history { changed := formatOptionalRFC3339(h.ChangedAt) if changed == "" { continue } convertedHistory = append(convertedHistory, ReanimatorStatusHistoryEntry{ Status: normalizeStatus(h.Status, true), ChangedAt: changed, Details: strings.TrimSpace(h.Details), }) } sort.Slice(convertedHistory, func(i, j int) bool { return convertedHistory[i].ChangedAt < convertedHistory[j].ChangedAt }) if len(convertedHistory) > 0 { meta.StatusHistory = convertedHistory if meta.StatusChangedAt == "" { meta.StatusChangedAt = convertedHistory[len(convertedHistory)-1].ChangedAt } } if statusAtCollection != nil { at := formatOptionalRFC3339(statusAtCollection.At) if at != "" && strings.TrimSpace(statusAtCollection.Status) != "" { meta.StatusAtCollection = &ReanimatorStatusAtCollection{ Status: normalizeStatus(statusAtCollection.Status, true), At: at, } } } if meta.StatusAtCollection == nil && strings.TrimSpace(currentStatus) != "" && collectedAt != "" { meta.StatusAtCollection = &ReanimatorStatusAtCollection{ Status: currentStatus, At: collectedAt, } } if meta.StatusCheckedAt == "" && len(meta.StatusHistory) > 0 { meta.StatusCheckedAt = meta.StatusHistory[len(meta.StatusHistory)-1].ChangedAt } if meta.StatusCheckedAt == "" && strings.TrimSpace(currentStatus) != "" && collectedAt != "" { meta.StatusCheckedAt = collectedAt } return meta } func formatOptionalRFC3339(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func dedupeFirmware(items []ReanimatorFirmware) []ReanimatorFirmware { if len(items) < 2 { return items } seen := make(map[string]struct{}, len(items)) result := make([]ReanimatorFirmware, 0, len(items)) for _, item := range items { key := strings.ToLower(strings.TrimSpace(item.DeviceName)) if key == "" { key = strings.ToLower(strings.TrimSpace(item.Version)) } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result = append(result, item) } return result } func dedupeCPUs(items []ReanimatorCPU) []ReanimatorCPU { if len(items) < 2 { return items } seen := make(map[int]struct{}, len(items)) result := make([]ReanimatorCPU, 0, len(items)) for _, item := range items { if _, ok := seen[item.Socket]; ok { continue } seen[item.Socket] = struct{}{} result = append(result, item) } return result } func dedupeMemory(items []ReanimatorMemory) []ReanimatorMemory { if len(items) < 2 { return items } seen := make(map[string]struct{}, len(items)) result := make([]ReanimatorMemory, 0, len(items)) for _, item := range items { key := strings.ToLower(strings.TrimSpace(item.Slot)) if key == "" { key = strings.ToLower(strings.TrimSpace(item.Location)) } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result = append(result, item) } return result } func dedupeStorage(items []ReanimatorStorage) []ReanimatorStorage { if len(items) < 2 { return items } seen := make(map[string]struct{}, len(items)) result := make([]ReanimatorStorage, 0, len(items)) for _, item := range items { key := strings.ToLower(strings.TrimSpace(item.SerialNumber)) if key == "" { key = "slot:" + strings.ToLower(strings.TrimSpace(item.Slot)) } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result = append(result, item) } return result } func dedupePSUs(items []ReanimatorPSU) []ReanimatorPSU { if len(items) < 2 { return items } seen := make(map[string]struct{}, len(items)) result := make([]ReanimatorPSU, 0, len(items)) for _, item := range items { key := strings.ToLower(strings.TrimSpace(item.SerialNumber)) if key == "" { key = "slot:" + strings.ToLower(strings.TrimSpace(item.Slot)) } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result = append(result, item) } return result } func dedupePCIe(items []ReanimatorPCIe) []ReanimatorPCIe { if len(items) < 2 { return items } type scored struct { item ReanimatorPCIe score int idx int } byKey := make(map[string]scored, len(items)) order := make([]string, 0, len(items)) for i, item := range items { key := pcieDedupKey(item) curr := scored{item: item, score: pcieQualityScore(item), idx: i} existing, ok := byKey[key] if !ok { byKey[key] = curr order = append(order, key) continue } if curr.score > existing.score { byKey[key] = curr } } result := make([]ReanimatorPCIe, 0, len(byKey)) for _, key := range order { result = append(result, byKey[key].item) } return result } func pcieDedupKey(item ReanimatorPCIe) string { slot := strings.ToLower(strings.TrimSpace(item.Slot)) serial := strings.ToLower(strings.TrimSpace(item.SerialNumber)) bdf := strings.ToLower(strings.TrimSpace(item.BDF)) if slot != "" { return "slot:" + slot } if serial != "" { return "sn:" + serial } if bdf != "" { return "bdf:" + bdf } return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model)) } func pcieQualityScore(item ReanimatorPCIe) int { score := 0 if strings.TrimSpace(item.SerialNumber) != "" { score += 4 } if strings.TrimSpace(item.Model) != "" && !isGenericPCIeModel(item.Model) { score += 3 } status := strings.ToLower(strings.TrimSpace(item.Status)) if status == "ok" || status == "warning" || status == "critical" { score += 2 } if strings.TrimSpace(item.BDF) != "" { score++ } if strings.EqualFold(strings.TrimSpace(item.DeviceClass), "DisplayController") { score++ } return score } func isGenericPCIeModel(model string) bool { switch strings.ToLower(strings.TrimSpace(model)) { case "", "unknown", "vga", "3d controller", "display controller": return true default: return false } } // inferCPUManufacturer determines CPU manufacturer from model string func inferCPUManufacturer(model string) string { upper := strings.ToUpper(model) // Intel patterns if strings.Contains(upper, "INTEL") || strings.Contains(upper, "XEON") || strings.Contains(upper, "CORE I") { return "Intel" } // AMD patterns if strings.Contains(upper, "AMD") || strings.Contains(upper, "EPYC") || strings.Contains(upper, "RYZEN") || strings.Contains(upper, "THREADRIPPER") { return "AMD" } // ARM patterns if strings.Contains(upper, "ARM") || strings.Contains(upper, "CORTEX") { return "ARM" } // Ampere patterns if strings.Contains(upper, "AMPERE") || strings.Contains(upper, "ALTRA") { return "Ampere" } return "" } func normalizedSerial(serial string) string { s := strings.TrimSpace(serial) if s == "" { return "" } switch strings.ToUpper(s) { case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-": return "" default: return s } } // inferStorageStatus determines storage device status func inferStorageStatus(stor models.Storage) string { if !stor.Present { return "Unknown" } return "Unknown" } func normalizeSourceType(sourceType string) string { normalized := strings.ToLower(strings.TrimSpace(sourceType)) switch normalized { case "api", "logfile", "manual": return normalized default: return "" } } func normalizeProtocol(protocol string) string { normalized := strings.ToLower(strings.TrimSpace(protocol)) switch normalized { case "redfish", "ipmi", "snmp", "ssh": return normalized default: return "" } } func normalizeNullableString(v string) string { trimmed := strings.TrimSpace(v) if strings.EqualFold(trimmed, "NULL") { return "" } return trimmed } func normalizeStatus(status string, allowEmpty bool) string { switch strings.ToLower(strings.TrimSpace(status)) { case "ok": return "OK" case "pass": return "OK" case "warning": return "Warning" case "critical": return "Critical" case "fail": return "Critical" case "unknown": return "Unknown" case "empty": if allowEmpty { return "Empty" } return "Unknown" default: if allowEmpty { return "Unknown" } return "Unknown" } } var ( ipv4Regex = regexp.MustCompile(`(?:^|[^0-9])((?:\d{1,3}\.){3}\d{1,3})(?:[^0-9]|$)`) ) func inferTargetHost(targetHost, filename string) string { if trimmed := strings.TrimSpace(targetHost); trimmed != "" { return trimmed } candidate := strings.TrimSpace(filename) if candidate == "" { return "" } if parsed, err := url.Parse(candidate); err == nil && parsed.Hostname() != "" { return parsed.Hostname() } if submatches := ipv4Regex.FindStringSubmatch(candidate); len(submatches) > 1 { return submatches[1] } return "" }