feat: improve inspur parsing and pci.ids integration

This commit is contained in:
2026-02-17 18:09:36 +03:00
parent b33cca5fcc
commit 758fa66282
26 changed files with 43567 additions and 247 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "third_party/pciids"]
path = third_party/pciids
url = https://github.com/pciutils/pciids.git

View File

@@ -44,6 +44,31 @@ Registry: `internal/collector/registry.go`
- progress logs include active collection stage and snapshot progress. - progress logs include active collection stage and snapshot progress.
- `ipmi` is currently a mock collector scaffold. - `ipmi` is currently a mock collector scaffold.
## Inspur/Kaytus parser notes
- Base hardware inventory comes from `asset.json` + `component.log` + `devicefrusdr.log`.
- Additional runtime enrichment is applied from `redis-dump.rdb` (if present):
- GPU serial/firmware/UUID and selected runtime metrics;
- NIC firmware/serial/part fields where text logs are incomplete.
- GPU/NIC enrichment from Redis is conservative (fills missing fields, avoids unsafe remapping).
### External PCI IDs lookup (no hardcoded model mapping)
`internal/parser/vendors/pciids` now loads IDs from a repo file
(`internal/parser/vendors/pciids/pci.ids`, embedded at build time) plus optional external overrides.
Lookup priority:
1. embedded `internal/parser/vendors/pciids/pci.ids`,
2. `./pci.ids`,
3. `/usr/share/hwdata/pci.ids`,
4. `/usr/share/misc/pci.ids`,
5. `/opt/homebrew/share/pciids/pci.ids`,
6. `LOGPILE_PCI_IDS_PATH` (highest priority overrides; supports path list).
Implication:
- for unknown device IDs (e.g. new NVIDIA GPU IDs), model naming can be updated via `pci.ids`
without changing parser code.
## Export behavior ## Export behavior
Endpoints: Endpoints:

View File

@@ -1,4 +1,4 @@
.PHONY: build run clean test build-all .PHONY: build run clean test build-all update-pci-ids
BINARY_NAME=logpile BINARY_NAME=logpile
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -6,6 +6,7 @@ COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)"
build: build:
@if [ "$(SKIP_PCI_IDS_UPDATE)" != "1" ]; then ./scripts/update-pci-ids.sh --best-effort; fi
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/logpile CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(BINARY_NAME) ./cmd/logpile
run: build run: build
@@ -19,6 +20,7 @@ test:
# Cross-platform builds # Cross-platform builds
build-all: clean build-all: clean
@if [ "$(SKIP_PCI_IDS_UPDATE)" != "1" ]; then ./scripts/update-pci-ids.sh --best-effort; fi
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/logpile CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-amd64 ./cmd/logpile
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 ./cmd/logpile CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-linux-arm64 ./cmd/logpile
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 ./cmd/logpile CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY_NAME)-darwin-amd64 ./cmd/logpile
@@ -33,3 +35,6 @@ fmt:
lint: lint:
golangci-lint run golangci-lint run
update-pci-ids:
./scripts/update-pci-ids.sh --sync-submodule

View File

@@ -17,6 +17,65 @@ LOGPile — standalone Go-приложение для анализа диагн
- Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы. - Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы.
- Экспорт в CSV / JSON. - Экспорт в CSV / JSON.
## Дополнительные источники данных (Inspur/Kaytus)
Для архивов Inspur/Kaytus парсер использует не только `asset.json` и `component.log`,
но и runtime-снимок `onekeylog/runningdata/redis-dump.rdb` (если файл присутствует).
Что это даёт:
- обогащение GPU: `serial_number`, `firmware` (VBIOS/FW), часть runtime telemetry;
- обогащение NIC: firmware/serial/part-number (когда в текстовых логах поля пустые).
## Внешний PCI IDs (без хардкода моделей)
Источник PCI IDs в проекте: официальный репозиторий
[`pciutils/pciids`](https://github.com/pciutils/pciids), подключён как git submodule:
`third_party/pciids`.
Локальная копия для встроенного lookup хранится в:
`internal/parser/vendors/pciids/pci.ids`.
Обновление локальной копии:
```bash
make update-pci-ids
```
Команда запускает `scripts/update-pci-ids.sh`, который скачивает актуальный
`pci.ids` из submodule (`git submodule update --init --remote third_party/pciids`)
и синхронизирует его в `internal/parser/vendors/pciids/pci.ids`.
Автообновление при сборке:
- `make build` и `make build-all` запускают `scripts/update-pci-ids.sh --best-effort`;
- если submodule уже инициализирован, `pci.ids` синхронизируется перед сборкой;
- если submodule не инициализирован/недоступен, используется текущая копия файла,
сборка не прерывается.
Отключить автообновление при сборке:
```bash
SKIP_PCI_IDS_UPDATE=1 make build
```
Если репозиторий клонирован без submodule:
```bash
git submodule update --init third_party/pciids
```
Парсер использует такой порядок lookup:
1. встроенный в бинарник `internal/parser/vendors/pciids/pci.ids`;
2. `./pci.ids`;
3. `/usr/share/hwdata/pci.ids`;
4. `/usr/share/misc/pci.ids`;
5. `/opt/homebrew/share/pciids/pci.ids`;
6. `LOGPILE_PCI_IDS_PATH` (можно передать несколько путей через `:`; имеет наивысший приоритет и переопределяет предыдущие значения).
Пример запуска:
```bash
LOGPILE_PCI_IDS_PATH=/path/to/pci.ids ./bin/logpile
```
## Требования ## Требования
- Go 1.22+ - Go 1.22+

View File

@@ -17,6 +17,7 @@ import (
"time" "time"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
) )
type RedfishConnector struct { type RedfishConnector struct {
@@ -725,12 +726,27 @@ func parseDrive(doc map[string]interface{}) models.Storage {
} }
func parseNIC(doc map[string]interface{}) models.NetworkAdapter { func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
vendorID := asHexOrInt(doc["VendorId"])
deviceID := asHexOrInt(doc["DeviceId"])
model := firstNonEmpty(asString(doc["Model"]), asString(doc["Name"]))
if isMissingOrRawPCIModel(model) {
if resolved := pciids.DeviceName(vendorID, deviceID); resolved != "" {
model = resolved
}
}
vendor := asString(doc["Manufacturer"])
if strings.TrimSpace(vendor) == "" {
vendor = pciids.VendorName(vendorID)
}
return models.NetworkAdapter{ return models.NetworkAdapter{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])), Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Location: asString(doc["Location"]), Location: asString(doc["Location"]),
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"), Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Model: strings.TrimSpace(model),
Vendor: asString(doc["Manufacturer"]), Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: asString(doc["SerialNumber"]), SerialNumber: asString(doc["SerialNumber"]),
PartNumber: asString(doc["PartNumber"]), PartNumber: asString(doc["PartNumber"]),
Status: mapStatus(doc["Status"]), Status: mapStatus(doc["Status"]),
@@ -824,6 +840,15 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
} }
} }
if isMissingOrRawPCIModel(gpu.Model) {
if resolved := pciids.DeviceName(gpu.VendorID, gpu.DeviceID); resolved != "" {
gpu.Model = resolved
}
}
if strings.TrimSpace(gpu.Manufacturer) == "" {
gpu.Manufacturer = pciids.VendorName(gpu.VendorID)
}
return gpu return gpu
} }
@@ -869,6 +894,17 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
if dev.DeviceClass == "" { if dev.DeviceClass == "" {
dev.DeviceClass = "PCIe device" dev.DeviceClass = "PCIe device"
} }
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = pciids.VendorName(dev.VendorID)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
}
return dev return dev
} }
@@ -878,7 +914,7 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
slot = fmt.Sprintf("PCIeFn%d", idx) slot = fmt.Sprintf("PCIeFn%d", idx)
} }
return models.PCIeDevice{ dev := models.PCIeDevice{
Slot: slot, Slot: slot,
BDF: asString(doc["FunctionId"]), BDF: asString(doc["FunctionId"]),
VendorID: asHexOrInt(doc["VendorId"]), VendorID: asHexOrInt(doc["VendorId"]),
@@ -891,6 +927,54 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
MaxLinkWidth: asInt(doc["MaxLinkWidth"]), MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])), MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
} }
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
dev.DeviceClass = resolved
}
}
if strings.TrimSpace(dev.Manufacturer) == "" {
dev.Manufacturer = pciids.VendorName(dev.VendorID)
}
if strings.TrimSpace(dev.PartNumber) == "" {
dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID)
}
return dev
}
func isMissingOrRawPCIModel(model string) bool {
model = strings.TrimSpace(model)
if model == "" {
return true
}
l := strings.ToLower(model)
if l == "unknown" || l == "n/a" || l == "na" || l == "none" {
return true
}
if strings.HasPrefix(l, "0x") && len(l) <= 6 {
return true
}
if len(model) <= 4 {
isHex := true
for _, c := range l {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
isHex = false
break
}
}
if isHex {
return true
}
}
return false
}
func isGenericPCIeClassLabel(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "pcie device", "display", "display controller", "vga", "3d controller", "network", "network controller", "storage", "storage controller", "other", "unknown":
return true
default:
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(v)), "0x")
}
} }
func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool { func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {

View File

@@ -0,0 +1,40 @@
package collector
import (
"strings"
"testing"
)
func TestParseNIC_ResolvesModelFromPCIIDs(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
"VendorId": "0x8086",
"DeviceId": "0x1521",
"Model": "0x1521",
}
nic := parseNIC(doc)
if nic.Model == "" {
t.Fatalf("expected model resolved from pci.ids")
}
if !strings.Contains(strings.ToUpper(nic.Model), "I350") {
t.Fatalf("expected I350 in model, got %q", nic.Model)
}
}
func TestParsePCIeFunction_ResolvesDeviceClassFromPCIIDs(t *testing.T) {
doc := map[string]interface{}{
"Id": "PCIE1",
"VendorId": "0x9005",
"DeviceId": "0x028f",
"ClassCode": "0x010700",
}
dev := parsePCIeFunction(doc, 0)
if dev.DeviceClass == "" || strings.EqualFold(dev.DeviceClass, "PCIe device") {
t.Fatalf("expected device class resolved from pci.ids, got %q", dev.DeviceClass)
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(dev.DeviceClass)), "0x") {
t.Fatalf("expected resolved name instead of raw hex, got %q", dev.DeviceClass)
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/csv" "encoding/csv"
"encoding/json" "encoding/json"
"io" "io"
"strings"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
) )
@@ -34,7 +35,7 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// FRU data // FRU data
for _, fru := range e.result.FRU { for _, fru := range e.result.FRU {
if fru.SerialNumber == "" { if !hasUsableSerial(fru.SerialNumber) {
continue continue
} }
name := fru.ProductName name := fru.ProductName
@@ -53,9 +54,36 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// Hardware data // Hardware data
if e.result.Hardware != nil { if e.result.Hardware != nil {
// Board
if hasUsableSerial(e.result.Hardware.BoardInfo.SerialNumber) {
if err := writer.Write([]string{
e.result.Hardware.BoardInfo.ProductName,
strings.TrimSpace(e.result.Hardware.BoardInfo.SerialNumber),
e.result.Hardware.BoardInfo.Manufacturer,
"Board",
}); err != nil {
return err
}
}
// CPUs
for _, cpu := range e.result.Hardware.CPUs {
if !hasUsableSerial(cpu.SerialNumber) {
continue
}
if err := writer.Write([]string{
cpu.Model,
strings.TrimSpace(cpu.SerialNumber),
"",
"CPU",
}); err != nil {
return err
}
}
// Memory // Memory
for _, mem := range e.result.Hardware.Memory { for _, mem := range e.result.Hardware.Memory {
if mem.SerialNumber == "" { if !hasUsableSerial(mem.SerialNumber) {
continue continue
} }
location := mem.Location location := mem.Location
@@ -64,7 +92,7 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
} }
if err := writer.Write([]string{ if err := writer.Write([]string{
mem.PartNumber, mem.PartNumber,
mem.SerialNumber, strings.TrimSpace(mem.SerialNumber),
mem.Manufacturer, mem.Manufacturer,
location, location,
}); err != nil { }); err != nil {
@@ -74,12 +102,12 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// Storage // Storage
for _, stor := range e.result.Hardware.Storage { for _, stor := range e.result.Hardware.Storage {
if stor.SerialNumber == "" { if !hasUsableSerial(stor.SerialNumber) {
continue continue
} }
if err := writer.Write([]string{ if err := writer.Write([]string{
stor.Model, stor.Model,
stor.SerialNumber, strings.TrimSpace(stor.SerialNumber),
stor.Manufacturer, stor.Manufacturer,
stor.Slot, stor.Slot,
}); err != nil { }); err != nil {
@@ -87,20 +115,88 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
} }
} }
// GPUs
for _, gpu := range e.result.Hardware.GPUs {
if !hasUsableSerial(gpu.SerialNumber) {
continue
}
component := gpu.Model
if component == "" {
component = "GPU"
}
if err := writer.Write([]string{
component,
strings.TrimSpace(gpu.SerialNumber),
gpu.Manufacturer,
gpu.Slot,
}); err != nil {
return err
}
}
// PCIe devices // PCIe devices
for _, pcie := range e.result.Hardware.PCIeDevices { for _, pcie := range e.result.Hardware.PCIeDevices {
if pcie.SerialNumber == "" { if !hasUsableSerial(pcie.SerialNumber) {
continue continue
} }
if err := writer.Write([]string{ if err := writer.Write([]string{
pcie.DeviceClass, pcie.DeviceClass,
pcie.SerialNumber, strings.TrimSpace(pcie.SerialNumber),
pcie.Manufacturer, pcie.Manufacturer,
pcie.Slot, pcie.Slot,
}); err != nil { }); err != nil {
return err return err
} }
} }
// Network adapters
for _, nic := range e.result.Hardware.NetworkAdapters {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
location := nic.Location
if location == "" {
location = nic.Slot
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
nic.Vendor,
location,
}); err != nil {
return err
}
}
// Legacy network cards
for _, nic := range e.result.Hardware.NetworkCards {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
"",
"Network",
}); err != nil {
return err
}
}
// Power supplies
for _, psu := range e.result.Hardware.PowerSupply {
if !hasUsableSerial(psu.SerialNumber) {
continue
}
if err := writer.Write([]string{
psu.Model,
strings.TrimSpace(psu.SerialNumber),
psu.Vendor,
psu.Slot,
}); err != nil {
return err
}
}
} }
return nil return nil
@@ -112,3 +208,16 @@ func (e *Exporter) ExportJSON(w io.Writer) error {
encoder.SetIndent("", " ") encoder.SetIndent("", " ")
return encoder.Encode(e.result) return encoder.Encode(e.result)
} }
func hasUsableSerial(serial string) bool {
s := strings.TrimSpace(serial)
if s == "" {
return false
}
switch strings.ToUpper(s) {
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
return false
default:
return true
}
}

View File

@@ -0,0 +1,79 @@
package exporter
import (
"bytes"
"encoding/csv"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) {
result := &models.AnalysisResult{
FRU: []models.FRUInfo{
{ProductName: "FRU Board", SerialNumber: "FRU-001", Manufacturer: "ACME"},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
ProductName: "X12",
SerialNumber: "BOARD-001",
Manufacturer: "Supermicro",
},
CPUs: []models.CPU{
{Socket: 0, Model: "Xeon", SerialNumber: "CPU-001"},
},
Memory: []models.MemoryDIMM{
{Slot: "DIMM0", PartNumber: "MEM-PN", SerialNumber: "MEM-001", Manufacturer: "Samsung"},
},
Storage: []models.Storage{
{Slot: "U.2-1", Model: "PM9A3", SerialNumber: "SSD-001", Manufacturer: "Samsung"},
},
GPUs: []models.GPU{
{Slot: "GPU1", Model: "H200", SerialNumber: "GPU-001", Manufacturer: "NVIDIA"},
},
PCIeDevices: []models.PCIeDevice{
{Slot: "PCIe1", DeviceClass: "NVSwitch", SerialNumber: "PCIE-001", Manufacturer: "NVIDIA"},
},
NetworkAdapters: []models.NetworkAdapter{
{Slot: "Slot 17", Location: "#CPU0_PCIE4", Model: "I350", SerialNumber: "NIC-001", Vendor: "Intel"},
{Slot: "Slot 18", Model: "skip-na", SerialNumber: "N/A", Vendor: "Intel"},
},
NetworkCards: []models.NIC{
{Model: "Legacy NIC", SerialNumber: "LNIC-001"},
},
PowerSupply: []models.PSU{
{Slot: "PSU0", Model: "GW-CRPS3000LW", SerialNumber: "PSU-001", Vendor: "Great Wall"},
},
},
}
var buf bytes.Buffer
if err := New(result).ExportCSV(&buf); err != nil {
t.Fatalf("ExportCSV failed: %v", err)
}
rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll()
if err != nil {
t.Fatalf("read csv: %v", err)
}
if len(rows) < 2 {
t.Fatalf("expected data rows, got %d", len(rows))
}
serials := make(map[string]bool)
for _, row := range rows[1:] {
if len(row) > 1 {
serials[row[1]] = true
}
}
want := []string{"FRU-001", "BOARD-001", "CPU-001", "MEM-001", "SSD-001", "GPU-001", "PCIE-001", "NIC-001", "LNIC-001", "PSU-001"}
for _, sn := range want {
if !serials[sn] {
t.Fatalf("expected serial %s in csv export", sn)
}
}
if serials["N/A"] {
t.Fatalf("did not expect unusable serial N/A in export")
}
}

View File

@@ -3,12 +3,15 @@ package inspur
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp"
"strings" "strings"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids" "git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
) )
var rawHexPCIDeviceRegex = regexp.MustCompile(`(?i)^0x[0-9a-f]+$`)
// AssetJSON represents the structure of Inspur asset.json file // AssetJSON represents the structure of Inspur asset.json file
type AssetJSON struct { type AssetJSON struct {
VersionInfo []struct { VersionInfo []struct {
@@ -207,8 +210,8 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
VendorID: pcie.VendorId, VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId, DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber), BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
LinkWidth: pcie.NegotiatedLinkWidth, LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed), LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth, MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed), MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
DeviceClass: pcieClassToString(pcie.ClassCode, pcie.SubClassCode), DeviceClass: pcieClassToString(pcie.ClassCode, pcie.SubClassCode),
@@ -225,25 +228,22 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
} }
// Use device name from PCI IDs database if available // Use device name from PCI IDs database if available
if deviceName != "" { if deviceName != "" {
device.DeviceClass = deviceName device.DeviceClass = normalizeModelLabel(deviceName)
} }
config.PCIeDevices = append(config.PCIeDevices, device) config.PCIeDevices = append(config.PCIeDevices, device)
// Extract GPUs (class 3 = display controller) // Extract GPUs (class 3 = display controller)
if pcie.ClassCode == 3 { if pcie.ClassCode == 3 {
gpuModel := deviceName gpuModel := normalizeGPUModel(pcie.VendorId, pcie.DeviceId, deviceName, pcie.ClassCode, pcie.SubClassCode)
if gpuModel == "" {
gpuModel = pcieClassToString(pcie.ClassCode, pcie.SubClassCode)
}
gpu := models.GPU{ gpu := models.GPU{
Slot: pcie.LocString, Slot: pcie.LocString,
Model: gpuModel, Model: gpuModel,
Manufacturer: vendor, Manufacturer: vendor,
VendorID: pcie.VendorId, VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId, DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber), BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
CurrentLinkWidth: pcie.NegotiatedLinkWidth, CurrentLinkWidth: pcie.NegotiatedLinkWidth,
CurrentLinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed), CurrentLinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth, MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed), MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
} }
@@ -260,6 +260,45 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
return config, nil return config, nil
} }
func normalizeModelLabel(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
return strings.Join(strings.Fields(v), " ")
}
func normalizeGPUModel(vendorID, deviceID int, model string, classCode, subClass int) string {
model = normalizeModelLabel(model)
if model == "" || rawHexPCIDeviceRegex.MatchString(model) || isGenericGPUModelLabel(model) {
if pciModel := normalizeModelLabel(pciids.DeviceName(vendorID, deviceID)); pciModel != "" {
model = pciModel
}
}
if model == "" || isGenericGPUModelLabel(model) {
model = pcieClassToString(classCode, subClass)
}
// Last fallback for unknown NVIDIA display devices: expose PCI DeviceID
// instead of generic "3D Controller".
if (model == "" || strings.EqualFold(model, "3D Controller")) && vendorID == 0x10de && deviceID > 0 {
return fmt.Sprintf("0x%04X", deviceID)
}
return model
}
func isGenericGPUModelLabel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case "", "gpu", "display", "display controller", "vga", "3d controller", "other", "unknown":
return true
default:
return false
}
}
func memoryTypeToString(memType int) string { func memoryTypeToString(memType int) string {
switch memType { switch memType {
case 26: case 26:

View File

@@ -0,0 +1,48 @@
package inspur
import "testing"
func TestParseAssetJSON_NVIDIAGPUModelFromPCIIDs(t *testing.T) {
raw := []byte(`{
"VersionInfo": [],
"CpuInfo": [],
"MemInfo": {"MemCommonInfo": [], "DimmInfo": []},
"HddInfo": [],
"PcieInfo": [{
"VendorId": 4318,
"DeviceId": 9019,
"BusNumber": 12,
"DeviceNumber": 0,
"FunctionNumber": 0,
"MaxLinkWidth": 16,
"MaxLinkSpeed": 5,
"NegotiatedLinkWidth": 16,
"CurrentLinkSpeed": 5,
"ClassCode": 3,
"SubClassCode": 2,
"PcieSlot": 11,
"LocString": "#CPU0_PCIE2",
"PartNumber": null,
"SerialNumber": null,
"Mac": []
}]
}`)
hw, err := ParseAssetJSON(raw)
if err != nil {
t.Fatalf("ParseAssetJSON failed: %v", err)
}
if len(hw.GPUs) != 1 {
t.Fatalf("expected 1 GPU, got %d", len(hw.GPUs))
}
if hw.GPUs[0].Model != "GH100 [H200 NVL]" {
t.Fatalf("expected model GH100 [H200 NVL], got %q", hw.GPUs[0].Model)
}
}
func TestNormalizeGPUModel_FallbackToDeviceIDForUnknownNVIDIA(t *testing.T) {
got := normalizeGPUModel(0x10de, 0xbeef, "0xBEEF\t", 3, 2)
if got != "0xBEEF" {
t.Fatalf("expected 0xBEEF, got %q", got)
}
}

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
) )
// ParseComponentLog parses component.log file and extracts detailed hardware info // ParseComponentLog parses component.log file and extracts detailed hardware info
@@ -52,20 +53,20 @@ func ParseComponentLogEvents(content []byte) []models.Event {
// MemoryRESTInfo represents the RESTful Memory info structure // MemoryRESTInfo represents the RESTful Memory info structure
type MemoryRESTInfo struct { type MemoryRESTInfo struct {
MemModules []struct { MemModules []struct {
MemModID int `json:"mem_mod_id"` MemModID int `json:"mem_mod_id"`
ConfigStatus int `json:"config_status"` ConfigStatus int `json:"config_status"`
MemModSlot string `json:"mem_mod_slot"` MemModSlot string `json:"mem_mod_slot"`
MemModStatus int `json:"mem_mod_status"` MemModStatus int `json:"mem_mod_status"`
MemModSize int `json:"mem_mod_size"` MemModSize int `json:"mem_mod_size"`
MemModType string `json:"mem_mod_type"` MemModType string `json:"mem_mod_type"`
MemModTechnology string `json:"mem_mod_technology"` MemModTechnology string `json:"mem_mod_technology"`
MemModFrequency int `json:"mem_mod_frequency"` MemModFrequency int `json:"mem_mod_frequency"`
MemModCurrentFreq int `json:"mem_mod_current_frequency"` MemModCurrentFreq int `json:"mem_mod_current_frequency"`
MemModVendor string `json:"mem_mod_vendor"` MemModVendor string `json:"mem_mod_vendor"`
MemModPartNum string `json:"mem_mod_part_num"` MemModPartNum string `json:"mem_mod_part_num"`
MemModSerial string `json:"mem_mod_serial_num"` MemModSerial string `json:"mem_mod_serial_num"`
MemModRanks int `json:"mem_mod_ranks"` MemModRanks int `json:"mem_mod_ranks"`
Status string `json:"status"` Status string `json:"status"`
} `json:"mem_modules"` } `json:"mem_modules"`
TotalMemoryCount int `json:"total_memory_count"` TotalMemoryCount int `json:"total_memory_count"`
PresentMemoryCount int `json:"present_memory_count"` PresentMemoryCount int `json:"present_memory_count"`
@@ -112,21 +113,21 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
// PSURESTInfo represents the RESTful PSU info structure // PSURESTInfo represents the RESTful PSU info structure
type PSURESTInfo struct { type PSURESTInfo struct {
PowerSupplies []struct { PowerSupplies []struct {
ID int `json:"id"` ID int `json:"id"`
Present int `json:"present"` Present int `json:"present"`
VendorID string `json:"vendor_id"` VendorID string `json:"vendor_id"`
Model string `json:"model"` Model string `json:"model"`
SerialNum string `json:"serial_num"` SerialNum string `json:"serial_num"`
PartNum string `json:"part_num"` PartNum string `json:"part_num"`
FwVer string `json:"fw_ver"` FwVer string `json:"fw_ver"`
InputType string `json:"input_type"` InputType string `json:"input_type"`
Status string `json:"status"` Status string `json:"status"`
RatedPower int `json:"rated_power"` RatedPower int `json:"rated_power"`
PSInPower int `json:"ps_in_power"` PSInPower int `json:"ps_in_power"`
PSOutPower int `json:"ps_out_power"` PSOutPower int `json:"ps_out_power"`
PSInVolt float64 `json:"ps_in_volt"` PSInVolt float64 `json:"ps_in_volt"`
PSOutVolt float64 `json:"ps_out_volt"` PSOutVolt float64 `json:"ps_out_volt"`
PSUMaxTemp int `json:"psu_max_temperature"` PSUMaxTemp int `json:"psu_max_temperature"`
} `json:"power_supplies"` } `json:"power_supplies"`
PresentPowerReading int `json:"present_power_reading"` PresentPowerReading int `json:"present_power_reading"`
} }
@@ -304,17 +305,28 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
} }
} }
model := normalizeModelLabel(adapter.Model)
if model == "" || looksLikeRawDeviceID(model) {
if resolved := normalizeModelLabel(pciids.DeviceName(adapter.VendorID, adapter.DeviceID)); resolved != "" {
model = resolved
}
}
vendor := normalizeModelLabel(adapter.Vendor)
if vendor == "" {
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
}
hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{ hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{
Slot: fmt.Sprintf("Slot %d", adapter.Slot), Slot: fmt.Sprintf("Slot %d", adapter.Slot),
Location: adapter.Location, Location: adapter.Location,
Present: adapter.Present == 1, Present: adapter.Present == 1,
Model: strings.TrimSpace(adapter.Model), Model: model,
Vendor: strings.TrimSpace(adapter.Vendor), Vendor: vendor,
VendorID: adapter.VendorID, VendorID: adapter.VendorID,
DeviceID: adapter.DeviceID, DeviceID: adapter.DeviceID,
SerialNumber: strings.TrimSpace(adapter.SN), SerialNumber: normalizeRedisValue(adapter.SN),
PartNumber: strings.TrimSpace(adapter.PN), PartNumber: normalizeRedisValue(adapter.PN),
Firmware: adapter.FwVer, Firmware: normalizeRedisValue(adapter.FwVer),
PortCount: adapter.PortNum, PortCount: adapter.PortNum,
PortType: adapter.PortType, PortType: adapter.PortType,
MACAddresses: macs, MACAddresses: macs,
@@ -323,6 +335,16 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
} }
} }
var rawDeviceIDLikeRegex = regexp.MustCompile(`(?i)^(?:0x)?[0-9a-f]{3,4}$`)
func looksLikeRawDeviceID(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return true
}
return rawDeviceIDLikeRegex.MatchString(v)
}
func parseMemoryEvents(text string) []models.Event { func parseMemoryEvents(text string) []models.Event {
var events []models.Event var events []models.Event

View File

@@ -0,0 +1,52 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestParseNetworkAdapterInfo_ResolvesModelFromPCIIDsForRawHexModel(t *testing.T) {
text := `RESTful Network Adapter info:
{
"sys_adapters": [
{
"id": 1,
"name": "NIC1",
"Location": "#CPU0_PCIE4",
"present": 1,
"slot": 4,
"vendor_id": 32902,
"device_id": 5409,
"vendor": "",
"model": "0x1521",
"fw_ver": "",
"status": "OK",
"sn": "",
"pn": "",
"port_num": 4,
"port_type": "Base-T",
"ports": []
}
]
}
RESTful fan`
hw := &models.HardwareConfig{}
parseNetworkAdapterInfo(text, hw)
if len(hw.NetworkAdapters) != 1 {
t.Fatalf("expected 1 network adapter, got %d", len(hw.NetworkAdapters))
}
got := hw.NetworkAdapters[0]
if got.Model == "" {
t.Fatalf("expected NIC model resolved from pci.ids, got empty")
}
if !strings.Contains(strings.ToUpper(got.Model), "I350") {
t.Fatalf("expected I350 in model, got %q", got.Model)
}
if got.Vendor == "" {
t.Fatalf("expected NIC vendor resolved from pci.ids")
}
}

View File

@@ -15,7 +15,7 @@ import (
// parserVersion - version of this parser module // parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic! // IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.1.0" const parserVersion = "1.2.1"
func init() { func init() {
parser.Register(&Parser{}) parser.Register(&Parser{})
@@ -125,6 +125,12 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Events = append(result.Events, componentEvents...) result.Events = append(result.Events, componentEvents...)
} }
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
// when text logs miss these fields.
if f := parser.FindFileByName(files, "redis-dump.rdb"); f != nil && result.Hardware != nil {
enrichFromRedisDump(f.Content, result.Hardware)
}
// Parse IDL-like logs (plain and structured JSON logs with embedded IDL messages) // Parse IDL-like logs (plain and structured JSON logs with embedded IDL messages)
idlFiles := parser.FindFileByPattern(files, "/idl.log", "idl_json.log", "run_json.log") idlFiles := parser.FindFileByPattern(files, "/idl.log", "idl_json.log", "run_json.log")
for _, f := range idlFiles { for _, f := range idlFiles {
@@ -199,14 +205,9 @@ func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult
// This supplements data from asset.json with serial numbers, firmware, etc. // This supplements data from asset.json with serial numbers, firmware, etc.
pcieDevicesFromREST := ParsePCIeDevices(content) pcieDevicesFromREST := ParsePCIeDevices(content)
// Merge PCIe data: keep asset.json data but add RESTful data if available // Merge PCIe data: asset.json is the base inventory, RESTful data enriches names/links/serials.
if result.Hardware != nil { if result.Hardware != nil {
// If asset.json didn't have PCIe devices, use RESTful data result.Hardware.PCIeDevices = MergePCIeDevices(result.Hardware.PCIeDevices, pcieDevicesFromREST)
if len(result.Hardware.PCIeDevices) == 0 && len(pcieDevicesFromREST) > 0 {
result.Hardware.PCIeDevices = pcieDevicesFromREST
}
// If we have both, merge them (RESTful data takes precedence for detailed info)
// For now, we keep asset.json data which has more details
} }
// Parse GPU devices and add temperature data from sensors // Parse GPU devices and add temperature data from sensors

View File

@@ -3,36 +3,38 @@ package inspur
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp"
"strings" "strings"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
) )
// PCIeRESTInfo represents the RESTful PCIE Device info structure // PCIeRESTInfo represents the RESTful PCIE Device info structure
type PCIeRESTInfo []struct { type PCIeRESTInfo []struct {
ID int `json:"id"` ID int `json:"id"`
Present int `json:"present"` Present int `json:"present"`
Enable int `json:"enable"` Enable int `json:"enable"`
Status int `json:"status"` Status int `json:"status"`
VendorID int `json:"vendor_id"` VendorID int `json:"vendor_id"`
VendorName string `json:"vendor_name"` VendorName string `json:"vendor_name"`
DeviceID int `json:"device_id"` DeviceID int `json:"device_id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
BusNum int `json:"bus_num"` BusNum int `json:"bus_num"`
DevNum int `json:"dev_num"` DevNum int `json:"dev_num"`
FuncNum int `json:"func_num"` FuncNum int `json:"func_num"`
MaxLinkWidth int `json:"max_link_width"` MaxLinkWidth int `json:"max_link_width"`
MaxLinkSpeed int `json:"max_link_speed"` MaxLinkSpeed int `json:"max_link_speed"`
CurrentLinkWidth int `json:"current_link_width"` CurrentLinkWidth int `json:"current_link_width"`
CurrentLinkSpeed int `json:"current_link_speed"` CurrentLinkSpeed int `json:"current_link_speed"`
Slot int `json:"slot"` Slot int `json:"slot"`
Location string `json:"location"` Location string `json:"location"`
DeviceLocator string `json:"DeviceLocator"` DeviceLocator string `json:"DeviceLocator"`
DevType int `json:"dev_type"` DevType int `json:"dev_type"`
DevSubtype int `json:"dev_subtype"` DevSubtype int `json:"dev_subtype"`
PartNum string `json:"part_num"` PartNum string `json:"part_num"`
SerialNum string `json:"serial_num"` SerialNum string `json:"serial_num"`
FwVer string `json:"fw_ver"` FwVer string `json:"fw_ver"`
} }
// ParsePCIeDevices parses RESTful PCIE Device info from devicefrusdr.log // ParsePCIeDevices parses RESTful PCIE Device info from devicefrusdr.log
@@ -73,9 +75,27 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice {
// Determine device class based on dev_type // Determine device class based on dev_type
deviceClass := determineDeviceClass(pcie.DevType, pcie.DevSubtype, pcie.DeviceName) deviceClass := determineDeviceClass(pcie.DevType, pcie.DevSubtype, pcie.DeviceName)
_, pciDeviceName := pciids.DeviceInfo(pcie.VendorID, pcie.DeviceID)
// Build BDF string // Build BDF string in canonical form (bb:dd.f)
bdf := fmt.Sprintf("%04x/%02x/%02x/%02x", 0, pcie.BusNum, pcie.DevNum, pcie.FuncNum) bdf := formatBDF(pcie.BusNum, pcie.DevNum, pcie.FuncNum)
partNumber := strings.TrimSpace(pcie.PartNum)
if partNumber == "" {
partNumber = sanitizePCIeDeviceName(pcie.DeviceName)
}
if partNumber == "" {
partNumber = normalizeModelLabel(pciDeviceName)
}
if isGenericPCIeClass(deviceClass) {
if resolved := normalizeModelLabel(pciDeviceName); resolved != "" {
deviceClass = resolved
}
}
manufacturer := strings.TrimSpace(pcie.VendorName)
if manufacturer == "" {
manufacturer = normalizeModelLabel(pciids.VendorName(pcie.VendorID))
}
device := models.PCIeDevice{ device := models.PCIeDevice{
Slot: pcie.Location, Slot: pcie.Location,
@@ -83,12 +103,12 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice {
DeviceID: pcie.DeviceID, DeviceID: pcie.DeviceID,
BDF: bdf, BDF: bdf,
DeviceClass: deviceClass, DeviceClass: deviceClass,
Manufacturer: pcie.VendorName, Manufacturer: manufacturer,
LinkWidth: pcie.CurrentLinkWidth, LinkWidth: pcie.CurrentLinkWidth,
LinkSpeed: currentSpeed, LinkSpeed: currentSpeed,
MaxLinkWidth: pcie.MaxLinkWidth, MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: maxSpeed, MaxLinkSpeed: maxSpeed,
PartNumber: strings.TrimSpace(pcie.PartNum), PartNumber: partNumber,
SerialNumber: strings.TrimSpace(pcie.SerialNum), SerialNumber: strings.TrimSpace(pcie.SerialNum),
} }
@@ -98,6 +118,149 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice {
return devices return devices
} }
var rawHexDeviceNameRegex = regexp.MustCompile(`(?i)^0x[0-9a-f]+$`)
func sanitizePCIeDeviceName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
if strings.EqualFold(name, "N/A") {
return ""
}
if rawHexDeviceNameRegex.MatchString(name) {
return ""
}
return name
}
// MergePCIeDevices enriches base devices (from asset.json) with detailed RESTful PCIe data.
// Matching is done by BDF first, then by slot fallback.
func MergePCIeDevices(base []models.PCIeDevice, rest []models.PCIeDevice) []models.PCIeDevice {
if len(rest) == 0 {
return base
}
if len(base) == 0 {
return append([]models.PCIeDevice(nil), rest...)
}
type ref struct {
index int
}
byBDF := make(map[string]ref, len(base))
bySlot := make(map[string]ref, len(base))
for i := range base {
bdf := normalizePCIeBDF(base[i].BDF)
if bdf != "" {
byBDF[bdf] = ref{index: i}
}
slot := strings.ToLower(strings.TrimSpace(base[i].Slot))
if slot != "" {
bySlot[slot] = ref{index: i}
}
}
for _, detailed := range rest {
idx := -1
if bdf := normalizePCIeBDF(detailed.BDF); bdf != "" {
if found, ok := byBDF[bdf]; ok {
idx = found.index
}
}
if idx == -1 {
slot := strings.ToLower(strings.TrimSpace(detailed.Slot))
if slot != "" {
if found, ok := bySlot[slot]; ok {
idx = found.index
}
}
}
if idx == -1 {
base = append(base, detailed)
newIdx := len(base) - 1
if bdf := normalizePCIeBDF(detailed.BDF); bdf != "" {
byBDF[bdf] = ref{index: newIdx}
}
if slot := strings.ToLower(strings.TrimSpace(detailed.Slot)); slot != "" {
bySlot[slot] = ref{index: newIdx}
}
continue
}
enrichPCIeDevice(&base[idx], detailed)
}
return base
}
func enrichPCIeDevice(dst *models.PCIeDevice, src models.PCIeDevice) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
if strings.TrimSpace(dst.BDF) == "" {
dst.BDF = src.BDF
}
if dst.VendorID == 0 {
dst.VendorID = src.VendorID
}
if dst.DeviceID == 0 {
dst.DeviceID = src.DeviceID
}
if strings.TrimSpace(dst.Manufacturer) == "" {
dst.Manufacturer = src.Manufacturer
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.LinkSpeed) == "" || strings.EqualFold(strings.TrimSpace(dst.LinkSpeed), "unknown") {
dst.LinkSpeed = src.LinkSpeed
}
if strings.TrimSpace(dst.MaxLinkSpeed) == "" || strings.EqualFold(strings.TrimSpace(dst.MaxLinkSpeed), "unknown") {
dst.MaxLinkSpeed = src.MaxLinkSpeed
}
if dst.LinkWidth == 0 {
dst.LinkWidth = src.LinkWidth
}
if dst.MaxLinkWidth == 0 {
dst.MaxLinkWidth = src.MaxLinkWidth
}
if isGenericPCIeClass(dst.DeviceClass) && !isGenericPCIeClass(src.DeviceClass) {
dst.DeviceClass = src.DeviceClass
}
}
func normalizePCIeBDF(bdf string) string {
bdf = strings.TrimSpace(strings.ToLower(bdf))
if bdf == "" {
return ""
}
if strings.Contains(bdf, "/") {
parts := strings.Split(bdf, "/")
if len(parts) == 4 {
return fmt.Sprintf("%s:%s.%s", parts[1], parts[2], parts[3])
}
}
return bdf
}
func isGenericPCIeClass(class string) bool {
switch strings.ToLower(strings.TrimSpace(class)) {
case "", "unknown", "other", "bridge", "network", "storage", "sas", "sata", "display", "vga", "3d controller", "serial bus":
return true
default:
return false
}
}
// determineDeviceClass maps device type to human-readable class // determineDeviceClass maps device type to human-readable class
func determineDeviceClass(devType, devSubtype int, deviceName string) string { func determineDeviceClass(devType, devSubtype int, deviceName string) string {
// dev_type mapping: // dev_type mapping:

View File

@@ -0,0 +1,77 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestParsePCIeDevices_UsesDeviceNameAsModelWhenPartNumberMissing(t *testing.T) {
content := []byte(`RESTful PCIE Device info:
[{"id":1,"present":1,"vendor_id":32902,"vendor_name":"Intel","device_id":5409,"device_name":"I350T4V2","bus_num":69,"dev_num":0,"func_num":0,"max_link_width":4,"max_link_speed":2,"current_link_width":4,"current_link_speed":2,"location":"#CPU0_PCIE4","dev_type":2,"dev_subtype":0,"part_num":"","serial_num":"","fw_ver":""}]
BMC sdr Info:`)
devices := ParsePCIeDevices(content)
if len(devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(devices))
}
if devices[0].PartNumber != "I350T4V2" {
t.Fatalf("expected part/model I350T4V2, got %q", devices[0].PartNumber)
}
if devices[0].BDF != "45:00.0" {
t.Fatalf("expected BDF 45:00.0, got %q", devices[0].BDF)
}
}
func TestMergePCIeDevices_EnrichesGenericAssetEntry(t *testing.T) {
base := []models.PCIeDevice{
{
Slot: "#CPU1_PCIE9",
BDF: "98:00.0",
VendorID: 0x9005,
DeviceID: 0x028f,
DeviceClass: "SAS",
Manufacturer: "Adaptec / Microsemi",
},
}
rest := []models.PCIeDevice{
{
Slot: "#CPU1_PCIE9",
BDF: "98:00.0",
VendorID: 0x9005,
DeviceID: 0x028f,
DeviceClass: "Storage Controller",
Manufacturer: "Microchip",
PartNumber: "PM8222-SHBA",
},
}
got := MergePCIeDevices(base, rest)
if len(got) != 1 {
t.Fatalf("expected 1 merged device, got %d", len(got))
}
if got[0].PartNumber != "PM8222-SHBA" {
t.Fatalf("expected merged part number PM8222-SHBA, got %q", got[0].PartNumber)
}
}
func TestParsePCIeDevices_ResolvesModelFromPCIIDsWhenDeviceNameIsRawHex(t *testing.T) {
content := []byte(`RESTful PCIE Device info:
[{"id":5,"present":1,"vendor_id":36869,"vendor_name":"","device_id":655,"device_name":"0x028F","bus_num":152,"dev_num":0,"func_num":0,"max_link_width":8,"max_link_speed":3,"current_link_width":8,"current_link_speed":3,"location":"#CPU1_PCIE9","dev_type":1,"dev_subtype":7,"part_num":"","serial_num":"","fw_ver":""}]
BMC sdr Info:`)
devices := ParsePCIeDevices(content)
if len(devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(devices))
}
if devices[0].PartNumber == "" {
t.Fatalf("expected part number resolved from pci.ids, got empty")
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(devices[0].PartNumber)), "0x") {
t.Fatalf("expected resolved name instead of raw hex, got %q", devices[0].PartNumber)
}
if devices[0].Manufacturer == "" {
t.Fatalf("expected manufacturer resolved from pci.ids")
}
}

View File

@@ -0,0 +1,559 @@
package inspur
import (
"encoding/hex"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"git.mchus.pro/mchus/logpile/internal/models"
)
var (
reRedisGPUKey = regexp.MustCompile(`GPUInfo:REDIS_GPUINFO_T([0-9]+):([A-Za-z0-9_]+)`)
reRedisNICKey = regexp.MustCompile(`RedisNicInfo:redis_nic_info_t:stNicDeviceInfo([0-9]+):([A-Za-z0-9_]+)`)
reRedisRAIDSerial = regexp.MustCompile(`RAIDMSCCInfo:redis_pcie_mscc_raid_info_t([0-9]+):RAIDInfo:SerialNum`)
reRedisPCIESNPN = regexp.MustCompile(`AssetInfoPCIE:SNPN([0-9]+):(SN|PN)`)
)
type redisGPUSnapshot struct {
ByIndex map[int]map[string]string
}
type redisNICSnapshot struct {
ByIndex map[int]map[string]string
}
type redisPCIESerialSnapshot struct {
ByPart map[string]string
}
func enrichFromRedisDump(content []byte, hw *models.HardwareConfig) {
if hw == nil || len(content) == 0 {
return
}
gpuSnap := parseRedisGPUSnapshot(content)
nicSnap := parseRedisNICSnapshot(content)
raidSerials := parseRedisRAIDSerials(content)
pcieSnap := parseRedisPCIESerialSnapshot(content)
applyRedisGPUEnrichment(hw, gpuSnap)
applyRedisNICEnrichment(hw, nicSnap)
applyRedisPCIESNPNEnrichment(hw, pcieSnap)
applyRedisPCIeEnrichment(hw, raidSerials)
}
func parseRedisRAIDSerials(content []byte) []string {
matches := reRedisRAIDSerial.FindAllSubmatchIndex(content, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]bool, len(matches))
serials := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) < 4 {
continue
}
value := normalizeRedisValue(extractRedisCandidateValue(content, m[1]))
if value == "" || seen[value] {
continue
}
seen[value] = true
serials = append(serials, value)
}
return serials
}
func parseRedisPCIESerialSnapshot(content []byte) redisPCIESerialSnapshot {
type rec struct {
PN string
SN string
}
tmp := make(map[int]rec)
matches := reRedisPCIESNPN.FindAllSubmatchIndex(content, -1)
for _, m := range matches {
if len(m) < 6 {
continue
}
idxStr := string(content[m[2]:m[3]])
field := string(content[m[4]:m[5]])
idx, err := strconv.Atoi(idxStr)
if err != nil {
continue
}
value := normalizeRedisValue(extractRedisCandidateValue(content, m[1]))
if value == "" {
continue
}
r := tmp[idx]
if field == "PN" {
r.PN = value
} else if field == "SN" {
r.SN = value
}
tmp[idx] = r
}
out := redisPCIESerialSnapshot{ByPart: make(map[string]string)}
for _, r := range tmp {
pn := normalizeRedisValue(r.PN)
sn := normalizeRedisValue(r.SN)
if pn == "" || sn == "" {
continue
}
out.ByPart[strings.ToLower(strings.TrimSpace(pn))] = sn
}
return out
}
func parseRedisGPUSnapshot(content []byte) redisGPUSnapshot {
snap := redisGPUSnapshot{ByIndex: make(map[int]map[string]string)}
matches := reRedisGPUKey.FindAllSubmatchIndex(content, -1)
for _, m := range matches {
if len(m) < 6 {
continue
}
idxStr := string(content[m[2]:m[3]])
field := string(content[m[4]:m[5]])
idx, err := strconv.Atoi(idxStr)
if err != nil {
continue
}
value := extractRedisInlineValue(content, m[1])
if value == "" {
continue
}
byField, ok := snap.ByIndex[idx]
if !ok {
byField = make(map[string]string)
snap.ByIndex[idx] = byField
}
byField[field] = value
}
return snap
}
func parseRedisNICSnapshot(content []byte) redisNICSnapshot {
snap := redisNICSnapshot{ByIndex: make(map[int]map[string]string)}
matches := reRedisNICKey.FindAllSubmatchIndex(content, -1)
for _, m := range matches {
if len(m) < 6 {
continue
}
idxStr := string(content[m[2]:m[3]])
field := string(content[m[4]:m[5]])
idx, err := strconv.Atoi(idxStr)
if err != nil {
continue
}
value := extractRedisInlineValue(content, m[1])
if value == "" {
continue
}
byField, ok := snap.ByIndex[idx]
if !ok {
byField = make(map[string]string)
snap.ByIndex[idx] = byField
}
byField[field] = value
}
return snap
}
func extractRedisInlineValue(content []byte, start int) string {
if start < 0 || start >= len(content) {
return ""
}
i := start
for i < len(content) && content[i] <= 0x20 {
i++
}
if i >= len(content) {
return ""
}
j := i
for j < len(content) {
c := content[j]
if c == 0 || c < 0x20 || c > 0x7e {
break
}
j++
}
if j <= i {
return ""
}
raw := strings.TrimSpace(string(content[i:j]))
if raw == "" {
return ""
}
decoded := maybeDecodeHexString(raw)
if decoded != "" {
return decoded
}
return raw
}
func extractRedisCandidateValue(content []byte, start int) string {
// Fast-path for simple inline string values.
if v := extractRedisInlineValue(content, start); normalizeRedisValue(v) != "" {
return v
}
if start < 0 || start >= len(content) {
return ""
}
end := start + 256
if end > len(content) {
end = len(content)
}
window := content[start:end]
for _, token := range splitAlphaNumTokens(window) {
if len(token) < 6 {
continue
}
lower := strings.ToLower(token)
if strings.Contains(lower, "redis") || strings.Contains(lower, "sensor") || strings.Contains(lower, "fullsdr") {
continue
}
if decoded := maybeDecodeHexString(token); normalizeRedisValue(decoded) != "" {
return decoded
}
if normalizeRedisValue(token) != "" {
return token
}
}
return ""
}
func splitAlphaNumTokens(b []byte) []string {
var out []string
start := -1
for i := 0; i < len(b); i++ {
c := rune(b[i])
if unicode.IsLetter(c) || unicode.IsDigit(c) {
if start == -1 {
start = i
}
continue
}
if start != -1 {
out = append(out, string(b[start:i]))
start = -1
}
}
if start != -1 {
out = append(out, string(b[start:]))
}
return out
}
func maybeDecodeHexString(s string) string {
if len(s) < 8 || len(s)%2 != 0 {
return ""
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return ""
}
}
b, err := hex.DecodeString(s)
if err != nil {
return ""
}
decoded := strings.TrimSpace(strings.TrimRight(string(b), "\x00"))
if decoded == "" {
return ""
}
for _, c := range decoded {
if c < 0x20 || c > 0x7e {
return ""
}
}
return decoded
}
func applyRedisGPUEnrichment(hw *models.HardwareConfig, snap redisGPUSnapshot) {
if len(hw.GPUs) == 0 || len(snap.ByIndex) == 0 {
return
}
type redisGPU struct {
Index int
Data map[string]string
}
redisGPUs := make([]redisGPU, 0, len(snap.ByIndex))
for idx, data := range snap.ByIndex {
if data == nil {
continue
}
if data["NV_GPU_SerialNumber"] == "" && data["NV_GPU_FWVersion"] == "" && data["NV_GPU_UUID"] == "" {
continue
}
redisGPUs = append(redisGPUs, redisGPU{Index: idx, Data: data})
}
if len(redisGPUs) == 0 {
return
}
sort.Slice(redisGPUs, func(i, j int) bool { return redisGPUs[i].Index < redisGPUs[j].Index })
target := make([]*models.GPU, 0, len(hw.GPUs))
for i := range hw.GPUs {
gpu := &hw.GPUs[i]
if isNVIDIAGPU(gpu) {
target = append(target, gpu)
}
}
if len(target) == 0 || len(target) != len(redisGPUs) {
return
}
sort.Slice(target, func(i, j int) bool {
left := strings.TrimSpace(target[i].BDF)
right := strings.TrimSpace(target[j].BDF)
if left != "" && right != "" {
return left < right
}
return strings.TrimSpace(target[i].Slot) < strings.TrimSpace(target[j].Slot)
})
for i := range target {
applyRedisGPUFields(target[i], redisGPUs[i].Data)
}
}
func isNVIDIAGPU(gpu *models.GPU) bool {
if gpu == nil {
return false
}
if gpu.VendorID == 0x10de {
return true
}
man := strings.ToLower(strings.TrimSpace(gpu.Manufacturer))
return strings.Contains(man, "nvidia")
}
func applyRedisGPUFields(gpu *models.GPU, fields map[string]string) {
if gpu == nil || fields == nil {
return
}
if serial := normalizeRedisValue(fields["NV_GPU_SerialNumber"]); serial != "" && isMissingGPUField(gpu.SerialNumber) {
gpu.SerialNumber = serial
}
if fw := normalizeRedisValue(fields["NV_GPU_FWVersion"]); fw != "" && isMissingGPUField(gpu.Firmware) {
gpu.Firmware = fw
}
if uuid := normalizeRedisValue(fields["NV_GPU_UUID"]); uuid != "" && isMissingGPUField(gpu.UUID) {
gpu.UUID = uuid
}
if part := normalizeRedisValue(fields["NVGPUPartNumber"]); part != "" && isMissingGPUField(gpu.PartNumber) {
gpu.PartNumber = part
}
if model := normalizeRedisValue(fields["NVGPUMarketingName"]); model != "" && isGenericGPUModel(gpu.Model) {
gpu.Model = model
}
if gpu.ClockSpeed == 0 {
if mhz, ok := parseIntField(fields["OperatingSpeedMHz"]); ok {
gpu.ClockSpeed = mhz
}
}
if gpu.Power == 0 {
if pwr, ok := parseIntField(fields["GPUTotalPower"]); ok {
gpu.Power = pwr
}
}
if gpu.Temperature == 0 {
if temp, ok := parseIntField(fields["Temp"]); ok {
gpu.Temperature = temp
}
}
if gpu.MemTemperature == 0 {
if temp, ok := parseIntField(fields["MemTemp"]); ok {
gpu.MemTemperature = temp
}
}
}
func parseIntField(v string) (int, bool) {
v = normalizeRedisValue(v)
if v == "" {
return 0, false
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return n, true
}
func normalizeRedisValue(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
l := strings.ToLower(v)
if l == "n/a" || l == "na" || l == "null" || l == "unknown" {
return ""
}
return v
}
func isMissingGPUField(v string) bool {
return normalizeRedisValue(v) == ""
}
func isGenericGPUModel(model string) bool {
m := strings.ToLower(strings.TrimSpace(model))
switch m {
case "", "unknown", "display", "display controller", "3d controller", "vga", "gpu":
return true
default:
return false
}
}
func applyRedisNICEnrichment(hw *models.HardwareConfig, snap redisNICSnapshot) {
if len(hw.NetworkAdapters) == 0 || len(snap.ByIndex) == 0 {
return
}
type redisNIC struct {
Index int
Data map[string]string
}
redisNICs := make([]redisNIC, 0, len(snap.ByIndex))
for idx, data := range snap.ByIndex {
if data == nil {
continue
}
if normalizeRedisValue(data["FWVersion"]) == "" {
continue
}
redisNICs = append(redisNICs, redisNIC{Index: idx, Data: data})
}
if len(redisNICs) == 0 {
return
}
sort.Slice(redisNICs, func(i, j int) bool { return redisNICs[i].Index < redisNICs[j].Index })
target := make([]*models.NetworkAdapter, 0, len(hw.NetworkAdapters))
for i := range hw.NetworkAdapters {
nic := &hw.NetworkAdapters[i]
if nic.Present {
target = append(target, nic)
}
}
if len(target) == 0 {
return
}
sort.Slice(target, func(i, j int) bool {
left := strings.TrimSpace(target[i].Location)
right := strings.TrimSpace(target[j].Location)
if left != "" && right != "" {
return left < right
}
return strings.TrimSpace(target[i].Slot) < strings.TrimSpace(target[j].Slot)
})
limit := len(target)
if len(redisNICs) < limit {
limit = len(redisNICs)
}
for i := 0; i < limit; i++ {
nic := target[i]
data := redisNICs[i].Data
if fw := normalizeRedisValue(data["FWVersion"]); fw != "" && normalizeRedisValue(nic.Firmware) == "" {
nic.Firmware = fw
}
if serial := normalizeRedisValue(data["SerialNum"]); serial != "" && normalizeRedisValue(nic.SerialNumber) == "" {
nic.SerialNumber = serial
}
if part := normalizeRedisValue(data["PartNum"]); part != "" && normalizeRedisValue(nic.PartNumber) == "" {
nic.PartNumber = part
}
}
}
func applyRedisPCIeEnrichment(hw *models.HardwareConfig, raidSerials []string) {
if hw == nil || len(hw.PCIeDevices) == 0 || len(raidSerials) == 0 {
return
}
target := make([]*models.PCIeDevice, 0, len(hw.PCIeDevices))
for i := range hw.PCIeDevices {
dev := &hw.PCIeDevices[i]
if normalizeRedisValue(dev.SerialNumber) != "" {
continue
}
class := strings.ToLower(strings.TrimSpace(dev.DeviceClass))
part := strings.ToLower(strings.TrimSpace(dev.PartNumber))
if strings.Contains(class, "raid") || strings.Contains(class, "sas") || strings.Contains(class, "storage") ||
strings.Contains(part, "raid") || strings.Contains(part, "sas") || strings.Contains(part, "hba") {
target = append(target, dev)
}
}
if len(target) == 0 {
return
}
sort.Slice(target, func(i, j int) bool {
left := strings.TrimSpace(target[i].BDF)
right := strings.TrimSpace(target[j].BDF)
if left != "" && right != "" {
return left < right
}
return strings.TrimSpace(target[i].Slot) < strings.TrimSpace(target[j].Slot)
})
limit := len(target)
if len(raidSerials) < limit {
limit = len(raidSerials)
}
for i := 0; i < limit; i++ {
target[i].SerialNumber = raidSerials[i]
}
}
func applyRedisPCIESNPNEnrichment(hw *models.HardwareConfig, snap redisPCIESerialSnapshot) {
if hw == nil || len(hw.PCIeDevices) == 0 || len(snap.ByPart) == 0 {
return
}
for i := range hw.PCIeDevices {
dev := &hw.PCIeDevices[i]
if normalizeRedisValue(dev.SerialNumber) != "" {
continue
}
part := strings.ToLower(strings.TrimSpace(dev.PartNumber))
if part == "" {
continue
}
if serial := normalizeRedisValue(snap.ByPart[part]); serial != "" {
dev.SerialNumber = serial
}
}
}

View File

@@ -0,0 +1,144 @@
package inspur
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestExtractRedisInlineValue_DecodesHexEncodedString(t *testing.T) {
data := []byte("RedisNicInfo:redis_nic_info_t:stNicDeviceInfo0:FWVersion 32362e34332e32353636000000000000\x00tail")
key := []byte("RedisNicInfo:redis_nic_info_t:stNicDeviceInfo0:FWVersion")
pos := indexBytes(data, key)
if pos < 0 {
t.Fatal("key not found")
}
got := extractRedisInlineValue(data, pos+len(key))
if got != "26.43.2566" {
t.Fatalf("expected decoded fw 26.43.2566, got %q", got)
}
}
func TestApplyRedisGPUEnrichment_FillsSerialFirmwareUUID(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#CPU0_PCIE2", BDF: "0c:00.0", VendorID: 0x10de, Model: "3D Controller"},
{Slot: "#CPU0_PCIE1", BDF: "58:00.0", VendorID: 0x10de, Model: "3D Controller"},
},
}
snap := redisGPUSnapshot{
ByIndex: map[int]map[string]string{
1: {
"NV_GPU_SerialNumber": "1321125009572",
"NV_GPU_FWVersion": "96.00.B7.00.02",
"NV_GPU_UUID": "GPU-AAA",
},
2: {
"NV_GPU_SerialNumber": "1321125010420",
"NV_GPU_FWVersion": "96.00.B7.00.02",
"NV_GPU_UUID": "GPU-BBB",
},
},
}
applyRedisGPUEnrichment(hw, snap)
if hw.GPUs[0].SerialNumber != "1321125009572" || hw.GPUs[0].Firmware != "96.00.B7.00.02" || hw.GPUs[0].UUID != "GPU-AAA" {
t.Fatalf("unexpected gpu0 enrichment: %+v", hw.GPUs[0])
}
if hw.GPUs[1].SerialNumber != "1321125010420" || hw.GPUs[1].Firmware != "96.00.B7.00.02" || hw.GPUs[1].UUID != "GPU-BBB" {
t.Fatalf("unexpected gpu1 enrichment: %+v", hw.GPUs[1])
}
}
func TestApplyRedisGPUEnrichment_SkipsOnCountMismatch(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#CPU0_PCIE2", BDF: "0c:00.0", VendorID: 0x10de, Model: "3D Controller"},
},
}
snap := redisGPUSnapshot{
ByIndex: map[int]map[string]string{
1: {"NV_GPU_SerialNumber": "1321125009572"},
2: {"NV_GPU_SerialNumber": "1321125010420"},
},
}
applyRedisGPUEnrichment(hw, snap)
if hw.GPUs[0].SerialNumber != "" {
t.Fatalf("expected no enrichment on count mismatch, got %q", hw.GPUs[0].SerialNumber)
}
}
func TestParseRedisRAIDSerials_DecodesHexSerial(t *testing.T) {
raw := []byte("RAIDMSCCInfo:redis_pcie_mscc_raid_info_t0:RAIDInfo:SerialNum\x80%@`5341523531314532 \x00tail")
got := parseRedisRAIDSerials(raw)
if len(got) != 1 {
t.Fatalf("expected 1 raid serial, got %d", len(got))
}
if got[0] != "SAR511E2" {
t.Fatalf("expected decoded serial SAR511E2, got %q", got[0])
}
}
func TestApplyRedisPCIeEnrichment_FillsStorageControllerSerial(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{Slot: "#CPU1_PCIE9", BDF: "98:00.0", DeviceClass: "Smart Storage PQI SAS", PartNumber: "PM8222-SHBA"},
{Slot: "#CPU0_PCIE3", BDF: "32:00.0", DeviceClass: "Fibre Channel", PartNumber: "LPE32002"},
},
}
applyRedisPCIeEnrichment(hw, []string{"SAR511E2"})
if hw.PCIeDevices[0].SerialNumber != "SAR511E2" {
t.Fatalf("expected PM8222 serial SAR511E2, got %q", hw.PCIeDevices[0].SerialNumber)
}
if hw.PCIeDevices[1].SerialNumber != "" {
t.Fatalf("expected non-storage device serial untouched, got %q", hw.PCIeDevices[1].SerialNumber)
}
}
func TestParseRedisPCIESerialSnapshot_MapsPNToSN(t *testing.T) {
raw := []byte("" +
"AssetInfoPCIE:SNPN9:PN PM8222-SHBA\x00" +
"AssetInfoPCIE:SNPN9:SN SAR511E2\x00")
snap := parseRedisPCIESerialSnapshot(raw)
got := snap.ByPart["pm8222-shba"]
if got != "SAR511E2" {
t.Fatalf("expected SN SAR511E2 for PM8222-SHBA, got %q", got)
}
}
func TestApplyRedisPCIESNPNEnrichment_FillsByPartNumber(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{Slot: "#CPU1_PCIE9", PartNumber: "PM8222-SHBA"},
},
}
snap := redisPCIESerialSnapshot{ByPart: map[string]string{"pm8222-shba": "SAR511E2"}}
applyRedisPCIESNPNEnrichment(hw, snap)
if hw.PCIeDevices[0].SerialNumber != "SAR511E2" {
t.Fatalf("expected serial SAR511E2, got %q", hw.PCIeDevices[0].SerialNumber)
}
}
func indexBytes(haystack, needle []byte) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := 0; j < len(needle); j++ {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}

41507
internal/parser/vendors/pciids/pci.ids vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,27 @@
package pciids package pciids
import ( import (
"bufio"
_ "embed"
"fmt" "fmt"
"os"
"strconv"
"strings" "strings"
"sync"
)
var (
//go:embed pci.ids
embeddedPCIIDs string
loadOnce sync.Once
vendors map[int]string
devices map[string]string
) )
// VendorName returns vendor name by PCI Vendor ID // VendorName returns vendor name by PCI Vendor ID
func VendorName(vendorID int) string { func VendorName(vendorID int) string {
loadPCIIDs()
if name, ok := vendors[vendorID]; ok { if name, ok := vendors[vendorID]; ok {
return name return name
} }
@@ -15,6 +30,7 @@ func VendorName(vendorID int) string {
// DeviceName returns device name by Vendor ID and Device ID // DeviceName returns device name by Vendor ID and Device ID
func DeviceName(vendorID, deviceID int) string { func DeviceName(vendorID, deviceID int) string {
loadPCIIDs()
key := fmt.Sprintf("%04x:%04x", vendorID, deviceID) key := fmt.Sprintf("%04x:%04x", vendorID, deviceID)
if name, ok := devices[key]; ok { if name, ok := devices[key]; ok {
return name return name
@@ -46,7 +62,6 @@ func VendorNameFromString(s string) string {
} else if c >= 'a' && c <= 'f' { } else if c >= 'a' && c <= 'f' {
id = id*16 + int(c-'a'+10) id = id*16 + int(c-'a'+10)
} else { } else {
// Not a valid hex string, return original
return "" return ""
} }
} }
@@ -54,124 +69,99 @@ func VendorNameFromString(s string) string {
return VendorName(id) return VendorName(id)
} }
// Common PCI Vendor IDs func loadPCIIDs() {
// Source: https://pci-ids.ucw.cz/ loadOnce.Do(func() {
var vendors = map[int]string{ vendors = make(map[int]string)
// Storage controllers and SSDs devices = make(map[string]string)
0x1E0F: "KIOXIA",
0x144D: "Samsung Electronics",
0x1C5C: "SK Hynix",
0x15B7: "SanDisk (Western Digital)",
0x1179: "Toshiba",
0x8086: "Intel",
0x1344: "Micron Technology",
0x126F: "Silicon Motion",
0x1987: "Phison Electronics",
0x1CC1: "ADATA Technology",
0x2646: "Kingston Technology",
0x1E95: "Solid State Storage Technology",
0x025E: "Solidigm",
0x1D97: "Shenzhen Longsys Electronics",
0x1E4B: "MAXIO Technology",
// Network adapters parsePCIIDs(strings.NewReader(embeddedPCIIDs), vendors, devices)
0x15B3: "Mellanox Technologies",
0x14E4: "Broadcom",
0x10EC: "Realtek Semiconductor",
0x1077: "QLogic",
0x19A2: "Emulex",
0x1137: "Cisco Systems",
0x1924: "Solarflare Communications",
0x177D: "Cavium",
0x1D6A: "Aquantia",
0x1FC9: "Tehuti Networks",
0x18D4: "Chelsio Communications",
// GPU / Graphics for _, path := range candidatePCIIDsPaths() {
0x10DE: "NVIDIA", f, err := os.Open(path)
0x1002: "AMD/ATI", if err != nil {
0x102B: "Matrox Electronics", continue
0x1A03: "ASPEED Technology", }
parsePCIIDs(f, vendors, devices)
// Storage controllers (RAID/HBA) _ = f.Close()
0x1000: "LSI Logic / Broadcom", }
0x9005: "Adaptec / Microsemi", })
0x1028: "Dell",
0x103C: "Hewlett-Packard",
0x17D3: "Areca Technology",
0x1CC4: "Union Memory",
// Server vendors
0x1014: "IBM",
0x15D9: "Supermicro",
0x8088: "Inspur",
// Other common
0x1022: "AMD",
0x1106: "VIA Technologies",
0x10B5: "PLX Technology",
0x1B21: "ASMedia Technology",
0x1B4B: "Marvell Technology",
0x197B: "JMicron Technology",
} }
// Device IDs (vendor:device -> name) func candidatePCIIDsPaths() []string {
var devices = map[string]string{ paths := []string{
// NVIDIA GPUs (0x10DE) "pci.ids",
"10de:26b9": "L40S 48GB", "/usr/share/hwdata/pci.ids",
"10de:26b1": "L40 48GB", "/usr/share/misc/pci.ids",
"10de:2684": "RTX 4090", "/opt/homebrew/share/pciids/pci.ids",
"10de:2704": "RTX 4080", }
"10de:2782": "RTX 4070 Ti",
"10de:2786": "RTX 4070",
"10de:27b8": "RTX 4060 Ti",
"10de:2882": "RTX 4060",
"10de:2204": "RTX 3090",
"10de:2208": "RTX 3080 Ti",
"10de:2206": "RTX 3080",
"10de:2484": "RTX 3070",
"10de:2503": "RTX 3060",
"10de:20b0": "A100 80GB",
"10de:20b2": "A100 40GB",
"10de:20f1": "A10",
"10de:2236": "A10G",
"10de:25b6": "A16",
"10de:20b5": "A30",
"10de:20b7": "A30X",
"10de:1db4": "V100 32GB",
"10de:1db1": "V100 16GB",
"10de:1e04": "RTX 2080 Ti",
"10de:1e07": "RTX 2080",
"10de:1f02": "RTX 2070",
"10de:26ba": "L40S-PCIE-48G",
"10de:2330": "H100 80GB PCIe",
"10de:2331": "H100 80GB SXM5",
"10de:2322": "H100 NVL",
"10de:2324": "H200",
// AMD GPUs (0x1002) // Env paths have highest priority, so they are applied last.
"1002:744c": "Instinct MI250X", if env := strings.TrimSpace(os.Getenv("LOGPILE_PCI_IDS_PATH")); env != "" {
"1002:7408": "Instinct MI100", for _, p := range strings.Split(env, string(os.PathListSeparator)) {
"1002:73a5": "RX 6950 XT", p = strings.TrimSpace(p)
"1002:73bf": "RX 6900 XT", if p != "" {
"1002:73df": "RX 6700 XT", paths = append(paths, p)
"1002:7480": "RX 7900 XTX", }
"1002:7483": "RX 7900 XT", }
}
// ASPEED (0x1A03) - BMC VGA return paths
"1a03:2000": "AST2500 VGA", }
"1a03:1150": "AST2600 VGA",
func parsePCIIDs(r interface{ Read([]byte) (int, error) }, outVendors map[int]string, outDevices map[string]string) {
// Intel GPUs scanner := bufio.NewScanner(r)
"8086:56c0": "Data Center GPU Flex 170", currentVendor := -1
"8086:56c1": "Data Center GPU Flex 140",
for scanner.Scan() {
// Mellanox/NVIDIA NICs (0x15B3) line := scanner.Text()
"15b3:1017": "ConnectX-5 100GbE", if line == "" || strings.HasPrefix(line, "#") {
"15b3:1019": "ConnectX-5 Ex", continue
"15b3:101b": "ConnectX-6", }
"15b3:101d": "ConnectX-6 Dx",
"15b3:101f": "ConnectX-6 Lx", // Subdevice line (tab-tab) - ignored for now
"15b3:1021": "ConnectX-7", if strings.HasPrefix(line, "\t\t") {
"15b3:a2d6": "ConnectX-4 Lx", continue
}
// Device line
if strings.HasPrefix(line, "\t") {
if currentVendor < 0 {
continue
}
trimmed := strings.TrimLeft(line, "\t")
fields := strings.Fields(trimmed)
if len(fields) < 2 {
continue
}
deviceID, err := strconv.ParseInt(fields[0], 16, 32)
if err != nil {
continue
}
name := strings.TrimSpace(trimmed[len(fields[0]):])
if name == "" {
continue
}
key := fmt.Sprintf("%04x:%04x", currentVendor, int(deviceID))
outDevices[key] = name
continue
}
// Vendor line
fields := strings.Fields(line)
if len(fields) < 2 {
currentVendor = -1
continue
}
vendorID, err := strconv.ParseInt(fields[0], 16, 32)
if err != nil {
currentVendor = -1
continue
}
name := strings.TrimSpace(line[len(fields[0]):])
if name == "" {
currentVendor = -1
continue
}
currentVendor = int(vendorID)
outVendors[currentVendor] = name
}
} }

View File

@@ -0,0 +1,38 @@
package pciids
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestExternalPCIIDsLookup(t *testing.T) {
dir := t.TempDir()
idsPath := filepath.Join(dir, "pci.ids")
content := "" +
"# sample\n" +
"10de NVIDIA Corporation\n" +
"\t233b NVIDIA H200 SXM\n" +
"8086 Intel Corporation\n" +
"\t1521 I350 Gigabit Network Connection\n"
if err := os.WriteFile(idsPath, []byte(content), 0o644); err != nil {
t.Fatalf("write pci.ids: %v", err)
}
t.Setenv("LOGPILE_PCI_IDS_PATH", idsPath)
loadOnce = sync.Once{}
vendors = nil
devices = nil
if got := DeviceName(0x10de, 0x233b); got != "NVIDIA H200 SXM" {
t.Fatalf("expected external device name, got %q", got)
}
if got := VendorName(0x10de); got != "NVIDIA Corporation" {
t.Fatalf("expected external vendor name, got %q", got)
}
if got := DeviceName(0x8086, 0x1521); got != "I350 Gigabit Network Connection" {
t.Fatalf("expected external intel device name, got %q", got)
}
}

View File

@@ -309,6 +309,23 @@ 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 {
@@ -403,6 +420,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
Manufacturer: gpu.Manufacturer, Manufacturer: gpu.Manufacturer,
Category: "GPU", Category: "GPU",
}) })
markSeen(gpu.Slot, gpu.SerialNumber)
} }
// PCIe devices // PCIe devices
@@ -410,7 +428,10 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
if !hasUsableSerial(pcie.SerialNumber) { if !hasUsableSerial(pcie.SerialNumber) {
continue continue
} }
component := pcie.DeviceClass if alreadySeen(pcie.Slot, pcie.SerialNumber) {
continue
}
component := normalizePCIeSerialComponentName(pcie)
if strings.EqualFold(strings.TrimSpace(pcie.DeviceClass), "NVSwitch") && strings.TrimSpace(pcie.PartNumber) != "" { if strings.EqualFold(strings.TrimSpace(pcie.DeviceClass), "NVSwitch") && strings.TrimSpace(pcie.PartNumber) != "" {
component = strings.TrimSpace(pcie.PartNumber) component = strings.TrimSpace(pcie.PartNumber)
} }
@@ -422,6 +443,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
PartNumber: pcie.PartNumber, PartNumber: pcie.PartNumber,
Category: "PCIe", Category: "PCIe",
}) })
markSeen(pcie.Slot, pcie.SerialNumber)
} }
// Network cards // Network cards
@@ -431,9 +453,11 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
} }
serials = append(serials, SerialEntry{ serials = append(serials, SerialEntry{
Component: nic.Model, Component: nic.Model,
Location: nic.Name,
SerialNumber: strings.TrimSpace(nic.SerialNumber), SerialNumber: strings.TrimSpace(nic.SerialNumber),
Category: "Network", Category: "Network",
}) })
markSeen(nic.Name, nic.SerialNumber)
} }
// Power supplies // Power supplies
@@ -454,6 +478,28 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, serials) jsonResponse(w, serials)
} }
func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
className := strings.TrimSpace(p.DeviceClass)
part := strings.TrimSpace(p.PartNumber)
if part != "" && !strings.EqualFold(part, className) {
return part
}
lowerClass := strings.ToLower(className)
switch lowerClass {
case "display", "display controller", "3d controller", "vga", "network", "network controller", "pcie device", "other", "unknown", "":
if part != "" {
return part
}
}
if className != "" {
return className
}
if part != "" {
return part
}
return "PCIe device"
}
func hasUsableSerial(serial string) bool { func hasUsableSerial(serial string) bool {
s := strings.TrimSpace(serial) s := strings.TrimSpace(serial)
if s == "" { if s == "" {
@@ -474,33 +520,70 @@ func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
return return
} }
jsonResponse(w, buildFirmwareEntries(result.Hardware))
}
type firmwareEntry struct {
Component string `json:"component"`
Model string `json:"model"`
Version string `json:"version"`
}
func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
if hw == nil {
return nil
}
// Deduplicate firmware by extracting model name and version // Deduplicate firmware by extracting model name and version
// E.g., "PSU0 (AP-CR3000F12BY)" and "PSU1 (AP-CR3000F12BY)" with same version -> one entry // E.g., "PSU0 (AP-CR3000F12BY)" and "PSU1 (AP-CR3000F12BY)" with same version -> one entry
type FirmwareEntry struct {
Component string `json:"component"`
Model string `json:"model"`
Version string `json:"version"`
}
seen := make(map[string]bool) seen := make(map[string]bool)
var deduplicated []FirmwareEntry var deduplicated []firmwareEntry
for _, fw := range result.Hardware.Firmware { appendEntry := func(component, model, version string) {
// Extract component type and model from device name component = strings.TrimSpace(component)
component, model := extractFirmwareComponentAndModel(fw.DeviceName) model = strings.TrimSpace(model)
key := component + "|" + model + "|" + fw.Version version = strings.TrimSpace(version)
if component == "" || version == "" {
if !seen[key] { return
seen[key] = true
deduplicated = append(deduplicated, FirmwareEntry{
Component: component,
Model: model,
Version: fw.Version,
})
} }
if model == "" {
model = "-"
}
key := component + "|" + model + "|" + version
if seen[key] {
return
}
seen[key] = true
deduplicated = append(deduplicated, firmwareEntry{
Component: component,
Model: model,
Version: version,
})
} }
jsonResponse(w, deduplicated) for _, fw := range hw.Firmware {
component, model := extractFirmwareComponentAndModel(fw.DeviceName)
appendEntry(component, model, fw.Version)
}
// Fallback for parsers that fill GPU firmware on device inventory only
// (e.g. runtime enrichment from redis/HGX) without explicit Hardware.Firmware entries.
for _, gpu := range hw.GPUs {
version := strings.TrimSpace(gpu.Firmware)
if version == "" {
continue
}
model := strings.TrimSpace(gpu.PartNumber)
if model == "" {
model = strings.TrimSpace(gpu.Model)
}
if model == "" {
model = strings.TrimSpace(gpu.Slot)
}
appendEntry("GPU", model, version)
}
return deduplicated
} }
// extractFirmwareComponentAndModel extracts the component type and model from firmware device name // extractFirmwareComponentAndModel extracts the component type and model from firmware device name

View File

@@ -1,6 +1,10 @@
package server package server
import "testing" import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestExtractFirmwareComponentAndModel_GPUUsesPartNumberFromParentheses(t *testing.T) { func TestExtractFirmwareComponentAndModel_GPUUsesPartNumberFromParentheses(t *testing.T) {
component, model := extractFirmwareComponentAndModel("GPU GPUSXM3 (692-2G520-0280-501)") component, model := extractFirmwareComponentAndModel("GPU GPUSXM3 (692-2G520-0280-501)")
@@ -21,3 +25,40 @@ func TestExtractFirmwareComponentAndModel_GPUFallbackWithoutParentheses(t *testi
t.Fatalf("expected GPU model 692-2G520-0280-501, got %q", model) t.Fatalf("expected GPU model 692-2G520-0280-501, got %q", model)
} }
} }
func TestBuildFirmwareEntries_IncludesGPUFirmwareFallback(t *testing.T) {
hw := &models.HardwareConfig{
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "1.0.0"},
},
GPUs: []models.GPU{
{
Slot: "#CPU0_PCIE2",
Model: "GH100 [H200 NVL]",
PartNumber: "699-2G530-0200-501",
Firmware: "96.00.B7.00.02",
},
{
Slot: "#CPU0_PCIE1",
Model: "GH100 [H200 NVL]",
PartNumber: "699-2G530-0200-501",
Firmware: "96.00.B7.00.02",
},
},
}
entries := buildFirmwareEntries(hw)
if len(entries) != 2 {
t.Fatalf("expected 2 deduplicated firmware entries, got %d", len(entries))
}
var hasGPU bool
for _, e := range entries {
if e.Component == "GPU" && e.Version == "96.00.B7.00.02" {
hasGPU = true
}
}
if !hasGPU {
t.Fatalf("expected GPU firmware entry from hardware.gpus fallback")
}
}

View File

@@ -0,0 +1,20 @@
package server
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestNormalizePCIeSerialComponentName_PrefersPartOverGenericClass(t *testing.T) {
got := normalizePCIeSerialComponentName(models.PCIeDevice{DeviceClass:"Display Controller", PartNumber:"GH100 [H200 NVL]"})
if got != "GH100 [H200 NVL]" {
t.Fatalf("expected part number, got %q", got)
}
}
func TestNormalizePCIeSerialComponentName_UsesClassWhenSpecific(t *testing.T) {
got := normalizePCIeSerialComponentName(models.PCIeDevice{DeviceClass:"I350 Gigabit Network Connection", PartNumber:"I350T4V2"})
if got != "I350T4V2" {
t.Fatalf("expected part number for readability, got %q", got)
}
}

59
scripts/update-pci-ids.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
OUT_FILE="$ROOT_DIR/internal/parser/vendors/pciids/pci.ids"
SUBMODULE_DIR="$ROOT_DIR/third_party/pciids"
SUBMODULE_FILE="$SUBMODULE_DIR/pci.ids"
BEST_EFFORT=0
SYNC_SUBMODULE=0
while [ $# -gt 0 ]; do
case "$1" in
--best-effort)
BEST_EFFORT=1
;;
--sync-submodule)
SYNC_SUBMODULE=1
;;
*)
echo "error: unknown argument: $1" >&2
exit 2
;;
esac
shift
done
run_or_warn() {
if "$@"; then
return 0
fi
if [ "$BEST_EFFORT" -eq 1 ] && [ -f "$OUT_FILE" ]; then
echo "warning: command failed: $*; keeping existing pci.ids" >&2
exit 0
fi
return 1
}
if [ "$SYNC_SUBMODULE" -eq 1 ]; then
run_or_warn git -C "$ROOT_DIR" submodule update --init --remote "$SUBMODULE_DIR"
fi
if [ ! -s "$SUBMODULE_FILE" ]; then
if [ "$BEST_EFFORT" -eq 1 ] && [ -f "$OUT_FILE" ]; then
echo "warning: missing submodule pci.ids; keeping existing file" >&2
exit 0
fi
echo "error: missing $SUBMODULE_FILE (run: git submodule update --init --remote third_party/pciids)" >&2
exit 1
fi
mkdir -p "$(dirname "$OUT_FILE")"
if [ -f "$OUT_FILE" ] && cmp -s "$SUBMODULE_FILE" "$OUT_FILE"; then
echo "pci.ids is already up to date"
exit 0
fi
cp "$SUBMODULE_FILE" "$OUT_FILE"
echo "updated $OUT_FILE from submodule $SUBMODULE_DIR"

1
third_party/pciids vendored Submodule

Submodule third_party/pciids added at 82b1a68f47

View File

@@ -834,10 +834,29 @@ 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">';
if (config.gpus && config.gpus.length > 0) { const gpuRows = (config.gpus && config.gpus.length > 0)
const gpuCount = config.gpus.length; ? config.gpus
const gpuModel = config.gpus[0].model || '-'; : (config.pcie_devices || [])
const gpuVendor = config.gpus[0].manufacturer || '-'; .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) {
const gpuCount = gpuRows.length;
const gpuModel = gpuRows[0].model || '-';
const gpuVendor = gpuRows[0].manufacturer || '-';
html += `<h3>Графические процессоры</h3> html += `<h3>Графические процессоры</h3>
<div class="section-overview"> <div class="section-overview">
<div class="stat-box"><span class="stat-value">${gpuCount}</span><span class="stat-label">Всего GPU</span></div> <div class="stat-box"><span class="stat-value">${gpuCount}</span><span class="stat-label">Всего GPU</span></div>
@@ -845,7 +864,7 @@ function renderConfig(data) {
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(gpuModel)}</span><span class="stat-label">Модель</span></div> <div class="stat-box model-box"><span class="stat-value">${escapeHtml(gpuModel)}</span><span class="stat-label">Модель</span></div>
</div> </div>
<table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>`; <table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>BDF</th><th>PCIe</th><th>Серийный номер</th></tr></thead><tbody>`;
config.gpus.forEach(gpu => { gpuRows.forEach(gpu => {
const pcieLink = formatPCIeLink( const pcieLink = formatPCIeLink(
gpu.current_link_width || gpu.link_width, gpu.current_link_width || gpu.link_width,
gpu.current_link_speed || gpu.link_speed, gpu.current_link_speed || gpu.link_speed,
@@ -869,11 +888,27 @@ 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">';
if (config.network_adapters && config.network_adapters.length > 0) { const networkRows = (config.network_adapters && config.network_adapters.length > 0)
const nicCount = config.network_adapters.length; ? config.network_adapters
const totalPorts = config.network_adapters.reduce((sum, n) => sum + (n.port_count || 0), 0); : (config.pcie_devices || [])
const nicTypes = [...new Set(config.network_adapters.map(n => n.port_type).filter(t => t))]; .filter((p) => {
const nicModels = [...new Set(config.network_adapters.map(n => n.model).filter(m => m))]; 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) {
const nicCount = networkRows.length;
const totalPorts = networkRows.reduce((sum, n) => sum + (n.port_count || 0), 0);
const nicTypes = [...new Set(networkRows.map(n => n.port_type).filter(t => t))];
const nicModels = [...new Set(networkRows.map(n => n.model).filter(m => m))];
html += `<h3>Сетевые адаптеры</h3> html += `<h3>Сетевые адаптеры</h3>
<div class="section-overview"> <div class="section-overview">
<div class="stat-box"><span class="stat-value">${nicCount}</span><span class="stat-label">Адаптеров</span></div> <div class="stat-box"><span class="stat-value">${nicCount}</span><span class="stat-label">Адаптеров</span></div>
@@ -882,7 +917,7 @@ function renderConfig(data) {
<div class="stat-box model-box"><span class="stat-value">${escapeHtml(nicModels.join(', ') || '-')}</span><span class="stat-label">Модели</span></div> <div class="stat-box model-box"><span class="stat-value">${escapeHtml(nicModels.join(', ') || '-')}</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>MAC адреса</th><th>Статус</th></tr></thead><tbody>`; <table class="config-table"><thead><tr><th>Слот</th><th>Модель</th><th>Производитель</th><th>Порты</th><th>Тип</th><th>MAC адреса</th><th>Статус</th></tr></thead><tbody>`;
config.network_adapters.forEach(nic => { networkRows.forEach(nic => {
const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-'; const macs = nic.mac_addresses ? nic.mac_addresses.join(', ') : '-';
const statusClass = nic.status === 'OK' ? '' : 'status-warning'; const statusClass = nic.status === 'OK' ? '' : 'status-warning';
html += `<tr> html += `<tr>
@@ -906,22 +941,40 @@ function renderConfig(data) {
const hasPCIe = config.pcie_devices && config.pcie_devices.length > 0; const hasPCIe = config.pcie_devices && config.pcie_devices.length > 0;
const hasGPUs = config.gpus && config.gpus.length > 0; const hasGPUs = config.gpus && config.gpus.length > 0;
if (hasPCIe || hasGPUs) { if (hasPCIe || hasGPUs) {
html += '<h3>PCIe устройства</h3><table class="config-table"><thead><tr><th>Слот</th><th>BDF</th><th>Тип</th><th>Модель</th><th>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th></tr></thead><tbody>'; 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>';
const pcieRowKey = (slot, bdf, vendorId, deviceId) => {
const normalizedBDF = (bdf || '').trim().toLowerCase();
if (normalizedBDF) return `bdf:${normalizedBDF}`;
const normalizedSlot = (slot || '').trim().toLowerCase();
if (normalizedSlot) return `slot:${normalizedSlot}`;
return `id:${vendorId || 0}:${deviceId || 0}`;
};
const gpuByKey = new Map();
(config.gpus || []).forEach(gpu => {
gpuByKey.set(pcieRowKey(gpu.slot, gpu.bdf, gpu.vendor_id, gpu.device_id), gpu);
});
(config.pcie_devices || []).forEach(p => { (config.pcie_devices || []).forEach(p => {
const key = pcieRowKey(p.slot, p.bdf, p.vendor_id, p.device_id);
const matchedGPU = gpuByKey.get(key);
const pcieLink = formatPCIeLink( const pcieLink = formatPCIeLink(
p.link_width, p.link_width,
p.link_speed, p.link_speed,
p.max_link_width, p.max_link_width,
p.max_link_speed 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> html += `<tr>
<td>${escapeHtml(p.slot || '-')}</td> <td>${escapeHtml(p.slot || '-')}</td>
<td><code>${escapeHtml(p.bdf || '-')}</code></td> <td><code>${escapeHtml(p.bdf || '-')}</code></td>
<td>${escapeHtml(p.device_class || '-')}</td>
<td>${escapeHtml(p.part_number || '-')}</td> <td>${escapeHtml(p.part_number || '-')}</td>
<td>${escapeHtml(p.manufacturer || '-')}</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><code>${p.vendor_id ? p.vendor_id.toString(16) : '-'}:${p.device_id ? p.device_id.toString(16) : '-'}</code></td>
<td>${pcieLink}</td> <td>${pcieLink}</td>
<td><code>${escapeHtml(serial || '-')}</code></td>
<td><code>${escapeHtml(firmware || '-')}</code></td>
</tr>`; </tr>`;
}); });
@@ -935,11 +988,12 @@ function renderConfig(data) {
html += `<tr> html += `<tr>
<td>${escapeHtml(gpu.slot || '-')}</td> <td>${escapeHtml(gpu.slot || '-')}</td>
<td><code>${escapeHtml(gpu.bdf || '-')}</code></td> <td><code>${escapeHtml(gpu.bdf || '-')}</code></td>
<td>GPU</td>
<td>${escapeHtml(gpu.model || gpu.part_number || '-')}</td> <td>${escapeHtml(gpu.model || gpu.part_number || '-')}</td>
<td>${escapeHtml(gpu.manufacturer || '-')}</td> <td>${escapeHtml(gpu.manufacturer || '-')}</td>
<td><code>${gpu.vendor_id ? gpu.vendor_id.toString(16) : '-'}:${gpu.device_id ? gpu.device_id.toString(16) : '-'}</code></td> <td><code>${gpu.vendor_id ? gpu.vendor_id.toString(16) : '-'}:${gpu.device_id ? gpu.device_id.toString(16) : '-'}</code></td>
<td>${pcieLink}</td> <td>${pcieLink}</td>
<td><code>${escapeHtml(gpu.serial_number || '-')}</code></td>
<td><code>${escapeHtml(gpu.firmware || '-')}</code></td>
</tr>`; </tr>`;
}); });
html += '</tbody></table>'; html += '</tbody></table>';
@@ -1229,6 +1283,24 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function findPCIeFirmwareVersion(firmwareEntries, pcieDevice) {
if (!Array.isArray(firmwareEntries) || !pcieDevice) return '';
const slot = (pcieDevice.slot || '').trim().toLowerCase();
const model = (pcieDevice.part_number || '').trim().toLowerCase();
if (!slot && !model) return '';
for (const fw of firmwareEntries) {
const name = (fw.device_name || '').trim().toLowerCase();
const version = (fw.version || '').trim();
if (!name || !version) continue;
if (slot && name.includes(slot)) return version;
if (model && name.includes(model)) return version;
}
return '';
}
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) {