feat(parser): lenovo xcc vroc volume parsing - v1.2

Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including
RAID level, capacity (GiB/TiB support added), status and member drives.
Add Drives []string to StorageVolume model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:58:50 +03:00
parent 835df2676c
commit aba7a54990
11 changed files with 1147 additions and 2236 deletions

View File

@@ -257,15 +257,16 @@ type Storage struct {
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
type StorageVolume struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
Drives []string `json:"drives,omitempty"` // member drive names/labels
}
// PCIeDevice represents a PCIe device

View File

@@ -9,6 +9,7 @@ package lenovo_xcc
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@@ -17,7 +18,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.1"
const parserVersion = "1.2"
func init() {
parser.Register(&Parser{})
@@ -88,6 +89,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
result.Hardware.Storage = parseDisks(f.Content)
}
if f := findByPath(files, "tmp/inventory_volume.log"); f != nil {
result.Hardware.Volumes = parseVolumes(f.Content)
}
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
result.Hardware.PCIeDevices = parseCards(f.Content)
}
@@ -103,6 +107,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
for _, f := range findEventFiles(files) {
result.Events = append(result.Events, parseEvents(f.Content)...)
}
applyDIMMWarningsFromEvents(result)
result.Protocol = "ipmi"
result.SourceType = models.SourceTypeArchive
@@ -297,6 +302,25 @@ type xccEventDoc struct {
Items []xccEvent `json:"items"`
}
type xccVolumeDoc struct {
Items []xccVolumeItem `json:"items"`
}
type xccVolumeItem struct {
Volumes []xccVolume `json:"volumes"`
TotalCapacityStr string `json:"totalCapacityStr"`
}
type xccVolume struct {
ID int `json:"id"`
Name string `json:"name"`
Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1"
RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1"
CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB"
Status int `json:"status"`
StatusStr string `json:"statusStr"` // e.g. "Optimal"
}
type xccEvent struct {
Severity string `json:"severity"` // "I", "W", "E", "C"
Source string `json:"source"`
@@ -462,6 +486,37 @@ func parseDisks(content []byte) []models.Storage {
return out
}
func parseVolumes(content []byte) []models.StorageVolume {
var doc xccVolumeDoc
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
return nil
}
var out []models.StorageVolume
for _, item := range doc.Items {
for _, v := range item.Volumes {
vol := models.StorageVolume{
ID: fmt.Sprintf("%d", v.ID),
Name: strings.TrimSpace(v.Name),
RAIDLevel: strings.TrimSpace(v.RDLvlStr),
SizeGB: parseCapacityToGB(v.CapacityStr),
Status: strings.TrimSpace(v.StatusStr),
}
drives := strings.TrimSpace(v.Drives)
if drives != "" {
for _, d := range strings.Split(drives, ",") {
vol.Drives = append(vol.Drives, strings.TrimSpace(d))
}
// M.2 NVMe volumes are managed by Intel VROC (VMD)
if strings.Contains(strings.ToLower(drives), "m.2") {
vol.Controller = "Intel VROC"
}
}
out = append(out, vol)
}
}
return out
}
func parseCards(content []byte) []models.PCIeDevice {
var doc xccCardDoc
if err := json.Unmarshal(content, &doc); err != nil {
@@ -613,6 +668,96 @@ func isUnqualifiedDIMM(value string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
}
var (
unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`)
unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`)
)
func applyDIMMWarningsFromEvents(result *models.AnalysisResult) {
if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 {
return
}
for _, ev := range result.Events {
if !isUnqualifiedDIMM(ev.Description) {
continue
}
idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description)
if idx < 0 {
continue
}
dimm := &result.Hardware.Memory[idx]
dimm.Status = "Warning"
dimm.ErrorDescription = ev.Description
if !ev.Timestamp.IsZero() {
ts := ev.Timestamp.UTC()
dimm.StatusChangedAt = &ts
dimm.StatusCheckedAt = &ts
}
appendDIMMStatusHistory(dimm, ev)
}
}
func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int {
slot := extractUnqualifiedDIMMSlot(description)
serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description))
for i := range memory {
if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) {
return i
}
}
for i := range memory {
if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial {
return i
}
}
return -1
}
func extractUnqualifiedDIMMSlot(description string) string {
m := unqualifiedDIMMSlotRE.FindStringSubmatch(description)
if len(m) < 2 {
return ""
}
return "DIMM " + strings.TrimSpace(m[1])
}
func extractUnqualifiedDIMMSerial(description string) string {
m := unqualifiedDIMMSerialRE.FindStringSubmatch(description)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func normalizeUnqualifiedDIMMSerial(serial string) string {
serial = strings.ToUpper(strings.TrimSpace(serial))
if idx := strings.Index(serial, "-"); idx >= 0 {
serial = serial[:idx]
}
return serial
}
func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) {
if dimm == nil || ev.Timestamp.IsZero() {
return
}
for _, item := range dimm.StatusHistory {
if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") &&
item.ChangedAt.Equal(ev.Timestamp.UTC()) &&
strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) {
return
}
}
dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{
Status: "Warning",
ChangedAt: ev.Timestamp.UTC(),
Details: ev.Description,
})
}
func parseXCCTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
formats := []string{
@@ -674,8 +819,12 @@ func parseCapacityToGB(s string) int {
switch strings.ToUpper(parts[1]) {
case "TB":
return int(v * 1000)
case "TIB":
return int(v * 1099.511627776) // 1 TiB = 1099.511... GB
case "GB":
return int(v)
case "GIB":
return int(v * 1.073741824) // 1 GiB = 1.073741824 GB
case "MB":
return int(v / 1024)
}

View File

@@ -2,6 +2,7 @@ package lenovo_xcc
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
@@ -224,6 +225,75 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
}
}
func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.Volumes) == 0 {
t.Error("expected at least one VROC volume, got none")
}
for i, v := range result.Hardware.Volumes {
t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v",
i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives)
if v.RAIDLevel == "" {
t.Errorf("Volume[%d]: RAIDLevel is empty", i)
}
if v.Status == "" {
t.Errorf("Volume[%d]: Status is empty", i)
}
}
}
func TestParseVolumes_IntelVROC(t *testing.T) {
content := []byte(`{
"identifier": "storage.id",
"items": [{
"volumes": [{
"id": 1,
"name": "",
"drives": "M.2 Drive 0, M.2 Drive 1",
"rdlvlstr": "RAID 1",
"capacityStr": "893.750 GiB",
"status": 3,
"statusStr": "Optimal"
}],
"totalCapacityStr": "893.750 GiB"
}]
}`)
vols := parseVolumes(content)
if len(vols) != 1 {
t.Fatalf("expected 1 volume, got %d", len(vols))
}
v := vols[0]
if v.ID != "1" {
t.Errorf("expected ID=1, got %q", v.ID)
}
if v.RAIDLevel != "RAID 1" {
t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel)
}
if v.Status != "Optimal" {
t.Errorf("expected Status=Optimal, got %q", v.Status)
}
if v.Controller != "Intel VROC" {
t.Errorf("expected Controller=Intel VROC, got %q", v.Controller)
}
if len(v.Drives) != 2 {
t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives)
}
if v.SizeGB < 900 || v.SizeGB > 1000 {
t.Errorf("expected SizeGB ~960, got %d", v.SizeGB)
}
}
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
content := []byte(`{
"items": [{
@@ -256,3 +326,41 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
t.Fatalf("expected warning severity, got %q", got)
}
}
func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
result := &models.AnalysisResult{
Events: []models.Event{
{
Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC),
Severity: models.SeverityWarning,
Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.",
},
},
Hardware: &models.HardwareConfig{
Memory: []models.MemoryDIMM{
{
Slot: "DIMM 3",
Present: true,
SerialNumber: "80CE042328460C5D88",
Status: "Normal",
},
},
},
}
applyDIMMWarningsFromEvents(result)
dimm := result.Hardware.Memory[0]
if dimm.Status != "Warning" {
t.Fatalf("expected DIMM status Warning, got %q", dimm.Status)
}
if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description {
t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription)
}
if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) {
t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt)
}
if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" {
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
}
}

View File

@@ -50,11 +50,20 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, map[string]string{
"AppVersion": s.config.AppVersion,
"AppCommit": s.config.AppCommit,
"AppVersion": normalizeDisplayVersion(s.config.AppVersion),
"AppCommit": s.config.AppCommit,
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
})
}
func normalizeDisplayVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
return strings.TrimPrefix(v, "v")
}
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
title := chartTitle(result)
@@ -2045,14 +2054,14 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
func toCollectorRequest(req CollectRequest) collector.Request {
return collector.Request{
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
DebugPayloads: req.DebugPayloads,
}
}

View File

@@ -19,10 +19,11 @@ import (
var WebFS embed.FS
type Config struct {
Port int
PreloadFile string
AppVersion string
AppCommit string
Port int
PreloadFile string
AppVersion string
AppCommit string
ChartVersion string
}
type Server struct {