diff --git a/CLAUDE.md b/CLAUDE.md
index 49d92eb..a50e95c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ Filename pattern for all exports:
Notes:
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
-- **Reanimator export** (`/api/export/reanimator`):
+ - **Reanimator export** (`/api/export/reanimator`):
- Exports hardware data in Reanimator format for integration with asset tracking systems.
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
- Requires `hardware.board.serial_number` to be present.
@@ -93,6 +93,26 @@ Notes:
- Includes GPUs and NetworkAdapters as PCIe devices.
- Filters out storage devices and PSUs without serial numbers.
+## Canonical device repository (AI memory)
+
+Single source of truth for hardware inventory is `hardware.devices`.
+
+Rules:
+- UI tabs must read hardware records from `hardware.devices`.
+- Device Inventory tab includes kinds: `pcie`, `storage`, `gpu`, `network`.
+- Reanimator export must use the same canonical repository (`hardware.devices`).
+- Any UI vs Reanimator mismatch is a bug.
+
+Canonical dedupe (applied once in repository builder):
+1. usable `serial_number`,
+2. fallback `bdf` (PCI ID in BDF format),
+3. if both are absent, keep records distinct (no forced merge).
+
+Implementation guidance:
+- Keep `hardware.devices` schema as close as possible to Reanimator JSON fields.
+- Exporter should mainly group/filter canonical records by section, not rebuild data from multiple sources.
+- New hardware attributes must be added to canonical device schema first, then mapped to Reanimator/UI.
+
## CLI flags (`cmd/logpile/main.go`)
- `--port`
diff --git a/README.md b/README.md
index 58076fb..36e7846 100644
--- a/README.md
+++ b/README.md
@@ -157,6 +157,7 @@ POST /api/collect
- `GET /api/export/csv` — серийные номера
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
+- `GET /api/export/reanimator` — экспорт для Reanimator
Имена экспортируемых файлов:
@@ -165,6 +166,22 @@ POST /api/collect
Пример:
`2026-02-04 (SYS-421GE-TNHR2) - C8X123456789.json`
+## Canonical inventory (`hardware.devices`)
+
+В проекте используется единый реестр устройств сервера: `hardware.devices`.
+Это source of truth для UI и экспорта Reanimator.
+
+Основные правила:
+- вкладки конфигурации читают данные устройств из `hardware.devices`;
+- `Device Inventory` строится по типам `pcie`, `storage`, `gpu`, `network`;
+- экспорт Reanimator использует тот же canonical-реестр;
+- расхождение данных UI и Reanimator считается дефектом.
+
+Дедупликация в canonical-реестре:
+1. по usable `serial_number` (не пустой и не `N/A/NA/NONE/NULL/UNKNOWN/-`);
+2. если serial отсутствует — по `bdf`;
+3. если serial и bdf отсутствуют — записи не схлопываются.
+
## API
```text
@@ -181,10 +198,15 @@ GET /api/serials
GET /api/firmware
GET /api/export/csv
GET /api/export/json
+GET /api/export/reanimator
DELETE /api/clear
POST /api/shutdown
```
+Примечания:
+- `GET /api/config` возвращает canonical inventory в `hardware.devices`.
+- `GET /api/serials` и `GET /api/firmware` строятся из того же canonical inventory.
+
`/api/status` и `/api/config` содержат метаданные источника:
- `source_type`: `archive` | `api`
- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым)
diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go
index ef7d013..ea5d6c4 100644
--- a/internal/exporter/reanimator_converter.go
+++ b/internal/exporter/reanimator_converter.go
@@ -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 {
diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go
index a96a502..ebf19e2 100644
--- a/internal/exporter/reanimator_converter_test.go
+++ b/internal/exporter/reanimator_converter_test.go
@@ -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 }
diff --git a/internal/exporter/reanimator_integration_test.go b/internal/exporter/reanimator_integration_test.go
index f531640..5f31c9b 100644
--- a/internal/exporter/reanimator_integration_test.go
+++ b/internal/exporter/reanimator_integration_test.go
@@ -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
diff --git a/internal/models/models.go b/internal/models/models.go
index 042c268..f348a4f 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -84,6 +84,7 @@ type FRUInfo struct {
type HardwareConfig struct {
Firmware []FirmwareInfo `json:"firmware,omitempty"`
BoardInfo BoardInfo `json:"board,omitempty"`
+ Devices []HardwareDevice `json:"devices,omitempty"`
CPUs []CPU `json:"cpus,omitempty"`
Memory []MemoryDIMM `json:"memory,omitempty"`
Storage []Storage `json:"storage,omitempty"`
@@ -94,6 +95,66 @@ type HardwareConfig struct {
PowerSupply []PSU `json:"power_supplies,omitempty"`
}
+const (
+ DeviceKindBoard = "board"
+ DeviceKindCPU = "cpu"
+ DeviceKindMemory = "memory"
+ DeviceKindStorage = "storage"
+ DeviceKindPCIe = "pcie"
+ DeviceKindGPU = "gpu"
+ DeviceKindNetwork = "network"
+ DeviceKindPSU = "psu"
+)
+
+// HardwareDevice is canonical device inventory used across UI and exports.
+type HardwareDevice struct {
+ ID string `json:"id"`
+ Kind string `json:"kind"`
+ Source string `json:"source,omitempty"`
+ Slot string `json:"slot,omitempty"`
+ Location string `json:"location,omitempty"`
+ BDF string `json:"bdf,omitempty"`
+ DeviceClass string `json:"device_class,omitempty"`
+ VendorID int `json:"vendor_id,omitempty"`
+ DeviceID int `json:"device_id,omitempty"`
+ Model string `json:"model,omitempty"`
+ PartNumber string `json:"part_number,omitempty"`
+ Manufacturer string `json:"manufacturer,omitempty"`
+ SerialNumber string `json:"serial_number,omitempty"`
+ Firmware string `json:"firmware,omitempty"`
+ Type string `json:"type,omitempty"`
+ Interface string `json:"interface,omitempty"`
+ Present *bool `json:"present,omitempty"`
+ SizeMB int `json:"size_mb,omitempty"`
+ SizeGB int `json:"size_gb,omitempty"`
+ Cores int `json:"cores,omitempty"`
+ Threads int `json:"threads,omitempty"`
+ FrequencyMHz int `json:"frequency_mhz,omitempty"`
+ MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
+ PortCount int `json:"port_count,omitempty"`
+ PortType string `json:"port_type,omitempty"`
+ MACAddresses []string `json:"mac_addresses,omitempty"`
+ LinkWidth int `json:"link_width,omitempty"`
+ LinkSpeed string `json:"link_speed,omitempty"`
+ MaxLinkWidth int `json:"max_link_width,omitempty"`
+ MaxLinkSpeed string `json:"max_link_speed,omitempty"`
+ WattageW int `json:"wattage_w,omitempty"`
+ InputType string `json:"input_type,omitempty"`
+ InputPowerW int `json:"input_power_w,omitempty"`
+ OutputPowerW int `json:"output_power_w,omitempty"`
+ InputVoltage float64 `json:"input_voltage,omitempty"`
+ TemperatureC int `json:"temperature_c,omitempty"`
+ Status string `json:"status,omitempty"`
+
+ StatusCheckedAt time.Time `json:"status_checked_at,omitempty"`
+ StatusChangedAt time.Time `json:"status_changed_at,omitempty"`
+ StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
+ StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
+ ErrorDescription string `json:"error_description,omitempty"`
+
+ Details map[string]any `json:"details,omitempty"`
+}
+
// FirmwareInfo represents firmware version information
type FirmwareInfo struct {
DeviceName string `json:"device_name"`
diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go
new file mode 100644
index 0000000..28c01c5
--- /dev/null
+++ b/internal/server/device_repository.go
@@ -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))
+}
diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go
new file mode 100644
index 0000000..f347bb7
--- /dev/null
+++ b/internal/server/device_repository_test.go
@@ -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")
+ }
+}
diff --git a/internal/server/handlers.go b/internal/server/handlers.go
index 4832493..0de781f 100644
--- a/internal/server/handlers.go
+++ b/internal/server/handlers.go
@@ -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
diff --git a/web/static/css/style.css b/web/static/css/style.css
index 318b8d8..565663b 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -837,6 +837,12 @@ footer {
color: #2c3e50;
}
+.pcie-group-title {
+ margin: 1rem 0 0.5rem;
+ color: #34495e;
+ font-size: 0.95rem;
+}
+
/* Config tables */
.config-table {
font-size: 0.875rem;
@@ -930,6 +936,64 @@ footer {
text-transform: uppercase;
}
+.stat-box.pcie-balance-ok {
+ border-left-color: #27ae60;
+}
+
+.stat-box.pcie-balance-warning {
+ border-left-color: #f39c12;
+}
+
+.stat-box.pcie-balance-critical {
+ border-left-color: #e74c3c;
+}
+
+.pcie-balance-bars {
+ margin-bottom: 1rem;
+ display: grid;
+ gap: 0.5rem;
+ max-width: 560px;
+}
+
+.pcie-balance-row {
+ display: grid;
+ grid-template-columns: 72px 1fr 42px;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.pcie-balance-cpu,
+.pcie-balance-value {
+ font-size: 0.8rem;
+ color: #2c3e50;
+ font-weight: 600;
+}
+
+.pcie-balance-track {
+ height: 10px;
+ border-radius: 999px;
+ background: #e5e9ec;
+ overflow: hidden;
+}
+
+.pcie-balance-fill {
+ height: 100%;
+ border-radius: inherit;
+ min-width: 2px;
+}
+
+.pcie-balance-fill.pcie-balance-ok {
+ background: #27ae60;
+}
+
+.pcie-balance-fill.pcie-balance-warning {
+ background: #f39c12;
+}
+
+.pcie-balance-fill.pcie-balance-critical {
+ background: #e74c3c;
+}
+
/* Responsive */
@media (max-width: 768px) {
.sensor-grid {
diff --git a/web/static/js/app.js b/web/static/js/app.js
index 34f2620..6f2413e 100644
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -646,6 +646,22 @@ function renderConfig(data) {
const config = data.hardware || data;
const spec = data.specification;
+ const devices = Array.isArray(config.devices) ? config.devices : [];
+
+ const cpus = devices.filter(d => d.kind === 'cpu');
+ const memory = devices.filter(d => d.kind === 'memory');
+ const powerSupplies = devices.filter(d => d.kind === 'psu');
+ const storage = devices.filter(d => d.kind === 'storage');
+ const gpus = devices.filter(d => d.kind === 'gpu');
+ const networkAdapters = devices.filter(d => d.kind === 'network');
+ const inventoryRows = devices.filter(d => ['pcie', 'storage', 'gpu', 'network'].includes(d.kind));
+ const pcieBalance = calculateCPUToPCIeBalance(inventoryRows, cpus);
+ const pcieByCPU = new Map();
+ pcieBalance.perCPU.forEach(item => {
+ const idx = extractCPUIndex(item.label);
+ if (idx !== null) pcieByCPU.set(idx, item.lanes);
+ });
+ const memoryByCPU = calculateMemoryModulesByCPU(memory);
let html = '';
@@ -684,30 +700,56 @@ function renderConfig(data) {
// CPU tab
html += '
';
- if (config.cpus && config.cpus.length > 0) {
- const cpuCount = config.cpus.length;
- const cpuModel = config.cpus[0].model || '-';
- const totalCores = config.cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
- const totalThreads = config.cpus.reduce((sum, c) => sum + (c.threads || 0), 0);
+ if (cpus.length > 0) {
+ const cpuCount = cpus.length;
+ const cpuModel = cpus[0].model || '-';
+ const totalCores = cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
+ const totalThreads = cpus.reduce((sum, c) => sum + (c.threads || 0), 0);
+ const balanceClass = pcieBalance.severity === 'critical'
+ ? 'pcie-balance-critical'
+ : (pcieBalance.severity === 'warning' ? 'pcie-balance-warning' : 'pcie-balance-ok');
+ const balanceLabel = pcieBalance.severity === 'critical'
+ ? 'Перевес высокий'
+ : (pcieBalance.severity === 'warning' ? 'Есть перевес' : 'Распределено ровно');
html += `
Процессоры
${cpuCount}Процессоров
${totalCores}Ядер
${totalThreads}Потоков
+
${pcieBalance.totalLanes}Занято PCIe линий
+
${balanceLabel}Баланс PCIe
${escapeHtml(cpuModel)}Модель
-
| Socket | Модель | Ядра | Потоки | Частота | Max Turbo | TDP | L3 Cache | PPIN |
`;
- config.cpus.forEach(cpu => {
+ `;
+ pcieBalance.perCPU.forEach(cpu => {
+ html += `
+
${escapeHtml(cpu.label)}
+
+
${cpu.lanes}
+
`;
+ });
+ html += `
+ | Socket | Модель | Ядра | Потоки | Частота | Max Turbo | TDP | L3 Cache | PCIe линии (занято) | Модулей памяти | PPIN |
`;
+ cpus.forEach(cpu => {
+ const socket = cpu.slot || '-';
+ const cpuIdx = extractCPUIndex(socket);
+ const pcieUsed = cpuIdx !== null ? (pcieByCPU.get(cpuIdx) || 0) : '-';
+ const memoryModules = cpuIdx !== null ? (memoryByCPU.get(cpuIdx) || 0) : '-';
+ const tdp = (cpu.details && cpu.details.tdp_w) || '-';
+ const l3 = (cpu.details && cpu.details.l3_cache_kb) ? Math.round(cpu.details.l3_cache_kb / 1024) : '-';
+ const ppin = (cpu.details && cpu.details.ppin) || '-';
html += `
- | CPU${cpu.socket} |
- ${escapeHtml(cpu.model)} |
- ${cpu.cores} |
- ${cpu.threads} |
- ${cpu.frequency_mhz} MHz |
- ${cpu.max_frequency_mhz} MHz |
- ${cpu.tdp_w}W |
- ${Math.round(cpu.l3_cache_kb/1024)} MB |
- ${escapeHtml(cpu.ppin || '-')} |
+ ${escapeHtml(socket)} |
+ ${escapeHtml(cpu.model || '-')} |
+ ${cpu.cores || '-'} |
+ ${cpu.threads || '-'} |
+ ${cpu.frequency_mhz ? cpu.frequency_mhz + ' MHz' : '-'} |
+ ${cpu.max_frequency_mhz ? cpu.max_frequency_mhz + ' MHz' : '-'} |
+ ${tdp !== '-' ? tdp + 'W' : '-'} |
+ ${l3 !== '-' ? l3 + ' MB' : '-'} |
+ ${pcieUsed} |
+ ${memoryModules} |
+ ${escapeHtml(ppin)} |
`;
});
html += '
';
@@ -718,10 +760,10 @@ function renderConfig(data) {
// Memory tab
html += '';
- if (config.memory && config.memory.length > 0) {
- const totalGB = config.memory.reduce((sum, m) => sum + m.size_mb, 0) / 1024;
- const presentCount = config.memory.filter(m => m.present !== false).length;
- const workingCount = config.memory.filter(m => m.size_mb > 0).length;
+ if (memory.length > 0) {
+ const totalGB = memory.reduce((sum, m) => sum + (m.size_mb || 0), 0) / 1024;
+ const presentCount = memory.filter(m => m.present !== false).length;
+ const workingCount = memory.filter(m => (m.size_mb || 0) > 0).length;
html += `
Модули памяти
${totalGB} GBВсего
@@ -731,10 +773,10 @@ function renderConfig(data) {
| Location | Наличие | Размер | Тип | Max частота | Текущая частота | Производитель | Артикул | Статус |
`;
- config.memory.forEach(mem => {
+ memory.forEach(mem => {
const present = mem.present !== false ? '✓' : '-';
const presentClass = mem.present !== false ? 'present-yes' : 'present-no';
- const sizeGB = mem.size_mb / 1024;
+ const sizeGB = (mem.size_mb || 0) / 1024;
const statusClass = (mem.status === 'OK' || !mem.status) ? '' : 'status-warning';
const rowClass = sizeGB === 0 ? 'row-warning' : '';
html += `
@@ -742,8 +784,8 @@ function renderConfig(data) {
| ${present} |
${sizeGB} GB |
${escapeHtml(mem.type || '-')} |
- ${mem.max_speed_mhz || '-'} MHz |
- ${mem.current_speed_mhz || mem.speed_mhz || '-'} MHz |
+ ${(mem.details && mem.details.max_speed_mhz) || '-'} MHz |
+ ${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz |
${escapeHtml(mem.manufacturer || '-')} |
${escapeHtml(mem.part_number || '-')} |
${escapeHtml(mem.status || 'OK')} |
@@ -757,12 +799,12 @@ function renderConfig(data) {
// Power tab
html += '';
- if (config.power_supplies && config.power_supplies.length > 0) {
- const psuTotal = config.power_supplies.length;
- const psuPresent = config.power_supplies.filter(p => p.present !== false).length;
- const psuOK = config.power_supplies.filter(p => p.status === 'OK').length;
- const psuModel = config.power_supplies[0].model || '-';
- const psuWattage = config.power_supplies[0].wattage_w || 0;
+ if (powerSupplies.length > 0) {
+ const psuTotal = powerSupplies.length;
+ const psuPresent = powerSupplies.filter(p => p.present !== false).length;
+ const psuOK = powerSupplies.filter(p => p.status === 'OK').length;
+ const psuModel = powerSupplies[0].model || '-';
+ const psuWattage = powerSupplies[0].wattage_w || 0;
html += `
Блоки питания
${psuTotal}Всего
@@ -772,11 +814,11 @@ function renderConfig(data) {
${escapeHtml(psuModel)}Модель
| Слот | Производитель | Модель | Мощность | Вход | Выход | Напряжение | Температура | Статус |
`;
- config.power_supplies.forEach(psu => {
+ powerSupplies.forEach(psu => {
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
html += `
| ${escapeHtml(psu.slot)} |
- ${escapeHtml(psu.vendor || '-')} |
+ ${escapeHtml(psu.manufacturer || psu.vendor || '-')} |
${escapeHtml(psu.model || '-')} |
${psu.wattage_w || '-'}W |
${psu.input_power_w || '-'}W |
@@ -794,12 +836,12 @@ function renderConfig(data) {
// Storage tab
html += '';
- if (config.storage && config.storage.length > 0) {
- const storTotal = config.storage.length;
- const storHDD = config.storage.filter(s => s.type === 'HDD').length;
- const storSSD = config.storage.filter(s => s.type === 'SSD').length;
- const storNVMe = config.storage.filter(s => s.type === 'NVMe').length;
- const totalTB = (config.storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
+ if (storage.length > 0) {
+ const storTotal = storage.length;
+ const storHDD = storage.filter(s => s.type === 'HDD').length;
+ const storSSD = storage.filter(s => s.type === 'SSD').length;
+ const storNVMe = storage.filter(s => s.type === 'NVMe').length;
+ const totalTB = (storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
let typesSummary = [];
if (storHDD > 0) typesSummary.push(`${storHDD} HDD`);
if (storSSD > 0) typesSummary.push(`${storSSD} SSD`);
@@ -807,19 +849,19 @@ function renderConfig(data) {
html += `
Накопители
${storTotal}Всего слотов
-
${config.storage.filter(s => s.present).length}Установлено
+
${storage.filter(s => s.present).length}Установлено
${totalTB > 0 ? totalTB + ' TB' : '-'}Объём
${typesSummary.join(', ') || '-'}По типам
| NO. | Статус | Расположение | Backplane ID | Тип | Модель | Размер | Серийный номер |
`;
- config.storage.forEach(s => {
+ storage.forEach(s => {
const presentIcon = s.present ? '●' : '○';
const presentText = s.present ? 'Present' : 'Empty';
html += `
| ${escapeHtml(s.slot || '-')} |
${presentIcon} ${presentText} |
${escapeHtml(s.location || '-')} |
- ${s.backplane_id !== undefined ? s.backplane_id : '-'} |
+ ${s.details && s.details.backplane_id !== undefined ? s.details.backplane_id : '-'} |
${escapeHtml(s.type || '-')} |
${escapeHtml(s.model || '-')} |
${s.size_gb > 0 ? s.size_gb + ' GB' : '-'} |
@@ -834,25 +876,7 @@ function renderConfig(data) {
// GPU tab
html += '';
- const gpuRows = (config.gpus && config.gpus.length > 0)
- ? config.gpus
- : (config.pcie_devices || [])
- .filter((p) => {
- const cls = String(p.device_class || '').toLowerCase();
- const mfr = String(p.manufacturer || '').toLowerCase();
- return cls.includes('gpu') || cls.includes('display') || cls.includes('3d') || mfr.includes('nvidia') || p.vendor_id === 0x10de;
- })
- .map((p) => ({
- slot: p.slot,
- model: p.part_number || p.device_class,
- manufacturer: p.manufacturer,
- bdf: p.bdf,
- serial_number: p.serial_number,
- current_link_width: p.link_width,
- current_link_speed: p.link_speed,
- max_link_width: p.max_link_width,
- max_link_speed: p.max_link_speed
- }));
+ const gpuRows = gpus;
if (gpuRows.length > 0) {
const gpuCount = gpuRows.length;
const gpuModel = gpuRows[0].model || '-';
@@ -888,22 +912,7 @@ function renderConfig(data) {
// Network tab
html += '
';
- const networkRows = (config.network_adapters && config.network_adapters.length > 0)
- ? config.network_adapters
- : (config.pcie_devices || [])
- .filter((p) => {
- const cls = String(p.device_class || '').toLowerCase();
- return cls.includes('network') || cls.includes('ethernet') || cls.includes('gigabit');
- })
- .map((p) => ({
- location: p.slot,
- model: p.part_number || p.device_class,
- vendor: p.manufacturer,
- port_count: 0,
- port_type: '',
- mac_addresses: p.mac_addresses || [],
- status: p.status || ''
- }));
+ const networkRows = networkAdapters;
if (networkRows.length > 0) {
const nicCount = networkRows.length;
const totalPorts = networkRows.reduce((sum, n) => sum + (n.port_count || 0), 0);
@@ -923,7 +932,7 @@ function renderConfig(data) {
html += `
| ${escapeHtml(nic.location || nic.slot || '-')} |
${escapeHtml(nic.model || '-')} |
- ${escapeHtml(nic.vendor || '-')} |
+ ${escapeHtml(nic.manufacturer || nic.vendor || '-')} |
${nic.port_count || '-'} |
${escapeHtml(nic.port_type || '-')} |
${escapeHtml(macs)} |
@@ -938,65 +947,56 @@ function renderConfig(data) {
// PCIe Device Inventory tab
html += '';
- const hasPCIe = config.pcie_devices && config.pcie_devices.length > 0;
- const hasGPUs = config.gpus && config.gpus.length > 0;
- if (hasPCIe || hasGPUs) {
- html += '
PCIe устройства
| Слот | BDF | Модель | Производитель | Vendor:Device ID | PCIe Link | Серийный номер | Прошивка |
';
- const pcieRowKey = (slot, bdf, vendorId, deviceId) => {
- const normalizedBDF = (bdf || '').trim().toLowerCase();
- if (normalizedBDF) return `bdf:${normalizedBDF}`;
- const normalizedSlot = (slot || '').trim().toLowerCase();
- if (normalizedSlot) return `slot:${normalizedSlot}`;
- return `id:${vendorId || 0}:${deviceId || 0}`;
- };
- const gpuByKey = new Map();
- (config.gpus || []).forEach(gpu => {
- gpuByKey.set(pcieRowKey(gpu.slot, gpu.bdf, gpu.vendor_id, gpu.device_id), gpu);
+ if (inventoryRows.length > 0) {
+ html += 'PCIe устройства
';
+ const groups = new Map();
+ inventoryRows.forEach(p => {
+ const idx = extractCPUIndex(p.slot);
+ const key = idx === null ? 'other' : `cpu${idx}`;
+ if (!groups.has(key)) {
+ groups.set(key, {
+ idx,
+ title: idx === null ? 'Без привязки к CPU' : `CPU${idx}`,
+ lanes: 0,
+ rows: []
+ });
+ }
+ const lanes = Number(p.link_width) > 0 ? Number(p.link_width) : (Number(p.max_link_width) > 0 ? Number(p.max_link_width) : 0);
+ const group = groups.get(key);
+ group.lanes += lanes;
+ group.rows.push(p);
});
- (config.pcie_devices || []).forEach(p => {
- const key = pcieRowKey(p.slot, p.bdf, p.vendor_id, p.device_id);
- const matchedGPU = gpuByKey.get(key);
-
- const pcieLink = formatPCIeLink(
- p.link_width,
- p.link_speed,
- p.max_link_width,
- p.max_link_speed
- );
- const serial = p.serial_number || (matchedGPU ? matchedGPU.serial_number : '');
- const firmware = p.firmware || (matchedGPU ? matchedGPU.firmware : '') || findPCIeFirmwareVersion(config.firmware, p);
- html += `
- | ${escapeHtml(p.slot || '-')} |
- ${escapeHtml(p.bdf || '-')} |
- ${escapeHtml(p.part_number || '-')} |
- ${escapeHtml(p.manufacturer || '-')} |
- ${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'} |
- ${pcieLink} |
- ${escapeHtml(serial || '-')} |
- ${escapeHtml(firmware || '-')} |
-
`;
+ const sortedGroups = [...groups.values()].sort((a, b) => {
+ if (a.idx === null) return 1;
+ if (b.idx === null) return -1;
+ return a.idx - b.idx;
});
- (config.gpus || []).forEach(gpu => {
- const pcieLink = formatPCIeLink(
- gpu.current_link_width || gpu.link_width,
- gpu.current_link_speed || gpu.link_speed,
- gpu.max_link_width,
- gpu.max_link_speed
- );
- html += `
- | ${escapeHtml(gpu.slot || '-')} |
- ${escapeHtml(gpu.bdf || '-')} |
- ${escapeHtml(gpu.model || gpu.part_number || '-')} |
- ${escapeHtml(gpu.manufacturer || '-')} |
- ${gpu.vendor_id ? gpu.vendor_id.toString(16) : '-'}:${gpu.device_id ? gpu.device_id.toString(16) : '-'} |
- ${pcieLink} |
- ${escapeHtml(gpu.serial_number || '-')} |
- ${escapeHtml(gpu.firmware || '-')} |
-
`;
+ sortedGroups.forEach(group => {
+ html += `${escapeHtml(group.title)} · занято линий: ${group.lanes}
`;
+ html += '| Слот | BDF | Модель | Производитель | Vendor:Device ID | PCIe Link | Серийный номер | Прошивка |
';
+ group.rows.forEach(p => {
+ const pcieLink = formatPCIeLink(
+ p.link_width,
+ p.link_speed,
+ p.max_link_width,
+ p.max_link_speed
+ );
+ const firmware = p.firmware || findPCIeFirmwareVersion(config.firmware, p);
+ html += `
+ | ${escapeHtml(p.slot || '-')} |
+ ${escapeHtml(p.bdf || '-')} |
+ ${escapeHtml(p.model || p.part_number || p.device_class || '-')} |
+ ${escapeHtml(p.manufacturer || '-')} |
+ ${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'} |
+ ${pcieLink} |
+ ${escapeHtml(p.serial_number || '-')} |
+ ${escapeHtml(firmware || '-')} |
+
`;
+ });
+ html += '
';
});
- html += '
';
} else {
html += '
Нет данных о PCIe устройствах
';
}
@@ -1283,6 +1283,83 @@ function escapeHtml(text) {
return div.innerHTML;
}
+function calculateCPUToPCIeBalance(inventoryRows, cpus) {
+ const laneByCPU = new Map();
+ const cpuIndexes = new Set();
+
+ (cpus || []).forEach(cpu => {
+ const idx = extractCPUIndex(cpu.slot);
+ if (idx !== null) {
+ cpuIndexes.add(idx);
+ laneByCPU.set(idx, 0);
+ }
+ });
+
+ (inventoryRows || []).forEach(dev => {
+ const idx = extractCPUIndex(dev.slot);
+ if (idx === null) return;
+
+ const lanes = Number(dev.link_width) > 0
+ ? Number(dev.link_width)
+ : (Number(dev.max_link_width) > 0
+ ? Number(dev.max_link_width)
+ : (dev.bdf ? 1 : 0));
+ if (lanes <= 0) return;
+
+ if (!laneByCPU.has(idx)) laneByCPU.set(idx, 0);
+ laneByCPU.set(idx, laneByCPU.get(idx) + lanes);
+ cpuIndexes.add(idx);
+ });
+
+ const indexes = [...cpuIndexes].sort((a, b) => a - b);
+ const values = indexes.map(i => laneByCPU.get(i) || 0);
+ const totalLanes = values.reduce((a, b) => a + b, 0);
+ const maxLanes = values.length ? Math.max(...values) : 0;
+ const minLanes = values.length ? Math.min(...values) : 0;
+ const diffRatio = totalLanes > 0 ? (maxLanes - minLanes) / totalLanes : 0;
+ let severity = 'ok';
+ if (values.length > 1) {
+ if (diffRatio >= 0.35) severity = 'critical';
+ else if (diffRatio >= 0.2) severity = 'warning';
+ }
+
+ const denominator = maxLanes > 0 ? maxLanes : 1;
+ const perCPU = indexes.map(i => {
+ const lanes = laneByCPU.get(i) || 0;
+ return {
+ label: `CPU${i}`,
+ lanes,
+ percent: Math.round((lanes / denominator) * 100)
+ };
+ });
+
+ if (perCPU.length === 0) {
+ perCPU.push({ label: 'CPU?', lanes: 0, percent: 0 });
+ }
+
+ return { totalLanes, severity, perCPU };
+}
+
+function extractCPUIndex(slot) {
+ const s = String(slot || '').trim();
+ if (!s) return null;
+ const m = s.match(/cpu\s*([0-9]+)/i);
+ if (!m) return null;
+ const idx = Number(m[1]);
+ return Number.isFinite(idx) ? idx : null;
+}
+
+function calculateMemoryModulesByCPU(memoryRows) {
+ const out = new Map();
+ (memoryRows || []).forEach(mem => {
+ if (mem.present === false || (mem.size_mb || 0) <= 0) return;
+ const idx = extractCPUIndex(mem.location || mem.slot);
+ if (idx === null) return;
+ out.set(idx, (out.get(idx) || 0) + 1);
+ });
+ return out;
+}
+
function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) {
if (!Array.isArray(firmwareEntries) || !pcieDevice) return '';
@@ -1290,17 +1367,30 @@ function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) {
const model = (pcieDevice.part_number || '').trim().toLowerCase();
if (!slot && !model) return '';
+ const slotPatterns = slot
+ ? [
+ new RegExp(`^psu\\s*${escapeRegExp(slot)}\\b`, 'i'),
+ new RegExp(`^nic\\s+${escapeRegExp(slot)}\\b`, 'i'),
+ new RegExp(`^gpu\\s+${escapeRegExp(slot)}\\b`, 'i'),
+ new RegExp(`^nvswitch\\s+${escapeRegExp(slot)}\\b`, 'i')
+ ]
+ : [];
+
for (const fw of firmwareEntries) {
const name = (fw.device_name || '').trim().toLowerCase();
const version = (fw.version || '').trim();
if (!name || !version) continue;
- if (slot && name.includes(slot)) return version;
+ if (slot && slotPatterns.some(re => re.test(name))) return version;
if (model && name.includes(model)) return version;
}
return '';
}
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
function formatPCIeLink(currentWidth, currentSpeed, maxWidth, maxSpeed) {
// Helper to convert speed to generation
function speedToGen(speed) {