Update parser and project changes
This commit is contained in:
@@ -49,7 +49,6 @@ Registry: `internal/collector/registry.go`
|
|||||||
Endpoints:
|
Endpoints:
|
||||||
- `/api/export/csv`
|
- `/api/export/csv`
|
||||||
- `/api/export/json`
|
- `/api/export/json`
|
||||||
- `/api/export/txt`
|
|
||||||
- `/api/export/reanimator`
|
- `/api/export/reanimator`
|
||||||
|
|
||||||
Filename pattern for all exports:
|
Filename pattern for all exports:
|
||||||
@@ -57,7 +56,6 @@ Filename pattern for all exports:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
|
- 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`):
|
- **Reanimator export** (`/api/export/reanimator`):
|
||||||
- Exports hardware data in Reanimator format for integration with asset tracking systems.
|
- Exports hardware data in Reanimator format for integration with asset tracking systems.
|
||||||
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
|
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ LOGPile — standalone Go-приложение для анализа диагн
|
|||||||
- нормализованные данные (CPU/RAM/Storage/GPU/PSU/NIC/PCIe/Firmware),
|
- нормализованные данные (CPU/RAM/Storage/GPU/PSU/NIC/PCIe/Firmware),
|
||||||
- сырой `redfish_tree` для будущего анализа.
|
- сырой `redfish_tree` для будущего анализа.
|
||||||
- Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы.
|
- Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы.
|
||||||
- Экспорт в CSV / JSON / TXT.
|
- Экспорт в CSV / JSON.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
@@ -98,7 +98,6 @@ POST /api/collect
|
|||||||
|
|
||||||
- `GET /api/export/csv` — серийные номера
|
- `GET /api/export/csv` — серийные номера
|
||||||
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
|
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
|
||||||
- `GET /api/export/txt` — табличный отчёт по разделам UI
|
|
||||||
|
|
||||||
Имена экспортируемых файлов:
|
Имена экспортируемых файлов:
|
||||||
|
|
||||||
@@ -123,7 +122,6 @@ GET /api/serials
|
|||||||
GET /api/firmware
|
GET /api/firmware
|
||||||
GET /api/export/csv
|
GET /api/export/csv
|
||||||
GET /api/export/json
|
GET /api/export/json
|
||||||
GET /api/export/txt
|
|
||||||
DELETE /api/clear
|
DELETE /api/clear
|
||||||
POST /api/shutdown
|
POST /api/shutdown
|
||||||
```
|
```
|
||||||
@@ -141,7 +139,7 @@ cmd/logpile/main.go # entrypoint
|
|||||||
internal/collector/ # live collectors (redfish, ipmi mock)
|
internal/collector/ # live collectors (redfish, ipmi mock)
|
||||||
internal/parser/ # archive parsers
|
internal/parser/ # archive parsers
|
||||||
internal/server/ # HTTP handlers
|
internal/server/ # HTTP handlers
|
||||||
internal/exporter/ # CSV/JSON/TXT export
|
internal/exporter/ # CSV/JSON export
|
||||||
internal/models/ # data contracts
|
internal/models/ # data contracts
|
||||||
web/ # embedded templates/static
|
web/ # embedded templates/static
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ go test ./internal/exporter/... -cover
|
|||||||
|
|
||||||
## Совместимость
|
## Совместимость
|
||||||
|
|
||||||
- ✓ Обратная совместимость: существующие экспорты (JSON/CSV/TXT) не затронуты
|
- ✓ Обратная совместимость: существующие экспорты (JSON/CSV) не затронуты
|
||||||
- ✓ Формат данных: `AnalysisResult` не изменен
|
- ✓ Формат данных: `AnalysisResult` не изменен
|
||||||
- ✓ API контракты: новый эндпоинт не влияет на существующие
|
- ✓ API контракты: новый эндпоинт не влияет на существующие
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- Upload flow now accepts JSON snapshots in addition to archives, enabling offline re-open of live Redfish collections.
|
||||||
- Export UX improved:
|
- Export UX improved:
|
||||||
- Export filenames now follow `YYYY-MM-DD (SERVER MODEL) - SERVER SN`.
|
- 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.
|
- 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).
|
- Redfish progress logs are more informative (snapshot stage and active top-level roots).
|
||||||
- Build/distribution hardening:
|
- Build/distribution hardening:
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ package exporter
|
|||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
@@ -114,221 +112,3 @@ func (e *Exporter) ExportJSON(w io.Writer) error {
|
|||||||
encoder.SetIndent("", " ")
|
encoder.SetIndent("", " ")
|
||||||
return encoder.Encode(e.result)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import (
|
|||||||
|
|
||||||
const maxSingleFileSize = 10 * 1024 * 1024
|
const maxSingleFileSize = 10 * 1024 * 1024
|
||||||
const maxZipArchiveSize = 50 * 1024 * 1024
|
const maxZipArchiveSize = 50 * 1024 * 1024
|
||||||
|
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
||||||
|
|
||||||
// ExtractedFile represents a file extracted from archive
|
// ExtractedFile represents a file extracted from archive
|
||||||
type ExtractedFile struct {
|
type ExtractedFile struct {
|
||||||
Path string
|
Path string
|
||||||
Content []byte
|
Content []byte
|
||||||
|
Truncated bool
|
||||||
|
TruncatedMessage string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractArchive extracts tar.gz or zip archive and returns file contents
|
// ExtractArchive extracts tar.gz or zip archive and returns file contents
|
||||||
@@ -121,12 +124,16 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
|||||||
}
|
}
|
||||||
defer gzr.Close()
|
defer gzr.Close()
|
||||||
|
|
||||||
// Read all decompressed content into buffer
|
// Read decompressed content with a hard cap.
|
||||||
// Limit to 50MB for plain gzip files, 10MB per file for tar.gz
|
// When the payload exceeds the cap, keep the first chunk and mark it as truncated.
|
||||||
decompressed, err := io.ReadAll(io.LimitReader(gzr, 50*1024*1024))
|
decompressed, err := io.ReadAll(io.LimitReader(gzr, maxGzipDecompressedSize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read gzip content: %w", err)
|
return nil, fmt.Errorf("read gzip content: %w", err)
|
||||||
}
|
}
|
||||||
|
gzipTruncated := len(decompressed) > maxGzipDecompressedSize
|
||||||
|
if gzipTruncated {
|
||||||
|
decompressed = decompressed[:maxGzipDecompressedSize]
|
||||||
|
}
|
||||||
|
|
||||||
// Try to read as tar archive
|
// Try to read as tar archive
|
||||||
tr := tar.NewReader(bytes.NewReader(decompressed))
|
tr := tar.NewReader(bytes.NewReader(decompressed))
|
||||||
@@ -142,12 +149,19 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
|
|||||||
baseName = gzr.Name
|
baseName = gzr.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ExtractedFile{
|
file := ExtractedFile{
|
||||||
{
|
|
||||||
Path: baseName,
|
Path: baseName,
|
||||||
Content: decompressed,
|
Content: decompressed,
|
||||||
},
|
}
|
||||||
}, nil
|
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)
|
return nil, fmt.Errorf("tar read: %w", err)
|
||||||
}
|
}
|
||||||
@@ -288,16 +302,24 @@ func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read file content: %w", err)
|
return nil, fmt.Errorf("read file content: %w", err)
|
||||||
}
|
}
|
||||||
if len(content) > maxSingleFileSize {
|
truncated := len(content) > maxSingleFileSize
|
||||||
return nil, fmt.Errorf("file too large: max %d bytes", maxSingleFileSize)
|
if truncated {
|
||||||
|
content = content[:maxSingleFileSize]
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ExtractedFile{
|
file := ExtractedFile{
|
||||||
{
|
|
||||||
Path: filepath.Base(filename),
|
Path: filepath.Base(filename),
|
||||||
Content: content,
|
Content: content,
|
||||||
},
|
}
|
||||||
}, nil
|
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
|
// FindFileByPattern finds files matching pattern in extracted files
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package parser
|
package parser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -46,3 +47,25 @@ func TestExtractArchiveTXT(t *testing.T) {
|
|||||||
t.Fatalf("content mismatch")
|
t.Fatalf("content mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractArchiveFromReaderTXT_TruncatedWhenTooLarge(t *testing.T) {
|
||||||
|
large := bytes.Repeat([]byte("a"), maxSingleFileSize+1024)
|
||||||
|
files, err := ExtractArchiveFromReader(bytes.NewReader(large), "huge.log")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract huge txt from reader: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("expected 1 file, got %d", len(files))
|
||||||
|
}
|
||||||
|
|
||||||
|
f := files[0]
|
||||||
|
if !f.Truncated {
|
||||||
|
t.Fatalf("expected file to be marked as truncated")
|
||||||
|
}
|
||||||
|
if got := len(f.Content); got != maxSingleFileSize {
|
||||||
|
t.Fatalf("expected truncated size %d, got %d", maxSingleFileSize, got)
|
||||||
|
}
|
||||||
|
if f.TruncatedMessage == "" {
|
||||||
|
t.Fatalf("expected truncation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package parser
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
@@ -62,11 +64,44 @@ func (p *BMCParser) parseFiles() error {
|
|||||||
|
|
||||||
// Preserve filename
|
// Preserve filename
|
||||||
result.Filename = p.result.Filename
|
result.Filename = p.result.Filename
|
||||||
|
|
||||||
|
appendExtractionWarnings(result, p.files)
|
||||||
p.result = result
|
p.result = result
|
||||||
|
|
||||||
return nil
|
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
|
// Result returns the analysis result
|
||||||
func (p *BMCParser) Result() *models.AnalysisResult {
|
func (p *BMCParser) Result() *models.AnalysisResult {
|
||||||
return p.result
|
return p.result
|
||||||
|
|||||||
34
internal/parser/parser_test.go
Normal file
34
internal/parser/parser_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendExtractionWarnings(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Events: make([]models.Event, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []ExtractedFile{
|
||||||
|
{Path: "ok.log", Content: []byte("ok")},
|
||||||
|
{Path: "big.log", Truncated: true, TruncatedMessage: "file exceeded size limit and was truncated"},
|
||||||
|
}
|
||||||
|
|
||||||
|
appendExtractionWarnings(result, files)
|
||||||
|
|
||||||
|
if len(result.Events) != 1 {
|
||||||
|
t.Fatalf("expected 1 warning event, got %d", len(result.Events))
|
||||||
|
}
|
||||||
|
ev := result.Events[0]
|
||||||
|
if ev.Severity != models.SeverityWarning {
|
||||||
|
t.Fatalf("expected warning severity, got %q", ev.Severity)
|
||||||
|
}
|
||||||
|
if ev.EventType != "Analysis Warning" {
|
||||||
|
t.Fatalf("unexpected event type: %q", ev.EventType)
|
||||||
|
}
|
||||||
|
if ev.RawData == "" {
|
||||||
|
t.Fatalf("expected warning details in RawData")
|
||||||
|
}
|
||||||
|
}
|
||||||
25
internal/parser/vendors/inspur/fru.go
vendored
25
internal/parser/vendors/inspur/fru.go
vendored
@@ -103,8 +103,9 @@ func extractBoardInfo(fruList []models.FRUInfo, hw *models.HardwareConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for the main board/chassis FRU entry
|
// Look for the main board/chassis FRU entry.
|
||||||
// Usually it's the first entry or one with "Builtin FRU" or containing board info
|
// 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 {
|
for _, fru := range fruList {
|
||||||
// Skip empty entries
|
// Skip empty entries
|
||||||
if fru.ProductName == "" && fru.SerialNumber == "" {
|
if fru.ProductName == "" && fru.SerialNumber == "" {
|
||||||
@@ -118,28 +119,26 @@ func extractBoardInfo(fruList []models.FRUInfo, hw *models.HardwareConfig) {
|
|||||||
strings.Contains(desc, "chassis") ||
|
strings.Contains(desc, "chassis") ||
|
||||||
strings.Contains(desc, "board")
|
strings.Contains(desc, "board")
|
||||||
|
|
||||||
// If we haven't set board info yet, or this is a main board entry
|
if fru.SerialNumber != "" && hw.BoardInfo.SerialNumber == "" {
|
||||||
if hw.BoardInfo.ProductName == "" || isMainBoard {
|
|
||||||
if fru.ProductName != "" {
|
|
||||||
hw.BoardInfo.ProductName = fru.ProductName
|
|
||||||
}
|
|
||||||
if fru.SerialNumber != "" {
|
|
||||||
hw.BoardInfo.SerialNumber = fru.SerialNumber
|
hw.BoardInfo.SerialNumber = fru.SerialNumber
|
||||||
}
|
}
|
||||||
if fru.Manufacturer != "" {
|
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
|
hw.BoardInfo.Manufacturer = fru.Manufacturer
|
||||||
}
|
}
|
||||||
if fru.PartNumber != "" {
|
if fru.PartNumber != "" && (hw.BoardInfo.PartNumber == "" || isMainBoard) {
|
||||||
hw.BoardInfo.PartNumber = fru.PartNumber
|
hw.BoardInfo.PartNumber = fru.PartNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found a main board entry, stop searching
|
// Main board entry with complete data is good enough to stop.
|
||||||
if isMainBoard && fru.ProductName != "" && fru.SerialNumber != "" {
|
if isMainBoard && hw.BoardInfo.ProductName != "" && hw.BoardInfo.SerialNumber != "" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// extractPlatformId extracts server model from ThermalConfig (PlatformId)
|
// extractPlatformId extracts server model from ThermalConfig (PlatformId)
|
||||||
func extractPlatformId(content []byte, hw *models.HardwareConfig) {
|
func extractPlatformId(content []byte, hw *models.HardwareConfig) {
|
||||||
|
|||||||
59
internal/parser/vendors/inspur/fru_test.go
vendored
Normal file
59
internal/parser/vendors/inspur/fru_test.go
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractBoardInfo_PreservesBuiltinSerial(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
fruList := []models.FRUInfo{
|
||||||
|
{
|
||||||
|
Description: "Builtin FRU Device (ID 0)",
|
||||||
|
SerialNumber: "21D634101",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "SCM_FRU (ID 8)",
|
||||||
|
SerialNumber: "CAR509K10613C10",
|
||||||
|
ProductName: "CA",
|
||||||
|
Manufacturer: "inagile",
|
||||||
|
PartNumber: "YZCA-02758-105",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
extractBoardInfo(fruList, hw)
|
||||||
|
|
||||||
|
if hw.BoardInfo.SerialNumber != "21D634101" {
|
||||||
|
t.Fatalf("expected board serial 21D634101, got %q", hw.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
if hw.BoardInfo.ProductName != "CA" {
|
||||||
|
t.Fatalf("expected product name CA, got %q", hw.BoardInfo.ProductName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractBoardInfo_DoesNotUsePSUVendorAsBoardManufacturer(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
fruList := []models.FRUInfo{
|
||||||
|
{
|
||||||
|
Description: "Builtin FRU Device (ID 0)",
|
||||||
|
SerialNumber: "2KD605238",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Description: "PSU0_FRU (ID 30)",
|
||||||
|
SerialNumber: "PMR315HS10F1A",
|
||||||
|
ProductName: "AP-CR3000F12BY",
|
||||||
|
Manufacturer: "APLUSPOWER",
|
||||||
|
PartNumber: "18XA1M43400C2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
extractBoardInfo(fruList, hw)
|
||||||
|
|
||||||
|
if hw.BoardInfo.SerialNumber != "2KD605238" {
|
||||||
|
t.Fatalf("expected board serial 2KD605238, got %q", hw.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
if hw.BoardInfo.Manufacturer != "" {
|
||||||
|
t.Fatalf("expected empty board manufacturer, got %q", hw.BoardInfo.Manufacturer)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
internal/parser/vendors/inspur/gpu_status.go
vendored
Normal file
56
internal/parser/vendors/inspur/gpu_status.go
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
faulty := make(map[int]bool)
|
||||||
|
for _, e := range events {
|
||||||
|
if !isGPUFaultEvent(e) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := reFaultGPU.FindAllStringSubmatch(e.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range hw.GPUs {
|
||||||
|
gpu := &hw.GPUs[i]
|
||||||
|
idx, ok := extractLogicalGPUIndex(gpu.Slot)
|
||||||
|
if ok && faulty[idx] {
|
||||||
|
gpu.Status = "Critical"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(gpu.Status) == "" {
|
||||||
|
gpu.Status = "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
120
internal/parser/vendors/inspur/hgx_gpu_status_test.go
vendored
Normal file
120
internal/parser/vendors/inspur/hgx_gpu_status_test.go
vendored
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
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"}
|
||||||
|
`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
internal/parser/vendors/inspur/hgx_hwinfo.go
vendored
Normal file
175
internal/parser/vendors/inspur/hgx_hwinfo.go
vendored
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hgxGPUAssemblyInfo struct {
|
||||||
|
Model string
|
||||||
|
Part string
|
||||||
|
Serial 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*"([^"]+)"`)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 extractLogicalGPUIndex(slot string) (int, bool) {
|
||||||
|
m := reSlotGPU.FindStringSubmatch(slot)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil || idx < 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return idx, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSXMIndex(logicalIdx int, bySXM map[int]hgxGPUAssemblyInfo) int {
|
||||||
|
if sxm, ok := hgxLogicalToSXM[logicalIdx]; ok {
|
||||||
|
if _, exists := bySXM[sxm]; exists {
|
||||||
|
return sxm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := logicalIdx + 1
|
||||||
|
if _, exists := bySXM[identity]; exists {
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldReplaceGPUModel(model string) bool {
|
||||||
|
trimmed := strings.TrimSpace(model)
|
||||||
|
if trimmed == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch strings.ToLower(trimmed) {
|
||||||
|
case "vga", "3d controller", "display controller", "unknown":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHGXGPUInventory(hw *models.HardwareConfig, bySXM map[int]hgxGPUAssemblyInfo) {
|
||||||
|
// Keep only logical HGX GPUs (#GPU0..#GPU7) and remove BMC VGA entries.
|
||||||
|
filtered := make([]models.GPU, 0, len(hw.GPUs))
|
||||||
|
present := make(map[int]bool)
|
||||||
|
for _, gpu := range hw.GPUs {
|
||||||
|
idx, ok := extractLogicalGPUIndex(gpu.Slot)
|
||||||
|
if !ok || idx < 0 || idx > 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
present[idx] = true
|
||||||
|
filtered = append(filtered, gpu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If some logical GPUs are missing in asset.json, add placeholders from HGX Redfish assembly.
|
||||||
|
for logicalIdx := 0; logicalIdx <= 7; logicalIdx++ {
|
||||||
|
if present[logicalIdx] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sxm := resolveSXMIndex(logicalIdx, bySXM)
|
||||||
|
info, ok := bySXM[sxm]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, models.GPU{
|
||||||
|
Slot: fmt.Sprintf("#GPU%d", logicalIdx),
|
||||||
|
Model: info.Model,
|
||||||
|
Manufacturer: "NVIDIA",
|
||||||
|
SerialNumber: info.Serial,
|
||||||
|
PartNumber: info.Part,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hw.GPUs = filtered
|
||||||
|
}
|
||||||
10
internal/parser/vendors/inspur/idl.go
vendored
10
internal/parser/vendors/inspur/idl.go
vendored
@@ -8,8 +8,10 @@ import (
|
|||||||
"git.mchus.pro/mchus/logpile/internal/models"
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseIDLLog parses the IDL (Inspur Diagnostic Log) file for BMC alarms
|
// ParseIDLLog parses IDL-style entries for BMC alarms.
|
||||||
// Format: |timestamp|component|type|severity|eventID|description|
|
// 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 {
|
func ParseIDLLog(content []byte) []models.Event {
|
||||||
var events []models.Event
|
var events []models.Event
|
||||||
|
|
||||||
@@ -21,10 +23,6 @@ func ParseIDLLog(content []byte) []models.Event {
|
|||||||
seenEvents := make(map[string]bool) // Deduplicate events
|
seenEvents := make(map[string]bool) // Deduplicate events
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if !strings.Contains(line, "CommerDiagnose") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
matches := re.FindStringSubmatch(line)
|
matches := re.FindStringSubmatch(line)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
30
internal/parser/vendors/inspur/parser.go
vendored
30
internal/parser/vendors/inspur/parser.go
vendored
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// parserVersion - version of this parser module
|
// parserVersion - version of this parser module
|
||||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||||
const parserVersion = "1.0.0"
|
const parserVersion = "1.1.0"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -125,8 +125,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
result.Events = append(result.Events, componentEvents...)
|
result.Events = append(result.Events, componentEvents...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse IDL log (BMC alarms/diagnose events)
|
// Parse IDL-like logs (plain and structured JSON logs with embedded IDL messages)
|
||||||
if f := parser.FindFileByName(files, "idl.log"); f != nil {
|
idlFiles := parser.FindFileByPattern(files, "/idl.log", "idl_json.log", "run_json.log")
|
||||||
|
for _, f := range idlFiles {
|
||||||
idlEvents := ParseIDLLog(f.Content)
|
idlEvents := ParseIDLLog(f.Content)
|
||||||
result.Events = append(result.Events, idlEvents...)
|
result.Events = append(result.Events, idlEvents...)
|
||||||
}
|
}
|
||||||
@@ -144,6 +145,29 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
result.Events = append(result.Events, events...)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6".
|
||||||
|
if result.Hardware != nil {
|
||||||
|
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
92
internal/parser/vendors/inspur/serial_fallback.go
vendored
Normal file
92
internal/parser/vendors/inspur/serial_fallback.go
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
hostnameJSONRegex = regexp.MustCompile(`"_HOSTNAME"\s*:\s*"([^"]+)"`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func inferBoardSerialFromFallbackLogs(files []parser.ExtractedFile) string {
|
||||||
|
// Prefer FRU dump when present.
|
||||||
|
if f := parser.FindFileByName(files, "fru.txt"); f != nil {
|
||||||
|
fruList := ParseFRU(f.Content)
|
||||||
|
for _, fru := range fruList {
|
||||||
|
serial := strings.TrimSpace(fru.SerialNumber)
|
||||||
|
if serial == "" || serial == "0" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := strings.ToLower(strings.TrimSpace(fru.Description))
|
||||||
|
if strings.Contains(desc, "builtin") || strings.Contains(desc, "fru device") {
|
||||||
|
return serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to explicit hostname file.
|
||||||
|
if f := parser.FindFileByName(files, "hostname"); f != nil {
|
||||||
|
if serial := sanitizeCandidateSerial(firstNonEmptyLine(string(f.Content))); serial != "" {
|
||||||
|
return serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort fallback from structured journal logs.
|
||||||
|
if f := parser.FindFileByName(files, "maintenance_json.log"); f != nil {
|
||||||
|
if m := hostnameJSONRegex.FindSubmatch(f.Content); len(m) == 2 {
|
||||||
|
if serial := sanitizeCandidateSerial(string(m[1])); serial != "" {
|
||||||
|
return serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferBoardModelFromFallbackLogs(files []parser.ExtractedFile) string {
|
||||||
|
// Prefer FRU dump when present.
|
||||||
|
if f := parser.FindFileByName(files, "fru.txt"); f != nil {
|
||||||
|
fruList := ParseFRU(f.Content)
|
||||||
|
for _, fru := range fruList {
|
||||||
|
model := sanitizeCandidateModel(fru.ProductName)
|
||||||
|
if model == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := strings.ToLower(strings.TrimSpace(fru.Description))
|
||||||
|
if strings.Contains(desc, "builtin") || strings.Contains(desc, "fru device") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyLine(s string) string {
|
||||||
|
for _, line := range strings.Split(s, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCandidateSerial(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || strings.EqualFold(s, "localhost") || strings.ContainsAny(s, " \t") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeCandidateModel(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" || strings.EqualFold(s, "null") || s == "0" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
76
internal/parser/vendors/inspur/serial_fallback_test.go
vendored
Normal file
76
internal/parser/vendors/inspur/serial_fallback_test.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInferBoardSerialFromFallbackLogs_PrefersFRU(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "component/fru.txt",
|
||||||
|
Content: []byte(`FRU Device Description : Builtin FRU Device (ID 0)
|
||||||
|
Product Serial : 23DB01639
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "runningdata/RTOSDump/hostname",
|
||||||
|
Content: []byte("HOSTNAME-FALLBACK\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "log/bmc/struct-log/maintenance_json.log",
|
||||||
|
Content: []byte(`{ "_HOSTNAME": "JSON-FALLBACK" }`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferBoardSerialFromFallbackLogs(files)
|
||||||
|
if got != "23DB01639" {
|
||||||
|
t.Fatalf("expected FRU serial 23DB01639, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferBoardSerialFromFallbackLogs_UsesHostnameFile(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "runningdata/RTOSDump/hostname",
|
||||||
|
Content: []byte("23DB01639\n"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferBoardSerialFromFallbackLogs(files)
|
||||||
|
if got != "23DB01639" {
|
||||||
|
t.Fatalf("expected hostname serial 23DB01639, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferBoardSerialFromFallbackLogs_UsesMaintenanceJSON(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "log/bmc/struct-log/maintenance_json.log",
|
||||||
|
Content: []byte(`{ "_HOSTNAME": "23DB01639", "MESSAGE": "ok" }`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferBoardSerialFromFallbackLogs(files)
|
||||||
|
if got != "23DB01639" {
|
||||||
|
t.Fatalf("expected JSON hostname serial 23DB01639, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferBoardModelFromFallbackLogs_PrefersFRU(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "component/fru.txt",
|
||||||
|
Content: []byte(`FRU Device Description : Builtin FRU Device (ID 0)
|
||||||
|
Board Product : KR9288-X3-A0-F0-00
|
||||||
|
Product Name : KR9288-X3-A0-F0-00
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := inferBoardModelFromFallbackLogs(files)
|
||||||
|
if got != "KR9288-X3-A0-F0-00" {
|
||||||
|
t.Fatalf("expected board model KR9288-X3-A0-F0-00, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
internal/parser/vendors/nvidia_bug_report/gpu.go
vendored
137
internal/parser/vendors/nvidia_bug_report/gpu.go
vendored
@@ -106,6 +106,8 @@ func parseGPUInfo(content string, result *models.AnalysisResult) {
|
|||||||
result.Hardware.GPUs = append(result.Hardware.GPUs, *currentGPU)
|
result.Hardware.GPUs = append(result.Hardware.GPUs, *currentGPU)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyGPUSerialNumbers(content, result.Hardware.GPUs)
|
||||||
|
|
||||||
// Create event for GPU summary
|
// Create event for GPU summary
|
||||||
if len(result.Hardware.GPUs) > 0 {
|
if len(result.Hardware.GPUs) > 0 {
|
||||||
result.Events = append(result.Events, models.Event{
|
result.Events = append(result.Events, models.Event{
|
||||||
@@ -168,3 +170,138 @@ func formatGPUSummary(gpus []models.GPU) string {
|
|||||||
|
|
||||||
return summary.String()
|
return summary.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyGPUSerialNumbers(content string, gpus []models.GPU) {
|
||||||
|
if len(gpus) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serialByBDF := parseGPUSerialsFromNvidiaSMI(content)
|
||||||
|
if len(serialByBDF) == 0 {
|
||||||
|
serialByBDF = parseGPUSerialsFromSummary(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(serialByBDF) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range gpus {
|
||||||
|
bdf := normalizeGPUAddress(gpus[i].BDF)
|
||||||
|
if bdf == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if serial, ok := serialByBDF[bdf]; ok && serial != "" {
|
||||||
|
gpus[i].SerialNumber = serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGPUSerialsFromNvidiaSMI(content string) map[string]string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
reGPU := regexp.MustCompile(`^GPU\s+([0-9A-F]{8}:[0-9A-F]{2}:[0-9A-F]{2}\.[0-9A-F])$`)
|
||||||
|
|
||||||
|
serialByBDF := make(map[string]string)
|
||||||
|
currentBDF := ""
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches := reGPU.FindStringSubmatch(line); len(matches) == 2 {
|
||||||
|
currentBDF = normalizeGPUAddress(matches[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentBDF == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "Serial Number") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(parts[1])
|
||||||
|
if serial != "" && !strings.EqualFold(serial, "N/A") {
|
||||||
|
serialByBDF[currentBDF] = serial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialByBDF
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGPUSerialsFromSummary(content string) map[string]string {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
|
||||||
|
serialByBDF := make(map[string]string)
|
||||||
|
inGPUDetails := false
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "NVIDIA GPU Details") {
|
||||||
|
inGPUDetails = true
|
||||||
|
}
|
||||||
|
if !inGPUDetails {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "NVIDIA Switch Details") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if payload == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Split(payload, ",")
|
||||||
|
if len(fields) < 6 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bdf := normalizeGPUAddress(strings.TrimSpace(fields[4]))
|
||||||
|
serial := strings.TrimSpace(fields[5])
|
||||||
|
if bdf == "" || serial == "" || strings.EqualFold(serial, "N/A") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serialByBDF[bdf] = serial
|
||||||
|
}
|
||||||
|
|
||||||
|
return serialByBDF
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGPUAddress(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if addr == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(addr, ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return strings.ToLower(addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := parts[0]
|
||||||
|
bus := parts[1]
|
||||||
|
devFn := parts[2]
|
||||||
|
|
||||||
|
devFnParts := strings.Split(devFn, ".")
|
||||||
|
if len(devFnParts) != 2 {
|
||||||
|
return strings.ToLower(addr)
|
||||||
|
}
|
||||||
|
device := devFnParts[0]
|
||||||
|
fn := devFnParts[1]
|
||||||
|
|
||||||
|
if len(domain) == 8 {
|
||||||
|
domain = domain[4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ToLower(domain + ":" + bus + ":" + device + "." + fn)
|
||||||
|
}
|
||||||
|
|||||||
54
internal/parser/vendors/nvidia_bug_report/gpu_test.go
vendored
Normal file
54
internal/parser/vendors/nvidia_bug_report/gpu_test.go
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package nvidia_bug_report
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyGPUSerialNumbers_FromNvidiaSMI(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
/usr/bin/nvidia-smi --query
|
||||||
|
GPU 00000000:18:00.0
|
||||||
|
Serial Number : 1653925025827
|
||||||
|
GPU 00000000:2A:00.0
|
||||||
|
Serial Number : 1653925050608
|
||||||
|
`
|
||||||
|
|
||||||
|
gpus := []models.GPU{
|
||||||
|
{BDF: "0000:18:00.0"},
|
||||||
|
{BDF: "0000:2a:00.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGPUSerialNumbers(content, gpus)
|
||||||
|
|
||||||
|
if gpus[0].SerialNumber != "1653925025827" {
|
||||||
|
t.Fatalf("unexpected serial for gpu0: %q", gpus[0].SerialNumber)
|
||||||
|
}
|
||||||
|
if gpus[1].SerialNumber != "1653925050608" {
|
||||||
|
t.Fatalf("unexpected serial for gpu1: %q", gpus[1].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyGPUSerialNumbers_FromSummaryFallback(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
NVIDIA GPU Details | NVIDIA H200, 570.172.08, 143771 MiB, 96.00.D0.00.03, 00000000:18:00.0, 1653925025827
|
||||||
|
| NVIDIA H200, 570.172.08, 143771 MiB, 96.00.D0.00.03, 00000000:2A:00.0, 1653925050608
|
||||||
|
NVIDIA Switch Details | No devices matching query 'Quantum'
|
||||||
|
`
|
||||||
|
|
||||||
|
gpus := []models.GPU{
|
||||||
|
{BDF: "0000:18:00.0"},
|
||||||
|
{BDF: "0000:2a:00.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGPUSerialNumbers(content, gpus)
|
||||||
|
|
||||||
|
if gpus[0].SerialNumber != "1653925025827" {
|
||||||
|
t.Fatalf("unexpected serial for gpu0: %q", gpus[0].SerialNumber)
|
||||||
|
}
|
||||||
|
if gpus[1].SerialNumber != "1653925050608" {
|
||||||
|
t.Fatalf("unexpected serial for gpu1: %q", gpus[1].SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -591,16 +591,6 @@ func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
|||||||
exp.ExportJSON(w)
|
exp.ExportJSON(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) {
|
|
||||||
result := s.GetResult()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "txt")))
|
|
||||||
|
|
||||||
exp := exporter.New(result)
|
|
||||||
exp.ExportTXT(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil || result.Hardware == nil {
|
if result == nil || result.Hardware == nil {
|
||||||
@@ -942,7 +932,7 @@ func exportFilename(result *models.AnalysisResult, ext string) string {
|
|||||||
sn = sanitizeFilenamePart(sn)
|
sn = sanitizeFilenamePart(sn)
|
||||||
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
|
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = "txt"
|
ext = "json"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
|
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware)
|
s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware)
|
||||||
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
||||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
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("GET /api/export/reanimator", s.handleExportReanimator)
|
||||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||||
|
|||||||
@@ -111,7 +111,6 @@
|
|||||||
<div class="tab-content active" id="config">
|
<div class="tab-content active" id="config">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button onclick="exportData('json')">Экспорт JSON</button>
|
<button onclick="exportData('json')">Экспорт JSON</button>
|
||||||
<button onclick="exportData('txt')">Экспорт TXT</button>
|
|
||||||
<button onclick="exportData('reanimator')">Экспорт Reanimator</button>
|
<button onclick="exportData('reanimator')">Экспорт Reanimator</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="config-content"></div>
|
<div id="config-content"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user