17 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 23:54:55 +03:00
Mikhail Chusavitin
7d9135dc63 Merge branch 'main' of https://git.mchus.pro/mchus/logpile 2026-02-05 15:16:36 +03:00
Mikhail Chusavitin
80e726d756 chore: remove unused local test and build artifacts 2026-02-05 15:15:01 +03:00
92134a6cc1 Support TXT uploads and extend XigmaNAS event parsing 2026-02-04 22:25:43 +03:00
ae588ae75a Register xigmanas vendor parser 2026-02-04 22:15:45 +03:00
b64a8d8709 Add XigmaNAS log parser and tests 2026-02-04 22:14:14 +03:00
64 changed files with 9749 additions and 458 deletions

5
.gitignore vendored
View File

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

View File

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

View File

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

227
REANIMATOR_EXPORT.md Normal file
View File

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

1046
docs/INTEGRATION_GUIDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,7 @@ package exporter
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"text/tabwriter"
"git.mchus.pro/mchus/logpile/internal/models"
)
@@ -114,221 +112,3 @@ func (e *Exporter) ExportJSON(w io.Writer) error {
encoder.SetIndent("", " ")
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
}
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)
}
fmt.Fprintln(w)
// Hardware summary
if e.result.Hardware != nil {
hw := e.result.Hardware
// Firmware tab
if len(hw.Firmware) > 0 {
fmt.Fprintln(w, "FIRMWARE VERSIONS")
fmt.Fprintln(w, "-----------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Component\tVersion\tBuild Time")
for _, fw := range hw.Firmware {
fmt.Fprintf(tw, "%s\t%s\t%s\n", fw.DeviceName, fw.Version, fw.BuildTime)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// CPU tab
if len(hw.CPUs) > 0 {
fmt.Fprintln(w, "PROCESSORS")
fmt.Fprintln(w, "----------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Socket\tModel\tCores\tThreads\tFreq MHz\tTurbo MHz\tTDP W\tPPIN/SN")
for _, cpu := range hw.CPUs {
id := cpu.SerialNumber
if id == "" {
id = cpu.PPIN
}
fmt.Fprintf(tw, "CPU%d\t%s\t%d\t%d\t%d\t%d\t%d\t%s\n",
cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz, cpu.MaxFreqMHz, cpu.TDP, id)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Memory tab
if len(hw.Memory) > 0 {
fmt.Fprintln(w, "MEMORY")
fmt.Fprintln(w, "------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tSize MB\tType\tSpeed MHz\tVendor\tModel/PN\tSerial\tStatus")
for _, mem := range hw.Memory {
location := mem.Location
if location == "" {
location = mem.Slot
}
fmt.Fprintf(tw, "%s\t%t\t%d\t%s\t%d\t%s\t%s\t%s\t%s\n",
location, mem.Present, mem.SizeMB, mem.Type, mem.CurrentSpeedMHz, mem.Manufacturer, mem.PartNumber, mem.SerialNumber, mem.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Power tab
if len(hw.PowerSupply) > 0 {
fmt.Fprintln(w, "POWER SUPPLIES")
fmt.Fprintln(w, "--------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tVendor\tModel\tWattage W\tInput W\tOutput W\tInput V\tTemp C\tStatus\tSerial")
for _, psu := range hw.PowerSupply {
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%d\t%d\t%d\t%.0f\t%d\t%s\t%s\n",
psu.Slot, psu.Present, psu.Vendor, psu.Model, psu.WattageW, psu.InputPowerW, psu.OutputPowerW, psu.InputVoltage, psu.TemperatureC, psu.Status, psu.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Storage tab
if len(hw.Storage) > 0 {
fmt.Fprintln(w, "STORAGE")
fmt.Fprintln(w, "-------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tPresent\tType\tInterface\tModel\tSize GB\tVendor\tFirmware\tSerial")
for _, stor := range hw.Storage {
fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
stor.Slot, stor.Present, stor.Type, stor.Interface, stor.Model, stor.SizeGB, stor.Manufacturer, stor.Firmware, stor.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// GPU tab
if len(hw.GPUs) > 0 {
fmt.Fprintln(w, "GPUS")
fmt.Fprintln(w, "----")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tModel\tVendor\tBDF\tPCIe\tSerial\tStatus")
for _, gpu := range hw.GPUs {
link := fmt.Sprintf("x%d %s", gpu.CurrentLinkWidth, gpu.CurrentLinkSpeed)
if gpu.MaxLinkWidth > 0 || gpu.MaxLinkSpeed != "" {
link = fmt.Sprintf("%s / x%d %s", link, gpu.MaxLinkWidth, gpu.MaxLinkSpeed)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
gpu.Slot, gpu.Model, gpu.Manufacturer, gpu.BDF, link, gpu.SerialNumber, gpu.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Network tab
if len(hw.NetworkAdapters) > 0 {
fmt.Fprintln(w, "NETWORK ADAPTERS")
fmt.Fprintln(w, "----------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tLocation\tModel\tVendor\tPorts\tType\tStatus\tSerial")
for _, nic := range hw.NetworkAdapters {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\t%s\n",
nic.Slot, nic.Location, nic.Model, nic.Vendor, nic.PortCount, nic.PortType, nic.Status, nic.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Device inventory tab
if len(hw.PCIeDevices) > 0 {
fmt.Fprintln(w, "PCIE DEVICES")
fmt.Fprintln(w, "------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Slot\tBDF\tClass\tVendor\tVID:DID\tLink\tSerial")
for _, pcie := range hw.PCIeDevices {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%04x:%04x\tx%d %s / x%d %s\t%s\n",
pcie.Slot, pcie.BDF, pcie.DeviceClass, pcie.Manufacturer, pcie.VendorID, pcie.DeviceID,
pcie.LinkWidth, pcie.LinkSpeed, pcie.MaxLinkWidth, pcie.MaxLinkSpeed, pcie.SerialNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
}
// Sensors tab
if len(e.result.Sensors) > 0 {
fmt.Fprintln(w, "SENSOR READINGS")
fmt.Fprintln(w, "---------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Type\tName\tValue\tUnit\tRaw\tStatus")
for _, s := range e.result.Sensors {
fmt.Fprintf(tw, "%s\t%s\t%.0f\t%s\t%s\t%s\n", s.Type, s.Name, s.Value, s.Unit, s.RawValue, s.Status)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Serials/FRU tab
if len(e.result.FRU) > 0 {
fmt.Fprintln(w, "FRU COMPONENTS")
fmt.Fprintln(w, "--------------")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Description\tManufacturer\tProduct\tSerial\tPart Number")
for _, fru := range e.result.FRU {
name := fru.ProductName
if name == "" {
name = fru.Description
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", fru.Description, fru.Manufacturer, name, fru.SerialNumber, fru.PartNumber)
}
_ = tw.Flush()
fmt.Fprintln(w)
}
// Events tab
fmt.Fprintf(w, "EVENTS: %d total\n", len(e.result.Events))
if len(e.result.Events) > 0 {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "Time\tSeverity\tSource\tType\tName\tDescription")
for _, ev := range e.result.Events {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
ev.Timestamp.Format("2006-01-02 15:04:05"), ev.Severity, ev.Source, ev.SensorType, ev.SensorName, ev.Description)
}
_ = tw.Flush()
}
var critical, warning, info int
for _, ev := range e.result.Events {
switch ev.Severity {
case models.SeverityCritical:
critical++
case models.SeverityWarning:
warning++
case models.SeverityInfo:
info++
}
}
fmt.Fprintf(w, " Critical: %d\n", critical)
fmt.Fprintf(w, " Warning: %d\n", warning)
fmt.Fprintf(w, " Info: %d\n", info)
// Footer
fmt.Fprintln(w)
fmt.Fprintln(w, "------------------------------------")
fmt.Fprintln(w, "Generated by LOGPile - mchus.pro")
fmt.Fprintln(w, "https://git.mchus.pro/mchus/logpile")
return nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,16 @@ import (
"strings"
)
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
@@ -29,6 +35,8 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
return extractTar(archivePath)
case ".zip":
return extractZip(archivePath)
case ".txt", ".log":
return extractSingleFile(archivePath)
default:
return nil, fmt.Errorf("unsupported archive format: %s", ext)
}
@@ -43,6 +51,10 @@ 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:
return nil, fmt.Errorf("unsupported archive format: %s", ext)
}
@@ -112,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))
@@ -133,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)
}
@@ -213,6 +236,92 @@ 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 {
return nil, fmt.Errorf("open file: %w", err)
}
defer f.Close()
return extractSingleFileFromReader(f, filepath.Base(path))
}
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
content, err := io.ReadAll(io.LimitReader(r, maxSingleFileSize+1))
if err != nil {
return nil, fmt.Errorf("read file content: %w", err)
}
truncated := len(content) > maxSingleFileSize
if truncated {
content = content[:maxSingleFileSize]
}
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
func FindFileByPattern(files []ExtractedFile, patterns ...string) []ExtractedFile {
var result []ExtractedFile

View File

@@ -0,0 +1,71 @@
package parser
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractArchiveFromReaderTXT(t *testing.T) {
content := "loader_brand=\"XigmaNAS\"\nSystem uptime:\n"
files, err := ExtractArchiveFromReader(strings.NewReader(content), "xigmanas.txt")
if err != nil {
t.Fatalf("extract txt from reader: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if files[0].Path != "xigmanas.txt" {
t.Fatalf("expected filename xigmanas.txt, got %q", files[0].Path)
}
if string(files[0].Content) != content {
t.Fatalf("content mismatch")
}
}
func TestExtractArchiveTXT(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sample.txt")
want := "plain text log"
if err := os.WriteFile(path, []byte(want), 0o600); err != nil {
t.Fatalf("write sample txt: %v", err)
}
files, err := ExtractArchive(path)
if err != nil {
t.Fatalf("extract txt file: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
if files[0].Path != "sample.txt" {
t.Fatalf("expected sample.txt, got %q", files[0].Path)
}
if string(files[0].Content) != want {
t.Fatalf("content mismatch")
}
}
func TestExtractArchiveFromReaderTXT_TruncatedWhenTooLarge(t *testing.T) {
large := bytes.Repeat([]byte("a"), maxSingleFileSize+1024)
files, err := ExtractArchiveFromReader(bytes.NewReader(large), "huge.log")
if err != nil {
t.Fatalf("extract huge txt from reader: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file, got %d", len(files))
}
f := files[0]
if !f.Truncated {
t.Fatalf("expected file to be marked as truncated")
}
if got := len(f.Content); got != maxSingleFileSize {
t.Fatalf("expected truncated size %d, got %d", maxSingleFileSize, got)
}
if f.TruncatedMessage == "" {
t.Fatalf("expected truncation message")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.0.0"
const parserVersion = "1.1.0"
func init() {
parser.Register(&Parser{})
@@ -125,8 +125,9 @@ 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 {
// 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 +145,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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -8,6 +8,8 @@ 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)
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"

View File

@@ -0,0 +1,46 @@
# Xigmanas Parser
Parser for Xigmanas (FreeBSD-based NAS) system logs.
## Supported Files
- `xigmanas` - Main system log file with configuration and status information
- `dmesg` - Kernel messages and hardware initialization information
- SMART data from disk monitoring
## Features
This parser extracts the following information from Xigmanas logs:
### System Information
- Firmware version
- System uptime
- CPU model and specifications
- Memory configuration
- Hardware platform information
### Storage Information
- Disk models and serial numbers
- Disk capacity and health status
- SMART temperature readings
### Hardware Configuration
- CPU information
- Memory modules
- Storage devices
## Detection Logic
The parser detects Xigmanas format by looking for:
- Files with "xigmanas", "system", or "dmesg" in their names
- Content containing "XigmaNAS" or "FreeBSD" strings
- SMART-related information in log content
## Example Output
The parser populates the following fields in AnalysisResult:
- `Hardware.Firmware` - Firmware versions
- `Hardware.CPUs` - CPU information
- `Hardware.Memory` - Memory configuration
- `Hardware.Storage` - Storage devices with SMART data
- `Sensors` - Temperature readings from SMART data

View File

@@ -0,0 +1,525 @@
// Package xigmanas provides parser for XigmaNAS diagnostic dumps.
package xigmanas
import (
"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 = "2.1.0"
func init() {
parser.Register(&Parser{})
}
// Parser implements VendorParser for XigmaNAS logs.
type Parser struct{}
func (p *Parser) Name() string { return "XigmaNAS Parser" }
func (p *Parser) Vendor() string { return "xigmanas" }
func (p *Parser) Version() string {
return parserVersion
}
// Detect checks if files contain typical XigmaNAS markers.
func (p *Parser) Detect(files []parser.ExtractedFile) int {
confidence := 0
for _, f := range files {
path := strings.ToLower(f.Path)
content := strings.ToLower(string(f.Content))
if strings.Contains(path, "xigmanas") || strings.HasSuffix(path, "dmesg") {
confidence += 20
}
if strings.Contains(content, `loader_brand="xigmanas"`) {
confidence += 70
}
if strings.Contains(content, "xigmanas kernel build") {
confidence += 35
}
if strings.Contains(content, "system uptime:") && strings.Contains(content, "routing tables:") {
confidence += 20
}
if strings.Contains(content, "s.m.a.r.t. [/dev/") {
confidence += 10
}
if confidence >= 100 {
return 100
}
}
if confidence > 100 {
return 100
}
return confidence
}
// Parse parses XigmaNAS logs 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),
},
}
content := joinFileContents(files)
if strings.TrimSpace(content) == "" {
return result, nil
}
parseSystemInfo(content, result)
parseCPU(content, result)
parseMemory(content, result)
parseUptime(content, result)
parseZFSState(content, result)
parseStorageAndSMART(content, result)
parseJournalLogSections(content, result)
return result, nil
}
func joinFileContents(files []parser.ExtractedFile) string {
var b strings.Builder
for _, f := range files {
b.Write(f.Content)
b.WriteString("\n")
}
return b.String()
}
func parseSystemInfo(content string, result *models.AnalysisResult) {
if m := regexp.MustCompile(`(?m)^Version:\s*\n-+\s*\n([^\n]+)`).FindStringSubmatch(content); len(m) == 2 {
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: "XigmaNAS",
Version: strings.TrimSpace(m[1]),
})
}
if m := regexp.MustCompile(`(?m)^smbios\.bios\.version="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
DeviceName: "System BIOS",
Version: strings.TrimSpace(m[1]),
})
}
board := models.BoardInfo{}
if m := regexp.MustCompile(`(?m)^smbios\.system\.maker="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
board.Manufacturer = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^smbios\.system\.product="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
board.ProductName = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^smbios\.system\.serial="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
board.SerialNumber = strings.TrimSpace(m[1])
}
if m := regexp.MustCompile(`(?m)^smbios\.system\.uuid="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
board.UUID = strings.TrimSpace(m[1])
}
result.Hardware.BoardInfo = board
}
func parseCPU(content string, result *models.AnalysisResult) {
var cores, threads int
if m := regexp.MustCompile(`(?m)^FreeBSD/SMP:\s+\d+\s+package\(s\)\s+x\s+(\d+)\s+core\(s\)`).FindStringSubmatch(content); len(m) == 2 {
cores = parseInt(m[1])
threads = cores
}
seen := map[string]struct{}{}
cpuRe := regexp.MustCompile(`(?m)^CPU:\s+(.+?)\s+\(([\d.]+)-MHz`)
for _, m := range cpuRe.FindAllStringSubmatch(content, -1) {
model := strings.TrimSpace(m[1])
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
result.Hardware.CPUs = append(result.Hardware.CPUs, models.CPU{
Socket: len(result.Hardware.CPUs),
Model: model,
Cores: cores,
Threads: threads,
FrequencyMHz: int(parseFloat(m[2])),
})
}
}
func parseMemory(content string, result *models.AnalysisResult) {
if m := regexp.MustCompile(`(?m)^real memory\s*=\s*\d+\s+\((\d+)\s+MB\)`).FindStringSubmatch(content); len(m) == 2 {
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
Slot: "system",
Present: true,
SizeMB: parseInt(m[1]),
Type: "DRAM",
Status: "ok",
})
return
}
// Fallback for logs that only have active/inactive breakdown.
if m := regexp.MustCompile(`(?m)^Mem:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
totalMB := 0
tokenRe := regexp.MustCompile(`(\d+)M`)
for _, t := range tokenRe.FindAllStringSubmatch(m[1], -1) {
totalMB += parseInt(t[1])
}
if totalMB > 0 {
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
Slot: "system",
Present: true,
SizeMB: totalMB,
Type: "DRAM",
Status: "estimated",
})
}
}
}
func parseUptime(content string, result *models.AnalysisResult) {
upRe := regexp.MustCompile(`(?m)^(\d+:\d+(?:AM|PM))\s+up\s+(.+?),\s+(\d+)\s+users?,\s+load averages?:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)$`)
m := upRe.FindStringSubmatch(content)
if len(m) != 7 {
return
}
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "System",
EventType: "Uptime",
Severity: models.SeverityInfo,
Description: "System uptime and load averages parsed",
RawData: "time=" + m[1] + "; uptime=" + m[2] + "; users=" + m[3] + "; load=" + m[4] + "," + m[5] + "," + m[6],
})
}
func parseZFSState(content string, result *models.AnalysisResult) {
m := regexp.MustCompile(`(?m)^state:\s+([A-Z]+)$`).FindStringSubmatch(content)
if len(m) != 2 {
return
}
state := m[1]
severity := models.SeverityInfo
if state != "ONLINE" {
severity = models.SeverityWarning
}
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "ZFS",
EventType: "Pool State",
Severity: severity,
Description: "ZFS pool state: " + state,
RawData: state,
})
}
func parseStorageAndSMART(content string, result *models.AnalysisResult) {
type smartInfo struct {
model string
serial string
firmware string
health string
tempC int
capacityB int64
}
storageBySlot := make(map[string]*models.Storage)
scsiRe := regexp.MustCompile(`(?m)^<([^>]+)>\s+at\s+scbus\d+\s+target\s+\d+\s+lun\s+\d+\s+\(([^,]+),([^)]+)\)$`)
for _, m := range scsiRe.FindAllStringSubmatch(content, -1) {
slot := strings.TrimSpace(m[3])
model, fw := splitModelAndFirmware(strings.TrimSpace(m[1]))
entry := &models.Storage{
Slot: slot,
Type: guessStorageType(slot),
Model: model,
Firmware: fw,
Present: true,
Interface: "SCSI/SATA",
}
storageBySlot[slot] = entry
}
smartBySlot := make(map[string]smartInfo)
sectionRe := regexp.MustCompile(`(?m)^S\.M\.A\.R\.T\.\s+\[(/dev/[^\]]+)\]:\s*\n-+\n`)
sections := sectionRe.FindAllStringSubmatchIndex(content, -1)
for i, sec := range sections {
// sec indexes:
// [0]=full start, [1]=full end, [2]=capture 1 start, [3]=capture 1 end
if len(sec) < 4 {
continue
}
slot := strings.TrimPrefix(strings.TrimSpace(content[sec[2]:sec[3]]), "/dev/")
bodyStart := sec[1]
bodyEnd := len(content)
if i+1 < len(sections) {
bodyEnd = sections[i+1][0]
}
body := content[bodyStart:bodyEnd]
info := smartInfo{
model: findFirst(body, `(?m)^Device Model:\s+(.+)$`),
serial: findFirst(body, `(?m)^Serial Number:\s+(.+)$`),
firmware: findFirst(body, `(?m)^Firmware Version:\s+(.+)$`),
health: findFirst(body, `(?m)^SMART overall-health self-assessment test result:\s+(.+)$`),
}
info.capacityB = parseCapacityBytes(findFirst(body, `(?m)^User Capacity:\s+([\d,]+)\s+bytes`))
if t := findFirst(body, `(?m)^\s*194\s+Temperature_Celsius.*?-\s+(\d+)(?:\s|\()`); t != "" {
info.tempC = parseInt(t)
}
smartBySlot[slot] = info
if info.tempC > 0 {
status := "ok"
if info.health != "" && !strings.EqualFold(info.health, "PASSED") {
status = "warning"
}
result.Sensors = append(result.Sensors, models.SensorReading{
Name: "disk_temp_" + slot,
Type: "temperature",
Value: float64(info.tempC),
Unit: "C",
Status: status,
RawValue: strconv.Itoa(info.tempC),
})
}
if info.health != "" && !strings.EqualFold(info.health, "PASSED") {
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "SMART",
EventType: "Disk Health",
Severity: models.SeverityWarning,
Description: "SMART health is not PASSED for " + slot,
RawData: info.health,
})
}
}
// Merge SMART data into storage entries and add missing entries.
for slot, info := range smartBySlot {
s := storageBySlot[slot]
if s == nil {
s = &models.Storage{
Slot: slot,
Type: guessStorageType(slot),
Present: true,
Interface: "SATA",
}
storageBySlot[slot] = s
}
if s.Model == "" && info.model != "" {
s.Model = info.model
}
if info.serial != "" {
s.SerialNumber = info.serial
}
if s.Firmware == "" && info.firmware != "" {
s.Firmware = info.firmware
}
if info.capacityB > 0 {
s.SizeGB = int(info.capacityB / 1_000_000_000)
}
}
for _, s := range storageBySlot {
result.Hardware.Storage = append(result.Hardware.Storage, *s)
}
}
func parseJournalLogSections(content string, result *models.AnalysisResult) {
sections := []struct {
heading string
eventType string
source string
}{
{heading: "Last 275 System log entries:", eventType: "System Log", source: "system.log"},
{heading: "Last 275 SMARTD log entries:", eventType: "SMARTD Log", source: "smartd.log"},
{heading: "Last 275 Daemon log entries:", eventType: "Daemon Log", source: "daemon.log"},
}
for _, sec := range sections {
body := extractLogSection(content, sec.heading)
if body == "" {
continue
}
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
msg := extractSyslogMessage(line)
if msg == "" {
msg = line
}
result.Events = append(result.Events, models.Event{
Timestamp: parseEventTimestamp(line),
Source: sec.source,
EventType: sec.eventType,
Severity: classifyEventSeverity(line),
Description: msg,
RawData: line,
})
}
}
}
func extractLogSection(content, heading string) string {
start := strings.Index(content, heading)
if start == -1 {
return ""
}
tail := content[start+len(heading):]
lines := strings.Split(tail, "\n")
i := 0
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
i++
}
if i < len(lines) && isDashLine(lines[i]) {
i++
}
out := make([]string, 0, 64)
for ; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "Last 275 ") && strings.HasSuffix(trimmed, " log entries:") {
break
}
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func isDashLine(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return false
}
for _, r := range s {
if r != '-' {
return false
}
}
return true
}
func parseEventTimestamp(line string) time.Time {
isoRe := regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}\b`)
if iso := isoRe.FindString(line); iso != "" {
if ts, err := time.Parse(time.RFC3339Nano, iso); err == nil {
return ts
}
}
prefixRe := regexp.MustCompile(`^[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}`)
if prefix := prefixRe.FindString(line); prefix != "" {
year := time.Now().Year()
if ts, err := time.Parse("Jan 2 15:04:05 2006", prefix+" "+strconv.Itoa(year)); err == nil {
return ts
}
}
return time.Now()
}
func classifyEventSeverity(line string) models.Severity {
lower := strings.ToLower(line)
switch {
case strings.Contains(lower, "panic"), strings.Contains(lower, "fatal"), strings.Contains(lower, "critical"):
return models.SeverityCritical
case strings.Contains(lower, "warning"),
strings.Contains(lower, "error"),
strings.Contains(lower, "failed"),
strings.Contains(lower, "failure"),
strings.Contains(lower, "login failure"),
strings.Contains(lower, "limiting open port"):
return models.SeverityWarning
default:
return models.SeverityInfo
}
}
func extractSyslogMessage(line string) string {
if idx := strings.Index(line, ": "); idx != -1 && idx+2 < len(line) {
return strings.TrimSpace(line[idx+2:])
}
// RFC5424-like segment in XigmaNAS dumps: "... <host> <proc> <pid> - - <message>"
fields := strings.Fields(line)
if len(fields) > 10 {
return strings.TrimSpace(strings.Join(fields[10:], " "))
}
return strings.TrimSpace(line)
}
func splitModelAndFirmware(raw string) (string, string) {
fields := strings.Fields(raw)
if len(fields) < 2 {
return raw, ""
}
last := fields[len(fields)-1]
// Firmware token is usually compact (e.g. GKAOAB0A, 1.00).
if regexp.MustCompile(`^[A-Za-z0-9._-]{2,12}$`).MatchString(last) {
return strings.TrimSpace(strings.Join(fields[:len(fields)-1], " ")), last
}
return raw, ""
}
func guessStorageType(slot string) string {
switch {
case strings.HasPrefix(slot, "cd"):
return "optical"
case strings.HasPrefix(slot, "da"), strings.HasPrefix(slot, "ada"):
return "hdd"
default:
return "unknown"
}
}
func findFirst(content, expr string) string {
m := regexp.MustCompile(expr).FindStringSubmatch(content)
if len(m) != 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func parseCapacityBytes(s string) int64 {
clean := strings.ReplaceAll(strings.TrimSpace(s), ",", "")
if clean == "" {
return 0
}
v, err := strconv.ParseInt(clean, 10, 64)
if err != nil {
return 0
}
return v
}
func parseInt(s string) int {
v, _ := strconv.Atoi(strings.TrimSpace(s))
return v
}
func parseFloat(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}

View File

@@ -0,0 +1,116 @@
package xigmanas
import (
"os"
"path/filepath"
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestParserDetect(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "xigmanas",
Content: []byte(`Version:
--------
14.3.0.5
loader_brand="XigmaNAS"`),
},
}
if got := p.Detect(files); got < 70 {
t.Fatalf("expected high confidence, got %d", got)
}
files2 := []parser.ExtractedFile{
{
Path: "random_file.txt",
Content: []byte("Some random content"),
},
}
if got := p.Detect(files2); got != 0 {
t.Fatalf("expected zero confidence, got %d", got)
}
}
func TestParserParseExample(t *testing.T) {
p := &Parser{}
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
raw, err := os.ReadFile(examplePath)
if err != nil {
t.Fatalf("read example file: %v", err)
}
files := []parser.ExtractedFile{
{Path: "xigmanas", Content: raw},
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if result == nil || result.Hardware == nil {
t.Fatal("expected non-nil result with hardware")
}
if len(result.Hardware.Firmware) == 0 {
t.Fatal("expected firmware data")
}
foundXigmaVersion := false
for _, fw := range result.Hardware.Firmware {
if fw.DeviceName == "XigmaNAS" && fw.Version == "14.3.0.5" {
foundXigmaVersion = true
}
}
if !foundXigmaVersion {
t.Fatalf("expected XigmaNAS firmware version 14.3.0.5, got %+v", result.Hardware.Firmware)
}
if result.Hardware.BoardInfo.Manufacturer != "HP" {
t.Fatalf("expected board manufacturer HP, got %q", result.Hardware.BoardInfo.Manufacturer)
}
if len(result.Hardware.CPUs) == 0 {
t.Fatal("expected at least one CPU")
}
if !strings.Contains(strings.ToLower(result.Hardware.CPUs[0].Model), "athlon") {
t.Fatalf("expected CPU model to contain athlon, got %q", result.Hardware.CPUs[0].Model)
}
if len(result.Hardware.Storage) < 4 {
t.Fatalf("expected at least 4 storage devices, got %d", len(result.Hardware.Storage))
}
if len(result.Sensors) == 0 {
t.Fatal("expected SMART temperature sensors")
}
if len(result.Events) == 0 {
t.Fatal("expected events from uptime/zfs sections")
}
var hasSystemLog, hasSmartdLog, hasDaemonLog, hasLoginFailure bool
for _, ev := range result.Events {
if ev.EventType == "System Log" {
hasSystemLog = true
}
if ev.EventType == "SMARTD Log" {
hasSmartdLog = true
}
if ev.EventType == "Daemon Log" {
hasDaemonLog = true
}
if strings.Contains(strings.ToLower(ev.Description), "login failure") {
hasLoginFailure = true
}
}
if !hasSystemLog || !hasSmartdLog || !hasDaemonLog {
t.Fatalf("expected events from System/SMARTD/Daemon sections, got system=%v smartd=%v daemon=%v", hasSystemLog, hasSmartdLog, hasDaemonLog)
}
if !hasLoginFailure {
t.Fatal("expected to parse login failure event from system log section")
}
}

View File

@@ -312,7 +312,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
// From FRU
for _, fru := range result.FRU {
if fru.SerialNumber == "" {
if !hasUsableSerial(fru.SerialNumber) {
continue
}
name := fru.ProductName
@@ -321,7 +321,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 +331,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 +343,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 +366,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,27 +375,49 @@ 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",
})
}
// PCIe devices
for _, pcie := range result.Hardware.PCIeDevices {
if !hasUsableSerial(pcie.SerialNumber) {
continue
}
component := pcie.DeviceClass
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",
@@ -408,43 +426,47 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
// 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,
SerialNumber: strings.TrimSpace(nic.SerialNumber),
Category: "Network",
})
}
// 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 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 {
@@ -508,6 +530,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 +625,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 +966,7 @@ func exportFilename(result *models.AnalysisResult, ext string) string {
sn = sanitizeFilenamePart(sn)
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
if ext == "" {
ext = "txt"
ext = "json"
}
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
}

View File

@@ -0,0 +1,23 @@
package server
import "testing"
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)
}
}

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ import (
func newFlowTestServer() (*Server, *httptest.Server) {
s := &Server{
jobManager: NewJobManager(),
jobManager: NewJobManager(),
collectors: testCollectorRegistry(),
}
mux := http.NewServeMux()
@@ -110,6 +110,61 @@ func TestUploadArchiveRegressionAndSourceMetadata(t *testing.T) {
}
}
func TestUploadTXTFile(t *testing.T) {
_, ts := newFlowTestServer()
defer ts.Close()
txt := `Version:
--------
14.3.0.5
loader_brand="XigmaNAS"
`
reqBody := &bytes.Buffer{}
writer := multipart.NewWriter(reqBody)
part, err := writer.CreateFormFile("archive", "xigmanas.txt")
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte(txt)); err != nil {
t.Fatalf("write txt body: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
uploadReq, err := http.NewRequest(http.MethodPost, ts.URL+"/api/upload", reqBody)
if err != nil {
t.Fatalf("build upload request: %v", err)
}
uploadReq.Header.Set("Content-Type", writer.FormDataContentType())
uploadResp, err := http.DefaultClient.Do(uploadReq)
if err != nil {
t.Fatalf("upload request failed: %v", err)
}
defer uploadResp.Body.Close()
if uploadResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 from /api/upload, got %d", uploadResp.StatusCode)
}
var uploadPayload map[string]interface{}
if err := json.NewDecoder(uploadResp.Body).Decode(&uploadPayload); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if uploadPayload["status"] != "ok" {
t.Fatalf("expected upload status ok, got %v", uploadPayload["status"])
}
if uploadPayload["filename"] != "xigmanas.txt" {
t.Fatalf("expected filename xigmanas.txt, got %v", uploadPayload["filename"])
}
if uploadPayload["vendor"] != "XigmaNAS Parser" {
t.Fatalf("expected vendor XigmaNAS Parser, got %v", uploadPayload["vendor"])
}
}
func TestCollectSmokeErrorFormat(t *testing.T) {
_, ts := newFlowTestServer()
defer ts.Close()

BIN
logpile

Binary file not shown.

View File

@@ -1,35 +0,0 @@
//go:build ignore
// +build ignore
package main
import (
"fmt"
"log"
"git.mchus.pro/mchus/logpile/internal/parser"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors"
)
func main() {
p := parser.NewBMCParser()
fmt.Println("Testing archive parsing...")
if err := p.ParseArchive("example/A514359X5A07900_logs-20260122-074208.tar"); err != nil {
log.Fatalf("ERROR: %v", err)
}
fmt.Println("✓ Archive parsed successfully!")
fmt.Printf("✓ Detected vendor: %s\n", p.DetectedVendor())
result := p.Result()
fmt.Printf("✓ GPUs found: %d\n", len(result.Hardware.GPUs))
fmt.Printf("✓ Events found: %d\n", len(result.Events))
fmt.Printf("✓ PCIe Devices found: %d\n", len(result.Hardware.PCIeDevices))
fmt.Println("\nBoard Info:")
fmt.Printf(" Manufacturer: %s\n", result.Hardware.BoardInfo.Manufacturer)
fmt.Printf(" Product Name: %s\n", result.Hardware.BoardInfo.ProductName)
fmt.Printf(" Serial Number: %s\n", result.Hardware.BoardInfo.SerialNumber)
fmt.Printf(" Part Number: %s\n", result.Hardware.BoardInfo.PartNumber)
}

View File

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

View File

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

View File

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

102
scripts/release.sh Executable file
View File

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

Binary file not shown.

View File

@@ -1,99 +0,0 @@
//go:build ignore
// +build ignore
package main
import (
"fmt"
"log"
"git.mchus.pro/mchus/logpile/internal/parser"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors"
)
func main() {
p := parser.NewBMCParser()
fmt.Println("Testing NVIDIA Bug Report parser (full)...")
if err := p.ParseArchive("/Users/mchusavitin/Downloads/nvidia-bug-report-2KD501412.log.gz"); err != nil {
log.Fatalf("ERROR: %v", err)
}
fmt.Println("✓ Archive parsed successfully!")
fmt.Printf("✓ Detected vendor: %s\n", p.DetectedVendor())
result := p.Result()
fmt.Printf("✓ CPUs: %d\n", len(result.Hardware.CPUs))
fmt.Printf("✓ Memory: %d modules\n", len(result.Hardware.Memory))
fmt.Printf("✓ Power Supplies: %d\n", len(result.Hardware.PowerSupply))
fmt.Printf("✓ GPUs: %d\n", len(result.Hardware.GPUs))
fmt.Printf("✓ Network Adapters: %d\n", len(result.Hardware.NetworkAdapters))
fmt.Println("\nSystem Information:")
if result.Hardware.BoardInfo.SerialNumber != "" {
fmt.Printf(" Serial Number: %s\n", result.Hardware.BoardInfo.SerialNumber)
}
if result.Hardware.BoardInfo.UUID != "" {
fmt.Printf(" UUID: %s\n", result.Hardware.BoardInfo.UUID)
}
if result.Hardware.BoardInfo.Manufacturer != "" {
fmt.Printf(" Manufacturer: %s\n", result.Hardware.BoardInfo.Manufacturer)
}
if result.Hardware.BoardInfo.ProductName != "" {
fmt.Printf(" Product: %s\n", result.Hardware.BoardInfo.ProductName)
}
if result.Hardware.BoardInfo.Version != "" {
fmt.Printf(" Version: %s\n", result.Hardware.BoardInfo.Version)
}
fmt.Println("\nCPU Information:")
for _, cpu := range result.Hardware.CPUs {
fmt.Printf(" Socket %d: %s\n", cpu.Socket, cpu.Model)
fmt.Printf(" S/N: %s, Cores: %d, Threads: %d\n", cpu.SerialNumber, cpu.Cores, cpu.Threads)
}
fmt.Println("\nPower Supplies:")
for _, psu := range result.Hardware.PowerSupply {
fmt.Printf(" %s: %s (%s)\n", psu.Slot, psu.Model, psu.Vendor)
fmt.Printf(" S/N: %s\n", psu.SerialNumber)
fmt.Printf(" Power: %d W, Revision: %s\n", psu.WattageW, psu.Firmware)
fmt.Printf(" Status: %s\n", psu.Status)
}
totalMemGB := 0
for _, mem := range result.Hardware.Memory {
totalMemGB += mem.SizeMB / 1024
}
fmt.Printf("\nMemory: %d modules, %d GB total\n", len(result.Hardware.Memory), totalMemGB)
fmt.Printf("\nNetwork Adapters: %d devices\n", len(result.Hardware.NetworkAdapters))
for _, nic := range result.Hardware.NetworkAdapters {
fmt.Printf(" %s: %s\n", nic.Location, nic.Model)
if nic.Slot != "" {
fmt.Printf(" Slot: %s\n", nic.Slot)
}
if nic.PartNumber != "" {
fmt.Printf(" P/N: %s\n", nic.PartNumber)
}
if nic.SerialNumber != "" {
fmt.Printf(" S/N: %s\n", nic.SerialNumber)
}
if nic.PortCount > 0 {
fmt.Printf(" Ports: %d x %s\n", nic.PortCount, nic.PortType)
}
}
fmt.Printf("\nGPUs: %d devices\n", len(result.Hardware.GPUs))
for _, gpu := range result.Hardware.GPUs {
fmt.Printf(" %s: %s\n", gpu.BDF, gpu.Model)
if gpu.UUID != "" {
fmt.Printf(" UUID: %s\n", gpu.UUID)
}
if gpu.VideoBIOS != "" {
fmt.Printf(" Video BIOS: %s\n", gpu.VideoBIOS)
}
if gpu.IRQ > 0 {
fmt.Printf(" IRQ: %d\n", gpu.IRQ)
}
}
}

View File

@@ -903,9 +903,11 @@ 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>Производитель</th><th>Vendor:Device ID</th><th>PCIe Link</th></tr></thead><tbody>';
(config.pcie_devices || []).forEach(p => {
const pcieLink = formatPCIeLink(
p.link_width,
p.link_speed,
@@ -916,11 +918,30 @@ function renderConfig(data) {
<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>
</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>GPU</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>
</tr>`;
});
html += '</tbody></table>';
} else {
html += '<p class="no-data">Нет данных о PCIe устройствах</p>';
@@ -1079,6 +1100,7 @@ function renderSerials(serials) {
'CPU': 'Процессор',
'Memory': 'Память',
'Storage': 'Накопитель',
'GPU': 'Видеокарта',
'PCIe': 'PCIe',
'Network': 'Сеть',
'PSU': 'БП',

View File

@@ -21,10 +21,10 @@
<div id="archive-source-content">
<div class="upload-area" id="drop-zone">
<p>Перетащите архив или JSON snapshot сюда</p>
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,.json,.tar,.tar.gz,.tgz,.zip" hidden>
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.json,.tar,.tar.gz,.tgz,.zip,.txt,.log" hidden>
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
<p class="hint">Поддерживаемые форматы: tar.gz, zip, json</p>
<p class="hint">Поддерживаемые форматы: tar.gz, zip, json, txt, log</p>
</div>
<div id="upload-status"></div>
<div id="parsers-info" class="parsers-info"></div>
@@ -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>