2330 lines
69 KiB
Go
2330 lines
69 KiB
Go
package exporter
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
|
)
|
|
|
|
var cpuMicrocodeFirmwareRegex = regexp.MustCompile(`(?i)^cpu\d+\s+microcode$`)
|
|
var cpuMicrocodeFirmwareCaptureRegex = 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)
|
|
devices := canonicalDevicesForExport(result.Hardware)
|
|
|
|
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(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
|
|
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
|
|
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
|
|
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
|
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
|
Sensors: convertSensors(result.Sensors),
|
|
EventLogs: convertEventLogs(result.Events, 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,
|
|
}
|
|
}
|
|
|
|
func canonicalDevicesForExport(hw *models.HardwareConfig) []models.HardwareDevice {
|
|
if hw == nil {
|
|
return nil
|
|
}
|
|
merged := append([]models.HardwareDevice{}, hw.Devices...)
|
|
merged = append(merged, buildDevicesFromLegacy(hw)...)
|
|
hw.Devices = dedupeCanonicalDevices(merged)
|
|
return hw.Devices
|
|
}
|
|
|
|
func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|
if hw == nil {
|
|
return nil
|
|
}
|
|
all := make([]models.HardwareDevice, 0, len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
|
|
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
|
|
appendDevice := func(d models.HardwareDevice) {
|
|
all = append(all, d)
|
|
}
|
|
|
|
for _, cpu := range hw.CPUs {
|
|
details := mergeDetailMaps(nil, cpu.Details)
|
|
details = mergeDetailMaps(details, map[string]any{
|
|
"socket": cpu.Socket,
|
|
})
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindCPU,
|
|
Slot: fmt.Sprintf("CPU%d", cpu.Socket),
|
|
Model: cpu.Model,
|
|
SerialNumber: cpu.SerialNumber,
|
|
Cores: cpu.Cores,
|
|
Threads: cpu.Threads,
|
|
FrequencyMHz: cpu.FrequencyMHz,
|
|
MaxFreqMHz: cpu.MaxFreqMHz,
|
|
Status: cpu.Status,
|
|
StatusCheckedAt: cpu.StatusCheckedAt,
|
|
StatusChangedAt: cpu.StatusChangedAt,
|
|
StatusAtCollect: cpu.StatusAtCollect,
|
|
StatusHistory: cpu.StatusHistory,
|
|
ErrorDescription: cpu.ErrorDescription,
|
|
Details: details,
|
|
})
|
|
}
|
|
for _, mem := range hw.Memory {
|
|
present := mem.Present
|
|
details := mergeDetailMaps(nil, mem.Details)
|
|
details = mergeDetailMaps(details, map[string]any{
|
|
"max_speed_mhz": mem.MaxSpeedMHz,
|
|
"current_speed_mhz": mem.CurrentSpeedMHz,
|
|
})
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindMemory,
|
|
Slot: mem.Slot,
|
|
Location: mem.Location,
|
|
Manufacturer: mem.Manufacturer,
|
|
SerialNumber: mem.SerialNumber,
|
|
PartNumber: mem.PartNumber,
|
|
Type: mem.Type,
|
|
Present: &present,
|
|
SizeMB: mem.SizeMB,
|
|
Status: mem.Status,
|
|
StatusCheckedAt: mem.StatusCheckedAt,
|
|
StatusChangedAt: mem.StatusChangedAt,
|
|
StatusAtCollect: mem.StatusAtCollect,
|
|
StatusHistory: mem.StatusHistory,
|
|
ErrorDescription: mem.ErrorDescription,
|
|
Details: details,
|
|
})
|
|
}
|
|
for _, stor := range hw.Storage {
|
|
present := stor.Present
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindStorage,
|
|
Slot: stor.Slot,
|
|
Model: stor.Model,
|
|
Manufacturer: stor.Manufacturer,
|
|
RemainingEndurancePct: stor.RemainingEndurancePct,
|
|
SerialNumber: stor.SerialNumber,
|
|
Firmware: stor.Firmware,
|
|
Type: stor.Type,
|
|
Interface: stor.Interface,
|
|
Present: &present,
|
|
SizeGB: stor.SizeGB,
|
|
Status: stor.Status,
|
|
StatusCheckedAt: stor.StatusCheckedAt,
|
|
StatusChangedAt: stor.StatusChangedAt,
|
|
StatusAtCollect: stor.StatusAtCollect,
|
|
StatusHistory: stor.StatusHistory,
|
|
ErrorDescription: stor.ErrorDescription,
|
|
Details: mergeDetailMaps(nil, stor.Details),
|
|
})
|
|
}
|
|
for _, pcie := range hw.PCIeDevices {
|
|
// Use PartNumber as model when available; fall back to chip description.
|
|
// Description contains the chip/product name (e.g. "BCM57414 NetXtreme-E …")
|
|
// while PartNumber is a part/product code. Prefer PartNumber when set.
|
|
pcieModel := pcie.PartNumber
|
|
if pcieModel == "" {
|
|
pcieModel = pcie.Description
|
|
}
|
|
details := mergeDetailMaps(nil, pcie.Details)
|
|
pcieFirmware := stringFromDetailMap(details, "firmware")
|
|
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
|
|
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
|
|
if pcieFirmware != "" {
|
|
details = mergeDetailMaps(details, map[string]any{
|
|
"firmware": pcieFirmware,
|
|
})
|
|
}
|
|
}
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindPCIe,
|
|
Slot: pcie.Slot,
|
|
BDF: pcie.BDF,
|
|
DeviceClass: pcie.DeviceClass,
|
|
VendorID: pcie.VendorID,
|
|
DeviceID: pcie.DeviceID,
|
|
Model: pcieModel,
|
|
PartNumber: pcie.PartNumber,
|
|
Manufacturer: pcie.Manufacturer,
|
|
SerialNumber: pcie.SerialNumber,
|
|
LinkWidth: pcie.LinkWidth,
|
|
LinkSpeed: pcie.LinkSpeed,
|
|
MaxLinkWidth: pcie.MaxLinkWidth,
|
|
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
|
NUMANode: pcie.NUMANode,
|
|
Status: pcie.Status,
|
|
StatusCheckedAt: pcie.StatusCheckedAt,
|
|
StatusChangedAt: pcie.StatusChangedAt,
|
|
StatusAtCollect: pcie.StatusAtCollect,
|
|
StatusHistory: pcie.StatusHistory,
|
|
ErrorDescription: pcie.ErrorDescription,
|
|
Details: details,
|
|
})
|
|
}
|
|
for _, gpu := range hw.GPUs {
|
|
details := mergeDetailMaps(nil, gpu.Details)
|
|
details = mergeDetailMaps(details, map[string]any{
|
|
"uuid": gpu.UUID,
|
|
"video_bios": gpu.VideoBIOS,
|
|
"irq": gpu.IRQ,
|
|
"bus_type": gpu.BusType,
|
|
"dma_size": gpu.DMASize,
|
|
"dma_mask": gpu.DMAMask,
|
|
"device_minor": gpu.DeviceMinor,
|
|
"temperature": gpu.Temperature,
|
|
"mem_temperature": gpu.MemTemperature,
|
|
"power": gpu.Power,
|
|
"max_power": gpu.MaxPower,
|
|
"clock_speed": gpu.ClockSpeed,
|
|
})
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindGPU,
|
|
Slot: gpu.Slot,
|
|
Location: gpu.Location,
|
|
BDF: gpu.BDF,
|
|
DeviceClass: "DisplayController",
|
|
VendorID: gpu.VendorID,
|
|
DeviceID: gpu.DeviceID,
|
|
Model: gpu.Model,
|
|
PartNumber: gpu.PartNumber,
|
|
Manufacturer: gpu.Manufacturer,
|
|
SerialNumber: gpu.SerialNumber,
|
|
Firmware: gpu.Firmware,
|
|
LinkWidth: gpu.CurrentLinkWidth,
|
|
LinkSpeed: gpu.CurrentLinkSpeed,
|
|
MaxLinkWidth: gpu.MaxLinkWidth,
|
|
MaxLinkSpeed: gpu.MaxLinkSpeed,
|
|
TemperatureC: gpu.Temperature,
|
|
Status: gpu.Status,
|
|
StatusCheckedAt: gpu.StatusCheckedAt,
|
|
StatusChangedAt: gpu.StatusChangedAt,
|
|
StatusAtCollect: gpu.StatusAtCollect,
|
|
StatusHistory: gpu.StatusHistory,
|
|
ErrorDescription: gpu.ErrorDescription,
|
|
Details: details,
|
|
})
|
|
}
|
|
for _, nic := range hw.NetworkAdapters {
|
|
present := nic.Present
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindNetwork,
|
|
Slot: nic.Slot,
|
|
Location: nic.Location,
|
|
BDF: nic.BDF,
|
|
VendorID: nic.VendorID,
|
|
DeviceID: nic.DeviceID,
|
|
Model: nic.Model,
|
|
PartNumber: nic.PartNumber,
|
|
Manufacturer: nic.Vendor,
|
|
SerialNumber: nic.SerialNumber,
|
|
Firmware: nic.Firmware,
|
|
PortCount: nic.PortCount,
|
|
PortType: nic.PortType,
|
|
MACAddresses: nic.MACAddresses,
|
|
LinkWidth: nic.LinkWidth,
|
|
LinkSpeed: nic.LinkSpeed,
|
|
MaxLinkWidth: nic.MaxLinkWidth,
|
|
MaxLinkSpeed: nic.MaxLinkSpeed,
|
|
NUMANode: nic.NUMANode,
|
|
Present: &present,
|
|
Status: nic.Status,
|
|
StatusCheckedAt: nic.StatusCheckedAt,
|
|
StatusChangedAt: nic.StatusChangedAt,
|
|
StatusAtCollect: nic.StatusAtCollect,
|
|
StatusHistory: nic.StatusHistory,
|
|
ErrorDescription: nic.ErrorDescription,
|
|
Details: mergeDetailMaps(nil, nic.Details),
|
|
})
|
|
}
|
|
for _, psu := range hw.PowerSupply {
|
|
present := psu.Present
|
|
appendDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindPSU,
|
|
Slot: psu.Slot,
|
|
Model: psu.Model,
|
|
PartNumber: psu.PartNumber,
|
|
Manufacturer: psu.Vendor,
|
|
SerialNumber: psu.SerialNumber,
|
|
Firmware: psu.Firmware,
|
|
Present: &present,
|
|
WattageW: psu.WattageW,
|
|
InputType: psu.InputType,
|
|
InputPowerW: psu.InputPowerW,
|
|
OutputPowerW: psu.OutputPowerW,
|
|
InputVoltage: psu.InputVoltage,
|
|
TemperatureC: psu.TemperatureC,
|
|
Status: psu.Status,
|
|
StatusCheckedAt: psu.StatusCheckedAt,
|
|
StatusChangedAt: psu.StatusChangedAt,
|
|
StatusAtCollect: psu.StatusAtCollect,
|
|
StatusHistory: psu.StatusHistory,
|
|
ErrorDescription: psu.ErrorDescription,
|
|
Details: mergeDetailMaps(nil, psu.Details),
|
|
})
|
|
}
|
|
return dedupeCanonicalDevices(all)
|
|
}
|
|
|
|
func dedupeCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevice {
|
|
type scored struct {
|
|
item models.HardwareDevice
|
|
score int
|
|
}
|
|
byKey := make(map[string]scored, len(items))
|
|
order := make([]string, 0, len(items))
|
|
noKey := make([]models.HardwareDevice, 0)
|
|
for _, item := range items {
|
|
key := canonicalKey(item)
|
|
if key == "" {
|
|
noKey = append(noKey, item)
|
|
continue
|
|
}
|
|
curr := scored{item: item, score: canonicalScore(item)}
|
|
prev, ok := byKey[key]
|
|
if !ok {
|
|
byKey[key] = curr
|
|
order = append(order, key)
|
|
continue
|
|
}
|
|
if curr.score > prev.score {
|
|
curr.item = mergeCanonicalDevice(curr.item, prev.item)
|
|
curr.score = canonicalScore(curr.item)
|
|
byKey[key] = curr
|
|
continue
|
|
}
|
|
prev.item = mergeCanonicalDevice(prev.item, curr.item)
|
|
prev.score = canonicalScore(prev.item)
|
|
byKey[key] = prev
|
|
}
|
|
// Secondary pass: for items without serial/BDF (noKey), try to merge into an
|
|
// existing keyed entry with the same model+manufacturer. This handles the case
|
|
// where a device appears both in PCIeDevices (with BDF) and NetworkAdapters
|
|
// (without BDF) — e.g. Inspur outboardPCIeCard vs PCIeCard with the same model.
|
|
// deviceIdentity returns the best available model name for secondary matching,
|
|
// preferring Model over DeviceClass (which may hold a resolved device name).
|
|
deviceIdentity := func(d models.HardwareDevice) string {
|
|
if m := strings.ToLower(strings.TrimSpace(d.Model)); m != "" {
|
|
return m
|
|
}
|
|
if dc := strings.ToLower(strings.TrimSpace(d.DeviceClass)); dc != "" && !isGenericDeviceClass(dc) {
|
|
return dc
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var unmatched []models.HardwareDevice
|
|
for _, item := range noKey {
|
|
mergeKind := canonicalMergeKind(item.Kind)
|
|
identity := deviceIdentity(item)
|
|
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
|
if identity == "" {
|
|
unmatched = append(unmatched, item)
|
|
continue
|
|
}
|
|
matchKey := ""
|
|
matchCount := 0
|
|
for _, k := range order {
|
|
existing := byKey[k].item
|
|
if canonicalMergeKind(existing.Kind) == mergeKind &&
|
|
deviceIdentity(existing) == identity &&
|
|
strings.ToLower(strings.TrimSpace(existing.Manufacturer)) == mfr {
|
|
matchKey = k
|
|
matchCount++
|
|
}
|
|
}
|
|
if matchCount == 1 {
|
|
prev := byKey[matchKey]
|
|
prev.item = mergeCanonicalDevice(prev.item, item)
|
|
prev.score = canonicalScore(prev.item)
|
|
byKey[matchKey] = prev
|
|
} else {
|
|
unmatched = append(unmatched, item)
|
|
}
|
|
}
|
|
|
|
unmatched = dedupeLooseCanonicalDevices(unmatched)
|
|
|
|
out := make([]models.HardwareDevice, 0, len(order)+len(unmatched))
|
|
for _, key := range order {
|
|
out = append(out, byKey[key].item)
|
|
}
|
|
out = append(out, unmatched...)
|
|
for i := range out {
|
|
out[i].ID = out[i].Kind + ":" + strconv.Itoa(i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeLooseCanonicalDevices(items []models.HardwareDevice) []models.HardwareDevice {
|
|
if len(items) <= 1 {
|
|
return items
|
|
}
|
|
out := make([]models.HardwareDevice, 0, len(items))
|
|
seen := make(map[string]int, len(items))
|
|
for _, item := range items {
|
|
key := canonicalLooseKey(item)
|
|
if key == "" {
|
|
out = append(out, item)
|
|
continue
|
|
}
|
|
if idx, ok := seen[key]; ok {
|
|
out[idx] = mergeCanonicalDevice(out[idx], item)
|
|
continue
|
|
}
|
|
seen[key] = len(out)
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeCanonicalDevice(primary, secondary models.HardwareDevice) models.HardwareDevice {
|
|
fillString := func(dst *string, src string) {
|
|
if strings.TrimSpace(*dst) == "" && strings.TrimSpace(src) != "" {
|
|
*dst = src
|
|
}
|
|
}
|
|
fillInt := func(dst *int, src int) {
|
|
if *dst == 0 && src != 0 {
|
|
*dst = src
|
|
}
|
|
}
|
|
fillFloat := func(dst *float64, src float64) {
|
|
if *dst == 0 && src != 0 {
|
|
*dst = src
|
|
}
|
|
}
|
|
|
|
fillString(&primary.Kind, secondary.Kind)
|
|
fillString(&primary.Source, secondary.Source)
|
|
fillString(&primary.Slot, secondary.Slot)
|
|
fillString(&primary.Location, secondary.Location)
|
|
fillString(&primary.BDF, secondary.BDF)
|
|
fillString(&primary.DeviceClass, secondary.DeviceClass)
|
|
fillInt(&primary.VendorID, secondary.VendorID)
|
|
fillInt(&primary.DeviceID, secondary.DeviceID)
|
|
fillString(&primary.Model, secondary.Model)
|
|
fillString(&primary.PartNumber, secondary.PartNumber)
|
|
fillString(&primary.Manufacturer, secondary.Manufacturer)
|
|
fillString(&primary.SerialNumber, secondary.SerialNumber)
|
|
fillString(&primary.Firmware, secondary.Firmware)
|
|
fillString(&primary.Type, secondary.Type)
|
|
fillString(&primary.Interface, secondary.Interface)
|
|
if primary.Present == nil && secondary.Present != nil {
|
|
primary.Present = secondary.Present
|
|
}
|
|
fillInt(&primary.SizeMB, secondary.SizeMB)
|
|
fillInt(&primary.SizeGB, secondary.SizeGB)
|
|
fillInt(&primary.Cores, secondary.Cores)
|
|
fillInt(&primary.Threads, secondary.Threads)
|
|
fillInt(&primary.FrequencyMHz, secondary.FrequencyMHz)
|
|
fillInt(&primary.MaxFreqMHz, secondary.MaxFreqMHz)
|
|
fillInt(&primary.PortCount, secondary.PortCount)
|
|
fillString(&primary.PortType, secondary.PortType)
|
|
if len(primary.MACAddresses) == 0 && len(secondary.MACAddresses) > 0 {
|
|
primary.MACAddresses = secondary.MACAddresses
|
|
}
|
|
if primary.RemainingEndurancePct == nil && secondary.RemainingEndurancePct != nil {
|
|
primary.RemainingEndurancePct = secondary.RemainingEndurancePct
|
|
}
|
|
fillInt(&primary.LinkWidth, secondary.LinkWidth)
|
|
fillString(&primary.LinkSpeed, secondary.LinkSpeed)
|
|
fillInt(&primary.MaxLinkWidth, secondary.MaxLinkWidth)
|
|
fillString(&primary.MaxLinkSpeed, secondary.MaxLinkSpeed)
|
|
fillInt(&primary.WattageW, secondary.WattageW)
|
|
fillString(&primary.InputType, secondary.InputType)
|
|
fillInt(&primary.InputPowerW, secondary.InputPowerW)
|
|
fillInt(&primary.OutputPowerW, secondary.OutputPowerW)
|
|
fillFloat(&primary.InputVoltage, secondary.InputVoltage)
|
|
fillInt(&primary.TemperatureC, secondary.TemperatureC)
|
|
fillString(&primary.Status, secondary.Status)
|
|
if primary.StatusCheckedAt == nil && secondary.StatusCheckedAt != nil {
|
|
primary.StatusCheckedAt = secondary.StatusCheckedAt
|
|
}
|
|
if primary.StatusChangedAt == nil && secondary.StatusChangedAt != nil {
|
|
primary.StatusChangedAt = secondary.StatusChangedAt
|
|
}
|
|
if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil {
|
|
primary.StatusAtCollect = secondary.StatusAtCollect
|
|
}
|
|
if len(primary.StatusHistory) == 0 && len(secondary.StatusHistory) > 0 {
|
|
primary.StatusHistory = secondary.StatusHistory
|
|
}
|
|
fillString(&primary.ErrorDescription, secondary.ErrorDescription)
|
|
primary.Details = mergeDetailMaps(primary.Details, secondary.Details)
|
|
return primary
|
|
}
|
|
|
|
func mergeDetailMaps(primary, secondary map[string]any) map[string]any {
|
|
if len(secondary) == 0 {
|
|
return primary
|
|
}
|
|
if primary == nil {
|
|
primary = make(map[string]any, len(secondary))
|
|
}
|
|
for k, v := range secondary {
|
|
if _, exists := primary[k]; !exists {
|
|
primary[k] = v
|
|
}
|
|
}
|
|
return primary
|
|
}
|
|
|
|
func canonicalKey(item models.HardwareDevice) string {
|
|
kind := canonicalMergeKind(item.Kind)
|
|
if sn := normalizedSerial(item.SerialNumber); sn != "" {
|
|
return kind + "|sn:" + strings.ToLower(sn)
|
|
}
|
|
if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" {
|
|
return kind + "|bdf:" + bdf
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func canonicalLooseKey(item models.HardwareDevice) string {
|
|
kind := canonicalMergeKind(item.Kind)
|
|
slot := strings.ToLower(strings.TrimSpace(item.Slot))
|
|
model := strings.ToLower(strings.TrimSpace(item.Model))
|
|
part := strings.ToLower(strings.TrimSpace(item.PartNumber))
|
|
mfr := strings.ToLower(strings.TrimSpace(item.Manufacturer))
|
|
if item.VendorID != 0 && item.DeviceID != 0 && slot != "" {
|
|
return fmt.Sprintf("%s|slotid:%s|%d|%d", kind, slot, item.VendorID, item.DeviceID)
|
|
}
|
|
if slot != "" && model != "" && mfr != "" {
|
|
return kind + "|slotmodel:" + slot + "|" + model + "|" + mfr
|
|
}
|
|
if slot != "" && part != "" && mfr != "" {
|
|
return kind + "|slotpart:" + slot + "|" + part + "|" + mfr
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func canonicalMergeKind(kind string) string {
|
|
switch kind {
|
|
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
|
|
return "pcie-class"
|
|
default:
|
|
return strings.TrimSpace(kind)
|
|
}
|
|
}
|
|
|
|
func canonicalScore(item models.HardwareDevice) int {
|
|
score := 0
|
|
if normalizedSerial(item.SerialNumber) != "" {
|
|
score += 6
|
|
}
|
|
if strings.TrimSpace(item.BDF) != "" {
|
|
score += 4
|
|
}
|
|
if strings.TrimSpace(item.Model) != "" {
|
|
score += 3
|
|
}
|
|
if strings.TrimSpace(item.Firmware) != "" {
|
|
score += 2
|
|
}
|
|
if strings.TrimSpace(item.Status) != "" {
|
|
score++
|
|
}
|
|
return score
|
|
}
|
|
|
|
// 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) || isDeviceBoundFirmwareFQDD(fw.Description) {
|
|
continue
|
|
}
|
|
result = append(result, ReanimatorFirmware{
|
|
DeviceName: fw.DeviceName,
|
|
Version: fw.Version,
|
|
})
|
|
}
|
|
if len(result) == 0 {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt, boardSerial string, microcodeBySocket map[int]string) []ReanimatorCPU {
|
|
result := make([]ReanimatorCPU, 0)
|
|
for _, d := range devices {
|
|
if d.Kind != models.DeviceKindCPU {
|
|
continue
|
|
}
|
|
if d.Present != nil && !*d.Present {
|
|
continue
|
|
}
|
|
socket := parseSocketFromSlot(d.Slot)
|
|
if v, ok := d.Details["socket"].(int); ok {
|
|
socket = v
|
|
}
|
|
cpuStatus := normalizeStatus(d.Status, false)
|
|
if strings.TrimSpace(d.Status) == "" {
|
|
cpuStatus = "Unknown"
|
|
}
|
|
meta := buildStatusMeta(cpuStatus, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
|
result = append(result, ReanimatorCPU{
|
|
Socket: socket,
|
|
Model: d.Model,
|
|
Cores: d.Cores,
|
|
Threads: d.Threads,
|
|
FrequencyMHz: d.FrequencyMHz,
|
|
MaxFrequencyMHz: d.MaxFreqMHz,
|
|
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
|
PowerW: floatFromDetailMap(d.Details, "power_w"),
|
|
Throttled: boolPtrFromDetailMap(d.Details, "throttled"),
|
|
CorrectableErrorCount: int64FromDetailMap(d.Details, "correctable_error_count"),
|
|
UncorrectableErrorCount: int64FromDetailMap(d.Details, "uncorrectable_error_count"),
|
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
|
Firmware: firstNonEmptyString(
|
|
stringFromDetailMap(d.Details, "microcode"),
|
|
microcodeBySocket[socket],
|
|
stringFromDetailMap(d.Details, "firmware"),
|
|
),
|
|
Manufacturer: inferCPUManufacturer(d.Model),
|
|
Status: cpuStatus,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorMemory {
|
|
result := make([]ReanimatorMemory, 0)
|
|
for _, d := range devices {
|
|
if d.Kind != models.DeviceKindMemory {
|
|
continue
|
|
}
|
|
present := boolFromPresentPtr(d.Present, true)
|
|
status := normalizeStatus(d.Status, true)
|
|
if !present || d.SizeMB == 0 || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" {
|
|
continue
|
|
}
|
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
|
result = append(result, ReanimatorMemory{
|
|
Slot: d.Slot,
|
|
Location: d.Location,
|
|
SizeMB: d.SizeMB,
|
|
Type: d.Type,
|
|
MaxSpeedMHz: intFromDetailMap(d.Details, "max_speed_mhz"),
|
|
CurrentSpeedMHz: intFromDetailMap(d.Details, "current_speed_mhz"),
|
|
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
|
CorrectableECCErrorCount: int64FromDetailMap(d.Details, "correctable_ecc_error_count"),
|
|
UncorrectableECCErrorCount: int64FromDetailMap(d.Details, "uncorrectable_ecc_error_count"),
|
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
|
SpareBlocksRemainingPct: floatFromDetailMap(d.Details, "spare_blocks_remaining_pct"),
|
|
PerformanceDegraded: boolPtrFromDetailMap(d.Details, "performance_degraded"),
|
|
DataLossDetected: boolPtrFromDetailMap(d.Details, "data_loss_detected"),
|
|
Manufacturer: d.Manufacturer,
|
|
SerialNumber: d.SerialNumber,
|
|
PartNumber: d.PartNumber,
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorStorage {
|
|
result := make([]ReanimatorStorage, 0)
|
|
for _, d := range devices {
|
|
if d.Kind != models.DeviceKindStorage {
|
|
continue
|
|
}
|
|
if isVirtualExportStorageDevice(d) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(d.SerialNumber) == "" {
|
|
continue
|
|
}
|
|
present := d.Present == nil || *d.Present
|
|
if !present {
|
|
continue
|
|
}
|
|
status := inferStorageStatus(models.Storage{Present: present})
|
|
if strings.TrimSpace(d.Status) != "" {
|
|
status = normalizeStatus(d.Status, false)
|
|
}
|
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
|
result = append(result, ReanimatorStorage{
|
|
Slot: d.Slot,
|
|
Type: d.Type,
|
|
Model: d.Model,
|
|
SizeGB: d.SizeGB,
|
|
SerialNumber: d.SerialNumber,
|
|
Manufacturer: d.Manufacturer,
|
|
Firmware: d.Firmware,
|
|
Interface: d.Interface,
|
|
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
|
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
|
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
|
UnsafeShutdowns: int64FromDetailMap(d.Details, "unsafe_shutdowns"),
|
|
MediaErrors: int64FromDetailMap(d.Details, "media_errors"),
|
|
ErrorLogEntries: int64FromDetailMap(d.Details, "error_log_entries"),
|
|
WrittenBytes: int64FromDetailMap(d.Details, "written_bytes"),
|
|
ReadBytes: int64FromDetailMap(d.Details, "read_bytes"),
|
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
|
RemainingEndurancePct: d.RemainingEndurancePct,
|
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
|
AvailableSparePct: floatFromDetailMap(d.Details, "available_spare_pct"),
|
|
ReallocatedSectors: int64FromDetailMap(d.Details, "reallocated_sectors"),
|
|
CurrentPendingSectors: int64FromDetailMap(d.Details, "current_pending_sectors"),
|
|
OfflineUncorrectable: int64FromDetailMap(d.Details, "offline_uncorrectable"),
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPCIe {
|
|
result := make([]ReanimatorPCIe, 0)
|
|
for _, d := range devices {
|
|
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindGPU && d.Kind != models.DeviceKindNetwork {
|
|
continue
|
|
}
|
|
if isStorageEndpointPCIeDevice(d) {
|
|
continue
|
|
}
|
|
if isPlaceholderPCIeExportDevice(d) {
|
|
continue
|
|
}
|
|
if d.Present != nil && !*d.Present {
|
|
continue
|
|
}
|
|
deviceClass := normalizePCIeDeviceClass(d)
|
|
model := normalizePlaceholderDeviceModel(d.Model)
|
|
if model == "" {
|
|
model = normalizePlaceholderDeviceModel(d.PartNumber)
|
|
}
|
|
// General rule: if model not found in source data but PCI IDs are known, resolve from pci.ids.
|
|
if model == "" && d.VendorID != 0 && d.DeviceID != 0 {
|
|
model = pciids.DeviceName(d.VendorID, d.DeviceID)
|
|
}
|
|
manufacturer := d.Manufacturer
|
|
if manufacturer == "" && d.VendorID != 0 {
|
|
manufacturer = pciids.VendorName(d.VendorID)
|
|
}
|
|
temperatureC := firstNonZeroFloat(
|
|
float64(d.TemperatureC),
|
|
floatFromDetailMap(d.Details, "temperature_c"),
|
|
floatFromDetailMap(d.Details, "temperature"),
|
|
)
|
|
powerW := firstNonZeroFloat(
|
|
floatFromDetailMap(d.Details, "power_w"),
|
|
float64(intFromDetailMap(d.Details, "power")),
|
|
)
|
|
status := normalizeStatus(d.Status, false)
|
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
|
slot := firstNonEmptyString(d.Slot, d.BDF)
|
|
result = append(result, ReanimatorPCIe{
|
|
Slot: slot,
|
|
VendorID: d.VendorID,
|
|
DeviceID: d.DeviceID,
|
|
NUMANode: d.NUMANode,
|
|
TemperatureC: temperatureC,
|
|
PowerW: powerW,
|
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
|
ECCCorrectedTotal: int64FromDetailMap(d.Details, "ecc_corrected_total"),
|
|
ECCUncorrectedTotal: int64FromDetailMap(d.Details, "ecc_uncorrected_total"),
|
|
HWSlowdown: boolPtrFromDetailMap(d.Details, "hw_slowdown"),
|
|
BatteryChargePct: floatFromDetailMap(d.Details, "battery_charge_pct"),
|
|
BatteryHealthPct: floatFromDetailMap(d.Details, "battery_health_pct"),
|
|
BatteryTemperatureC: floatFromDetailMap(d.Details, "battery_temperature_c"),
|
|
BatteryVoltageV: floatFromDetailMap(d.Details, "battery_voltage_v"),
|
|
BatteryReplaceRequired: boolPtrFromDetailMap(d.Details, "battery_replace_required"),
|
|
SFPTemperatureC: floatFromDetailMap(d.Details, "sfp_temperature_c"),
|
|
SFPTXPowerDBm: floatFromDetailMap(d.Details, "sfp_tx_power_dbm"),
|
|
SFPRXPowerDBm: floatFromDetailMap(d.Details, "sfp_rx_power_dbm"),
|
|
SFPVoltageV: floatFromDetailMap(d.Details, "sfp_voltage_v"),
|
|
SFPBiasMA: floatFromDetailMap(d.Details, "sfp_bias_ma"),
|
|
BDF: d.BDF,
|
|
DeviceClass: deviceClass,
|
|
Manufacturer: manufacturer,
|
|
Model: model,
|
|
LinkWidth: d.LinkWidth,
|
|
LinkSpeed: d.LinkSpeed,
|
|
MaxLinkWidth: d.MaxLinkWidth,
|
|
MaxLinkSpeed: d.MaxLinkSpeed,
|
|
MACAddresses: append([]string(nil), d.MACAddresses...),
|
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
|
Firmware: firstNonEmptyString(d.Firmware, stringFromDetailMap(d.Details, "firmware")),
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isStorageEndpointPCIeDevice(d models.HardwareDevice) bool {
|
|
if d.Kind != models.DeviceKindPCIe {
|
|
return false
|
|
}
|
|
|
|
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
|
if !strings.Contains(class, "storage") && !strings.Contains(class, "nonvolatile") && !strings.Contains(class, "nvme") {
|
|
return false
|
|
}
|
|
|
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
|
d.Slot,
|
|
d.Model,
|
|
d.PartNumber,
|
|
d.Manufacturer,
|
|
stringFromDetailMap(d.Details, "description"),
|
|
}, " ")))
|
|
|
|
if strings.Contains(joined, "raid") || strings.Contains(joined, "hba") || strings.Contains(joined, "controller") {
|
|
return false
|
|
}
|
|
|
|
return strings.Contains(joined, "nvme") ||
|
|
strings.Contains(joined, "ssd") ||
|
|
strings.Contains(joined, "u.2") ||
|
|
strings.Contains(joined, "e1.s") ||
|
|
strings.Contains(joined, "e3.s") ||
|
|
strings.Contains(joined, "disk") ||
|
|
strings.Contains(joined, "drive")
|
|
}
|
|
|
|
func isVirtualExportStorageDevice(d models.HardwareDevice) bool {
|
|
if d.Kind != models.DeviceKindStorage {
|
|
return false
|
|
}
|
|
mfr := strings.ToUpper(strings.TrimSpace(d.Manufacturer))
|
|
model := strings.ToUpper(strings.TrimSpace(d.Model))
|
|
slot := strings.ToUpper(strings.TrimSpace(d.Slot))
|
|
if strings.Contains(mfr, "AMERICAN MEGATRENDS") || strings.Contains(mfr, "AMI") {
|
|
joined := strings.Join([]string{mfr, model, slot}, " ")
|
|
for _, marker := range []string{
|
|
"VIRTUAL CDROM",
|
|
"VIRTUAL CD/DVD",
|
|
"VIRTUAL FLOPPY",
|
|
"VIRTUAL FDD",
|
|
"VIRTUAL MEDIA",
|
|
"USB_DEVICE",
|
|
} {
|
|
if strings.Contains(joined, marker) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isPlaceholderPCIeExportDevice(d models.HardwareDevice) bool {
|
|
if d.Kind != models.DeviceKindPCIe && d.Kind != models.DeviceKindNetwork {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(d.BDF) != "" {
|
|
return false
|
|
}
|
|
if d.VendorID != 0 || d.DeviceID != 0 {
|
|
return false
|
|
}
|
|
if normalizedSerial(d.SerialNumber) != "" {
|
|
return false
|
|
}
|
|
if len(d.MACAddresses) > 0 {
|
|
return false
|
|
}
|
|
if strings.TrimSpace(d.Firmware) != "" {
|
|
return false
|
|
}
|
|
if d.LinkWidth != 0 || d.MaxLinkWidth != 0 || strings.TrimSpace(d.LinkSpeed) != "" || strings.TrimSpace(d.MaxLinkSpeed) != "" {
|
|
return false
|
|
}
|
|
if hasMeaningfulExporterText(d.Model) || hasMeaningfulExporterText(d.PartNumber) || hasMeaningfulExporterText(d.Manufacturer) || hasMeaningfulExporterText(stringFromDetailMap(d.Details, "description")) {
|
|
return false
|
|
}
|
|
|
|
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
|
if class != "" && class != "unknown" && class != "other" && class != "pcie device" && class != "network" && class != "network controller" && class != "networkcontroller" {
|
|
return false
|
|
}
|
|
|
|
return isNumericExporterSlot(d.Slot)
|
|
}
|
|
|
|
func convertPSUsFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorPSU {
|
|
result := make([]ReanimatorPSU, 0)
|
|
for _, d := range devices {
|
|
if d.Kind != models.DeviceKindPSU {
|
|
continue
|
|
}
|
|
present := d.Present != nil && *d.Present
|
|
if !present || strings.TrimSpace(d.SerialNumber) == "" {
|
|
continue
|
|
}
|
|
status := normalizeStatus(d.Status, false)
|
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
|
result = append(result, ReanimatorPSU{
|
|
Slot: d.Slot,
|
|
Model: d.Model,
|
|
Vendor: d.Manufacturer,
|
|
WattageW: d.WattageW,
|
|
SerialNumber: d.SerialNumber,
|
|
PartNumber: d.PartNumber,
|
|
Firmware: d.Firmware,
|
|
Status: status,
|
|
InputType: d.InputType,
|
|
InputPowerW: float64(d.InputPowerW),
|
|
OutputPowerW: float64(d.OutputPowerW),
|
|
InputVoltage: d.InputVoltage,
|
|
TemperatureC: firstNonZeroFloat(float64(d.TemperatureC), floatFromDetailMap(d.Details, "temperature_c")),
|
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
|
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func convertEventLogs(events []models.Event, collectedAt string) []ReanimatorEventLog {
|
|
if len(events) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]ReanimatorEventLog, 0, len(events))
|
|
for _, event := range events {
|
|
source := normalizeEventLogSource(event.Source)
|
|
message := strings.TrimSpace(event.Description)
|
|
if source == "" || message == "" {
|
|
continue
|
|
}
|
|
|
|
item := ReanimatorEventLog{
|
|
Source: source,
|
|
EventTime: formatEventLogTime(event.Timestamp, collectedAt),
|
|
Severity: normalizeEventLogSeverity(event.Severity),
|
|
MessageID: strings.TrimSpace(event.ID),
|
|
Message: message,
|
|
ComponentRef: firstNonEmptyString(strings.TrimSpace(event.SensorName), strings.TrimSpace(event.SensorType)),
|
|
}
|
|
if raw := strings.TrimSpace(event.RawData); raw != "" {
|
|
item.RawPayload = map[string]any{
|
|
"raw_data": raw,
|
|
}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func convertSensors(sensors []models.SensorReading) *ReanimatorSensors {
|
|
if len(sensors) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := &ReanimatorSensors{}
|
|
seenFans := map[string]struct{}{}
|
|
powerIndex := map[string]int{}
|
|
seenTemps := map[string]struct{}{}
|
|
seenOther := map[string]struct{}{}
|
|
|
|
for _, s := range sensors {
|
|
name := strings.TrimSpace(s.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if !sensorHasNumericReading(s) {
|
|
continue
|
|
}
|
|
status := normalizeSensorStatus(s.Status)
|
|
sType := strings.ToLower(strings.TrimSpace(s.Type))
|
|
unit := strings.TrimSpace(s.Unit)
|
|
|
|
switch {
|
|
case sType == "fan" || strings.EqualFold(unit, "RPM"):
|
|
if seenFirst(seenFans, name) {
|
|
continue
|
|
}
|
|
out.Fans = append(out.Fans, ReanimatorFanSensor{
|
|
Name: name,
|
|
RPM: int(s.Value),
|
|
Status: status,
|
|
})
|
|
case sType == "power" || sType == "voltage" || sType == "current" || strings.EqualFold(unit, "V") || strings.EqualFold(unit, "A") || strings.EqualFold(unit, "W"):
|
|
baseName := groupedPowerSensorName(name)
|
|
if idx, ok := powerIndex[baseName]; ok {
|
|
mergePowerSensorReading(&out.Power[idx], sType, unit, s.Value, status)
|
|
continue
|
|
}
|
|
item := ReanimatorPowerSensor{
|
|
Name: baseName,
|
|
Status: status,
|
|
}
|
|
mergePowerSensorReading(&item, sType, unit, s.Value, status)
|
|
powerIndex[baseName] = len(out.Power)
|
|
out.Power = append(out.Power, item)
|
|
case sType == "temperature" || strings.EqualFold(unit, "C") || strings.EqualFold(unit, "°C"):
|
|
if seenFirst(seenTemps, name) {
|
|
continue
|
|
}
|
|
out.Temperatures = append(out.Temperatures, ReanimatorTemperatureSensor{
|
|
Name: name,
|
|
Celsius: s.Value,
|
|
Status: status,
|
|
})
|
|
default:
|
|
if seenFirst(seenOther, name) {
|
|
continue
|
|
}
|
|
out.Other = append(out.Other, ReanimatorOtherSensor{
|
|
Name: name,
|
|
Value: s.Value,
|
|
Unit: unit,
|
|
Status: status,
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(out.Fans) == 0 && len(out.Power) == 0 && len(out.Temperatures) == 0 && len(out.Other) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func groupedPowerSensorName(name string) string {
|
|
trimmed := strings.TrimSpace(name)
|
|
lower := strings.ToLower(trimmed)
|
|
inputSuffixes := []string{"_inputpower", "_inputvoltage", "_inputcurrent", "_pin", "_vin", "_iin"}
|
|
for _, suffix := range inputSuffixes {
|
|
if strings.HasSuffix(lower, suffix) {
|
|
return strings.TrimSpace(trimmed[:len(trimmed)-len(suffix)])
|
|
}
|
|
}
|
|
outputSuffixes := []string{"_outputpower", "_outputvoltage", "_outputcurrent", "_pout", "_vout", "_iout"}
|
|
for _, suffix := range outputSuffixes {
|
|
if strings.HasSuffix(lower, suffix) {
|
|
return strings.TrimSpace(trimmed[:len(trimmed)-len(suffix)]) + "_Output"
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func mergePowerSensorReading(item *ReanimatorPowerSensor, sType, unit string, value float64, status string) {
|
|
if item == nil {
|
|
return
|
|
}
|
|
switch {
|
|
case sType == "current" || strings.EqualFold(unit, "A"):
|
|
item.CurrentA = value
|
|
case sType == "power" || strings.EqualFold(unit, "W"):
|
|
item.PowerW = value
|
|
default:
|
|
item.VoltageV = value
|
|
}
|
|
item.Status = mergeSensorStatus(item.Status, status)
|
|
}
|
|
|
|
func mergeSensorStatus(current, incoming string) string {
|
|
current = strings.TrimSpace(current)
|
|
incoming = strings.TrimSpace(incoming)
|
|
if current == "" {
|
|
return incoming
|
|
}
|
|
if incoming == "" {
|
|
return current
|
|
}
|
|
if sensorStatusRank(incoming) > sensorStatusRank(current) {
|
|
return incoming
|
|
}
|
|
return current
|
|
}
|
|
|
|
func sensorStatusRank(status string) int {
|
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
case "critical":
|
|
return 3
|
|
case "warning":
|
|
return 2
|
|
case "ok":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func sensorHasNumericReading(s models.SensorReading) bool {
|
|
if strings.TrimSpace(s.RawValue) != "" {
|
|
if _, err := strconv.ParseFloat(strings.TrimSpace(s.RawValue), 64); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return s.Value != 0
|
|
}
|
|
|
|
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") ||
|
|
// Supermicro Redfish FirmwareInventory names "GPU1 System Slot0", "NIC1 System Slot0 ..."
|
|
// where the number follows immediately after the type prefix (no space separator).
|
|
(strings.HasPrefix(n, "gpu") && len(n) > 3 && n[3] >= '0' && n[3] <= '9') ||
|
|
(strings.HasPrefix(n, "nic") && len(n) > 3 && n[3] >= '0' && n[3] <= '9') ||
|
|
// "NVMeController1" — storage controller bound to an NVMe device slot
|
|
strings.HasPrefix(n, "nvmecontroller") ||
|
|
// "Power supply N" — Supermicro PSU firmware (distinct from generic "PSU" prefix)
|
|
strings.HasPrefix(n, "power supply") ||
|
|
// "Software Inventory" — generic label used by HGX baseboard for all per-component
|
|
// firmware slots (GPU, NVSwitch, PCIeRetimer, ERoT, InfoROM, etc.). The useful name
|
|
// is only in the inventory item Id, not the Name field, so the entry is not actionable.
|
|
n == "software inventory" ||
|
|
// HGX baseboard firmware inventory IDs for device-bound components
|
|
strings.Contains(n, "_fw_gpu_") ||
|
|
strings.Contains(n, "_fw_nvswitch_") ||
|
|
strings.Contains(n, "_fw_erot_") ||
|
|
strings.Contains(n, "_inforom_gpu_") {
|
|
return true
|
|
}
|
|
|
|
return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name))
|
|
}
|
|
|
|
func normalizeEventLogSource(source string) string {
|
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
|
case "redfish":
|
|
return "redfish"
|
|
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
|
|
return "bmc"
|
|
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
|
return "host"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeEventLogSeverity(severity models.Severity) string {
|
|
switch severity {
|
|
case models.SeverityCritical:
|
|
return "Critical"
|
|
case models.SeverityWarning:
|
|
return "Warning"
|
|
case models.SeverityInfo:
|
|
return "Info"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func formatEventLogTime(ts time.Time, collectedAt string) string {
|
|
if !ts.IsZero() {
|
|
return ts.UTC().Format(time.RFC3339)
|
|
}
|
|
return strings.TrimSpace(collectedAt)
|
|
}
|
|
|
|
func manufacturedYearWeekFromDetails(details map[string]any) string {
|
|
if details == nil {
|
|
return ""
|
|
}
|
|
value := normalizeManufacturedYearWeek(stringFromDetailMap(details, "manufactured_year_week"))
|
|
if value != "" {
|
|
return value
|
|
}
|
|
return normalizeManufacturedYearWeek(stringFromDetailMap(details, "mfg_date"))
|
|
}
|
|
|
|
var manufacturedYearWeekRegex = regexp.MustCompile(`^\d{4}-W\d{2}$`)
|
|
|
|
func normalizeManufacturedYearWeek(value string) string {
|
|
value = strings.TrimSpace(strings.ToUpper(value))
|
|
if manufacturedYearWeekRegex.MatchString(value) {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isDeviceBoundFirmwareFQDD returns true if the description looks like a device-bound FQDD
|
|
// (e.g. NIC.Integrated.1-1-1, PSU.Slot.1, Disk.Bay.0:..., RAID.SL.3-1, InfiniBand.Slot.1-1).
|
|
// These firmware entries are already embedded in the device itself and must not appear
|
|
// in hardware.firmware.
|
|
func isDeviceBoundFirmwareFQDD(desc string) bool {
|
|
d := strings.ToLower(strings.TrimSpace(desc))
|
|
if d == "" {
|
|
return false
|
|
}
|
|
// "raid." covers all RAID controller/backplane FQDDs: RAID.SL.*, RAID.Integrated.*, RAID.Backplane.*
|
|
// "infiniband." covers Mellanox InfiniBand/Ethernet adapters exposed as InfiniBand.Slot.*
|
|
for _, prefix := range []string{"nic.", "psu.", "disk.", "raid.", "gpu.", "infiniband.", "fc."} {
|
|
if strings.HasPrefix(d, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildCPUMicrocodeBySocket(firmware []models.FirmwareInfo) map[int]string {
|
|
if len(firmware) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[int]string)
|
|
for _, fw := range firmware {
|
|
m := cpuMicrocodeFirmwareCaptureRegex.FindStringSubmatch(strings.TrimSpace(fw.DeviceName))
|
|
if len(m) != 2 || strings.TrimSpace(fw.Version) == "" {
|
|
continue
|
|
}
|
|
socket, err := strconv.Atoi(m[1])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, exists := out[socket]; exists {
|
|
continue
|
|
}
|
|
out[socket] = strings.TrimSpace(fw.Version)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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.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,
|
|
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
|
|
Firmware: "",
|
|
Manufacturer: manufacturer,
|
|
Status: cpuStatus,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
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 {
|
|
if !mem.Present || mem.SizeMB == 0 || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" {
|
|
continue
|
|
}
|
|
status := normalizeStatus(mem.Status, true)
|
|
|
|
meta := buildStatusMeta(
|
|
status,
|
|
mem.StatusCheckedAt,
|
|
mem.StatusChangedAt,
|
|
mem.StatusHistory,
|
|
mem.ErrorDescription,
|
|
collectedAt,
|
|
)
|
|
|
|
result = append(result, ReanimatorMemory{
|
|
Slot: mem.Slot,
|
|
Location: mem.Location,
|
|
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,
|
|
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.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,
|
|
RemainingEndurancePct: stor.RemainingEndurancePct,
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
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
|
|
}
|
|
|
|
if isStorageEndpointPCIeDevice(models.HardwareDevice{
|
|
Kind: models.DeviceKindPCIe,
|
|
Slot: pcie.Slot,
|
|
DeviceClass: pcie.DeviceClass,
|
|
Model: pcie.Description,
|
|
PartNumber: pcie.PartNumber,
|
|
Manufacturer: pcie.Manufacturer,
|
|
}) {
|
|
continue
|
|
}
|
|
|
|
serialNumber := strings.TrimSpace(pcie.SerialNumber)
|
|
|
|
// Determine model: PartNumber > Description (chip name) > DeviceClass (bus width fallback)
|
|
model := normalizePlaceholderDeviceModel(pcie.PartNumber)
|
|
if model == "" {
|
|
model = normalizePlaceholderDeviceModel(pcie.Description)
|
|
}
|
|
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.StatusHistory,
|
|
pcie.ErrorDescription,
|
|
collectedAt,
|
|
)
|
|
|
|
result = append(result, ReanimatorPCIe{
|
|
Slot: pcie.Slot,
|
|
VendorID: pcie.VendorID,
|
|
DeviceID: pcie.DeviceID,
|
|
BDF: pcie.BDF,
|
|
DeviceClass: normalizeLegacyPCIeDeviceClass(pcie.DeviceClass),
|
|
Manufacturer: pcie.Manufacturer,
|
|
Model: model,
|
|
LinkWidth: pcie.LinkWidth,
|
|
LinkSpeed: pcie.LinkSpeed,
|
|
MaxLinkWidth: pcie.MaxLinkWidth,
|
|
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
|
MACAddresses: append([]string(nil), pcie.MACAddresses...),
|
|
NUMANode: pcie.NUMANode,
|
|
SerialNumber: serialNumber,
|
|
Firmware: firmware,
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
|
|
// Convert GPUs as PCIe devices
|
|
for _, gpu := range hw.GPUs {
|
|
serialNumber := strings.TrimSpace(gpu.SerialNumber)
|
|
|
|
status := normalizeStatus(gpu.Status, false)
|
|
meta := buildStatusMeta(
|
|
status,
|
|
gpu.StatusCheckedAt,
|
|
gpu.StatusChangedAt,
|
|
gpu.StatusHistory,
|
|
gpu.ErrorDescription,
|
|
collectedAt,
|
|
)
|
|
|
|
result = append(result, ReanimatorPCIe{
|
|
Slot: gpu.Slot,
|
|
VendorID: gpu.VendorID,
|
|
DeviceID: gpu.DeviceID,
|
|
BDF: gpu.BDF,
|
|
DeviceClass: "VideoController",
|
|
Manufacturer: gpu.Manufacturer,
|
|
Model: gpu.Model,
|
|
LinkWidth: gpu.CurrentLinkWidth,
|
|
LinkSpeed: gpu.CurrentLinkSpeed,
|
|
MaxLinkWidth: gpu.MaxLinkWidth,
|
|
MaxLinkSpeed: gpu.MaxLinkSpeed,
|
|
SerialNumber: serialNumber,
|
|
Firmware: gpu.Firmware,
|
|
TemperatureC: float64(gpu.Temperature),
|
|
PowerW: float64(gpu.Power),
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
|
|
// Convert network adapters as PCIe devices
|
|
for _, nic := range hw.NetworkAdapters {
|
|
if !nic.Present {
|
|
continue
|
|
}
|
|
|
|
serialNumber := strings.TrimSpace(nic.SerialNumber)
|
|
|
|
status := normalizeStatus(nic.Status, false)
|
|
meta := buildStatusMeta(
|
|
status,
|
|
nic.StatusCheckedAt,
|
|
nic.StatusChangedAt,
|
|
nic.StatusHistory,
|
|
nic.ErrorDescription,
|
|
collectedAt,
|
|
)
|
|
|
|
result = append(result, ReanimatorPCIe{
|
|
Slot: nic.Slot,
|
|
VendorID: nic.VendorID,
|
|
DeviceID: nic.DeviceID,
|
|
BDF: "",
|
|
DeviceClass: normalizeNetworkDeviceClass(nic.PortType, nic.Model, nic.Description),
|
|
Manufacturer: nic.Vendor,
|
|
Model: nic.Model,
|
|
LinkWidth: 0,
|
|
LinkSpeed: "",
|
|
MaxLinkWidth: 0,
|
|
MaxLinkSpeed: "",
|
|
MACAddresses: append([]string(nil), nic.MACAddresses...),
|
|
NUMANode: nic.NUMANode,
|
|
SerialNumber: serialNumber,
|
|
Firmware: nic.Firmware,
|
|
Status: status,
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
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), "HGX_FW_EROT_") {
|
|
continue
|
|
}
|
|
slot := ""
|
|
switch {
|
|
case strings.HasPrefix(strings.ToUpper(name), "NVSWITCH "):
|
|
rest := strings.TrimSpace(name[len("NVSwitch "):])
|
|
if rest == "" {
|
|
continue
|
|
}
|
|
slot = rest
|
|
if idx := strings.Index(rest, " ("); idx > 0 {
|
|
slot = strings.TrimSpace(rest[:idx])
|
|
}
|
|
case strings.HasPrefix(strings.ToUpper(name), "HGX_FW_NVSWITCH_"):
|
|
slot = strings.TrimPrefix(strings.ToUpper(name), "HGX_FW_")
|
|
default:
|
|
continue
|
|
}
|
|
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.StatusHistory,
|
|
psu.ErrorDescription,
|
|
collectedAt,
|
|
)
|
|
|
|
result = append(result, ReanimatorPSU{
|
|
Slot: psu.Slot,
|
|
Model: psu.Model,
|
|
Vendor: psu.Vendor,
|
|
WattageW: psu.WattageW,
|
|
SerialNumber: psu.SerialNumber,
|
|
PartNumber: psu.PartNumber,
|
|
Firmware: psu.Firmware,
|
|
Status: status,
|
|
InputType: psu.InputType,
|
|
InputPowerW: float64(psu.InputPowerW),
|
|
OutputPowerW: float64(psu.OutputPowerW),
|
|
InputVoltage: psu.InputVoltage,
|
|
TemperatureC: float64(psu.TemperatureC),
|
|
StatusCheckedAt: meta.StatusCheckedAt,
|
|
StatusChangedAt: meta.StatusChangedAt,
|
|
StatusHistory: meta.StatusHistory,
|
|
ErrorDescription: meta.ErrorDescription,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func seenFirst(seen map[string]struct{}, key string) bool {
|
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
|
if normalized == "" {
|
|
return false
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
return true
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
return false
|
|
}
|
|
|
|
func normalizeSensorStatus(status string) string {
|
|
return normalizeStatus(status, false)
|
|
}
|
|
|
|
type convertedStatusMeta struct {
|
|
StatusCheckedAt string
|
|
StatusChangedAt string
|
|
StatusHistory []ReanimatorStatusHistoryEntry
|
|
ErrorDescription string
|
|
}
|
|
|
|
func buildStatusMeta(
|
|
currentStatus string,
|
|
checkedAt *time.Time,
|
|
changedAt *time.Time,
|
|
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 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 == nil || 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
|
|
}
|
|
}
|
|
|
|
// isGenericDeviceClass returns true for Redfish topology class labels that are
|
|
// not meaningful device identifiers (e.g. "SingleFunction", "DisplayController").
|
|
func isGenericDeviceClass(dc string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(dc)) {
|
|
case "", "pcie device", "display", "display controller", "displaycontroller",
|
|
"vga", "3d controller", "network", "network controller", "network adapter",
|
|
"storage", "storage controller", "other", "unknown",
|
|
"singlefunction", "multifunction", "simulated":
|
|
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
|
|
}
|
|
}
|
|
|
|
func parseSocketFromSlot(slot string) int {
|
|
s := strings.TrimSpace(strings.ToUpper(slot))
|
|
s = strings.TrimPrefix(s, "CPU")
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
v, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return v
|
|
}
|
|
|
|
func intFromDetailMap(details map[string]any, key string) int {
|
|
if details == nil {
|
|
return 0
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case int32:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case float32:
|
|
return int(n)
|
|
case string:
|
|
i, err := strconv.Atoi(strings.TrimSpace(n))
|
|
if err == nil {
|
|
return i
|
|
}
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func stringFromDetailMap(details map[string]any, key string) string {
|
|
if details == nil {
|
|
return ""
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
switch s := v.(type) {
|
|
case string:
|
|
return strings.TrimSpace(s)
|
|
default:
|
|
return strings.TrimSpace(fmt.Sprint(s))
|
|
}
|
|
}
|
|
|
|
func int64FromDetailMap(details map[string]any, key string) int64 {
|
|
if details == nil {
|
|
return 0
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case int:
|
|
return int64(n)
|
|
case int64:
|
|
return n
|
|
case int32:
|
|
return int64(n)
|
|
case float64:
|
|
return int64(n)
|
|
case float32:
|
|
return int64(n)
|
|
case string:
|
|
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
|
if err == nil {
|
|
return i
|
|
}
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func boolPtrFromDetailMap(details map[string]any, key string) *bool {
|
|
if details == nil {
|
|
return nil
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch b := v.(type) {
|
|
case bool:
|
|
return &b
|
|
case string:
|
|
s := strings.ToLower(strings.TrimSpace(b))
|
|
if s == "true" || s == "1" || s == "yes" {
|
|
value := true
|
|
return &value
|
|
}
|
|
if s == "false" || s == "0" || s == "no" {
|
|
value := false
|
|
return &value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boolFromPresentPtr(v *bool, defaultValue bool) bool {
|
|
if v == nil {
|
|
return defaultValue
|
|
}
|
|
return *v
|
|
}
|
|
|
|
func floatFromDetailMap(details map[string]any, key string) float64 {
|
|
if details == nil {
|
|
return 0
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case float32:
|
|
return float64(n)
|
|
case int:
|
|
return float64(n)
|
|
case int64:
|
|
return float64(n)
|
|
case int32:
|
|
return float64(n)
|
|
case string:
|
|
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
|
if err == nil {
|
|
return f
|
|
}
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func firstNonZeroInt(values ...int) int {
|
|
for _, v := range values {
|
|
if v != 0 {
|
|
return v
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNonZeroFloat(values ...float64) float64 {
|
|
for _, v := range values {
|
|
if v != 0 {
|
|
return v
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func normalizePCIeDeviceClass(d models.HardwareDevice) string {
|
|
switch d.Kind {
|
|
case models.DeviceKindGPU:
|
|
return "VideoController"
|
|
case models.DeviceKindNetwork:
|
|
return normalizeNetworkDeviceClass(d.PortType, d.Model, stringFromDetailMap(d.Details, "description"))
|
|
default:
|
|
return normalizeLegacyPCIeDeviceClass(d.DeviceClass)
|
|
}
|
|
}
|
|
|
|
func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
|
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
|
|
case "", "network", "network controller", "networkcontroller":
|
|
return "NetworkController"
|
|
case "ethernet", "ethernet controller", "ethernetcontroller":
|
|
return "EthernetController"
|
|
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
|
|
return "FibreChannelController"
|
|
case "display", "displaycontroller", "display controller", "vga":
|
|
return "DisplayController"
|
|
case "video", "video controller", "videocontroller", "3d controller":
|
|
return "VideoController"
|
|
case "processing accelerator", "processingaccelerator":
|
|
return "ProcessingAccelerator"
|
|
case "mass storage controller", "massstoragecontroller":
|
|
return "MassStorageController"
|
|
case "storage controller", "storagecontroller":
|
|
return "StorageController"
|
|
default:
|
|
return strings.TrimSpace(deviceClass)
|
|
}
|
|
}
|
|
|
|
func normalizeNetworkDeviceClass(portType, model, description string) string {
|
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
|
|
switch {
|
|
case strings.Contains(joined, "ethernet"):
|
|
return "EthernetController"
|
|
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
|
|
return "FibreChannelController"
|
|
default:
|
|
return "NetworkController"
|
|
}
|
|
}
|
|
|
|
func normalizePlaceholderDeviceModel(model string) string {
|
|
trimmed := strings.TrimSpace(model)
|
|
switch strings.ToLower(trimmed) {
|
|
case "", "network device view", "pci device view", "pcie device view", "storage device view":
|
|
return ""
|
|
default:
|
|
return trimmed
|
|
}
|
|
}
|
|
|
|
func hasMeaningfulExporterText(v string) bool {
|
|
s := strings.ToLower(strings.TrimSpace(v))
|
|
if s == "" {
|
|
return false
|
|
}
|
|
switch s {
|
|
case "-", "n/a", "na", "none", "null", "unknown", "network device view", "pci device view", "pcie device view", "storage device view":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func isNumericExporterSlot(slot string) bool {
|
|
slot = strings.TrimSpace(slot)
|
|
if slot == "" {
|
|
return false
|
|
}
|
|
for _, r := range slot {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
case "archive":
|
|
return "logfile"
|
|
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 ""
|
|
}
|