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)}Модель
- `; - config.cpus.forEach(cpu => { +
`; + pcieBalance.perCPU.forEach(cpu => { + html += `
+ ${escapeHtml(cpu.label)} +
+ ${cpu.lanes} +
`; + }); + html += `
+
SocketМодельЯдраПотокиЧастотаMax TurboTDPL3 CachePPIN
`; + 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 += ` - - - - - - - - - + + + + + + + + + + + `; }); html += '
SocketМодельЯдраПотокиЧастотаMax TurboTDPL3 CachePCIe линии (занято)Модулей памятиPPIN
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)}
'; @@ -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) { `; - 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) { - - + + @@ -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)}Модель
LocationНаличиеРазмерТипMax частотаТекущая частотаПроизводительАртикулСтатус
${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')}
`; - config.power_supplies.forEach(psu => { + powerSupplies.forEach(psu => { const statusClass = psu.status === 'OK' ? '' : 'status-warning'; html += ` - + @@ -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(', ') || '-'}По типам
СлотПроизводительМодельМощностьВходВыходНапряжениеТемператураСтатус
${escapeHtml(psu.slot)}${escapeHtml(psu.vendor || '-')}${escapeHtml(psu.manufacturer || psu.vendor || '-')} ${escapeHtml(psu.model || '-')} ${psu.wattage_w || '-'}W ${psu.input_power_w || '-'}W
`; - config.storage.forEach(s => { + storage.forEach(s => { const presentIcon = s.present ? '' : ''; const presentText = s.present ? 'Present' : 'Empty'; html += ` - + @@ -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 += `
- + @@ -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 устройства

NO.СтатусРасположениеBackplane IDТипМодельРазмерСерийный номер
${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' : '-'}
${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)}
'; - 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 += ` - - - - - - - - - `; + 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 += ` - - - - - - - - - `; + sortedGroups.forEach(group => { + html += `

${escapeHtml(group.title)} · занято линий: ${group.lanes}

`; + html += '
СлотBDFМодельПроизводительVendor:Device IDPCIe LinkСерийный номерПрошивка
${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 || '-')}
${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 || '-')}
'; + 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 += ` + + + + + + + + + `; + }); + html += '
СлотBDFМодельПроизводительVendor:Device IDPCIe LinkСерийный номерПрошивка
${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 += ''; } 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) {