feat(nvidia_bug_report): extract Xid/SXid GPU error events

Parse Xid and SXid entries from the kernel log into GPU error events with
severity and the decoded failure reason.

See ADL-055.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-27 17:05:52 +03:00
co-authored by Claude Sonnet 5
parent e7b8a8badc
commit a430a8c46f
5 changed files with 512 additions and 1 deletions
@@ -0,0 +1,77 @@
# Analyzing nvidia-bug-report.log.gz — agent instructions
Short checklist for an AI agent (or a human) given a `nvidia-bug-report-*.log.gz` and
asked "what's wrong with this box."
## 1. Structured inventory pass (LOGPile)
Run it through LOGPile's `nvidia_bug_report` parser first — it gives board/CPU/memory/
GPU/NIC/PSU inventory as structured JSON (`internal/parser/vendors/nvidia_bug_report/`).
As of this session the decompressor no longer hard-caps at 50MB (streams with a
gzip-bomb ratio guard instead — see `internal/parser/archive.go`), so large dumps
come through whole. Known gap: PSU `present` flag was buggy (fixed), sensors/FRU are
legitimately empty for this format (no `ipmitool sdr`/`fru` in host-side bug reports).
## 2. Grep passes on the raw decompressed log
In priority order:
1. `Xid (PCI:` — GPU error reports. Note **all** `PCI:xxxx` addresses and Xid codes,
count occurrences, get first/last timestamp per GPU.
2. `SXid` — NVSwitch (fabric manager) errors on HGX/NVSwitch systems.
3. `GPU recovery action changed` — tracks a GPU through `None → Drain and Reset →
GPU Reset Required` — a GPU stuck at "Reset Required" for a long span is a live
incident, not a transient blip.
4. `fell off the bus`, `Uncorrectable`, `Double Bit ECC`, `Row Remap` — hardware
memory/PCIe failures.
5. Thermal/power: `thermal slowdown`, `power slowdown`, `Reason: SW\|HW`.
6. `nvidia-smi -q` section per GPU: `ECC Errors`, `Retired Pages`, `Remapped Rows`,
`GPU Recovery Action` — cross-check current (not historical) state at capture time.
7. `segfault`, kernel panics, `Call Trace`, `MCE`/machine-check in dmesg/journalctl
excerpts.
Cross-reference: an Xid burst that stops before the `nvidia-smi -q` snapshot and shows
`GPU Recovery Action: None` + clean ECC counters at the end means the box already
self-recovered (e.g. via reboot) — still worth flagging, but not an active fire.
## 3. Classify each Xid, don't just list it
Look it up in NVIDIA's Xid catalog (link below) before writing a verdict. Same code
can mean different things depending on class:
- App-level GPU exception (bad kernel launch, illegal memory access) — usually not
hardware-actionable, correlate with the offending `pid`/process name in the log.
- NVLink/fabric fault (`MSE Degraded`, link training failures) — check
`knvlink*` messages, Fabric Manager logs — often requires GPU/node reset, can
recur → RMA candidate if repeated.
- ECC/memory retirement (uncorrectable DRAM, row-remap failures) — RMA candidate,
check `Retired Pages` / `Remapped Rows` counts.
- `XID 79` (GPU fell off the bus) — drain the node, treat as hardware failure.
## 4. Escalation bar
Recommend RMA/hardware escalation when: repeated fatal Xid/SXid on the same GPU across
multiple days, non-zero uncorrectable ECC or remap failures, or a GPU that required
manual reset more than once. Otherwise: flag as informational / correlate with workload.
## What already exists online (recipes found 2026-08-26)
- **Lambda Labs — `check-nvidia-bug-report.sh`**: an actual automated scanner for this
exact file, open source. Clones and runs against the extracted report; flags Xid
errors, thermal/power slowdown, segfaults, CPU throttling, "fell off the bus",
RmInit failures. This is the closest thing to a ready-made "recipe" — worth using
directly or mining its grep patterns.
https://github.com/lambdal-support/lambda-public-tools (see `check-nvidia-bug-report/`)
Guide: [Lambda Docs — Using the nvidia-bug-report.log file to troubleshoot your system](https://docs.lambda.ai/education/linux-usage/using-the-nvidia-bug-report.log-file-to-troubleshoot-your-system/)
- **NVIDIA — GPU Debug Guidelines**: official step-by-step (collect → `dcgmi diag` →
classify Xid → escalate/RMA criteria), the authoritative human-oriented recipe.
https://docs.nvidia.com/deploy/gpu-debug-guidelines/index.html
- **NVIDIA — Xid Errors reference**: the Xid code catalog itself (meanings, no fixed
hardware/software verdict per code — context-dependent).
https://docs.nvidia.com/deploy/xid-errors/introduction.html
- **AWS re:Post — Troubleshoot Xid errors in NVIDIA GPU-accelerated instances**:
cloud-instance-flavored version of the same workflow.
https://repost.aws/knowledge-center/ec2-linux-troubleshoot-xid-errors
No public "AI agent prompt/recipe" specifically for feeding this file to an LLM was
found — the closest things are the shell-script scanners above; an agent should treat
their grep pattern lists as a starting checklist (§2 above already folds them in).
+230
View File
@@ -0,0 +1,230 @@
package nvidia_bug_report
import (
"bufio"
"fmt"
"regexp"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// syslogLineRegex matches the kernel/journal line prefix nvidia-bug-report.sh
// captures from dmesg/journalctl: "Aug 24 13:52:04 hostname kernel: <message>".
var syslogLineRegex = regexp.MustCompile(`^(\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+\S+\s+(.+)$`)
// xidRegex matches NVRM Xid (GPU) and SXid (NVSwitch/fabric manager) error
// reports, e.g. "NVRM: Xid (PCI:0000:dc:00): 31, pid=..." or
// "NVRM: Xid (PCI:0000:1a:00): 150, MSE Degraded Fatal ...".
var xidRegex = regexp.MustCompile(`NVRM:\s*(S?Xid)\s*\(PCI:([0-9a-fA-F:.]+)\):\s*(\d+),\s*(.*)$`)
// nvlinkRxDetectFailRegex matches a specific NVLink topology-discovery retry
// message that was observed to flood the log 100k+ times in a single burst
// around a real NVLink fault; individual lines carry no extra information
// beyond "it happened again", so they are aggregated into one summary event
// instead of one event per line.
var nvlinkRxDetectFailRegex = regexp.MustCompile(`knvlink\w*RxDetect\w*: Failed to update Rx Detect Link mask`)
// fellOffBusRegex matches the classic "GPU fell off/has fallen off the bus"
// hardware fault for the (rare) case it's logged outside an Xid line — the
// same fault is usually also reported as Xid 79, already covered above.
var fellOffBusRegex = regexp.MustCompile(`(?i)fell off the bus|fallen off the bus`)
// hardwareLeaningXidCodes are Xid codes NVIDIA's GPU Debug Guidelines
// (https://docs.nvidia.com/deploy/gpu-debug-guidelines/index.html) and Xid
// catalog associate with likely hardware failures (ECC/memory retirement,
// NVLink/fabric faults, GPU falling off the bus) as opposed to an
// application-triggered exception. This is a heuristic severity hint for
// triage, not a definitive verdict — the actual cause is always
// context-dependent; see bible-local/docs/nvidia-bug-report-analysis.md.
var hardwareLeaningXidCodes = map[string]bool{
"48": true, "63": true, "64": true, "74": true, "79": true,
"92": true, "94": true, "95": true, "119": true, "120": true,
"122": true, "123": true, "134": true, "137": true, "150": true, "154": true, "155": true,
}
// gpuFault accumulates the worst status seen for one GPU's PCI address, so
// the fault can be written back onto that GPU's own inventory record — an
// event in the Event Logs table is easy to miss; a GPU card showing
// "Critical" is not.
type gpuFault struct {
severity models.Severity
lastTS time.Time
description string
}
func (f *gpuFault) absorb(severity models.Severity, ts time.Time, description string) {
if severity == models.SeverityCritical || f.severity != models.SeverityCritical {
f.severity = severity
}
if ts.After(f.lastTS) {
f.lastTS = ts
f.description = description
}
}
// parseXidAndFaultEvents scans the raw bug report for Xid/SXid GPU error
// reports and a small set of other well-known hardware fault patterns,
// records them as events, and writes the worst fault seen for each GPU back
// onto that GPU's own Status/ErrorDescription/StatusChangedAt fields so the
// inventory record itself reflects the fault, not just the event log. This
// does not attempt to classify every message — see
// bible-local/docs/nvidia-bug-report-analysis.md for the full manual triage
// checklist.
func parseXidAndFaultEvents(content string, result *models.AnalysisResult) {
scanner := bufio.NewScanner(strings.NewReader(content))
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
collectedYear := time.Now().Year()
if !result.CollectedAt.IsZero() {
collectedYear = result.CollectedAt.Year()
}
faultsByPCI := make(map[string]*gpuFault)
recordFault := func(pci string, severity models.Severity, ts time.Time, description string) {
if pci == "" {
return
}
f, ok := faultsByPCI[pci]
if !ok {
f = &gpuFault{}
faultsByPCI[pci] = f
}
f.absorb(severity, ts, description)
}
var nvlinkFloodCount int
var nvlinkFloodFirst, nvlinkFloodLast time.Time
for scanner.Scan() {
line := scanner.Text()
m := syslogLineRegex.FindStringSubmatch(line)
if m == nil {
continue
}
timeStr, message := m[1], m[2]
ts := parseSyslogTimestamp(timeStr, collectedYear, result.CollectedAt)
if xm := xidRegex.FindStringSubmatch(message); xm != nil {
kind, pci, code, rest := xm[1], xm[2], xm[3], strings.TrimSpace(xm[4])
severity := models.SeverityWarning
if hardwareLeaningXidCodes[code] || strings.Contains(rest, "GPU Reset Required") {
severity = models.SeverityCritical
}
eventType := "GPU Xid Error"
if kind == "SXid" {
eventType = "NVSwitch SXid Error"
}
description := fmt.Sprintf("%s %s reported on %s", kind, code, pci)
result.Events = append(result.Events, models.Event{
Timestamp: ts,
Source: "NVIDIA Driver",
SensorType: "GPU",
SensorName: pci,
EventType: eventType,
Severity: severity,
Description: description,
RawData: rest,
})
if kind == "Xid" {
recordFault(pci, severity, ts, description+": "+rest)
}
continue
}
if fellOffBusRegex.MatchString(message) {
result.Events = append(result.Events, models.Event{
Timestamp: ts,
Source: "NVIDIA Driver",
SensorType: "GPU",
EventType: "GPU Fell Off The Bus",
Severity: models.SeverityCritical,
Description: "GPU fell off the PCIe bus",
RawData: strings.TrimSpace(message),
})
continue
}
if nvlinkRxDetectFailRegex.MatchString(message) {
nvlinkFloodCount++
// Track true chronological min/max, not scan order: a
// nvidia-bug-report.sh dump concatenates multiple overlapping
// log sources (e.g. dmesg -T and journalctl excerpts covering
// different but overlapping windows), so later lines in the
// file are not guaranteed to be later in time.
if !ts.IsZero() {
if nvlinkFloodFirst.IsZero() || ts.Before(nvlinkFloodFirst) {
nvlinkFloodFirst = ts
}
if ts.After(nvlinkFloodLast) {
nvlinkFloodLast = ts
}
}
}
}
if nvlinkFloodCount > 0 {
severity := models.SeverityWarning
if nvlinkFloodCount > 100 {
severity = models.SeverityCritical
}
result.Events = append(result.Events, models.Event{
Timestamp: nvlinkFloodLast,
Source: "NVIDIA Driver",
SensorType: "GPU",
EventType: "NVLink Rx Detect Failure Burst",
Severity: severity,
Description: fmt.Sprintf("NVLink Rx Detect Link mask update failed %d times between %s and %s", nvlinkFloodCount, nvlinkFloodFirst.Format(time.RFC3339), nvlinkFloodLast.Format(time.RFC3339)),
})
}
applyGPUFaultStatus(result, faultsByPCI)
}
// applyGPUFaultStatus writes each GPU's worst known fault onto its own
// Status/ErrorDescription/StatusChangedAt fields, matched by PCI address
// (Xid lines report "0000:1a:00", GPU.BDF is "0000:1a:00.0" — compare on the
// bus:device:slot prefix, ignoring the trailing PCI function digit).
func applyGPUFaultStatus(result *models.AnalysisResult, faultsByPCI map[string]*gpuFault) {
if len(faultsByPCI) == 0 || result.Hardware == nil {
return
}
for i := range result.Hardware.GPUs {
gpu := &result.Hardware.GPUs[i]
base := gpu.BDF
if idx := strings.LastIndex(base, "."); idx != -1 {
base = base[:idx]
}
f, ok := faultsByPCI[base]
if !ok || gpu.Status == "Excluded" {
continue
}
gpu.Status = string(f.severity)
gpu.ErrorDescription = f.description
ts := f.lastTS
gpu.StatusChangedAt = &ts
}
}
// parseSyslogTimestamp parses a syslog-style "Mon _2 15:04:05" timestamp
// (no year) using collectedYear as the assumed year, then corrects for a
// year rollover if that guess would put the event implausibly after the
// report was collected (e.g. a December log line in a January report).
func parseSyslogTimestamp(raw string, collectedYear int, collectedAt time.Time) time.Time {
ts, err := parser.ParseInDefaultArchiveLocation("Jan _2 15:04:05 2006", fmt.Sprintf("%s %d", raw, collectedYear))
if err != nil {
return time.Time{}
}
if !collectedAt.IsZero() && ts.After(collectedAt.Add(48*time.Hour)) {
ts, err = parser.ParseInDefaultArchiveLocation("Jan _2 15:04:05 2006", fmt.Sprintf("%s %d", raw, collectedYear-1))
if err != nil {
return time.Time{}
}
}
return ts
}
+199
View File
@@ -0,0 +1,199 @@
package nvidia_bug_report
import (
"strings"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func eventsByType(events []models.Event, eventType string) []models.Event {
var out []models.Event
for _, e := range events {
if e.EventType == eventType {
out = append(out, e)
}
}
return out
}
func TestParseXidAndFaultEvents_ClassifiesByCode(t *testing.T) {
content := strings.Join([]string{
"Jul 29 02:33:16 host kernel: NVRM: Xid (PCI:0000:dc:00): 31, pid=123, name=python, MMU Fault",
"Aug 19 21:17:46 host kernel: NVRM: Xid (PCI:0000:1a:00): 154, GPU recovery action changed from 0x0 (None) to 0x4 (Drain and Reset)",
"Aug 23 02:36:37 host kernel: NVRM: Xid (PCI:0000:1a:00): 154, GPU recovery action changed from 0x4 (Drain and Reset) to 0x1 (GPU Reset Required)",
}, "\n")
result := &models.AnalysisResult{
CollectedAt: time.Date(2026, 8, 24, 13, 56, 0, 0, time.UTC),
}
parseXidAndFaultEvents(content, result)
xids := eventsByType(result.Events, "GPU Xid Error")
if len(xids) != 3 {
t.Fatalf("expected 3 Xid events, got %d", len(xids))
}
if xids[0].Severity != models.SeverityWarning {
t.Fatalf("expected Xid 31 to be warning, got %s", xids[0].Severity)
}
if xids[0].SensorName != "0000:dc:00" {
t.Fatalf("expected sensor name 0000:dc:00, got %q", xids[0].SensorName)
}
if xids[0].Timestamp.Month() != time.July || xids[0].Timestamp.Year() != 2026 {
t.Fatalf("expected July 2026 timestamp, got %s", xids[0].Timestamp)
}
if xids[1].Severity != models.SeverityCritical {
t.Fatalf("expected Xid 154 (Drain and Reset) to be critical, got %s", xids[1].Severity)
}
if xids[2].Severity != models.SeverityCritical {
t.Fatalf("expected Xid 154 (GPU Reset Required) to be critical, got %s", xids[2].Severity)
}
}
func TestParseXidAndFaultEvents_Xid137IsHardwareLeaning(t *testing.T) {
// Xid 137 (NVLink TLC RX PRIV Error) is an NVLink transport-layer
// hardware fault, typically seen right before a contained Xid 94 —
// must classify as critical, not the app-level default.
content := "Aug 18 04:45:34 host kernel: NVRM: Xid (PCI:0000:63:00): 137, TLC RX interrupt hit on link 2 on GPU0: PRIV Error"
result := &models.AnalysisResult{CollectedAt: time.Date(2026, 8, 18, 17, 40, 0, 0, time.UTC)}
parseXidAndFaultEvents(content, result)
xids := eventsByType(result.Events, "GPU Xid Error")
if len(xids) != 1 {
t.Fatalf("expected 1 Xid event, got %d", len(xids))
}
if xids[0].Severity != models.SeverityCritical {
t.Fatalf("expected Xid 137 to be critical, got %s", xids[0].Severity)
}
}
func TestParseXidAndFaultEvents_SXidUsesDistinctEventType(t *testing.T) {
content := "Aug 20 01:00:00 host kernel: NVRM: SXid (PCI:0000:90:00): 12028, some NVSwitch fault detail"
result := &models.AnalysisResult{CollectedAt: time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC)}
parseXidAndFaultEvents(content, result)
sxids := eventsByType(result.Events, "NVSwitch SXid Error")
if len(sxids) != 1 {
t.Fatalf("expected 1 SXid event, got %d", len(sxids))
}
}
func TestParseXidAndFaultEvents_AggregatesNVLinkFloodIntoOneEvent(t *testing.T) {
var lines []string
for i := 0; i < 500; i++ {
lines = append(lines, "Aug 24 08:49:26 host kernel: NVRM: knvlinkUpdatePostRxDetectLinkMask_IMPL: Failed to update Rx Detect Link mask!")
}
content := strings.Join(lines, "\n")
result := &models.AnalysisResult{CollectedAt: time.Date(2026, 8, 24, 13, 0, 0, 0, time.UTC)}
parseXidAndFaultEvents(content, result)
floods := eventsByType(result.Events, "NVLink Rx Detect Failure Burst")
if len(floods) != 1 {
t.Fatalf("expected exactly 1 aggregated flood event for 500 lines, got %d", len(floods))
}
if floods[0].Severity != models.SeverityCritical {
t.Fatalf("expected flood of 500 to be critical, got %s", floods[0].Severity)
}
if !strings.Contains(floods[0].Description, "500 times") {
t.Fatalf("expected description to mention count, got %q", floods[0].Description)
}
}
// TestParseXidAndFaultEvents_FloodTimeRangeIsChronologicalNotScanOrder is a
// regression test: a real nvidia-bug-report.sh dump concatenates multiple
// overlapping log sources (e.g. dmesg -T and journalctl excerpts), so a
// later line in the file is not guaranteed to be later in time. The flood
// summary's first/last timestamps must reflect the true chronological
// min/max of all matched lines, not the order they were scanned in.
func TestParseXidAndFaultEvents_FloodTimeRangeIsChronologicalNotScanOrder(t *testing.T) {
content := strings.Join([]string{
// Appears first in the file but is chronologically the latest.
"Aug 24 08:49:26 host kernel: NVRM: knvlinkUpdatePostRxDetectLinkMask_IMPL: Failed to update Rx Detect Link mask!",
// Appears later in the file (a different, earlier-covering log section) but predates the line above.
"Aug 19 21:30:00 host kernel: NVRM: knvlinkUpdatePostRxDetectLinkMask_IMPL: Failed to update Rx Detect Link mask!",
}, "\n")
result := &models.AnalysisResult{CollectedAt: time.Date(2026, 8, 24, 13, 0, 0, 0, time.UTC)}
parseXidAndFaultEvents(content, result)
floods := eventsByType(result.Events, "NVLink Rx Detect Failure Burst")
if len(floods) != 1 {
t.Fatalf("expected 1 aggregated flood event, got %d", len(floods))
}
if !strings.Contains(floods[0].Description, "2026-08-19") {
t.Fatalf("expected description to mention the true earliest date (2026-08-19), got %q", floods[0].Description)
}
}
func TestParseXidAndFaultEvents_FellOffBus(t *testing.T) {
content := "Aug 24 08:00:00 host kernel: NVRM: GPU 0000:1a:00.0: GPU has fallen off the bus."
result := &models.AnalysisResult{CollectedAt: time.Date(2026, 8, 24, 13, 0, 0, 0, time.UTC)}
parseXidAndFaultEvents(content, result)
events := eventsByType(result.Events, "GPU Fell Off The Bus")
if len(events) != 1 {
t.Fatalf("expected 1 fell-off-bus event, got %d", len(events))
}
if events[0].Severity != models.SeverityCritical {
t.Fatalf("expected critical severity, got %s", events[0].Severity)
}
}
func TestParseXidAndFaultEvents_WritesFaultStatusOntoGPURecord(t *testing.T) {
content := strings.Join([]string{
"Jul 29 02:33:16 host kernel: NVRM: Xid (PCI:0000:dc:00): 31, pid=123, name=python, MMU Fault",
"Aug 19 21:17:46 host kernel: NVRM: Xid (PCI:0000:1a:00): 154, GPU recovery action changed from 0x0 (None) to 0x4 (Drain and Reset)",
"Aug 23 02:36:37 host kernel: NVRM: Xid (PCI:0000:1a:00): 154, GPU recovery action changed from 0x4 (Drain and Reset) to 0x1 (GPU Reset Required)",
}, "\n")
result := &models.AnalysisResult{
CollectedAt: time.Date(2026, 8, 24, 13, 56, 0, 0, time.UTC),
Hardware: &models.HardwareConfig{
GPUs: []models.GPU{
{BDF: "0000:1a:00.0", Model: "NVIDIA B200"},
{BDF: "0000:dc:00.0", Model: "NVIDIA B200"},
{BDF: "0000:3b:00.0", Model: "NVIDIA B200"}, // untouched — no Xid for this one
},
},
}
parseXidAndFaultEvents(content, result)
faulted := result.Hardware.GPUs[0]
if faulted.Status != "critical" {
t.Fatalf("expected 0000:1a:00.0 status critical, got %q", faulted.Status)
}
if faulted.ErrorDescription == "" {
t.Fatalf("expected a non-empty error description")
}
if faulted.StatusChangedAt == nil || !faulted.StatusChangedAt.Equal(time.Date(2026, 8, 23, 2, 36, 37, 0, parser.DefaultArchiveLocation())) {
t.Fatalf("expected status_changed_at to be the latest fault timestamp, got %v", faulted.StatusChangedAt)
}
warned := result.Hardware.GPUs[1]
if warned.Status != "warning" {
t.Fatalf("expected 0000:dc:00.0 status warning, got %q", warned.Status)
}
untouched := result.Hardware.GPUs[2]
if untouched.Status != "" {
t.Fatalf("expected 0000:3b:00.0 to be untouched, got status %q", untouched.Status)
}
}
func TestParseSyslogTimestamp_HandlesYearRollover(t *testing.T) {
// Report collected early January; a log line stamped "Dec 30" must be
// attributed to the previous year, not the future.
collectedAt := time.Date(2027, 1, 5, 12, 0, 0, 0, time.UTC)
ts := parseSyslogTimestamp("Dec 30 10:00:00", collectedAt.Year(), collectedAt)
if ts.Year() != 2026 {
t.Fatalf("expected rollover to previous year 2026, got %d", ts.Year())
}
}
+4 -1
View File
@@ -13,7 +13,7 @@ import (
)
// parserVersion - version of this parser module
const parserVersion = "1.2"
const parserVersion = "1.3"
var bugReportDateLineRegex = regexp.MustCompile(`(?m)^Date:\s+(.+?)\s*$`)
var dateWithTZAbbrevRegex = regexp.MustCompile(`^([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+([A-Za-z]{2,5})\s+(\d{4})$`)
@@ -126,6 +126,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
// Parse driver version
parseDriverVersion(content, result)
// Parse Xid/SXid GPU error reports and other known hardware fault patterns
parseXidAndFaultEvents(content, result)
return result, nil
}
+2
View File
@@ -32,6 +32,7 @@ func parsePSUInfo(content string, result *models.AnalysisResult) {
if currentPSU != nil && currentPSU.Slot != "" {
// Only add if PSU is present
if strings.Contains(strings.ToLower(currentPSU.Status), "present") {
currentPSU.Present = true
result.Hardware.PowerSupply = append(result.Hardware.PowerSupply, *currentPSU)
}
}
@@ -91,6 +92,7 @@ func parsePSUInfo(content string, result *models.AnalysisResult) {
// Save last PSU if exists
if currentPSU != nil && currentPSU.Slot != "" {
if strings.Contains(strings.ToLower(currentPSU.Status), "present") {
currentPSU.Present = true
result.Hardware.PowerSupply = append(result.Hardware.PowerSupply, *currentPSU)
}
}