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

@@ -0,0 +1,734 @@
package server
import (
"fmt"
"regexp"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
type slotFirmwareInfo struct {
Model string
Version string
Category string
}
var (
psuFirmwareRe = regexp.MustCompile(`(?i)^PSU\s*([0-9A-Za-z_-]+)\s*(?:\(([^)]+)\))?$`)
nicFirmwareRe = regexp.MustCompile(`(?i)^NIC\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
gpuFirmwareRe = regexp.MustCompile(`(?i)^GPU\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
nvsFirmwareRe = regexp.MustCompile(`(?i)^NVSwitch\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
)
func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
if hw == nil {
return nil
}
all := make([]models.HardwareDevice, 0, 1+len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
fwBySlot := buildFirmwareBySlot(hw.Firmware)
nextID := 0
add := func(d models.HardwareDevice) {
d.ID = fmt.Sprintf("%s:%d", d.Kind, nextID)
nextID++
all = append(all, d)
}
add(models.HardwareDevice{
Kind: models.DeviceKindBoard,
Source: "board",
Slot: "board",
Model: strings.TrimSpace(hw.BoardInfo.ProductName),
PartNumber: strings.TrimSpace(hw.BoardInfo.PartNumber),
Manufacturer: strings.TrimSpace(hw.BoardInfo.Manufacturer),
SerialNumber: strings.TrimSpace(hw.BoardInfo.SerialNumber),
Details: map[string]any{
"description": strings.TrimSpace(hw.BoardInfo.Description),
"version": strings.TrimSpace(hw.BoardInfo.Version),
"uuid": strings.TrimSpace(hw.BoardInfo.UUID),
},
})
for _, cpu := range hw.CPUs {
add(models.HardwareDevice{
Kind: models.DeviceKindCPU,
Source: "cpus",
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{
"description": cpu.Description,
"socket": cpu.Socket,
"l1_cache_kb": cpu.L1CacheKB,
"l2_cache_kb": cpu.L2CacheKB,
"l3_cache_kb": cpu.L3CacheKB,
"tdp_w": cpu.TDP,
"ppin": cpu.PPIN,
},
})
}
for _, mem := range hw.Memory {
if !mem.Present || mem.SizeMB == 0 {
continue
}
present := mem.Present
add(models.HardwareDevice{
Kind: models.DeviceKindMemory,
Source: "memory",
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{
"description": mem.Description,
"technology": mem.Technology,
"max_speed_mhz": mem.MaxSpeedMHz,
"current_speed_mhz": mem.CurrentSpeedMHz,
"ranks": mem.Ranks,
},
})
}
for _, stor := range hw.Storage {
if !stor.Present {
continue
}
present := stor.Present
add(models.HardwareDevice{
Kind: models.DeviceKindStorage,
Source: "storage",
Slot: stor.Slot,
Location: stor.Location,
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,
Details: map[string]any{
"description": stor.Description,
"backplane_id": stor.BackplaneID,
},
})
}
for _, p := range hw.PCIeDevices {
if isEmptyPCIeDevice(p) {
continue
}
slotKey := normalizeSlotKey(p.Slot)
fwInfo := fwBySlot[slotKey]
model := strings.TrimSpace(p.PartNumber)
if model == "" {
model = strings.TrimSpace(p.DeviceClass)
}
if model == "" {
model = strings.TrimSpace(p.Description)
}
if model == "" && fwInfo.Model != "" {
model = fwInfo.Model
}
add(models.HardwareDevice{
Kind: models.DeviceKindPCIe,
Source: "pcie_devices",
Slot: p.Slot,
BDF: p.BDF,
DeviceClass: p.DeviceClass,
VendorID: p.VendorID,
DeviceID: p.DeviceID,
Model: model,
PartNumber: p.PartNumber,
Manufacturer: p.Manufacturer,
SerialNumber: p.SerialNumber,
Firmware: fwInfo.Version,
MACAddresses: p.MACAddresses,
LinkWidth: p.LinkWidth,
LinkSpeed: p.LinkSpeed,
MaxLinkWidth: p.MaxLinkWidth,
MaxLinkSpeed: p.MaxLinkSpeed,
Status: p.Status,
StatusCheckedAt: p.StatusCheckedAt,
StatusChangedAt: p.StatusChangedAt,
StatusAtCollect: p.StatusAtCollect,
StatusHistory: p.StatusHistory,
ErrorDescription: p.ErrorDescription,
Details: map[string]any{
"description": p.Description,
"fw_category": fwInfo.Category,
},
})
}
for _, gpu := range hw.GPUs {
add(models.HardwareDevice{
Kind: models.DeviceKindGPU,
Source: "gpus",
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,
Status: gpu.Status,
StatusCheckedAt: gpu.StatusCheckedAt,
StatusChangedAt: gpu.StatusChangedAt,
StatusAtCollect: gpu.StatusAtCollect,
StatusHistory: gpu.StatusHistory,
ErrorDescription: gpu.ErrorDescription,
Details: map[string]any{
"description": gpu.Description,
"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,
},
})
}
for _, nic := range hw.NetworkAdapters {
if !nic.Present {
continue
}
present := nic.Present
add(models.HardwareDevice{
Kind: models.DeviceKindNetwork,
Source: "network_adapters",
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,
Details: map[string]any{
"description": nic.Description,
},
})
}
for _, psu := range hw.PowerSupply {
if !psu.Present {
continue
}
present := psu.Present
add(models.HardwareDevice{
Kind: models.DeviceKindPSU,
Source: "power_supplies",
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: map[string]any{
"description": psu.Description,
"output_voltage": psu.OutputVoltage,
},
})
}
return dedupeDevices(all)
}
func isEmptyPCIeDevice(p models.PCIeDevice) bool {
if isNumericSlot(strings.TrimSpace(p.Slot)) &&
strings.TrimSpace(p.BDF) == "" &&
p.VendorID == 0 &&
p.DeviceID == 0 &&
normalizedSerial(p.SerialNumber) == "" &&
!hasMeaningfulText(p.PartNumber) &&
!hasMeaningfulText(p.Manufacturer) &&
!hasMeaningfulText(p.Description) &&
len(p.MACAddresses) == 0 &&
p.LinkWidth == 0 &&
p.MaxLinkWidth == 0 {
return true
}
if strings.TrimSpace(p.BDF) != "" {
return false
}
if p.VendorID != 0 || p.DeviceID != 0 {
return false
}
if normalizedSerial(p.SerialNumber) != "" {
return false
}
if hasMeaningfulText(p.PartNumber) {
return false
}
if hasMeaningfulText(p.Manufacturer) {
return false
}
if hasMeaningfulText(p.Description) {
return false
}
if strings.TrimSpace(p.DeviceClass) != "" {
class := strings.ToLower(strings.TrimSpace(p.DeviceClass))
if class != "unknown" && class != "other" && class != "pcie device" {
return false
}
}
return true
}
func isNumericSlot(slot string) bool {
if slot == "" {
return false
}
for _, r := range slot {
if r < '0' || r > '9' {
return false
}
}
return true
}
func hasMeaningfulText(v string) bool {
s := strings.ToLower(strings.TrimSpace(v))
if s == "" {
return false
}
switch s {
case "-", "n/a", "na", "none", "null", "unknown":
return false
default:
return true
}
}
func dedupeDevices(items []models.HardwareDevice) []models.HardwareDevice {
if len(items) < 2 {
return items
}
parent := make([]int, len(items))
for i := range parent {
parent[i] = i
}
find := func(x int) int {
for parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
union := func(a, b int) {
ra := find(a)
rb := find(b)
if ra != rb {
parent[rb] = ra
}
}
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if shouldMergeDevices(items[i], items[j]) {
union(i, j)
}
}
}
groups := make(map[int][]int, len(items))
order := make([]int, 0, len(items))
for i := range items {
root := find(i)
if _, ok := groups[root]; !ok {
order = append(order, root)
}
groups[root] = append(groups[root], i)
}
out := make([]models.HardwareDevice, 0, len(order))
for _, root := range order {
indices := groups[root]
bestIdx := indices[0]
bestScore := qualityScore(items[bestIdx])
for _, idx := range indices[1:] {
if s := qualityScore(items[idx]); s > bestScore {
bestIdx = idx
bestScore = s
}
}
merged := items[bestIdx]
for _, idx := range indices {
if idx == bestIdx {
continue
}
merged = mergeDevices(merged, items[idx])
}
out = append(out, merged)
}
for i := range out {
out[i].ID = out[i].Kind + ":" + strconv.Itoa(i)
}
return out
}
func shouldMergeDevices(a, b models.HardwareDevice) bool {
aSN := strings.ToLower(normalizedSerial(a.SerialNumber))
bSN := strings.ToLower(normalizedSerial(b.SerialNumber))
aBDF := strings.ToLower(strings.TrimSpace(a.BDF))
bBDF := strings.ToLower(strings.TrimSpace(b.BDF))
// Hard conflicts.
if aSN != "" && bSN != "" && aSN == bSN {
return true
}
if aSN != "" && bSN != "" && aSN != bSN {
return false
}
if aBDF != "" && bBDF != "" && aBDF != bBDF {
return false
}
// Strong identities.
if aBDF != "" && aBDF == bBDF {
return true
}
// If both have no strong IDs, be conservative.
if aSN == "" && bSN == "" && aBDF == "" && bBDF == "" {
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
return true
}
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
return true
}
return false
}
score := 0
if samePCIID(a, b) {
score += 4
}
if sameModel(a, b) {
score += 3
}
if sameManufacturer(a, b) {
score += 2
}
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
score += 2
}
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
score += 2
}
if sameKindFamily(a.Kind, b.Kind) {
score++
}
if samePCIID(a, b) && ((aBDF != "" && bBDF == "") || (aBDF == "" && bBDF != "")) {
score += 2
}
return score >= 7
}
func mergeDevices(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.ID, secondary.ID)
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
}
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.IsZero() && !secondary.StatusCheckedAt.IsZero() {
primary.StatusCheckedAt = secondary.StatusCheckedAt
}
if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() {
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)
if primary.Details == nil && secondary.Details != nil {
primary.Details = secondary.Details
}
return primary
}
func samePCIID(a, b models.HardwareDevice) bool {
if (a.VendorID == 0 && a.DeviceID == 0) || (b.VendorID == 0 && b.DeviceID == 0) {
return false
}
return a.VendorID == b.VendorID && a.DeviceID == b.DeviceID
}
func sameModel(a, b models.HardwareDevice) bool {
am := normalizeText(coalesce(a.Model, a.PartNumber, a.DeviceClass))
bm := normalizeText(coalesce(b.Model, b.PartNumber, b.DeviceClass))
return am != "" && am == bm
}
func sameManufacturer(a, b models.HardwareDevice) bool {
am := normalizeText(a.Manufacturer)
bm := normalizeText(b.Manufacturer)
return am != "" && am == bm
}
func hasMACOverlap(a, b []string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
set := make(map[string]struct{}, len(a))
for _, mac := range a {
key := normalizeText(mac)
if key != "" {
set[key] = struct{}{}
}
}
for _, mac := range b {
if _, ok := set[normalizeText(mac)]; ok {
return true
}
}
return false
}
func sameKindFamily(a, b string) bool {
if a == b {
return true
}
family := map[string]bool{
models.DeviceKindPCIe: true,
models.DeviceKindGPU: true,
models.DeviceKindNetwork: true,
}
return family[a] && family[b]
}
func normalizeText(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, "_", "")
s = strings.ReplaceAll(s, "-", "")
return s
}
func normalizeSlot(slot string) string {
return normalizeText(slot)
}
func qualityScore(d models.HardwareDevice) int {
score := 0
if normalizedSerial(d.SerialNumber) != "" {
score += 6
}
if strings.TrimSpace(d.BDF) != "" {
score += 4
}
if strings.TrimSpace(d.Model) != "" {
score += 3
}
if strings.TrimSpace(d.Firmware) != "" {
score += 2
}
if strings.TrimSpace(d.Status) != "" {
score++
}
return score
}
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 buildFirmwareBySlot(firmware []models.FirmwareInfo) map[string]slotFirmwareInfo {
out := make(map[string]slotFirmwareInfo)
add := func(slot, model, version, category string) {
key := normalizeSlotKey(slot)
if key == "" || strings.TrimSpace(version) == "" {
return
}
existing, ok := out[key]
if ok && strings.TrimSpace(existing.Model) != "" {
return
}
out[key] = slotFirmwareInfo{
Model: strings.TrimSpace(model),
Version: strings.TrimSpace(version),
Category: category,
}
}
for _, fw := range firmware {
name := strings.TrimSpace(fw.DeviceName)
if name == "" {
continue
}
if m := psuFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
model := strings.TrimSpace(m[2])
if model == "" {
model = "PSU"
}
add(m[1], model, fw.Version, "psu")
continue
}
if m := nicFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
model := strings.TrimSpace(m[2])
if model == "" {
model = "NIC"
}
add(m[1], model, fw.Version, "nic")
continue
}
if m := gpuFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
model := strings.TrimSpace(m[2])
if model == "" {
model = "GPU"
}
add(m[1], model, fw.Version, "gpu")
continue
}
if m := nvsFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
model := strings.TrimSpace(m[2])
if model == "" {
model = "NVSwitch"
}
add(m[1], model, fw.Version, "nvswitch")
continue
}
}
return out
}
func normalizeSlotKey(slot string) string {
return strings.ToLower(strings.TrimSpace(slot))
}

View File

@@ -0,0 +1,152 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestBuildHardwareDevices_DedupSerialThenBDF(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{Slot: "A1", SerialNumber: "SER-1", BDF: "0000:01:00.0", DeviceClass: "NetworkController"},
{Slot: "A2", SerialNumber: "SER-1", BDF: "0000:02:00.0", DeviceClass: "NetworkController"},
{Slot: "B1", SerialNumber: "", BDF: "0000:03:00.0", DeviceClass: "NetworkController"},
{Slot: "B2", SerialNumber: "", BDF: "0000:03:00.0", DeviceClass: "NetworkController"},
{Slot: "C1", SerialNumber: "", BDF: "", DeviceClass: "NetworkController"},
{Slot: "C2", SerialNumber: "", BDF: "", DeviceClass: "NetworkController"},
},
}
devices := BuildHardwareDevices(hw)
// 1 board + (SER-1 dedup -> 1) + (BDF 03 dedup -> 1) + (C1,C2 keep both) = 5
if len(devices) != 5 {
t.Fatalf("expected 5 devices after dedupe, got %d", len(devices))
}
bySlot := map[string]bool{}
for _, d := range devices {
bySlot[d.Slot] = true
}
if !bySlot["A1"] && !bySlot["A2"] {
t.Fatalf("expected one serial-deduped A* device")
}
if bySlot["B1"] && bySlot["B2"] {
t.Fatalf("expected B1/B2 to dedupe by bdf")
}
if !bySlot["C1"] || !bySlot["C2"] {
t.Fatalf("expected C1 and C2 to remain without serial/bdf")
}
}
func TestBuildHardwareDevices_SkipsEmptyMemorySlots(t *testing.T) {
hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{
{Slot: "A1", Present: true, SizeMB: 32768, SerialNumber: "DIMM-1"},
{Slot: "A2", Present: false, SizeMB: 0, SerialNumber: "DIMM-2"},
},
}
devices := BuildHardwareDevices(hw)
memoryCount := 0
for _, d := range devices {
if d.Kind == models.DeviceKindMemory {
memoryCount++
if d.Slot == "A2" {
t.Fatalf("empty memory slot should not be included")
}
}
}
if memoryCount != 1 {
t.Fatalf("expected 1 installed memory record, got %d", memoryCount)
}
}
func TestBuildHardwareDevices_DedupCrossKindByBDF(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{
Slot: "SL0CP0_001",
BDF: "02:00.0",
DeviceClass: "DisplayController",
VendorID: 0x1a03,
DeviceID: 0x2000,
PartNumber: "ASPEED Graphics Family",
Manufacturer: "ASPEED Technology, Inc.",
},
},
GPUs: []models.GPU{
{
Slot: "SL0CP0_001",
BDF: "02:00.0",
Model: "ASPEED Graphics Family",
Manufacturer: "ASPEED Technology, Inc.",
VendorID: 0x1a03,
DeviceID: 0x2000,
},
},
}
devices := BuildHardwareDevices(hw)
count := 0
for _, d := range devices {
if d.BDF == "02:00.0" {
count++
}
}
if count != 1 {
t.Fatalf("expected 1 canonical device for bdf 02:00.0, got %d", count)
}
}
func TestBuildHardwareDevices_SkipsFirmwareOnlyNumericSlots(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{Slot: "0", DeviceClass: "Unknown", Manufacturer: "-", PartNumber: "-", Description: "-"},
{Slot: "1", DeviceClass: "Other", Manufacturer: "unknown", PartNumber: "N/A", Description: "NULL"},
},
}
devices := BuildHardwareDevices(hw)
for _, d := range devices {
if d.Kind == models.DeviceKindPCIe && (d.Slot == "0" || d.Slot == "1") {
t.Fatalf("firmware-only numeric-slot pcie record must be filtered, got slot %q", d.Slot)
}
}
}
func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
srv := &Server{}
srv.SetResult(&models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{ProductName: "X", SerialNumber: "SN-1"},
CPUs: []models.CPU{{Socket: 0, Model: "CPU"}},
},
})
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
w := httptest.NewRecorder()
srv.handleGetConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload map[string]any
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
hardware, ok := payload["hardware"].(map[string]any)
if !ok {
t.Fatalf("expected hardware object")
}
if _, ok := hardware["devices"]; !ok {
t.Fatalf("expected hardware.devices in config response")
}
if _, ok := hardware["cpus"]; ok {
t.Fatalf("did not expect legacy hardware.cpus in config response")
}
}

View File

@@ -163,10 +163,14 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
return
}
// Build specification summary
spec := buildSpecification(result)
devices := canonicalDevices(result.Hardware)
spec := buildSpecification(result.Hardware)
response["hardware"] = result.Hardware
response["hardware"] = map[string]any{
"board": result.Hardware.BoardInfo,
"firmware": result.Hardware.Firmware,
"devices": devices,
}
response["specification"] = spec
jsonResponse(w, response)
}
@@ -178,17 +182,28 @@ type SpecLine struct {
Quantity int `json:"quantity"`
}
func buildSpecification(result *models.AnalysisResult) []SpecLine {
func canonicalDevices(hw *models.HardwareConfig) []models.HardwareDevice {
if hw == nil {
return nil
}
hw.Devices = BuildHardwareDevices(hw)
return hw.Devices
}
func buildSpecification(hw *models.HardwareConfig) []SpecLine {
var spec []SpecLine
hw := result.Hardware
if hw == nil {
return spec
}
devices := canonicalDevices(hw)
// CPUs - group by model
cpuGroups := make(map[string]int)
cpuDetails := make(map[string]models.CPU)
for _, cpu := range hw.CPUs {
cpuDetails := make(map[string]models.HardwareDevice)
for _, cpu := range devices {
if cpu.Kind != models.DeviceKindCPU {
continue
}
cpuGroups[cpu.Model]++
cpuDetails[cpu.Model] = cpu
}
@@ -198,21 +213,26 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
model,
float64(cpu.FrequencyMHz)/1000,
cpu.Cores,
cpu.TDP)
intFromDetails(cpu.Details, "tdp_w"))
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
}
// Memory - group by size, type and frequency (only installed modules)
memGroups := make(map[string]int)
for _, mem := range hw.Memory {
for _, mem := range devices {
if mem.Kind != models.DeviceKindMemory {
continue
}
present := mem.Present != nil && *mem.Present
// Skip empty slots (not present or 0 size)
if !mem.Present || mem.SizeMB == 0 {
if !present || mem.SizeMB == 0 {
continue
}
// Include frequency if available
key := ""
if mem.CurrentSpeedMHz > 0 {
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, mem.CurrentSpeedMHz)
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
if currentSpeed > 0 {
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, currentSpeed)
} else {
key = fmt.Sprintf("%s %dGB", mem.Type, mem.SizeMB/1024)
}
@@ -224,7 +244,10 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
// Storage - group by type and capacity
storGroups := make(map[string]int)
for _, stor := range hw.Storage {
for _, stor := range devices {
if stor.Kind != models.DeviceKindStorage {
continue
}
var key string
if stor.SizeGB >= 1000 {
key = fmt.Sprintf("%s %s %.2fTB", stor.Type, stor.Interface, float64(stor.SizeGB)/1000)
@@ -239,8 +262,11 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
// PCIe devices - group by device class/name and manufacturer
pcieGroups := make(map[string]int)
pcieDetails := make(map[string]models.PCIeDevice)
for _, pcie := range hw.PCIeDevices {
pcieDetails := make(map[string]models.HardwareDevice)
for _, pcie := range devices {
if pcie.Kind != models.DeviceKindPCIe && pcie.Kind != models.DeviceKindGPU && pcie.Kind != models.DeviceKindNetwork {
continue
}
// Create unique key from manufacturer + device class/name
key := pcie.DeviceClass
if pcie.Manufacturer != "" {
@@ -259,7 +285,7 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
// Determine category based on device class or known GPU names
deviceClass := pcie.DeviceClass
isGPU := isGPUDevice(deviceClass)
isGPU := pcie.Kind == models.DeviceKindGPU || isGPUDevice(deviceClass)
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
if isGPU {
@@ -275,7 +301,10 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
// Power supplies - group by model/wattage
psuGroups := make(map[string]int)
for _, psu := range hw.PowerSupply {
for _, psu := range devices {
if psu.Kind != models.DeviceKindPSU {
continue
}
key := psu.Model
if key == "" && psu.WattageW > 0 {
key = fmt.Sprintf("%dW", psu.WattageW)
@@ -309,23 +338,6 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
}
var serials []SerialEntry
seenByLocationSerial := make(map[string]bool)
markSeen := func(location, serial string) {
loc := strings.ToLower(strings.TrimSpace(location))
sn := strings.ToLower(strings.TrimSpace(serial))
if loc == "" || sn == "" {
return
}
seenByLocationSerial[loc+"|"+sn] = true
}
alreadySeen := func(location, serial string) bool {
loc := strings.ToLower(strings.TrimSpace(location))
sn := strings.ToLower(strings.TrimSpace(serial))
if loc == "" || sn == "" {
return false
}
return seenByLocationSerial[loc+"|"+sn]
}
// From FRU
for _, fru := range result.FRU {
@@ -345,132 +357,18 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
})
}
// From Hardware
if result.Hardware != nil {
// Board
if hasUsableSerial(result.Hardware.BoardInfo.SerialNumber) {
serials = append(serials, SerialEntry{
Component: result.Hardware.BoardInfo.ProductName,
SerialNumber: strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber),
Manufacturer: result.Hardware.BoardInfo.Manufacturer,
PartNumber: result.Hardware.BoardInfo.PartNumber,
Category: "Board",
})
}
// CPUs
for _, cpu := range result.Hardware.CPUs {
if !hasUsableSerial(cpu.SerialNumber) {
for _, d := range canonicalDevices(result.Hardware) {
if !hasUsableSerial(d.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: cpu.Model,
Location: fmt.Sprintf("CPU%d", cpu.Socket),
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
Category: "CPU",
})
}
// Memory DIMMs
for _, mem := range result.Hardware.Memory {
if !hasUsableSerial(mem.SerialNumber) {
continue
}
location := mem.Location
if location == "" {
location = mem.Slot
}
serials = append(serials, SerialEntry{
Component: mem.PartNumber,
Location: location,
SerialNumber: strings.TrimSpace(mem.SerialNumber),
Manufacturer: mem.Manufacturer,
PartNumber: mem.PartNumber,
Category: "Memory",
})
}
// Storage
for _, stor := range result.Hardware.Storage {
if !hasUsableSerial(stor.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: stor.Model,
Location: stor.Slot,
SerialNumber: strings.TrimSpace(stor.SerialNumber),
Manufacturer: stor.Manufacturer,
Category: "Storage",
})
}
// GPUs
for _, gpu := range result.Hardware.GPUs {
if !hasUsableSerial(gpu.SerialNumber) {
continue
}
model := gpu.Model
if model == "" {
model = "GPU"
}
serials = append(serials, SerialEntry{
Component: model,
Location: gpu.Slot,
SerialNumber: strings.TrimSpace(gpu.SerialNumber),
Manufacturer: gpu.Manufacturer,
Category: "GPU",
})
markSeen(gpu.Slot, gpu.SerialNumber)
}
// PCIe devices
for _, pcie := range result.Hardware.PCIeDevices {
if !hasUsableSerial(pcie.SerialNumber) {
continue
}
if alreadySeen(pcie.Slot, pcie.SerialNumber) {
continue
}
component := normalizePCIeSerialComponentName(pcie)
if strings.EqualFold(strings.TrimSpace(pcie.DeviceClass), "NVSwitch") && strings.TrimSpace(pcie.PartNumber) != "" {
component = strings.TrimSpace(pcie.PartNumber)
}
serials = append(serials, SerialEntry{
Component: component,
Location: pcie.Slot,
SerialNumber: strings.TrimSpace(pcie.SerialNumber),
Manufacturer: pcie.Manufacturer,
PartNumber: pcie.PartNumber,
Category: "PCIe",
})
markSeen(pcie.Slot, pcie.SerialNumber)
}
// Network cards
for _, nic := range result.Hardware.NetworkCards {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: nic.Model,
Location: nic.Name,
SerialNumber: strings.TrimSpace(nic.SerialNumber),
Category: "Network",
})
markSeen(nic.Name, nic.SerialNumber)
}
// Power supplies
for _, psu := range result.Hardware.PowerSupply {
if !hasUsableSerial(psu.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: psu.Model,
Location: psu.Slot,
SerialNumber: strings.TrimSpace(psu.SerialNumber),
Manufacturer: psu.Vendor,
Category: "PSU",
Component: serialComponent(d),
Location: strings.TrimSpace(coalesce(d.Location, d.Slot)),
SerialNumber: strings.TrimSpace(d.SerialNumber),
Manufacturer: strings.TrimSpace(d.Manufacturer),
PartNumber: strings.TrimSpace(d.PartNumber),
Category: serialCategory(d.Kind),
})
}
}
@@ -566,26 +464,91 @@ func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
appendEntry(component, model, fw.Version)
}
// Fallback for parsers that fill GPU firmware on device inventory only
// (e.g. runtime enrichment from redis/HGX) without explicit Hardware.Firmware entries.
for _, gpu := range hw.GPUs {
version := strings.TrimSpace(gpu.Firmware)
for _, d := range canonicalDevices(hw) {
version := strings.TrimSpace(d.Firmware)
if version == "" {
continue
}
model := strings.TrimSpace(gpu.PartNumber)
model := strings.TrimSpace(d.PartNumber)
if model == "" {
model = strings.TrimSpace(gpu.Model)
model = strings.TrimSpace(d.Model)
}
if model == "" {
model = strings.TrimSpace(gpu.Slot)
model = strings.TrimSpace(d.Slot)
}
appendEntry("GPU", model, version)
appendEntry(serialCategory(d.Kind), model, version)
}
return deduplicated
}
func serialComponent(d models.HardwareDevice) string {
if strings.TrimSpace(d.Model) != "" {
return strings.TrimSpace(d.Model)
}
if strings.TrimSpace(d.PartNumber) != "" {
return strings.TrimSpace(d.PartNumber)
}
if d.Kind == models.DeviceKindPCIe {
return normalizePCIeSerialComponentName(models.PCIeDevice{
DeviceClass: d.DeviceClass,
PartNumber: d.PartNumber,
})
}
if strings.TrimSpace(d.DeviceClass) != "" {
return strings.TrimSpace(d.DeviceClass)
}
return strings.ToUpper(d.Kind)
}
func serialCategory(kind string) string {
switch kind {
case models.DeviceKindBoard:
return "Board"
case models.DeviceKindCPU:
return "CPU"
case models.DeviceKindMemory:
return "Memory"
case models.DeviceKindStorage:
return "Storage"
case models.DeviceKindGPU:
return "GPU"
case models.DeviceKindNetwork:
return "Network"
case models.DeviceKindPSU:
return "PSU"
default:
return "PCIe"
}
}
func intFromDetails(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
}
}
func coalesce(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// extractFirmwareComponentAndModel extracts the component type and model from firmware device name
func extractFirmwareComponentAndModel(deviceName string) (component, model string) {
// Parse different firmware name formats and extract component + model