Compare commits
13 Commits
7d9135dc63
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 758fa66282 | |||
| b33cca5fcc | |||
| 514da76ddb | |||
| c13788132b | |||
| 5e49adaf05 | |||
| c7b2a7ab29 | |||
| 0af3cee9b6 | |||
| 8715fcace4 | |||
| 1b1bc74fc7 | |||
| 77e25ddc02 | |||
| bcce975fd6 | |||
| 8b065c6cca | |||
| aa22034944 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "third_party/pciids"]
|
||||
path = third_party/pciids
|
||||
url = https://github.com/pciutils/pciids.git
|
||||
39
CLAUDE.md
39
CLAUDE.md
@@ -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`)
|
||||
|
||||
|
||||
7
Makefile
7
Makefile
@@ -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
|
||||
|
||||
65
README.md
65
README.md
@@ -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
227
REANIMATOR_EXPORT.md
Normal 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
1046
docs/INTEGRATION_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
40
internal/collector/redfish_pciids_test.go
Normal file
40
internal/collector/redfish_pciids_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
79
internal/exporter/exporter_csv_test.go
Normal file
79
internal/exporter/exporter_csv_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
164
internal/exporter/generate_example_test.go
Normal file
164
internal/exporter/generate_example_test.go
Normal 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))
|
||||
}
|
||||
898
internal/exporter/reanimator_converter.go
Normal file
898
internal/exporter/reanimator_converter.go
Normal 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 ""
|
||||
}
|
||||
701
internal/exporter/reanimator_converter_test.go
Normal file
701
internal/exporter/reanimator_converter_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
293
internal/exporter/reanimator_integration_test.go
Normal file
293
internal/exporter/reanimator_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
149
internal/exporter/reanimator_models.go
Normal file
149
internal/exporter/reanimator_models.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
internal/parser/parser_test.go
Normal file
34
internal/parser/parser_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
69
internal/parser/vendors/inspur/asset.go
vendored
69
internal/parser/vendors/inspur/asset.go
vendored
@@ -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:
|
||||
|
||||
48
internal/parser/vendors/inspur/asset_gpu_model_test.go
vendored
Normal file
48
internal/parser/vendors/inspur/asset_gpu_model_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
90
internal/parser/vendors/inspur/component.go
vendored
90
internal/parser/vendors/inspur/component.go
vendored
@@ -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
|
||||
|
||||
|
||||
52
internal/parser/vendors/inspur/component_test.go
vendored
Normal file
52
internal/parser/vendors/inspur/component_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
39
internal/parser/vendors/inspur/fru.go
vendored
39
internal/parser/vendors/inspur/fru.go
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
internal/parser/vendors/inspur/fru_test.go
vendored
Normal file
59
internal/parser/vendors/inspur/fru_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
115
internal/parser/vendors/inspur/gpu_status.go
vendored
Normal file
115
internal/parser/vendors/inspur/gpu_status.go
vendored
Normal 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")
|
||||
}
|
||||
69
internal/parser/vendors/inspur/hgx_firmware_test.go
vendored
Normal file
69
internal/parser/vendors/inspur/hgx_firmware_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
171
internal/parser/vendors/inspur/hgx_gpu_status_test.go
vendored
Normal file
171
internal/parser/vendors/inspur/hgx_gpu_status_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
360
internal/parser/vendors/inspur/hgx_hwinfo.go
vendored
Normal file
360
internal/parser/vendors/inspur/hgx_hwinfo.go
vendored
Normal 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
|
||||
}
|
||||
10
internal/parser/vendors/inspur/idl.go
vendored
10
internal/parser/vendors/inspur/idl.go
vendored
@@ -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
|
||||
|
||||
46
internal/parser/vendors/inspur/parser.go
vendored
46
internal/parser/vendors/inspur/parser.go
vendored
@@ -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
|
||||
|
||||
217
internal/parser/vendors/inspur/pcie.go
vendored
217
internal/parser/vendors/inspur/pcie.go
vendored
@@ -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:
|
||||
|
||||
77
internal/parser/vendors/inspur/pcie_test.go
vendored
Normal file
77
internal/parser/vendors/inspur/pcie_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
559
internal/parser/vendors/inspur/redis_dump.go
vendored
Normal file
559
internal/parser/vendors/inspur/redis_dump.go
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
144
internal/parser/vendors/inspur/redis_dump_test.go
vendored
Normal file
144
internal/parser/vendors/inspur/redis_dump_test.go
vendored
Normal 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
|
||||
}
|
||||
92
internal/parser/vendors/inspur/serial_fallback.go
vendored
Normal file
92
internal/parser/vendors/inspur/serial_fallback.go
vendored
Normal 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
|
||||
}
|
||||
76
internal/parser/vendors/inspur/serial_fallback_test.go
vendored
Normal file
76
internal/parser/vendors/inspur/serial_fallback_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
274
internal/parser/vendors/nvidia/component_status_time.go
vendored
Normal file
274
internal/parser/vendors/nvidia/component_status_time.go
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
143
internal/parser/vendors/nvidia/component_status_time_test.go
vendored
Normal file
143
internal/parser/vendors/nvidia/component_status_time_test.go
vendored
Normal 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))
|
||||
}
|
||||
}
|
||||
374
internal/parser/vendors/nvidia/gpu_model.go
vendored
Normal file
374
internal/parser/vendors/nvidia/gpu_model.go
vendored
Normal 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))
|
||||
}
|
||||
207
internal/parser/vendors/nvidia/gpu_model_test.go
vendored
Normal file
207
internal/parser/vendors/nvidia/gpu_model_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
155
internal/parser/vendors/nvidia/inventory_log.go
vendored
Normal file
155
internal/parser/vendors/nvidia/inventory_log.go
vendored
Normal 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
|
||||
}
|
||||
126
internal/parser/vendors/nvidia/inventory_log_test.go
vendored
Normal file
126
internal/parser/vendors/nvidia/inventory_log_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
370
internal/parser/vendors/nvidia/nvflash_verbose.go
vendored
Normal file
370
internal/parser/vendors/nvidia/nvflash_verbose.go
vendored
Normal 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])
|
||||
}
|
||||
93
internal/parser/vendors/nvidia/nvflash_verbose_test.go
vendored
Normal file
93
internal/parser/vendors/nvidia/nvflash_verbose_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
66
internal/parser/vendors/nvidia/parser.go
vendored
66
internal/parser/vendors/nvidia/parser.go
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
291
internal/parser/vendors/nvidia/parser_test.go
vendored
Normal file
291
internal/parser/vendors/nvidia/parser_test.go
vendored
Normal 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
|
||||
}
|
||||
338
internal/parser/vendors/nvidia/summary.go
vendored
338
internal/parser/vendors/nvidia/summary.go
vendored
@@ -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
|
||||
|
||||
122
internal/parser/vendors/nvidia/summary_status_test.go
vendored
Normal file
122
internal/parser/vendors/nvidia/summary_status_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
46
internal/parser/vendors/nvidia/unified_summary_filter_test.go
vendored
Normal file
46
internal/parser/vendors/nvidia/unified_summary_filter_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
35
internal/parser/vendors/nvidia/unified_summary_test.go
vendored
Normal file
35
internal/parser/vendors/nvidia/unified_summary_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
137
internal/parser/vendors/nvidia_bug_report/gpu.go
vendored
137
internal/parser/vendors/nvidia_bug_report/gpu.go
vendored
@@ -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)
|
||||
}
|
||||
|
||||
54
internal/parser/vendors/nvidia_bug_report/gpu_test.go
vendored
Normal file
54
internal/parser/vendors/nvidia_bug_report/gpu_test.go
vendored
Normal 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
41507
internal/parser/vendors/pciids/pci.ids
vendored
Normal file
File diff suppressed because it is too large
Load Diff
222
internal/parser/vendors/pciids/pciids.go
vendored
222
internal/parser/vendors/pciids/pciids.go
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
38
internal/parser/vendors/pciids/pciids_external_test.go
vendored
Normal file
38
internal/parser/vendors/pciids/pciids_external_test.go
vendored
Normal 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
606
internal/parser/vendors/unraid/parser.go
vendored
Normal 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
|
||||
}
|
||||
277
internal/parser/vendors/unraid/parser_test.go
vendored
Normal file
277
internal/parser/vendors/unraid/parser_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
64
internal/server/handlers_firmware_test.go
Normal file
64
internal/server/handlers_firmware_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
132
internal/server/handlers_gpu_test.go
Normal file
132
internal/server/handlers_gpu_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
20
internal/server/handlers_serials_test.go
Normal file
20
internal/server/handlers_serials_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
13
releases/v1.3.0-dirty/RELEASE_NOTES.md
Normal file
13
releases/v1.3.0-dirty/RELEASE_NOTES.md
Normal 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.
|
||||
42
releases/v1.3.0/RELEASE_NOTES.md
Normal file
42
releases/v1.3.0/RELEASE_NOTES.md
Normal 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
102
scripts/release.sh
Executable 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
59
scripts/update-pci-ids.sh
Executable 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
1
third_party/pciids
vendored
Submodule
Submodule third_party/pciids added at 82b1a68f47
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user