Introduce canonical hardware.devices repository and align UI/Reanimator exports

This commit is contained in:
2026-02-17 19:07:18 +03:00
parent a82b55b144
commit de5521a4e5
11 changed files with 1944 additions and 319 deletions

View File

@@ -5,6 +5,7 @@ import (
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -31,6 +32,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
targetHost := inferTargetHost(result.TargetHost, result.Filename)
collectedAt := formatRFC3339(result.CollectedAt)
devices := canonicalDevicesForExport(result.Hardware)
export := &ReanimatorExport{
Filename: result.Filename,
@@ -41,11 +43,11 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
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)),
CPUs: convertCPUsFromDevices(devices, collectedAt),
Memory: convertMemoryFromDevices(devices, collectedAt),
Storage: convertStorageFromDevices(devices, collectedAt),
PCIeDevices: convertPCIeFromDevices(devices, collectedAt),
PowerSupplies: convertPSUsFromDevices(devices, collectedAt),
},
}
@@ -71,6 +73,269 @@ func convertBoard(board models.BoardInfo) ReanimatorBoard {
}
}
func canonicalDevicesForExport(hw *models.HardwareConfig) []models.HardwareDevice {
if hw == nil {
return nil
}
if len(hw.Devices) > 0 {
return hw.Devices
}
hw.Devices = buildDevicesFromLegacy(hw)
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))
appendDevice := func(d models.HardwareDevice) {
all = append(all, d)
}
for _, cpu := range hw.CPUs {
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: map[string]any{
"socket": cpu.Socket,
},
})
}
for _, mem := range hw.Memory {
if !mem.Present || mem.SizeMB == 0 {
continue
}
present := mem.Present
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: map[string]any{
"max_speed_mhz": mem.MaxSpeedMHz,
"current_speed_mhz": mem.CurrentSpeedMHz,
},
})
}
for _, stor := range hw.Storage {
if !stor.Present {
continue
}
present := stor.Present
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindStorage,
Slot: stor.Slot,
Model: stor.Model,
Manufacturer: stor.Manufacturer,
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,
})
}
for _, pcie := range hw.PCIeDevices {
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindPCIe,
Slot: pcie.Slot,
BDF: pcie.BDF,
DeviceClass: pcie.DeviceClass,
VendorID: pcie.VendorID,
DeviceID: pcie.DeviceID,
Model: pcie.PartNumber,
PartNumber: pcie.PartNumber,
Manufacturer: pcie.Manufacturer,
SerialNumber: pcie.SerialNumber,
LinkWidth: pcie.LinkWidth,
LinkSpeed: pcie.LinkSpeed,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcie.MaxLinkSpeed,
Status: pcie.Status,
StatusCheckedAt: pcie.StatusCheckedAt,
StatusChangedAt: pcie.StatusChangedAt,
StatusAtCollect: pcie.StatusAtCollect,
StatusHistory: pcie.StatusHistory,
ErrorDescription: pcie.ErrorDescription,
})
}
for _, gpu := range hw.GPUs {
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindGPU,
Slot: gpu.Slot,
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,
Status: gpu.Status,
StatusCheckedAt: gpu.StatusCheckedAt,
StatusChangedAt: gpu.StatusChangedAt,
StatusAtCollect: gpu.StatusAtCollect,
StatusHistory: gpu.StatusHistory,
ErrorDescription: gpu.ErrorDescription,
})
}
for _, nic := range hw.NetworkAdapters {
if !nic.Present {
continue
}
present := nic.Present
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindNetwork,
Slot: nic.Slot,
Location: nic.Location,
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,
Present: &present,
Status: nic.Status,
StatusCheckedAt: nic.StatusCheckedAt,
StatusChangedAt: nic.StatusChangedAt,
StatusAtCollect: nic.StatusAtCollect,
StatusHistory: nic.StatusHistory,
ErrorDescription: nic.ErrorDescription,
})
}
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,
})
}
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 {
byKey[key] = curr
}
}
out := make([]models.HardwareDevice, 0, len(order)+len(noKey))
for _, key := range order {
out = append(out, byKey[key].item)
}
out = append(out, noKey...)
for i := range out {
out[i].ID = out[i].Kind + ":" + strconv.Itoa(i)
}
return out
}
func canonicalKey(item models.HardwareDevice) string {
if sn := normalizedSerial(item.SerialNumber); sn != "" {
return "sn:" + strings.ToLower(sn)
}
if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" {
return "bdf:" + bdf
}
return ""
}
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 {
@@ -93,6 +358,194 @@ func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
return result
}
func convertCPUsFromDevices(devices []models.HardwareDevice, collectedAt string) []ReanimatorCPU {
result := make([]ReanimatorCPU, 0)
for _, d := range devices {
if d.Kind != models.DeviceKindCPU {
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.StatusAtCollect, 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,
Manufacturer: inferCPUManufacturer(d.Model),
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
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 := d.Present != nil && *d.Present
if !present || d.SizeMB == 0 {
continue
}
status := normalizeStatus(d.Status, true)
if strings.TrimSpace(d.Status) == "" {
if present {
status = "OK"
} else {
status = "Empty"
}
}
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusAtCollect, d.StatusHistory, d.ErrorDescription, collectedAt)
result = append(result, ReanimatorMemory{
Slot: d.Slot,
Location: d.Location,
Present: present,
SizeMB: d.SizeMB,
Type: d.Type,
MaxSpeedMHz: intFromDetailMap(d.Details, "max_speed_mhz"),
CurrentSpeedMHz: intFromDetailMap(d.Details, "current_speed_mhz"),
Manufacturer: d.Manufacturer,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
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 strings.TrimSpace(d.SerialNumber) == "" {
continue
}
present := d.Present == nil || *d.Present
status := inferStorageStatus(models.Storage{Present: present})
if strings.TrimSpace(d.Status) != "" {
status = normalizeStatus(d.Status, false)
}
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusAtCollect, 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,
Present: present,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
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
}
deviceClass := d.DeviceClass
if d.Kind == models.DeviceKindGPU && strings.TrimSpace(deviceClass) == "" {
deviceClass = "DisplayController"
}
model := d.Model
if model == "" {
model = d.PartNumber
}
status := normalizeStatus(d.Status, false)
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusAtCollect, d.StatusHistory, d.ErrorDescription, collectedAt)
result = append(result, ReanimatorPCIe{
Slot: d.Slot,
VendorID: d.VendorID,
DeviceID: d.DeviceID,
BDF: d.BDF,
DeviceClass: deviceClass,
Manufacturer: d.Manufacturer,
Model: model,
LinkWidth: d.LinkWidth,
LinkSpeed: d.LinkSpeed,
MaxLinkWidth: d.MaxLinkWidth,
MaxLinkSpeed: d.MaxLinkSpeed,
SerialNumber: normalizedSerial(d.SerialNumber),
Firmware: d.Firmware,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
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.StatusAtCollect, d.StatusHistory, d.ErrorDescription, collectedAt)
result = append(result, ReanimatorPSU{
Slot: d.Slot,
Present: present,
Model: d.Model,
Vendor: d.Manufacturer,
WattageW: d.WattageW,
SerialNumber: d.SerialNumber,
PartNumber: d.PartNumber,
Firmware: d.Firmware,
Status: status,
InputType: d.InputType,
InputPowerW: d.InputPowerW,
OutputPowerW: d.OutputPowerW,
InputVoltage: d.InputVoltage,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
func isDeviceBoundFirmwareName(name string) bool {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
@@ -809,6 +1262,37 @@ func normalizedSerial(serial string) string {
}
}
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 float64:
return int(n)
default:
return 0
}
}
// inferStorageStatus determines storage device status
func inferStorageStatus(stor models.Storage) string {
if !stor.Present {

View File

@@ -599,8 +599,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
{Socket: 0, Model: "CPU-A-DUP"},
},
Memory: []models.MemoryDIMM{
{Slot: "DIMM_A1", Present: true, SerialNumber: "MEM-1", Status: "OK"},
{Slot: "DIMM_A1", Present: true, SerialNumber: "MEM-1-DUP", Status: "OK"},
{Slot: "DIMM_A1", Present: true, SizeMB: 32768, SerialNumber: "MEM-1", Status: "OK"},
{Slot: "DIMM_A1", Present: true, SizeMB: 32768, SerialNumber: "MEM-1-DUP", Status: "OK"},
},
Storage: []models.Storage{
{Slot: "U.2-1", SerialNumber: "SSD-1", Model: "Disk1", Present: true},
@@ -629,11 +629,11 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
if len(out.Hardware.Firmware) != 1 {
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected deduped cpus len=1, got %d", len(out.Hardware.CPUs))
if len(out.Hardware.CPUs) != 2 {
t.Fatalf("expected cpus len=2 (no serial/bdf dedupe), got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected deduped memory len=1, got %d", len(out.Hardware.Memory))
if len(out.Hardware.Memory) != 2 {
t.Fatalf("expected memory len=2 (different serials), got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
@@ -641,8 +641,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected deduped pcie len=2 (gpu+nic), got %d", len(out.Hardware.PCIeDevices))
if len(out.Hardware.PCIeDevices) != 4 {
t.Fatalf("expected pcie len=4 with serial->bdf dedupe, got %d", len(out.Hardware.PCIeDevices))
}
gpuCount := 0
@@ -651,8 +651,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
gpuCount++
}
}
if gpuCount != 1 {
t.Fatalf("expected single #GPU0 record, got %d", gpuCount)
if gpuCount != 2 {
t.Fatalf("expected two #GPU0 records (pcie+gpu kinds), got %d", gpuCount)
}
}
@@ -699,3 +699,42 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
}
}
func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
input := &models.AnalysisResult{
Filename: "canonical.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindCPU,
Slot: "CPU0",
Model: "INTEL(R) XEON(R)",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
},
{
Kind: models.DeviceKindStorage,
Slot: "U.2-1",
Model: "Disk1",
SerialNumber: "SSD-1",
Present: boolPtr(true),
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected cpu from hardware.devices, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected storage from hardware.devices, got %d", len(out.Hardware.Storage))
}
}
func boolPtr(v bool) *bool { return &v }

View File

@@ -201,13 +201,9 @@ func TestFullReanimatorExport(t *testing.T) {
t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status)
}
// Memory (should include empty slots)
if len(hw.Memory) != 2 {
t.Errorf("Expected 2 memory entries (including empty), got %d", len(hw.Memory))
}
if hw.Memory[1].Status != "Empty" {
t.Errorf("Empty memory slot status mismatch: got %q", hw.Memory[1].Status)
// Memory (empty slots are excluded)
if len(hw.Memory) != 1 {
t.Errorf("Expected 1 memory entry (installed only), got %d", len(hw.Memory))
}
// Storage