v1.2.0: Enhanced Inspur/Kaytus parser with GPU, PCIe, and storage support

Major improvements:
- Add CSV SEL event parser for Kaytus firmware format
- Add PCIe device parser with link speed/width detection
- Add GPU temperature and PCIe link monitoring
- Add disk backplane parser for storage bay information
- Fix memory module detection (only show installed DIMMs)

Parser enhancements:
- Parse RESTful PCIe Device info (max/current link width/speed)
- Parse GPU sensor data (core and memory temperatures)
- Parse diskbackplane info (slot count, installed drives)
- Parse SEL events from CSV format (selelist.csv)
- Fix memory Present status logic (check mem_mod_status)

Web interface improvements:
- Add PCIe link degradation highlighting (red when current < max)
- Add storage table with Present status and location
- Update memory specification to show only installed modules with frequency
- Sort events from newest to oldest
- Filter out N/A serial numbers from display

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-30 12:30:18 +03:00
parent c7422e95aa
commit 21f4e5a67e
12 changed files with 667 additions and 48 deletions

View File

@@ -207,8 +207,8 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
DeviceClass: pcieClassToString(pcie.ClassCode, pcie.SubClassCode),
@@ -242,8 +242,10 @@ func ParseAssetJSON(content []byte) (*models.HardwareConfig, error) {
VendorID: pcie.VendorId,
DeviceID: pcie.DeviceId,
BDF: formatBDF(pcie.BusNumber, pcie.DeviceNumber, pcie.FunctionNumber),
LinkWidth: pcie.NegotiatedLinkWidth,
LinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
CurrentLinkWidth: pcie.NegotiatedLinkWidth,
CurrentLinkSpeed: pcieLinkSpeedToString(pcie.CurrentLinkSpeed),
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcieLinkSpeedToString(pcie.MaxLinkSpeed),
}
if pcie.PartNumber != nil {
gpu.PartNumber = strings.TrimSpace(*pcie.PartNumber)

View File

@@ -27,6 +27,9 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) {
// Parse RESTful HDD info
parseHDDInfo(text, hw)
// Parse RESTful diskbackplane info
parseDiskBackplaneInfo(text, hw)
// Parse RESTful Network Adapter info
parseNetworkAdapterInfo(text, hw)
@@ -52,6 +55,7 @@ type MemoryRESTInfo struct {
MemModID int `json:"mem_mod_id"`
ConfigStatus int `json:"config_status"`
MemModSlot string `json:"mem_mod_slot"`
MemModStatus int `json:"mem_mod_status"`
MemModSize int `json:"mem_mod_size"`
MemModType string `json:"mem_mod_type"`
MemModTechnology string `json:"mem_mod_technology"`
@@ -90,7 +94,7 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
hw.Memory = append(hw.Memory, models.MemoryDIMM{
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
Present: mem.ConfigStatus == 1,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
Type: mem.MemModType,
Technology: strings.TrimSpace(mem.MemModTechnology),
@@ -420,3 +424,56 @@ func extractComponentFirmware(text string, hw *models.HardwareConfig) {
}
}
}
// DiskBackplaneRESTInfo represents the RESTful diskbackplane info structure
type DiskBackplaneRESTInfo []struct {
PortCount int `json:"port_count"`
DriverCount int `json:"driver_count"`
Front int `json:"front"`
BackplaneIndex int `json:"backplane_index"`
Present int `json:"present"`
CPLDVersion string `json:"cpld_version"`
Temperature int `json:"temperature"`
}
func parseDiskBackplaneInfo(text string, hw *models.HardwareConfig) {
// Find RESTful diskbackplane info section
re := regexp.MustCompile(`RESTful diskbackplane info:\s*(\[[\s\S]*?\])\s*BMC`)
match := re.FindStringSubmatch(text)
if match == nil {
return
}
jsonStr := match[1]
jsonStr = strings.ReplaceAll(jsonStr, "\n", "")
var backplaneInfo DiskBackplaneRESTInfo
if err := json.Unmarshal([]byte(jsonStr), &backplaneInfo); err != nil {
return
}
// Create storage entries based on backplane info
for _, bp := range backplaneInfo {
if bp.Present != 1 {
continue
}
location := "Rear"
if bp.Front == 1 {
location = "Front"
}
// Create entries for each port (disk slot)
for i := 0; i < bp.PortCount; i++ {
isPresent := i < bp.DriverCount
hw.Storage = append(hw.Storage, models.Storage{
Slot: fmt.Sprintf("%d", i),
Present: isPresent,
Location: location,
BackplaneID: bp.BackplaneIndex,
Type: "HDD",
})
}
}
}

View File

@@ -6,6 +6,7 @@
package inspur
import (
"fmt"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
@@ -91,12 +92,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
Sensors: make([]models.SensorReading, 0),
}
// Parse devicefrusdr.log (contains SDR and FRU data)
if f := parser.FindFileByName(files, "devicefrusdr.log"); f != nil {
p.parseDeviceFruSDR(f.Content, result)
}
// Parse asset.json
// Parse asset.json first (base hardware info)
if f := parser.FindFileByName(files, "asset.json"); f != nil {
if hw, err := ParseAssetJSON(f.Content); err == nil {
result.Hardware = hw
@@ -107,6 +103,12 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if result.Hardware == nil {
result.Hardware = &models.HardwareConfig{}
}
// Parse devicefrusdr.log (contains SDR, FRU, PCIe and additional data)
if f := parser.FindFileByName(files, "devicefrusdr.log"); f != nil {
p.parseDeviceFruSDR(f.Content, result)
}
extractBoardInfo(result.FRU, result.Hardware)
// Extract PlatformId (server model) from ThermalConfig
@@ -129,6 +131,12 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Events = append(result.Events, idlEvents...)
}
// Parse SEL list (selelist.csv)
if f := parser.FindFileByName(files, "selelist.csv"); f != nil {
selEvents := ParseSELList(f.Content)
result.Events = append(result.Events, selEvents...)
}
// Parse syslog files
syslogFiles := parser.FindFileByPattern(files, "syslog/alert", "syslog/warning", "syslog/notice", "syslog/info")
for _, f := range syslogFiles {
@@ -161,4 +169,70 @@ func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult
fruContent := lines[fruStart:]
result.FRU = ParseFRU([]byte(fruContent))
}
// Parse PCIe devices from RESTful PCIE Device info
// This supplements data from asset.json with serial numbers, firmware, etc.
pcieDevicesFromREST := ParsePCIeDevices(content)
// Merge PCIe data: keep asset.json data but add RESTful data if available
if result.Hardware != nil {
// If asset.json didn't have PCIe devices, use RESTful data
if len(result.Hardware.PCIeDevices) == 0 && len(pcieDevicesFromREST) > 0 {
result.Hardware.PCIeDevices = pcieDevicesFromREST
}
// If we have both, merge them (RESTful data takes precedence for detailed info)
// For now, we keep asset.json data which has more details
}
// Parse GPU devices and add temperature data from sensors
if len(result.Sensors) > 0 && result.Hardware != nil {
// Use existing GPU data from asset.json and enrich with sensor data
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
// Extract GPU number from slot name
slotNum := extractSlotNumberFromGPU(gpu.Slot)
// Find temperature sensors for this GPU
for _, sensor := range result.Sensors {
sensorName := strings.ToUpper(sensor.Name)
// Match GPU temperature sensor
if strings.Contains(sensorName, fmt.Sprintf("GPU%d_TEMP", slotNum)) && !strings.Contains(sensorName, "MEM") {
if sensor.RawValue != "" {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature)
}
}
// Match GPU memory temperature
if strings.Contains(sensorName, fmt.Sprintf("GPU%d_MEM_TEMP", slotNum)) {
if sensor.RawValue != "" {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.MemTemperature)
}
}
// Match PCIe slot temperature as fallback
if strings.Contains(sensorName, fmt.Sprintf("PCIE%d_GPU_TLM_T", slotNum)) && gpu.Temperature == 0 {
if sensor.RawValue != "" {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature)
}
}
}
}
}
}
// extractSlotNumberFromGPU extracts slot number from GPU slot string
func extractSlotNumberFromGPU(slot string) int {
parts := strings.Split(slot, "_")
for _, part := range parts {
if strings.HasPrefix(part, "PCIE") {
var num int
fmt.Sscanf(part, "PCIE%d", &num)
if num > 0 {
return num
}
}
}
return 0
}

214
internal/parser/vendors/inspur/pcie.go vendored Normal file
View File

@@ -0,0 +1,214 @@
package inspur
import (
"encoding/json"
"fmt"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
// PCIeRESTInfo represents the RESTful PCIE Device info structure
type PCIeRESTInfo []struct {
ID int `json:"id"`
Present int `json:"present"`
Enable int `json:"enable"`
Status int `json:"status"`
VendorID int `json:"vendor_id"`
VendorName string `json:"vendor_name"`
DeviceID int `json:"device_id"`
DeviceName string `json:"device_name"`
BusNum int `json:"bus_num"`
DevNum int `json:"dev_num"`
FuncNum int `json:"func_num"`
MaxLinkWidth int `json:"max_link_width"`
MaxLinkSpeed int `json:"max_link_speed"`
CurrentLinkWidth int `json:"current_link_width"`
CurrentLinkSpeed int `json:"current_link_speed"`
Slot int `json:"slot"`
Location string `json:"location"`
DeviceLocator string `json:"DeviceLocator"`
DevType int `json:"dev_type"`
DevSubtype int `json:"dev_subtype"`
PartNum string `json:"part_num"`
SerialNum string `json:"serial_num"`
FwVer string `json:"fw_ver"`
}
// ParsePCIeDevices parses RESTful PCIE Device info from devicefrusdr.log
func ParsePCIeDevices(content []byte) []models.PCIeDevice {
text := string(content)
// Find RESTful PCIE Device info section
startMarker := "RESTful PCIE Device info:"
endMarker := "BMC sdr Info:"
startIdx := strings.Index(text, startMarker)
if startIdx == -1 {
return nil
}
endIdx := strings.Index(text[startIdx:], endMarker)
if endIdx == -1 {
endIdx = len(text) - startIdx
}
jsonText := text[startIdx+len(startMarker) : startIdx+endIdx]
jsonText = strings.TrimSpace(jsonText)
var pcieInfo PCIeRESTInfo
if err := json.Unmarshal([]byte(jsonText), &pcieInfo); err != nil {
return nil
}
var devices []models.PCIeDevice
for _, pcie := range pcieInfo {
if pcie.Present != 1 {
continue
}
// Convert PCIe speed to GEN notation
maxSpeed := fmt.Sprintf("GEN%d", pcie.MaxLinkSpeed)
currentSpeed := fmt.Sprintf("GEN%d", pcie.CurrentLinkSpeed)
// Determine device class based on dev_type
deviceClass := determineDeviceClass(pcie.DevType, pcie.DevSubtype, pcie.DeviceName)
// Build BDF string
bdf := fmt.Sprintf("%04x/%02x/%02x/%02x", 0, pcie.BusNum, pcie.DevNum, pcie.FuncNum)
device := models.PCIeDevice{
Slot: pcie.Location,
VendorID: pcie.VendorID,
DeviceID: pcie.DeviceID,
BDF: bdf,
DeviceClass: deviceClass,
Manufacturer: pcie.VendorName,
LinkWidth: pcie.CurrentLinkWidth,
LinkSpeed: currentSpeed,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: maxSpeed,
PartNumber: strings.TrimSpace(pcie.PartNum),
SerialNumber: strings.TrimSpace(pcie.SerialNum),
}
devices = append(devices, device)
}
return devices
}
// determineDeviceClass maps device type to human-readable class
func determineDeviceClass(devType, devSubtype int, deviceName string) string {
// dev_type mapping:
// 1 = Mass Storage Controller
// 2 = Network Controller
// 3 = Display Controller (GPU)
// 4 = Multimedia Controller
switch devType {
case 1:
if devSubtype == 4 {
return "RAID Controller"
}
return "Storage Controller"
case 2:
return "Network Controller"
case 3:
// GPU
if strings.Contains(strings.ToUpper(deviceName), "H100") {
return "GPU (H100)"
}
if strings.Contains(strings.ToUpper(deviceName), "A100") {
return "GPU (A100)"
}
if strings.Contains(strings.ToUpper(deviceName), "NVIDIA") {
return "GPU"
}
return "Display Controller"
case 4:
return "Multimedia Controller"
default:
return "Unknown"
}
}
// ParseGPUs extracts GPU data from PCIe devices and sensors
func ParseGPUs(pcieDevices []models.PCIeDevice, sensors []models.SensorReading) []models.GPU {
var gpus []models.GPU
// Find GPU devices
for _, pcie := range pcieDevices {
if !strings.Contains(strings.ToLower(pcie.DeviceClass), "gpu") &&
!strings.Contains(strings.ToLower(pcie.DeviceClass), "display") {
continue
}
// Skip integrated graphics (ASPEED, etc.)
if strings.Contains(pcie.Manufacturer, "ASPEED") {
continue
}
gpu := models.GPU{
Slot: pcie.Slot,
Location: pcie.Slot,
Model: pcie.DeviceClass,
Manufacturer: pcie.Manufacturer,
SerialNumber: pcie.SerialNumber,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcie.MaxLinkSpeed,
CurrentLinkWidth: pcie.LinkWidth,
CurrentLinkSpeed: pcie.LinkSpeed,
Status: "OK",
}
// Extract GPU number from slot name (e.g., "PCIE7" -> 7)
slotNum := extractSlotNumber(pcie.Slot)
// Find temperature sensors for this GPU
for _, sensor := range sensors {
sensorName := strings.ToUpper(sensor.Name)
// Match GPU temperature sensor (e.g., "GPU7_Temp")
if strings.Contains(sensorName, fmt.Sprintf("GPU%d_TEMP", slotNum)) {
if sensor.RawValue != "" {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature)
}
}
// Match GPU memory temperature (e.g., "GPU7_Mem_Temp")
if strings.Contains(sensorName, fmt.Sprintf("GPU%d_MEM_TEMP", slotNum)) {
if sensor.RawValue != "" {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.MemTemperature)
}
}
// Match PCIe slot temperature (e.g., "PCIE7_GPU_TLM_T")
if strings.Contains(sensorName, fmt.Sprintf("PCIE%d_GPU_TLM_T", slotNum)) {
if sensor.RawValue != "" && gpu.Temperature == 0 {
fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature)
}
}
}
gpus = append(gpus, gpu)
}
return gpus
}
// extractSlotNumber extracts slot number from location string
// e.g., "CPU0_PE3_AC_PCIE7" -> 7
func extractSlotNumber(location string) int {
parts := strings.Split(location, "_")
for _, part := range parts {
if strings.HasPrefix(part, "PCIE") || strings.HasPrefix(part, "#CPU") {
var num int
fmt.Sscanf(part, "PCIE%d", &num)
if num > 0 {
return num
}
}
}
return 0
}

View File

@@ -46,6 +46,7 @@ func ParseSDR(content []byte) []models.SensorReading {
if v, err := strconv.ParseFloat(vm[1], 64); err == nil {
reading.Value = v
reading.Unit = strings.TrimSpace(vm[2])
reading.RawValue = valueStr // Keep original string for reference
}
}
} else if strings.HasPrefix(valueStr, "0x") {

174
internal/parser/vendors/inspur/sel.go vendored Normal file
View File

@@ -0,0 +1,174 @@
package inspur
import (
"encoding/csv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// ParseSELList parses selelist.csv file with SEL events
// Format: ID, Date (MM/DD/YYYY), Time (HH:MM:SS), Sensor, Event, Status
// Example: 1,04/18/2025,09:31:18,Event Logging Disabled SEL_Status,Log area reset/cleared,Asserted
func ParseSELList(content []byte) []models.Event {
var events []models.Event
text := string(content)
lines := strings.Split(text, "\n")
// Skip header line(s) if present
startIdx := 0
for i, line := range lines {
if strings.Contains(strings.ToLower(line), "sel elist") {
startIdx = i + 1
break
}
}
// Parse CSV data
for i := startIdx; i < len(lines); i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
// Parse CSV line
r := csv.NewReader(strings.NewReader(line))
records, err := r.Read()
if err != nil || len(records) < 6 {
continue
}
eventID := strings.TrimSpace(records[0])
dateStr := strings.TrimSpace(records[1])
timeStr := strings.TrimSpace(records[2])
sensorStr := strings.TrimSpace(records[3])
eventDesc := strings.TrimSpace(records[4])
status := strings.TrimSpace(records[5])
// Parse timestamp: MM/DD/YYYY HH:MM:SS
timestamp := parseSELTimestamp(dateStr, timeStr)
// Extract sensor type and name
sensorType, sensorName := parseSensorInfo(sensorStr)
// Determine severity
severity := determineSELSeverity(sensorStr, eventDesc, status)
// Build full description
description := buildSELDescription(eventDesc, status)
events = append(events, models.Event{
ID: eventID,
Timestamp: timestamp,
Source: "SEL",
SensorType: sensorType,
SensorName: sensorName,
EventType: eventDesc,
Severity: severity,
Description: description,
RawData: line,
})
}
return events
}
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
func parseSELTimestamp(dateStr, timeStr string) time.Time {
// Combine date and time: MM/DD/YYYY HH:MM:SS
timestampStr := dateStr + " " + timeStr
// Try parsing with MM/DD/YYYY format
t, err := time.Parse("01/02/2006 15:04:05", timestampStr)
if err != nil {
// Fallback to current time
return time.Now()
}
return t
}
// parseSensorInfo extracts sensor type and name from sensor string
// Example: "Event Logging Disabled SEL_Status" -> ("sel", "SEL_Status")
// Example: "Power Supply PSU0_Status" -> ("power_supply", "PSU0_Status")
func parseSensorInfo(sensorStr string) (sensorType, sensorName string) {
parts := strings.Fields(sensorStr)
if len(parts) == 0 {
return "unknown", sensorStr
}
// Last part is usually the sensor name
sensorName = parts[len(parts)-1]
// First parts form the sensor type
if len(parts) > 1 {
sensorType = strings.ToLower(strings.Join(parts[:len(parts)-1], "_"))
} else {
sensorType = "system"
}
return
}
// determineSELSeverity determines event severity based on sensor and event description
func determineSELSeverity(sensorStr, eventDesc, status string) models.Severity {
lowerSensor := strings.ToLower(sensorStr)
lowerEvent := strings.ToLower(eventDesc)
lowerStatus := strings.ToLower(status)
// Critical indicators
criticalKeywords := []string{
"critical", "failure", "fault", "error",
"ac lost", "predictive failure", "redundancy lost",
"going high", "going low", "transition to critical",
}
for _, keyword := range criticalKeywords {
if strings.Contains(lowerSensor, keyword) ||
strings.Contains(lowerEvent, keyword) ||
strings.Contains(lowerStatus, keyword) {
return models.SeverityCritical
}
}
// Warning indicators
warningKeywords := []string{
"warning", "disabled", "non-recoverable",
"device removed", "device absent",
}
for _, keyword := range warningKeywords {
if strings.Contains(lowerSensor, keyword) ||
strings.Contains(lowerEvent, keyword) ||
strings.Contains(lowerStatus, keyword) {
return models.SeverityWarning
}
}
// Info indicators (normal operations)
infoKeywords := []string{
"presence detected", "device present", "asserted",
"initiated by", "state asserted", "s0/g0: working",
"power button pressed",
}
for _, keyword := range infoKeywords {
if strings.Contains(lowerEvent, keyword) ||
strings.Contains(lowerStatus, keyword) {
return models.SeverityInfo
}
}
// Default to info
return models.SeverityInfo
}
// buildSELDescription builds human-readable description
func buildSELDescription(eventDesc, status string) string {
if status == "Asserted" || status == "Deasserted" {
return eventDesc
}
return eventDesc + " (" + status + ")"
}