Introduce canonical hardware.devices repository and align UI/Reanimator exports
This commit is contained in:
22
CLAUDE.md
22
CLAUDE.md
@@ -81,7 +81,7 @@ Filename pattern for all exports:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
|
- 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.
|
- Exports hardware data in Reanimator format for integration with asset tracking systems.
|
||||||
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
|
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
|
||||||
- Requires `hardware.board.serial_number` to be present.
|
- Requires `hardware.board.serial_number` to be present.
|
||||||
@@ -93,6 +93,26 @@ Notes:
|
|||||||
- Includes GPUs and NetworkAdapters as PCIe devices.
|
- Includes GPUs and NetworkAdapters as PCIe devices.
|
||||||
- Filters out storage devices and PSUs without serial numbers.
|
- 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`)
|
## CLI flags (`cmd/logpile/main.go`)
|
||||||
|
|
||||||
- `--port`
|
- `--port`
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -157,6 +157,7 @@ POST /api/collect
|
|||||||
|
|
||||||
- `GET /api/export/csv` — серийные номера
|
- `GET /api/export/csv` — серийные номера
|
||||||
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
|
- `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`
|
`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
|
## API
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -181,10 +198,15 @@ GET /api/serials
|
|||||||
GET /api/firmware
|
GET /api/firmware
|
||||||
GET /api/export/csv
|
GET /api/export/csv
|
||||||
GET /api/export/json
|
GET /api/export/json
|
||||||
|
GET /api/export/reanimator
|
||||||
DELETE /api/clear
|
DELETE /api/clear
|
||||||
POST /api/shutdown
|
POST /api/shutdown
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Примечания:
|
||||||
|
- `GET /api/config` возвращает canonical inventory в `hardware.devices`.
|
||||||
|
- `GET /api/serials` и `GET /api/firmware` строятся из того же canonical inventory.
|
||||||
|
|
||||||
`/api/status` и `/api/config` содержат метаданные источника:
|
`/api/status` и `/api/config` содержат метаданные источника:
|
||||||
- `source_type`: `archive` | `api`
|
- `source_type`: `archive` | `api`
|
||||||
- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым)
|
- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
|||||||
targetHost := inferTargetHost(result.TargetHost, result.Filename)
|
targetHost := inferTargetHost(result.TargetHost, result.Filename)
|
||||||
|
|
||||||
collectedAt := formatRFC3339(result.CollectedAt)
|
collectedAt := formatRFC3339(result.CollectedAt)
|
||||||
|
devices := canonicalDevicesForExport(result.Hardware)
|
||||||
|
|
||||||
export := &ReanimatorExport{
|
export := &ReanimatorExport{
|
||||||
Filename: result.Filename,
|
Filename: result.Filename,
|
||||||
@@ -41,11 +43,11 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
|||||||
Hardware: ReanimatorHardware{
|
Hardware: ReanimatorHardware{
|
||||||
Board: convertBoard(result.Hardware.BoardInfo),
|
Board: convertBoard(result.Hardware.BoardInfo),
|
||||||
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
|
||||||
CPUs: dedupeCPUs(convertCPUs(result.Hardware.CPUs, collectedAt)),
|
CPUs: convertCPUsFromDevices(devices, collectedAt),
|
||||||
Memory: dedupeMemory(convertMemory(result.Hardware.Memory, collectedAt)),
|
Memory: convertMemoryFromDevices(devices, collectedAt),
|
||||||
Storage: dedupeStorage(convertStorage(result.Hardware.Storage, collectedAt)),
|
Storage: convertStorageFromDevices(devices, collectedAt),
|
||||||
PCIeDevices: dedupePCIe(convertPCIeDevices(result.Hardware, collectedAt)),
|
PCIeDevices: convertPCIeFromDevices(devices, collectedAt),
|
||||||
PowerSupplies: dedupePSUs(convertPowerSupplies(result.Hardware.PowerSupply, 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
|
// convertFirmware converts firmware information to Reanimator format
|
||||||
func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
|
func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
|
||||||
if len(firmware) == 0 {
|
if len(firmware) == 0 {
|
||||||
@@ -93,6 +358,194 @@ func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
|
|||||||
return result
|
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 {
|
func isDeviceBoundFirmwareName(name string) bool {
|
||||||
n := strings.TrimSpace(strings.ToLower(name))
|
n := strings.TrimSpace(strings.ToLower(name))
|
||||||
if n == "" {
|
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
|
// inferStorageStatus determines storage device status
|
||||||
func inferStorageStatus(stor models.Storage) string {
|
func inferStorageStatus(stor models.Storage) string {
|
||||||
if !stor.Present {
|
if !stor.Present {
|
||||||
|
|||||||
@@ -599,8 +599,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
|
|||||||
{Socket: 0, Model: "CPU-A-DUP"},
|
{Socket: 0, Model: "CPU-A-DUP"},
|
||||||
},
|
},
|
||||||
Memory: []models.MemoryDIMM{
|
Memory: []models.MemoryDIMM{
|
||||||
{Slot: "DIMM_A1", Present: true, SerialNumber: "MEM-1", Status: "OK"},
|
{Slot: "DIMM_A1", Present: true, SizeMB: 32768, 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-DUP", Status: "OK"},
|
||||||
},
|
},
|
||||||
Storage: []models.Storage{
|
Storage: []models.Storage{
|
||||||
{Slot: "U.2-1", SerialNumber: "SSD-1", Model: "Disk1", Present: true},
|
{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 {
|
if len(out.Hardware.Firmware) != 1 {
|
||||||
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
|
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.CPUs) != 1 {
|
if len(out.Hardware.CPUs) != 2 {
|
||||||
t.Fatalf("expected deduped cpus len=1, got %d", len(out.Hardware.CPUs))
|
t.Fatalf("expected cpus len=2 (no serial/bdf dedupe), got %d", len(out.Hardware.CPUs))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.Memory) != 1 {
|
if len(out.Hardware.Memory) != 2 {
|
||||||
t.Fatalf("expected deduped memory len=1, got %d", len(out.Hardware.Memory))
|
t.Fatalf("expected memory len=2 (different serials), got %d", len(out.Hardware.Memory))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.Storage) != 1 {
|
if len(out.Hardware.Storage) != 1 {
|
||||||
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
|
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 {
|
if len(out.Hardware.PowerSupplies) != 1 {
|
||||||
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
|
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
|
||||||
}
|
}
|
||||||
if len(out.Hardware.PCIeDevices) != 2 {
|
if len(out.Hardware.PCIeDevices) != 4 {
|
||||||
t.Fatalf("expected deduped pcie len=2 (gpu+nic), got %d", len(out.Hardware.PCIeDevices))
|
t.Fatalf("expected pcie len=4 with serial->bdf dedupe, got %d", len(out.Hardware.PCIeDevices))
|
||||||
}
|
}
|
||||||
|
|
||||||
gpuCount := 0
|
gpuCount := 0
|
||||||
@@ -651,8 +651,8 @@ func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
|
|||||||
gpuCount++
|
gpuCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if gpuCount != 1 {
|
if gpuCount != 2 {
|
||||||
t.Fatalf("expected single #GPU0 record, got %d", gpuCount)
|
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")
|
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 }
|
||||||
|
|||||||
@@ -201,13 +201,9 @@ func TestFullReanimatorExport(t *testing.T) {
|
|||||||
t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status)
|
t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory (should include empty slots)
|
// Memory (empty slots are excluded)
|
||||||
if len(hw.Memory) != 2 {
|
if len(hw.Memory) != 1 {
|
||||||
t.Errorf("Expected 2 memory entries (including empty), got %d", len(hw.Memory))
|
t.Errorf("Expected 1 memory entry (installed only), got %d", len(hw.Memory))
|
||||||
}
|
|
||||||
|
|
||||||
if hw.Memory[1].Status != "Empty" {
|
|
||||||
t.Errorf("Empty memory slot status mismatch: got %q", hw.Memory[1].Status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type FRUInfo struct {
|
|||||||
type HardwareConfig struct {
|
type HardwareConfig struct {
|
||||||
Firmware []FirmwareInfo `json:"firmware,omitempty"`
|
Firmware []FirmwareInfo `json:"firmware,omitempty"`
|
||||||
BoardInfo BoardInfo `json:"board,omitempty"`
|
BoardInfo BoardInfo `json:"board,omitempty"`
|
||||||
|
Devices []HardwareDevice `json:"devices,omitempty"`
|
||||||
CPUs []CPU `json:"cpus,omitempty"`
|
CPUs []CPU `json:"cpus,omitempty"`
|
||||||
Memory []MemoryDIMM `json:"memory,omitempty"`
|
Memory []MemoryDIMM `json:"memory,omitempty"`
|
||||||
Storage []Storage `json:"storage,omitempty"`
|
Storage []Storage `json:"storage,omitempty"`
|
||||||
@@ -94,6 +95,66 @@ type HardwareConfig struct {
|
|||||||
PowerSupply []PSU `json:"power_supplies,omitempty"`
|
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
|
// FirmwareInfo represents firmware version information
|
||||||
type FirmwareInfo struct {
|
type FirmwareInfo struct {
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
|
|||||||
734
internal/server/device_repository.go
Normal file
734
internal/server/device_repository.go
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type slotFirmwareInfo struct {
|
||||||
|
Model string
|
||||||
|
Version string
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
psuFirmwareRe = regexp.MustCompile(`(?i)^PSU\s*([0-9A-Za-z_-]+)\s*(?:\(([^)]+)\))?$`)
|
||||||
|
nicFirmwareRe = regexp.MustCompile(`(?i)^NIC\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
|
||||||
|
gpuFirmwareRe = regexp.MustCompile(`(?i)^GPU\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
|
||||||
|
nvsFirmwareRe = regexp.MustCompile(`(?i)^NVSwitch\s+([^()]+?)\s*(?:\(([^)]+)\))?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||||
|
if hw == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
all := make([]models.HardwareDevice, 0, 1+len(hw.CPUs)+len(hw.Memory)+len(hw.Storage)+len(hw.PCIeDevices)+len(hw.GPUs)+len(hw.NetworkAdapters)+len(hw.PowerSupply))
|
||||||
|
fwBySlot := buildFirmwareBySlot(hw.Firmware)
|
||||||
|
nextID := 0
|
||||||
|
add := func(d models.HardwareDevice) {
|
||||||
|
d.ID = fmt.Sprintf("%s:%d", d.Kind, nextID)
|
||||||
|
nextID++
|
||||||
|
all = append(all, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindBoard,
|
||||||
|
Source: "board",
|
||||||
|
Slot: "board",
|
||||||
|
Model: strings.TrimSpace(hw.BoardInfo.ProductName),
|
||||||
|
PartNumber: strings.TrimSpace(hw.BoardInfo.PartNumber),
|
||||||
|
Manufacturer: strings.TrimSpace(hw.BoardInfo.Manufacturer),
|
||||||
|
SerialNumber: strings.TrimSpace(hw.BoardInfo.SerialNumber),
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": strings.TrimSpace(hw.BoardInfo.Description),
|
||||||
|
"version": strings.TrimSpace(hw.BoardInfo.Version),
|
||||||
|
"uuid": strings.TrimSpace(hw.BoardInfo.UUID),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, cpu := range hw.CPUs {
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindCPU,
|
||||||
|
Source: "cpus",
|
||||||
|
Slot: fmt.Sprintf("CPU%d", cpu.Socket),
|
||||||
|
Model: cpu.Model,
|
||||||
|
SerialNumber: cpu.SerialNumber,
|
||||||
|
Cores: cpu.Cores,
|
||||||
|
Threads: cpu.Threads,
|
||||||
|
FrequencyMHz: cpu.FrequencyMHz,
|
||||||
|
MaxFreqMHz: cpu.MaxFreqMHz,
|
||||||
|
Status: cpu.Status,
|
||||||
|
StatusCheckedAt: cpu.StatusCheckedAt,
|
||||||
|
StatusChangedAt: cpu.StatusChangedAt,
|
||||||
|
StatusAtCollect: cpu.StatusAtCollect,
|
||||||
|
StatusHistory: cpu.StatusHistory,
|
||||||
|
ErrorDescription: cpu.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": cpu.Description,
|
||||||
|
"socket": cpu.Socket,
|
||||||
|
"l1_cache_kb": cpu.L1CacheKB,
|
||||||
|
"l2_cache_kb": cpu.L2CacheKB,
|
||||||
|
"l3_cache_kb": cpu.L3CacheKB,
|
||||||
|
"tdp_w": cpu.TDP,
|
||||||
|
"ppin": cpu.PPIN,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mem := range hw.Memory {
|
||||||
|
if !mem.Present || mem.SizeMB == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
present := mem.Present
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindMemory,
|
||||||
|
Source: "memory",
|
||||||
|
Slot: mem.Slot,
|
||||||
|
Location: mem.Location,
|
||||||
|
Manufacturer: mem.Manufacturer,
|
||||||
|
SerialNumber: mem.SerialNumber,
|
||||||
|
PartNumber: mem.PartNumber,
|
||||||
|
Type: mem.Type,
|
||||||
|
Present: &present,
|
||||||
|
SizeMB: mem.SizeMB,
|
||||||
|
Status: mem.Status,
|
||||||
|
StatusCheckedAt: mem.StatusCheckedAt,
|
||||||
|
StatusChangedAt: mem.StatusChangedAt,
|
||||||
|
StatusAtCollect: mem.StatusAtCollect,
|
||||||
|
StatusHistory: mem.StatusHistory,
|
||||||
|
ErrorDescription: mem.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": mem.Description,
|
||||||
|
"technology": mem.Technology,
|
||||||
|
"max_speed_mhz": mem.MaxSpeedMHz,
|
||||||
|
"current_speed_mhz": mem.CurrentSpeedMHz,
|
||||||
|
"ranks": mem.Ranks,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stor := range hw.Storage {
|
||||||
|
if !stor.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
present := stor.Present
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindStorage,
|
||||||
|
Source: "storage",
|
||||||
|
Slot: stor.Slot,
|
||||||
|
Location: stor.Location,
|
||||||
|
Model: stor.Model,
|
||||||
|
Manufacturer: stor.Manufacturer,
|
||||||
|
SerialNumber: stor.SerialNumber,
|
||||||
|
Firmware: stor.Firmware,
|
||||||
|
Type: stor.Type,
|
||||||
|
Interface: stor.Interface,
|
||||||
|
Present: &present,
|
||||||
|
SizeGB: stor.SizeGB,
|
||||||
|
Status: stor.Status,
|
||||||
|
StatusCheckedAt: stor.StatusCheckedAt,
|
||||||
|
StatusChangedAt: stor.StatusChangedAt,
|
||||||
|
StatusAtCollect: stor.StatusAtCollect,
|
||||||
|
StatusHistory: stor.StatusHistory,
|
||||||
|
ErrorDescription: stor.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": stor.Description,
|
||||||
|
"backplane_id": stor.BackplaneID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range hw.PCIeDevices {
|
||||||
|
if isEmptyPCIeDevice(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slotKey := normalizeSlotKey(p.Slot)
|
||||||
|
fwInfo := fwBySlot[slotKey]
|
||||||
|
model := strings.TrimSpace(p.PartNumber)
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(p.DeviceClass)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(p.Description)
|
||||||
|
}
|
||||||
|
if model == "" && fwInfo.Model != "" {
|
||||||
|
model = fwInfo.Model
|
||||||
|
}
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindPCIe,
|
||||||
|
Source: "pcie_devices",
|
||||||
|
Slot: p.Slot,
|
||||||
|
BDF: p.BDF,
|
||||||
|
DeviceClass: p.DeviceClass,
|
||||||
|
VendorID: p.VendorID,
|
||||||
|
DeviceID: p.DeviceID,
|
||||||
|
Model: model,
|
||||||
|
PartNumber: p.PartNumber,
|
||||||
|
Manufacturer: p.Manufacturer,
|
||||||
|
SerialNumber: p.SerialNumber,
|
||||||
|
Firmware: fwInfo.Version,
|
||||||
|
MACAddresses: p.MACAddresses,
|
||||||
|
LinkWidth: p.LinkWidth,
|
||||||
|
LinkSpeed: p.LinkSpeed,
|
||||||
|
MaxLinkWidth: p.MaxLinkWidth,
|
||||||
|
MaxLinkSpeed: p.MaxLinkSpeed,
|
||||||
|
Status: p.Status,
|
||||||
|
StatusCheckedAt: p.StatusCheckedAt,
|
||||||
|
StatusChangedAt: p.StatusChangedAt,
|
||||||
|
StatusAtCollect: p.StatusAtCollect,
|
||||||
|
StatusHistory: p.StatusHistory,
|
||||||
|
ErrorDescription: p.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": p.Description,
|
||||||
|
"fw_category": fwInfo.Category,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, gpu := range hw.GPUs {
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindGPU,
|
||||||
|
Source: "gpus",
|
||||||
|
Slot: gpu.Slot,
|
||||||
|
Location: gpu.Location,
|
||||||
|
BDF: gpu.BDF,
|
||||||
|
DeviceClass: "DisplayController",
|
||||||
|
VendorID: gpu.VendorID,
|
||||||
|
DeviceID: gpu.DeviceID,
|
||||||
|
Model: gpu.Model,
|
||||||
|
PartNumber: gpu.PartNumber,
|
||||||
|
Manufacturer: gpu.Manufacturer,
|
||||||
|
SerialNumber: gpu.SerialNumber,
|
||||||
|
Firmware: gpu.Firmware,
|
||||||
|
LinkWidth: gpu.CurrentLinkWidth,
|
||||||
|
LinkSpeed: gpu.CurrentLinkSpeed,
|
||||||
|
MaxLinkWidth: gpu.MaxLinkWidth,
|
||||||
|
MaxLinkSpeed: gpu.MaxLinkSpeed,
|
||||||
|
Status: gpu.Status,
|
||||||
|
StatusCheckedAt: gpu.StatusCheckedAt,
|
||||||
|
StatusChangedAt: gpu.StatusChangedAt,
|
||||||
|
StatusAtCollect: gpu.StatusAtCollect,
|
||||||
|
StatusHistory: gpu.StatusHistory,
|
||||||
|
ErrorDescription: gpu.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": gpu.Description,
|
||||||
|
"uuid": gpu.UUID,
|
||||||
|
"video_bios": gpu.VideoBIOS,
|
||||||
|
"irq": gpu.IRQ,
|
||||||
|
"bus_type": gpu.BusType,
|
||||||
|
"dma_size": gpu.DMASize,
|
||||||
|
"dma_mask": gpu.DMAMask,
|
||||||
|
"device_minor": gpu.DeviceMinor,
|
||||||
|
"temperature": gpu.Temperature,
|
||||||
|
"mem_temperature": gpu.MemTemperature,
|
||||||
|
"power": gpu.Power,
|
||||||
|
"max_power": gpu.MaxPower,
|
||||||
|
"clock_speed": gpu.ClockSpeed,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nic := range hw.NetworkAdapters {
|
||||||
|
if !nic.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
present := nic.Present
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindNetwork,
|
||||||
|
Source: "network_adapters",
|
||||||
|
Slot: nic.Slot,
|
||||||
|
Location: nic.Location,
|
||||||
|
VendorID: nic.VendorID,
|
||||||
|
DeviceID: nic.DeviceID,
|
||||||
|
Model: nic.Model,
|
||||||
|
PartNumber: nic.PartNumber,
|
||||||
|
Manufacturer: nic.Vendor,
|
||||||
|
SerialNumber: nic.SerialNumber,
|
||||||
|
Firmware: nic.Firmware,
|
||||||
|
PortCount: nic.PortCount,
|
||||||
|
PortType: nic.PortType,
|
||||||
|
MACAddresses: nic.MACAddresses,
|
||||||
|
Present: &present,
|
||||||
|
Status: nic.Status,
|
||||||
|
StatusCheckedAt: nic.StatusCheckedAt,
|
||||||
|
StatusChangedAt: nic.StatusChangedAt,
|
||||||
|
StatusAtCollect: nic.StatusAtCollect,
|
||||||
|
StatusHistory: nic.StatusHistory,
|
||||||
|
ErrorDescription: nic.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": nic.Description,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, psu := range hw.PowerSupply {
|
||||||
|
if !psu.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
present := psu.Present
|
||||||
|
add(models.HardwareDevice{
|
||||||
|
Kind: models.DeviceKindPSU,
|
||||||
|
Source: "power_supplies",
|
||||||
|
Slot: psu.Slot,
|
||||||
|
Model: psu.Model,
|
||||||
|
PartNumber: psu.PartNumber,
|
||||||
|
Manufacturer: psu.Vendor,
|
||||||
|
SerialNumber: psu.SerialNumber,
|
||||||
|
Firmware: psu.Firmware,
|
||||||
|
Present: &present,
|
||||||
|
WattageW: psu.WattageW,
|
||||||
|
InputType: psu.InputType,
|
||||||
|
InputPowerW: psu.InputPowerW,
|
||||||
|
OutputPowerW: psu.OutputPowerW,
|
||||||
|
InputVoltage: psu.InputVoltage,
|
||||||
|
TemperatureC: psu.TemperatureC,
|
||||||
|
Status: psu.Status,
|
||||||
|
StatusCheckedAt: psu.StatusCheckedAt,
|
||||||
|
StatusChangedAt: psu.StatusChangedAt,
|
||||||
|
StatusAtCollect: psu.StatusAtCollect,
|
||||||
|
StatusHistory: psu.StatusHistory,
|
||||||
|
ErrorDescription: psu.ErrorDescription,
|
||||||
|
Details: map[string]any{
|
||||||
|
"description": psu.Description,
|
||||||
|
"output_voltage": psu.OutputVoltage,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeDevices(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyPCIeDevice(p models.PCIeDevice) bool {
|
||||||
|
if isNumericSlot(strings.TrimSpace(p.Slot)) &&
|
||||||
|
strings.TrimSpace(p.BDF) == "" &&
|
||||||
|
p.VendorID == 0 &&
|
||||||
|
p.DeviceID == 0 &&
|
||||||
|
normalizedSerial(p.SerialNumber) == "" &&
|
||||||
|
!hasMeaningfulText(p.PartNumber) &&
|
||||||
|
!hasMeaningfulText(p.Manufacturer) &&
|
||||||
|
!hasMeaningfulText(p.Description) &&
|
||||||
|
len(p.MACAddresses) == 0 &&
|
||||||
|
p.LinkWidth == 0 &&
|
||||||
|
p.MaxLinkWidth == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(p.BDF) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.VendorID != 0 || p.DeviceID != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if normalizedSerial(p.SerialNumber) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasMeaningfulText(p.PartNumber) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasMeaningfulText(p.Manufacturer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if hasMeaningfulText(p.Description) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.DeviceClass) != "" {
|
||||||
|
class := strings.ToLower(strings.TrimSpace(p.DeviceClass))
|
||||||
|
if class != "unknown" && class != "other" && class != "pcie device" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNumericSlot(slot string) bool {
|
||||||
|
if slot == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range slot {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMeaningfulText(v string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch s {
|
||||||
|
case "-", "n/a", "na", "none", "null", "unknown":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dedupeDevices(items []models.HardwareDevice) []models.HardwareDevice {
|
||||||
|
if len(items) < 2 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
parent := make([]int, len(items))
|
||||||
|
for i := range parent {
|
||||||
|
parent[i] = i
|
||||||
|
}
|
||||||
|
find := func(x int) int {
|
||||||
|
for parent[x] != x {
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
union := func(a, b int) {
|
||||||
|
ra := find(a)
|
||||||
|
rb := find(b)
|
||||||
|
if ra != rb {
|
||||||
|
parent[rb] = ra
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(items); i++ {
|
||||||
|
for j := i + 1; j < len(items); j++ {
|
||||||
|
if shouldMergeDevices(items[i], items[j]) {
|
||||||
|
union(i, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := make(map[int][]int, len(items))
|
||||||
|
order := make([]int, 0, len(items))
|
||||||
|
for i := range items {
|
||||||
|
root := find(i)
|
||||||
|
if _, ok := groups[root]; !ok {
|
||||||
|
order = append(order, root)
|
||||||
|
}
|
||||||
|
groups[root] = append(groups[root], i)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]models.HardwareDevice, 0, len(order))
|
||||||
|
for _, root := range order {
|
||||||
|
indices := groups[root]
|
||||||
|
bestIdx := indices[0]
|
||||||
|
bestScore := qualityScore(items[bestIdx])
|
||||||
|
for _, idx := range indices[1:] {
|
||||||
|
if s := qualityScore(items[idx]); s > bestScore {
|
||||||
|
bestIdx = idx
|
||||||
|
bestScore = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged := items[bestIdx]
|
||||||
|
for _, idx := range indices {
|
||||||
|
if idx == bestIdx {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = mergeDevices(merged, items[idx])
|
||||||
|
}
|
||||||
|
out = append(out, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range out {
|
||||||
|
out[i].ID = out[i].Kind + ":" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldMergeDevices(a, b models.HardwareDevice) bool {
|
||||||
|
aSN := strings.ToLower(normalizedSerial(a.SerialNumber))
|
||||||
|
bSN := strings.ToLower(normalizedSerial(b.SerialNumber))
|
||||||
|
aBDF := strings.ToLower(strings.TrimSpace(a.BDF))
|
||||||
|
bBDF := strings.ToLower(strings.TrimSpace(b.BDF))
|
||||||
|
|
||||||
|
// Hard conflicts.
|
||||||
|
if aSN != "" && bSN != "" && aSN == bSN {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if aSN != "" && bSN != "" && aSN != bSN {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if aBDF != "" && bBDF != "" && aBDF != bBDF {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strong identities.
|
||||||
|
if aBDF != "" && aBDF == bBDF {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If both have no strong IDs, be conservative.
|
||||||
|
if aSN == "" && bSN == "" && aBDF == "" && bBDF == "" {
|
||||||
|
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
if samePCIID(a, b) {
|
||||||
|
score += 4
|
||||||
|
}
|
||||||
|
if sameModel(a, b) {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
if sameManufacturer(a, b) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if normalizeSlot(a.Slot) != "" && normalizeSlot(a.Slot) == normalizeSlot(b.Slot) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if hasMACOverlap(a.MACAddresses, b.MACAddresses) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if sameKindFamily(a.Kind, b.Kind) {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
if samePCIID(a, b) && ((aBDF != "" && bBDF == "") || (aBDF == "" && bBDF != "")) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return score >= 7
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeDevices(primary, secondary models.HardwareDevice) models.HardwareDevice {
|
||||||
|
fillString := func(dst *string, src string) {
|
||||||
|
if strings.TrimSpace(*dst) == "" && strings.TrimSpace(src) != "" {
|
||||||
|
*dst = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fillInt := func(dst *int, src int) {
|
||||||
|
if *dst == 0 && src != 0 {
|
||||||
|
*dst = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fillFloat := func(dst *float64, src float64) {
|
||||||
|
if *dst == 0 && src != 0 {
|
||||||
|
*dst = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fillString(&primary.ID, secondary.ID)
|
||||||
|
fillString(&primary.Kind, secondary.Kind)
|
||||||
|
fillString(&primary.Source, secondary.Source)
|
||||||
|
fillString(&primary.Slot, secondary.Slot)
|
||||||
|
fillString(&primary.Location, secondary.Location)
|
||||||
|
fillString(&primary.BDF, secondary.BDF)
|
||||||
|
fillString(&primary.DeviceClass, secondary.DeviceClass)
|
||||||
|
fillInt(&primary.VendorID, secondary.VendorID)
|
||||||
|
fillInt(&primary.DeviceID, secondary.DeviceID)
|
||||||
|
fillString(&primary.Model, secondary.Model)
|
||||||
|
fillString(&primary.PartNumber, secondary.PartNumber)
|
||||||
|
fillString(&primary.Manufacturer, secondary.Manufacturer)
|
||||||
|
fillString(&primary.SerialNumber, secondary.SerialNumber)
|
||||||
|
fillString(&primary.Firmware, secondary.Firmware)
|
||||||
|
fillString(&primary.Type, secondary.Type)
|
||||||
|
fillString(&primary.Interface, secondary.Interface)
|
||||||
|
if primary.Present == nil && secondary.Present != nil {
|
||||||
|
primary.Present = secondary.Present
|
||||||
|
}
|
||||||
|
fillInt(&primary.SizeMB, secondary.SizeMB)
|
||||||
|
fillInt(&primary.SizeGB, secondary.SizeGB)
|
||||||
|
fillInt(&primary.Cores, secondary.Cores)
|
||||||
|
fillInt(&primary.Threads, secondary.Threads)
|
||||||
|
fillInt(&primary.FrequencyMHz, secondary.FrequencyMHz)
|
||||||
|
fillInt(&primary.MaxFreqMHz, secondary.MaxFreqMHz)
|
||||||
|
fillInt(&primary.PortCount, secondary.PortCount)
|
||||||
|
fillString(&primary.PortType, secondary.PortType)
|
||||||
|
if len(primary.MACAddresses) == 0 && len(secondary.MACAddresses) > 0 {
|
||||||
|
primary.MACAddresses = secondary.MACAddresses
|
||||||
|
}
|
||||||
|
fillInt(&primary.LinkWidth, secondary.LinkWidth)
|
||||||
|
fillString(&primary.LinkSpeed, secondary.LinkSpeed)
|
||||||
|
fillInt(&primary.MaxLinkWidth, secondary.MaxLinkWidth)
|
||||||
|
fillString(&primary.MaxLinkSpeed, secondary.MaxLinkSpeed)
|
||||||
|
fillInt(&primary.WattageW, secondary.WattageW)
|
||||||
|
fillString(&primary.InputType, secondary.InputType)
|
||||||
|
fillInt(&primary.InputPowerW, secondary.InputPowerW)
|
||||||
|
fillInt(&primary.OutputPowerW, secondary.OutputPowerW)
|
||||||
|
fillFloat(&primary.InputVoltage, secondary.InputVoltage)
|
||||||
|
fillInt(&primary.TemperatureC, secondary.TemperatureC)
|
||||||
|
fillString(&primary.Status, secondary.Status)
|
||||||
|
if primary.StatusCheckedAt.IsZero() && !secondary.StatusCheckedAt.IsZero() {
|
||||||
|
primary.StatusCheckedAt = secondary.StatusCheckedAt
|
||||||
|
}
|
||||||
|
if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() {
|
||||||
|
primary.StatusChangedAt = secondary.StatusChangedAt
|
||||||
|
}
|
||||||
|
if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil {
|
||||||
|
primary.StatusAtCollect = secondary.StatusAtCollect
|
||||||
|
}
|
||||||
|
if len(primary.StatusHistory) == 0 && len(secondary.StatusHistory) > 0 {
|
||||||
|
primary.StatusHistory = secondary.StatusHistory
|
||||||
|
}
|
||||||
|
fillString(&primary.ErrorDescription, secondary.ErrorDescription)
|
||||||
|
if primary.Details == nil && secondary.Details != nil {
|
||||||
|
primary.Details = secondary.Details
|
||||||
|
}
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
|
||||||
|
func samePCIID(a, b models.HardwareDevice) bool {
|
||||||
|
if (a.VendorID == 0 && a.DeviceID == 0) || (b.VendorID == 0 && b.DeviceID == 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.VendorID == b.VendorID && a.DeviceID == b.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameModel(a, b models.HardwareDevice) bool {
|
||||||
|
am := normalizeText(coalesce(a.Model, a.PartNumber, a.DeviceClass))
|
||||||
|
bm := normalizeText(coalesce(b.Model, b.PartNumber, b.DeviceClass))
|
||||||
|
return am != "" && am == bm
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameManufacturer(a, b models.HardwareDevice) bool {
|
||||||
|
am := normalizeText(a.Manufacturer)
|
||||||
|
bm := normalizeText(b.Manufacturer)
|
||||||
|
return am != "" && am == bm
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasMACOverlap(a, b []string) bool {
|
||||||
|
if len(a) == 0 || len(b) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
set := make(map[string]struct{}, len(a))
|
||||||
|
for _, mac := range a {
|
||||||
|
key := normalizeText(mac)
|
||||||
|
if key != "" {
|
||||||
|
set[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mac := range b {
|
||||||
|
if _, ok := set[normalizeText(mac)]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameKindFamily(a, b string) bool {
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
family := map[string]bool{
|
||||||
|
models.DeviceKindPCIe: true,
|
||||||
|
models.DeviceKindGPU: true,
|
||||||
|
models.DeviceKindNetwork: true,
|
||||||
|
}
|
||||||
|
return family[a] && family[b]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeText(v string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(v))
|
||||||
|
s = strings.ReplaceAll(s, " ", "")
|
||||||
|
s = strings.ReplaceAll(s, "_", "")
|
||||||
|
s = strings.ReplaceAll(s, "-", "")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSlot(slot string) string {
|
||||||
|
return normalizeText(slot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qualityScore(d models.HardwareDevice) int {
|
||||||
|
score := 0
|
||||||
|
if normalizedSerial(d.SerialNumber) != "" {
|
||||||
|
score += 6
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.BDF) != "" {
|
||||||
|
score += 4
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Model) != "" {
|
||||||
|
score += 3
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Firmware) != "" {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(d.Status) != "" {
|
||||||
|
score++
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedSerial(serial string) string {
|
||||||
|
s := strings.TrimSpace(serial)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(s) {
|
||||||
|
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
|
||||||
|
return ""
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFirmwareBySlot(firmware []models.FirmwareInfo) map[string]slotFirmwareInfo {
|
||||||
|
out := make(map[string]slotFirmwareInfo)
|
||||||
|
add := func(slot, model, version, category string) {
|
||||||
|
key := normalizeSlotKey(slot)
|
||||||
|
if key == "" || strings.TrimSpace(version) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, ok := out[key]
|
||||||
|
if ok && strings.TrimSpace(existing.Model) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out[key] = slotFirmwareInfo{
|
||||||
|
Model: strings.TrimSpace(model),
|
||||||
|
Version: strings.TrimSpace(version),
|
||||||
|
Category: category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fw := range firmware {
|
||||||
|
name := strings.TrimSpace(fw.DeviceName)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := psuFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
|
||||||
|
model := strings.TrimSpace(m[2])
|
||||||
|
if model == "" {
|
||||||
|
model = "PSU"
|
||||||
|
}
|
||||||
|
add(m[1], model, fw.Version, "psu")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := nicFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
|
||||||
|
model := strings.TrimSpace(m[2])
|
||||||
|
if model == "" {
|
||||||
|
model = "NIC"
|
||||||
|
}
|
||||||
|
add(m[1], model, fw.Version, "nic")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := gpuFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
|
||||||
|
model := strings.TrimSpace(m[2])
|
||||||
|
if model == "" {
|
||||||
|
model = "GPU"
|
||||||
|
}
|
||||||
|
add(m[1], model, fw.Version, "gpu")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m := nvsFirmwareRe.FindStringSubmatch(name); len(m) == 3 {
|
||||||
|
model := strings.TrimSpace(m[2])
|
||||||
|
if model == "" {
|
||||||
|
model = "NVSwitch"
|
||||||
|
}
|
||||||
|
add(m[1], model, fw.Version, "nvswitch")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSlotKey(slot string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(slot))
|
||||||
|
}
|
||||||
152
internal/server/device_repository_test.go
Normal file
152
internal/server/device_repository_test.go
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_DedupSerialThenBDF(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{Slot: "A1", SerialNumber: "SER-1", BDF: "0000:01:00.0", DeviceClass: "NetworkController"},
|
||||||
|
{Slot: "A2", SerialNumber: "SER-1", BDF: "0000:02:00.0", DeviceClass: "NetworkController"},
|
||||||
|
{Slot: "B1", SerialNumber: "", BDF: "0000:03:00.0", DeviceClass: "NetworkController"},
|
||||||
|
{Slot: "B2", SerialNumber: "", BDF: "0000:03:00.0", DeviceClass: "NetworkController"},
|
||||||
|
{Slot: "C1", SerialNumber: "", BDF: "", DeviceClass: "NetworkController"},
|
||||||
|
{Slot: "C2", SerialNumber: "", BDF: "", DeviceClass: "NetworkController"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
// 1 board + (SER-1 dedup -> 1) + (BDF 03 dedup -> 1) + (C1,C2 keep both) = 5
|
||||||
|
if len(devices) != 5 {
|
||||||
|
t.Fatalf("expected 5 devices after dedupe, got %d", len(devices))
|
||||||
|
}
|
||||||
|
|
||||||
|
bySlot := map[string]bool{}
|
||||||
|
for _, d := range devices {
|
||||||
|
bySlot[d.Slot] = true
|
||||||
|
}
|
||||||
|
if !bySlot["A1"] && !bySlot["A2"] {
|
||||||
|
t.Fatalf("expected one serial-deduped A* device")
|
||||||
|
}
|
||||||
|
if bySlot["B1"] && bySlot["B2"] {
|
||||||
|
t.Fatalf("expected B1/B2 to dedupe by bdf")
|
||||||
|
}
|
||||||
|
if !bySlot["C1"] || !bySlot["C2"] {
|
||||||
|
t.Fatalf("expected C1 and C2 to remain without serial/bdf")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_SkipsEmptyMemorySlots(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{Slot: "A1", Present: true, SizeMB: 32768, SerialNumber: "DIMM-1"},
|
||||||
|
{Slot: "A2", Present: false, SizeMB: 0, SerialNumber: "DIMM-2"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
memoryCount := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.Kind == models.DeviceKindMemory {
|
||||||
|
memoryCount++
|
||||||
|
if d.Slot == "A2" {
|
||||||
|
t.Fatalf("empty memory slot should not be included")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memoryCount != 1 {
|
||||||
|
t.Fatalf("expected 1 installed memory record, got %d", memoryCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_DedupCrossKindByBDF(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "SL0CP0_001",
|
||||||
|
BDF: "02:00.0",
|
||||||
|
DeviceClass: "DisplayController",
|
||||||
|
VendorID: 0x1a03,
|
||||||
|
DeviceID: 0x2000,
|
||||||
|
PartNumber: "ASPEED Graphics Family",
|
||||||
|
Manufacturer: "ASPEED Technology, Inc.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GPUs: []models.GPU{
|
||||||
|
{
|
||||||
|
Slot: "SL0CP0_001",
|
||||||
|
BDF: "02:00.0",
|
||||||
|
Model: "ASPEED Graphics Family",
|
||||||
|
Manufacturer: "ASPEED Technology, Inc.",
|
||||||
|
VendorID: 0x1a03,
|
||||||
|
DeviceID: 0x2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
count := 0
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.BDF == "02:00.0" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 canonical device for bdf 02:00.0, got %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHardwareDevices_SkipsFirmwareOnlyNumericSlots(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{Slot: "0", DeviceClass: "Unknown", Manufacturer: "-", PartNumber: "-", Description: "-"},
|
||||||
|
{Slot: "1", DeviceClass: "Other", Manufacturer: "unknown", PartNumber: "N/A", Description: "NULL"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := BuildHardwareDevices(hw)
|
||||||
|
for _, d := range devices {
|
||||||
|
if d.Kind == models.DeviceKindPCIe && (d.Slot == "0" || d.Slot == "1") {
|
||||||
|
t.Fatalf("firmware-only numeric-slot pcie record must be filtered, got slot %q", d.Slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
|
||||||
|
srv := &Server{}
|
||||||
|
srv.SetResult(&models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{ProductName: "X", SerialNumber: "SN-1"},
|
||||||
|
CPUs: []models.CPU{{Socket: 0, Model: "CPU"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
srv.handleGetConfig(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
hardware, ok := payload["hardware"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected hardware object")
|
||||||
|
}
|
||||||
|
if _, ok := hardware["devices"]; !ok {
|
||||||
|
t.Fatalf("expected hardware.devices in config response")
|
||||||
|
}
|
||||||
|
if _, ok := hardware["cpus"]; ok {
|
||||||
|
t.Fatalf("did not expect legacy hardware.cpus in config response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,10 +163,14 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build specification summary
|
devices := canonicalDevices(result.Hardware)
|
||||||
spec := buildSpecification(result)
|
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
|
response["specification"] = spec
|
||||||
jsonResponse(w, response)
|
jsonResponse(w, response)
|
||||||
}
|
}
|
||||||
@@ -178,17 +182,28 @@ type SpecLine struct {
|
|||||||
Quantity int `json:"quantity"`
|
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
|
var spec []SpecLine
|
||||||
hw := result.Hardware
|
|
||||||
if hw == nil {
|
if hw == nil {
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
devices := canonicalDevices(hw)
|
||||||
|
|
||||||
// CPUs - group by model
|
// CPUs - group by model
|
||||||
cpuGroups := make(map[string]int)
|
cpuGroups := make(map[string]int)
|
||||||
cpuDetails := make(map[string]models.CPU)
|
cpuDetails := make(map[string]models.HardwareDevice)
|
||||||
for _, cpu := range hw.CPUs {
|
for _, cpu := range devices {
|
||||||
|
if cpu.Kind != models.DeviceKindCPU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
cpuGroups[cpu.Model]++
|
cpuGroups[cpu.Model]++
|
||||||
cpuDetails[cpu.Model] = cpu
|
cpuDetails[cpu.Model] = cpu
|
||||||
}
|
}
|
||||||
@@ -198,21 +213,26 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
|
|||||||
model,
|
model,
|
||||||
float64(cpu.FrequencyMHz)/1000,
|
float64(cpu.FrequencyMHz)/1000,
|
||||||
cpu.Cores,
|
cpu.Cores,
|
||||||
cpu.TDP)
|
intFromDetails(cpu.Details, "tdp_w"))
|
||||||
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
|
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory - group by size, type and frequency (only installed modules)
|
// Memory - group by size, type and frequency (only installed modules)
|
||||||
memGroups := make(map[string]int)
|
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)
|
// Skip empty slots (not present or 0 size)
|
||||||
if !mem.Present || mem.SizeMB == 0 {
|
if !present || mem.SizeMB == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Include frequency if available
|
// Include frequency if available
|
||||||
key := ""
|
key := ""
|
||||||
if mem.CurrentSpeedMHz > 0 {
|
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
|
||||||
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, mem.CurrentSpeedMHz)
|
if currentSpeed > 0 {
|
||||||
|
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, currentSpeed)
|
||||||
} else {
|
} else {
|
||||||
key = fmt.Sprintf("%s %dGB", mem.Type, mem.SizeMB/1024)
|
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
|
// Storage - group by type and capacity
|
||||||
storGroups := make(map[string]int)
|
storGroups := make(map[string]int)
|
||||||
for _, stor := range hw.Storage {
|
for _, stor := range devices {
|
||||||
|
if stor.Kind != models.DeviceKindStorage {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var key string
|
var key string
|
||||||
if stor.SizeGB >= 1000 {
|
if stor.SizeGB >= 1000 {
|
||||||
key = fmt.Sprintf("%s %s %.2fTB", stor.Type, stor.Interface, float64(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
|
// PCIe devices - group by device class/name and manufacturer
|
||||||
pcieGroups := make(map[string]int)
|
pcieGroups := make(map[string]int)
|
||||||
pcieDetails := make(map[string]models.PCIeDevice)
|
pcieDetails := make(map[string]models.HardwareDevice)
|
||||||
for _, pcie := range hw.PCIeDevices {
|
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
|
// Create unique key from manufacturer + device class/name
|
||||||
key := pcie.DeviceClass
|
key := pcie.DeviceClass
|
||||||
if pcie.Manufacturer != "" {
|
if pcie.Manufacturer != "" {
|
||||||
@@ -259,7 +285,7 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
|
|||||||
|
|
||||||
// Determine category based on device class or known GPU names
|
// Determine category based on device class or known GPU names
|
||||||
deviceClass := pcie.DeviceClass
|
deviceClass := pcie.DeviceClass
|
||||||
isGPU := isGPUDevice(deviceClass)
|
isGPU := pcie.Kind == models.DeviceKindGPU || isGPUDevice(deviceClass)
|
||||||
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
||||||
|
|
||||||
if isGPU {
|
if isGPU {
|
||||||
@@ -275,7 +301,10 @@ func buildSpecification(result *models.AnalysisResult) []SpecLine {
|
|||||||
|
|
||||||
// Power supplies - group by model/wattage
|
// Power supplies - group by model/wattage
|
||||||
psuGroups := make(map[string]int)
|
psuGroups := make(map[string]int)
|
||||||
for _, psu := range hw.PowerSupply {
|
for _, psu := range devices {
|
||||||
|
if psu.Kind != models.DeviceKindPSU {
|
||||||
|
continue
|
||||||
|
}
|
||||||
key := psu.Model
|
key := psu.Model
|
||||||
if key == "" && psu.WattageW > 0 {
|
if key == "" && psu.WattageW > 0 {
|
||||||
key = fmt.Sprintf("%dW", psu.WattageW)
|
key = fmt.Sprintf("%dW", psu.WattageW)
|
||||||
@@ -309,23 +338,6 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var serials []SerialEntry
|
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
|
// From FRU
|
||||||
for _, fru := range result.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 {
|
if result.Hardware != nil {
|
||||||
// Board
|
for _, d := range canonicalDevices(result.Hardware) {
|
||||||
if hasUsableSerial(result.Hardware.BoardInfo.SerialNumber) {
|
if !hasUsableSerial(d.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) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
serials = append(serials, SerialEntry{
|
serials = append(serials, SerialEntry{
|
||||||
Component: cpu.Model,
|
Component: serialComponent(d),
|
||||||
Location: fmt.Sprintf("CPU%d", cpu.Socket),
|
Location: strings.TrimSpace(coalesce(d.Location, d.Slot)),
|
||||||
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
||||||
Category: "CPU",
|
Manufacturer: strings.TrimSpace(d.Manufacturer),
|
||||||
})
|
PartNumber: strings.TrimSpace(d.PartNumber),
|
||||||
}
|
Category: serialCategory(d.Kind),
|
||||||
|
|
||||||
// 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",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,26 +464,91 @@ func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
|
|||||||
appendEntry(component, model, fw.Version)
|
appendEntry(component, model, fw.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for parsers that fill GPU firmware on device inventory only
|
for _, d := range canonicalDevices(hw) {
|
||||||
// (e.g. runtime enrichment from redis/HGX) without explicit Hardware.Firmware entries.
|
version := strings.TrimSpace(d.Firmware)
|
||||||
for _, gpu := range hw.GPUs {
|
|
||||||
version := strings.TrimSpace(gpu.Firmware)
|
|
||||||
if version == "" {
|
if version == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
model := strings.TrimSpace(gpu.PartNumber)
|
model := strings.TrimSpace(d.PartNumber)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = strings.TrimSpace(gpu.Model)
|
model = strings.TrimSpace(d.Model)
|
||||||
}
|
}
|
||||||
if 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
|
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
|
// extractFirmwareComponentAndModel extracts the component type and model from firmware device name
|
||||||
func extractFirmwareComponentAndModel(deviceName string) (component, model string) {
|
func extractFirmwareComponentAndModel(deviceName string) (component, model string) {
|
||||||
// Parse different firmware name formats and extract component + model
|
// Parse different firmware name formats and extract component + model
|
||||||
|
|||||||
@@ -837,6 +837,12 @@ footer {
|
|||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pcie-group-title {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
color: #34495e;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Config tables */
|
/* Config tables */
|
||||||
.config-table {
|
.config-table {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -930,6 +936,64 @@ footer {
|
|||||||
text-transform: uppercase;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sensor-grid {
|
.sensor-grid {
|
||||||
|
|||||||
@@ -646,6 +646,22 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
const config = data.hardware || data;
|
const config = data.hardware || data;
|
||||||
const spec = data.specification;
|
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 = '';
|
let html = '';
|
||||||
|
|
||||||
@@ -684,30 +700,56 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// CPU tab
|
// CPU tab
|
||||||
html += '<div class="config-tab-content" id="config-cpu">';
|
html += '<div class="config-tab-content" id="config-cpu">';
|
||||||
if (config.cpus && config.cpus.length > 0) {
|
if (cpus.length > 0) {
|
||||||
const cpuCount = config.cpus.length;
|
const cpuCount = cpus.length;
|
||||||
const cpuModel = config.cpus[0].model || '-';
|
const cpuModel = cpus[0].model || '-';
|
||||||
const totalCores = config.cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
|
const totalCores = cpus.reduce((sum, c) => sum + (c.cores || 0), 0);
|
||||||
const totalThreads = config.cpus.reduce((sum, c) => sum + (c.threads || 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 += `<h3>Процессоры</h3>
|
html += `<h3>Процессоры</h3>
|
||||||
<div class="section-overview">
|
<div class="section-overview">
|
||||||
<div class="stat-box"><span class="stat-value">${cpuCount}</span><span class="stat-label">Процессоров</span></div>
|
<div class="stat-box"><span class="stat-value">${cpuCount}</span><span class="stat-label">Процессоров</span></div>
|
||||||
<div class="stat-box"><span class="stat-value">${totalCores}</span><span class="stat-label">Ядер</span></div>
|
<div class="stat-box"><span class="stat-value">${totalCores}</span><span class="stat-label">Ядер</span></div>
|
||||||
<div class="stat-box"><span class="stat-value">${totalThreads}</span><span class="stat-label">Потоков</span></div>
|
<div class="stat-box"><span class="stat-value">${totalThreads}</span><span class="stat-label">Потоков</span></div>
|
||||||
|
<div class="stat-box"><span class="stat-value">${pcieBalance.totalLanes}</span><span class="stat-label">Занято PCIe линий</span></div>
|
||||||
|
<div class="stat-box ${balanceClass}"><span class="stat-value">${balanceLabel}</span><span class="stat-label">Баланс PCIe</span></div>
|
||||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(cpuModel)}</span><span class="stat-label">Модель</span></div>
|
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(cpuModel)}</span><span class="stat-label">Модель</span></div>
|
||||||
</div>
|
</div>
|
||||||
<table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PPIN</th></tr></thead><tbody>`;
|
<div class="pcie-balance-bars">`;
|
||||||
config.cpus.forEach(cpu => {
|
pcieBalance.perCPU.forEach(cpu => {
|
||||||
|
html += `<div class="pcie-balance-row">
|
||||||
|
<span class="pcie-balance-cpu">${escapeHtml(cpu.label)}</span>
|
||||||
|
<div class="pcie-balance-track"><div class="pcie-balance-fill ${balanceClass}" style="width:${cpu.percent}%"></div></div>
|
||||||
|
<span class="pcie-balance-value">${cpu.lanes}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>
|
||||||
|
<table class="config-table"><thead><tr><th>Socket</th><th>Модель</th><th>Ядра</th><th>Потоки</th><th>Частота</th><th>Max Turbo</th><th>TDP</th><th>L3 Cache</th><th>PCIe линии (занято)</th><th>Модулей памяти</th><th>PPIN</th></tr></thead><tbody>`;
|
||||||
|
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 += `<tr>
|
html += `<tr>
|
||||||
<td>CPU${cpu.socket}</td>
|
<td>${escapeHtml(socket)}</td>
|
||||||
<td>${escapeHtml(cpu.model)}</td>
|
<td>${escapeHtml(cpu.model || '-')}</td>
|
||||||
<td>${cpu.cores}</td>
|
<td>${cpu.cores || '-'}</td>
|
||||||
<td>${cpu.threads}</td>
|
<td>${cpu.threads || '-'}</td>
|
||||||
<td>${cpu.frequency_mhz} MHz</td>
|
<td>${cpu.frequency_mhz ? cpu.frequency_mhz + ' MHz' : '-'}</td>
|
||||||
<td>${cpu.max_frequency_mhz} MHz</td>
|
<td>${cpu.max_frequency_mhz ? cpu.max_frequency_mhz + ' MHz' : '-'}</td>
|
||||||
<td>${cpu.tdp_w}W</td>
|
<td>${tdp !== '-' ? tdp + 'W' : '-'}</td>
|
||||||
<td>${Math.round(cpu.l3_cache_kb/1024)} MB</td>
|
<td>${l3 !== '-' ? l3 + ' MB' : '-'}</td>
|
||||||
<td><code>${escapeHtml(cpu.ppin || '-')}</code></td>
|
<td>${pcieUsed}</td>
|
||||||
|
<td>${memoryModules}</td>
|
||||||
|
<td><code>${escapeHtml(ppin)}</code></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
@@ -718,10 +760,10 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// Memory tab
|
// Memory tab
|
||||||
html += '<div class="config-tab-content" id="config-memory">';
|
html += '<div class="config-tab-content" id="config-memory">';
|
||||||
if (config.memory && config.memory.length > 0) {
|
if (memory.length > 0) {
|
||||||
const totalGB = config.memory.reduce((sum, m) => sum + m.size_mb, 0) / 1024;
|
const totalGB = memory.reduce((sum, m) => sum + (m.size_mb || 0), 0) / 1024;
|
||||||
const presentCount = config.memory.filter(m => m.present !== false).length;
|
const presentCount = memory.filter(m => m.present !== false).length;
|
||||||
const workingCount = config.memory.filter(m => m.size_mb > 0).length;
|
const workingCount = memory.filter(m => (m.size_mb || 0) > 0).length;
|
||||||
html += `<h3>Модули памяти</h3>
|
html += `<h3>Модули памяти</h3>
|
||||||
<div class="memory-overview">
|
<div class="memory-overview">
|
||||||
<div class="stat-box"><span class="stat-value">${totalGB} GB</span><span class="stat-label">Всего</span></div>
|
<div class="stat-box"><span class="stat-value">${totalGB} GB</span><span class="stat-label">Всего</span></div>
|
||||||
@@ -731,10 +773,10 @@ function renderConfig(data) {
|
|||||||
<table class="config-table memory-table"><thead><tr>
|
<table class="config-table memory-table"><thead><tr>
|
||||||
<th>Location</th><th>Наличие</th><th>Размер</th><th>Тип</th><th>Max частота</th><th>Текущая частота</th><th>Производитель</th><th>Артикул</th><th>Статус</th>
|
<th>Location</th><th>Наличие</th><th>Размер</th><th>Тип</th><th>Max частота</th><th>Текущая частота</th><th>Производитель</th><th>Артикул</th><th>Статус</th>
|
||||||
</tr></thead><tbody>`;
|
</tr></thead><tbody>`;
|
||||||
config.memory.forEach(mem => {
|
memory.forEach(mem => {
|
||||||
const present = mem.present !== false ? '✓' : '-';
|
const present = mem.present !== false ? '✓' : '-';
|
||||||
const presentClass = mem.present !== false ? 'present-yes' : 'present-no';
|
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 statusClass = (mem.status === 'OK' || !mem.status) ? '' : 'status-warning';
|
||||||
const rowClass = sizeGB === 0 ? 'row-warning' : '';
|
const rowClass = sizeGB === 0 ? 'row-warning' : '';
|
||||||
html += `<tr class="${rowClass}">
|
html += `<tr class="${rowClass}">
|
||||||
@@ -742,8 +784,8 @@ function renderConfig(data) {
|
|||||||
<td class="${presentClass}">${present}</td>
|
<td class="${presentClass}">${present}</td>
|
||||||
<td>${sizeGB} GB</td>
|
<td>${sizeGB} GB</td>
|
||||||
<td>${escapeHtml(mem.type || '-')}</td>
|
<td>${escapeHtml(mem.type || '-')}</td>
|
||||||
<td>${mem.max_speed_mhz || '-'} MHz</td>
|
<td>${(mem.details && mem.details.max_speed_mhz) || '-'} MHz</td>
|
||||||
<td>${mem.current_speed_mhz || mem.speed_mhz || '-'} MHz</td>
|
<td>${(mem.details && mem.details.current_speed_mhz) || mem.speed_mhz || '-'} MHz</td>
|
||||||
<td>${escapeHtml(mem.manufacturer || '-')}</td>
|
<td>${escapeHtml(mem.manufacturer || '-')}</td>
|
||||||
<td><code>${escapeHtml(mem.part_number || '-')}</code></td>
|
<td><code>${escapeHtml(mem.part_number || '-')}</code></td>
|
||||||
<td class="${statusClass}">${escapeHtml(mem.status || 'OK')}</td>
|
<td class="${statusClass}">${escapeHtml(mem.status || 'OK')}</td>
|
||||||
@@ -757,12 +799,12 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// Power tab
|
// Power tab
|
||||||
html += '<div class="config-tab-content" id="config-power">';
|
html += '<div class="config-tab-content" id="config-power">';
|
||||||
if (config.power_supplies && config.power_supplies.length > 0) {
|
if (powerSupplies.length > 0) {
|
||||||
const psuTotal = config.power_supplies.length;
|
const psuTotal = powerSupplies.length;
|
||||||
const psuPresent = config.power_supplies.filter(p => p.present !== false).length;
|
const psuPresent = powerSupplies.filter(p => p.present !== false).length;
|
||||||
const psuOK = config.power_supplies.filter(p => p.status === 'OK').length;
|
const psuOK = powerSupplies.filter(p => p.status === 'OK').length;
|
||||||
const psuModel = config.power_supplies[0].model || '-';
|
const psuModel = powerSupplies[0].model || '-';
|
||||||
const psuWattage = config.power_supplies[0].wattage_w || 0;
|
const psuWattage = powerSupplies[0].wattage_w || 0;
|
||||||
html += `<h3>Блоки питания</h3>
|
html += `<h3>Блоки питания</h3>
|
||||||
<div class="section-overview">
|
<div class="section-overview">
|
||||||
<div class="stat-box"><span class="stat-value">${psuTotal}</span><span class="stat-label">Всего</span></div>
|
<div class="stat-box"><span class="stat-value">${psuTotal}</span><span class="stat-label">Всего</span></div>
|
||||||
@@ -772,11 +814,11 @@ function renderConfig(data) {
|
|||||||
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(psuModel)}</span><span class="stat-label">Модель</span></div>
|
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(psuModel)}</span><span class="stat-label">Модель</span></div>
|
||||||
</div>
|
</div>
|
||||||
<table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>`;
|
<table class="config-table"><thead><tr><th>Слот</th><th>Производитель</th><th>Модель</th><th>Мощность</th><th>Вход</th><th>Выход</th><th>Напряжение</th><th>Температура</th><th>Статус</th></tr></thead><tbody>`;
|
||||||
config.power_supplies.forEach(psu => {
|
powerSupplies.forEach(psu => {
|
||||||
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
|
const statusClass = psu.status === 'OK' ? '' : 'status-warning';
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td>${escapeHtml(psu.slot)}</td>
|
<td>${escapeHtml(psu.slot)}</td>
|
||||||
<td>${escapeHtml(psu.vendor || '-')}</td>
|
<td>${escapeHtml(psu.manufacturer || psu.vendor || '-')}</td>
|
||||||
<td>${escapeHtml(psu.model || '-')}</td>
|
<td>${escapeHtml(psu.model || '-')}</td>
|
||||||
<td>${psu.wattage_w || '-'}W</td>
|
<td>${psu.wattage_w || '-'}W</td>
|
||||||
<td>${psu.input_power_w || '-'}W</td>
|
<td>${psu.input_power_w || '-'}W</td>
|
||||||
@@ -794,12 +836,12 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// Storage tab
|
// Storage tab
|
||||||
html += '<div class="config-tab-content" id="config-storage">';
|
html += '<div class="config-tab-content" id="config-storage">';
|
||||||
if (config.storage && config.storage.length > 0) {
|
if (storage.length > 0) {
|
||||||
const storTotal = config.storage.length;
|
const storTotal = storage.length;
|
||||||
const storHDD = config.storage.filter(s => s.type === 'HDD').length;
|
const storHDD = storage.filter(s => s.type === 'HDD').length;
|
||||||
const storSSD = config.storage.filter(s => s.type === 'SSD').length;
|
const storSSD = storage.filter(s => s.type === 'SSD').length;
|
||||||
const storNVMe = config.storage.filter(s => s.type === 'NVMe').length;
|
const storNVMe = storage.filter(s => s.type === 'NVMe').length;
|
||||||
const totalTB = (config.storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
|
const totalTB = (storage.reduce((sum, s) => sum + (s.size_gb || 0), 0) / 1000).toFixed(1);
|
||||||
let typesSummary = [];
|
let typesSummary = [];
|
||||||
if (storHDD > 0) typesSummary.push(`${storHDD} HDD`);
|
if (storHDD > 0) typesSummary.push(`${storHDD} HDD`);
|
||||||
if (storSSD > 0) typesSummary.push(`${storSSD} SSD`);
|
if (storSSD > 0) typesSummary.push(`${storSSD} SSD`);
|
||||||
@@ -807,19 +849,19 @@ function renderConfig(data) {
|
|||||||
html += `<h3>Накопители</h3>
|
html += `<h3>Накопители</h3>
|
||||||
<div class="section-overview">
|
<div class="section-overview">
|
||||||
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего слотов</span></div>
|
<div class="stat-box"><span class="stat-value">${storTotal}</span><span class="stat-label">Всего слотов</span></div>
|
||||||
<div class="stat-box"><span class="stat-value">${config.storage.filter(s => s.present).length}</span><span class="stat-label">Установлено</span></div>
|
<div class="stat-box"><span class="stat-value">${storage.filter(s => s.present).length}</span><span class="stat-label">Установлено</span></div>
|
||||||
<div class="stat-box"><span class="stat-value">${totalTB > 0 ? totalTB + ' TB' : '-'}</span><span class="stat-label">Объём</span></div>
|
<div class="stat-box"><span class="stat-value">${totalTB > 0 ? totalTB + ' TB' : '-'}</span><span class="stat-label">Объём</span></div>
|
||||||
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
|
<div class="stat-box model-box"><span class="stat-value">${typesSummary.join(', ') || '-'}</span><span class="stat-label">По типам</span></div>
|
||||||
</div>
|
</div>
|
||||||
<table class="config-table"><thead><tr><th>NO.</th><th>Статус</th><th>Расположение</th><th>Backplane ID</th><th>Тип</th><th>Модель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
|
<table class="config-table"><thead><tr><th>NO.</th><th>Статус</th><th>Расположение</th><th>Backplane ID</th><th>Тип</th><th>Модель</th><th>Размер</th><th>Серийный номер</th></tr></thead><tbody>`;
|
||||||
config.storage.forEach(s => {
|
storage.forEach(s => {
|
||||||
const presentIcon = s.present ? '<span style="color: #27ae60;">●</span>' : '<span style="color: #95a5a6;">○</span>';
|
const presentIcon = s.present ? '<span style="color: #27ae60;">●</span>' : '<span style="color: #95a5a6;">○</span>';
|
||||||
const presentText = s.present ? 'Present' : 'Empty';
|
const presentText = s.present ? 'Present' : 'Empty';
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td>${escapeHtml(s.slot || '-')}</td>
|
<td>${escapeHtml(s.slot || '-')}</td>
|
||||||
<td>${presentIcon} ${presentText}</td>
|
<td>${presentIcon} ${presentText}</td>
|
||||||
<td>${escapeHtml(s.location || '-')}</td>
|
<td>${escapeHtml(s.location || '-')}</td>
|
||||||
<td>${s.backplane_id !== undefined ? s.backplane_id : '-'}</td>
|
<td>${s.details && s.details.backplane_id !== undefined ? s.details.backplane_id : '-'}</td>
|
||||||
<td>${escapeHtml(s.type || '-')}</td>
|
<td>${escapeHtml(s.type || '-')}</td>
|
||||||
<td>${escapeHtml(s.model || '-')}</td>
|
<td>${escapeHtml(s.model || '-')}</td>
|
||||||
<td>${s.size_gb > 0 ? s.size_gb + ' GB' : '-'}</td>
|
<td>${s.size_gb > 0 ? s.size_gb + ' GB' : '-'}</td>
|
||||||
@@ -834,25 +876,7 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// GPU tab
|
// GPU tab
|
||||||
html += '<div class="config-tab-content" id="config-gpu">';
|
html += '<div class="config-tab-content" id="config-gpu">';
|
||||||
const gpuRows = (config.gpus && config.gpus.length > 0)
|
const gpuRows = gpus;
|
||||||
? 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
|
|
||||||
}));
|
|
||||||
if (gpuRows.length > 0) {
|
if (gpuRows.length > 0) {
|
||||||
const gpuCount = gpuRows.length;
|
const gpuCount = gpuRows.length;
|
||||||
const gpuModel = gpuRows[0].model || '-';
|
const gpuModel = gpuRows[0].model || '-';
|
||||||
@@ -888,22 +912,7 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// Network tab
|
// Network tab
|
||||||
html += '<div class="config-tab-content" id="config-network">';
|
html += '<div class="config-tab-content" id="config-network">';
|
||||||
const networkRows = (config.network_adapters && config.network_adapters.length > 0)
|
const networkRows = networkAdapters;
|
||||||
? 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 || ''
|
|
||||||
}));
|
|
||||||
if (networkRows.length > 0) {
|
if (networkRows.length > 0) {
|
||||||
const nicCount = networkRows.length;
|
const nicCount = networkRows.length;
|
||||||
const totalPorts = networkRows.reduce((sum, n) => sum + (n.port_count || 0), 0);
|
const totalPorts = networkRows.reduce((sum, n) => sum + (n.port_count || 0), 0);
|
||||||
@@ -923,7 +932,7 @@ function renderConfig(data) {
|
|||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td>${escapeHtml(nic.location || nic.slot || '-')}</td>
|
<td>${escapeHtml(nic.location || nic.slot || '-')}</td>
|
||||||
<td>${escapeHtml(nic.model || '-')}</td>
|
<td>${escapeHtml(nic.model || '-')}</td>
|
||||||
<td>${escapeHtml(nic.vendor || '-')}</td>
|
<td>${escapeHtml(nic.manufacturer || nic.vendor || '-')}</td>
|
||||||
<td>${nic.port_count || '-'}</td>
|
<td>${nic.port_count || '-'}</td>
|
||||||
<td>${escapeHtml(nic.port_type || '-')}</td>
|
<td>${escapeHtml(nic.port_type || '-')}</td>
|
||||||
<td><code>${escapeHtml(macs)}</code></td>
|
<td><code>${escapeHtml(macs)}</code></td>
|
||||||
@@ -938,65 +947,56 @@ function renderConfig(data) {
|
|||||||
|
|
||||||
// PCIe Device Inventory tab
|
// PCIe Device Inventory tab
|
||||||
html += '<div class="config-tab-content" id="config-pcie">';
|
html += '<div class="config-tab-content" id="config-pcie">';
|
||||||
const hasPCIe = config.pcie_devices && config.pcie_devices.length > 0;
|
if (inventoryRows.length > 0) {
|
||||||
const hasGPUs = config.gpus && config.gpus.length > 0;
|
html += '<h3>PCIe устройства</h3>';
|
||||||
if (hasPCIe || hasGPUs) {
|
const groups = new Map();
|
||||||
html += '<h3>PCIe устройства</h3><table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Модель</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th><th>Серийный номер</th><th>Прошивка</th></tr></thead><tbody>';
|
inventoryRows.forEach(p => {
|
||||||
const pcieRowKey = (slot, bdf, vendorId, deviceId) => {
|
const idx = extractCPUIndex(p.slot);
|
||||||
const normalizedBDF = (bdf || '').trim().toLowerCase();
|
const key = idx === null ? 'other' : `cpu${idx}`;
|
||||||
if (normalizedBDF) return `bdf:${normalizedBDF}`;
|
if (!groups.has(key)) {
|
||||||
const normalizedSlot = (slot || '').trim().toLowerCase();
|
groups.set(key, {
|
||||||
if (normalizedSlot) return `slot:${normalizedSlot}`;
|
idx,
|
||||||
return `id:${vendorId || 0}:${deviceId || 0}`;
|
title: idx === null ? 'Без привязки к CPU' : `CPU${idx}`,
|
||||||
};
|
lanes: 0,
|
||||||
const gpuByKey = new Map();
|
rows: []
|
||||||
(config.gpus || []).forEach(gpu => {
|
});
|
||||||
gpuByKey.set(pcieRowKey(gpu.slot, gpu.bdf, gpu.vendor_id, gpu.device_id), gpu);
|
}
|
||||||
|
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 sortedGroups = [...groups.values()].sort((a, b) => {
|
||||||
const key = pcieRowKey(p.slot, p.bdf, p.vendor_id, p.device_id);
|
if (a.idx === null) return 1;
|
||||||
const matchedGPU = gpuByKey.get(key);
|
if (b.idx === null) return -1;
|
||||||
|
return a.idx - b.idx;
|
||||||
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 += `<tr>
|
|
||||||
<td>${escapeHtml(p.slot || '-')}</td>
|
|
||||||
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
|
|
||||||
<td>${escapeHtml(p.part_number || '-')}</td>
|
|
||||||
<td>${escapeHtml(p.manufacturer || '-')}</td>
|
|
||||||
<td><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
|
|
||||||
<td>${pcieLink}</td>
|
|
||||||
<td><code>${escapeHtml(serial || '-')}</code></td>
|
|
||||||
<td><code>${escapeHtml(firmware || '-')}</code></td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
(config.gpus || []).forEach(gpu => {
|
sortedGroups.forEach(group => {
|
||||||
const pcieLink = formatPCIeLink(
|
html += `<h4 class="pcie-group-title">${escapeHtml(group.title)} · занято линий: ${group.lanes}</h4>`;
|
||||||
gpu.current_link_width || gpu.link_width,
|
html += '<table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Модель</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th><th>Серийный номер</th><th>Прошивка</th></tr></thead><tbody>';
|
||||||
gpu.current_link_speed || gpu.link_speed,
|
group.rows.forEach(p => {
|
||||||
gpu.max_link_width,
|
const pcieLink = formatPCIeLink(
|
||||||
gpu.max_link_speed
|
p.link_width,
|
||||||
);
|
p.link_speed,
|
||||||
html += `<tr>
|
p.max_link_width,
|
||||||
<td>${escapeHtml(gpu.slot || '-')}</td>
|
p.max_link_speed
|
||||||
<td><code>${escapeHtml(gpu.bdf || '-')}</code></td>
|
);
|
||||||
<td>${escapeHtml(gpu.model || gpu.part_number || '-')}</td>
|
const firmware = p.firmware || findPCIeFirmwareVersion(config.firmware, p);
|
||||||
<td>${escapeHtml(gpu.manufacturer || '-')}</td>
|
html += `<tr>
|
||||||
<td><code>${gpu.vendor_id ? gpu.vendor_id.toString(16) : '-'}:${gpu.device_id ? gpu.device_id.toString(16) : '-'}</code></td>
|
<td>${escapeHtml(p.slot || '-')}</td>
|
||||||
<td>${pcieLink}</td>
|
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
|
||||||
<td><code>${escapeHtml(gpu.serial_number || '-')}</code></td>
|
<td>${escapeHtml(p.model || p.part_number || p.device_class || '-')}</td>
|
||||||
<td><code>${escapeHtml(gpu.firmware || '-')}</code></td>
|
<td>${escapeHtml(p.manufacturer || '-')}</td>
|
||||||
</tr>`;
|
<td><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
|
||||||
|
<td>${pcieLink}</td>
|
||||||
|
<td><code>${escapeHtml(p.serial_number || '-')}</code></td>
|
||||||
|
<td><code>${escapeHtml(firmware || '-')}</code></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
|
||||||
} else {
|
} else {
|
||||||
html += '<p class="no-data">Нет данных о PCIe устройствах</p>';
|
html += '<p class="no-data">Нет данных о PCIe устройствах</p>';
|
||||||
}
|
}
|
||||||
@@ -1283,6 +1283,83 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
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) {
|
function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) {
|
||||||
if (!Array.isArray(firmwareEntries) || !pcieDevice) return '';
|
if (!Array.isArray(firmwareEntries) || !pcieDevice) return '';
|
||||||
|
|
||||||
@@ -1290,17 +1367,30 @@ function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) {
|
|||||||
const model = (pcieDevice.part_number || '').trim().toLowerCase();
|
const model = (pcieDevice.part_number || '').trim().toLowerCase();
|
||||||
if (!slot && !model) return '';
|
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) {
|
for (const fw of firmwareEntries) {
|
||||||
const name = (fw.device_name || '').trim().toLowerCase();
|
const name = (fw.device_name || '').trim().toLowerCase();
|
||||||
const version = (fw.version || '').trim();
|
const version = (fw.version || '').trim();
|
||||||
if (!name || !version) continue;
|
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;
|
if (model && name.includes(model)) return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
function formatPCIeLink(currentWidth, currentSpeed, maxWidth, maxSpeed) {
|
function formatPCIeLink(currentWidth, currentSpeed, maxWidth, maxSpeed) {
|
||||||
// Helper to convert speed to generation
|
// Helper to convert speed to generation
|
||||||
function speedToGen(speed) {
|
function speedToGen(speed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user