feat(parser): add Lenovo XCC mini-log parser
This commit is contained in:
689
internal/parser/vendors/lenovo_xcc/parser.go
vendored
Normal file
689
internal/parser/vendors/lenovo_xcc/parser.go
vendored
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
// Package lenovo_xcc provides parser for Lenovo XCC mini-log archives.
|
||||||
|
// Tested with: ThinkSystem SR650 V3 (XCC mini-log zip, exported via XCC UI)
|
||||||
|
//
|
||||||
|
// Archive structure: zip with tmp/ directory containing JSON .log files.
|
||||||
|
//
|
||||||
|
// IMPORTANT: Increment parserVersion when modifying parser logic!
|
||||||
|
package lenovo_xcc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const parserVersion = "1.0"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
parser.Register(&Parser{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser implements VendorParser for Lenovo XCC mini-log archives.
|
||||||
|
type Parser struct{}
|
||||||
|
|
||||||
|
func (p *Parser) Name() string { return "Lenovo XCC Mini-Log Parser" }
|
||||||
|
func (p *Parser) Vendor() string { return "lenovo_xcc" }
|
||||||
|
func (p *Parser) Version() string { return parserVersion }
|
||||||
|
|
||||||
|
// Detect checks if files match the Lenovo XCC mini-log archive format.
|
||||||
|
// Returns confidence score 0-100.
|
||||||
|
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||||
|
confidence := 0
|
||||||
|
for _, f := range files {
|
||||||
|
path := strings.ToLower(f.Path)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, "tmp/basic_sys_info.log"):
|
||||||
|
confidence += 60
|
||||||
|
case strings.HasSuffix(path, "tmp/inventory_cpu.log"):
|
||||||
|
confidence += 20
|
||||||
|
case strings.HasSuffix(path, "tmp/xcc_plat_events1.log"):
|
||||||
|
confidence += 20
|
||||||
|
case strings.HasSuffix(path, "tmp/inventory_dimm.log"):
|
||||||
|
confidence += 10
|
||||||
|
case strings.HasSuffix(path, "tmp/inventory_fw.log"):
|
||||||
|
confidence += 10
|
||||||
|
}
|
||||||
|
if confidence >= 100 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse parses the Lenovo XCC mini-log archive and returns an analysis result.
|
||||||
|
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),
|
||||||
|
PCIeDevices: make([]models.PCIeDevice, 0),
|
||||||
|
PowerSupply: make([]models.PSU, 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if f := findByPath(files, "tmp/basic_sys_info.log"); f != nil {
|
||||||
|
parseBasicSysInfo(f.Content, result)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_fw.log"); f != nil {
|
||||||
|
result.Hardware.Firmware = append(result.Hardware.Firmware, parseFirmware(f.Content)...)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_cpu.log"); f != nil {
|
||||||
|
result.Hardware.CPUs = parseCPUs(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_dimm.log"); f != nil {
|
||||||
|
result.Hardware.Memory = parseDIMMs(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
|
||||||
|
result.Hardware.Storage = parseDisks(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
|
||||||
|
result.Hardware.PCIeDevices = parseCards(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_psu.log"); f != nil {
|
||||||
|
result.Hardware.PowerSupply = parsePSUs(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_ipmi_fru.log"); f != nil {
|
||||||
|
result.FRU = parseFRU(f.Content)
|
||||||
|
}
|
||||||
|
if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil {
|
||||||
|
result.Sensors = parseSensors(f.Content)
|
||||||
|
}
|
||||||
|
for _, f := range findEventFiles(files) {
|
||||||
|
result.Events = append(result.Events, parseEvents(f.Content)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Protocol = "ipmi"
|
||||||
|
result.SourceType = models.SourceTypeArchive
|
||||||
|
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByPath returns the first file whose lowercased path ends with the given suffix.
|
||||||
|
func findByPath(files []parser.ExtractedFile, suffix string) *parser.ExtractedFile {
|
||||||
|
for i := range files {
|
||||||
|
if strings.HasSuffix(strings.ToLower(files[i].Path), suffix) {
|
||||||
|
return &files[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findEventFiles returns all xcc_plat_eventsN.log files.
|
||||||
|
func findEventFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
|
||||||
|
var out []parser.ExtractedFile
|
||||||
|
for _, f := range files {
|
||||||
|
path := strings.ToLower(f.Path)
|
||||||
|
if strings.Contains(path, "tmp/xcc_plat_events") && strings.HasSuffix(path, ".log") {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON structures ---
|
||||||
|
|
||||||
|
type xccBasicSysInfoDoc struct {
|
||||||
|
Items []xccBasicSysInfoItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccBasicSysInfoItem struct {
|
||||||
|
MachineName string `json:"machine_name"`
|
||||||
|
MachineTypeModel string `json:"machine_typemodel"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
PowerState string `json:"power_state"`
|
||||||
|
ServerState string `json:"server_state"`
|
||||||
|
CurrentTime string `json:"current_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// xccFWEntry covers both basic_sys_info firmware (no type_str) and inventory_fw (has type_str).
|
||||||
|
type xccFWEntry struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
TypeCode int `json:"type"`
|
||||||
|
TypeStr string `json:"type_str"` // only in inventory_fw.log
|
||||||
|
Version string `json:"version"`
|
||||||
|
Build string `json:"build"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccFirmwareDoc struct {
|
||||||
|
Items []xccFWEntry `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCPUDoc struct {
|
||||||
|
Items []xccCPUItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCPUItem struct {
|
||||||
|
Processors []xccCPU `json:"processors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCPU struct {
|
||||||
|
Name int `json:"processors_name"`
|
||||||
|
Model string `json:"processors_cpu_model"`
|
||||||
|
Cores json.RawMessage `json:"processors_cores"` // may be int or string
|
||||||
|
Threads json.RawMessage `json:"processors_threads"` // may be int or string
|
||||||
|
ClockSpeed string `json:"processors_clock_speed"`
|
||||||
|
L1DataCache string `json:"processors_l1datacache"`
|
||||||
|
L2Cache string `json:"processors_l2cache"`
|
||||||
|
L3Cache string `json:"processors_l3cache"`
|
||||||
|
Status string `json:"processors_status"`
|
||||||
|
SerialNumber string `json:"processors_serial_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDIMMDoc struct {
|
||||||
|
Items []xccDIMMItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDIMMItem struct {
|
||||||
|
Memory []xccDIMM `json:"memory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDIMM struct {
|
||||||
|
Index int `json:"memory_index"`
|
||||||
|
Status string `json:"memory_status"`
|
||||||
|
Name string `json:"memory_name"`
|
||||||
|
Type string `json:"memory_type"`
|
||||||
|
Capacity json.RawMessage `json:"memory_capacity"` // int (GB) or string
|
||||||
|
PartNumber string `json:"memory_part_number"`
|
||||||
|
SerialNumber string `json:"memory_serial_number"`
|
||||||
|
Manufacturer string `json:"memory_manufacturer"`
|
||||||
|
MemSpeed json.RawMessage `json:"memory_mem_speed"` // int or string
|
||||||
|
ConfigSpeed json.RawMessage `json:"memory_config_speed"` // int or string
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDiskDoc struct {
|
||||||
|
Items []xccDiskItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDiskItem struct {
|
||||||
|
Disks []xccDisk `json:"disks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccDisk struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
SlotNo int `json:"slotNo"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Media string `json:"media"`
|
||||||
|
SerialNo string `json:"serialNo"`
|
||||||
|
PartNo string `json:"partNo"`
|
||||||
|
CapacityStr string `json:"capacityStr"` // e.g. "3.20 TB"
|
||||||
|
Manufacture string `json:"manufacture"`
|
||||||
|
ProductName string `json:"productName"`
|
||||||
|
RemainLife int `json:"remainLife"` // 0-100
|
||||||
|
FWVersion string `json:"fwVersion"`
|
||||||
|
Temperature int `json:"temperature"`
|
||||||
|
HealthStatus int `json:"healthStatus"` // int code: 2=Normal
|
||||||
|
State int `json:"state"`
|
||||||
|
StateStr string `json:"statestr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCardDoc struct {
|
||||||
|
Items []xccCard `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCard struct {
|
||||||
|
Key int `json:"key"`
|
||||||
|
SlotNo int `json:"slotNo"`
|
||||||
|
AdapterName string `json:"adapterName"`
|
||||||
|
ConnectorLabel string `json:"connectorLabel"`
|
||||||
|
OOBSupported int `json:"oobSupported"`
|
||||||
|
Location int `json:"location"`
|
||||||
|
Functions []xccCardFunc `json:"functions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccCardFunc struct {
|
||||||
|
FunType int `json:"funType"`
|
||||||
|
BusNo int `json:"generic_busNo"`
|
||||||
|
DevNo int `json:"generic_devNo"`
|
||||||
|
FunNo int `json:"generic_funNo"`
|
||||||
|
VendorID int `json:"generic_vendorId"` // direct int
|
||||||
|
DeviceID int `json:"generic_devId"` // direct int
|
||||||
|
SlotDesignation string `json:"generic_slotDesignation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccPSUDoc struct {
|
||||||
|
Items []xccPSUItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccPSUItem struct {
|
||||||
|
Power []xccPSU `json:"power"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccPSU struct {
|
||||||
|
Name int `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RatedPower int `json:"rated_power"`
|
||||||
|
PartNumber string `json:"part_number"`
|
||||||
|
FRUNumber string `json:"fru_number"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
ManufID string `json:"manuf_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccFRUDoc struct {
|
||||||
|
Items []xccFRUItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccFRUItem struct {
|
||||||
|
BuiltinFRU []map[string]string `json:"builtin_fru_device"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccSensorDoc struct {
|
||||||
|
Items []xccSensor `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccSensor struct {
|
||||||
|
Name string `json:"Sensor Name"`
|
||||||
|
Value string `json:"Value"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccEventDoc struct {
|
||||||
|
Items []xccEvent `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xccEvent struct {
|
||||||
|
Severity string `json:"severity"` // "I", "W", "E", "C"
|
||||||
|
Source string `json:"source"`
|
||||||
|
Date string `json:"date"` // "2025-12-22T13:24:02.070"
|
||||||
|
Index int `json:"index"`
|
||||||
|
EventID string `json:"eventid"`
|
||||||
|
CmnID string `json:"cmnid"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parsers ---
|
||||||
|
|
||||||
|
func parseBasicSysInfo(content []byte, result *models.AnalysisResult) {
|
||||||
|
var doc xccBasicSysInfoDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := doc.Items[0]
|
||||||
|
|
||||||
|
result.Hardware.BoardInfo = models.BoardInfo{
|
||||||
|
ProductName: strings.TrimSpace(item.MachineTypeModel),
|
||||||
|
SerialNumber: strings.TrimSpace(item.SerialNumber),
|
||||||
|
UUID: strings.TrimSpace(item.UUID),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, err := parseXCCTime(item.CurrentTime); err == nil {
|
||||||
|
result.CollectedAt = t.UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFirmware(content []byte) []models.FirmwareInfo {
|
||||||
|
var doc xccFirmwareDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.FirmwareInfo
|
||||||
|
for _, fw := range doc.Items {
|
||||||
|
if fi := xccFWEntryToModel(fw); fi != nil {
|
||||||
|
out = append(out, *fi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func xccFWEntryToModel(fw xccFWEntry) *models.FirmwareInfo {
|
||||||
|
name := strings.TrimSpace(fw.TypeStr)
|
||||||
|
version := strings.TrimSpace(fw.Version)
|
||||||
|
if name == "" && version == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
build := strings.TrimSpace(fw.Build)
|
||||||
|
v := version
|
||||||
|
if build != "" {
|
||||||
|
v = version + " (" + build + ")"
|
||||||
|
}
|
||||||
|
return &models.FirmwareInfo{
|
||||||
|
DeviceName: name,
|
||||||
|
Version: v,
|
||||||
|
BuildTime: strings.TrimSpace(fw.ReleaseDate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUs(content []byte) []models.CPU {
|
||||||
|
var doc xccCPUDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.CPU
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, c := range item.Processors {
|
||||||
|
cpu := models.CPU{
|
||||||
|
Socket: c.Name,
|
||||||
|
Model: strings.TrimSpace(c.Model),
|
||||||
|
Cores: rawJSONToInt(c.Cores),
|
||||||
|
Threads: rawJSONToInt(c.Threads),
|
||||||
|
FrequencyMHz: parseMHz(c.ClockSpeed),
|
||||||
|
L1CacheKB: parseKB(c.L1DataCache),
|
||||||
|
L2CacheKB: parseKB(c.L2Cache),
|
||||||
|
L3CacheKB: parseKB(c.L3Cache),
|
||||||
|
Status: strings.TrimSpace(c.Status),
|
||||||
|
SerialNumber: strings.TrimSpace(c.SerialNumber),
|
||||||
|
}
|
||||||
|
out = append(out, cpu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDIMMs(content []byte) []models.MemoryDIMM {
|
||||||
|
var doc xccDIMMDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.MemoryDIMM
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, m := range item.Memory {
|
||||||
|
present := !strings.EqualFold(strings.TrimSpace(m.Status), "not present") &&
|
||||||
|
!strings.EqualFold(strings.TrimSpace(m.Status), "absent")
|
||||||
|
// memory_capacity is in GB (int); convert to MB
|
||||||
|
capacityGB := rawJSONToInt(m.Capacity)
|
||||||
|
dimm := models.MemoryDIMM{
|
||||||
|
Slot: strings.TrimSpace(m.Name),
|
||||||
|
Location: strings.TrimSpace(m.Name),
|
||||||
|
Present: present,
|
||||||
|
SizeMB: capacityGB * 1024,
|
||||||
|
Type: strings.TrimSpace(m.Type),
|
||||||
|
MaxSpeedMHz: rawJSONToInt(m.MemSpeed),
|
||||||
|
CurrentSpeedMHz: rawJSONToInt(m.ConfigSpeed),
|
||||||
|
Manufacturer: strings.TrimSpace(m.Manufacturer),
|
||||||
|
SerialNumber: strings.TrimSpace(m.SerialNumber),
|
||||||
|
PartNumber: strings.TrimSpace(strings.TrimRight(m.PartNumber, " ")),
|
||||||
|
Status: strings.TrimSpace(m.Status),
|
||||||
|
}
|
||||||
|
out = append(out, dimm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDisks(content []byte) []models.Storage {
|
||||||
|
var doc xccDiskDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.Storage
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, d := range item.Disks {
|
||||||
|
sizeGB := parseCapacityToGB(d.CapacityStr)
|
||||||
|
stateStr := strings.TrimSpace(d.StateStr)
|
||||||
|
present := !strings.EqualFold(stateStr, "absent") &&
|
||||||
|
!strings.EqualFold(stateStr, "not present")
|
||||||
|
disk := models.Storage{
|
||||||
|
Slot: fmt.Sprintf("%d", d.SlotNo),
|
||||||
|
Type: strings.TrimSpace(d.Media),
|
||||||
|
Model: strings.TrimSpace(d.ProductName),
|
||||||
|
SizeGB: sizeGB,
|
||||||
|
SerialNumber: strings.TrimSpace(d.SerialNo),
|
||||||
|
Manufacturer: strings.TrimSpace(d.Manufacture),
|
||||||
|
Firmware: strings.TrimSpace(d.FWVersion),
|
||||||
|
Interface: strings.TrimSpace(d.Interface),
|
||||||
|
Present: present,
|
||||||
|
Status: stateStr,
|
||||||
|
}
|
||||||
|
if d.RemainLife >= 0 && d.RemainLife <= 100 {
|
||||||
|
v := d.RemainLife
|
||||||
|
disk.RemainingEndurancePct = &v
|
||||||
|
}
|
||||||
|
out = append(out, disk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCards(content []byte) []models.PCIeDevice {
|
||||||
|
var doc xccCardDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.PCIeDevice
|
||||||
|
for _, card := range doc.Items {
|
||||||
|
slot := strings.TrimSpace(card.ConnectorLabel)
|
||||||
|
if slot == "" {
|
||||||
|
slot = fmt.Sprintf("%d", card.SlotNo)
|
||||||
|
}
|
||||||
|
dev := models.PCIeDevice{
|
||||||
|
Slot: slot,
|
||||||
|
Description: strings.TrimSpace(card.AdapterName),
|
||||||
|
}
|
||||||
|
if len(card.Functions) > 0 {
|
||||||
|
fn := card.Functions[0]
|
||||||
|
dev.BDF = fmt.Sprintf("%02x:%02x.%x", fn.BusNo, fn.DevNo, fn.FunNo)
|
||||||
|
dev.VendorID = fn.VendorID
|
||||||
|
dev.DeviceID = fn.DeviceID
|
||||||
|
}
|
||||||
|
out = append(out, dev)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePSUs(content []byte) []models.PSU {
|
||||||
|
var doc xccPSUDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.PSU
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, p := range item.Power {
|
||||||
|
psu := models.PSU{
|
||||||
|
Slot: fmt.Sprintf("%d", p.Name),
|
||||||
|
Present: true,
|
||||||
|
WattageW: p.RatedPower,
|
||||||
|
SerialNumber: strings.TrimSpace(p.SerialNumber),
|
||||||
|
PartNumber: strings.TrimSpace(p.PartNumber),
|
||||||
|
Vendor: strings.TrimSpace(p.ManufID),
|
||||||
|
Status: strings.TrimSpace(p.Status),
|
||||||
|
}
|
||||||
|
out = append(out, psu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFRU(content []byte) []models.FRUInfo {
|
||||||
|
var doc xccFRUDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.FRUInfo
|
||||||
|
for _, item := range doc.Items {
|
||||||
|
for _, entry := range item.BuiltinFRU {
|
||||||
|
fru := models.FRUInfo{
|
||||||
|
Description: entry["FRU Device Description"],
|
||||||
|
Manufacturer: entry["Board Mfg"],
|
||||||
|
ProductName: entry["Board Product"],
|
||||||
|
SerialNumber: entry["Board Serial"],
|
||||||
|
PartNumber: entry["Board Part Number"],
|
||||||
|
MfgDate: entry["Board Mfg Date"],
|
||||||
|
}
|
||||||
|
if fru.ProductName == "" {
|
||||||
|
fru.ProductName = entry["Product Name"]
|
||||||
|
}
|
||||||
|
if fru.SerialNumber == "" {
|
||||||
|
fru.SerialNumber = entry["Product Serial"]
|
||||||
|
}
|
||||||
|
if fru.PartNumber == "" {
|
||||||
|
fru.PartNumber = entry["Product Part Number"]
|
||||||
|
}
|
||||||
|
if fru.Description == "" && fru.ProductName == "" && fru.SerialNumber == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fru)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSensors(content []byte) []models.SensorReading {
|
||||||
|
var doc xccSensorDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.SensorReading
|
||||||
|
for _, s := range doc.Items {
|
||||||
|
name := strings.TrimSpace(s.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sr := models.SensorReading{
|
||||||
|
Name: name,
|
||||||
|
RawValue: strings.TrimSpace(s.Value),
|
||||||
|
Unit: strings.TrimSpace(s.Unit),
|
||||||
|
Status: strings.TrimSpace(s.Status),
|
||||||
|
}
|
||||||
|
if v, err := strconv.ParseFloat(sr.RawValue, 64); err == nil {
|
||||||
|
sr.Value = v
|
||||||
|
}
|
||||||
|
out = append(out, sr)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEvents(content []byte) []models.Event {
|
||||||
|
var doc xccEventDoc
|
||||||
|
if err := json.Unmarshal(content, &doc); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []models.Event
|
||||||
|
for _, e := range doc.Items {
|
||||||
|
ev := models.Event{
|
||||||
|
ID: e.EventID,
|
||||||
|
Source: strings.TrimSpace(e.Source),
|
||||||
|
Description: strings.TrimSpace(e.Message),
|
||||||
|
Severity: xccSeverity(e.Severity),
|
||||||
|
}
|
||||||
|
if t, err := parseXCCTime(e.Date); err == nil {
|
||||||
|
ev.Timestamp = t.UTC()
|
||||||
|
}
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func xccSeverity(s string) models.Severity {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||||
|
case "C":
|
||||||
|
return models.SeverityCritical
|
||||||
|
case "E":
|
||||||
|
return models.SeverityCritical
|
||||||
|
case "W":
|
||||||
|
return models.SeverityWarning
|
||||||
|
default:
|
||||||
|
return models.SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseXCCTime(s string) (time.Time, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
formats := []string{
|
||||||
|
"2006-01-02T15:04:05.000",
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02 15:04:05",
|
||||||
|
}
|
||||||
|
for _, f := range formats {
|
||||||
|
if t, err := time.Parse(f, s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unparseable time: %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMHz parses "4100 MHz" → 4100
|
||||||
|
func parseMHz(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.Atoi(parts[0])
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKB parses "384 KB" → 384
|
||||||
|
func parseKB(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.Atoi(parts[0])
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMB parses "32768 MB" → 32768
|
||||||
|
func parseMB(s string) int {
|
||||||
|
return parseKB(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMTs parses "4800 MT/s" → 4800 (treated as MHz equivalent)
|
||||||
|
func parseMTs(s string) int {
|
||||||
|
return parseKB(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCapacityToGB parses "3.20 TB" or "480 GB" → GB integer
|
||||||
|
func parseCapacityToGB(s string) int {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
parts := strings.Fields(s)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(parts[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(parts[1]) {
|
||||||
|
case "TB":
|
||||||
|
return int(v * 1000)
|
||||||
|
case "GB":
|
||||||
|
return int(v)
|
||||||
|
case "MB":
|
||||||
|
return int(v / 1024)
|
||||||
|
}
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawJSONToInt parses a json.RawMessage that may be an int or a quoted string → int
|
||||||
|
func rawJSONToInt(raw json.RawMessage) int {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// try direct int
|
||||||
|
var n int
|
||||||
|
if err := json.Unmarshal(raw, &n); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// try string
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err == nil {
|
||||||
|
v, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHexID parses "0x15b3" → 5555
|
||||||
|
func parseHexID(s string) int {
|
||||||
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
|
s = strings.TrimPrefix(s, "0x")
|
||||||
|
v, _ := strconv.ParseInt(s, 16, 32)
|
||||||
|
return int(v)
|
||||||
|
}
|
||||||
225
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
Normal file
225
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package lenovo_xcc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
lxcc "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exampleArchive = "/Users/mchusavitin/Documents/git/logpile/example/7D76CTO1WW_JF0002KT_xcc_mini-log_20260413-122150.zip"
|
||||||
|
|
||||||
|
func TestDetect_LenovoXCCMiniLog(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
score := p.Detect(files)
|
||||||
|
if score < 80 {
|
||||||
|
t.Errorf("expected Detect score >= 80 for XCC mini-log archive, got %d", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_BasicSysInfo(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil result or hardware")
|
||||||
|
}
|
||||||
|
|
||||||
|
hw := result.Hardware
|
||||||
|
if hw.BoardInfo.SerialNumber == "" {
|
||||||
|
t.Error("BoardInfo.SerialNumber is empty")
|
||||||
|
}
|
||||||
|
if hw.BoardInfo.ProductName == "" {
|
||||||
|
t.Error("BoardInfo.ProductName is empty")
|
||||||
|
}
|
||||||
|
t.Logf("BoardInfo: serial=%s model=%s uuid=%s", hw.BoardInfo.SerialNumber, hw.BoardInfo.ProductName, hw.BoardInfo.UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_CPUs(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.CPUs) == 0 {
|
||||||
|
t.Error("expected at least one CPU, got none")
|
||||||
|
}
|
||||||
|
for i, cpu := range result.Hardware.CPUs {
|
||||||
|
t.Logf("CPU[%d]: socket=%d model=%q cores=%d threads=%d freq=%dMHz", i, cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Memory(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Memory) == 0 {
|
||||||
|
t.Error("expected memory DIMMs, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Memory: %d DIMMs", len(result.Hardware.Memory))
|
||||||
|
for i, m := range result.Hardware.Memory {
|
||||||
|
t.Logf("DIMM[%d]: slot=%s present=%v size=%dMB sn=%s", i, m.Slot, m.Present, m.SizeMB, m.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Storage(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Storage: %d disks", len(result.Hardware.Storage))
|
||||||
|
for i, s := range result.Hardware.Storage {
|
||||||
|
t.Logf("Disk[%d]: slot=%s model=%q size=%dGB sn=%s", i, s.Slot, s.Model, s.SizeGB, s.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_PCIeCards(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("PCIe cards: %d", len(result.Hardware.PCIeDevices))
|
||||||
|
for i, c := range result.Hardware.PCIeDevices {
|
||||||
|
t.Logf("Card[%d]: slot=%s desc=%q bdf=%s", i, c.Slot, c.Description, c.BDF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_PSUs(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.PowerSupply) == 0 {
|
||||||
|
t.Error("expected PSUs, got none")
|
||||||
|
}
|
||||||
|
for i, p := range result.Hardware.PowerSupply {
|
||||||
|
t.Logf("PSU[%d]: slot=%s wattage=%dW status=%s sn=%s", i, p.Slot, p.WattageW, p.Status, p.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Sensors(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Sensors) == 0 {
|
||||||
|
t.Error("expected sensors, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Sensors: %d", len(result.Sensors))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Events(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Events) == 0 {
|
||||||
|
t.Error("expected events, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Events: %d", len(result.Events))
|
||||||
|
for i, e := range result.Events {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf("Event[%d]: severity=%s ts=%s desc=%q", i, e.Severity, e.Timestamp.Format("2006-01-02T15:04:05"), e.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_FRU(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("FRU: %d entries", len(result.FRU))
|
||||||
|
for i, f := range result.FRU {
|
||||||
|
t.Logf("FRU[%d]: desc=%q product=%q serial=%q", i, f.Description, f.ProductName, f.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &lxcc.Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Firmware) == 0 {
|
||||||
|
t.Error("expected firmware entries, got none")
|
||||||
|
}
|
||||||
|
for i, f := range result.Hardware.Firmware {
|
||||||
|
t.Logf("FW[%d]: name=%q version=%q buildtime=%q", i, f.DeviceName, f.Version, f.BuildTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
||||||
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc"
|
||||||
|
|
||||||
// Generic fallback parser (must be last for lowest priority)
|
// Generic fallback parser (must be last for lowest priority)
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"
|
||||||
|
|||||||
Reference in New Issue
Block a user