13 Commits

Author SHA1 Message Date
758fa66282 feat: improve inspur parsing and pci.ids integration 2026-02-17 18:09:36 +03:00
b33cca5fcc nvidia: improve component mapping, firmware, statuses and check times 2026-02-16 23:17:13 +03:00
514da76ddb Update Inspur parsing and align release docs 2026-02-15 23:13:47 +03:00
c13788132b Add release script and release notes (no artifacts) 2026-02-15 22:23:53 +03:00
5e49adaf05 Update parser and project changes 2026-02-15 22:02:07 +03:00
c7b2a7ab29 Fix NVIDIA GPU/NVSwitch parsing and Reanimator export statuses 2026-02-15 21:00:30 +03:00
0af3cee9b6 Add integration guide, example generator, and built binary 2026-02-15 20:08:46 +03:00
8715fcace4 Align Reanimator export with updated integration guide 2026-02-15 20:06:36 +03:00
1b1bc74fc7 Add Reanimator format export support
Implement export to Reanimator format for asset tracking integration.

Features:
- New API endpoint: GET /api/export/reanimator
- Web UI button "Экспорт Reanimator" in Configuration tab
- Auto-detect CPU manufacturer (Intel/AMD/ARM/Ampere)
- Generate PCIe serial numbers if missing
- Merge GPUs and NetworkAdapters into pcie_devices
- Filter components without serial numbers
- RFC3339 timestamp format
- Full compliance with Reanimator specification

Changes:
- Add reanimator_models.go: data models for Reanimator format
- Add reanimator_converter.go: conversion functions
- Add reanimator_converter_test.go: unit tests
- Add reanimator_integration_test.go: integration tests
- Update handlers.go: add handleExportReanimator
- Update server.go: register /api/export/reanimator route
- Update index.html: add export button
- Update CLAUDE.md: document export behavior
- Add REANIMATOR_EXPORT.md: implementation summary

Tests: All tests passing (15+ new tests)
Format spec: example/docs/INTEGRATION_GUIDE.md

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-12 21:54:37 +03:00
77e25ddc02 Fix NVIDIA GPU serial number format extraction
Extract decimal serial numbers from devname parameters (e.g., "SXM5_SN_1653925027099")
instead of hex PCIe Device Serial Numbers. This provides the correct GPU serial
numbers as they appear in NVIDIA diagnostics tooling.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 22:57:50 +03:00
bcce975fd6 Add GPU serial number extraction for NVIDIA diagnostics
Parse inventory/output.log to extract GPU serial numbers from lspci output,
expose them via serials API, and add GPU category to web UI.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-10 22:50:46 +03:00
8b065c6cca Harden zip reader and syslog scan 2026-02-06 00:03:25 +03:00
aa22034944 Add Unraid diagnostics parser and fix zip upload support
Implements comprehensive parser for Unraid diagnostics archives with support for:
- System information (OS version, BIOS, motherboard)
- CPU details from lscpu (model, cores, threads, frequency)
- Memory information
- Storage devices with SMART data integration
- Temperature sensors from disk array
- System event logs

Parser intelligently merges data from multiple sources:
- SMART files provide detailed disk information (model, S/N, firmware)
- vars.txt provides disk configuration and filesystem types
- Deduplication ensures clean results

Also fixes critical bug where zip archives could not be uploaded via web interface
due to missing extractZipFromReader implementation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 23:54:55 +03:00
76 changed files with 52487 additions and 566 deletions

5
.gitignore vendored
View File

@@ -62,3 +62,8 @@ go.work.sum
# Distribution binaries
dist/
# Release artifacts
releases/**/SHA256SUMS.txt
releases/**/*.tar.gz
releases/**/*.zip

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,19 +44,54 @@ Registry: `internal/collector/registry.go`
- progress logs include active collection stage and snapshot progress.
- `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
Endpoints:
- `/api/export/csv`
- `/api/export/json`
- `/api/export/txt`
- `/api/export/reanimator`
Filename pattern for all exports:
`YYYY-MM-DD (SERVER MODEL) - SERVER SN.<ext>`
Notes:
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
- TXT export is tabular and mirrors UI sections (no raw JSON section).
- **Reanimator export** (`/api/export/reanimator`):
- Exports hardware data in Reanimator format for integration with asset tracking systems.
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
- Requires `hardware.board.serial_number` to be present.
- Key features:
- Infers CPU manufacturer from model name (Intel/AMD/ARM/Ampere).
- Generates PCIe serial numbers if missing: `{board_serial}-PCIE-{slot}`.
- Adds status fields (defaults to "OK").
- RFC3339 timestamp format.
- Includes GPUs and NetworkAdapters as PCIe devices.
- Filters out storage devices and PSUs without serial numbers.
## CLI flags (`cmd/logpile/main.go`)

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
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)"
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
run: build
@@ -19,6 +20,7 @@ test:
# Cross-platform builds
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=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
@@ -33,3 +35,6 @@ fmt:
lint:
golangci-lint run
update-pci-ids:
./scripts/update-pci-ids.sh --sync-submodule

View File

@@ -15,7 +15,66 @@ LOGPile — standalone Go-приложение для анализа диагн
- нормализованные данные (CPU/RAM/Storage/GPU/PSU/NIC/PCIe/Firmware),
- сырой `redfish_tree` для будущего анализа.
- Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы.
- Экспорт в CSV / JSON / TXT.
- Экспорт в 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
```
## Требования
@@ -98,7 +157,6 @@ POST /api/collect
- `GET /api/export/csv` — серийные номера
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
- `GET /api/export/txt` — табличный отчёт по разделам UI
Имена экспортируемых файлов:
@@ -123,7 +181,6 @@ GET /api/serials
GET /api/firmware
GET /api/export/csv
GET /api/export/json
GET /api/export/txt
DELETE /api/clear
POST /api/shutdown
```
@@ -141,7 +198,7 @@ cmd/logpile/main.go # entrypoint
internal/collector/ # live collectors (redfish, ipmi mock)
internal/parser/ # archive parsers
internal/server/ # HTTP handlers
internal/exporter/ # CSV/JSON/TXT export
internal/exporter/ # CSV/JSON export
internal/models/ # data contracts
web/ # embedded templates/static
```

227
REANIMATOR_EXPORT.md Normal file
View File

@@ -0,0 +1,227 @@
# Reanimator Export - Implementation Summary
## Обзор
Реализован новый формат экспорта данных LOGPile в формат Reanimator для интеграции с системами отслеживания серверных компонентов (asset tracking).
## Реализованные компоненты
### 1. Модели данных (`internal/exporter/reanimator_models.go`)
Определены структуры для формата Reanimator:
- `ReanimatorExport` - корневая структура экспорта
- `ReanimatorHardware` - контейнер для всех аппаратных компонентов
- `ReanimatorBoard` - материнская плата/сервер
- `ReanimatorCPU` - процессоры
- `ReanimatorMemory` - модули памяти (DIMM)
- `ReanimatorStorage` - накопители
- `ReanimatorPCIe` - PCIe устройства
- `ReanimatorPSU` - блоки питания
- `ReanimatorFirmware` - прошивки
### 2. Функции конвертации (`internal/exporter/reanimator_converter.go`)
Главная функция: `ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, error)`
Вспомогательные функции:
- `inferCPUManufacturer()` - определение производителя CPU по модели (Intel/AMD/ARM/Ampere)
- `generatePCIeSerialNumber()` - генерация серийных номеров для PCIe устройств
- `inferStorageStatus()` - определение статуса накопителей
- `convertBoard()`, `convertCPUs()`, `convertMemory()`, и т.д. - конвертация отдельных секций
**Ключевые особенности конвертации:**
- Автоматическое определение производителя CPU из модели
- Генерация серийных номеров для PCIe устройств: `{board_serial}-PCIE-{slot}`
- Объединение GPUs и NetworkAdapters в секцию pcie_devices
- Фильтрация компонентов без серийных номеров (storage, PSU)
- Нормализация статусов в допустимые значения (`OK`, `Warning`, `Critical`, `Unknown`; `Empty` только для memory)
- RFC3339 формат для collected_at
- Вывод target_host из filename (`redfish://`, `ipmi://`) если отсутствует в source
- `target_host` опционален: если определить не удалось, поле не включается в JSON
- Нормализация `board.manufacturer` и `board.product_name`: строка `"NULL"` трактуется как отсутствующее значение
- Нормализация/очистка `source_type` и `protocol`: в экспорт попадают только допустимые значения из гайда
### 3. HTTP эндпоинт
**Маршрут:** `GET /api/export/reanimator`
**Обработчик:** `handleExportReanimator()` в `internal/server/handlers.go`
**Функциональность:**
- Проверка наличия данных hardware
- Конвертация в формат Reanimator
- Возврат JSON с отступами для читаемости
- Установка заголовка Content-Disposition для скачивания
### 4. Frontend интеграция
Добавлена кнопка "Экспорт Reanimator" в веб-интерфейсе:
- Расположение: вкладка "Конфигурация"
- Использует существующую функцию `exportData('reanimator')`
### 5. Тесты
**Unit-тесты** (`reanimator_converter_test.go`):
- `TestConvertToReanimator` - основная функция конвертации
- `TestInferCPUManufacturer` - определение производителя CPU
- `TestGeneratePCIeSerialNumber` - генерация серийных номеров
- `TestInferStorageStatus` - определение статуса накопителей
- `TestConvertCPUs`, `TestConvertMemory`, и т.д. - тесты для каждого типа компонентов
**Интеграционные тесты** (`reanimator_integration_test.go`):
- `TestFullReanimatorExport` - полный экспорт с реалистичными данными
- `TestReanimatorExportWithoutTargetHost` - тест вывода target_host
**Результаты:** Все тесты проходят успешно ✓
### 6. Документация
Обновлен `CLAUDE.md`:
- Добавлен эндпоинт `/api/export/reanimator` в секцию "Export behavior"
- Описаны ключевые особенности экспорта
- Добавлена ссылка на спецификацию формата
### 7. Примеры
Создан пример экспорта: `example/docs/export-example-logpile.json`
## Формат экспорта
### Обязательные поля:
- `collected_at` (RFC3339)
- `target_host`
- `hardware.board.serial_number`
### Структура экспорта:
```json
{
"filename": "redfish://10.10.10.103",
"source_type": "api",
"protocol": "redfish",
"target_host": "10.10.10.103",
"collected_at": "2026-02-10T15:30:00Z",
"hardware": {
"board": {...},
"firmware": [...],
"cpus": [...],
"memory": [...],
"storage": [...],
"pcie_devices": [...],
"power_supplies": [...]
}
}
```
## Соответствие спецификации Reanimator
Формат полностью соответствует спецификации из `example/docs/INTEGRATION_GUIDE.md`:
Все обязательные поля присутствуют
✓ Правильные типы данных
✓ RFC3339 формат времени
✓ Генерация серийных номеров для PCIe
✓ Определение производителя CPU
✓ Статусы компонентов
✓ Включение пустых слотов памяти (present=false)
## Особенности реализации
### Маппинг моделей LOGPile → Reanimator
| LOGPile | Reanimator | Примечания |
|---------|------------|------------|
| `BoardInfo` | `board` | Прямой маппинг |
| `CPU` | `cpus` | + manufacturer (выводится) + status=`Unknown` при отсутствии фактического статуса |
| `MemoryDIMM` | `memory` | Прямой маппинг |
| `Storage` | `storage` | + status=`Unknown` (статус источником не предоставляется) |
| `PCIeDevice` | `pcie_devices` | + model + status=`Unknown` |
| `GPU` | `pcie_devices` | Объединены как `device_class=DisplayController` |
| `NetworkAdapter` | `pcie_devices` | Объединены как NetworkController |
| `PSU` | `power_supplies` | Прямой маппинг |
| `FirmwareInfo` | `firmware` | Прямой маппинг |
### Фильтрация данных
**Исключаются из экспорта:**
- Storage без serial_number
- PSU без serial_number или present=false
- NetworkAdapters с present=false
**Включаются в экспорт:**
- Memory с present=false (как Empty slots)
- PCIe устройства без serial_number (генерируется)
## Использование
### Через Web UI:
1. Загрузить архив или собрать данные через API
2. Перейти на вкладку "Конфигурация"
3. Нажать "Экспорт Reanimator"
### Через API:
```bash
curl http://localhost:8082/api/export/reanimator > reanimator.json
```
### Программно:
```go
import "git.mchus.pro/mchus/logpile/internal/exporter"
result := &models.AnalysisResult{...}
reanimatorData, err := exporter.ConvertToReanimator(result)
if err != nil {
// handle error
}
jsonData, _ := json.MarshalIndent(reanimatorData, "", " ")
```
## Тестирование
Запуск тестов:
```bash
# Все тесты
go test ./internal/exporter/...
# Только тесты Reanimator
go test ./internal/exporter/... -v -run Reanimator
# С покрытием
go test ./internal/exporter/... -cover
```
## Файлы изменений
**Новые файлы:**
- `internal/exporter/reanimator_models.go` (4.6 KB)
- `internal/exporter/reanimator_converter.go` (10 KB)
- `internal/exporter/reanimator_converter_test.go` (8.0 KB)
- `internal/exporter/reanimator_integration_test.go` (7.4 KB)
- `internal/exporter/generate_example_test.go` (4.3 KB)
- `example/docs/export-example-logpile.json` (2.3 KB)
**Измененные файлы:**
- `internal/server/handlers.go` - добавлен handleExportReanimator
- `internal/server/server.go` - добавлен маршрут
- `web/templates/index.html` - добавлена кнопка экспорта
- `CLAUDE.md` - обновлена документация
## Совместимость
- ✓ Обратная совместимость: существующие экспорты (JSON/CSV) не затронуты
- ✓ Формат данных: `AnalysisResult` не изменен
- ✓ API контракты: новый эндпоинт не влияет на существующие
## Будущие улучшения
1. Поддержка статусов из реальных данных (Warning/Critical) для Storage
2. Расширенная телеметрия для компонентов
3. Валидация экспорта против JSON схемы Reanimator
4. Поддержка инкрементальных обновлений
---
**Статус:** ✅ Реализация завершена и протестирована
**Версия:** LOGPile v1.2.1+
**Дата:** 2026-02-12

1046
docs/INTEGRATION_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@ import (
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
)
type RedfishConnector struct {
@@ -725,12 +726,27 @@ func parseDrive(doc map[string]interface{}) models.Storage {
}
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{
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Location: asString(doc["Location"]),
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Vendor: asString(doc["Manufacturer"]),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: asString(doc["SerialNumber"]),
PartNumber: asString(doc["PartNumber"]),
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
}
@@ -869,6 +894,17 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
if dev.DeviceClass == "" {
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
}
@@ -878,7 +914,7 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
slot = fmt.Sprintf("PCIeFn%d", idx)
}
return models.PCIeDevice{
dev := models.PCIeDevice{
Slot: slot,
BDF: asString(doc["FunctionId"]),
VendorID: asHexOrInt(doc["VendorId"]),
@@ -891,6 +927,54 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
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 {

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

@@ -3,9 +3,8 @@ package exporter
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
@@ -36,7 +35,7 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// FRU data
for _, fru := range e.result.FRU {
if fru.SerialNumber == "" {
if !hasUsableSerial(fru.SerialNumber) {
continue
}
name := fru.ProductName
@@ -55,9 +54,36 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// Hardware data
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
for _, mem := range e.result.Hardware.Memory {
if mem.SerialNumber == "" {
if !hasUsableSerial(mem.SerialNumber) {
continue
}
location := mem.Location
@@ -66,7 +92,7 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
}
if err := writer.Write([]string{
mem.PartNumber,
mem.SerialNumber,
strings.TrimSpace(mem.SerialNumber),
mem.Manufacturer,
location,
}); err != nil {
@@ -76,12 +102,12 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
// Storage
for _, stor := range e.result.Hardware.Storage {
if stor.SerialNumber == "" {
if !hasUsableSerial(stor.SerialNumber) {
continue
}
if err := writer.Write([]string{
stor.Model,
stor.SerialNumber,
strings.TrimSpace(stor.SerialNumber),
stor.Manufacturer,
stor.Slot,
}); err != nil {
@@ -89,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
for _, pcie := range e.result.Hardware.PCIeDevices {
if pcie.SerialNumber == "" {
if !hasUsableSerial(pcie.SerialNumber) {
continue
}
if err := writer.Write([]string{
pcie.DeviceClass,
pcie.SerialNumber,
strings.TrimSpace(pcie.SerialNumber),
pcie.Manufacturer,
pcie.Slot,
}); err != nil {
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
@@ -115,220 +209,15 @@ func (e *Exporter) ExportJSON(w io.Writer) error {
return encoder.Encode(e.result)
}
// ExportTXT exports a human-readable text report
func (e *Exporter) ExportTXT(w io.Writer) error {
fmt.Fprintln(w, "LOGPile Analysis Report - mchus.pro")
fmt.Fprintln(w, "====================================")
fmt.Fprintln(w)
if e.result == nil {
fmt.Fprintln(w, "No data loaded.")
return nil
func hasUsableSerial(serial string) bool {
s := strings.TrimSpace(serial)
if s == "" {
return false
}
fmt.Fprintf(w, "File:\t%s\n", e.result.Filename)
fmt.Fprintf(w, "Source:\t%s\n", e.result.SourceType)
fmt.Fprintf(w, "Protocol:\t%s\n", e.result.Protocol)
fmt.Fprintf(w, "Target:\t%s\n", e.result.TargetHost)
fmt.Fprintln(w)
// Server model and serial number
if e.result.Hardware != nil && e.result.Hardware.BoardInfo.ProductName != "" {
fmt.Fprintf(w, "Server Model:\t%s\n", e.result.Hardware.BoardInfo.ProductName)
fmt.Fprintf(w, "Serial Number:\t%s\n", e.result.Hardware.BoardInfo.SerialNumber)
switch strings.ToUpper(s) {
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
return false
default:
return true
}
fmt.Fprintln(w)
// Hardware summary
if e.result.Hardware != nil {
hw := e.result.Hardware
// Firmware tab
if len(hw.Firmware) > 0 {
fmt.Fprintln(w, "FIRMWARE VERSIONS")
fmt.Fprintln(w, "-----------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Component\tVersion\tBuild Time")
for _, fw := range hw.Firmware {
fmt.Fprintf(tw, "%s\t%s\t%s\n", fw.DeviceName, fw.Version, fw.BuildTime)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// CPU tab
if len(hw.CPUs) > 0 {
fmt.Fprintln(w, "PROCESSORS")
fmt.Fprintln(w, "----------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Socket\tModel\tCores\tThreads\tFreq MHz\tTurbo MHz\tTDP W\tPPIN/SN")
for _, cpu := range hw.CPUs {
id := cpu.SerialNumber
if id == "" {
id = cpu.PPIN
}
fmt.Fprintf(tw, "CPU%d\t%s\t%d\t%d\t%d\t%d\t%d\t%s\n",
cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz, cpu.MaxFreqMHz, cpu.TDP, id)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Memory tab
if len(hw.Memory) > 0 {
fmt.Fprintln(w, "MEMORY")
fmt.Fprintln(w, "------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tSize MB\tType\tSpeed MHz\tVendor\tModel/PN\tSerial\tStatus")
for _, mem := range hw.Memory {
location := mem.Location
if location == "" {
location = mem.Slot
}
fmt.Fprintf(tw, "%s\t%t\t%d\t%s\t%d\t%s\t%s\t%s\t%s\n",
location, mem.Present, mem.SizeMB, mem.Type, mem.CurrentSpeedMHz, mem.Manufacturer, mem.PartNumber, mem.SerialNumber, mem.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Power tab
if len(hw.PowerSupply) > 0 {
fmt.Fprintln(w, "POWER SUPPLIES")
fmt.Fprintln(w, "--------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tVendor\tModel\tWattage W\tInput W\tOutput W\tInput V\tTemp C\tStatus\tSerial")
for _, psu := range hw.PowerSupply {
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%d\t%d\t%d\t%.0f\t%d\t%s\t%s\n",
psu.Slot, psu.Present, psu.Vendor, psu.Model, psu.WattageW, psu.InputPowerW, psu.OutputPowerW, psu.InputVoltage, psu.TemperatureC, psu.Status, psu.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Storage tab
if len(hw.Storage) > 0 {
fmt.Fprintln(w, "STORAGE")
fmt.Fprintln(w, "-------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tType\tInterface\tModel\tSize GB\tVendor\tFirmware\tSerial")
for _, stor := range hw.Storage {
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
stor.Slot, stor.Present, stor.Type, stor.Interface, stor.Model, stor.SizeGB, stor.Manufacturer, stor.Firmware, stor.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// GPU tab
if len(hw.GPUs) > 0 {
fmt.Fprintln(w, "GPUS")
fmt.Fprintln(w, "----")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tModel\tVendor\tBDF\tPCIe\tSerial\tStatus")
for _, gpu := range hw.GPUs {
link := fmt.Sprintf("x%d %s", gpu.CurrentLinkWidth, gpu.CurrentLinkSpeed)
if gpu.MaxLinkWidth > 0 || gpu.MaxLinkSpeed != "" {
link = fmt.Sprintf("%s / x%d %s", link, gpu.MaxLinkWidth, gpu.MaxLinkSpeed)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
gpu.Slot, gpu.Model, gpu.Manufacturer, gpu.BDF, link, gpu.SerialNumber, gpu.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Network tab
if len(hw.NetworkAdapters) > 0 {
fmt.Fprintln(w, "NETWORK ADAPTERS")
fmt.Fprintln(w, "----------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tLocation\tModel\tVendor\tPorts\tType\tStatus\tSerial")
for _, nic := range hw.NetworkAdapters {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
nic.Slot, nic.Location, nic.Model, nic.Vendor, nic.PortCount, nic.PortType, nic.Status, nic.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Device inventory tab
if len(hw.PCIeDevices) > 0 {
fmt.Fprintln(w, "PCIE DEVICES")
fmt.Fprintln(w, "------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tBDF\tClass\tVendor\tVID:DID\tLink\tSerial")
for _, pcie := range hw.PCIeDevices {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%04x:%04x\tx%d %s / x%d %s\t%s\n",
pcie.Slot, pcie.BDF, pcie.DeviceClass, pcie.Manufacturer, pcie.VendorID, pcie.DeviceID,
pcie.LinkWidth, pcie.LinkSpeed, pcie.MaxLinkWidth, pcie.MaxLinkSpeed, pcie.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
}
// Sensors tab
if len(e.result.Sensors) > 0 {
fmt.Fprintln(w, "SENSOR READINGS")
fmt.Fprintln(w, "---------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Type\tName\tValue\tUnit\tRaw\tStatus")
for _, s := range e.result.Sensors {
fmt.Fprintf(tw, "%s\t%s\t%.0f\t%s\t%s\t%s\n", s.Type, s.Name, s.Value, s.Unit, s.RawValue, s.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Serials/FRU tab
if len(e.result.FRU) > 0 {
fmt.Fprintln(w, "FRU COMPONENTS")
fmt.Fprintln(w, "--------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Description\tManufacturer\tProduct\tSerial\tPart Number")
for _, fru := range e.result.FRU {
name := fru.ProductName
if name == "" {
name = fru.Description
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", fru.Description, fru.Manufacturer, name, fru.SerialNumber, fru.PartNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Events tab
fmt.Fprintf(w, "EVENTS: %d total\n", len(e.result.Events))
if len(e.result.Events) > 0 {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Time\tSeverity\tSource\tType\tName\tDescription")
for _, ev := range e.result.Events {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
ev.Timestamp.Format("2006-01-02 15:04:05"), ev.Severity, ev.Source, ev.SensorType, ev.SensorName, ev.Description)
}
_ = tw.Flush()
}
var critical, warning, info int
for _, ev := range e.result.Events {
switch ev.Severity {
case models.SeverityCritical:
critical++
case models.SeverityWarning:
warning++
case models.SeverityInfo:
info++
}
}
fmt.Fprintf(w, " Critical: %d\n", critical)
fmt.Fprintf(w, " Warning: %d\n", warning)
fmt.Fprintf(w, " Info: %d\n", info)
// Footer
fmt.Fprintln(w)
fmt.Fprintln(w, "------------------------------------")
fmt.Fprintln(w, "Generated by LOGPile - mchus.pro")
fmt.Fprintln(w, "https://git.mchus.pro/mchus/logpile")
return nil
}

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

@@ -0,0 +1,164 @@
package exporter
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// TestGenerateReanimatorExample generates an example reanimator.json file
// This test is marked as skipped by default - run with: go test -v -run TestGenerateReanimatorExample
func TestGenerateReanimatorExample(t *testing.T) {
t.Skip("Skip by default - run manually to generate example")
// Create realistic test data matching import-example-full.json structure
result := &models.AnalysisResult{
Filename: "redfish://10.10.10.103",
SourceType: "api",
Protocol: "redfish",
TargetHost: "10.10.10.103",
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Supermicro",
ProductName: "X12DPG-QT6",
SerialNumber: "21D634101",
PartNumber: "X12DPG-QT6-REV1.01",
UUID: "d7ef2fe5-2fd0-11f0-910a-346f11040868",
},
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "06.08.05"},
{DeviceName: "BMC", Version: "5.17.00"},
{DeviceName: "CPLD", Version: "01.02.03"},
},
CPUs: []models.CPU{
{
Socket: 0,
Model: "INTEL(R) XEON(R) GOLD 6530",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
MaxFreqMHz: 4000,
},
{
Socket: 1,
Model: "INTEL(R) XEON(R) GOLD 6530",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
MaxFreqMHz: 4000,
},
},
Memory: []models.MemoryDIMM{
{
Slot: "CPU0_C0D0",
Location: "CPU0_C0D0",
Present: true,
SizeMB: 32768,
Type: "DDR5",
MaxSpeedMHz: 4800,
CurrentSpeedMHz: 4800,
Manufacturer: "Hynix",
SerialNumber: "80AD032419E17CEEC1",
PartNumber: "HMCG88AGBRA191N",
Status: "OK",
},
{
Slot: "CPU1_C0D0",
Location: "CPU1_C0D0",
Present: true,
SizeMB: 32768,
Type: "DDR5",
MaxSpeedMHz: 4800,
CurrentSpeedMHz: 4800,
Manufacturer: "Hynix",
SerialNumber: "80AD032419E17D6FBA",
PartNumber: "HMCG88AGBRA191N",
Status: "OK",
},
},
Storage: []models.Storage{
{
Slot: "OB01",
Type: "NVMe",
Model: "INTEL SSDPF2KX076T1",
SizeGB: 7680,
SerialNumber: "BTAX41900GF87P6DGN",
Manufacturer: "Intel",
Firmware: "9CV10510",
Interface: "NVMe",
Present: true,
},
{
Slot: "OB02",
Type: "NVMe",
Model: "INTEL SSDPF2KX076T1",
SizeGB: 7680,
SerialNumber: "BTAX41900BEG7P6DGN",
Manufacturer: "Intel",
Firmware: "9CV10510",
Interface: "NVMe",
Present: true,
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIeCard1",
VendorID: 32902,
DeviceID: 2912,
BDF: "0000:18:00.0",
DeviceClass: "MassStorageController",
Manufacturer: "Intel",
PartNumber: "RAID Controller",
SerialNumber: "RAID-001-12345",
LinkWidth: 8,
LinkSpeed: "Gen3",
MaxLinkWidth: 8,
MaxLinkSpeed: "Gen3",
},
},
PowerSupply: []models.PSU{
{
Slot: "0",
Present: true,
Model: "GW-CRPS3000LW",
Vendor: "Great Wall",
WattageW: 3000,
SerialNumber: "2P06C102610",
PartNumber: "V0310C9000000000",
Firmware: "00.03.05",
Status: "OK",
InputType: "ACWideRange",
InputPowerW: 137,
OutputPowerW: 104,
InputVoltage: 215.25,
},
},
},
}
// Convert to Reanimator format
reanimator, err := ConvertToReanimator(result)
if err != nil {
t.Fatalf("ConvertToReanimator failed: %v", err)
}
// Marshal to JSON with indentation
jsonData, err := json.MarshalIndent(reanimator, "", " ")
if err != nil {
t.Fatalf("Failed to marshal JSON: %v", err)
}
// Write to example file
examplePath := filepath.Join("../../example/docs", "export-example-logpile.json")
if err := os.WriteFile(examplePath, jsonData, 0644); err != nil {
t.Fatalf("Failed to write example file: %v", err)
}
t.Logf("Generated example file: %s", examplePath)
t.Logf("JSON length: %d bytes", len(jsonData))
}

View File

@@ -0,0 +1,898 @@
package exporter
import (
"fmt"
"net/url"
"regexp"
"sort"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
var cpuMicrocodeFirmwareRegex = regexp.MustCompile(`(?i)^cpu\d+\s+microcode$`)
// ConvertToReanimator converts AnalysisResult to Reanimator export format
func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, error) {
if result == nil {
return nil, fmt.Errorf("no data available for export")
}
if result.Hardware == nil {
return nil, fmt.Errorf("no hardware data available for export")
}
if result.Hardware.BoardInfo.SerialNumber == "" {
return nil, fmt.Errorf("board serial_number is required for Reanimator export")
}
// Determine target host (optional field)
targetHost := inferTargetHost(result.TargetHost, result.Filename)
collectedAt := formatRFC3339(result.CollectedAt)
export := &ReanimatorExport{
Filename: result.Filename,
SourceType: normalizeSourceType(result.SourceType),
Protocol: normalizeProtocol(result.Protocol),
TargetHost: targetHost,
CollectedAt: collectedAt,
Hardware: ReanimatorHardware{
Board: convertBoard(result.Hardware.BoardInfo),
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
CPUs: dedupeCPUs(convertCPUs(result.Hardware.CPUs, collectedAt)),
Memory: dedupeMemory(convertMemory(result.Hardware.Memory, collectedAt)),
Storage: dedupeStorage(convertStorage(result.Hardware.Storage, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeDevices(result.Hardware, collectedAt)),
PowerSupplies: dedupePSUs(convertPowerSupplies(result.Hardware.PowerSupply, collectedAt)),
},
}
return export, nil
}
// formatRFC3339 formats time in RFC3339 format, returns current time if zero
func formatRFC3339(t time.Time) string {
if t.IsZero() {
return time.Now().UTC().Format(time.RFC3339)
}
return t.UTC().Format(time.RFC3339)
}
// convertBoard converts BoardInfo to Reanimator format
func convertBoard(board models.BoardInfo) ReanimatorBoard {
return ReanimatorBoard{
Manufacturer: normalizeNullableString(board.Manufacturer),
ProductName: normalizeNullableString(board.ProductName),
SerialNumber: board.SerialNumber,
PartNumber: board.PartNumber,
UUID: board.UUID,
}
}
// convertFirmware converts firmware information to Reanimator format
func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
if len(firmware) == 0 {
return nil
}
result := make([]ReanimatorFirmware, 0, len(firmware))
for _, fw := range firmware {
if isDeviceBoundFirmwareName(fw.DeviceName) {
continue
}
result = append(result, ReanimatorFirmware{
DeviceName: fw.DeviceName,
Version: fw.Version,
})
}
if len(result) == 0 {
return nil
}
return result
}
func isDeviceBoundFirmwareName(name string) bool {
n := strings.TrimSpace(strings.ToLower(name))
if n == "" {
return false
}
if strings.HasPrefix(n, "gpu ") ||
strings.HasPrefix(n, "nvswitch ") ||
strings.HasPrefix(n, "nic ") ||
strings.HasPrefix(n, "hdd ") ||
strings.HasPrefix(n, "ssd ") ||
strings.HasPrefix(n, "nvme ") ||
strings.HasPrefix(n, "psu") {
return true
}
return cpuMicrocodeFirmwareRegex.MatchString(strings.TrimSpace(name))
}
// convertCPUs converts CPU information to Reanimator format
func convertCPUs(cpus []models.CPU, collectedAt string) []ReanimatorCPU {
if len(cpus) == 0 {
return nil
}
result := make([]ReanimatorCPU, 0, len(cpus))
for _, cpu := range cpus {
manufacturer := inferCPUManufacturer(cpu.Model)
cpuStatus := normalizeStatus(cpu.Status, false)
if strings.TrimSpace(cpu.Status) == "" {
cpuStatus = "Unknown"
}
meta := buildStatusMeta(
cpuStatus,
cpu.StatusCheckedAt,
cpu.StatusChangedAt,
cpu.StatusAtCollect,
cpu.StatusHistory,
cpu.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorCPU{
Socket: cpu.Socket,
Model: cpu.Model,
Cores: cpu.Cores,
Threads: cpu.Threads,
FrequencyMHz: cpu.FrequencyMHz,
MaxFrequencyMHz: cpu.MaxFreqMHz,
Manufacturer: manufacturer,
Status: cpuStatus,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
// convertMemory converts memory modules to Reanimator format
func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorMemory {
if len(memory) == 0 {
return nil
}
result := make([]ReanimatorMemory, 0, len(memory))
for _, mem := range memory {
status := normalizeStatus(mem.Status, true)
if strings.TrimSpace(mem.Status) == "" {
if mem.Present {
status = "OK"
} else {
status = "Empty"
}
}
meta := buildStatusMeta(
status,
mem.StatusCheckedAt,
mem.StatusChangedAt,
mem.StatusAtCollect,
mem.StatusHistory,
mem.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorMemory{
Slot: mem.Slot,
Location: mem.Location,
Present: mem.Present,
SizeMB: mem.SizeMB,
Type: mem.Type,
MaxSpeedMHz: mem.MaxSpeedMHz,
CurrentSpeedMHz: mem.CurrentSpeedMHz,
Manufacturer: mem.Manufacturer,
SerialNumber: mem.SerialNumber,
PartNumber: mem.PartNumber,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
// convertStorage converts storage devices to Reanimator format
func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorStorage {
if len(storage) == 0 {
return nil
}
result := make([]ReanimatorStorage, 0, len(storage))
for _, stor := range storage {
// Skip storage without serial number
if stor.SerialNumber == "" {
continue
}
status := inferStorageStatus(stor)
if strings.TrimSpace(stor.Status) != "" {
status = normalizeStatus(stor.Status, false)
}
meta := buildStatusMeta(
status,
stor.StatusCheckedAt,
stor.StatusChangedAt,
stor.StatusAtCollect,
stor.StatusHistory,
stor.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorStorage{
Slot: stor.Slot,
Type: stor.Type,
Model: stor.Model,
SizeGB: stor.SizeGB,
SerialNumber: stor.SerialNumber,
Manufacturer: stor.Manufacturer,
Firmware: stor.Firmware,
Interface: stor.Interface,
Present: stor.Present,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
func convertPCIeDevices(hw *models.HardwareConfig, collectedAt string) []ReanimatorPCIe {
result := make([]ReanimatorPCIe, 0)
gpuSlots := make(map[string]struct{}, len(hw.GPUs))
nvswitchFirmwareBySlot := buildNVSwitchFirmwareBySlot(hw.Firmware)
for _, gpu := range hw.GPUs {
slot := strings.ToLower(strings.TrimSpace(gpu.Slot))
if slot != "" {
gpuSlots[slot] = struct{}{}
}
}
// Convert regular PCIe devices
for _, pcie := range hw.PCIeDevices {
slot := strings.ToLower(strings.TrimSpace(pcie.Slot))
if _, isDedicatedGPU := gpuSlots[slot]; isDedicatedGPU || isDisplayClass(pcie.DeviceClass) {
// Skip GPU-like PCIe entries to avoid duplicates:
// dedicated GPUs are exported from hw.GPUs with richer metadata.
continue
}
serialNumber := normalizedSerial(pcie.SerialNumber)
// Determine model (prefer PartNumber, fallback to DeviceClass)
model := pcie.PartNumber
if model == "" {
model = pcie.DeviceClass
}
status := normalizeStatus(pcie.Status, false)
firmware := ""
if isNVSwitchPCIeDevice(pcie) {
firmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
}
meta := buildStatusMeta(
status,
pcie.StatusCheckedAt,
pcie.StatusChangedAt,
pcie.StatusAtCollect,
pcie.StatusHistory,
pcie.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorPCIe{
Slot: pcie.Slot,
VendorID: pcie.VendorID,
DeviceID: pcie.DeviceID,
BDF: pcie.BDF,
DeviceClass: pcie.DeviceClass,
Manufacturer: pcie.Manufacturer,
Model: model,
LinkWidth: pcie.LinkWidth,
LinkSpeed: pcie.LinkSpeed,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcie.MaxLinkSpeed,
SerialNumber: serialNumber,
Firmware: firmware,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
// Convert GPUs as PCIe devices
for _, gpu := range hw.GPUs {
serialNumber := normalizedSerial(gpu.SerialNumber)
// Determine device class
deviceClass := "DisplayController"
status := normalizeStatus(gpu.Status, false)
meta := buildStatusMeta(
status,
gpu.StatusCheckedAt,
gpu.StatusChangedAt,
gpu.StatusAtCollect,
gpu.StatusHistory,
gpu.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorPCIe{
Slot: gpu.Slot,
VendorID: gpu.VendorID,
DeviceID: gpu.DeviceID,
BDF: gpu.BDF,
DeviceClass: deviceClass,
Manufacturer: gpu.Manufacturer,
Model: gpu.Model,
LinkWidth: gpu.CurrentLinkWidth,
LinkSpeed: gpu.CurrentLinkSpeed,
MaxLinkWidth: gpu.MaxLinkWidth,
MaxLinkSpeed: gpu.MaxLinkSpeed,
SerialNumber: serialNumber,
Firmware: gpu.Firmware,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
// Convert network adapters as PCIe devices
for _, nic := range hw.NetworkAdapters {
if !nic.Present {
continue
}
serialNumber := normalizedSerial(nic.SerialNumber)
status := normalizeStatus(nic.Status, false)
meta := buildStatusMeta(
status,
nic.StatusCheckedAt,
nic.StatusChangedAt,
nic.StatusAtCollect,
nic.StatusHistory,
nic.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorPCIe{
Slot: nic.Slot,
VendorID: nic.VendorID,
DeviceID: nic.DeviceID,
BDF: "",
DeviceClass: "NetworkController",
Manufacturer: nic.Vendor,
Model: nic.Model,
LinkWidth: 0,
LinkSpeed: "",
MaxLinkWidth: 0,
MaxLinkSpeed: "",
SerialNumber: serialNumber,
Firmware: nic.Firmware,
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
func isNVSwitchPCIeDevice(pcie models.PCIeDevice) bool {
deviceClass := strings.TrimSpace(pcie.DeviceClass)
if strings.EqualFold(deviceClass, "NVSwitch") {
return true
}
slot := normalizeNVSwitchSlotForLookup(pcie.Slot)
return strings.HasPrefix(slot, "NVSWITCH")
}
func buildNVSwitchFirmwareBySlot(firmware []models.FirmwareInfo) map[string]string {
result := make(map[string]string)
for _, fw := range firmware {
name := strings.TrimSpace(fw.DeviceName)
if !strings.HasPrefix(strings.ToUpper(name), "NVSWITCH ") {
continue
}
rest := strings.TrimSpace(name[len("NVSwitch "):])
if rest == "" {
continue
}
slot := rest
if idx := strings.Index(rest, " ("); idx > 0 {
slot = strings.TrimSpace(rest[:idx])
}
slot = normalizeNVSwitchSlotForLookup(slot)
if slot == "" {
continue
}
if _, exists := result[slot]; exists {
continue
}
version := strings.TrimSpace(fw.Version)
if version == "" {
continue
}
result[slot] = version
}
return result
}
func normalizeNVSwitchSlotForLookup(slot string) string {
normalized := strings.ToUpper(strings.TrimSpace(slot))
if strings.HasPrefix(normalized, "NVSWITCHNVSWITCH") {
return "NVSWITCH" + strings.TrimPrefix(normalized, "NVSWITCHNVSWITCH")
}
return normalized
}
func isDisplayClass(deviceClass string) bool {
class := strings.ToLower(strings.TrimSpace(deviceClass))
return strings.Contains(class, "display") ||
strings.Contains(class, "vga") ||
strings.Contains(class, "3d controller")
}
// convertPowerSupplies converts power supplies to Reanimator format
func convertPowerSupplies(psus []models.PSU, collectedAt string) []ReanimatorPSU {
if len(psus) == 0 {
return nil
}
result := make([]ReanimatorPSU, 0, len(psus))
for _, psu := range psus {
// Skip PSUs without serial number (if not present)
if !psu.Present || psu.SerialNumber == "" {
continue
}
status := normalizeStatus(psu.Status, false)
meta := buildStatusMeta(
status,
psu.StatusCheckedAt,
psu.StatusChangedAt,
psu.StatusAtCollect,
psu.StatusHistory,
psu.ErrorDescription,
collectedAt,
)
result = append(result, ReanimatorPSU{
Slot: psu.Slot,
Present: psu.Present,
Model: psu.Model,
Vendor: psu.Vendor,
WattageW: psu.WattageW,
SerialNumber: psu.SerialNumber,
PartNumber: psu.PartNumber,
Firmware: psu.Firmware,
Status: status,
InputType: psu.InputType,
InputPowerW: psu.InputPowerW,
OutputPowerW: psu.OutputPowerW,
InputVoltage: psu.InputVoltage,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
StatusAtCollect: meta.StatusAtCollection,
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result
}
type convertedStatusMeta struct {
StatusCheckedAt string
StatusChangedAt string
StatusAtCollection *ReanimatorStatusAtCollection
StatusHistory []ReanimatorStatusHistoryEntry
ErrorDescription string
}
func buildStatusMeta(
currentStatus string,
checkedAt time.Time,
changedAt time.Time,
statusAtCollection *models.StatusAtCollection,
history []models.StatusHistoryEntry,
errorDescription string,
collectedAt string,
) convertedStatusMeta {
meta := convertedStatusMeta{
StatusCheckedAt: formatOptionalRFC3339(checkedAt),
StatusChangedAt: formatOptionalRFC3339(changedAt),
ErrorDescription: strings.TrimSpace(errorDescription),
}
convertedHistory := make([]ReanimatorStatusHistoryEntry, 0, len(history))
for _, h := range history {
changed := formatOptionalRFC3339(h.ChangedAt)
if changed == "" {
continue
}
convertedHistory = append(convertedHistory, ReanimatorStatusHistoryEntry{
Status: normalizeStatus(h.Status, true),
ChangedAt: changed,
Details: strings.TrimSpace(h.Details),
})
}
sort.Slice(convertedHistory, func(i, j int) bool {
return convertedHistory[i].ChangedAt < convertedHistory[j].ChangedAt
})
if len(convertedHistory) > 0 {
meta.StatusHistory = convertedHistory
if meta.StatusChangedAt == "" {
meta.StatusChangedAt = convertedHistory[len(convertedHistory)-1].ChangedAt
}
}
if statusAtCollection != nil {
at := formatOptionalRFC3339(statusAtCollection.At)
if at != "" && strings.TrimSpace(statusAtCollection.Status) != "" {
meta.StatusAtCollection = &ReanimatorStatusAtCollection{
Status: normalizeStatus(statusAtCollection.Status, true),
At: at,
}
}
}
if meta.StatusAtCollection == nil && strings.TrimSpace(currentStatus) != "" && collectedAt != "" {
meta.StatusAtCollection = &ReanimatorStatusAtCollection{
Status: currentStatus,
At: collectedAt,
}
}
if meta.StatusCheckedAt == "" && len(meta.StatusHistory) > 0 {
meta.StatusCheckedAt = meta.StatusHistory[len(meta.StatusHistory)-1].ChangedAt
}
if meta.StatusCheckedAt == "" && strings.TrimSpace(currentStatus) != "" && collectedAt != "" {
meta.StatusCheckedAt = collectedAt
}
return meta
}
func formatOptionalRFC3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
func dedupeFirmware(items []ReanimatorFirmware) []ReanimatorFirmware {
if len(items) < 2 {
return items
}
seen := make(map[string]struct{}, len(items))
result := make([]ReanimatorFirmware, 0, len(items))
for _, item := range items {
key := strings.ToLower(strings.TrimSpace(item.DeviceName))
if key == "" {
key = strings.ToLower(strings.TrimSpace(item.Version))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func dedupeCPUs(items []ReanimatorCPU) []ReanimatorCPU {
if len(items) < 2 {
return items
}
seen := make(map[int]struct{}, len(items))
result := make([]ReanimatorCPU, 0, len(items))
for _, item := range items {
if _, ok := seen[item.Socket]; ok {
continue
}
seen[item.Socket] = struct{}{}
result = append(result, item)
}
return result
}
func dedupeMemory(items []ReanimatorMemory) []ReanimatorMemory {
if len(items) < 2 {
return items
}
seen := make(map[string]struct{}, len(items))
result := make([]ReanimatorMemory, 0, len(items))
for _, item := range items {
key := strings.ToLower(strings.TrimSpace(item.Slot))
if key == "" {
key = strings.ToLower(strings.TrimSpace(item.Location))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func dedupeStorage(items []ReanimatorStorage) []ReanimatorStorage {
if len(items) < 2 {
return items
}
seen := make(map[string]struct{}, len(items))
result := make([]ReanimatorStorage, 0, len(items))
for _, item := range items {
key := strings.ToLower(strings.TrimSpace(item.SerialNumber))
if key == "" {
key = "slot:" + strings.ToLower(strings.TrimSpace(item.Slot))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func dedupePSUs(items []ReanimatorPSU) []ReanimatorPSU {
if len(items) < 2 {
return items
}
seen := make(map[string]struct{}, len(items))
result := make([]ReanimatorPSU, 0, len(items))
for _, item := range items {
key := strings.ToLower(strings.TrimSpace(item.SerialNumber))
if key == "" {
key = "slot:" + strings.ToLower(strings.TrimSpace(item.Slot))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, item)
}
return result
}
func dedupePCIe(items []ReanimatorPCIe) []ReanimatorPCIe {
if len(items) < 2 {
return items
}
type scored struct {
item ReanimatorPCIe
score int
idx int
}
byKey := make(map[string]scored, len(items))
order := make([]string, 0, len(items))
for i, item := range items {
key := pcieDedupKey(item)
curr := scored{item: item, score: pcieQualityScore(item), idx: i}
existing, ok := byKey[key]
if !ok {
byKey[key] = curr
order = append(order, key)
continue
}
if curr.score > existing.score {
byKey[key] = curr
}
}
result := make([]ReanimatorPCIe, 0, len(byKey))
for _, key := range order {
result = append(result, byKey[key].item)
}
return result
}
func pcieDedupKey(item ReanimatorPCIe) string {
slot := strings.ToLower(strings.TrimSpace(item.Slot))
serial := strings.ToLower(strings.TrimSpace(item.SerialNumber))
bdf := strings.ToLower(strings.TrimSpace(item.BDF))
if slot != "" {
return "slot:" + slot
}
if serial != "" {
return "sn:" + serial
}
if bdf != "" {
return "bdf:" + bdf
}
return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model))
}
func pcieQualityScore(item ReanimatorPCIe) int {
score := 0
if strings.TrimSpace(item.SerialNumber) != "" {
score += 4
}
if strings.TrimSpace(item.Model) != "" && !isGenericPCIeModel(item.Model) {
score += 3
}
status := strings.ToLower(strings.TrimSpace(item.Status))
if status == "ok" || status == "warning" || status == "critical" {
score += 2
}
if strings.TrimSpace(item.BDF) != "" {
score++
}
if strings.EqualFold(strings.TrimSpace(item.DeviceClass), "DisplayController") {
score++
}
return score
}
func isGenericPCIeModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case "", "unknown", "vga", "3d controller", "display controller":
return true
default:
return false
}
}
// inferCPUManufacturer determines CPU manufacturer from model string
func inferCPUManufacturer(model string) string {
upper := strings.ToUpper(model)
// Intel patterns
if strings.Contains(upper, "INTEL") ||
strings.Contains(upper, "XEON") ||
strings.Contains(upper, "CORE I") {
return "Intel"
}
// AMD patterns
if strings.Contains(upper, "AMD") ||
strings.Contains(upper, "EPYC") ||
strings.Contains(upper, "RYZEN") ||
strings.Contains(upper, "THREADRIPPER") {
return "AMD"
}
// ARM patterns
if strings.Contains(upper, "ARM") ||
strings.Contains(upper, "CORTEX") {
return "ARM"
}
// Ampere patterns
if strings.Contains(upper, "AMPERE") ||
strings.Contains(upper, "ALTRA") {
return "Ampere"
}
return ""
}
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
}
}
// inferStorageStatus determines storage device status
func inferStorageStatus(stor models.Storage) string {
if !stor.Present {
return "Unknown"
}
return "Unknown"
}
func normalizeSourceType(sourceType string) string {
normalized := strings.ToLower(strings.TrimSpace(sourceType))
switch normalized {
case "api", "logfile", "manual":
return normalized
default:
return ""
}
}
func normalizeProtocol(protocol string) string {
normalized := strings.ToLower(strings.TrimSpace(protocol))
switch normalized {
case "redfish", "ipmi", "snmp", "ssh":
return normalized
default:
return ""
}
}
func normalizeNullableString(v string) string {
trimmed := strings.TrimSpace(v)
if strings.EqualFold(trimmed, "NULL") {
return ""
}
return trimmed
}
func normalizeStatus(status string, allowEmpty bool) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "ok":
return "OK"
case "pass":
return "OK"
case "warning":
return "Warning"
case "critical":
return "Critical"
case "fail":
return "Critical"
case "unknown":
return "Unknown"
case "empty":
if allowEmpty {
return "Empty"
}
return "Unknown"
default:
if allowEmpty {
return "Unknown"
}
return "Unknown"
}
}
var (
ipv4Regex = regexp.MustCompile(`(?:^|[^0-9])((?:\d{1,3}\.){3}\d{1,3})(?:[^0-9]|$)`)
)
func inferTargetHost(targetHost, filename string) string {
if trimmed := strings.TrimSpace(targetHost); trimmed != "" {
return trimmed
}
candidate := strings.TrimSpace(filename)
if candidate == "" {
return ""
}
if parsed, err := url.Parse(candidate); err == nil && parsed.Hostname() != "" {
return parsed.Hostname()
}
if submatches := ipv4Regex.FindStringSubmatch(candidate); len(submatches) > 1 {
return submatches[1]
}
return ""
}

View File

@@ -0,0 +1,701 @@
package exporter
import (
"encoding/json"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestConvertToReanimator(t *testing.T) {
tests := []struct {
name string
input *models.AnalysisResult
wantErr bool
errMsg string
}{
{
name: "nil result",
input: nil,
wantErr: true,
errMsg: "no data available",
},
{
name: "no hardware",
input: &models.AnalysisResult{
Filename: "test.json",
},
wantErr: true,
errMsg: "no hardware data available",
},
{
name: "no board serial",
input: &models.AnalysisResult{
Filename: "test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{},
},
},
wantErr: true,
errMsg: "board serial_number is required",
},
{
name: "valid minimal data",
input: &models.AnalysisResult{
Filename: "test.json",
SourceType: "api",
Protocol: "redfish",
TargetHost: "10.10.10.10",
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Supermicro",
ProductName: "X12DPG-QT6",
SerialNumber: "TEST123",
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ConvertToReanimator(tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.errMsg)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if result == nil {
t.Error("expected non-nil result")
return
}
if result.Hardware.Board.SerialNumber != tt.input.Hardware.BoardInfo.SerialNumber {
t.Errorf("board serial mismatch: got %q, want %q",
result.Hardware.Board.SerialNumber,
tt.input.Hardware.BoardInfo.SerialNumber)
}
})
}
}
func TestInferCPUManufacturer(t *testing.T) {
tests := []struct {
model string
want string
}{
{"INTEL(R) XEON(R) GOLD 6530", "Intel"},
{"Intel Core i9-12900K", "Intel"},
{"AMD EPYC 7763", "AMD"},
{"AMD Ryzen 9 5950X", "AMD"},
{"ARM Cortex-A78", "ARM"},
{"Ampere Altra Max", "Ampere"},
{"Unknown CPU Model", ""},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
got := inferCPUManufacturer(tt.model)
if got != tt.want {
t.Errorf("inferCPUManufacturer(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizedSerial(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{
name: "empty",
in: "",
want: "",
},
{
name: "n_a",
in: "N/A",
want: "",
},
{
name: "unknown",
in: "unknown",
want: "",
},
{
name: "normal",
in: "SN123",
want: "SN123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizedSerial(tt.in)
if got != tt.want {
t.Errorf("normalizedSerial() = %q, want %q", got, tt.want)
}
})
}
}
func TestInferStorageStatus(t *testing.T) {
tests := []struct {
name string
stor models.Storage
want string
}{
{
name: "present",
stor: models.Storage{
Present: true,
},
want: "Unknown",
},
{
name: "not present",
stor: models.Storage{
Present: false,
},
want: "Unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := inferStorageStatus(tt.stor)
if got != tt.want {
t.Errorf("inferStorageStatus() = %q, want %q", got, tt.want)
}
})
}
}
func TestNormalizeStatus_PassFail(t *testing.T) {
if got := normalizeStatus("PASS", false); got != "OK" {
t.Fatalf("expected PASS -> OK, got %q", got)
}
if got := normalizeStatus("FAIL", false); got != "Critical" {
t.Fatalf("expected FAIL -> Critical, got %q", got)
}
}
func TestConvertCPUs(t *testing.T) {
cpus := []models.CPU{
{
Socket: 0,
Model: "INTEL(R) XEON(R) GOLD 6530",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
MaxFreqMHz: 4000,
},
{
Socket: 1,
Model: "AMD EPYC 7763",
Cores: 64,
Threads: 128,
FrequencyMHz: 2450,
MaxFreqMHz: 3500,
},
}
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
if len(result) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(result))
}
if result[0].Manufacturer != "Intel" {
t.Errorf("expected Intel manufacturer for first CPU, got %q", result[0].Manufacturer)
}
if result[1].Manufacturer != "AMD" {
t.Errorf("expected AMD manufacturer for second CPU, got %q", result[1].Manufacturer)
}
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
}
func TestConvertMemory(t *testing.T) {
memory := []models.MemoryDIMM{
{
Slot: "CPU0_C0D0",
Present: true,
SizeMB: 32768,
Type: "DDR5",
SerialNumber: "TEST-MEM-001",
Status: "OK",
},
{
Slot: "CPU0_C1D0",
Present: false,
},
}
result := convertMemory(memory, "2026-02-10T15:30:00Z")
if len(result) != 2 {
t.Fatalf("expected 2 memory modules, got %d", len(result))
}
if result[0].Status != "OK" {
t.Errorf("expected OK status for first module, got %q", result[0].Status)
}
if result[1].Status != "Empty" {
t.Errorf("expected Empty status for second module, got %q", result[1].Status)
}
}
func TestConvertStorage(t *testing.T) {
storage := []models.Storage{
{
Slot: "OB01",
Type: "NVMe",
Model: "INTEL SSDPF2KX076T1",
SerialNumber: "BTAX41900GF87P6DGN",
Present: true,
},
{
Slot: "OB02",
Type: "NVMe",
Model: "INTEL SSDPF2KX076T1",
SerialNumber: "", // No serial - should be skipped
Present: true,
},
}
result := convertStorage(storage, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result))
}
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
}
func TestConvertPCIeDevices(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIeCard1",
VendorID: 32902,
DeviceID: 2912,
BDF: "0000:18:00.0",
DeviceClass: "MassStorageController",
Manufacturer: "Intel",
PartNumber: "RSP3DD080F",
SerialNumber: "RAID-001",
},
{
Slot: "PCIeCard2",
DeviceClass: "NetworkController",
Manufacturer: "Mellanox",
SerialNumber: "", // Should be generated
},
},
GPUs: []models.GPU{
{
Slot: "GPU1",
Model: "NVIDIA A100",
Manufacturer: "NVIDIA",
SerialNumber: "GPU-001",
Status: "OK",
},
},
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "NIC1",
Model: "ConnectX-6",
Vendor: "Mellanox",
Present: true,
SerialNumber: "NIC-001",
},
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
if len(result) != 4 {
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
}
// Check that serial is empty for second PCIe device (no auto-generation)
if result[1].SerialNumber != "" {
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
}
// Check GPU was included
foundGPU := false
for _, dev := range result {
if dev.SerialNumber == "GPU-001" {
foundGPU = true
if dev.DeviceClass != "DisplayController" {
t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass)
}
break
}
}
if !foundGPU {
t.Error("expected GPU to be included in PCIe devices")
}
}
func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
hw := &models.HardwareConfig{
Firmware: []models.FirmwareInfo{
{
DeviceName: "NVSwitch NVSWITCH1 (965-25612-0002-000)",
Version: "96.10.6D.00.01",
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "NVSWITCH1",
DeviceClass: "NVSwitch",
BDF: "0000:06:00.0",
// SerialNumber empty on purpose; should remain empty.
},
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 PCIe device, got %d", len(result))
}
if result[0].SerialNumber != "" {
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
}
if result[0].Firmware != "96.10.6D.00.01" {
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
}
}
func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{
Slot: "#GPU0",
DeviceClass: "3D Controller",
},
},
GPUs: []models.GPU{
{
Slot: "#GPU0",
Model: "B200 180GB HBM3e",
Manufacturer: "NVIDIA",
SerialNumber: "1655024043371",
Status: "OK",
},
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
}
if result[0].DeviceClass != "DisplayController" {
t.Fatalf("expected GPU record with DisplayController class, got %q", result[0].DeviceClass)
}
if result[0].Status != "OK" {
t.Fatalf("expected GPU status OK, got %q", result[0].Status)
}
}
func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "#GPU6",
Model: "B200 180GB HBM3e",
Manufacturer: "NVIDIA",
SerialNumber: "1655024043204",
Status: "Critical",
StatusHistory: []models.StatusHistoryEntry{
{
Status: "Critical",
ChangedAt: time.Date(2026, 1, 12, 15, 5, 18, 0, time.UTC),
Details: "BIOS miss F_GPU6",
},
},
ErrorDescription: "BIOS miss F_GPU6",
},
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 converted GPU, got %d", len(result))
}
if len(result[0].StatusHistory) != 1 {
t.Fatalf("expected 1 history entry, got %d", len(result[0].StatusHistory))
}
if result[0].StatusHistory[0].ChangedAt != "2026-01-12T15:05:18Z" {
t.Fatalf("unexpected history changed_at: %q", result[0].StatusHistory[0].ChangedAt)
}
if result[0].StatusAtCollect == nil || result[0].StatusAtCollect.At != "2026-02-10T15:30:00Z" {
t.Fatalf("expected status_at_collection to be populated from collected_at")
}
}
func TestConvertPowerSupplies(t *testing.T) {
psus := []models.PSU{
{
Slot: "0",
Present: true,
Model: "GW-CRPS3000LW",
Vendor: "Great Wall",
WattageW: 3000,
SerialNumber: "PSU-001",
Status: "OK",
},
{
Slot: "1",
Present: false,
SerialNumber: "", // Not present, should be skipped
},
}
result := convertPowerSupplies(psus, "2026-02-10T15:30:00Z")
if len(result) != 1 {
t.Fatalf("expected 1 PSU (skipped empty), got %d", len(result))
}
if result[0].Status != "OK" {
t.Errorf("expected OK status, got %q", result[0].Status)
}
}
func TestConvertBoardNormalizesNULL(t *testing.T) {
board := convertBoard(models.BoardInfo{
Manufacturer: " NULL ",
ProductName: "null",
SerialNumber: "TEST123",
})
if board.Manufacturer != "" {
t.Fatalf("expected empty manufacturer, got %q", board.Manufacturer)
}
if board.ProductName != "" {
t.Fatalf("expected empty product_name, got %q", board.ProductName)
}
}
func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) {
result, err := ConvertToReanimator(&models.AnalysisResult{
Filename: "redfish://10.0.0.1",
SourceType: "archive",
TargetHost: "10.0.0.1",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "TEST123"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
payload, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(payload), `"source_type"`) {
t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload))
}
}
func TestTargetHostOmittedWhenUnavailable(t *testing.T) {
result, err := ConvertToReanimator(&models.AnalysisResult{
Filename: "test.json",
SourceType: "api",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "TEST123"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
payload, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(payload), `"target_host"`) {
t.Fatalf("expected target_host to be omitted when unavailable, got %s", string(payload))
}
}
func TestInferTargetHost(t *testing.T) {
tests := []struct {
name string
targetHost string
filename string
want string
}{
{
name: "explicit target host wins",
targetHost: "10.0.0.10",
filename: "redfish://10.0.0.20",
want: "10.0.0.10",
},
{
name: "hostname from URL",
filename: "redfish://10.10.10.103",
want: "10.10.10.103",
},
{
name: "ip extracted from archive name",
filename: "nvidia_bug_report_192.168.12.34.tar.gz",
want: "192.168.12.34",
},
{
name: "no host available",
filename: "test.json",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := inferTargetHost(tt.targetHost, tt.filename)
if got != tt.want {
t.Fatalf("inferTargetHost() = %q, want %q", got, tt.want)
}
})
}
}
func TestConvertToReanimator_DeduplicatesAllSections(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dup-test.json",
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Firmware: []models.FirmwareInfo{
{DeviceName: "BMC", Version: "1.0"},
{DeviceName: "BMC", Version: "1.1"},
},
CPUs: []models.CPU{
{Socket: 0, Model: "CPU-A"},
{Socket: 0, Model: "CPU-A-DUP"},
},
Memory: []models.MemoryDIMM{
{Slot: "DIMM_A1", Present: true, SerialNumber: "MEM-1", Status: "OK"},
{Slot: "DIMM_A1", Present: true, SerialNumber: "MEM-1-DUP", Status: "OK"},
},
Storage: []models.Storage{
{Slot: "U.2-1", SerialNumber: "SSD-1", Model: "Disk1", Present: true},
{Slot: "U.2-2", SerialNumber: "SSD-1", Model: "Disk1-dup", Present: true},
},
PCIeDevices: []models.PCIeDevice{
{Slot: "#GPU0", DeviceClass: "3D Controller", BDF: "17:00.0"},
{Slot: "SLOT-NIC1", DeviceClass: "NetworkController", BDF: "18:00.0"},
{Slot: "SLOT-NIC1", DeviceClass: "NetworkController", BDF: "18:00.1"},
},
GPUs: []models.GPU{
{Slot: "#GPU0", Model: "B200 180GB HBM3e", SerialNumber: "GPU-1", Status: "OK"},
},
PowerSupply: []models.PSU{
{Slot: "0", Present: true, SerialNumber: "PSU-1", Status: "OK"},
{Slot: "1", Present: true, SerialNumber: "PSU-1", Status: "OK"},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.Firmware) != 1 {
t.Fatalf("expected deduped firmware len=1, got %d", len(out.Hardware.Firmware))
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected deduped cpus len=1, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected deduped memory len=1, got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected deduped storage len=1, got %d", len(out.Hardware.Storage))
}
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected deduped psu len=1, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected deduped pcie len=2 (gpu+nic), got %d", len(out.Hardware.PCIeDevices))
}
gpuCount := 0
for _, dev := range out.Hardware.PCIeDevices {
if dev.Slot == "#GPU0" {
gpuCount++
}
}
if gpuCount != 1 {
t.Fatalf("expected single #GPU0 record, got %d", gpuCount)
}
}
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
input := &models.AnalysisResult{
Filename: "fw-filter-test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "1.0.0"},
{DeviceName: "BMC", Version: "2.0.0"},
{DeviceName: "GPU GPUSXM1 (692-2G520-0280-501)", Version: "96.00.D0.00.03"},
{DeviceName: "NVSwitch NVSWITCH0 (965-25612-0002-000)", Version: "96.10.6D.00.01"},
{DeviceName: "NIC #CPU1_PCIE9 (MCX512A-ACAT)", Version: "28.38.1900"},
{DeviceName: "CPU0 Microcode", Version: "0x2b000643"},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.Firmware) != 2 {
t.Fatalf("expected only machine-level firmware entries, got %d", len(out.Hardware.Firmware))
}
got := map[string]string{}
for _, fw := range out.Hardware.Firmware {
got[fw.DeviceName] = fw.Version
}
if got["BIOS"] != "1.0.0" {
t.Fatalf("expected BIOS firmware to be kept")
}
if got["BMC"] != "2.0.0" {
t.Fatalf("expected BMC firmware to be kept")
}
if _, exists := got["GPU GPUSXM1 (692-2G520-0280-501)"]; exists {
t.Fatalf("expected GPU firmware to be excluded from hardware.firmware")
}
if _, exists := got["NVSwitch NVSWITCH0 (965-25612-0002-000)"]; exists {
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
}
}

View File

@@ -0,0 +1,293 @@
package exporter
import (
"encoding/json"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// TestFullReanimatorExport tests complete export with realistic data
func TestFullReanimatorExport(t *testing.T) {
// Create a realistic AnalysisResult similar to import-example-full.json
result := &models.AnalysisResult{
Filename: "redfish://10.10.10.103",
SourceType: "api",
Protocol: "redfish",
TargetHost: "10.10.10.103",
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
Manufacturer: "Supermicro",
ProductName: "X12DPG-QT6",
SerialNumber: "21D634101",
PartNumber: "X12DPG-QT6-REV1.01",
UUID: "d7ef2fe5-2fd0-11f0-910a-346f11040868",
},
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "06.08.05"},
{DeviceName: "BMC", Version: "5.17.00"},
{DeviceName: "CPLD", Version: "01.02.03"},
},
CPUs: []models.CPU{
{
Socket: 0,
Model: "INTEL(R) XEON(R) GOLD 6530",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
MaxFreqMHz: 4000,
},
{
Socket: 1,
Model: "INTEL(R) XEON(R) GOLD 6530",
Cores: 32,
Threads: 64,
FrequencyMHz: 2100,
MaxFreqMHz: 4000,
},
},
Memory: []models.MemoryDIMM{
{
Slot: "CPU0_C0D0",
Location: "CPU0_C0D0",
Present: true,
SizeMB: 32768,
Type: "DDR5",
MaxSpeedMHz: 4800,
CurrentSpeedMHz: 4800,
Manufacturer: "Hynix",
SerialNumber: "80AD032419E17CEEC1",
PartNumber: "HMCG88AGBRA191N",
Status: "OK",
},
{
Slot: "CPU0_C1D0",
Location: "CPU0_C1D0",
Present: false,
SizeMB: 0,
Type: "",
MaxSpeedMHz: 0,
CurrentSpeedMHz: 0,
Status: "Empty",
},
},
Storage: []models.Storage{
{
Slot: "OB01",
Type: "NVMe",
Model: "INTEL SSDPF2KX076T1",
SizeGB: 7680,
SerialNumber: "BTAX41900GF87P6DGN",
Manufacturer: "Intel",
Firmware: "9CV10510",
Interface: "NVMe",
Present: true,
},
{
Slot: "FP00HDD00",
Type: "HDD",
Model: "ST12000NM0008",
SizeGB: 12000,
SerialNumber: "ZJV01234ABC",
Manufacturer: "Seagate",
Firmware: "SN03",
Interface: "SATA",
Present: true,
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIeCard1",
VendorID: 32902,
DeviceID: 2912,
BDF: "0000:18:00.0",
DeviceClass: "MassStorageController",
Manufacturer: "Intel",
PartNumber: "RAID Controller RSP3DD080F",
LinkWidth: 8,
LinkSpeed: "Gen3",
MaxLinkWidth: 8,
MaxLinkSpeed: "Gen3",
SerialNumber: "RAID-001-12345",
},
{
Slot: "PCIeCard2",
VendorID: 5555,
DeviceID: 4401,
BDF: "0000:3b:00.0",
DeviceClass: "NetworkController",
Manufacturer: "Mellanox",
PartNumber: "ConnectX-5",
LinkWidth: 16,
LinkSpeed: "Gen3",
MaxLinkWidth: 16,
MaxLinkSpeed: "Gen3",
SerialNumber: "MT2892012345",
},
},
PowerSupply: []models.PSU{
{
Slot: "0",
Present: true,
Model: "GW-CRPS3000LW",
Vendor: "Great Wall",
WattageW: 3000,
SerialNumber: "2P06C102610",
PartNumber: "V0310C9000000000",
Firmware: "00.03.05",
Status: "OK",
InputType: "ACWideRange",
InputPowerW: 137,
OutputPowerW: 104,
InputVoltage: 215.25,
},
},
},
}
// Convert to Reanimator format
reanimator, err := ConvertToReanimator(result)
if err != nil {
t.Fatalf("ConvertToReanimator failed: %v", err)
}
// Verify top-level fields
if reanimator.Filename != "redfish://10.10.10.103" {
t.Errorf("Filename mismatch: got %q", reanimator.Filename)
}
if reanimator.SourceType != "api" {
t.Errorf("SourceType mismatch: got %q", reanimator.SourceType)
}
if reanimator.Protocol != "redfish" {
t.Errorf("Protocol mismatch: got %q", reanimator.Protocol)
}
if reanimator.TargetHost != "10.10.10.103" {
t.Errorf("TargetHost mismatch: got %q", reanimator.TargetHost)
}
if reanimator.CollectedAt != "2026-02-10T15:30:00Z" {
t.Errorf("CollectedAt mismatch: got %q", reanimator.CollectedAt)
}
// Verify hardware sections
hw := reanimator.Hardware
// Board
if hw.Board.SerialNumber != "21D634101" {
t.Errorf("Board serial mismatch: got %q", hw.Board.SerialNumber)
}
// Firmware
if len(hw.Firmware) != 3 {
t.Errorf("Expected 3 firmware entries, got %d", len(hw.Firmware))
}
// CPUs
if len(hw.CPUs) != 2 {
t.Fatalf("Expected 2 CPUs, got %d", len(hw.CPUs))
}
if hw.CPUs[0].Manufacturer != "Intel" {
t.Errorf("CPU manufacturer not inferred: got %q", hw.CPUs[0].Manufacturer)
}
if hw.CPUs[0].Status != "Unknown" {
t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status)
}
// Memory (should include empty slots)
if len(hw.Memory) != 2 {
t.Errorf("Expected 2 memory entries (including empty), got %d", len(hw.Memory))
}
if hw.Memory[1].Status != "Empty" {
t.Errorf("Empty memory slot status mismatch: got %q", hw.Memory[1].Status)
}
// Storage
if len(hw.Storage) != 2 {
t.Errorf("Expected 2 storage devices, got %d", len(hw.Storage))
}
if hw.Storage[0].Status != "Unknown" {
t.Errorf("Storage status mismatch: got %q", hw.Storage[0].Status)
}
// PCIe devices
if len(hw.PCIeDevices) != 2 {
t.Errorf("Expected 2 PCIe devices, got %d", len(hw.PCIeDevices))
}
if hw.PCIeDevices[0].Model == "" {
t.Error("PCIe model should be populated from PartNumber")
}
// Power supplies
if len(hw.PowerSupplies) != 1 {
t.Errorf("Expected 1 PSU, got %d", len(hw.PowerSupplies))
}
// Verify JSON marshaling works
jsonData, err := json.MarshalIndent(reanimator, "", " ")
if err != nil {
t.Fatalf("Failed to marshal to JSON: %v", err)
}
// Check that JSON contains expected fields
jsonStr := string(jsonData)
expectedFields := []string{
`"filename"`,
`"source_type"`,
`"protocol"`,
`"target_host"`,
`"collected_at"`,
`"hardware"`,
`"board"`,
`"cpus"`,
`"memory"`,
`"storage"`,
`"pcie_devices"`,
`"power_supplies"`,
`"firmware"`,
}
for _, field := range expectedFields {
if !strings.Contains(jsonStr, field) {
t.Errorf("JSON missing expected field: %s", field)
}
}
// Optional: print JSON for manual inspection (commented out for normal test runs)
// t.Logf("Generated Reanimator JSON:\n%s", string(jsonData))
}
// TestReanimatorExportWithoutTargetHost tests that target_host is inferred from filename
func TestReanimatorExportWithoutTargetHost(t *testing.T) {
result := &models.AnalysisResult{
Filename: "redfish://192.168.1.100",
SourceType: "api",
Protocol: "redfish",
TargetHost: "", // Empty - should be inferred
CollectedAt: time.Now(),
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{
SerialNumber: "TEST123",
},
},
}
reanimator, err := ConvertToReanimator(result)
if err != nil {
t.Fatalf("ConvertToReanimator failed: %v", err)
}
if reanimator.TargetHost != "192.168.1.100" {
t.Errorf("Expected target_host to be inferred from filename, got %q", reanimator.TargetHost)
}
}

View File

@@ -0,0 +1,149 @@
package exporter
// ReanimatorExport represents the top-level structure for Reanimator format export
type ReanimatorExport struct {
Filename string `json:"filename"`
SourceType string `json:"source_type,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
CollectedAt string `json:"collected_at"` // RFC3339 format
Hardware ReanimatorHardware `json:"hardware"`
}
// ReanimatorHardware contains all hardware components
type ReanimatorHardware struct {
Board ReanimatorBoard `json:"board"`
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
Memory []ReanimatorMemory `json:"memory,omitempty"`
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
type ReanimatorBoard struct {
Manufacturer string `json:"manufacturer,omitempty"`
ProductName string `json:"product_name,omitempty"`
SerialNumber string `json:"serial_number"`
PartNumber string `json:"part_number,omitempty"`
UUID string `json:"uuid,omitempty"`
}
// ReanimatorFirmware represents firmware version information
type ReanimatorFirmware struct {
DeviceName string `json:"device_name"`
Version string `json:"version"`
}
type ReanimatorStatusAtCollection struct {
Status string `json:"status"`
At string `json:"at"`
}
type ReanimatorStatusHistoryEntry struct {
Status string `json:"status"`
ChangedAt string `json:"changed_at"`
Details string `json:"details,omitempty"`
}
// ReanimatorCPU represents processor information
type ReanimatorCPU struct {
Socket int `json:"socket"`
Model string `json:"model"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorMemory represents a memory module (DIMM)
type ReanimatorMemory struct {
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Present bool `json:"present"`
SizeMB int `json:"size_mb,omitempty"`
Type string `json:"type,omitempty"`
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorStorage represents a storage device
type ReanimatorStorage struct {
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPCIe represents a PCIe device
type ReanimatorPCIe struct {
Slot string `json:"slot"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,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"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPSU represents a power supply unit
type ReanimatorPSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,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"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}

View File

@@ -43,6 +43,19 @@ const (
SeverityInfo Severity = "info"
)
// StatusAtCollection captures component status at a specific timestamp.
type StatusAtCollection struct {
Status string `json:"status"`
At time.Time `json:"at"`
}
// StatusHistoryEntry represents a status transition point.
type StatusHistoryEntry struct {
Status string `json:"status"`
ChangedAt time.Time `json:"changed_at"`
Details string `json:"details,omitempty"`
}
// SensorReading represents a single sensor reading
type SensorReading struct {
Name string `json:"name"`
@@ -83,15 +96,17 @@ type HardwareConfig struct {
// FirmwareInfo represents firmware version information
type FirmwareInfo struct {
DeviceName string `json:"device_name"`
Version string `json:"version"`
BuildTime string `json:"build_time,omitempty"`
DeviceName string `json:"device_name"`
Description string `json:"description,omitempty"`
Version string `json:"version"`
BuildTime string `json:"build_time,omitempty"`
}
// BoardInfo represents motherboard/system information
type BoardInfo struct {
Manufacturer string `json:"manufacturer,omitempty"`
ProductName string `json:"product_name,omitempty"`
Description string `json:"description,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Version string `json:"version,omitempty"`
@@ -102,6 +117,7 @@ type BoardInfo struct {
type CPU struct {
Socket int `json:"socket"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Cores int `json:"cores"`
Threads int `json:"threads"`
FrequencyMHz int `json:"frequency_mhz"`
@@ -112,12 +128,20 @@ type CPU struct {
TDP int `json:"tdp_w,omitempty"`
PPIN string `json:"ppin,omitempty"`
SerialNumber string `json:"serial_number,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"`
}
// MemoryDIMM represents a memory module
type MemoryDIMM struct {
Slot string `json:"slot"`
Location string `json:"location"`
Description string `json:"description,omitempty"`
Present bool `json:"present"`
SizeMB int `json:"size_mb"`
Type string `json:"type"`
@@ -129,6 +153,12 @@ type MemoryDIMM struct {
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
Ranks int `json:"ranks,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"`
}
// Storage represents a storage device
@@ -136,6 +166,7 @@ type Storage struct {
Slot string `json:"slot"`
Type string `json:"type"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
SizeGB int `json:"size_gb"`
SerialNumber string `json:"serial_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
@@ -144,11 +175,19 @@ type Storage struct {
Present bool `json:"present"`
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,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"`
}
// PCIeDevice represents a PCIe device
type PCIeDevice struct {
Slot string `json:"slot"`
Description string `json:"description,omitempty"`
VendorID int `json:"vendor_id"`
DeviceID int `json:"device_id"`
BDF string `json:"bdf"`
@@ -161,12 +200,20 @@ type PCIeDevice struct {
PartNumber string `json:"part_number,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
MACAddresses []string `json:"mac_addresses,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"`
}
// NIC represents a network interface card
type NIC struct {
Name string `json:"name"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
MACAddress string `json:"mac_address"`
SpeedMbps int `json:"speed_mbps,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
@@ -177,6 +224,7 @@ type PSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
@@ -189,6 +237,12 @@ type PSU struct {
InputVoltage float64 `json:"input_voltage,omitempty"`
OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,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"`
}
// GPU represents a graphics processing unit
@@ -196,6 +250,7 @@ type GPU struct {
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
@@ -220,6 +275,12 @@ type GPU struct {
CurrentLinkWidth int `json:"current_link_width,omitempty"`
CurrentLinkSpeed string `json:"current_link_speed,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"`
}
// NetworkAdapter represents a network adapter with detailed info
@@ -228,6 +289,7 @@ type NetworkAdapter struct {
Location string `json:"location"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
@@ -238,4 +300,10 @@ type NetworkAdapter struct {
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,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"`
}

View File

@@ -13,11 +13,15 @@ import (
)
const maxSingleFileSize = 10 * 1024 * 1024
const maxZipArchiveSize = 50 * 1024 * 1024
const maxGzipDecompressedSize = 50 * 1024 * 1024
// ExtractedFile represents a file extracted from archive
type ExtractedFile struct {
Path string
Content []byte
Path string
Content []byte
Truncated bool
TruncatedMessage string
}
// ExtractArchive extracts tar.gz or zip archive and returns file contents
@@ -47,6 +51,8 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
return extractTarGzFromReader(r, filename)
case ".tar":
return extractTarFromReader(r)
case ".zip":
return extractZipFromReader(r)
case ".txt", ".log":
return extractSingleFileFromReader(r, filename)
default:
@@ -118,12 +124,16 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
}
defer gzr.Close()
// Read all decompressed content into buffer
// Limit to 50MB for plain gzip files, 10MB per file for tar.gz
decompressed, err := io.ReadAll(io.LimitReader(gzr, 50*1024*1024))
// Read decompressed content with a hard cap.
// When the payload exceeds the cap, keep the first chunk and mark it as truncated.
decompressed, err := io.ReadAll(io.LimitReader(gzr, maxGzipDecompressedSize+1))
if err != nil {
return nil, fmt.Errorf("read gzip content: %w", err)
}
gzipTruncated := len(decompressed) > maxGzipDecompressedSize
if gzipTruncated {
decompressed = decompressed[:maxGzipDecompressedSize]
}
// Try to read as tar archive
tr := tar.NewReader(bytes.NewReader(decompressed))
@@ -139,12 +149,19 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
baseName = gzr.Name
}
return []ExtractedFile{
{
Path: baseName,
Content: decompressed,
},
}, nil
file := ExtractedFile{
Path: baseName,
Content: decompressed,
}
if gzipTruncated {
file.Truncated = true
file.TruncatedMessage = fmt.Sprintf(
"decompressed gzip content exceeded %d bytes and was truncated",
maxGzipDecompressedSize,
)
}
return []ExtractedFile{file}, nil
}
return nil, fmt.Errorf("tar read: %w", err)
}
@@ -219,6 +236,57 @@ func extractZip(archivePath string) ([]ExtractedFile, error) {
return files, nil
}
func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
// Read all data into memory with a hard cap
data, err := io.ReadAll(io.LimitReader(r, maxZipArchiveSize+1))
if err != nil {
return nil, fmt.Errorf("read zip data: %w", err)
}
if len(data) > maxZipArchiveSize {
return nil, fmt.Errorf("zip too large: max %d bytes", maxZipArchiveSize)
}
// Create a ReaderAt from the byte slice
readerAt := bytes.NewReader(data)
// Open the zip archive
zipReader, err := zip.NewReader(readerAt, int64(len(data)))
if err != nil {
return nil, fmt.Errorf("open zip: %w", err)
}
var files []ExtractedFile
for _, f := range zipReader.File {
if f.FileInfo().IsDir() {
continue
}
// Skip large files (>10MB)
if f.FileInfo().Size() > 10*1024*1024 {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("open file %s: %w", f.Name, err)
}
content, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return nil, fmt.Errorf("read file %s: %w", f.Name, err)
}
files = append(files, ExtractedFile{
Path: f.Name,
Content: content,
})
}
return files, nil
}
func extractSingleFile(path string) ([]ExtractedFile, error) {
f, err := os.Open(path)
if err != nil {
@@ -234,16 +302,24 @@ func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile,
if err != nil {
return nil, fmt.Errorf("read file content: %w", err)
}
if len(content) > maxSingleFileSize {
return nil, fmt.Errorf("file too large: max %d bytes", maxSingleFileSize)
truncated := len(content) > maxSingleFileSize
if truncated {
content = content[:maxSingleFileSize]
}
return []ExtractedFile{
{
Path: filepath.Base(filename),
Content: content,
},
}, nil
file := ExtractedFile{
Path: filepath.Base(filename),
Content: content,
}
if truncated {
file.Truncated = true
file.TruncatedMessage = fmt.Sprintf(
"file exceeded %d bytes and was truncated",
maxSingleFileSize,
)
}
return []ExtractedFile{file}, nil
}
// FindFileByPattern finds files matching pattern in extracted files

View File

@@ -1,6 +1,7 @@
package parser
import (
"bytes"
"os"
"path/filepath"
"strings"
@@ -46,3 +47,25 @@ func TestExtractArchiveTXT(t *testing.T) {
t.Fatalf("content mismatch")
}
}
func TestExtractArchiveFromReaderTXT_TruncatedWhenTooLarge(t *testing.T) {
large := bytes.Repeat([]byte("a"), maxSingleFileSize+1024)
files, err := ExtractArchiveFromReader(bytes.NewReader(large), "huge.log")
if err != nil {
t.Fatalf("extract huge txt from reader: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
f := files[0]
if !f.Truncated {
t.Fatalf("expected file to be marked as truncated")
}
if got := len(f.Content); got != maxSingleFileSize {
t.Fatalf("expected truncated size %d, got %d", maxSingleFileSize, got)
}
if f.TruncatedMessage == "" {
t.Fatalf("expected truncation message")
}
}

View File

@@ -3,6 +3,8 @@ package parser
import (
"fmt"
"io"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
@@ -62,11 +64,44 @@ func (p *BMCParser) parseFiles() error {
// Preserve filename
result.Filename = p.result.Filename
appendExtractionWarnings(result, p.files)
p.result = result
return nil
}
func appendExtractionWarnings(result *models.AnalysisResult, files []ExtractedFile) {
if result == nil {
return
}
truncated := make([]string, 0)
for _, f := range files {
if !f.Truncated {
continue
}
if f.TruncatedMessage != "" {
truncated = append(truncated, fmt.Sprintf("%s: %s", f.Path, f.TruncatedMessage))
continue
}
truncated = append(truncated, fmt.Sprintf("%s: content was truncated due to size limit", f.Path))
}
if len(truncated) == 0 {
return
}
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "LOGPile",
EventType: "Analysis Warning",
Severity: models.SeverityWarning,
Description: "Input data was too large; analysis is partial and may be incomplete",
RawData: strings.Join(truncated, "; "),
})
}
// Result returns the analysis result
func (p *BMCParser) Result() *models.AnalysisResult {
return p.result

View File

@@ -0,0 +1,34 @@
package parser
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestAppendExtractionWarnings(t *testing.T) {
result := &models.AnalysisResult{
Events: make([]models.Event, 0),
}
files := []ExtractedFile{
{Path: "ok.log", Content: []byte("ok")},
{Path: "big.log", Truncated: true, TruncatedMessage: "file exceeded size limit and was truncated"},
}
appendExtractionWarnings(result, files)
if len(result.Events) != 1 {
t.Fatalf("expected 1 warning event, got %d", len(result.Events))
}
ev := result.Events[0]
if ev.Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", ev.Severity)
}
if ev.EventType != "Analysis Warning" {
t.Fatalf("unexpected event type: %q", ev.EventType)
}
if ev.RawData == "" {
t.Fatalf("expected warning details in RawData")
}
}

View File

@@ -3,12 +3,15 @@ package inspur
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"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
type AssetJSON struct {
VersionInfo []struct {
@@ -207,8 +210,8 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
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
if deviceName != "" {
device.DeviceClass = deviceName
device.DeviceClass = normalizeModelLabel(deviceName)
}
config.PCIeDevices = append(config.PCIeDevices, device)
// Extract GPUs (class 3 = display controller)
if pcie.ClassCode == 3 {
gpuModel := deviceName
if gpuModel == "" {
gpuModel = pcieClassToString(pcie.ClassCode, pcie.SubClassCode)
}
gpuModel := normalizeGPUModel(pcie.VendorId, pcie.DeviceId, deviceName, pcie.ClassCode, pcie.SubClassCode)
gpu := models.GPU{
Slot: pcie.LocString,
Model: gpuModel,
Manufacturer: vendor,
VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
CurrentLinkWidth: pcie.NegotiatedLinkWidth,
CurrentLinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
Slot: pcie.LocString,
Model: gpuModel,
Manufacturer: vendor,
VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
CurrentLinkWidth: pcie.NegotiatedLinkWidth,
CurrentLinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
}
@@ -260,6 +260,45 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
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 {
switch memType {
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"
"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
@@ -52,20 +53,20 @@ func ParseComponentLogEvents(content []byte) []models.Event {
// MemoryRESTInfo represents the RESTful Memory info structure
type MemoryRESTInfo struct {
MemModules []struct {
MemModID int `json:"mem_mod_id"`
ConfigStatus int `json:"config_status"`
MemModSlot string `json:"mem_mod_slot"`
MemModStatus int `json:"mem_mod_status"`
MemModSize int `json:"mem_mod_size"`
MemModType string `json:"mem_mod_type"`
MemModTechnology string `json:"mem_mod_technology"`
MemModFrequency int `json:"mem_mod_frequency"`
MemModCurrentFreq int `json:"mem_mod_current_frequency"`
MemModVendor string `json:"mem_mod_vendor"`
MemModPartNum string `json:"mem_mod_part_num"`
MemModSerial string `json:"mem_mod_serial_num"`
MemModRanks int `json:"mem_mod_ranks"`
Status string `json:"status"`
MemModID int `json:"mem_mod_id"`
ConfigStatus int `json:"config_status"`
MemModSlot string `json:"mem_mod_slot"`
MemModStatus int `json:"mem_mod_status"`
MemModSize int `json:"mem_mod_size"`
MemModType string `json:"mem_mod_type"`
MemModTechnology string `json:"mem_mod_technology"`
MemModFrequency int `json:"mem_mod_frequency"`
MemModCurrentFreq int `json:"mem_mod_current_frequency"`
MemModVendor string `json:"mem_mod_vendor"`
MemModPartNum string `json:"mem_mod_part_num"`
MemModSerial string `json:"mem_mod_serial_num"`
MemModRanks int `json:"mem_mod_ranks"`
Status string `json:"status"`
} `json:"mem_modules"`
TotalMemoryCount int `json:"total_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
type PSURESTInfo struct {
PowerSupplies []struct {
ID int `json:"id"`
Present int `json:"present"`
VendorID string `json:"vendor_id"`
Model string `json:"model"`
SerialNum string `json:"serial_num"`
PartNum string `json:"part_num"`
FwVer string `json:"fw_ver"`
InputType string `json:"input_type"`
Status string `json:"status"`
RatedPower int `json:"rated_power"`
PSInPower int `json:"ps_in_power"`
PSOutPower int `json:"ps_out_power"`
PSInVolt float64 `json:"ps_in_volt"`
PSOutVolt float64 `json:"ps_out_volt"`
PSUMaxTemp int `json:"psu_max_temperature"`
ID int `json:"id"`
Present int `json:"present"`
VendorID string `json:"vendor_id"`
Model string `json:"model"`
SerialNum string `json:"serial_num"`
PartNum string `json:"part_num"`
FwVer string `json:"fw_ver"`
InputType string `json:"input_type"`
Status string `json:"status"`
RatedPower int `json:"rated_power"`
PSInPower int `json:"ps_in_power"`
PSOutPower int `json:"ps_out_power"`
PSInVolt float64 `json:"ps_in_volt"`
PSOutVolt float64 `json:"ps_out_volt"`
PSUMaxTemp int `json:"psu_max_temperature"`
} `json:"power_supplies"`
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{
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
Location: adapter.Location,
Present: adapter.Present == 1,
Model: strings.TrimSpace(adapter.Model),
Vendor: strings.TrimSpace(adapter.Vendor),
Model: model,
Vendor: vendor,
VendorID: adapter.VendorID,
DeviceID: adapter.DeviceID,
SerialNumber: strings.TrimSpace(adapter.SN),
PartNumber: strings.TrimSpace(adapter.PN),
Firmware: adapter.FwVer,
SerialNumber: normalizeRedisValue(adapter.SN),
PartNumber: normalizeRedisValue(adapter.PN),
Firmware: normalizeRedisValue(adapter.FwVer),
PortCount: adapter.PortNum,
PortType: adapter.PortType,
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 {
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

@@ -103,8 +103,9 @@ func extractBoardInfo(fruList []models.FRUInfo, hw *models.HardwareConfig) {
return
}
// Look for the main board/chassis FRU entry
// Usually it's the first entry or one with "Builtin FRU" or containing board info
// Look for the main board/chassis FRU entry.
// Keep the first non-empty serial as the server serial and avoid overwriting it
// with module-specific serials (e.g., SCM_FRU).
for _, fru := range fruList {
// Skip empty entries
if fru.ProductName == "" && fru.SerialNumber == "" {
@@ -118,25 +119,23 @@ func extractBoardInfo(fruList []models.FRUInfo, hw *models.HardwareConfig) {
strings.Contains(desc, "chassis") ||
strings.Contains(desc, "board")
// If we haven't set board info yet, or this is a main board entry
if hw.BoardInfo.ProductName == "" || isMainBoard {
if fru.ProductName != "" {
hw.BoardInfo.ProductName = fru.ProductName
}
if fru.SerialNumber != "" {
hw.BoardInfo.SerialNumber = fru.SerialNumber
}
if fru.Manufacturer != "" {
hw.BoardInfo.Manufacturer = fru.Manufacturer
}
if fru.PartNumber != "" {
hw.BoardInfo.PartNumber = fru.PartNumber
}
if fru.SerialNumber != "" && hw.BoardInfo.SerialNumber == "" {
hw.BoardInfo.SerialNumber = fru.SerialNumber
}
if fru.ProductName != "" && (hw.BoardInfo.ProductName == "" || isMainBoard) {
hw.BoardInfo.ProductName = fru.ProductName
}
// Manufacturer from non-main FRU entries (e.g. PSU vendor) should not become server vendor.
if fru.Manufacturer != "" && isMainBoard && hw.BoardInfo.Manufacturer == "" {
hw.BoardInfo.Manufacturer = fru.Manufacturer
}
if fru.PartNumber != "" && (hw.BoardInfo.PartNumber == "" || isMainBoard) {
hw.BoardInfo.PartNumber = fru.PartNumber
}
// If we found a main board entry, stop searching
if isMainBoard && fru.ProductName != "" && fru.SerialNumber != "" {
break
}
// Main board entry with complete data is good enough to stop.
if isMainBoard && hw.BoardInfo.ProductName != "" && hw.BoardInfo.SerialNumber != "" {
break
}
}
}

View File

@@ -0,0 +1,59 @@
package inspur
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestExtractBoardInfo_PreservesBuiltinSerial(t *testing.T) {
hw := &models.HardwareConfig{}
fruList := []models.FRUInfo{
{
Description: "Builtin FRU Device (ID 0)",
SerialNumber: "21D634101",
},
{
Description: "SCM_FRU (ID 8)",
SerialNumber: "CAR509K10613C10",
ProductName: "CA",
Manufacturer: "inagile",
PartNumber: "YZCA-02758-105",
},
}
extractBoardInfo(fruList, hw)
if hw.BoardInfo.SerialNumber != "21D634101" {
t.Fatalf("expected board serial 21D634101, got %q", hw.BoardInfo.SerialNumber)
}
if hw.BoardInfo.ProductName != "CA" {
t.Fatalf("expected product name CA, got %q", hw.BoardInfo.ProductName)
}
}
func TestExtractBoardInfo_DoesNotUsePSUVendorAsBoardManufacturer(t *testing.T) {
hw := &models.HardwareConfig{}
fruList := []models.FRUInfo{
{
Description: "Builtin FRU Device (ID 0)",
SerialNumber: "2KD605238",
},
{
Description: "PSU0_FRU (ID 30)",
SerialNumber: "PMR315HS10F1A",
ProductName: "AP-CR3000F12BY",
Manufacturer: "APLUSPOWER",
PartNumber: "18XA1M43400C2",
},
}
extractBoardInfo(fruList, hw)
if hw.BoardInfo.SerialNumber != "2KD605238" {
t.Fatalf("expected board serial 2KD605238, got %q", hw.BoardInfo.SerialNumber)
}
if hw.BoardInfo.Manufacturer != "" {
t.Fatalf("expected empty board manufacturer, got %q", hw.BoardInfo.Manufacturer)
}
}

View File

@@ -0,0 +1,115 @@
package inspur
import (
"regexp"
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
var reFaultGPU = regexp.MustCompile(`\bF_GPU(\d+)\b`)
func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event) {
if hw == nil || len(hw.GPUs) == 0 {
return
}
gpuByIndex := make(map[int]*models.GPU)
for i := range hw.GPUs {
gpu := &hw.GPUs[i]
idx, ok := extractLogicalGPUIndex(gpu.Slot)
if !ok {
continue
}
gpuByIndex[idx] = gpu
gpu.StatusHistory = nil
gpu.ErrorDescription = ""
}
relevantEvents := make([]models.Event, 0)
for _, e := range events {
if !isGPUFaultEvent(e) || len(extractFaultyGPUSet(e.Description)) == 0 {
continue
}
relevantEvents = append(relevantEvents, e)
}
if len(relevantEvents) == 0 {
for _, gpu := range gpuByIndex {
if strings.TrimSpace(gpu.Status) == "" {
gpu.Status = "OK"
}
}
return
}
sort.Slice(relevantEvents, func(i, j int) bool {
return relevantEvents[i].Timestamp.Before(relevantEvents[j].Timestamp)
})
currentStatus := make(map[int]string, len(gpuByIndex))
lastCriticalDetails := make(map[int]string, len(gpuByIndex))
for idx := range gpuByIndex {
currentStatus[idx] = "OK"
}
for _, e := range relevantEvents {
faultySet := extractFaultyGPUSet(e.Description)
for idx, gpu := range gpuByIndex {
newStatus := "OK"
if faultySet[idx] {
newStatus = "Critical"
lastCriticalDetails[idx] = strings.TrimSpace(e.Description)
}
if currentStatus[idx] != newStatus {
gpu.StatusHistory = append(gpu.StatusHistory, models.StatusHistoryEntry{
Status: newStatus,
ChangedAt: e.Timestamp,
Details: strings.TrimSpace(e.Description),
})
gpu.StatusChangedAt = e.Timestamp
currentStatus[idx] = newStatus
}
gpu.StatusCheckedAt = e.Timestamp
}
}
for idx, gpu := range gpuByIndex {
gpu.Status = currentStatus[idx]
if gpu.Status == "Critical" {
gpu.ErrorDescription = lastCriticalDetails[idx]
} else {
gpu.ErrorDescription = ""
}
if gpu.StatusCheckedAt.IsZero() && strings.TrimSpace(gpu.Status) == "" {
gpu.Status = "OK"
}
}
}
func extractFaultyGPUSet(description string) map[int]bool {
faulty := make(map[int]bool)
matches := reFaultGPU.FindAllStringSubmatch(description, -1)
for _, m := range matches {
if len(m) < 2 {
continue
}
idx, err := strconv.Atoi(m[1])
if err == nil && idx >= 0 {
faulty[idx] = true
}
}
return faulty
}
func isGPUFaultEvent(e models.Event) bool {
desc := strings.ToLower(e.Description)
if strings.Contains(desc, "bios miss f_gpu") {
return true
}
return strings.EqualFold(strings.TrimSpace(e.ID), "17FFB002")
}

View File

@@ -0,0 +1,69 @@
package inspur
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestAppendHGXFirmwareFromHWInfo_AppendsInventoryEntries(t *testing.T) {
hw := &models.HardwareConfig{
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "1.0.0"},
},
}
content := []byte(`
{
"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/HGX_FW_BMC_0",
"Id": "HGX_FW_BMC_0",
"Oem": {
"Nvidia": {
"ActiveFirmwareSlot": {"Version": "25.05-A"},
"InactiveFirmwareSlot": {"Version": "25.04-B"}
}
},
"Version": "25.05-A",
"WriteProtected": false
}
{
"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/HGX_FW_GPU_SXM_1",
"Id": "HGX_FW_GPU_SXM_1",
"Version": "97.00.C5.00.0E",
"WriteProtected": false
}
{
"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/HGX_Driver_GPU_SXM_1",
"Id": "HGX_Driver_GPU_SXM_1",
"Version": "",
"WriteProtected": false
}
`)
appendHGXFirmwareFromHWInfo(content, hw)
if len(hw.Firmware) != 5 {
t.Fatalf("expected 5 firmware entries after append, got %d", len(hw.Firmware))
}
seen := make(map[string]string)
for _, fw := range hw.Firmware {
seen[fw.DeviceName] = fw.Version
}
if seen["HGX_FW_BMC_0"] != "25.05-A" {
t.Fatalf("expected HGX_FW_BMC_0 version 25.05-A, got %q", seen["HGX_FW_BMC_0"])
}
if seen["HGX_FW_BMC_0 Active Slot"] != "25.05-A" {
t.Fatalf("expected active slot version, got %q", seen["HGX_FW_BMC_0 Active Slot"])
}
if seen["HGX_FW_BMC_0 Inactive Slot"] != "25.04-B" {
t.Fatalf("expected inactive slot version, got %q", seen["HGX_FW_BMC_0 Inactive Slot"])
}
if seen["HGX_FW_GPU_SXM_1"] != "97.00.C5.00.0E" {
t.Fatalf("expected GPU FW entry, got %q", seen["HGX_FW_GPU_SXM_1"])
}
if _, ok := seen["HGX_Driver_GPU_SXM_1"]; ok {
t.Fatalf("did not expect empty version driver entry")
}
}

View File

@@ -0,0 +1,171 @@
package inspur
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestEnrichGPUsFromHGXHWInfo_UsesHGXLogicalMapping(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#GPU6"},
{Slot: "#GPU7"},
{Slot: "#GPU0"},
{Slot: "#CPU0_PE1_E_BMC", Model: "AST2500 VGA"},
},
}
content := []byte(`
# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_GPU_SXM_1/Assembly
{"Name":"GPU Board Assembly","Model":"B200 180GB HBM3e","PartNumber":"PN1","SerialNumber":"SXM1SN"}
# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_GPU_SXM_3/Assembly
{"Name":"GPU Board Assembly","Model":"B200 180GB HBM3e","PartNumber":"PN3","SerialNumber":"SXM3SN"}
# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_GPU_SXM_5/Assembly
{"Name":"GPU Board Assembly","Model":"B200 180GB HBM3e","PartNumber":"PN5","SerialNumber":"SXM5SN"}
{"Id":"HGX_FW_GPU_SXM_1","Version":"FW1"}
{"Id":"HGX_FW_GPU_SXM_3","Version":"FW3"}
{"Id":"HGX_FW_GPU_SXM_5","Version":"FW5"}
{"Id":"HGX_InfoROM_GPU_SXM_3","Version":"IR3"}
`)
enrichGPUsFromHGXHWInfo(content, hw)
if hw.GPUs[0].SerialNumber != "SXM3SN" {
t.Fatalf("expected #GPU6 to map to SXM3 serial, got %q", hw.GPUs[0].SerialNumber)
}
if hw.GPUs[1].SerialNumber != "SXM1SN" {
t.Fatalf("expected #GPU7 to map to SXM1 serial, got %q", hw.GPUs[1].SerialNumber)
}
if hw.GPUs[2].SerialNumber != "SXM5SN" {
t.Fatalf("expected #GPU0 to map to SXM5 serial, got %q", hw.GPUs[2].SerialNumber)
}
if hw.GPUs[0].Firmware != "FW3" {
t.Fatalf("expected #GPU6 firmware FW3, got %q", hw.GPUs[0].Firmware)
}
if hw.GPUs[0].VideoBIOS != "IR3" {
t.Fatalf("expected #GPU6 InfoROM in VideoBIOS IR3, got %q", hw.GPUs[0].VideoBIOS)
}
if hw.GPUs[2].Firmware != "FW5" {
t.Fatalf("expected #GPU0 firmware FW5, got %q", hw.GPUs[2].Firmware)
}
for _, g := range hw.GPUs {
if g.Slot == "#CPU0_PE1_E_BMC" {
t.Fatalf("expected non-HGX BMC VGA entry to be filtered out")
}
}
}
func TestEnrichGPUsFromHGXHWInfo_AddsMissingLogicalGPU(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#GPU0"},
{Slot: "#GPU1"},
{Slot: "#GPU2"},
{Slot: "#GPU3"},
{Slot: "#GPU4"},
{Slot: "#GPU5"},
{Slot: "#GPU7"},
},
}
content := []byte(`
# curl -X GET http://127.0.0.1/redfish/v1/Chassis/HGX_GPU_SXM_3/Assembly
{"Name":"GPU Board Assembly","Model":"B200 180GB HBM3e","PartNumber":"PN3","SerialNumber":"SXM3SN"}
`)
enrichGPUsFromHGXHWInfo(content, hw)
found := false
for _, g := range hw.GPUs {
if g.Slot == "#GPU6" {
found = true
if g.SerialNumber != "SXM3SN" {
t.Fatalf("expected synthesized #GPU6 serial SXM3SN, got %q", g.SerialNumber)
}
}
}
if !found {
t.Fatalf("expected synthesized #GPU6 entry")
}
}
func TestApplyGPUStatusFromEvents_MarksFaultedGPU(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#GPU6"},
{Slot: "#GPU5"},
},
}
events := []models.Event{
{
ID: "17FFB002",
Timestamp: time.Now(),
Description: "PCIe Present mismatch BIOS miss F_GPU6",
},
}
applyGPUStatusFromEvents(hw, events)
if hw.GPUs[0].Status != "Critical" {
t.Fatalf("expected #GPU6 status Critical, got %q", hw.GPUs[0].Status)
}
if hw.GPUs[1].Status != "OK" {
t.Fatalf("expected healthy GPU status OK, got %q", hw.GPUs[1].Status)
}
}
func TestApplyGPUStatusFromEvents_UsesLatestEventAsCurrentStatusAndKeepsHistory(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#GPU1"},
{Slot: "#GPU3"},
{Slot: "#GPU6"},
},
}
events := []models.Event{
{
ID: "17FFB002",
Timestamp: time.Date(2026, 1, 12, 22, 51, 16, 0, time.FixedZone("UTC+8", 8*3600)),
Description: "PCIe Present mismatch BIOS miss F_GPU1 F_GPU3 F_GPU6",
},
{
ID: "17FFB002",
Timestamp: time.Date(2026, 1, 12, 23, 5, 18, 0, time.FixedZone("UTC+8", 8*3600)),
Description: "PCIe Present mismatch BIOS miss F_GPU6",
},
}
applyGPUStatusFromEvents(hw, events)
if hw.GPUs[0].Status != "OK" {
t.Fatalf("expected #GPU1 to recover to OK on latest event, got %q", hw.GPUs[0].Status)
}
if hw.GPUs[1].Status != "OK" {
t.Fatalf("expected #GPU3 to recover to OK on latest event, got %q", hw.GPUs[1].Status)
}
if hw.GPUs[2].Status != "Critical" {
t.Fatalf("expected #GPU6 to remain Critical, got %q", hw.GPUs[2].Status)
}
if len(hw.GPUs[0].StatusHistory) == 0 {
t.Fatalf("expected #GPU1 status history to be populated")
}
}
func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
content := []byte(`{ "MESSAGE": "|2026-01-12T23:05:18+08:00|PCIE|Assert|Critical|17FFB002|PCIe Present mismatch BIOS miss F_GPU6 - Assert|" }`)
events := ParseIDLLog(content)
if len(events) != 1 {
t.Fatalf("expected 1 event from JSON line, got %d", len(events))
}
if events[0].ID != "17FFB002" {
t.Fatalf("expected event ID 17FFB002, got %q", events[0].ID)
}
if events[0].Source != "PCIE" {
t.Fatalf("expected source PCIE, got %q", events[0].Source)
}
}

View File

@@ -0,0 +1,360 @@
package inspur
import (
"fmt"
"regexp"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
type hgxGPUAssemblyInfo struct {
Model string
Part string
Serial string
}
type hgxGPUFirmwareInfo struct {
Firmware string
InfoROM string
}
type hgxFirmwareInventoryEntry struct {
ID string
Version string
ActiveVersion string
InactiveVersion string
}
// Logical GPU index mapping used by HGX B200 UI ordering.
// Example from real logs/UI:
// GPU0->SXM5, GPU1->SXM7, GPU2->SXM6, GPU3->SXM8, GPU4->SXM2, GPU5->SXM4, GPU6->SXM3, GPU7->SXM1.
var hgxLogicalToSXM = map[int]int{
0: 5,
1: 7,
2: 6,
3: 8,
4: 2,
5: 4,
6: 3,
7: 1,
}
var (
reHGXGPUBlock = regexp.MustCompile(`(?s)/redfish/v1/Chassis/HGX_GPU_SXM_(\d+)/Assembly.*?"Name":\s*"GPU Board Assembly".*?"Model":\s*"([^"]+)".*?"PartNumber":\s*"([^"]+)".*?"SerialNumber":\s*"([^"]+)"`)
reHGXFWBlock = regexp.MustCompile(`(?s)"Id":\s*"HGX_FW_GPU_SXM_(\d+)".*?"Version":\s*"([^"]*)"`)
reHGXInfoROM = regexp.MustCompile(`(?s)"Id":\s*"HGX_InfoROM_GPU_SXM_(\d+)".*?"Version":\s*"([^"]*)"`)
reIDLine = regexp.MustCompile(`"Id":\s*"([^"]+)"`)
reVersion = regexp.MustCompile(`"Version":\s*"([^"]*)"`)
reSlotGPU = regexp.MustCompile(`(?i)gpu\s*#?\s*(\d+)`)
)
func enrichGPUsFromHGXHWInfo(content []byte, hw *models.HardwareConfig) {
if hw == nil || len(hw.GPUs) == 0 || len(content) == 0 {
return
}
bySXM := parseHGXGPUAssembly(content)
if len(bySXM) == 0 {
return
}
fwBySXM := parseHGXGPUFirmware(content)
normalizeHGXGPUInventory(hw, bySXM)
for i := range hw.GPUs {
gpu := &hw.GPUs[i]
logicalIdx, ok := extractLogicalGPUIndex(gpu.Slot)
if !ok {
// Keep existing info if slot index cannot be determined.
continue
}
sxm := resolveSXMIndex(logicalIdx, bySXM)
info, found := bySXM[sxm]
if !found {
continue
}
if strings.TrimSpace(gpu.SerialNumber) == "" {
gpu.SerialNumber = info.Serial
}
if shouldReplaceGPUModel(gpu.Model) {
gpu.Model = info.Model
}
if strings.TrimSpace(gpu.PartNumber) == "" {
gpu.PartNumber = info.Part
}
if strings.TrimSpace(gpu.Manufacturer) == "" {
gpu.Manufacturer = "NVIDIA"
}
if fw, ok := fwBySXM[sxm]; ok {
if strings.TrimSpace(gpu.Firmware) == "" && strings.TrimSpace(fw.Firmware) != "" {
gpu.Firmware = fw.Firmware
}
if strings.TrimSpace(gpu.VideoBIOS) == "" && strings.TrimSpace(fw.InfoROM) != "" {
gpu.VideoBIOS = fw.InfoROM
}
}
}
}
func appendHGXFirmwareFromHWInfo(content []byte, hw *models.HardwareConfig) {
if hw == nil || len(content) == 0 {
return
}
entries := parseHGXFirmwareInventory(content)
if len(entries) == 0 {
return
}
existing := make(map[string]bool, len(hw.Firmware))
for _, fw := range hw.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName) + "|" + strings.TrimSpace(fw.Version))
existing[key] = true
}
appendFW := func(name, version string) {
name = strings.TrimSpace(name)
version = strings.TrimSpace(version)
if name == "" || version == "" {
return
}
key := strings.ToLower(name + "|" + version)
if existing[key] {
return
}
existing[key] = true
hw.Firmware = append(hw.Firmware, models.FirmwareInfo{
DeviceName: name,
Version: version,
})
}
for _, e := range entries {
appendFW(e.ID, e.Version)
if e.ActiveVersion != "" && e.InactiveVersion != "" && e.ActiveVersion != e.InactiveVersion {
appendFW(e.ID+" Active Slot", e.ActiveVersion)
appendFW(e.ID+" Inactive Slot", e.InactiveVersion)
}
}
}
func parseHGXGPUAssembly(content []byte) map[int]hgxGPUAssemblyInfo {
result := make(map[int]hgxGPUAssemblyInfo)
matches := reHGXGPUBlock.FindAllSubmatch(content, -1)
for _, m := range matches {
if len(m) != 5 {
continue
}
sxmIdx, err := strconv.Atoi(string(m[1]))
if err != nil || sxmIdx <= 0 {
continue
}
result[sxmIdx] = hgxGPUAssemblyInfo{
Model: strings.TrimSpace(string(m[2])),
Part: strings.TrimSpace(string(m[3])),
Serial: strings.TrimSpace(string(m[4])),
}
}
return result
}
func parseHGXGPUFirmware(content []byte) map[int]hgxGPUFirmwareInfo {
result := make(map[int]hgxGPUFirmwareInfo)
matchesFW := reHGXFWBlock.FindAllSubmatch(content, -1)
for _, m := range matchesFW {
if len(m) != 3 {
continue
}
sxmIdx, err := strconv.Atoi(string(m[1]))
if err != nil || sxmIdx <= 0 {
continue
}
version := strings.TrimSpace(string(m[2]))
if version == "" {
continue
}
current := result[sxmIdx]
if current.Firmware == "" {
current.Firmware = version
}
result[sxmIdx] = current
}
matchesInfoROM := reHGXInfoROM.FindAllSubmatch(content, -1)
for _, m := range matchesInfoROM {
if len(m) != 3 {
continue
}
sxmIdx, err := strconv.Atoi(string(m[1]))
if err != nil || sxmIdx <= 0 {
continue
}
version := strings.TrimSpace(string(m[2]))
if version == "" {
continue
}
current := result[sxmIdx]
if current.InfoROM == "" {
current.InfoROM = version
}
result[sxmIdx] = current
}
return result
}
func parseHGXFirmwareInventory(content []byte) []hgxFirmwareInventoryEntry {
lines := strings.Split(string(content), "\n")
result := make([]hgxFirmwareInventoryEntry, 0)
var current *hgxFirmwareInventoryEntry
section := ""
flush := func() {
if current == nil {
return
}
if current.Version == "" && current.ActiveVersion == "" && current.InactiveVersion == "" {
current = nil
section = ""
return
}
result = append(result, *current)
current = nil
section = ""
}
for _, line := range lines {
if m := reIDLine.FindStringSubmatch(line); len(m) > 1 {
flush()
id := strings.TrimSpace(m[1])
if strings.HasPrefix(id, "HGX_") {
current = &hgxFirmwareInventoryEntry{ID: id}
}
continue
}
if current == nil {
continue
}
if strings.Contains(line, `"ActiveFirmwareSlot"`) {
section = "active"
}
if strings.Contains(line, `"InactiveFirmwareSlot"`) {
section = "inactive"
}
if m := reVersion.FindStringSubmatch(line); len(m) > 1 {
version := strings.TrimSpace(m[1])
if version == "" {
section = ""
continue
}
switch section {
case "active":
if current.ActiveVersion == "" {
current.ActiveVersion = version
}
case "inactive":
if current.InactiveVersion == "" {
current.InactiveVersion = version
}
default:
// Keep top-level version from the last seen plain "Version" in current entry.
current.Version = version
}
section = ""
}
}
flush()
return result
}
func extractLogicalGPUIndex(slot string) (int, bool) {
m := reSlotGPU.FindStringSubmatch(slot)
if len(m) < 2 {
return 0, false
}
idx, err := strconv.Atoi(m[1])
if err != nil || idx < 0 {
return 0, false
}
return idx, true
}
func resolveSXMIndex(logicalIdx int, bySXM map[int]hgxGPUAssemblyInfo) int {
if sxm, ok := hgxLogicalToSXM[logicalIdx]; ok {
if _, exists := bySXM[sxm]; exists {
return sxm
}
}
identity := logicalIdx + 1
if _, exists := bySXM[identity]; exists {
return identity
}
return identity
}
func shouldReplaceGPUModel(model string) bool {
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return true
}
switch strings.ToLower(trimmed) {
case "vga", "3d controller", "display controller", "unknown":
return true
default:
return false
}
}
func normalizeHGXGPUInventory(hw *models.HardwareConfig, bySXM map[int]hgxGPUAssemblyInfo) {
// Keep only logical HGX GPUs (#GPU0..#GPU7) and remove BMC VGA entries.
filtered := make([]models.GPU, 0, len(hw.GPUs))
present := make(map[int]bool)
for _, gpu := range hw.GPUs {
idx, ok := extractLogicalGPUIndex(gpu.Slot)
if !ok || idx < 0 || idx > 7 {
continue
}
present[idx] = true
filtered = append(filtered, gpu)
}
// If some logical GPUs are missing in asset.json, add placeholders from HGX Redfish assembly.
for logicalIdx := 0; logicalIdx <= 7; logicalIdx++ {
if present[logicalIdx] {
continue
}
sxm := resolveSXMIndex(logicalIdx, bySXM)
info, ok := bySXM[sxm]
if !ok {
continue
}
filtered = append(filtered, models.GPU{
Slot: fmt.Sprintf("#GPU%d", logicalIdx),
Model: info.Model,
Manufacturer: "NVIDIA",
SerialNumber: info.Serial,
PartNumber: info.Part,
})
}
hw.GPUs = filtered
}

View File

@@ -8,8 +8,10 @@ import (
"git.mchus.pro/mchus/logpile/internal/models"
)
// ParseIDLLog parses the IDL (Inspur Diagnostic Log) file for BMC alarms
// Format: |timestamp|component|type|severity|eventID|description|
// ParseIDLLog parses IDL-style entries for BMC alarms.
// Works for both plain idl.log lines and JSON structured logs (idl_json/run_json)
// where MESSAGE/LOG2_FMTMSG contains:
// |timestamp|component|type|severity|eventID|description|
func ParseIDLLog(content []byte) []models.Event {
var events []models.Event
@@ -21,10 +23,6 @@ func ParseIDLLog(content []byte) []models.Event {
seenEvents := make(map[string]bool) // Deduplicate events
for _, line := range lines {
if !strings.Contains(line, "CommerDiagnose") {
continue
}
matches := re.FindStringSubmatch(line)
if matches == nil {
continue

View File

@@ -15,7 +15,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.0.0"
const parserVersion = "1.2.1"
func init() {
parser.Register(&Parser{})
@@ -125,8 +125,15 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Events = append(result.Events, componentEvents...)
}
// Parse IDL log (BMC alarms/diagnose events)
if f := parser.FindFileByName(files, "idl.log"); f != nil {
// 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)
idlFiles := parser.FindFileByPattern(files, "/idl.log", "idl_json.log", "run_json.log")
for _, f := range idlFiles {
idlEvents := ParseIDLLog(f.Content)
result.Events = append(result.Events, idlEvents...)
}
@@ -144,6 +151,30 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Events = append(result.Events, events...)
}
// Fallback for archives where board serial is missing in parsed FRU/asset data:
// recover it from log content, never from archive filename.
if strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber) == "" {
if serial := inferBoardSerialFromFallbackLogs(files); serial != "" {
result.Hardware.BoardInfo.SerialNumber = serial
}
}
if strings.TrimSpace(result.Hardware.BoardInfo.ProductName) == "" {
if model := inferBoardModelFromFallbackLogs(files); model != "" {
result.Hardware.BoardInfo.ProductName = model
}
}
// Enrich GPU inventory from HGX Redfish snapshot (serial/model/part mapping).
if f := parser.FindFileByName(files, "HGX_HWInfo_FWVersion.log"); f != nil && result.Hardware != nil {
enrichGPUsFromHGXHWInfo(f.Content, result.Hardware)
appendHGXFirmwareFromHWInfo(f.Content, result.Hardware)
}
// Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".
if result.Hardware != nil {
applyGPUStatusFromEvents(result.Hardware, result.Events)
}
return result, nil
}
@@ -174,14 +205,9 @@ func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult
// This supplements data from asset.json with serial numbers, firmware, etc.
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 asset.json didn't have PCIe devices, use RESTful data
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
result.Hardware.PCIeDevices = MergePCIeDevices(result.Hardware.PCIeDevices, pcieDevicesFromREST)
}
// Parse GPU devices and add temperature data from sensors

View File

@@ -3,36 +3,38 @@ package inspur
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
)
// PCIeRESTInfo represents the RESTful PCIE Device info structure
type PCIeRESTInfo []struct {
ID int `json:"id"`
Present int `json:"present"`
Enable int `json:"enable"`
Status int `json:"status"`
VendorID int `json:"vendor_id"`
VendorName string `json:"vendor_name"`
DeviceID int `json:"device_id"`
DeviceName string `json:"device_name"`
BusNum int `json:"bus_num"`
DevNum int `json:"dev_num"`
FuncNum int `json:"func_num"`
MaxLinkWidth int `json:"max_link_width"`
MaxLinkSpeed int `json:"max_link_speed"`
CurrentLinkWidth int `json:"current_link_width"`
CurrentLinkSpeed int `json:"current_link_speed"`
Slot int `json:"slot"`
Location string `json:"location"`
DeviceLocator string `json:"DeviceLocator"`
DevType int `json:"dev_type"`
DevSubtype int `json:"dev_subtype"`
PartNum string `json:"part_num"`
SerialNum string `json:"serial_num"`
FwVer string `json:"fw_ver"`
ID int `json:"id"`
Present int `json:"present"`
Enable int `json:"enable"`
Status int `json:"status"`
VendorID int `json:"vendor_id"`
VendorName string `json:"vendor_name"`
DeviceID int `json:"device_id"`
DeviceName string `json:"device_name"`
BusNum int `json:"bus_num"`
DevNum int `json:"dev_num"`
FuncNum int `json:"func_num"`
MaxLinkWidth int `json:"max_link_width"`
MaxLinkSpeed int `json:"max_link_speed"`
CurrentLinkWidth int `json:"current_link_width"`
CurrentLinkSpeed int `json:"current_link_speed"`
Slot int `json:"slot"`
Location string `json:"location"`
DeviceLocator string `json:"DeviceLocator"`
DevType int `json:"dev_type"`
DevSubtype int `json:"dev_subtype"`
PartNum string `json:"part_num"`
SerialNum string `json:"serial_num"`
FwVer string `json:"fw_ver"`
}
// 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
deviceClass := determineDeviceClass(pcie.DevType, pcie.DevSubtype, pcie.DeviceName)
_, pciDeviceName := pciids.DeviceInfo(pcie.VendorID, pcie.DeviceID)
// Build BDF string
bdf := fmt.Sprintf("%04x/%02x/%02x/%02x", 0, pcie.BusNum, pcie.DevNum, pcie.FuncNum)
// Build BDF string in canonical form (bb:dd.f)
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{
Slot: pcie.Location,
@@ -83,12 +103,12 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice {
DeviceID: pcie.DeviceID,
BDF: bdf,
DeviceClass: deviceClass,
Manufacturer: pcie.VendorName,
Manufacturer: manufacturer,
LinkWidth: pcie.CurrentLinkWidth,
LinkSpeed: currentSpeed,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: maxSpeed,
PartNumber: strings.TrimSpace(pcie.PartNum),
PartNumber: partNumber,
SerialNumber: strings.TrimSpace(pcie.SerialNum),
}
@@ -98,6 +118,149 @@ func ParsePCIeDevices(content []byte) []models.PCIeDevice {
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
func determineDeviceClass(devType, devSubtype int, deviceName string) string {
// 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
}

View File

@@ -0,0 +1,92 @@
package inspur
import (
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var (
hostnameJSONRegex = regexp.MustCompile(`"_HOSTNAME"\s*:\s*"([^"]+)"`)
)
func inferBoardSerialFromFallbackLogs(files []parser.ExtractedFile) string {
// Prefer FRU dump when present.
if f := parser.FindFileByName(files, "fru.txt"); f != nil {
fruList := ParseFRU(f.Content)
for _, fru := range fruList {
serial := strings.TrimSpace(fru.SerialNumber)
if serial == "" || serial == "0" {
continue
}
desc := strings.ToLower(strings.TrimSpace(fru.Description))
if strings.Contains(desc, "builtin") || strings.Contains(desc, "fru device") {
return serial
}
}
}
// Fallback to explicit hostname file.
if f := parser.FindFileByName(files, "hostname"); f != nil {
if serial := sanitizeCandidateSerial(firstNonEmptyLine(string(f.Content))); serial != "" {
return serial
}
}
// Last-resort fallback from structured journal logs.
if f := parser.FindFileByName(files, "maintenance_json.log"); f != nil {
if m := hostnameJSONRegex.FindSubmatch(f.Content); len(m) == 2 {
if serial := sanitizeCandidateSerial(string(m[1])); serial != "" {
return serial
}
}
}
return ""
}
func inferBoardModelFromFallbackLogs(files []parser.ExtractedFile) string {
// Prefer FRU dump when present.
if f := parser.FindFileByName(files, "fru.txt"); f != nil {
fruList := ParseFRU(f.Content)
for _, fru := range fruList {
model := sanitizeCandidateModel(fru.ProductName)
if model == "" {
continue
}
desc := strings.ToLower(strings.TrimSpace(fru.Description))
if strings.Contains(desc, "builtin") || strings.Contains(desc, "fru device") {
return model
}
}
}
return ""
}
func firstNonEmptyLine(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line != "" {
return line
}
}
return ""
}
func sanitizeCandidateSerial(s string) string {
s = strings.TrimSpace(s)
if s == "" || strings.EqualFold(s, "localhost") || strings.ContainsAny(s, " \t") {
return ""
}
return s
}
func sanitizeCandidateModel(s string) string {
s = strings.TrimSpace(s)
if s == "" || strings.EqualFold(s, "null") || s == "0" {
return ""
}
return s
}

View File

@@ -0,0 +1,76 @@
package inspur
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestInferBoardSerialFromFallbackLogs_PrefersFRU(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "component/fru.txt",
Content: []byte(`FRU Device Description : Builtin FRU Device (ID 0)
Product Serial : 23DB01639
`),
},
{
Path: "runningdata/RTOSDump/hostname",
Content: []byte("HOSTNAME-FALLBACK\n"),
},
{
Path: "log/bmc/struct-log/maintenance_json.log",
Content: []byte(`{ "_HOSTNAME": "JSON-FALLBACK" }`),
},
}
got := inferBoardSerialFromFallbackLogs(files)
if got != "23DB01639" {
t.Fatalf("expected FRU serial 23DB01639, got %q", got)
}
}
func TestInferBoardSerialFromFallbackLogs_UsesHostnameFile(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "runningdata/RTOSDump/hostname",
Content: []byte("23DB01639\n"),
},
}
got := inferBoardSerialFromFallbackLogs(files)
if got != "23DB01639" {
t.Fatalf("expected hostname serial 23DB01639, got %q", got)
}
}
func TestInferBoardSerialFromFallbackLogs_UsesMaintenanceJSON(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "log/bmc/struct-log/maintenance_json.log",
Content: []byte(`{ "_HOSTNAME": "23DB01639", "MESSAGE": "ok" }`),
},
}
got := inferBoardSerialFromFallbackLogs(files)
if got != "23DB01639" {
t.Fatalf("expected JSON hostname serial 23DB01639, got %q", got)
}
}
func TestInferBoardModelFromFallbackLogs_PrefersFRU(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "component/fru.txt",
Content: []byte(`FRU Device Description : Builtin FRU Device (ID 0)
Board Product : KR9288-X3-A0-F0-00
Product Name : KR9288-X3-A0-F0-00
`),
},
}
got := inferBoardModelFromFallbackLogs(files)
if got != "KR9288-X3-A0-F0-00" {
t.Fatalf("expected board model KR9288-X3-A0-F0-00, got %q", got)
}
}

View File

@@ -0,0 +1,274 @@
package nvidia
import (
"regexp"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var verboseRunTestingLineRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}),\d+\s+-\s+Testing\s+([a-zA-Z0-9_]+)\s*$`)
var runLogStartTimeRegex = regexp.MustCompile(`^Start time\s+([A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2})\s*$`)
var runLogTestDurationRegex = regexp.MustCompile(`^Testing\s+([a-zA-Z0-9_]+)\s+\S+\s+\[\s*([0-9]+):([0-9]{2})s\s*\]\s*$`)
var modsStartLineRegex = regexp.MustCompile(`(?m)^MODS start:\s+([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s*$`)
var gpuFieldiagOutputPathRegex = regexp.MustCompile(`(?i)gpu_fieldiag[\\/]+sxm(\d+)_sn_([^\\/]+)[\\/]+output\.log$`)
var nvswitchDevnameRegex = regexp.MustCompile(`devname=[^,\s]+,(NVSWITCH\d+)`)
type componentCheckTimes struct {
GPUDefault time.Time
NVSwitchDefault time.Time
GPUBySerial map[string]time.Time // key: GPU serial
GPUBySlot map[string]time.Time // key: GPUSXM<idx>
NVSwitchBySlot map[string]time.Time // key: NVSWITCH<idx>
}
// CollectGPUAndNVSwitchCheckTimes extracts GPU/NVSwitch check timestamps from NVIDIA logs.
// Priority:
// 1) verbose_run.log "Testing <test>" timestamps
// 2) run.log start time + cumulative durations
func CollectGPUAndNVSwitchCheckTimes(files []parser.ExtractedFile) componentCheckTimes {
gpuBySerial := make(map[string]time.Time)
gpuBySlot := make(map[string]time.Time)
nvsBySlot := make(map[string]time.Time)
for _, f := range files {
path := strings.TrimSpace(f.Path)
pathLower := strings.ToLower(path)
// Per-GPU timestamp from gpu_fieldiag/<SXMx_SN_serial>/output.log
if strings.HasSuffix(pathLower, "output.log") && strings.Contains(pathLower, "gpu_fieldiag/") {
ts := parseModsStartTime(f.Content)
if ts.IsZero() {
continue
}
matches := gpuFieldiagOutputPathRegex.FindStringSubmatch(path)
if len(matches) == 3 {
slot := "GPUSXM" + strings.TrimSpace(matches[1])
serial := strings.TrimSpace(matches[2])
if slot != "" {
gpuBySlot[slot] = ts
}
if serial != "" {
gpuBySerial[serial] = ts
}
}
}
// Per-NVSwitch timestamp and slot list from nvswitch/output.log
if strings.HasSuffix(pathLower, "nvswitch/output.log") || strings.HasSuffix(pathLower, "nvswitch\\output.log") {
ts := parseModsStartTime(f.Content)
if ts.IsZero() {
continue
}
for _, slot := range parseNVSwitchSlotsFromOutput(f.Content) {
nvsBySlot[slot] = ts
}
}
}
testStarts := make(map[string]time.Time)
if f := parser.FindFileByName(files, "verbose_run.log"); f != nil {
for testName, ts := range parseVerboseRunTestStartTimes(f.Content) {
testStarts[strings.ToLower(strings.TrimSpace(testName))] = ts
}
}
if len(testStarts) == 0 {
if f := parser.FindFileByName(files, "run.log"); f != nil {
for testName, ts := range parseRunLogTestStartTimes(f.Content) {
testStarts[strings.ToLower(strings.TrimSpace(testName))] = ts
}
}
}
return componentCheckTimes{
GPUDefault: pickFirstTestTime(testStarts, "gpu_fieldiag", "gpumem", "gpustress", "pcie", "inventory"),
NVSwitchDefault: pickFirstTestTime(testStarts, "nvswitch", "inventory"),
GPUBySerial: gpuBySerial,
GPUBySlot: gpuBySlot,
NVSwitchBySlot: nvsBySlot,
}
}
func pickFirstTestTime(testStarts map[string]time.Time, names ...string) time.Time {
for _, name := range names {
if ts := testStarts[strings.ToLower(strings.TrimSpace(name))]; !ts.IsZero() {
return ts
}
}
return time.Time{}
}
func parseVerboseRunTestStartTimes(content []byte) map[string]time.Time {
result := make(map[string]time.Time)
lines := strings.Split(string(content), "\n")
for _, line := range lines {
matches := verboseRunTestingLineRegex.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) != 3 {
continue
}
ts, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(matches[1]), time.UTC)
if err != nil {
continue
}
testName := strings.ToLower(strings.TrimSpace(matches[2]))
if testName == "" {
continue
}
if _, exists := result[testName]; !exists {
result[testName] = ts
}
}
return result
}
func parseRunLogTestStartTimes(content []byte) map[string]time.Time {
lines := strings.Split(string(content), "\n")
start := time.Time{}
for _, line := range lines {
matches := runLogStartTimeRegex.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) != 2 {
continue
}
parsed, err := time.ParseInLocation("Mon, 02 Jan 2006 15:04:05", strings.TrimSpace(matches[1]), time.UTC)
if err != nil {
continue
}
start = parsed
break
}
if start.IsZero() {
return nil
}
result := make(map[string]time.Time)
cursor := start
for _, line := range lines {
matches := runLogTestDurationRegex.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) != 4 {
continue
}
testName := strings.ToLower(strings.TrimSpace(matches[1]))
minutes, errMin := strconv.Atoi(strings.TrimSpace(matches[2]))
seconds, errSec := strconv.Atoi(strings.TrimSpace(matches[3]))
if errMin != nil || errSec != nil {
continue
}
if _, exists := result[testName]; !exists {
result[testName] = cursor
}
cursor = cursor.Add(time.Duration(minutes)*time.Minute + time.Duration(seconds)*time.Second)
}
return result
}
func parseModsStartTime(content []byte) time.Time {
matches := modsStartLineRegex.FindSubmatch(content)
if len(matches) != 2 {
return time.Time{}
}
tsRaw := strings.TrimSpace(string(matches[1]))
if tsRaw == "" {
return time.Time{}
}
ts, err := time.ParseInLocation("Mon Jan 2 15:04:05 2006", tsRaw, time.UTC)
if err != nil {
return time.Time{}
}
return ts
}
func parseNVSwitchSlotsFromOutput(content []byte) []string {
matches := nvswitchDevnameRegex.FindAllSubmatch(content, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]struct{})
out := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) != 2 {
continue
}
slot := strings.ToUpper(strings.TrimSpace(string(m[1])))
if slot == "" {
continue
}
if _, exists := seen[slot]; exists {
continue
}
seen[slot] = struct{}{}
out = append(out, slot)
}
return out
}
// ApplyGPUAndNVSwitchCheckTimes writes parsed check timestamps to component status metadata.
func ApplyGPUAndNVSwitchCheckTimes(result *models.AnalysisResult, times componentCheckTimes) {
if result == nil || result.Hardware == nil {
return
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
ts := time.Time{}
if serial := strings.TrimSpace(gpu.SerialNumber); serial != "" {
ts = times.GPUBySerial[serial]
}
if ts.IsZero() {
ts = times.GPUBySlot[strings.ToUpper(strings.TrimSpace(gpu.Slot))]
}
if ts.IsZero() {
ts = times.GPUDefault
}
if ts.IsZero() {
continue
}
gpu.StatusCheckedAt = ts
status := strings.TrimSpace(gpu.Status)
if status == "" {
status = "Unknown"
}
gpu.StatusAtCollect = &models.StatusAtCollection{
Status: status,
At: ts,
}
}
for i := range result.Hardware.PCIeDevices {
dev := &result.Hardware.PCIeDevices[i]
slot := normalizeNVSwitchSlot(strings.TrimSpace(dev.Slot))
if slot == "" {
continue
}
slot = strings.ToUpper(slot)
if !strings.EqualFold(strings.TrimSpace(dev.DeviceClass), "NVSwitch") &&
!strings.HasPrefix(slot, "NVSWITCH") {
continue
}
ts := times.NVSwitchBySlot[slot]
if ts.IsZero() {
ts = times.NVSwitchDefault
}
if ts.IsZero() {
continue
}
dev.StatusCheckedAt = ts
status := strings.TrimSpace(dev.Status)
if status == "" {
status = "Unknown"
}
dev.StatusAtCollect = &models.StatusAtCollection{
Status: status,
At: ts,
}
}
}

View File

@@ -0,0 +1,143 @@
package nvidia
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestParseVerboseRunTestStartTimes(t *testing.T) {
content := []byte(`
2026-01-22 09:11:32,458 - Testing nvswitch
2026-01-22 09:45:36,016 - Testing gpu_fieldiag
`)
got := parseVerboseRunTestStartTimes(content)
nvs := got["nvswitch"]
if nvs.IsZero() {
t.Fatalf("expected nvswitch timestamp")
}
gpu := got["gpu_fieldiag"]
if gpu.IsZero() {
t.Fatalf("expected gpu_fieldiag timestamp")
}
if nvs.Format(time.RFC3339) != "2026-01-22T09:11:32Z" {
t.Fatalf("unexpected nvswitch timestamp: %s", nvs.Format(time.RFC3339))
}
if gpu.Format(time.RFC3339) != "2026-01-22T09:45:36Z" {
t.Fatalf("unexpected gpu_fieldiag timestamp: %s", gpu.Format(time.RFC3339))
}
}
func TestParseRunLogTestStartTimes(t *testing.T) {
content := []byte(`
Start time Thu, 22 Jan 2026 07:42:26
Testing gpumem FAILED [ 26:12s ]
Testing gpustress OK [ 7:10s ]
Testing nvswitch OK [ 9:25s ]
`)
got := parseRunLogTestStartTimes(content)
if got["gpumem"].Format(time.RFC3339) != "2026-01-22T07:42:26Z" {
t.Fatalf("unexpected gpumem start: %s", got["gpumem"].Format(time.RFC3339))
}
if got["gpustress"].Format(time.RFC3339) != "2026-01-22T08:08:38Z" {
t.Fatalf("unexpected gpustress start: %s", got["gpustress"].Format(time.RFC3339))
}
if got["nvswitch"].Format(time.RFC3339) != "2026-01-22T08:15:48Z" {
t.Fatalf("unexpected nvswitch start: %s", got["nvswitch"].Format(time.RFC3339))
}
}
func TestApplyGPUAndNVSwitchCheckTimes(t *testing.T) {
gpuTs := time.Date(2026, 1, 22, 9, 45, 36, 0, time.UTC)
nvsTs := time.Date(2026, 1, 22, 9, 11, 32, 0, time.UTC)
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "GPUSXM5", Status: "FAIL"},
},
PCIeDevices: []models.PCIeDevice{
{Slot: "NVSWITCH0", DeviceClass: "NVSwitch", Status: "PASS"},
{Slot: "NIC0", DeviceClass: "NetworkController", Status: "PASS"},
},
},
}
ApplyGPUAndNVSwitchCheckTimes(result, componentCheckTimes{
GPUBySlot: map[string]time.Time{"GPUSXM5": gpuTs},
NVSwitchBySlot: map[string]time.Time{"NVSWITCH0": nvsTs},
})
if got := result.Hardware.GPUs[0].StatusCheckedAt; !got.Equal(gpuTs) {
t.Fatalf("expected gpu status_checked_at %s, got %s", gpuTs.Format(time.RFC3339), got.Format(time.RFC3339))
}
if result.Hardware.GPUs[0].StatusAtCollect == nil || !result.Hardware.GPUs[0].StatusAtCollect.At.Equal(gpuTs) {
t.Fatalf("expected gpu status_at_collection.at %s", gpuTs.Format(time.RFC3339))
}
if got := result.Hardware.PCIeDevices[0].StatusCheckedAt; !got.Equal(nvsTs) {
t.Fatalf("expected nvswitch status_checked_at %s, got %s", nvsTs.Format(time.RFC3339), got.Format(time.RFC3339))
}
if result.Hardware.PCIeDevices[0].StatusAtCollect == nil || !result.Hardware.PCIeDevices[0].StatusAtCollect.At.Equal(nvsTs) {
t.Fatalf("expected nvswitch status_at_collection.at %s", nvsTs.Format(time.RFC3339))
}
if !result.Hardware.PCIeDevices[1].StatusCheckedAt.IsZero() {
t.Fatalf("expected non-nvswitch device status_checked_at to stay zero")
}
}
func TestCollectGPUAndNVSwitchCheckTimes_FromVerboseRun(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "verbose_run.log",
Content: []byte(`
2026-01-22 09:11:32,458 - Testing nvswitch
2026-01-22 09:45:36,016 - Testing gpu_fieldiag
`),
},
}
got := CollectGPUAndNVSwitchCheckTimes(files)
if got.GPUDefault.Format(time.RFC3339) != "2026-01-22T09:45:36Z" {
t.Fatalf("unexpected GPU check time: %s", got.GPUDefault.Format(time.RFC3339))
}
if got.NVSwitchDefault.Format(time.RFC3339) != "2026-01-22T09:11:32Z" {
t.Fatalf("unexpected NVSwitch check time: %s", got.NVSwitchDefault.Format(time.RFC3339))
}
}
func TestCollectGPUAndNVSwitchCheckTimes_FromComponentOutputLogs(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "gpu_fieldiag/SXM5_SN_1653925025497/output.log",
Content: []byte(`
$ some command
MODS start: Thu Jan 22 09:45:36 2026
`),
},
{
Path: "nvswitch/output.log",
Content: []byte(`
$ cmd devname=0000:08:00.0,NVSWITCH3 devname=0000:07:00.0,NVSWITCH2 devname=0000:06:00.0,NVSWITCH1 devname=0000:05:00.0,NVSWITCH0
MODS start: Thu Jan 22 09:11:32 2026
`),
},
}
got := CollectGPUAndNVSwitchCheckTimes(files)
if got.GPUBySerial["1653925025497"].Format(time.RFC3339) != "2026-01-22T09:45:36Z" {
t.Fatalf("unexpected GPU serial check time: %s", got.GPUBySerial["1653925025497"].Format(time.RFC3339))
}
if got.GPUBySlot["GPUSXM5"].Format(time.RFC3339) != "2026-01-22T09:45:36Z" {
t.Fatalf("unexpected GPU slot check time: %s", got.GPUBySlot["GPUSXM5"].Format(time.RFC3339))
}
if got.NVSwitchBySlot["NVSWITCH0"].Format(time.RFC3339) != "2026-01-22T09:11:32Z" {
t.Fatalf("unexpected NVSwitch0 check time: %s", got.NVSwitchBySlot["NVSWITCH0"].Format(time.RFC3339))
}
if got.NVSwitchBySlot["NVSWITCH3"].Format(time.RFC3339) != "2026-01-22T09:11:32Z" {
t.Fatalf("unexpected NVSwitch3 check time: %s", got.NVSwitchBySlot["NVSWITCH3"].Format(time.RFC3339))
}
}

View File

@@ -0,0 +1,374 @@
package nvidia
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var (
gpuNameWithSerialRegex = regexp.MustCompile(`^SXM(\d+)_SN_(.+)$`)
gpuNameSlotOnlyRegex = regexp.MustCompile(`^SXM(\d+)$`)
skuCodeRegex = regexp.MustCompile(`^(G\d{3})[.-](\d{4})`)
skuCodeInsideRegex = regexp.MustCompile(`(?:^|[^A-Z0-9])(?:\d)?(G\d{3})[.-](\d{4})(?:[^A-Z0-9]|$)`)
inforomPathRegex = regexp.MustCompile(`(?i)(?:^|[\\/])(checkinforom|inforom)[\\/](SXM(\d+))(?:_SN_([^\\/]+))?[\\/]fieldiag\.jso$`)
inforomProductPNRegex = regexp.MustCompile(`"product_part_num"\s*:\s*"([^"]+)"`)
inforomSerialRegex = regexp.MustCompile(`"serial_number"\s*:\s*"([^"]+)"`)
)
type testSpecData struct {
Actions []struct {
VirtualID string `json:"virtual_id"`
Args struct {
SKUToFile map[string]string `json:"sku_to_sku_json_file_map"`
ModsMapping map[string]json.RawMessage `json:"mods_mapping"`
} `json:"args"`
} `json:"actions"`
}
type inventoryFieldDiagSummary struct {
ModsRuns []struct {
ModsHeader []struct {
GPUName string `json:"GpuName"`
BoardInfo string `json:"BoardInfo"`
} `json:"ModsHeader"`
} `json:"ModsRuns"`
}
var hardcodedSKUToFileMap = map[string]string{
"G520-0200": "sku_hgx-h100-8-gpu_80g_aircooled_field.json",
"G520-0201": "sku_hgx-h100-8-gpu_80g_aircooled_field.json",
"G520-0202": "sku_hgx-h100-8-gpu_80g_tpol_field.json",
"G520-0203": "sku_hgx-h100-8-gpu_80g_tpol_field.json",
"G520-0205": "sku_hgx-h800-8-gpu_80g_aircooled_field.json",
"G520-0207": "sku_hgx-h800-8-gpu_80g_tpol_field.json",
"G520-0221": "sku_hgx-h100-8-gpu_96g_aircooled_field.json",
"G520-0236": "sku_hgx-h20-8-gpu_96g_aircooled_field.json",
"G520-0238": "sku_hgx-h20-8-gpu_96g_tpol_field.json",
"G520-0266": "sku_hgx-h20-8-gpu_141g_aircooled_field.json",
"G520-0280": "sku_hgx-h200-8-gpu_141g_aircooled_field.json",
"G520-0282": "sku_hgx-h200-8-gpu_141g_tpol_field.json",
"G520-0292": "sku_hgx-h100-8-gpu_sku_292_field.json",
}
// ApplyGPUModelsFromSKU updates GPU model names using SKU mapping from testspec.json.
// Mapping source:
// - inventory/fieldiag_summary.json: GPUName -> BoardInfo(SKU)
// - hardcoded SKU mapping
// - testspec.json: SKU -> sku_hgx-... filename (fallback for unknown hardcoded SKU)
// - inforom/*/fieldiag.jso: product_part_num (full P/N with embedded SKU)
// - testspec.json gpu_fieldiag.mods_mapping: DeviceID -> GPU generation (last fallback for description)
func ApplyGPUModelsFromSKU(files []parser.ExtractedFile, result *models.AnalysisResult) {
if result == nil || result.Hardware == nil || len(result.Hardware.GPUs) == 0 {
return
}
skuToFile := parseSKUToFileMap(files)
generationByDeviceID := parseGenerationByDeviceID(files)
serialToSKU, slotToSKU, serialToPartNumber, slotToPartNumber := parseGPUSKUMapping(files)
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
slot := strings.TrimSpace(gpu.Slot)
serial := strings.TrimSpace(gpu.SerialNumber)
if gpu.PartNumber == "" && serial != "" {
if pn := strings.TrimSpace(serialToPartNumber[serial]); pn != "" {
gpu.PartNumber = pn
}
}
if gpu.PartNumber == "" {
if pn := strings.TrimSpace(slotToPartNumber[slot]); pn != "" {
gpu.PartNumber = pn
}
}
if partNumber := strings.TrimSpace(gpu.PartNumber); partNumber != "" {
gpu.Model = partNumber
}
sku := extractSKUFromPartNumber(gpu.PartNumber)
if sku == "" && serial != "" {
sku = serialToSKU[serial]
}
if sku == "" {
sku = slotToSKU[slot]
}
if sku != "" {
if desc := resolveDescriptionFromSKU(sku, skuToFile); desc != "" {
gpu.Description = desc
continue
}
}
if gen := resolveGenerationDescription(gpu.DeviceID, generationByDeviceID); gen != "" {
gpu.Description = gen
}
}
}
func parseSKUToFileMap(files []parser.ExtractedFile) map[string]string {
result := make(map[string]string, len(hardcodedSKUToFileMap))
for sku, file := range hardcodedSKUToFileMap {
result[normalizeSKUCode(sku)] = strings.TrimSpace(file)
}
specFile := parser.FindFileByName(files, "testspec.json")
if specFile == nil {
return result
}
var spec testSpecData
if err := json.Unmarshal(specFile.Content, &spec); err != nil {
return result
}
for _, action := range spec.Actions {
for sku, file := range action.Args.SKUToFile {
normSKU := normalizeSKUCode(sku)
if normSKU == "" {
continue
}
// Priority: hardcoded mapping wins, testspec extends unknown SKU list.
if _, exists := result[normSKU]; !exists {
result[normSKU] = strings.TrimSpace(file)
}
}
}
return result
}
func parseGenerationByDeviceID(files []parser.ExtractedFile) map[string]string {
specFile := parser.FindFileByName(files, "testspec.json")
if specFile == nil {
return nil
}
var spec testSpecData
if err := json.Unmarshal(specFile.Content, &spec); err != nil {
return nil
}
familyToGeneration := make(map[string]string)
deviceToGeneration := make(map[string]string)
for _, action := range spec.Actions {
if strings.TrimSpace(strings.ToLower(action.VirtualID)) != "gpu_fieldiag" {
continue
}
for key, raw := range action.Args.ModsMapping {
if strings.HasPrefix(key, "#mods.") {
family := strings.TrimSpace(strings.TrimPrefix(key, "#mods."))
if family == "" {
continue
}
var generation string
if err := json.Unmarshal(raw, &generation); err == nil {
generation = strings.TrimSpace(generation)
if generation != "" {
familyToGeneration[family] = generation
}
}
}
}
for key, raw := range action.Args.ModsMapping {
family := strings.TrimSpace(key)
if family == "" || strings.HasPrefix(family, "#") {
continue
}
generation := strings.TrimSpace(familyToGeneration[family])
if generation == "" {
continue
}
var deviceIDs []string
if err := json.Unmarshal(raw, &deviceIDs); err != nil {
continue
}
for _, id := range deviceIDs {
norm := normalizeDeviceIDHex(id)
if norm != "" {
deviceToGeneration[norm] = generation
}
}
}
}
return deviceToGeneration
}
func parseGPUSKUMapping(files []parser.ExtractedFile) (map[string]string, map[string]string, map[string]string, map[string]string) {
serialToSKU := make(map[string]string)
slotToSKU := make(map[string]string)
serialToPartNumber := make(map[string]string)
slotToPartNumber := make(map[string]string)
// 1) inventory/fieldiag_summary.json mapping (GPUName/BoardInfo).
var summaryFile *parser.ExtractedFile
for _, f := range files {
path := strings.ToLower(f.Path)
if strings.Contains(path, "inventory/fieldiag_summary.json") ||
strings.Contains(path, "inventory\\fieldiag_summary.json") {
summaryFile = &f
break
}
}
if summaryFile == nil {
// Continue: inforom may still contain usable part numbers.
} else {
var summaries []inventoryFieldDiagSummary
if err := json.Unmarshal(summaryFile.Content, &summaries); err == nil {
for _, summary := range summaries {
addSummaryMapping(summary, serialToSKU, slotToSKU)
}
} else {
var summary inventoryFieldDiagSummary
if err := json.Unmarshal(summaryFile.Content, &summary); err == nil {
addSummaryMapping(summary, serialToSKU, slotToSKU)
}
}
}
// 2) inforom/checkinforom fieldiag.jso mapping (full product_part_num).
for _, f := range files {
path := strings.TrimSpace(f.Path)
m := inforomPathRegex.FindStringSubmatch(path)
if len(m) == 0 {
continue
}
slot := "GPU" + strings.ToUpper(strings.TrimSpace(m[2])) // SXM7 -> GPUSXM7
serialFromPath := strings.TrimSpace(m[4])
productPNMatch := inforomProductPNRegex.FindSubmatch(f.Content)
if len(productPNMatch) == 2 {
partNumber := strings.TrimSpace(string(productPNMatch[1]))
if partNumber != "" {
slotToPartNumber[slot] = partNumber
if serialFromPath != "" {
serialToPartNumber[serialFromPath] = partNumber
}
if sku := extractSKUFromPartNumber(partNumber); sku != "" {
slotToSKU[slot] = sku
if serialFromPath != "" {
serialToSKU[serialFromPath] = sku
}
}
}
}
serialMatch := inforomSerialRegex.FindSubmatch(f.Content)
if len(serialMatch) == 2 {
serial := strings.TrimSpace(string(serialMatch[1]))
if serial != "" {
if sku := slotToSKU[slot]; sku != "" {
serialToSKU[serial] = sku
}
if pn := slotToPartNumber[slot]; pn != "" {
serialToPartNumber[serial] = pn
}
}
}
}
return serialToSKU, slotToSKU, serialToPartNumber, slotToPartNumber
}
func addSummaryMapping(summary inventoryFieldDiagSummary, serialToSKU map[string]string, slotToSKU map[string]string) {
for _, run := range summary.ModsRuns {
for _, h := range run.ModsHeader {
sku := normalizeSKUCode(h.BoardInfo)
if sku == "" {
continue
}
gpuName := strings.TrimSpace(h.GPUName)
if matches := gpuNameWithSerialRegex.FindStringSubmatch(gpuName); len(matches) == 3 {
slotToSKU["GPUSXM"+matches[1]] = sku
serialToSKU[strings.TrimSpace(matches[2])] = sku
continue
}
if matches := gpuNameSlotOnlyRegex.FindStringSubmatch(gpuName); len(matches) == 2 {
slotToSKU["GPUSXM"+matches[1]] = sku
}
}
}
}
func resolveDescriptionFromSKU(sku string, skuToFile map[string]string) string {
file := strings.ToLower(strings.TrimSpace(skuToFile[normalizeSKUCode(sku)]))
if file == "" {
return ""
}
return skuFilenameToDescription(file)
}
func normalizeSKUCode(v string) string {
s := strings.TrimSpace(strings.ToUpper(v))
if s == "" {
return ""
}
if m := skuCodeRegex.FindStringSubmatch(s); len(m) == 3 {
return m[1] + "-" + m[2]
}
return s
}
func extractSKUFromPartNumber(partNumber string) string {
s := strings.TrimSpace(strings.ToUpper(partNumber))
if s == "" {
return ""
}
if m := skuCodeInsideRegex.FindStringSubmatch(s); len(m) == 3 {
return m[1] + "-" + m[2]
}
return ""
}
func skuFilenameToDescription(file string) string {
s := strings.TrimSpace(strings.ToLower(file))
if s == "" {
return ""
}
s = strings.TrimSuffix(s, ".json")
s = strings.TrimSuffix(s, "_field")
s = strings.TrimPrefix(s, "sku_")
s = strings.ReplaceAll(s, "-", " ")
s = strings.ReplaceAll(s, "_", " ")
s = strings.Join(strings.Fields(s), " ")
return strings.TrimSpace(s)
}
func resolveGenerationDescription(deviceID int, deviceToGeneration map[string]string) string {
if deviceID <= 0 || len(deviceToGeneration) == 0 {
return ""
}
return strings.TrimSpace(deviceToGeneration[normalizeDeviceIDHex(strconv.FormatInt(int64(deviceID), 16))])
}
func normalizeDeviceIDHex(v string) string {
s := strings.TrimSpace(strings.ToLower(v))
s = strings.TrimPrefix(s, "0x")
if s == "" {
return ""
}
n, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return ""
}
return "0x" + strings.ToLower(strconv.FormatUint(n, 16))
}

View File

@@ -0,0 +1,207 @@
package nvidia
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestApplyGPUModelsFromSKU(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "inventory/fieldiag_summary.json",
Content: []byte(`{
"ModsRuns":[
{"ModsHeader":[
{"GpuName":"SXM5_SN_1653925025497","BoardInfo":"G520-0280"}
]}
]
}`),
},
{
Path: "testspec.json",
Content: []byte(`{
"actions":[
{
"virtual_id":"inventory",
"args":{
"sku_to_sku_json_file_map":{
"G520-0280":"sku_hgx-h200-8-gpu_141g_aircooled_field.json"
}
}
}
]
}`),
},
}
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
SerialNumber: "1653925025497",
Model: "NVIDIA Device 2335",
},
},
},
}
ApplyGPUModelsFromSKU(files, result)
if got := result.Hardware.GPUs[0].Model; got != "NVIDIA Device 2335" {
t.Fatalf("expected model NVIDIA Device 2335, got %q", got)
}
if got := result.Hardware.GPUs[0].Description; got != "hgx h200 8 gpu 141g aircooled" {
t.Fatalf("expected description hgx h200 8 gpu 141g aircooled, got %q", got)
}
}
func TestApplyGPUModelsFromSKU_FromPartNumber(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "inforom/SXM5/fieldiag.jso",
Content: []byte(`[
[
{
"__tag__":"inforom",
"serial_number":"1653925025497",
"product_part_num":"692-2G520-0280-501"
}
]
]`),
},
{
Path: "testspec.json",
Content: []byte(`{
"actions":[
{
"virtual_id":"inventory",
"args":{
"sku_to_sku_json_file_map":{
"G520-0280":"sku_hgx-h200-8-gpu_141g_aircooled_field.json"
}
}
}
]
}`),
},
}
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
SerialNumber: "1653925025497",
Model: "NVIDIA Device 2335",
},
},
},
}
ApplyGPUModelsFromSKU(files, result)
if got := result.Hardware.GPUs[0].Model; got != "692-2G520-0280-501" {
t.Fatalf("expected model 692-2G520-0280-501, got %q", got)
}
if got := result.Hardware.GPUs[0].PartNumber; got != "692-2G520-0280-501" {
t.Fatalf("expected part number 692-2G520-0280-501, got %q", got)
}
if got := result.Hardware.GPUs[0].Description; got != "hgx h200 8 gpu 141g aircooled" {
t.Fatalf("expected description hgx h200 8 gpu 141g aircooled, got %q", got)
}
}
func TestApplyGPUModelsFromSKU_FieldDiagSummaryArrayFormat(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "inventory/fieldiag_summary.json",
Content: []byte(`[
{
"ModsRuns":[
{"ModsHeader":[
{"GpuName":"SXM5_SN_1653925025497","BoardInfo":"G520-0280"}
]}
]
}
]`),
},
{
Path: "testspec.json",
Content: []byte(`{
"actions":[
{
"virtual_id":"inventory",
"args":{
"sku_to_sku_json_file_map":{
"G520-0280":"sku_hgx-h200-8-gpu_141g_aircooled_field.json"
}
}
}
]
}`),
},
}
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
SerialNumber: "1653925025497",
Model: "NVIDIA Device 2335",
},
},
},
}
ApplyGPUModelsFromSKU(files, result)
if got := result.Hardware.GPUs[0].Model; got != "NVIDIA Device 2335" {
t.Fatalf("expected model NVIDIA Device 2335, got %q", got)
}
if got := result.Hardware.GPUs[0].Description; got != "hgx h200 8 gpu 141g aircooled" {
t.Fatalf("expected description hgx h200 8 gpu 141g aircooled, got %q", got)
}
}
func TestApplyGPUModelsFromSKU_FallbackToGenerationFromModsMapping(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "testspec.json",
Content: []byte(`{
"actions":[
{
"virtual_id":"gpu_fieldiag",
"args":{
"mods_mapping":{
"#mods.525":"Hopper",
"525":["0x2335"]
}
}
}
]
}`),
},
}
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
Model: "NVIDIA Device 2335",
DeviceID: 0x2335,
},
},
},
}
ApplyGPUModelsFromSKU(files, result)
if got := result.Hardware.GPUs[0].Description; got != "Hopper" {
t.Fatalf("expected description Hopper, got %q", got)
}
}

View File

@@ -0,0 +1,155 @@
package nvidia
import (
"bufio"
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var (
// Regex to extract devname mappings from fieldiag command line
// Example: "devname=0000:ba:00.0,SXM5_SN_1653925027099"
devnameRegex = regexp.MustCompile(`devname=([\da-fA-F:\.]+),(\w+)`)
// Regex to capture BDF from commands like:
// "$ lspci -vvvs 0000:05:00.0" or "$ lspci -vvs 0000:05:00.0"
lspciBDFRegex = regexp.MustCompile(`^\$\s+lspci\s+-[^\s]*\s+([0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7])\s*$`)
// Example: "Capabilities: [2f0 v1] Device Serial Number 99-d3-61-c8-ac-2d-b0-48"
deviceSerialRegex = regexp.MustCompile(`Device Serial Number\s+([0-9a-fA-F\-:]+)`)
)
// ParseInventoryLog parses inventory/output.log to extract GPU serial numbers
// from fieldiag devname parameters (e.g., "SXM5_SN_1653925027099")
func ParseInventoryLog(content []byte, result *models.AnalysisResult) error {
if result.Hardware == nil || len(result.Hardware.GPUs) == 0 {
// No GPUs to update
return nil
}
scanner := bufio.NewScanner(strings.NewReader(string(content)))
// First pass: build mapping of PCI BDF -> Slot name and serial number from fieldiag command line
pciToSlot := make(map[string]string)
pciToSerial := make(map[string]string)
for scanner.Scan() {
line := scanner.Text()
// Look for fieldiag command with devname parameters
if strings.Contains(line, "devname=") && strings.Contains(line, "fieldiag") {
matches := devnameRegex.FindAllStringSubmatch(line, -1)
for _, match := range matches {
if len(match) == 3 {
pciBDF := match[1]
slotName := match[2]
// Extract slot number and serial from name like "SXM5_SN_1653925027099"
if strings.HasPrefix(slotName, "SXM") {
parts := strings.Split(slotName, "_")
if len(parts) >= 1 {
// Convert "SXM5" to "GPUSXM5"
slot := "GPU" + parts[0]
pciToSlot[pciBDF] = slot
}
// Extract serial number from "SXM5_SN_1653925027099"
if len(parts) == 3 && parts[1] == "SN" {
serial := parts[2]
pciToSerial[pciBDF] = serial
}
}
}
}
}
}
// Second pass: assign serial numbers to GPUs based on slot mapping
for i := range result.Hardware.GPUs {
slot := result.Hardware.GPUs[i].Slot
// Find the PCI BDF for this slot
var foundSerial string
for pciBDF, mappedSlot := range pciToSlot {
if mappedSlot == slot {
// Found matching slot, get serial number
if serial, ok := pciToSerial[pciBDF]; ok {
foundSerial = serial
break
}
}
}
if foundSerial != "" {
result.Hardware.GPUs[i].SerialNumber = foundSerial
}
}
// Third pass: parse lspci "Device Serial Number" by BDF (useful for NVSwitch serials).
bdfToDeviceSerial := make(map[string]string)
currentBDF := ""
scanner = bufio.NewScanner(strings.NewReader(string(content)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if m := lspciBDFRegex.FindStringSubmatch(line); len(m) == 2 {
currentBDF = strings.ToLower(strings.TrimSpace(m[1]))
continue
}
if currentBDF == "" {
continue
}
if m := deviceSerialRegex.FindStringSubmatch(line); len(m) == 2 {
serial := strings.TrimSpace(m[1])
if serial != "" {
bdfToDeviceSerial[currentBDF] = serial
}
currentBDF = ""
}
}
// Apply to PCIe devices first (includes NVSwitch).
for i := range result.Hardware.PCIeDevices {
dev := &result.Hardware.PCIeDevices[i]
if strings.TrimSpace(dev.SerialNumber) != "" {
continue
}
bdf := strings.ToLower(strings.TrimSpace(dev.BDF))
if bdf == "" {
continue
}
if serial := bdfToDeviceSerial[bdf]; serial != "" {
dev.SerialNumber = serial
}
}
// Apply to GPUs only if GPU serial is still empty (do not overwrite prod serial from devname).
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
if strings.TrimSpace(gpu.SerialNumber) != "" {
continue
}
bdf := strings.ToLower(strings.TrimSpace(gpu.BDF))
if bdf == "" {
continue
}
if serial := bdfToDeviceSerial[bdf]; serial != "" {
gpu.SerialNumber = serial
}
}
return scanner.Err()
}
// findInventoryOutputLog finds the inventory/output.log file
func findInventoryOutputLog(files []parser.ExtractedFile) *parser.ExtractedFile {
for _, f := range files {
// Look for inventory/output.log
path := strings.ToLower(f.Path)
if strings.Contains(path, "inventory/output.log") ||
strings.Contains(path, "inventory\\output.log") {
return &f
}
}
return nil
}

View File

@@ -0,0 +1,126 @@
package nvidia
import (
"os"
"path/filepath"
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestParseInventoryLog(t *testing.T) {
// Test with the real archive
archivePath := filepath.Join("../../../../example", "A514359X5A09844_logs-20260115-151707.tar")
// Check if file exists
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
// Extract files from archive
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
// Find inventory/output.log
var inventoryLog *parser.ExtractedFile
for _, f := range files {
if strings.Contains(f.Path, "inventory/output.log") {
inventoryLog = &f
break
}
}
if inventoryLog == nil {
t.Fatal("inventory/output.log not found")
}
content := string(inventoryLog.Content)
// Test devname regex - this extracts both slot mapping and serial numbers
t.Log("Testing devname extraction:")
lines := strings.Split(content, "\n")
serialCount := 0
for i, line := range lines {
if strings.Contains(line, "devname=") && strings.Contains(line, "fieldiag") {
t.Logf("Line %d: Found fieldiag command", i)
matches := devnameRegex.FindAllStringSubmatch(line, -1)
t.Logf(" Found %d devname matches", len(matches))
for _, match := range matches {
if len(match) == 3 {
pciBDF := match[1]
slotName := match[2]
t.Logf(" PCI: %s -> Slot: %s", pciBDF, slotName)
// Extract serial number from slot name
if strings.HasPrefix(slotName, "SXM") {
parts := strings.Split(slotName, "_")
if len(parts) == 3 && parts[1] == "SN" {
serial := parts[2]
t.Logf(" Serial: %s", serial)
serialCount++
}
}
}
}
break
}
}
t.Logf("\nTotal GPU serials extracted: %d", serialCount)
if serialCount == 0 {
t.Error("Expected to find GPU serial numbers, but found none")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestParseInventoryLog_AssignsNVSwitchSerialByBDF(t *testing.T) {
content := []byte(`
$ lspci -vvvs 0000:05:00.0
05:00.0 Bridge: NVIDIA Corporation Device 22a3 (rev a1)
Capabilities: [2f0 v1] Device Serial Number 99-d3-61-c8-ac-2d-b0-48
/tmp/fieldiag devname=0000:ba:00.0,SXM5_SN_1653925025497 fieldiag
`)
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
BDF: "0000:ba:00.0",
SerialNumber: "",
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "NVSWITCH0",
BDF: "0000:05:00.0",
SerialNumber: "",
},
},
},
}
if err := ParseInventoryLog(content, result); err != nil {
t.Fatalf("ParseInventoryLog failed: %v", err)
}
if got := result.Hardware.PCIeDevices[0].SerialNumber; got != "99-d3-61-c8-ac-2d-b0-48" {
t.Fatalf("expected NVSwitch serial 99-d3-61-c8-ac-2d-b0-48, got %q", got)
}
// GPU serial should come from fieldiag devname mapping.
if got := result.Hardware.GPUs[0].SerialNumber; got != "1653925025497" {
t.Fatalf("expected GPU serial 1653925025497, got %q", got)
}
}

View File

@@ -0,0 +1,370 @@
package nvidia
import (
"bufio"
"fmt"
"regexp"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
var (
nvflashAdapterRegex = regexp.MustCompile(`^Adapter:\s+.+\(([\da-fA-F]+),([\da-fA-F]+),([\da-fA-F]+),([\da-fA-F]+)\)\s+S:([0-9A-Fa-f]{2}),B:([0-9A-Fa-f]{2}),D:([0-9A-Fa-f]{2}),F:([0-9A-Fa-f])`)
gpuPCIIDRegex = regexp.MustCompile(`^GPU_SXM(\d+)_PCIID:\s*(\S+)$`)
nvsPCIIDRegex = regexp.MustCompile(`^NVSWITCH_NVSWITCH(\d+)_PCIID:\s*(\S+)$`)
)
var nvswitchProjectToPartNumber = map[string]string{
"5612-0002": "965-25612-0002-000",
}
type nvflashDeviceRecord struct {
BDF string
VendorID int
DeviceID int
SSVendorID int
SSDeviceID int
Version string
BoardID string
HierarchyID string
ChipSKU string
Project string
}
// ParseNVFlashVerboseLog parses inventory/nvflash_verbose.log and applies firmware versions
// to already discovered devices using PCI BDF with optional ID checks.
func ParseNVFlashVerboseLog(content []byte, result *models.AnalysisResult) error {
if result == nil || result.Hardware == nil {
return nil
}
records := parseNVFlashRecords(content)
if len(records) == 0 {
return nil
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
bdf := normalizePCIBDF(gpu.BDF)
if bdf == "" {
continue
}
rec, ok := records[bdf]
if !ok {
continue
}
if gpu.DeviceID != 0 && rec.DeviceID != 0 && gpu.DeviceID != rec.DeviceID {
continue
}
if gpu.VendorID != 0 && rec.VendorID != 0 && gpu.VendorID != rec.VendorID {
continue
}
if strings.TrimSpace(rec.Version) != "" {
gpu.Firmware = strings.TrimSpace(rec.Version)
}
}
for i := range result.Hardware.PCIeDevices {
dev := &result.Hardware.PCIeDevices[i]
bdf := normalizePCIBDF(dev.BDF)
if bdf == "" {
continue
}
rec, ok := records[bdf]
if !ok {
continue
}
if dev.DeviceID != 0 && rec.DeviceID != 0 && dev.DeviceID != rec.DeviceID {
continue
}
if dev.VendorID != 0 && rec.VendorID != 0 && dev.VendorID != rec.VendorID {
continue
}
if strings.EqualFold(strings.TrimSpace(dev.DeviceClass), "NVSwitch") || strings.HasPrefix(strings.ToUpper(strings.TrimSpace(dev.Slot)), "NVSWITCH") {
if mappedPN := mapNVSwitchPartNumberByProject(rec.Project); mappedPN != "" {
dev.PartNumber = mappedPN
}
}
if strings.TrimSpace(rec.Version) != "" && strings.TrimSpace(dev.PartNumber) == "" {
// Fallback for non-NVSwitch devices where part number is unknown.
dev.PartNumber = strings.TrimSpace(rec.Version)
}
}
appendNVFlashFirmwareEntries(result, records)
return nil
}
// ApplyInventoryPCIIDs enriches devices with PCI BDFs from inventory/inventory.log.
func ApplyInventoryPCIIDs(content []byte, result *models.AnalysisResult) error {
if result == nil || result.Hardware == nil {
return nil
}
slotToBDF := parseInventoryPCIIDs(content)
if len(slotToBDF) == 0 {
return nil
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
if strings.TrimSpace(gpu.BDF) != "" {
continue
}
if bdf := slotToBDF[strings.TrimSpace(gpu.Slot)]; bdf != "" {
gpu.BDF = bdf
}
}
for i := range result.Hardware.PCIeDevices {
dev := &result.Hardware.PCIeDevices[i]
if strings.TrimSpace(dev.BDF) != "" {
continue
}
if bdf := slotToBDF[normalizeNVSwitchSlot(strings.TrimSpace(dev.Slot))]; bdf != "" {
dev.BDF = bdf
}
}
return nil
}
func parseNVFlashRecords(content []byte) map[string]nvflashDeviceRecord {
scanner := bufio.NewScanner(strings.NewReader(string(content)))
records := make(map[string]nvflashDeviceRecord)
var current *nvflashDeviceRecord
commit := func() {
if current == nil {
return
}
if current.BDF == "" || strings.TrimSpace(current.Version) == "" {
return
}
records[current.BDF] = *current
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if m := nvflashAdapterRegex.FindStringSubmatch(line); len(m) == 9 {
commit()
vendorID, _ := parseHexInt(m[1])
deviceID, _ := parseHexInt(m[2])
ssVendorID, _ := parseHexInt(m[3])
ssDeviceID, _ := parseHexInt(m[4])
current = &nvflashDeviceRecord{
BDF: fmt.Sprintf("0000:%s:%s.%s", strings.ToLower(m[6]), strings.ToLower(m[7]), strings.ToLower(m[8])),
VendorID: vendorID,
DeviceID: deviceID,
SSVendorID: ssVendorID,
SSDeviceID: ssDeviceID,
}
continue
}
if current == nil {
continue
}
if !strings.Contains(line, ":") {
continue
}
parts := strings.SplitN(line, ":", 2)
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if key == "" || val == "" {
continue
}
switch key {
case "Version":
current.Version = val
case "Board ID":
current.BoardID = strings.ToLower(strings.TrimPrefix(val, "0x"))
case "Vendor ID":
if v, err := parseHexInt(val); err == nil {
current.VendorID = v
}
case "Device ID":
if v, err := parseHexInt(val); err == nil {
current.DeviceID = v
}
case "Hierarchy ID":
current.HierarchyID = val
case "Chip SKU":
current.ChipSKU = val
case "Project":
current.Project = val
}
}
commit()
return records
}
func parseInventoryPCIIDs(content []byte) map[string]string {
scanner := bufio.NewScanner(strings.NewReader(string(content)))
slotToBDF := make(map[string]string)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if m := gpuPCIIDRegex.FindStringSubmatch(line); len(m) == 3 {
slotToBDF["GPUSXM"+m[1]] = normalizePCIBDF(m[2])
continue
}
if m := nvsPCIIDRegex.FindStringSubmatch(line); len(m) == 3 {
slotToBDF["NVSWITCH"+m[1]] = normalizePCIBDF(m[2])
}
}
return slotToBDF
}
func normalizePCIBDF(v string) string {
s := strings.TrimSpace(strings.ToLower(v))
if s == "" {
return ""
}
// bus:device.func -> 0000:bus:device.func
short := regexp.MustCompile(`^([0-9a-f]{2}:[0-9a-f]{2}\.[0-7])$`)
if m := short.FindStringSubmatch(s); len(m) == 2 {
return "0000:" + m[1]
}
full := regexp.MustCompile(`^([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7])$`)
if m := full.FindStringSubmatch(s); len(m) == 2 {
return m[1]
}
return s
}
func parseHexInt(v string) (int, error) {
s := strings.TrimSpace(strings.ToLower(v))
s = strings.TrimPrefix(s, "0x")
if s == "" {
return 0, fmt.Errorf("empty hex value")
}
n, err := strconv.ParseInt(s, 16, 32)
if err != nil {
return 0, err
}
return int(n), nil
}
func findNVFlashVerboseLog(files []parser.ExtractedFile) *parser.ExtractedFile {
for _, f := range files {
path := strings.ToLower(f.Path)
if strings.Contains(path, "inventory/nvflash_verbose.log") ||
strings.Contains(path, "inventory\\nvflash_verbose.log") {
return &f
}
}
return nil
}
func findInventoryInfoLog(files []parser.ExtractedFile) *parser.ExtractedFile {
for _, f := range files {
path := strings.ToLower(f.Path)
if strings.Contains(path, "inventory/inventory.log") ||
strings.Contains(path, "inventory\\inventory.log") {
return &f
}
}
return nil
}
func appendNVFlashFirmwareEntries(result *models.AnalysisResult, records map[string]nvflashDeviceRecord) {
if result == nil || result.Hardware == nil {
return
}
if result.Hardware.Firmware == nil {
result.Hardware.Firmware = make([]models.FirmwareInfo, 0)
}
seen := make(map[string]struct{})
for _, fw := range result.Hardware.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName)) + "|" + strings.TrimSpace(fw.Version)
seen[key] = struct{}{}
}
for _, gpu := range result.Hardware.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)
}
deviceName := fmt.Sprintf("GPU %s (%s)", strings.TrimSpace(gpu.Slot), model)
key := strings.ToLower(deviceName) + "|" + version
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: deviceName,
Version: version,
})
}
for _, dev := range result.Hardware.PCIeDevices {
bdf := normalizePCIBDF(dev.BDF)
rec, ok := records[bdf]
if !ok {
continue
}
version := strings.TrimSpace(rec.Version)
if version == "" {
continue
}
slot := strings.TrimSpace(dev.Slot)
deviceClass := strings.TrimSpace(dev.DeviceClass)
if strings.EqualFold(deviceClass, "NVSwitch") || strings.HasPrefix(strings.ToUpper(slot), "NVSWITCH") {
model := slot
if pn := strings.TrimSpace(dev.PartNumber); pn != "" {
model = pn
}
deviceName := fmt.Sprintf("NVSwitch %s (%s)", slot, model)
key := strings.ToLower(deviceName) + "|" + version
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: deviceName,
Version: version,
})
}
}
}
func mapNVSwitchPartNumberByProject(project string) string {
key := strings.TrimSpace(strings.ToLower(project))
if key == "" {
return ""
}
return strings.TrimSpace(nvswitchProjectToPartNumber[key])
}

View File

@@ -0,0 +1,93 @@
package nvidia
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestApplyInventoryPCIIDsAndNVFlashFirmware(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM5",
DeviceID: 0x2335,
},
},
PCIeDevices: []models.PCIeDevice{
{
Slot: "NVSWITCHNVSWITCH2",
DeviceID: 0x22a3,
},
},
},
}
inventoryLog := []byte(`
GPU_SXM5_PCIID: 0000:ba:00.0
NVSWITCH_NVSWITCH2_PCIID: 0000:07:00.0
`)
nvflashLog := []byte(`
Adapter: Graphics Device (10DE,2335,10DE,18BE) S:00,B:BA,D:00,F:00
Version : 96.00.D0.00.03
Board ID : 0x053C
Vendor ID : 0x10DE
Device ID : 0x2335
Hierarchy ID : Normal Board
Chip SKU : 895-0
Project : G520-0280
Adapter: Graphics Device (10DE,22A3,10DE,1796) S:00,B:07,D:00,F:00
Version : 96.10.6D.00.01
Board ID : 0x03B7
Vendor ID : 0x10DE
Device ID : 0x22A3
Hierarchy ID : Normal Board
Chip SKU : 890-0
Project : 5612-0002
`)
if err := ApplyInventoryPCIIDs(inventoryLog, result); err != nil {
t.Fatalf("ApplyInventoryPCIIDs failed: %v", err)
}
if err := ParseNVFlashVerboseLog(nvflashLog, result); err != nil {
t.Fatalf("ParseNVFlashVerboseLog failed: %v", err)
}
if got := result.Hardware.GPUs[0].BDF; got != "0000:ba:00.0" {
t.Fatalf("expected GPU BDF 0000:ba:00.0, got %q", got)
}
if got := result.Hardware.GPUs[0].Firmware; got != "96.00.D0.00.03" {
t.Fatalf("expected GPU firmware 96.00.D0.00.03, got %q", got)
}
if got := result.Hardware.PCIeDevices[0].BDF; got != "0000:07:00.0" {
t.Fatalf("expected NVSwitch BDF 0000:07:00.0, got %q", got)
}
if got := result.Hardware.PCIeDevices[0].PartNumber; got != "965-25612-0002-000" {
t.Fatalf("expected NVSwitch part number 965-25612-0002-000, got %q", got)
}
if len(result.Hardware.Firmware) == 0 {
t.Fatalf("expected firmware entries to be populated from nvflash log")
}
hasGPUFW := false
hasNVSwitchFW := false
for _, fw := range result.Hardware.Firmware {
if fw.Version == "96.00.D0.00.03" {
hasGPUFW = true
}
if fw.Version == "96.10.6D.00.01" {
hasNVSwitchFW = true
}
}
if !hasGPUFW {
t.Fatalf("expected GPU firmware version 96.00.D0.00.03 in hardware firmware list")
}
if !hasNVSwitchFW {
t.Fatalf("expected NVSwitch firmware version 96.10.6D.00.01 in hardware firmware list")
}
}

View File

@@ -14,7 +14,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.1.0"
const parserVersion = "1.3.0"
func init() {
parser.Register(&Parser{})
@@ -70,7 +70,7 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
if strings.HasSuffix(path, "output.log") {
// Check if it contains dmidecode output
if strings.Contains(string(f.Content), "dmidecode") ||
strings.Contains(string(f.Content), "System Information") {
strings.Contains(string(f.Content), "System Information") {
confidence += 10
}
}
@@ -105,6 +105,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Hardware = &models.HardwareConfig{
GPUs: make([]models.GPU, 0),
}
gpuStatuses := make(map[string]string)
gpuFailureDetails := make(map[string]string)
nvswitchStatuses := make(map[string]string)
// Parse output.log first (contains dmidecode system info)
// Find the output.log file that contains dmidecode output
@@ -124,18 +127,75 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
}
}
// Parse inventory/output.log (contains GPU serial numbers from lspci)
inventoryLogFile := findInventoryOutputLog(files)
if inventoryLogFile != nil {
if err := ParseInventoryLog(inventoryLogFile.Content, result); err != nil {
// Log error but continue parsing other files
_ = err // Ignore error for now
}
}
// Parse inventory/inventory.log to enrich PCI BDF mapping for components.
inventoryInfoLog := findInventoryInfoLog(files)
if inventoryInfoLog != nil {
if err := ApplyInventoryPCIIDs(inventoryInfoLog.Content, result); err != nil {
_ = err
}
}
// Enhance GPU model names using SKU mapping from testspec + inventory summary.
ApplyGPUModelsFromSKU(files, result)
// Parse inventory/nvflash_verbose.log and apply firmware versions by BDF + IDs.
// This runs after GPU model/part-number enrichment so firmware tab uses final model labels.
nvflashVerbose := findNVFlashVerboseLog(files)
if nvflashVerbose != nil {
if err := ParseNVFlashVerboseLog(nvflashVerbose.Content, result); err != nil {
_ = err
}
}
// Parse summary.json (test results summary)
if f := parser.FindFileByName(files, "summary.json"); f != nil {
events := ParseSummaryJSON(f.Content)
result.Events = append(result.Events, events...)
for componentID, status := range CollectGPUStatusesFromSummaryJSON(f.Content) {
gpuStatuses[componentID] = mergeGPUStatus(gpuStatuses[componentID], status)
}
for slot, status := range CollectNVSwitchStatusesFromSummaryJSON(f.Content) {
nvswitchStatuses[slot] = mergeGPUStatus(nvswitchStatuses[slot], status)
}
for componentID, detail := range CollectGPUFailureDetailsFromSummaryJSON(f.Content) {
if _, exists := gpuFailureDetails[componentID]; !exists && strings.TrimSpace(detail) != "" {
gpuFailureDetails[componentID] = strings.TrimSpace(detail)
}
}
}
// Parse summary.csv (alternative format)
if f := parser.FindFileByName(files, "summary.csv"); f != nil {
csvEvents := ParseSummaryCSV(f.Content)
result.Events = append(result.Events, csvEvents...)
for componentID, status := range CollectGPUStatusesFromSummaryCSV(f.Content) {
gpuStatuses[componentID] = mergeGPUStatus(gpuStatuses[componentID], status)
}
for slot, status := range CollectNVSwitchStatusesFromSummaryCSV(f.Content) {
nvswitchStatuses[slot] = mergeGPUStatus(nvswitchStatuses[slot], status)
}
for componentID, detail := range CollectGPUFailureDetailsFromSummaryCSV(f.Content) {
if _, exists := gpuFailureDetails[componentID]; !exists && strings.TrimSpace(detail) != "" {
gpuFailureDetails[componentID] = strings.TrimSpace(detail)
}
}
}
// Apply per-GPU PASS/FAIL status derived from summary files.
ApplyGPUStatuses(result, gpuStatuses)
ApplyGPUFailureDetails(result, gpuFailureDetails)
ApplyNVSwitchStatuses(result, nvswitchStatuses)
ApplyGPUAndNVSwitchCheckTimes(result, CollectGPUAndNVSwitchCheckTimes(files))
// Parse GPU field diagnostics logs
gpuFieldiagFiles := parser.FindFileByPattern(files, "gpu_fieldiag/", ".log")
for _, f := range gpuFieldiagFiles {
@@ -158,7 +218,7 @@ func findDmidecodeOutputLog(files []parser.ExtractedFile) *parser.ExtractedFile
// Check if it contains dmidecode output
content := string(f.Content)
if strings.Contains(content, "dmidecode") &&
strings.Contains(content, "System Information") {
strings.Contains(content, "System Information") {
return &f
}
}

View File

@@ -0,0 +1,291 @@
package nvidia
import (
"os"
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestNVIDIAParser_RealArchive(t *testing.T) {
// Test with the real archive that was reported as problematic
archivePath := filepath.Join("../../../../example", "A514359X5A09844_logs-20260115-151707.tar")
// Check if file exists
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
// Extract files from archive
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
// Check if inventory/output.log exists
hasInventoryLog := false
for _, f := range files {
if filepath.Base(f.Path) == "output.log" {
t.Logf("Found file: %s", f.Path)
}
if f.Path == "./inventory/output.log" || f.Path == "inventory/output.log" {
hasInventoryLog = true
t.Logf("Found inventory/output.log with %d bytes", len(f.Content))
}
}
if !hasInventoryLog {
t.Error("inventory/output.log not found in extracted files")
}
// Create parser and parse
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Failed to parse archive: %v", err)
}
// Verify basic system info
if result.Hardware.BoardInfo.Manufacturer == "" {
t.Error("Expected Manufacturer to be set")
}
if result.Hardware.BoardInfo.ProductName == "" {
t.Error("Expected ProductName to be set")
}
if result.Hardware.BoardInfo.SerialNumber == "" {
t.Error("Expected SerialNumber to be set")
}
t.Logf("System Info:")
t.Logf(" Manufacturer: %s", result.Hardware.BoardInfo.Manufacturer)
t.Logf(" Product: %s", result.Hardware.BoardInfo.ProductName)
t.Logf(" Serial: %s", result.Hardware.BoardInfo.SerialNumber)
// Verify GPUs were found
if len(result.Hardware.GPUs) == 0 {
t.Error("Expected to find GPUs")
}
t.Logf("\nFound %d GPUs:", len(result.Hardware.GPUs))
gpusWithSerials := 0
for _, gpu := range result.Hardware.GPUs {
t.Logf(" %s: %s (Firmware: %s, Serial: %s, BDF: %s)",
gpu.Slot, gpu.Model, gpu.Firmware, gpu.SerialNumber, gpu.BDF)
if gpu.SerialNumber != "" {
gpusWithSerials++
}
}
// Verify that GPU serial numbers were extracted
if gpusWithSerials == 0 {
t.Error("Expected at least some GPUs to have serial numbers")
}
t.Logf("\nGPUs with serial numbers: %d/%d", gpusWithSerials, len(result.Hardware.GPUs))
// Check events for SXM2 failures
t.Logf("\nTotal events: %d", len(result.Events))
// Look for the specific serial or SXM2
sxm2Events := 0
for _, event := range result.Events {
desc := event.Description + " " + event.RawData + " " + event.EventType
if contains(desc, "SXM2") || contains(desc, "1653925025827") {
t.Logf(" SXM2 Event: [%s] %s (Severity: %s)", event.EventType, event.Description, event.Severity)
sxm2Events++
}
}
if sxm2Events == 0 {
t.Error("Expected to find events for SXM2 (faulty GPU 1653925025827)")
}
t.Logf("\nSXM2 failure events: %d", sxm2Events)
}
func TestNVIDIAParser_GPUStatusFromSummary_RealArchive07900(t *testing.T) {
archivePath := filepath.Join("../../../../example", "A514359X5A07900_logs-20260122-074208.tar")
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Failed to parse archive: %v", err)
}
if result.Hardware == nil || len(result.Hardware.GPUs) == 0 {
t.Fatalf("expected GPUs in parsed result")
}
statusBySerial := make(map[string]string, len(result.Hardware.GPUs))
for _, gpu := range result.Hardware.GPUs {
if gpu.SerialNumber != "" {
statusBySerial[gpu.SerialNumber] = gpu.Status
}
}
if got := statusBySerial["1653925025497"]; got != "FAIL" {
t.Fatalf("expected GPU serial 1653925025497 status FAIL, got %q", got)
}
for serial, st := range statusBySerial {
if serial == "1653925025497" {
continue
}
if st != "PASS" {
t.Fatalf("expected non-failing GPU serial %s status PASS, got %q", serial, st)
}
}
}
func TestNVIDIAParser_GPUErrorDetailsFromSummary_RealArchive07900(t *testing.T) {
archivePath := filepath.Join("../../../../example", "A514359X5A07900_logs-20260122-074208.tar")
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Failed to parse archive: %v", err)
}
if result.Hardware == nil || len(result.Hardware.GPUs) == 0 {
t.Fatalf("expected GPUs in parsed result")
}
errBySerial := make(map[string]string, len(result.Hardware.GPUs))
for _, gpu := range result.Hardware.GPUs {
if gpu.SerialNumber != "" {
errBySerial[gpu.SerialNumber] = gpu.ErrorDescription
}
}
if got := errBySerial["1653925025497"]; got != "Row remapping failed" {
t.Fatalf("expected GPU serial 1653925025497 error Row remapping failed, got %q", got)
}
}
func TestNVIDIAParser_GPUModelFromSKU_RealArchive07900(t *testing.T) {
archivePath := filepath.Join("../../../../example", "A514359X5A07900_logs-20260122-074208.tar")
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Failed to parse archive: %v", err)
}
if result.Hardware == nil || len(result.Hardware.GPUs) == 0 {
t.Fatalf("expected GPUs in parsed result")
}
found := false
for _, gpu := range result.Hardware.GPUs {
if gpu.Model == "692-2G520-0280-501" && gpu.Description == "hgx h200 8 gpu 141g aircooled" {
found = true
break
}
}
if !found {
t.Fatalf("expected at least one GPU with model 692-2G520-0280-501 and description hgx h200 8 gpu 141g aircooled")
}
}
func TestNVIDIAParser_ComponentCheckTimes_RealArchive07900(t *testing.T) {
archivePath := filepath.Join("../../../../example", "A514359X5A07900_logs-20260122-074208.tar")
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
t.Skip("Test archive not found, skipping test")
}
files, err := parser.ExtractArchive(archivePath)
if err != nil {
t.Fatalf("Failed to extract archive: %v", err)
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Failed to parse archive: %v", err)
}
if result.Hardware == nil {
t.Fatalf("expected hardware in parsed result")
}
expectedGPU := time.Date(2026, 1, 22, 9, 45, 36, 0, time.UTC)
expectedNVSwitch := time.Date(2026, 1, 22, 9, 11, 32, 0, time.UTC)
if len(result.Hardware.GPUs) == 0 {
t.Fatalf("expected GPUs in parsed result")
}
for _, gpu := range result.Hardware.GPUs {
if !gpu.StatusCheckedAt.Equal(expectedGPU) {
t.Fatalf("expected GPU %s status_checked_at %s, got %s", gpu.Slot, expectedGPU.Format(time.RFC3339), gpu.StatusCheckedAt.Format(time.RFC3339))
}
if gpu.StatusAtCollect == nil || !gpu.StatusAtCollect.At.Equal(expectedGPU) {
t.Fatalf("expected GPU %s status_at_collection.at %s", gpu.Slot, expectedGPU.Format(time.RFC3339))
}
}
nvsCount := 0
for _, dev := range result.Hardware.PCIeDevices {
slot := normalizeNVSwitchSlot(dev.Slot)
if slot == "" {
continue
}
if dev.DeviceClass != "NVSwitch" && len(slot) < len("NVSWITCH") {
continue
}
if dev.DeviceClass != "NVSwitch" && slot[:len("NVSWITCH")] != "NVSWITCH" {
continue
}
nvsCount++
if !dev.StatusCheckedAt.Equal(expectedNVSwitch) {
t.Fatalf("expected NVSwitch %s status_checked_at %s, got %s", dev.Slot, expectedNVSwitch.Format(time.RFC3339), dev.StatusCheckedAt.Format(time.RFC3339))
}
if dev.StatusAtCollect == nil || !dev.StatusAtCollect.At.Equal(expectedNVSwitch) {
t.Fatalf("expected NVSwitch %s status_at_collection.at %s", dev.Slot, expectedNVSwitch.Format(time.RFC3339))
}
}
if nvsCount == 0 {
t.Fatalf("expected NVSwitch devices in parsed result")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/csv"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
@@ -20,6 +21,9 @@ type SummaryEntry struct {
IgnoreError string `json:"Ignore Error"`
}
var gpuComponentIDRegex = regexp.MustCompile(`^SXM(\d+)_SN_(.+)$`)
var nvswitchInventoryComponentRegex = regexp.MustCompile(`^NVSWITCH_(NVSWITCH\d+)_`)
// ParseSummaryJSON parses summary.json file and returns events
func ParseSummaryJSON(content []byte) []models.Event {
var entries []SummaryEntry
@@ -92,6 +96,340 @@ func ParseSummaryCSV(content []byte) []models.Event {
return events
}
// CollectGPUStatusesFromSummaryJSON extracts per-GPU PASS/FAIL status from summary.json.
// Key format in returned map is component ID from summary (e.g. "SXM5_SN_1653925025497").
func CollectGPUStatusesFromSummaryJSON(content []byte) map[string]string {
var entries []SummaryEntry
if err := json.Unmarshal(content, &entries); err != nil {
return nil
}
statuses := make(map[string]string)
for _, entry := range entries {
component := strings.TrimSpace(entry.ComponentID)
if component == "" || !gpuComponentIDRegex.MatchString(component) {
continue
}
current := statuses[component]
next := "PASS"
if !isSummaryJSONRecordPassing(entry.ErrorCode, entry.Notes) {
next = "FAIL"
}
statuses[component] = mergeGPUStatus(current, next)
}
return statuses
}
// CollectGPUFailureDetailsFromSummaryJSON extracts per-GPU failure details from summary.json.
// Key format in returned map is component ID from summary (e.g. "SXM5_SN_1653925025497").
func CollectGPUFailureDetailsFromSummaryJSON(content []byte) map[string]string {
var entries []SummaryEntry
if err := json.Unmarshal(content, &entries); err != nil {
return nil
}
details := make(map[string]string)
for _, entry := range entries {
component := strings.TrimSpace(entry.ComponentID)
if component == "" || !gpuComponentIDRegex.MatchString(component) {
continue
}
if isSummaryJSONRecordPassing(entry.ErrorCode, entry.Notes) {
continue
}
note := strings.TrimSpace(entry.Notes)
if note == "" || strings.EqualFold(note, "OK") {
note = strings.TrimSpace(entry.ErrorCode)
}
if note == "" {
continue
}
// Keep first non-empty detail to avoid noisy overrides.
if _, exists := details[component]; !exists {
details[component] = note
}
}
return details
}
// CollectGPUStatusesFromSummaryCSV extracts per-GPU PASS/FAIL status from summary.csv.
// Key format in returned map is component ID from summary (e.g. "SXM5_SN_1653925025497").
func CollectGPUStatusesFromSummaryCSV(content []byte) map[string]string {
reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll()
if err != nil {
return nil
}
statuses := make(map[string]string)
for i, record := range records {
if i == 0 || len(record) < 7 {
continue
}
component := strings.TrimSpace(record[5])
if component == "" || !gpuComponentIDRegex.MatchString(component) {
continue
}
errorCode := strings.TrimSpace(record[0])
notes := strings.TrimSpace(record[6])
current := statuses[component]
next := "PASS"
if !isSummaryCSVRecordPassing(errorCode, notes) {
next = "FAIL"
}
statuses[component] = mergeGPUStatus(current, next)
}
return statuses
}
// CollectNVSwitchStatusesFromSummaryJSON extracts per-NVSwitch PASS/FAIL status from summary.json.
// Key format in returned map is normalized switch slot (e.g. "NVSWITCH0").
func CollectNVSwitchStatusesFromSummaryJSON(content []byte) map[string]string {
var entries []SummaryEntry
if err := json.Unmarshal(content, &entries); err != nil {
return nil
}
statuses := make(map[string]string)
for _, entry := range entries {
component := strings.TrimSpace(entry.ComponentID)
matches := nvswitchInventoryComponentRegex.FindStringSubmatch(component)
if len(matches) != 2 {
continue
}
slot := strings.TrimSpace(matches[1])
if slot == "" {
continue
}
current := statuses[slot]
next := "PASS"
if !isSummaryJSONRecordPassing(entry.ErrorCode, entry.Notes) {
next = "FAIL"
}
statuses[slot] = mergeGPUStatus(current, next)
}
return statuses
}
// CollectNVSwitchStatusesFromSummaryCSV extracts per-NVSwitch PASS/FAIL status from summary.csv.
// Key format in returned map is normalized switch slot (e.g. "NVSWITCH0").
func CollectNVSwitchStatusesFromSummaryCSV(content []byte) map[string]string {
reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll()
if err != nil {
return nil
}
statuses := make(map[string]string)
for i, record := range records {
if i == 0 || len(record) < 7 {
continue
}
component := strings.TrimSpace(record[5])
matches := nvswitchInventoryComponentRegex.FindStringSubmatch(component)
if len(matches) != 2 {
continue
}
slot := strings.TrimSpace(matches[1])
if slot == "" {
continue
}
errorCode := strings.TrimSpace(record[0])
notes := strings.TrimSpace(record[6])
current := statuses[slot]
next := "PASS"
if !isSummaryCSVRecordPassing(errorCode, notes) {
next = "FAIL"
}
statuses[slot] = mergeGPUStatus(current, next)
}
return statuses
}
// CollectGPUFailureDetailsFromSummaryCSV extracts per-GPU failure details from summary.csv.
// Key format in returned map is component ID from summary (e.g. "SXM5_SN_1653925025497").
func CollectGPUFailureDetailsFromSummaryCSV(content []byte) map[string]string {
reader := csv.NewReader(strings.NewReader(string(content)))
records, err := reader.ReadAll()
if err != nil {
return nil
}
details := make(map[string]string)
for i, record := range records {
if i == 0 || len(record) < 7 {
continue
}
component := strings.TrimSpace(record[5])
if component == "" || !gpuComponentIDRegex.MatchString(component) {
continue
}
errorCode := strings.TrimSpace(record[0])
notes := strings.TrimSpace(record[6])
if isSummaryCSVRecordPassing(errorCode, notes) {
continue
}
note := notes
if note == "" || strings.EqualFold(note, "OK") {
note = errorCode
}
if note == "" {
continue
}
if _, exists := details[component]; !exists {
details[component] = note
}
}
return details
}
func isSummaryJSONRecordPassing(errorCode, notes string) bool {
_ = errorCode
return strings.TrimSpace(notes) == "OK"
}
func isSummaryCSVRecordPassing(errorCode, notes string) bool {
_ = errorCode
return strings.TrimSpace(notes) == "OK"
}
func mergeGPUStatus(current, next string) string {
// FAIL has highest priority.
if current == "FAIL" || next == "FAIL" {
return "FAIL"
}
if current == "PASS" || next == "PASS" {
return "PASS"
}
return ""
}
// ApplyGPUStatuses applies aggregated PASS/FAIL statuses from summary components to parsed GPUs.
func ApplyGPUStatuses(result *models.AnalysisResult, componentStatuses map[string]string) {
if result == nil || result.Hardware == nil || len(result.Hardware.GPUs) == 0 || len(componentStatuses) == 0 {
return
}
slotStatus := make(map[string]string) // key: GPUSXM<idx>
serialStatus := make(map[string]string) // key: GPU serial
for componentID, status := range componentStatuses {
matches := gpuComponentIDRegex.FindStringSubmatch(strings.TrimSpace(componentID))
if len(matches) != 3 {
continue
}
slotKey := "GPUSXM" + matches[1]
serialKey := strings.TrimSpace(matches[2])
slotStatus[slotKey] = mergeGPUStatus(slotStatus[slotKey], status)
if serialKey != "" {
serialStatus[serialKey] = mergeGPUStatus(serialStatus[serialKey], status)
}
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
next := ""
if serial := strings.TrimSpace(gpu.SerialNumber); serial != "" {
next = serialStatus[serial]
}
if next == "" {
next = slotStatus[strings.TrimSpace(gpu.Slot)]
}
if next != "" {
gpu.Status = next
}
}
}
// ApplyNVSwitchStatuses applies aggregated PASS/FAIL statuses from summary components to parsed NVSwitch devices.
func ApplyNVSwitchStatuses(result *models.AnalysisResult, switchStatuses map[string]string) {
if result == nil || result.Hardware == nil || len(result.Hardware.PCIeDevices) == 0 || len(switchStatuses) == 0 {
return
}
for i := range result.Hardware.PCIeDevices {
dev := &result.Hardware.PCIeDevices[i]
slot := normalizeNVSwitchSlot(strings.TrimSpace(dev.Slot))
if slot == "" {
continue
}
if !strings.HasPrefix(strings.ToUpper(slot), "NVSWITCH") {
continue
}
if st := switchStatuses[slot]; st != "" {
dev.Status = st
}
}
}
// ApplyGPUFailureDetails maps parsed failure details from summary components to GPUs.
func ApplyGPUFailureDetails(result *models.AnalysisResult, componentDetails map[string]string) {
if result == nil || result.Hardware == nil || len(result.Hardware.GPUs) == 0 || len(componentDetails) == 0 {
return
}
slotDetails := make(map[string]string) // key: GPUSXM<idx>
serialDetails := make(map[string]string) // key: GPU serial
for componentID, detail := range componentDetails {
matches := gpuComponentIDRegex.FindStringSubmatch(strings.TrimSpace(componentID))
if len(matches) != 3 {
continue
}
detail = strings.TrimSpace(detail)
if detail == "" {
continue
}
slotKey := "GPUSXM" + matches[1]
serialKey := strings.TrimSpace(matches[2])
if _, exists := slotDetails[slotKey]; !exists {
slotDetails[slotKey] = detail
}
if serialKey != "" {
if _, exists := serialDetails[serialKey]; !exists {
serialDetails[serialKey] = detail
}
}
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
detail := ""
if serial := strings.TrimSpace(gpu.SerialNumber); serial != "" {
detail = serialDetails[serial]
}
if detail == "" {
detail = slotDetails[strings.TrimSpace(gpu.Slot)]
}
if detail != "" {
gpu.ErrorDescription = detail
}
}
}
// formatSummaryDescription creates a human-readable description from summary entry
func formatSummaryDescription(entry SummaryEntry) string {
component := entry.ComponentID

View File

@@ -0,0 +1,122 @@
package nvidia
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestApplyGPUStatuses_FromSummaryCSV_FailAndPass(t *testing.T) {
csvData := strings.Join([]string{
"ErrorCode,Test,VirtualID,SubTest,Type,ComponentID,Notes,Level,,,IgnoreError",
"0,gpumem,gpumem,,GPU,SXM1_SN_111,OK,1,,,False",
"363,gpumem,gpumem,,GPU,SXM5_SN_1653925025497,Row remapping failed,1,,,False",
"0,gpu_fieldiag,gpu_fieldiag,,GPU,SXM1_SN_111,OK,1,,,False",
"0,gpu_fieldiag,gpu_fieldiag,,GPU,SXM2_SN_222,OK,1,,,False",
}, "\n")
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "GPUSXM1", SerialNumber: "111"},
{Slot: "GPUSXM2", SerialNumber: "222"},
{Slot: "GPUSXM5", SerialNumber: "1653925025497"},
},
},
}
statuses := CollectGPUStatusesFromSummaryCSV([]byte(csvData))
ApplyGPUStatuses(result, statuses)
bySerial := map[string]string{}
for _, gpu := range result.Hardware.GPUs {
bySerial[gpu.SerialNumber] = gpu.Status
}
if bySerial["1653925025497"] != "FAIL" {
t.Fatalf("expected serial 1653925025497 status FAIL, got %q", bySerial["1653925025497"])
}
if bySerial["111"] != "PASS" {
t.Fatalf("expected serial 111 status PASS, got %q", bySerial["111"])
}
if bySerial["222"] != "PASS" {
t.Fatalf("expected serial 222 status PASS, got %q", bySerial["222"])
}
}
func TestApplyGPUFailureDetails_FromSummaryJSON_BySerial(t *testing.T) {
jsonData := []byte(`[
{
"Error Code": "005-000-1-000000000363",
"Test": "gpumem",
"Component ID": "SXM5_SN_1653925025497",
"Notes": "Row remapping failed",
"Virtual ID": "gpumem",
"Ignore Error": "False"
}
]`)
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "GPUSXM5", SerialNumber: "1653925025497"},
{Slot: "GPUSXM2", SerialNumber: "1653925024190"},
},
},
}
details := CollectGPUFailureDetailsFromSummaryJSON(jsonData)
ApplyGPUFailureDetails(result, details)
if got := result.Hardware.GPUs[0].ErrorDescription; got != "Row remapping failed" {
t.Fatalf("expected serial 1653925025497 error Row remapping failed, got %q", got)
}
if got := result.Hardware.GPUs[1].ErrorDescription; got != "" {
t.Fatalf("expected no error description for healthy GPU, got %q", got)
}
}
func TestApplyNVSwitchStatuses_FromSummaryJSON(t *testing.T) {
jsonData := []byte(`[
{
"Error Code": "0",
"Test": "inventory",
"Component ID": "NVSWITCH_NVSWITCH0_VendorID",
"Notes": "OK",
"Virtual ID": "inventory",
"Ignore Error": "False"
},
{
"Error Code": "1",
"Test": "inventory",
"Component ID": "NVSWITCH_NVSWITCH1_LinkState",
"Notes": "Link down",
"Virtual ID": "inventory",
"Ignore Error": "False"
}
]`)
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
{Slot: "NVSWITCH0", Status: "Unknown"},
{Slot: "NVSWITCH1", Status: "Unknown"},
{Slot: "NVSWITCH2", Status: "Unknown"},
},
},
}
statuses := CollectNVSwitchStatusesFromSummaryJSON(jsonData)
ApplyNVSwitchStatuses(result, statuses)
if got := result.Hardware.PCIeDevices[0].Status; got != "PASS" {
t.Fatalf("expected NVSWITCH0 status PASS, got %q", got)
}
if got := result.Hardware.PCIeDevices[1].Status; got != "FAIL" {
t.Fatalf("expected NVSWITCH1 status FAIL, got %q", got)
}
if got := result.Hardware.PCIeDevices[2].Status; got != "Unknown" {
t.Fatalf("expected NVSWITCH2 status unchanged Unknown, got %q", got)
}
}

View File

@@ -3,6 +3,7 @@ package nvidia
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
@@ -53,6 +54,8 @@ type Property struct {
Value interface{} `json:"value"` // Can be string or number
}
var nvswitchComponentIDRegex = regexp.MustCompile(`^(NVSWITCH\d+|NVSWITCHNVSWITCH\d+)$`)
// GetValueAsString returns the value as a string
func (p *Property) GetValueAsString() string {
switch v := p.Value.(type) {
@@ -107,7 +110,7 @@ func parseInventoryComponents(components []Component, result *models.AnalysisRes
}
// Parse NVSwitch components
if strings.HasPrefix(comp.ComponentID, "NVSWITCHNVSWITCH") {
if isNVSwitchComponentID(comp.ComponentID) {
nvswitch := parseNVSwitchComponent(comp)
if nvswitch != nil {
// Add as PCIe device for now
@@ -152,7 +155,7 @@ func parseSystemInfo(comp Component, result *models.AnalysisResult) bool {
// Don't overwrite real data from output.log with generic data
// Only set if empty or still has the default placeholder value
if result.Hardware.BoardInfo.ProductName == "" ||
result.Hardware.BoardInfo.ProductName == "GPU Server (Field Diag)" {
result.Hardware.BoardInfo.ProductName == "GPU Server (Field Diag)" {
result.Hardware.BoardInfo.ProductName = value
}
case "SerialNumber", "Serial", "BoardSerial", "SystemSerial":
@@ -183,6 +186,9 @@ func parseGPUComponent(comp Component) *models.GPU {
switch prop.ID {
case "DeviceID":
deviceID = prop.GetValueAsString()
if deviceID != "" {
fmt.Sscanf(deviceID, "%x", &gpu.DeviceID)
}
case "Vendor":
gpu.Manufacturer = prop.GetValueAsString()
case "DeviceName":
@@ -217,7 +223,7 @@ func parseGPUComponent(comp Component) *models.GPU {
// parseNVSwitchComponent parses NVSwitch component information
func parseNVSwitchComponent(comp Component) *models.PCIeDevice {
device := &models.PCIeDevice{
Slot: comp.ComponentID, // e.g., "NVSWITCHNVSWITCH0"
Slot: normalizeNVSwitchSlot(comp.ComponentID),
}
var vendorIDStr, deviceIDStr, vbios, pciID string
@@ -279,3 +285,15 @@ func parseNVSwitchComponent(comp Component) *models.PCIeDevice {
return device
}
func normalizeNVSwitchSlot(componentID string) string {
slot := strings.TrimSpace(componentID)
if strings.HasPrefix(slot, "NVSWITCHNVSWITCH") {
return strings.Replace(slot, "NVSWITCHNVSWITCH", "NVSWITCH", 1)
}
return slot
}
func isNVSwitchComponentID(componentID string) bool {
return nvswitchComponentIDRegex.MatchString(strings.TrimSpace(componentID))
}

View File

@@ -0,0 +1,46 @@
package nvidia
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestParseInventoryComponents_IgnoresNVSwitchPropertyChecks(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{},
}
components := []Component{
{
ComponentID: "NVSWITCHNVSWITCH1",
Properties: []Property{
{ID: "VendorID", Value: "10de"},
{ID: "DeviceID", Value: "22a3"},
{ID: "PCIID", Value: "0000:06:00.0"},
},
},
{
ComponentID: "NVSWITCHNum",
Properties: []Property{
{ID: "NVSWITCHNum", Value: 4},
},
},
{
ComponentID: "NVSWITCH_NVSWITCH1_VendorID",
Properties: []Property{
{ID: "NVSWITCH_NVSWITCH1_VendorID", Value: "10de"},
},
},
}
parseInventoryComponents(components, result)
if got := len(result.Hardware.PCIeDevices); got != 1 {
t.Fatalf("expected exactly 1 parsed NVSwitch device, got %d", got)
}
if result.Hardware.PCIeDevices[0].Slot != "NVSWITCH1" {
t.Fatalf("expected slot NVSWITCH1, got %q", result.Hardware.PCIeDevices[0].Slot)
}
}

View File

@@ -0,0 +1,35 @@
package nvidia
import "testing"
func TestParseNVSwitchComponent_NormalizesDuplicatedPrefixInSlot(t *testing.T) {
comp := Component{
ComponentID: "NVSWITCHNVSWITCH1",
Properties: []Property{
{ID: "VendorID", Value: "10de"},
{ID: "DeviceID", Value: "22a3"},
{ID: "Vendor", Value: "NVIDIA Corporation"},
{ID: "PCIID", Value: "0000:06:00.0"},
{ID: "PCISpeed", Value: "16GT/s"},
{ID: "PCIWidth", Value: "x2"},
{ID: "VBIOS_version", Value: "96.10.6D.00.01"},
},
}
device := parseNVSwitchComponent(comp)
if device == nil {
t.Fatal("expected non-nil NVSwitch device")
}
if device.Slot != "NVSWITCH1" {
t.Fatalf("expected normalized slot NVSWITCH1, got %q", device.Slot)
}
if device.BDF != "0000:06:00.0" {
t.Fatalf("expected BDF 0000:06:00.0, got %q", device.BDF)
}
if device.DeviceClass != "NVSwitch" {
t.Fatalf("expected device class NVSwitch, got %q", device.DeviceClass)
}
}

View File

@@ -106,6 +106,8 @@ func parseGPUInfo(content string, result *models.AnalysisResult) {
result.Hardware.GPUs = append(result.Hardware.GPUs, *currentGPU)
}
applyGPUSerialNumbers(content, result.Hardware.GPUs)
// Create event for GPU summary
if len(result.Hardware.GPUs) > 0 {
result.Events = append(result.Events, models.Event{
@@ -168,3 +170,138 @@ func formatGPUSummary(gpus []models.GPU) string {
return summary.String()
}
func applyGPUSerialNumbers(content string, gpus []models.GPU) {
if len(gpus) == 0 {
return
}
serialByBDF := parseGPUSerialsFromNvidiaSMI(content)
if len(serialByBDF) == 0 {
serialByBDF = parseGPUSerialsFromSummary(content)
}
if len(serialByBDF) == 0 {
return
}
for i := range gpus {
bdf := normalizeGPUAddress(gpus[i].BDF)
if bdf == "" {
continue
}
if serial, ok := serialByBDF[bdf]; ok && serial != "" {
gpus[i].SerialNumber = serial
}
}
}
func parseGPUSerialsFromNvidiaSMI(content string) map[string]string {
scanner := bufio.NewScanner(strings.NewReader(content))
reGPU := regexp.MustCompile(`^GPU\s+([0-9A-F]{8}:[0-9A-F]{2}:[0-9A-F]{2}\.[0-9A-F])$`)
serialByBDF := make(map[string]string)
currentBDF := ""
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if matches := reGPU.FindStringSubmatch(line); len(matches) == 2 {
currentBDF = normalizeGPUAddress(matches[1])
continue
}
if currentBDF == "" {
continue
}
if strings.HasPrefix(line, "Serial Number") {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
serial := strings.TrimSpace(parts[1])
if serial != "" && !strings.EqualFold(serial, "N/A") {
serialByBDF[currentBDF] = serial
}
}
}
return serialByBDF
}
func parseGPUSerialsFromSummary(content string) map[string]string {
scanner := bufio.NewScanner(strings.NewReader(content))
serialByBDF := make(map[string]string)
inGPUDetails := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "NVIDIA GPU Details") {
inGPUDetails = true
}
if !inGPUDetails {
continue
}
if strings.HasPrefix(trimmed, "NVIDIA Switch Details") {
break
}
parts := strings.Split(line, "|")
if len(parts) < 2 {
continue
}
payload := strings.TrimSpace(parts[len(parts)-1])
if payload == "" {
continue
}
fields := strings.Split(payload, ",")
if len(fields) < 6 {
continue
}
bdf := normalizeGPUAddress(strings.TrimSpace(fields[4]))
serial := strings.TrimSpace(fields[5])
if bdf == "" || serial == "" || strings.EqualFold(serial, "N/A") {
continue
}
serialByBDF[bdf] = serial
}
return serialByBDF
}
func normalizeGPUAddress(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return ""
}
parts := strings.Split(addr, ":")
if len(parts) != 3 {
return strings.ToLower(addr)
}
domain := parts[0]
bus := parts[1]
devFn := parts[2]
devFnParts := strings.Split(devFn, ".")
if len(devFnParts) != 2 {
return strings.ToLower(addr)
}
device := devFnParts[0]
fn := devFnParts[1]
if len(domain) == 8 {
domain = domain[4:]
}
return strings.ToLower(domain + ":" + bus + ":" + device + "." + fn)
}

View File

@@ -0,0 +1,54 @@
package nvidia_bug_report
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestApplyGPUSerialNumbers_FromNvidiaSMI(t *testing.T) {
content := `
/usr/bin/nvidia-smi --query
GPU 00000000:18:00.0
Serial Number : 1653925025827
GPU 00000000:2A:00.0
Serial Number : 1653925050608
`
gpus := []models.GPU{
{BDF: "0000:18:00.0"},
{BDF: "0000:2a:00.0"},
}
applyGPUSerialNumbers(content, gpus)
if gpus[0].SerialNumber != "1653925025827" {
t.Fatalf("unexpected serial for gpu0: %q", gpus[0].SerialNumber)
}
if gpus[1].SerialNumber != "1653925050608" {
t.Fatalf("unexpected serial for gpu1: %q", gpus[1].SerialNumber)
}
}
func TestApplyGPUSerialNumbers_FromSummaryFallback(t *testing.T) {
content := `
NVIDIA GPU Details | NVIDIA H200, 570.172.08, 143771 MiB, 96.00.D0.00.03, 00000000:18:00.0, 1653925025827
| NVIDIA H200, 570.172.08, 143771 MiB, 96.00.D0.00.03, 00000000:2A:00.0, 1653925050608
NVIDIA Switch Details | No devices matching query 'Quantum'
`
gpus := []models.GPU{
{BDF: "0000:18:00.0"},
{BDF: "0000:2a:00.0"},
}
applyGPUSerialNumbers(content, gpus)
if gpus[0].SerialNumber != "1653925025827" {
t.Fatalf("unexpected serial for gpu0: %q", gpus[0].SerialNumber)
}
if gpus[1].SerialNumber != "1653925050608" {
t.Fatalf("unexpected serial for gpu1: %q", gpus[1].SerialNumber)
}
}

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
import (
"bufio"
_ "embed"
"fmt"
"os"
"strconv"
"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
func VendorName(vendorID int) string {
loadPCIIDs()
if name, ok := vendors[vendorID]; ok {
return name
}
@@ -15,6 +30,7 @@ func VendorName(vendorID int) string {
// DeviceName returns device name by Vendor ID and Device ID
func DeviceName(vendorID, deviceID int) string {
loadPCIIDs()
key := fmt.Sprintf("%04x:%04x", vendorID, deviceID)
if name, ok := devices[key]; ok {
return name
@@ -46,7 +62,6 @@ func VendorNameFromString(s string) string {
} else if c >= 'a' && c <= 'f' {
id = id*16 + int(c-'a'+10)
} else {
// Not a valid hex string, return original
return ""
}
}
@@ -54,124 +69,99 @@ func VendorNameFromString(s string) string {
return VendorName(id)
}
// Common PCI Vendor IDs
// Source: https://pci-ids.ucw.cz/
var vendors = map[int]string{
// Storage controllers and SSDs
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",
func loadPCIIDs() {
loadOnce.Do(func() {
vendors = make(map[int]string)
devices = make(map[string]string)
// Network adapters
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",
parsePCIIDs(strings.NewReader(embeddedPCIIDs), vendors, devices)
// GPU / Graphics
0x10DE: "NVIDIA",
0x1002: "AMD/ATI",
0x102B: "Matrox Electronics",
0x1A03: "ASPEED Technology",
// Storage controllers (RAID/HBA)
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",
for _, path := range candidatePCIIDsPaths() {
f, err := os.Open(path)
if err != nil {
continue
}
parsePCIIDs(f, vendors, devices)
_ = f.Close()
}
})
}
// Device IDs (vendor:device -> name)
var devices = map[string]string{
// NVIDIA GPUs (0x10DE)
"10de:26b9": "L40S 48GB",
"10de:26b1": "L40 48GB",
"10de:2684": "RTX 4090",
"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",
func candidatePCIIDsPaths() []string {
paths := []string{
"pci.ids",
"/usr/share/hwdata/pci.ids",
"/usr/share/misc/pci.ids",
"/opt/homebrew/share/pciids/pci.ids",
}
// AMD GPUs (0x1002)
"1002:744c": "Instinct MI250X",
"1002:7408": "Instinct MI100",
"1002:73a5": "RX 6950 XT",
"1002:73bf": "RX 6900 XT",
"1002:73df": "RX 6700 XT",
"1002:7480": "RX 7900 XTX",
"1002:7483": "RX 7900 XT",
// ASPEED (0x1A03) - BMC VGA
"1a03:2000": "AST2500 VGA",
"1a03:1150": "AST2600 VGA",
// Intel GPUs
"8086:56c0": "Data Center GPU Flex 170",
"8086:56c1": "Data Center GPU Flex 140",
// Mellanox/NVIDIA NICs (0x15B3)
"15b3:1017": "ConnectX-5 100GbE",
"15b3:1019": "ConnectX-5 Ex",
"15b3:101b": "ConnectX-6",
"15b3:101d": "ConnectX-6 Dx",
"15b3:101f": "ConnectX-6 Lx",
"15b3:1021": "ConnectX-7",
"15b3:a2d6": "ConnectX-4 Lx",
// Env paths have highest priority, so they are applied last.
if env := strings.TrimSpace(os.Getenv("LOGPILE_PCI_IDS_PATH")); env != "" {
for _, p := range strings.Split(env, string(os.PathListSeparator)) {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
}
return paths
}
func parsePCIIDs(r interface{ Read([]byte) (int, error) }, outVendors map[int]string, outDevices map[string]string) {
scanner := bufio.NewScanner(r)
currentVendor := -1
for scanner.Scan() {
line := scanner.Text()
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Subdevice line (tab-tab) - ignored for now
if strings.HasPrefix(line, "\t\t") {
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)
}
}

606
internal/parser/vendors/unraid/parser.go vendored Normal file
View File

@@ -0,0 +1,606 @@
// Package unraid provides parser for Unraid diagnostics archives.
package unraid
import (
"bufio"
"regexp"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// parserVersion - increment when parsing logic changes.
const parserVersion = "1.0.0"
func init() {
parser.Register(&Parser{})
}
// Parser implements VendorParser for Unraid diagnostics.
type Parser struct{}
func (p *Parser) Name() string { return "Unraid Parser" }
func (p *Parser) Vendor() string { return "unraid" }
func (p *Parser) Version() string { return parserVersion }
// Detect checks if files contain typical Unraid markers.
func (p *Parser) Detect(files []parser.ExtractedFile) int {
confidence := 0
hasUnraidVersion := false
hasDiagnosticsDir := false
hasVarsParity := false
for _, f := range files {
path := strings.ToLower(f.Path)
content := string(f.Content)
// Check for unraid version file
if strings.Contains(path, "unraid-") && strings.HasSuffix(path, ".txt") {
hasUnraidVersion = true
confidence += 40
}
// Check for Unraid-specific directories
if strings.Contains(path, "diagnostics-") &&
(strings.Contains(path, "/system/") ||
strings.Contains(path, "/smart/") ||
strings.Contains(path, "/config/")) {
hasDiagnosticsDir = true
if confidence < 60 {
confidence += 20
}
}
// Check file content for Unraid markers
if strings.Contains(content, "Unraid kernel build") {
confidence += 50
}
// Check for vars.txt with disk array info
if strings.Contains(path, "vars.txt") && strings.Contains(content, "[parity]") {
hasVarsParity = true
confidence += 30
}
if confidence >= 100 {
return 100
}
}
// Boost confidence if we see multiple key indicators together
if hasUnraidVersion && (hasDiagnosticsDir || hasVarsParity) {
confidence += 20
}
if confidence > 100 {
return 100
}
return confidence
}
// Parse parses Unraid diagnostics and returns normalized data.
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
result := &models.AnalysisResult{
Events: make([]models.Event, 0),
FRU: make([]models.FRUInfo, 0),
Sensors: make([]models.SensorReading, 0),
Hardware: &models.HardwareConfig{
Firmware: make([]models.FirmwareInfo, 0),
CPUs: make([]models.CPU, 0),
Memory: make([]models.MemoryDIMM, 0),
Storage: make([]models.Storage, 0),
},
}
// Track storage by slot to avoid duplicates
storageBySlot := make(map[string]*models.Storage)
// Parse different file types
for _, f := range files {
path := strings.ToLower(f.Path)
content := string(f.Content)
switch {
case strings.Contains(path, "unraid-") && strings.HasSuffix(path, ".txt"):
parseVersionFile(content, result)
case strings.HasSuffix(path, "/system/lscpu.txt") || strings.HasSuffix(path, "\\system\\lscpu.txt"):
parseLsCPU(content, result)
case strings.HasSuffix(path, "/system/motherboard.txt") || strings.HasSuffix(path, "\\system\\motherboard.txt"):
parseMotherboard(content, result)
case strings.HasSuffix(path, "/system/memory.txt") || strings.HasSuffix(path, "\\system\\memory.txt"):
parseMemory(content, result)
case strings.HasSuffix(path, "/system/vars.txt") || strings.HasSuffix(path, "\\system\\vars.txt"):
parseVarsToMap(content, storageBySlot, result)
case strings.Contains(path, "/smart/") && strings.HasSuffix(path, ".txt"):
parseSMARTFileToMap(content, f.Path, storageBySlot, result)
case strings.HasSuffix(path, "/logs/syslog.txt") || strings.HasSuffix(path, "\\logs\\syslog.txt"):
parseSyslog(content, result)
}
}
// Convert storage map to slice
for _, disk := range storageBySlot {
result.Hardware.Storage = append(result.Hardware.Storage, *disk)
}
return result, nil
}
func parseVersionFile(content string, result *models.AnalysisResult) {
lines := strings.Split(content, "\n")
if len(lines) > 0 {
version := strings.TrimSpace(lines[0])
if version != "" {
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: "Unraid OS",
Version: version,
})
}
}
}
func parseLsCPU(content string, result *models.AnalysisResult) {
// Normalize line endings
content = strings.ReplaceAll(content, "\r\n", "\n")
var cpu models.CPU
cpu.Socket = 0 // Default to socket 0
// Parse CPU model - handle multiple spaces
if m := regexp.MustCompile(`(?m)^Model name:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
cpu.Model = strings.TrimSpace(m[1])
}
// Parse CPU(s) - total thread count
if m := regexp.MustCompile(`(?m)^CPU\(s\):\s+(\d+)$`).FindStringSubmatch(content); len(m) == 2 {
cpu.Threads = parseInt(m[1])
}
// Parse cores per socket
if m := regexp.MustCompile(`(?m)^Core\(s\) per socket:\s+(\d+)$`).FindStringSubmatch(content); len(m) == 2 {
cpu.Cores = parseInt(m[1])
}
// Parse CPU max MHz
if m := regexp.MustCompile(`(?m)^CPU max MHz:\s+([\d.]+)$`).FindStringSubmatch(content); len(m) == 2 {
cpu.FrequencyMHz = int(parseFloat(m[1]))
}
// If no max MHz, try current MHz
if cpu.FrequencyMHz == 0 {
if m := regexp.MustCompile(`(?m)^CPU MHz:\s+([\d.]+)$`).FindStringSubmatch(content); len(m) == 2 {
cpu.FrequencyMHz = int(parseFloat(m[1]))
}
}
// Only add if we got at least the model
if cpu.Model != "" {
result.Hardware.CPUs = append(result.Hardware.CPUs, cpu)
}
}
func parseMotherboard(content string, result *models.AnalysisResult) {
var board models.BoardInfo
// Parse manufacturer from dmidecode output
lines := strings.Split(content, "\n")
inBIOSSection := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, "BIOS Information") {
inBIOSSection = true
continue
}
if inBIOSSection {
if strings.HasPrefix(trimmed, "Vendor:") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
board.Manufacturer = strings.TrimSpace(parts[1])
}
} else if strings.HasPrefix(trimmed, "Version:") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
biosVersion := strings.TrimSpace(parts[1])
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: "System BIOS",
Version: biosVersion,
})
}
} else if strings.HasPrefix(trimmed, "Release Date:") {
// Could extract BIOS date if needed
}
}
}
// Extract product name from first line
if len(lines) > 0 {
firstLine := strings.TrimSpace(lines[0])
if firstLine != "" {
board.ProductName = firstLine
}
}
result.Hardware.BoardInfo = board
}
func parseMemory(content string, result *models.AnalysisResult) {
// Parse memory from free output
// Example: Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi
if m := regexp.MustCompile(`(?m)^Mem:\s+(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)`).FindStringSubmatch(content); len(m) >= 3 {
size := parseFloat(m[1])
unit := m[2]
var sizeMB int
switch unit {
case "Ki":
sizeMB = int(size / 1024)
case "Mi":
sizeMB = int(size)
case "Gi":
sizeMB = int(size * 1024)
case "Ti":
sizeMB = int(size * 1024 * 1024)
}
if sizeMB > 0 {
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
Slot: "system",
Present: true,
SizeMB: sizeMB,
Type: "DRAM",
Status: "ok",
})
}
}
}
func parseVarsToMap(content string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
// Normalize line endings
content = strings.ReplaceAll(content, "\r\n", "\n")
// Parse PHP-style array from vars.txt
// Extract only the first "disks" section to avoid duplicates
disksStart := strings.Index(content, "disks\n(")
if disksStart == -1 {
return
}
// Find the end of this disks array (look for next top-level key or end)
remaining := content[disksStart:]
endPattern := regexp.MustCompile(`(?m)^[a-z_]+\n\(`)
endMatches := endPattern.FindAllStringIndex(remaining, -1)
var disksSection string
if len(endMatches) > 1 {
// Use second match as end (first match is "disks" itself)
disksSection = remaining[:endMatches[1][0]]
} else {
disksSection = remaining
}
// Look for disk entries within this section only
diskRe := regexp.MustCompile(`(?m)^\s+\[(disk\d+|parity|cache\d*)\]\s+=>\s+Array`)
matches := diskRe.FindAllStringSubmatch(disksSection, -1)
seen := make(map[string]bool)
for _, match := range matches {
if len(match) < 2 {
continue
}
diskName := match[1]
// Skip if already processed
if seen[diskName] {
continue
}
seen[diskName] = true
// Find the section for this disk
diskSection := extractDiskSection(disksSection, diskName)
if diskSection == "" {
continue
}
var disk models.Storage
disk.Slot = diskName
// Parse disk properties
if m := regexp.MustCompile(`\[device\]\s*=>\s*(\w+)`).FindStringSubmatch(diskSection); len(m) == 2 {
disk.Interface = "SATA (" + m[1] + ")"
}
if m := regexp.MustCompile(`\[id\]\s*=>\s*([^\n]+)`).FindStringSubmatch(diskSection); len(m) == 2 {
idValue := strings.TrimSpace(m[1])
// Only use if it's not empty or a placeholder
if idValue != "" && !strings.Contains(idValue, "=>") {
disk.Model = idValue
}
}
if m := regexp.MustCompile(`\[size\]\s*=>\s*(\d+)`).FindStringSubmatch(diskSection); len(m) == 2 {
sizeKB := parseInt(m[1])
if sizeKB > 0 {
disk.SizeGB = sizeKB / (1024 * 1024) // Convert KB to GB
}
}
if m := regexp.MustCompile(`\[temp\]\s*=>\s*(\d+)`).FindStringSubmatch(diskSection); len(m) == 2 {
temp := parseInt(m[1])
if temp > 0 {
result.Sensors = append(result.Sensors, models.SensorReading{
Name: diskName + "_temp",
Type: "temperature",
Value: float64(temp),
Unit: "C",
Status: getTempStatus(temp),
RawValue: strconv.Itoa(temp),
})
}
}
if m := regexp.MustCompile(`\[fsType\]\s*=>\s*(\w+)`).FindStringSubmatch(diskSection); len(m) == 2 {
fsType := m[1]
if fsType != "" && fsType != "auto" {
disk.Type = fsType
}
}
disk.Present = true
// Only add/merge disks with meaningful data
if disk.Model != "" && disk.SizeGB > 0 {
// Check if we already have this disk from SMART files
if existing, ok := storageBySlot[diskName]; ok {
// Merge vars.txt data into existing entry, preferring SMART data
if existing.Model == "" && disk.Model != "" {
existing.Model = disk.Model
}
if existing.SizeGB == 0 && disk.SizeGB > 0 {
existing.SizeGB = disk.SizeGB
}
if existing.Type == "" && disk.Type != "" {
existing.Type = disk.Type
}
if existing.Interface == "" && disk.Interface != "" {
existing.Interface = disk.Interface
}
// vars.txt doesn't have serial/firmware, so don't overwrite from SMART
} else {
// New disk not in SMART data
storageBySlot[diskName] = &disk
}
}
}
}
func extractDiskSection(content, diskName string) string {
// Find the start of this disk's array section
startPattern := regexp.MustCompile(`(?m)^\s+\[` + regexp.QuoteMeta(diskName) + `\]\s+=>\s+Array\s*\n\s+\(`)
startIdx := startPattern.FindStringIndex(content)
if startIdx == nil {
return ""
}
// Find the end (next disk or end of disks array)
endPattern := regexp.MustCompile(`(?m)^\s+\)`)
remainingContent := content[startIdx[1]:]
endIdx := endPattern.FindStringIndex(remainingContent)
if endIdx == nil {
return remainingContent
}
return remainingContent[:endIdx[0]]
}
func parseSMARTFileToMap(content, filePath string, storageBySlot map[string]*models.Storage, result *models.AnalysisResult) {
// Extract disk name from filename
// Example: ST4000NM000B-2TF100_WX103EC9-20260205-2333 disk1 (sdi).txt
diskName := ""
if m := regexp.MustCompile(`(disk\d+|parity|cache\d*)`).FindStringSubmatch(filePath); len(m) > 0 {
diskName = m[1]
}
if diskName == "" {
return
}
var disk models.Storage
disk.Slot = diskName
// Parse device model
if m := regexp.MustCompile(`(?m)^Device Model:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
disk.Model = strings.TrimSpace(m[1])
}
// Parse serial number
if m := regexp.MustCompile(`(?m)^Serial Number:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
disk.SerialNumber = strings.TrimSpace(m[1])
}
// Parse firmware version
if m := regexp.MustCompile(`(?m)^Firmware Version:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
disk.Firmware = strings.TrimSpace(m[1])
}
// Parse capacity
if m := regexp.MustCompile(`(?m)^User Capacity:\s+([\d,]+)\s+bytes`).FindStringSubmatch(content); len(m) == 2 {
capacityStr := strings.ReplaceAll(m[1], ",", "")
if capacity, err := strconv.ParseInt(capacityStr, 10, 64); err == nil {
disk.SizeGB = int(capacity / 1_000_000_000)
}
}
// Parse rotation rate
if m := regexp.MustCompile(`(?m)^Rotation Rate:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
rateStr := strings.TrimSpace(m[1])
if strings.Contains(strings.ToLower(rateStr), "solid state") {
disk.Type = "ssd"
} else {
disk.Type = "hdd"
}
}
// Parse SATA version for interface
if m := regexp.MustCompile(`(?m)^SATA Version is:\s+(.+?)(?:,|$)`).FindStringSubmatch(content); len(m) == 2 {
disk.Interface = strings.TrimSpace(m[1])
}
// Parse SMART health
if m := regexp.MustCompile(`(?m)^SMART overall-health self-assessment test result:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
health := strings.TrimSpace(m[1])
if !strings.EqualFold(health, "PASSED") {
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "SMART",
EventType: "Disk Health",
Severity: models.SeverityWarning,
Description: "SMART health check failed for " + diskName,
RawData: health,
})
}
}
disk.Present = true
// Only add/merge if we got meaningful data
if disk.Model != "" || disk.SerialNumber != "" {
// Check if we already have this disk from vars.txt
if existing, ok := storageBySlot[diskName]; ok {
// Merge SMART data into existing entry
if existing.Model == "" && disk.Model != "" {
existing.Model = disk.Model
}
if existing.SerialNumber == "" && disk.SerialNumber != "" {
existing.SerialNumber = disk.SerialNumber
}
if existing.Firmware == "" && disk.Firmware != "" {
existing.Firmware = disk.Firmware
}
if existing.SizeGB == 0 && disk.SizeGB > 0 {
existing.SizeGB = disk.SizeGB
}
if existing.Type == "" && disk.Type != "" {
existing.Type = disk.Type
}
if existing.Interface == "" && disk.Interface != "" {
existing.Interface = disk.Interface
}
} else {
// New disk not in vars.txt
storageBySlot[diskName] = &disk
}
}
}
func parseSyslog(content string, result *models.AnalysisResult) {
scanner := bufio.NewScanner(strings.NewReader(content))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineCount := 0
maxLines := 100 // Limit parsing to avoid too many events
for scanner.Scan() && lineCount < maxLines {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
// Parse syslog line
// Example: Feb 5 23:33:01 box3 kernel: Linux version 6.12.54-Unraid
timestamp, message, severity := parseSyslogLine(line)
result.Events = append(result.Events, models.Event{
Timestamp: timestamp,
Source: "syslog",
EventType: "System Log",
Severity: severity,
Description: message,
RawData: line,
})
lineCount++
}
if err := scanner.Err(); err != nil {
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "syslog",
EventType: "System Log",
Severity: models.SeverityWarning,
Description: "syslog scan error",
RawData: err.Error(),
})
}
}
func parseSyslogLine(line string) (time.Time, string, models.Severity) {
// Simple syslog parser
// Format: Feb 5 23:33:01 hostname process[pid]: message
timestamp := time.Now()
message := line
severity := models.SeverityInfo
// Try to parse timestamp
syslogRe := regexp.MustCompile(`^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+(.+)$`)
if m := syslogRe.FindStringSubmatch(line); len(m) == 3 {
timeStr := m[1]
message = m[2]
// Parse timestamp (add current year)
year := time.Now().Year()
if ts, err := time.Parse("Jan 2 15:04:05 2006", timeStr+" "+strconv.Itoa(year)); err == nil {
timestamp = ts
}
}
// Classify severity
lowerMsg := strings.ToLower(message)
switch {
case strings.Contains(lowerMsg, "panic"),
strings.Contains(lowerMsg, "fatal"),
strings.Contains(lowerMsg, "critical"):
severity = models.SeverityCritical
case strings.Contains(lowerMsg, "error"),
strings.Contains(lowerMsg, "warning"),
strings.Contains(lowerMsg, "failed"):
severity = models.SeverityWarning
default:
severity = models.SeverityInfo
}
return timestamp, message, severity
}
func getTempStatus(temp int) string {
switch {
case temp >= 60:
return "critical"
case temp >= 50:
return "warning"
default:
return "ok"
}
}
func parseInt(s string) int {
v, _ := strconv.Atoi(strings.TrimSpace(s))
return v
}
func parseFloat(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}

View File

@@ -0,0 +1,277 @@
package unraid
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestDetect(t *testing.T) {
tests := []struct {
name string
files []parser.ExtractedFile
wantMin int
wantMax int
shouldFind bool
}{
{
name: "typical unraid diagnostics",
files: []parser.ExtractedFile{
{
Path: "box3-diagnostics-20260205-2333/unraid-7.2.0.txt",
Content: []byte("7.2.0\n"),
},
{
Path: "box3-diagnostics-20260205-2333/system/vars.txt",
Content: []byte("[parity] => Array\n[disk1] => Array\n"),
},
},
wantMin: 50,
wantMax: 100,
shouldFind: true,
},
{
name: "unraid with kernel marker",
files: []parser.ExtractedFile{
{
Path: "diagnostics/system/lscpu.txt",
Content: []byte("Unraid kernel build 6.12.54"),
},
},
wantMin: 50,
wantMax: 100,
shouldFind: true,
},
{
name: "not unraid",
files: []parser.ExtractedFile{
{
Path: "some/random/file.txt",
Content: []byte("just some random content"),
},
},
wantMin: 0,
wantMax: 0,
shouldFind: false,
},
}
p := &Parser{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.Detect(tt.files)
if tt.shouldFind && got < tt.wantMin {
t.Errorf("Detect() = %v, want at least %v", got, tt.wantMin)
}
if got > tt.wantMax {
t.Errorf("Detect() = %v, want at most %v", got, tt.wantMax)
}
if !tt.shouldFind && got > 0 {
t.Errorf("Detect() = %v, want 0 (should not detect)", got)
}
})
}
}
func TestParse_Version(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "unraid-7.2.0.txt",
Content: []byte("7.2.0\n"),
},
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(result.Hardware.Firmware) == 0 {
t.Fatal("expected firmware info")
}
fw := result.Hardware.Firmware[0]
if fw.DeviceName != "Unraid OS" {
t.Errorf("DeviceName = %v, want 'Unraid OS'", fw.DeviceName)
}
if fw.Version != "7.2.0" {
t.Errorf("Version = %v, want '7.2.0'", fw.Version)
}
}
func TestParse_CPU(t *testing.T) {
lscpuContent := `Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
CPU(s): 16
Model name: Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz
Core(s) per socket: 8
Socket(s): 1
CPU max MHz: 3400.0000
`
files := []parser.ExtractedFile{
{
Path: "diagnostics/system/lscpu.txt",
Content: []byte(lscpuContent),
},
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(result.Hardware.CPUs) == 0 {
t.Fatal("expected CPU info")
}
cpu := result.Hardware.CPUs[0]
if cpu.Model != "Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz" {
t.Errorf("Model = %v", cpu.Model)
}
if cpu.Cores != 8 {
t.Errorf("Cores = %v, want 8", cpu.Cores)
}
if cpu.Threads != 16 {
t.Errorf("Threads = %v, want 16", cpu.Threads)
}
if cpu.FrequencyMHz != 3400 {
t.Errorf("FrequencyMHz = %v, want 3400", cpu.FrequencyMHz)
}
}
func TestParse_Memory(t *testing.T) {
memContent := ` total used free shared buff/cache available
Mem: 50Gi 11Gi 1.4Gi 565Mi 39Gi 39Gi
Swap: 0B 0B 0B
Total: 50Gi 11Gi 1.4Gi
`
files := []parser.ExtractedFile{
{
Path: "diagnostics/system/memory.txt",
Content: []byte(memContent),
},
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(result.Hardware.Memory) == 0 {
t.Fatal("expected memory info")
}
mem := result.Hardware.Memory[0]
expectedSizeMB := 50 * 1024 // 50 GiB in MB
if mem.SizeMB != expectedSizeMB {
t.Errorf("SizeMB = %v, want %v", mem.SizeMB, expectedSizeMB)
}
if mem.Type != "DRAM" {
t.Errorf("Type = %v, want 'DRAM'", mem.Type)
}
}
func TestParse_SMART(t *testing.T) {
smartContent := `smartctl 7.5 2025-04-30 r5714 [x86_64-linux-6.12.54-Unraid] (local build)
Copyright (C) 2002-25, Bruce Allen, Christian Franke, www.smartmontools.org
=== START OF INFORMATION SECTION ===
Device Model: ST4000NM000B-2TF100
Serial Number: WX103EC9
LU WWN Device Id: 5 000c50 0ed59db60
Firmware Version: TNA1
User Capacity: 4,000,787,030,016 bytes [4.00 TB]
Sector Size: 512 bytes logical/physical
Rotation Rate: 7200 rpm
Form Factor: 3.5 inches
SATA Version is: SATA 3.3, 6.0 Gb/s (current: 6.0 Gb/s)
=== START OF READ SMART DATA SECTION ===
SMART overall-health self-assessment test result: PASSED
`
files := []parser.ExtractedFile{
{
Path: "diagnostics/smart/ST4000NM000B-2TF100_WX103EC9-20260205-2333 disk1 (sdi).txt",
Content: []byte(smartContent),
},
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(result.Hardware.Storage) == 0 {
t.Fatal("expected storage info")
}
disk := result.Hardware.Storage[0]
if disk.Model != "ST4000NM000B-2TF100" {
t.Errorf("Model = %v, want 'ST4000NM000B-2TF100'", disk.Model)
}
if disk.SerialNumber != "WX103EC9" {
t.Errorf("SerialNumber = %v, want 'WX103EC9'", disk.SerialNumber)
}
if disk.Firmware != "TNA1" {
t.Errorf("Firmware = %v, want 'TNA1'", disk.Firmware)
}
if disk.SizeGB != 4000 {
t.Errorf("SizeGB = %v, want 4000", disk.SizeGB)
}
if disk.Type != "hdd" {
t.Errorf("Type = %v, want 'hdd'", disk.Type)
}
// Check that no health warnings were generated (PASSED health)
healthWarnings := 0
for _, event := range result.Events {
if event.EventType == "Disk Health" && event.Severity == "warning" {
healthWarnings++
}
}
if healthWarnings != 0 {
t.Errorf("Expected no health warnings for PASSED disk, got %v", healthWarnings)
}
}
func TestParser_Metadata(t *testing.T) {
p := &Parser{}
if p.Name() != "Unraid Parser" {
t.Errorf("Name() = %v, want 'Unraid Parser'", p.Name())
}
if p.Vendor() != "unraid" {
t.Errorf("Vendor() = %v, want 'unraid'", p.Vendor())
}
if p.Version() == "" {
t.Error("Version() should not be empty")
}
}

View File

@@ -8,6 +8,7 @@ import (
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/supermicro"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
// Generic fallback parser (must be last for lowest priority)

View File

@@ -309,10 +309,27 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
}
var serials []SerialEntry
seenByLocationSerial := make(map[string]bool)
markSeen := func(location, serial string) {
loc := strings.ToLower(strings.TrimSpace(location))
sn := strings.ToLower(strings.TrimSpace(serial))
if loc == "" || sn == "" {
return
}
seenByLocationSerial[loc+"|"+sn] = true
}
alreadySeen := func(location, serial string) bool {
loc := strings.ToLower(strings.TrimSpace(location))
sn := strings.ToLower(strings.TrimSpace(serial))
if loc == "" || sn == "" {
return false
}
return seenByLocationSerial[loc+"|"+sn]
}
// From FRU
for _, fru := range result.FRU {
if fru.SerialNumber == "" {
if !hasUsableSerial(fru.SerialNumber) {
continue
}
name := fru.ProductName
@@ -321,7 +338,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
}
serials = append(serials, SerialEntry{
Component: name,
SerialNumber: fru.SerialNumber,
SerialNumber: strings.TrimSpace(fru.SerialNumber),
Manufacturer: fru.Manufacturer,
PartNumber: fru.PartNumber,
Category: "FRU",
@@ -331,10 +348,10 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
// From Hardware
if result.Hardware != nil {
// Board
if result.Hardware.BoardInfo.SerialNumber != "" {
if hasUsableSerial(result.Hardware.BoardInfo.SerialNumber) {
serials = append(serials, SerialEntry{
Component: result.Hardware.BoardInfo.ProductName,
SerialNumber: result.Hardware.BoardInfo.SerialNumber,
SerialNumber: strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber),
Manufacturer: result.Hardware.BoardInfo.Manufacturer,
PartNumber: result.Hardware.BoardInfo.PartNumber,
Category: "Board",
@@ -343,24 +360,20 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
// CPUs
for _, cpu := range result.Hardware.CPUs {
sn := cpu.SerialNumber
if sn == "" {
sn = cpu.PPIN // Use PPIN as fallback identifier
}
if sn == "" {
if !hasUsableSerial(cpu.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: cpu.Model,
Location: fmt.Sprintf("CPU%d", cpu.Socket),
SerialNumber: sn,
SerialNumber: strings.TrimSpace(cpu.SerialNumber),
Category: "CPU",
})
}
// Memory DIMMs
for _, mem := range result.Hardware.Memory {
if mem.SerialNumber == "" {
if !hasUsableSerial(mem.SerialNumber) {
continue
}
location := mem.Location
@@ -370,7 +383,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
serials = append(serials, SerialEntry{
Component: mem.PartNumber,
Location: location,
SerialNumber: mem.SerialNumber,
SerialNumber: strings.TrimSpace(mem.SerialNumber),
Manufacturer: mem.Manufacturer,
PartNumber: mem.PartNumber,
Category: "Memory",
@@ -379,72 +392,127 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
// Storage
for _, stor := range result.Hardware.Storage {
if stor.SerialNumber == "" {
if !hasUsableSerial(stor.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: stor.Model,
Location: stor.Slot,
SerialNumber: stor.SerialNumber,
SerialNumber: strings.TrimSpace(stor.SerialNumber),
Manufacturer: stor.Manufacturer,
Category: "Storage",
})
}
// PCIe devices
for _, pcie := range result.Hardware.PCIeDevices {
if pcie.SerialNumber == "" {
// GPUs
for _, gpu := range result.Hardware.GPUs {
if !hasUsableSerial(gpu.SerialNumber) {
continue
}
model := gpu.Model
if model == "" {
model = "GPU"
}
serials = append(serials, SerialEntry{
Component: pcie.DeviceClass,
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: pcie.SerialNumber,
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 nic.SerialNumber == "" {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: nic.Model,
SerialNumber: nic.SerialNumber,
Location: nic.Name,
SerialNumber: strings.TrimSpace(nic.SerialNumber),
Category: "Network",
})
markSeen(nic.Name, nic.SerialNumber)
}
// Power supplies
for _, psu := range result.Hardware.PowerSupply {
if psu.SerialNumber == "" {
if !hasUsableSerial(psu.SerialNumber) {
continue
}
serials = append(serials, SerialEntry{
Component: psu.Model,
Location: psu.Slot,
SerialNumber: psu.SerialNumber,
SerialNumber: strings.TrimSpace(psu.SerialNumber),
Manufacturer: psu.Vendor,
Category: "PSU",
})
}
// Firmware (using version as "serial number" for display)
for _, fw := range result.Hardware.Firmware {
serials = append(serials, SerialEntry{
Component: fw.DeviceName,
SerialNumber: fw.Version,
Category: "Firmware",
})
}
}
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 {
s := strings.TrimSpace(serial)
if s == "" {
return false
}
switch strings.ToUpper(s) {
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
return false
default:
return true
}
}
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil || result.Hardware == nil {
@@ -452,33 +520,70 @@ func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
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
// 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)
var deduplicated []FirmwareEntry
var deduplicated []firmwareEntry
for _, fw := range result.Hardware.Firmware {
// Extract component type and model from device name
component, model := extractFirmwareComponentAndModel(fw.DeviceName)
key := component + "|" + model + "|" + fw.Version
if !seen[key] {
seen[key] = true
deduplicated = append(deduplicated, FirmwareEntry{
Component: component,
Model: model,
Version: fw.Version,
})
appendEntry := func(component, model, version string) {
component = strings.TrimSpace(component)
model = strings.TrimSpace(model)
version = strings.TrimSpace(version)
if component == "" || version == "" {
return
}
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
@@ -508,6 +613,36 @@ func extractFirmwareComponentAndModel(deviceName string) (component, model strin
return "NIC", "-"
}
// For "GPU GPUSXM5 (692-2G520-0280-501)" -> component: "GPU", model: "GPUSXM5 (692-2G520-0280-501)"
if strings.HasPrefix(deviceName, "GPU ") {
if idx := strings.Index(deviceName, "("); idx != -1 {
model = strings.TrimSpace(strings.Trim(deviceName[idx:], "()"))
if model != "" {
return "GPU", model
}
}
model = strings.TrimSpace(strings.TrimPrefix(deviceName, "GPU "))
if model == "" {
return "GPU", "-"
}
return "GPU", model
}
// For "NVSwitch NVSWITCH2 (NVSWITCH2)" -> component: "NVSwitch", model: "NVSWITCH2 (NVSWITCH2)"
if strings.HasPrefix(deviceName, "NVSwitch ") {
if idx := strings.Index(deviceName, "("); idx != -1 {
model = strings.TrimSpace(strings.Trim(deviceName[idx:], "()"))
if model != "" {
return "NVSwitch", model
}
}
model = strings.TrimSpace(strings.TrimPrefix(deviceName, "NVSwitch "))
if model == "" {
return "NVSwitch", "-"
}
return "NVSwitch", model
}
// For "HDD Samsung MZ7L33T8HBNA-00A07" -> component: "HDD", model: "Samsung MZ7L33T8HBNA-00A07"
if strings.HasPrefix(deviceName, "HDD ") {
return "HDD", strings.TrimPrefix(deviceName, "HDD ")
@@ -573,14 +708,32 @@ func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
exp.ExportJSON(w)
}
func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil || result.Hardware == nil {
jsonError(w, "No hardware data available for export", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "txt")))
reanimatorData, err := exporter.ConvertToReanimator(result)
if err != nil {
statusCode := http.StatusInternalServerError
if strings.Contains(err.Error(), "required for Reanimator export") {
statusCode = http.StatusBadRequest
}
jsonError(w, fmt.Sprintf("Export failed: %v", err), statusCode)
return
}
exp := exporter.New(result)
exp.ExportTXT(w)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "reanimator.json")))
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(reanimatorData); err != nil {
// Log error, but likely too late to send error response
return
}
}
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
@@ -896,7 +1049,7 @@ func exportFilename(result *models.AnalysisResult, ext string) string {
sn = sanitizeFilenamePart(sn)
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
if ext == "" {
ext = "txt"
ext = "json"
}
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
}

View File

@@ -0,0 +1,64 @@
package server
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestExtractFirmwareComponentAndModel_GPUUsesPartNumberFromParentheses(t *testing.T) {
component, model := extractFirmwareComponentAndModel("GPU GPUSXM3 (692-2G520-0280-501)")
if component != "GPU" {
t.Fatalf("expected component GPU, got %q", component)
}
if model != "692-2G520-0280-501" {
t.Fatalf("expected GPU model 692-2G520-0280-501, got %q", model)
}
}
func TestExtractFirmwareComponentAndModel_GPUFallbackWithoutParentheses(t *testing.T) {
component, model := extractFirmwareComponentAndModel("GPU 692-2G520-0280-501")
if component != "GPU" {
t.Fatalf("expected component GPU, got %q", component)
}
if model != "692-2G520-0280-501" {
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,132 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestHandleGetSerials_WithGPUs(t *testing.T) {
// Create test server with GPU data
srv := &Server{}
testResult := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPUSXM1",
Model: "NVIDIA Device 2335",
Manufacturer: "NVIDIA Corporation",
SerialNumber: "48:B0:2D:BB:8E:51:9E:E5",
Firmware: "96.00.D0.00.03",
BDF: "0000:3a:00.0",
},
{
Slot: "GPUSXM2",
Model: "NVIDIA Device 2335",
Manufacturer: "NVIDIA Corporation",
SerialNumber: "48:B0:2D:EE:DA:27:CF:78",
Firmware: "96.00.D0.00.03",
BDF: "0000:18:00.0",
},
},
},
}
srv.SetResult(testResult)
// Create request
req := httptest.NewRequest("GET", "/api/serials", nil)
w := httptest.NewRecorder()
// Call handler
srv.handleGetSerials(w, req)
// Check response
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Parse response
var serials []struct {
Component string `json:"component"`
Location string `json:"location,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Category string `json:"category"`
}
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
// Check that we have GPU entries
gpuCount := 0
for _, s := range serials {
if s.Category == "GPU" {
gpuCount++
t.Logf("Found GPU: %s (%s) S/N: %s", s.Component, s.Location, s.SerialNumber)
// Verify fields are set
if s.SerialNumber == "" {
t.Errorf("GPU serial number is empty")
}
if s.Location == "" {
t.Errorf("GPU location is empty")
}
if s.Manufacturer == "" {
t.Errorf("GPU manufacturer is empty")
}
}
}
if gpuCount != 2 {
t.Errorf("Expected 2 GPUs in serials, got %d", gpuCount)
}
}
func TestHandleGetSerials_WithoutGPUSerials(t *testing.T) {
// Create test server with GPUs but no serial numbers
srv := &Server{}
testResult := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{
Slot: "GPU0",
Model: "Some GPU",
Manufacturer: "Vendor",
SerialNumber: "", // No serial number
},
},
},
}
srv.SetResult(testResult)
// Create request
req := httptest.NewRequest("GET", "/api/serials", nil)
w := httptest.NewRecorder()
// Call handler
srv.handleGetSerials(w, req)
// Parse response
var serials []struct {
Category string `json:"category"`
}
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
// Check that GPUs without serial numbers are not included
for _, s := range serials {
if s.Category == "GPU" {
t.Error("GPU without serial number should not be included in serials list")
}
}
}

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)
}
}

View File

@@ -30,7 +30,7 @@ type Server struct {
result *models.AnalysisResult
detectedVendor string
jobManager *JobManager
jobManager *JobManager
collectors *collector.Registry
}
@@ -67,7 +67,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware)
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)

BIN
logpile Executable file

Binary file not shown.

View File

@@ -8,7 +8,6 @@ Release date: 2026-02-04
- Upload flow now accepts JSON snapshots in addition to archives, enabling offline re-open of live Redfish collections.
- Export UX improved:
- Export filenames now follow `YYYY-MM-DD (SERVER MODEL) - SERVER SN`.
- TXT export now outputs tabular sections matching web UI views (no raw JSON dump).
- Live API UI improvements: parser/file badges for Redfish sessions and clearer upload format messaging.
- Redfish progress logs are more informative (snapshot stage and active top-level roots).
- Build/distribution hardening:

View File

@@ -0,0 +1,13 @@
# logpile v1.3.0-dirty
Дата релиза: 2026-02-15
Тег: `v1.3.0-dirty`
## Что нового
- TODO: опишите ключевые изменения релиза.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,42 @@
# Release v1.3.0
Previous tag: `v1.2.1`
Diff range: `v1.2.1..v1.3.0`
## Summary
This release expands parser coverage, adds Reanimator export capabilities, and improves resilience of archive ingestion and diagnostics parsing.
## What's New
- Added XigmaNAS log parser, vendor registration, and extended event parsing.
- Added Unraid diagnostics parser and improved zip upload handling.
- Added GPU serial number extraction for NVIDIA diagnostics.
- Added Reanimator export format support.
- Added integration guide and example generator.
## Improvements
- Updated parser behavior and project handling.
- Aligned Reanimator export behavior with integration guide updates.
- Improved handling of TXT uploads.
## Fixes
- Fixed NVIDIA GPU serial number format extraction.
- Fixed NVIDIA GPU/NVSwitch parsing and Reanimator export statuses.
- Hardened zip reader and syslog scan logic.
- Removed unused local test/build artifacts.
## Commits Since `v1.2.1`
- `5e49ada` Update parser and project changes
- `c7b2a7a` Fix NVIDIA GPU/NVSwitch parsing and Reanimator export statuses
- `0af3cee` Add integration guide, example generator, and built binary
- `8715fca` Align Reanimator export with updated integration guide
- `1b1bc74` Add Reanimator format export support
- `77e25dd` Fix NVIDIA GPU serial number format extraction
- `bcce975` Add GPU serial number extraction for NVIDIA diagnostics
- `8b065c6` Harden zip reader and syslog scan
- `aa22034` Add Unraid diagnostics parser and fix zip upload support
- `7d9135d` Merge branch 'main' of https://git.mchus.pro/mchus/logpile
- `80e726d` chore: remove unused local test and build artifacts
- `92134a6` Support TXT uploads and extend XigmaNAS event parsing
- `ae588ae` Register xigmanas vendor parser
- `b64a8d8` Add XigmaNAS log parser and tests
- `f9230e1` Update README and CLAUDE docs for current Redfish workflow

102
scripts/release.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
set -e
# logpile Release Build Script
# Creates binaries for selected platforms and packages them for release
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Get version from git
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
if [[ $VERSION == *"dirty"* ]] && [[ "${ALLOW_DIRTY}" != "1" ]]; then
echo -e "${RED}Error: Working directory has uncommitted changes${NC}"
echo "Commit your changes first (or run with ALLOW_DIRTY=1)"
exit 1
fi
echo -e "${GREEN}Building logpile version: ${VERSION}${NC}"
echo ""
# Stable build env for this machine/toolchain
export GOPATH="${GOPATH:-/tmp/go}"
export GOCACHE="${GOCACHE:-/tmp/gocache}"
export GOTOOLCHAIN="${GOTOOLCHAIN:-go1.22.12}"
mkdir -p "${GOPATH}" "${GOCACHE}"
# Create release directory
RELEASE_DIR="releases/${VERSION}"
mkdir -p "${RELEASE_DIR}"
# Create release notes template (always include macOS Gatekeeper note)
if [ ! -f "${RELEASE_DIR}/RELEASE_NOTES.md" ]; then
cat > "${RELEASE_DIR}/RELEASE_NOTES.md" <<EON
# logpile ${VERSION}
Дата релиза: $(date +%Y-%m-%d)
Тег: \`${VERSION}\`
## Что нового
- TODO: опишите ключевые изменения релиза.
## Запуск на macOS
Снимите карантинный атрибут через терминал: \`xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64\`
После этого бинарник запустится без предупреждения Gatekeeper.
EON
fi
# Build selected platforms
echo -e "${YELLOW}Building binaries...${NC}"
mkdir -p bin
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
go build -ldflags "-X main.version=${VERSION} -X main.commit=$(git rev-parse --short HEAD)" \
-o bin/logpile-darwin-arm64 ./cmd/logpile
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \
go build -ldflags "-X main.version=${VERSION} -X main.commit=$(git rev-parse --short HEAD)" \
-o bin/logpile-windows-amd64.exe ./cmd/logpile
echo ""
echo -e "${YELLOW}Creating release packages...${NC}"
# macOS Apple Silicon
if [ -f "bin/logpile-darwin-arm64" ]; then
cd bin
tar -czf "../${RELEASE_DIR}/logpile-${VERSION}-darwin-arm64.tar.gz" logpile-darwin-arm64
cd ..
echo -e "${GREEN} logpile-${VERSION}-darwin-arm64.tar.gz${NC}"
fi
# Windows AMD64
if [ -f "bin/logpile-windows-amd64.exe" ]; then
cd bin
zip -q "../${RELEASE_DIR}/logpile-${VERSION}-windows-amd64.zip" logpile-windows-amd64.exe
cd ..
echo -e "${GREEN} logpile-${VERSION}-windows-amd64.zip${NC}"
fi
# Generate checksums
echo ""
echo -e "${YELLOW}Generating checksums...${NC}"
cd "${RELEASE_DIR}"
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
cd ../..
echo -e "${GREEN} SHA256SUMS.txt${NC}"
# List release files
echo ""
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo "Files in ${RELEASE_DIR}:"
ls -lh "${RELEASE_DIR}"
echo ""
echo -e "${GREEN}Done!${NC}"

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
html += '<div class="config-tab-content" id="config-gpu">';
if (config.gpus && config.gpus.length > 0) {
const gpuCount = config.gpus.length;
const gpuModel = config.gpus[0].model || '-';
const gpuVendor = config.gpus[0].manufacturer || '-';
const gpuRows = (config.gpus && config.gpus.length > 0)
? config.gpus
: (config.pcie_devices || [])
.filter((p) => {
const cls = String(p.device_class || '').toLowerCase();
const mfr = String(p.manufacturer || '').toLowerCase();
return cls.includes('gpu') || cls.includes('display') || cls.includes('3d') || mfr.includes('nvidia') || p.vendor_id === 0x10de;
})
.map((p) => ({
slot: p.slot,
model: p.part_number || p.device_class,
manufacturer: p.manufacturer,
bdf: p.bdf,
serial_number: p.serial_number,
current_link_width: p.link_width,
current_link_speed: p.link_speed,
max_link_width: p.max_link_width,
max_link_speed: p.max_link_speed
}));
if (gpuRows.length > 0) {
const gpuCount = gpuRows.length;
const gpuModel = gpuRows[0].model || '-';
const gpuVendor = gpuRows[0].manufacturer || '-';
html += `<h3>Графические процессоры</h3>
<div class="section-overview">
<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>
<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(
gpu.current_link_width || gpu.link_width,
gpu.current_link_speed || gpu.link_speed,
@@ -869,11 +888,27 @@ function renderConfig(data) {
// Network tab
html += '<div class="config-tab-content" id="config-network">';
if (config.network_adapters && config.network_adapters.length > 0) {
const nicCount = config.network_adapters.length;
const totalPorts = config.network_adapters.reduce((sum, n) => sum + (n.port_count || 0), 0);
const nicTypes = [...new Set(config.network_adapters.map(n => n.port_type).filter(t => t))];
const nicModels = [...new Set(config.network_adapters.map(n => n.model).filter(m => m))];
const networkRows = (config.network_adapters && config.network_adapters.length > 0)
? config.network_adapters
: (config.pcie_devices || [])
.filter((p) => {
const cls = String(p.device_class || '').toLowerCase();
return cls.includes('network') || cls.includes('ethernet') || cls.includes('gigabit');
})
.map((p) => ({
location: p.slot,
model: p.part_number || p.device_class,
vendor: p.manufacturer,
port_count: 0,
port_type: '',
mac_addresses: p.mac_addresses || [],
status: p.status || ''
}));
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>
<div class="section-overview">
<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>
<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 statusClass = nic.status === 'OK' ? '' : 'status-warning';
html += `<tr>
@@ -903,22 +938,62 @@ function renderConfig(data) {
// PCIe Device Inventory tab
html += '<div class="config-tab-content" id="config-pcie">';
if (config.pcie_devices && config.pcie_devices.length > 0) {
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></tr></thead><tbody>';
config.pcie_devices.forEach(p => {
const hasPCIe = config.pcie_devices && config.pcie_devices.length > 0;
const hasGPUs = config.gpus && config.gpus.length > 0;
if (hasPCIe || hasGPUs) {
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 => {
const key = pcieRowKey(p.slot, p.bdf, p.vendor_id, p.device_id);
const matchedGPU = gpuByKey.get(key);
const pcieLink = formatPCIeLink(
p.link_width,
p.link_speed,
p.max_link_width,
p.max_link_speed
);
const serial = p.serial_number || (matchedGPU ? matchedGPU.serial_number : '');
const firmware = p.firmware || (matchedGPU ? matchedGPU.firmware : '') || findPCIeFirmwareVersion(config.firmware, p);
html += `<tr>
<td>${escapeHtml(p.slot || '-')}</td>
<td><code>${escapeHtml(p.bdf || '-')}</code></td>
<td>${escapeHtml(p.device_class || '-')}</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 => {
const pcieLink = formatPCIeLink(
gpu.current_link_width || gpu.link_width,
gpu.current_link_speed || gpu.link_speed,
gpu.max_link_width,
gpu.max_link_speed
);
html += `<tr>
<td>${escapeHtml(gpu.slot || '-')}</td>
<td><code>${escapeHtml(gpu.bdf || '-')}</code></td>
<td>${escapeHtml(gpu.model || gpu.part_number || '-')}</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>${pcieLink}</td>
<td><code>${escapeHtml(gpu.serial_number || '-')}</code></td>
<td><code>${escapeHtml(gpu.firmware || '-')}</code></td>
</tr>`;
});
html += '</tbody></table>';
@@ -1079,6 +1154,7 @@ function renderSerials(serials) {
'CPU': 'Процессор',
'Memory': 'Память',
'Storage': 'Накопитель',
'GPU': 'Видеокарта',
'PCIe': 'PCIe',
'Network': 'Сеть',
'PSU': 'БП',
@@ -1207,6 +1283,24 @@ function escapeHtml(text) {
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) {
// Helper to convert speed to generation
function speedToGen(speed) {

View File

@@ -111,7 +111,7 @@
<div class="tab-content active" id="config">
<div class="toolbar">
<button onclick="exportData('json')">Экспорт JSON</button>
<button onclick="exportData('txt')">Экспорт TXT</button>
<button onclick="exportData('reanimator')">Экспорт Reanimator</button>
</div>
<div id="config-content"></div>
</div>