6 Commits

Author SHA1 Message Date
Mikhail Chusavitin
63ed8a384e feat: sync with hardware ingest contract v2.10
- PCIeDevice: add model, firmware, present, iommu_group, telemetry fields
  (temperature_c, power_w, ecc_corrected_total, ecc_uncorrected_total,
  hw_slowdown) — were silently dropped on JSON parse, breaking bee audit display
- buildDevicesFromLegacy: use pcie.Model as fallback (PartNumber > Model >
  Description), copy MACAddresses/Present/Firmware, propagate telemetry into
  Details so convertPCIeFromDevices picks them up
- Storage: add logical_block_size_bytes, physical_block_size_bytes,
  metadata_bytes_per_block (contract v2.10, 2026-04-29) to models, exporter
  struct and converter pipeline
- ReanimatorHardware: add platform_config map[string]any (contract v2.9)
- Update internal/chart submodule to v2.0 (contract 2.10 viewer support:
  event_logs section, platform_config section, storage block size columns)
- Update bible submodule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 15:53:47 +03:00
Mikhail Chusavitin
1162ccd22e Trim noisy Lenovo Redfish collection paths 2026-04-29 17:02:40 +03:00
Mikhail Chusavitin
3887df6547 Improve Lenovo XCC inventory enrichment 2026-04-29 16:38:30 +03:00
Mikhail Chusavitin
a82fb227e5 submodule update 2026-04-16 15:33:48 +03:00
c9969fc3da feat(parser): lenovo xcc warnings and redfish logs - v1.1 2026-04-13 20:34:04 +03:00
89b6701f43 feat(parser): add Lenovo XCC mini-log parser 2026-04-13 20:20:37 +03:00
17 changed files with 1755 additions and 78 deletions

2
bible

Submodule bible updated: 456c1f022c...d2600f1279

View File

@@ -55,6 +55,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
| `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
@@ -194,6 +195,7 @@ and `LogDump/` trees.
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
| Lenovo XCC mini-log | `lenovo_xcc` | Ready | ThinkSystem SR650 V3 XCC mini-log ZIP |
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
| Unraid | `unraid` | Ready | Unraid diagnostics archives |

View File

@@ -57,6 +57,11 @@ Current behavior:
7. Packages any already-present binaries from `bin/`
8. Generates `SHA256SUMS.txt`
Release tag format:
- project release tags use `vN.M`
- do not create `vN.M.P` tags for LOGPile releases
- release artifacts and `main.version` inherit the exact git tag string
Important limitation:
- `scripts/release.sh` does not run `make build-all` for you
- if you want Linux or additional macOS archives in the release directory, build them before running the script

View File

@@ -1137,3 +1137,20 @@ presented in the UI as "Сбор расширенных данных для ди
- Default live collection skips those heavy diagnostic plan-B retries and reaches replay faster.
- Operators can explicitly opt into the slower diagnostic path when they need deeper collection.
- The same user-facing toggle continues to enable extra debug payload capture for troubleshooting.
---
## ADL-044 — LOGPile project release tags use `vN.M`
**Date:** 2026-04-13
**Context:** The repository accumulated release tags in `vN.M.P` form, while the shared module
versioning contract in `bible/rules/patterns/module-versioning/contract.md` standardizes version
shape as `N.M`. Release tooling reads the git tag verbatim into build metadata and release
artifacts, so inconsistent tag shape leaks directly into packaged versions.
**Decision:** Use `vN.M` for LOGPile project release tags going forward. Do not create new
`vN.M.P` tags for repository releases. Build metadata, release directory names, and release notes
continue to inherit the exact git tag string from `git describe --tags`.
**Consequences:**
- Future project releases have a two-component version string such as `v1.12`.
- Release artifacts and `--version` output stay aligned with the tag shape without extra mapping.
- Existing historical `vN.M.P` tags remain as-is unless explicitly rewritten.

View File

@@ -50,11 +50,15 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
}
for _, systemPath := range systemPaths {
collectFrom(joinPath(systemPath, "/LogServices"), isHardwareLogService)
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, systemPath, "LogServices") {
collectFrom(logServicesPath, isHardwareLogService)
}
}
// Managers hold the IPMI SEL on AMI/MSI BMCs — include only the "SEL" service.
for _, managerPath := range managerPaths {
collectFrom(joinPath(managerPath, "/LogServices"), isManagerSELService)
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, managerPath, "LogServices") {
collectFrom(logServicesPath, isManagerSELService)
}
}
if len(out) > 0 {
@@ -63,6 +67,42 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
return out
}
func (c *RedfishConnector) redfishLinkedCollectionPaths(
ctx context.Context,
client *http.Client,
req Request,
baseURL, resourcePath, linkKey string,
) []string {
resourcePath = normalizeRedfishPath(resourcePath)
if resourcePath == "" || strings.TrimSpace(linkKey) == "" {
return nil
}
seen := make(map[string]struct{}, 2)
var out []string
add := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if _, ok := seen[path]; ok {
return
}
seen[path] = struct{}{}
out = append(out, path)
}
add(joinPath(resourcePath, "/"+strings.TrimSpace(linkKey)))
resourceDoc, err := c.getJSON(ctx, client, req, baseURL, resourcePath)
if err == nil {
if linked := redfishLinkedPath(resourceDoc, linkKey); linked != "" {
add(linked)
}
}
return out
}
// fetchRedfishLogEntriesWithPaging fetches entries from a LogEntry collection,
// following nextLink pages. Stops early when entries older than cutoff are encountered
// (assumes BMC returns entries newest-first, which is typical).
@@ -182,7 +222,7 @@ func redfishLogServiceEntriesPath(svc map[string]interface{}) string {
// Audit, authentication, and session events are excluded.
func isHardwareLogEntry(entry map[string]interface{}) bool {
entryType := strings.TrimSpace(asString(entry["EntryType"]))
if strings.EqualFold(entryType, "Oem") {
if strings.EqualFold(entryType, "Oem") && !strings.EqualFold(strings.TrimSpace(asString(entry["OemRecordFormat"])), "Lenovo") {
return false
}
@@ -362,6 +402,9 @@ func parseIPMIDumpKV(message string) map[string]string {
// AMI/MSI BMCs often set Severity="OK" on all SEL records regardless of content,
// so we fall back to inferring severity from SensorType when the explicit field is unhelpful.
func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
if redfishLogEntryLooksLikeWarning(entry) {
return models.SeverityWarning
}
// Newer Redfish uses MessageSeverity; older uses Severity.
raw := strings.ToLower(firstNonEmpty(
strings.TrimSpace(asString(entry["MessageSeverity"])),
@@ -380,6 +423,16 @@ func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
}
}
func redfishLogEntryLooksLikeWarning(entry map[string]interface{}) bool {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(entry["Message"]),
asString(entry["Name"]),
asString(entry["SensorType"]),
asString(entry["EntryCode"]),
}, " ")))
return strings.Contains(joined, "unqualified dimm")
}
// redfishSeverityFromSensorType infers event severity from the IPMI/Redfish SensorType string.
func redfishSeverityFromSensorType(sensorType string) models.Severity {
switch strings.ToLower(sensorType) {

View File

@@ -0,0 +1,125 @@
package collector
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
func TestCollectRedfishLogEntries_UsesLinkedManagerLogServicesPath(t *testing.T) {
mux := http.NewServeMux()
register := func(path string, payload interface{}) {
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(payload)
})
}
register("/redfish/v1/Managers/1", map[string]interface{}{
"Id": "1",
"LogServices": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/LogServices",
},
})
register("/redfish/v1/Systems/1/LogServices", map[string]interface{}{
"Members": []map[string]string{
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL"},
},
})
register("/redfish/v1/Systems/1/LogServices/SEL", map[string]interface{}{
"Id": "SEL",
"Entries": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries",
},
})
register("/redfish/v1/Systems/1/LogServices/SEL/Entries", map[string]interface{}{
"Members": []map[string]string{
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries/1"},
},
})
register("/redfish/v1/Systems/1/LogServices/SEL/Entries/1", map[string]interface{}{
"Id": "1",
"Created": time.Now().UTC().Format(time.RFC3339),
"Message": "System found Unqualified DIMM in slot DIMM A1",
"MessageSeverity": "OK",
"SensorType": "Memory",
"EntryType": "Event",
})
ts := httptest.NewServer(mux)
defer ts.Close()
c := NewRedfishConnector()
got := c.collectRedfishLogEntries(context.Background(), ts.Client(), Request{
Host: ts.URL,
Port: 443,
Protocol: "redfish",
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "strict",
}, ts.URL, nil, []string{"/redfish/v1/Managers/1"})
if len(got) != 1 {
t.Fatalf("expected 1 collected log entry, got %d", len(got))
}
if got[0]["Message"] != "System found Unqualified DIMM in slot DIMM A1" {
t.Fatalf("unexpected collected message: %#v", got[0]["Message"])
}
}
func TestParseRedfishLogEntries_UnqualifiedDIMMBecomesWarning(t *testing.T) {
rawPayloads := map[string]any{
"redfish_log_entries": []any{
map[string]any{
"Id": "sel-1",
"Created": "2026-04-13T12:00:00Z",
"Message": "System found Unqualified DIMM in slot DIMM A1",
"MessageSeverity": "OK",
"SensorType": "Memory",
"EntryType": "Event",
},
},
}
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
if events[0].Description != "System found Unqualified DIMM in slot DIMM A1" {
t.Fatalf("unexpected description: %q", events[0].Description)
}
}
func TestParseRedfishLogEntries_LenovoOEMEntryIsKept(t *testing.T) {
rawPayloads := map[string]any{
"redfish_log_entries": []any{
map[string]any{
"Id": "plat-55",
"Created": "2026-04-13T12:00:00Z",
"Message": "DIMM A1 is unqualified",
"MessageSeverity": "Warning",
"SensorType": "Memory",
"EntryType": "Oem",
"OemRecordFormat": "Lenovo",
"EntryCode": "Assert",
},
},
}
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
if len(events) != 1 {
t.Fatalf("expected 1 Lenovo OEM event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
}

View File

@@ -352,7 +352,15 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
})
wantExcluded := []string{"/Sensors/", "/Oem/Lenovo/LEDs/", "/Oem/Lenovo/Slots/"}
wantExcluded := []string{
"/Sensors/",
"/Oem/Lenovo/LEDs/",
"/Oem/Lenovo/Slots/",
"/Oem/Lenovo/Configuration",
"/NetworkProtocol/Oem/Lenovo/",
"/VirtualMedia/",
"/ThermalSubsystem/Fans/",
}
for _, want := range wantExcluded {
found := false
for _, ex := range plan.Tuning.SnapshotExcludeContains {
@@ -367,6 +375,46 @@ func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.
}
}
func TestResolveAcquisitionPlan_LenovoFiltersNonInventoryChassisBranches(t *testing.T) {
signals := MatchSignals{
SystemManufacturer: "Lenovo",
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
ResourceHints: []string{
"/redfish/v1/Chassis/1/Power",
"/redfish/v1/Chassis/1/Thermal",
"/redfish/v1/Chassis/1/NetworkAdapters",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}
match := MatchProfiles(signals)
plan := BuildAcquisitionPlan(signals)
resolved := ResolveAcquisitionPlan(match, plan, DiscoveredResources{
ChassisPaths: []string{
"/redfish/v1/Chassis/1",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}, signals)
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/1/Power") {
t.Fatal("expected primary Lenovo chassis power path to remain critical")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3/Power") {
t.Fatal("did not expect non-inventory Lenovo backplane chassis power path")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/IO_Board/Assembly") {
t.Fatal("did not expect IO board assembly path without inventory hints")
}
if containsString(resolved.Plan.PlanBPaths, "/redfish/v1/Chassis/3/Assembly") {
t.Fatal("did not expect non-inventory Lenovo chassis plan-b target")
}
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3") {
t.Fatal("expected chassis root to remain discoverable even when suffixes are filtered")
}
}
func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) {
signals := MatchSignals{
SystemManufacturer: "Micro-Star International Co., Ltd.",

View File

@@ -1,5 +1,7 @@
package redfishprofile
import "strings"
func lenovoProfile() Profile {
return staticProfile{
name: "lenovo",
@@ -33,14 +35,30 @@ func lenovoProfile() Profile {
// Lenovo OEM subtrees under Oem/Lenovo/LEDs and Oem/Lenovo/Slots also
// enumerate dozens of individual documents not relevant to inventory.
ensureSnapshotExcludeContains(plan,
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
"/Oem/Lenovo/History", // historical telemetry
"/Oem/Lenovo/ScheduledPower", // power scheduling config
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
"/PortForwardingMap/", // network port forwarding config
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
"/Oem/Lenovo/History", // historical telemetry
"/Oem/Lenovo/Configuration", // BMC config service, not inventory
"/Oem/Lenovo/DateTimeService", // BMC time service config
"/Oem/Lenovo/GroupService", // XCC fleet/group management state
"/Oem/Lenovo/Recipients", // alert recipient config
"/Oem/Lenovo/RemoteControl", // remote-media/session management
"/Oem/Lenovo/RemoteMap", // remote-media mapping config
"/Oem/Lenovo/SecureKeyLifecycleService", // key lifecycle/cert config
"/Oem/Lenovo/ServerProfile", // profile export/import config
"/Oem/Lenovo/ServiceData", // support/service metadata
"/Oem/Lenovo/SsoCertificates", // SSO certificate config
"/Oem/Lenovo/SystemGuard", // snapshot/history service
"/Oem/Lenovo/Watchdogs", // watchdog config
"/Oem/Lenovo/ScheduledPower", // power scheduling config
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
"/NetworkProtocol/Oem/Lenovo/", // DNS/LDAP/SMTP/SNMP manager config
"/PortForwardingMap/", // network port forwarding config
"/VirtualMedia/", // virtual media inventory/config, not hardware
"/Boot/Certificates", // secure boot certificate stores, not inventory
"/ThermalSubsystem/Fans/", // per-fan member docs; replay uses aggregate Thermal only
)
// Lenovo XCC BMC is typically slow (p95 latency often 3-5s even under
// normal load). Set rate thresholds that don't over-throttle on the
@@ -61,5 +79,97 @@ func lenovoProfile() Profile {
})
addPlanNote(plan, "lenovo xcc acquisition extensions enabled: noisy sensor/oem paths excluded from snapshot")
},
refineAcquisition: func(resolved *ResolvedAcquisitionPlan, discovered DiscoveredResources, signals MatchSignals) {
allowedChassis := lenovoAllowedInventoryChassis(discovered.ChassisPaths, signals.ResourceHints)
resolved.SeedPaths = filterLenovoChassisInventoryPaths(resolved.SeedPaths, allowedChassis)
resolved.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.CriticalPaths, allowedChassis)
resolved.Plan.SeedPaths = filterLenovoChassisInventoryPaths(resolved.Plan.SeedPaths, allowedChassis)
resolved.Plan.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.Plan.CriticalPaths, allowedChassis)
resolved.Plan.PlanBPaths = filterLenovoChassisInventoryPaths(resolved.Plan.PlanBPaths, allowedChassis)
},
}
}
func lenovoAllowedInventoryChassis(chassisPaths, resourceHints []string) map[string]struct{} {
allowed := make(map[string]struct{}, len(chassisPaths))
for _, chassisPath := range chassisPaths {
normalized := normalizePath(chassisPath)
if normalized == "" {
continue
}
if normalized == "/redfish/v1/Chassis/1" {
allowed[normalized] = struct{}{}
continue
}
for _, hint := range resourceHints {
hint = normalizePath(hint)
if !strings.HasPrefix(hint, normalized+"/") {
continue
}
if lenovoHintLooksLikeChassisInventory(hint) {
allowed[normalized] = struct{}{}
break
}
}
}
return allowed
}
func lenovoHintLooksLikeChassisInventory(path string) bool {
for _, suffix := range []string{
"/Power",
"/PowerSubsystem",
"/PowerSubsystem/PowerSupplies",
"/Thermal",
"/ThresholdSensors",
"/DiscreteSensors",
"/SensorsList",
"/NetworkAdapters",
"/PCIeDevices",
"/Drives",
"/Assembly",
} {
if strings.HasSuffix(path, suffix) || strings.Contains(path, suffix+"/") {
return true
}
}
return false
}
func filterLenovoChassisInventoryPaths(paths []string, allowedChassis map[string]struct{}) []string {
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
for _, path := range paths {
normalized := normalizePath(path)
chassis := lenovoPathChassisRoot(normalized)
if chassis == "" {
out = append(out, normalized)
continue
}
if normalized == chassis {
out = append(out, normalized)
continue
}
if _, ok := allowedChassis[chassis]; ok {
out = append(out, normalized)
}
}
return dedupeSorted(out)
}
func lenovoPathChassisRoot(path string) string {
const prefix = "/redfish/v1/Chassis/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if rest == "" {
return ""
}
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
return prefix + rest[:idx]
}
return prefix + rest
}

View File

@@ -159,6 +159,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
}
for _, stor := range hw.Storage {
present := stor.Present
storDetails := mergeDetailMaps(nil, stor.Details)
if stor.LogicalBlockSizeBytes != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"logical_block_size_bytes": stor.LogicalBlockSizeBytes})
}
if stor.PhysicalBlockSizeBytes != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"physical_block_size_bytes": stor.PhysicalBlockSizeBytes})
}
if stor.MetadataBytesPerBlock != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"metadata_bytes_per_block": stor.MetadataBytesPerBlock})
}
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindStorage,
Slot: stor.Slot,
@@ -177,27 +187,38 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
StatusAtCollect: stor.StatusAtCollect,
StatusHistory: stor.StatusHistory,
ErrorDescription: stor.ErrorDescription,
Details: mergeDetailMaps(nil, stor.Details),
Details: storDetails,
})
}
for _, pcie := range hw.PCIeDevices {
// Use PartNumber as model when available; fall back to chip description.
// Description contains the chip/product name (e.g. "BCM57414 NetXtreme-E …")
// while PartNumber is a part/product code. Prefer PartNumber when set.
pcieModel := pcie.PartNumber
if pcieModel == "" {
pcieModel = pcie.Description
}
// Priority: PartNumber (vendor P/N) > Model (product name) > Description (chip label).
pcieModel := firstNonEmptyString(pcie.PartNumber, pcie.Model, pcie.Description)
details := mergeDetailMaps(nil, pcie.Details)
pcieFirmware := stringFromDetailMap(details, "firmware")
// Firmware: prefer direct field, fall back to details, then NVSwitch lookup.
pcieFirmware := firstNonEmptyString(pcie.Firmware, stringFromDetailMap(details, "firmware"))
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
if pcieFirmware != "" {
details = mergeDetailMaps(details, map[string]any{
"firmware": pcieFirmware,
})
}
}
if pcieFirmware != "" {
details = mergeDetailMaps(details, map[string]any{"firmware": pcieFirmware})
}
// Telemetry fields: put into details so convertPCIeFromDevices can pick them up.
if pcie.TemperatureC != nil {
details = mergeDetailMaps(details, map[string]any{"temperature_c": *pcie.TemperatureC})
}
if pcie.PowerW != nil {
details = mergeDetailMaps(details, map[string]any{"power_w": *pcie.PowerW})
}
if pcie.ECCCorrectedTotal != nil {
details = mergeDetailMaps(details, map[string]any{"ecc_corrected_total": *pcie.ECCCorrectedTotal})
}
if pcie.ECCUncorrectedTotal != nil {
details = mergeDetailMaps(details, map[string]any{"ecc_uncorrected_total": *pcie.ECCUncorrectedTotal})
}
if pcie.HWSlowdown != nil {
details = mergeDetailMaps(details, map[string]any{"hw_slowdown": *pcie.HWSlowdown})
}
present := pcie.Present
appendDevice(models.HardwareDevice{
Kind: models.DeviceKindPCIe,
Slot: pcie.Slot,
@@ -209,11 +230,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
PartNumber: pcie.PartNumber,
Manufacturer: pcie.Manufacturer,
SerialNumber: pcie.SerialNumber,
MACAddresses: append([]string(nil), pcie.MACAddresses...),
LinkWidth: pcie.LinkWidth,
LinkSpeed: pcie.LinkSpeed,
MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcie.MaxLinkSpeed,
NUMANode: pcie.NUMANode,
Present: present,
Status: pcie.Status,
StatusCheckedAt: pcie.StatusCheckedAt,
StatusChangedAt: pcie.StatusChangedAt,
@@ -738,36 +761,39 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
presentValue := present
result = append(result, ReanimatorStorage{
Slot: d.Slot,
Type: d.Type,
Model: d.Model,
SizeGB: d.SizeGB,
SerialNumber: d.SerialNumber,
Manufacturer: d.Manufacturer,
Firmware: d.Firmware,
Interface: d.Interface,
Present: &presentValue,
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
UnsafeShutdowns: int64FromDetailMap(d.Details, "unsafe_shutdowns"),
MediaErrors: int64FromDetailMap(d.Details, "media_errors"),
ErrorLogEntries: int64FromDetailMap(d.Details, "error_log_entries"),
WrittenBytes: int64FromDetailMap(d.Details, "written_bytes"),
ReadBytes: int64FromDetailMap(d.Details, "read_bytes"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
RemainingEndurancePct: d.RemainingEndurancePct,
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
AvailableSparePct: floatFromDetailMap(d.Details, "available_spare_pct"),
ReallocatedSectors: int64FromDetailMap(d.Details, "reallocated_sectors"),
CurrentPendingSectors: int64FromDetailMap(d.Details, "current_pending_sectors"),
OfflineUncorrectable: int64FromDetailMap(d.Details, "offline_uncorrectable"),
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
Slot: d.Slot,
Type: d.Type,
Model: d.Model,
SizeGB: d.SizeGB,
SerialNumber: d.SerialNumber,
Manufacturer: d.Manufacturer,
Firmware: d.Firmware,
Interface: d.Interface,
Present: &presentValue,
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
MetadataBytesPerBlock: int64FromDetailMap(d.Details, "metadata_bytes_per_block"),
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
UnsafeShutdowns: int64FromDetailMap(d.Details, "unsafe_shutdowns"),
MediaErrors: int64FromDetailMap(d.Details, "media_errors"),
ErrorLogEntries: int64FromDetailMap(d.Details, "error_log_entries"),
WrittenBytes: int64FromDetailMap(d.Details, "written_bytes"),
ReadBytes: int64FromDetailMap(d.Details, "read_bytes"),
LifeUsedPct: floatFromDetailMap(d.Details, "life_used_pct"),
RemainingEndurancePct: d.RemainingEndurancePct,
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
AvailableSparePct: floatFromDetailMap(d.Details, "available_spare_pct"),
ReallocatedSectors: int64FromDetailMap(d.Details, "reallocated_sectors"),
CurrentPendingSectors: int64FromDetailMap(d.Details, "current_pending_sectors"),
OfflineUncorrectable: int64FromDetailMap(d.Details, "offline_uncorrectable"),
Status: status,
StatusCheckedAt: meta.StatusCheckedAt,
StatusChangedAt: meta.StatusChangedAt,
ManufacturedYearWeek: manufacturedYearWeekFromDetails(d.Details),
StatusHistory: meta.StatusHistory,
ErrorDescription: meta.ErrorDescription,
})
}
return result

View File

@@ -12,15 +12,16 @@ type ReanimatorExport struct {
// ReanimatorHardware contains all hardware components
type ReanimatorHardware struct {
Board ReanimatorBoard `json:"board"`
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
Memory []ReanimatorMemory `json:"memory,omitempty"`
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
Board ReanimatorBoard `json:"board"`
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
Memory []ReanimatorMemory `json:"memory,omitempty"`
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
PlatformConfig map[string]any `json:"platform_config,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -101,17 +102,20 @@ type ReanimatorMemory struct {
// ReanimatorStorage represents a storage device
type ReanimatorStorage struct {
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"`
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"`
PowerCycles int64 `json:"power_cycles,omitempty"`
UnsafeShutdowns int64 `json:"unsafe_shutdowns,omitempty"`
MediaErrors int64 `json:"media_errors,omitempty"`

View File

@@ -245,6 +245,9 @@ type Storage struct {
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
Status string `json:"status,omitempty"`
Details map[string]any `json:"details,omitempty"`
@@ -277,6 +280,8 @@ type PCIeDevice struct {
BDF string `json:"bdf"`
DeviceClass string `json:"device_class"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
LinkWidth int `json:"link_width"`
LinkSpeed string `json:"link_speed"`
MaxLinkWidth int `json:"max_link_width"`
@@ -285,8 +290,17 @@ type PCIeDevice struct {
SerialNumber string `json:"serial_number,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Present *bool `json:"present,omitempty"`
IOMMUGroup *int `json:"iommu_group,omitempty"`
Status string `json:"status,omitempty"`
// GPU telemetry fields (populated by bee audit for GPU devices)
TemperatureC *float64 `json:"temperature_c,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"`
ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"`
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`

View File

@@ -0,0 +1,873 @@
// 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"
"regexp"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.2"
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 {
memory, events := parseDIMMs(f.Content)
result.Hardware.Memory = memory
result.Events = append(result.Events, events...)
}
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)
enrichBoardFromFRU(result)
}
if f := findByPath(files, "tmp/inventory_ipmi_sensor.log"); f != nil {
result.Sensors = parseSensors(f.Content)
result.Hardware.PowerSupply = enrichPSUsFromSensors(result.Hardware.PowerSupply, result.Sensors)
}
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: cleanXCCValue(item.MachineTypeModel),
SerialNumber: cleanXCCValue(item.SerialNumber),
UUID: cleanXCCValue(item.UUID),
}
if host := cleanXCCValue(item.MachineName); host != "" {
result.TargetHost = host
}
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, []models.Event) {
var doc xccDIMMDoc
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
return nil, nil
}
var out []models.MemoryDIMM
var events []models.Event
for _, item := range doc.Items {
for _, m := range item.Memory {
status := strings.TrimSpace(m.Status)
present := !strings.EqualFold(status, "not present") &&
!strings.EqualFold(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: status,
}
out = append(out, dimm)
if isUnqualifiedDIMM(status) {
events = append(events, models.Event{
Source: "Memory",
SensorType: "Memory",
SensorName: dimm.Slot,
EventType: "DIMM Qualification",
Severity: models.SeverityWarning,
Description: status,
})
}
}
}
return out, events
}
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")
status := mapDiskHealthStatus(d.HealthStatus, stateStr)
disk := models.Storage{
Slot: fmt.Sprintf("%d", d.SlotNo),
Type: strings.TrimSpace(d.Media),
Model: cleanXCCValue(d.ProductName),
SizeGB: sizeGB,
SerialNumber: cleanXCCValue(d.SerialNo),
Manufacturer: cleanXCCValue(d.Manufacture),
Firmware: cleanXCCValue(d.FWVersion),
Interface: strings.TrimSpace(d.Interface),
Present: present,
Status: status,
}
if d.Temperature > 0 {
disk.Details = map[string]any{"temperature_c": d.Temperature}
}
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 {
model := cleanXCCValue(p.FRUNumber)
if model == "" {
model = cleanXCCValue(p.PartNumber)
}
psu := models.PSU{
Slot: fmt.Sprintf("%d", p.Name),
Present: true,
Model: model,
WattageW: p.RatedPower,
SerialNumber: cleanXCCValue(p.SerialNumber),
PartNumber: cleanXCCValue(p.PartNumber),
Vendor: cleanXCCValue(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
}
unit := strings.TrimSpace(s.Unit)
sr := models.SensorReading{
Name: name,
RawValue: strings.TrimSpace(s.Value),
Unit: unit,
Status: strings.TrimSpace(s.Status),
Type: classifySensorType(name, unit),
}
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, e.Message),
}
if t, err := parseXCCTime(e.Date); err == nil {
ev.Timestamp = t.UTC()
}
out = append(out, ev)
}
return out
}
// --- Cross-reference enrichment ---
// enrichBoardFromFRU sets BoardInfo.Manufacturer from the system board FRU entry
// when it is not already populated. Mirrors bee's board parsing from dmidecode type 1.
func enrichBoardFromFRU(result *models.AnalysisResult) {
if result.Hardware.BoardInfo.Manufacturer != "" {
return
}
for _, fru := range result.FRU {
desc := strings.ToLower(fru.Description)
if !strings.Contains(desc, "system board") &&
!strings.Contains(desc, "planar") &&
!strings.Contains(desc, "backplane") {
continue
}
if mfg := cleanXCCValue(fru.Manufacturer); mfg != "" {
result.Hardware.BoardInfo.Manufacturer = mfg
return
}
}
}
// psuSensorSlot extracts a 1-based PSU slot number from a sensor name.
// Recognises patterns: "PSU1 ...", "PSU 2 ...", "Power Supply 1 ...", "PWS1 ..."
var psuSensorSlotPattern = regexp.MustCompile(`(?i)(?:PSU|Power\s+Supply|PWS)\s*(\d+)`)
// enrichPSUsFromSensors cross-references sensor readings into PSU InputPowerW /
// OutputPowerW / InputVoltage. Mirrors bee's enrichPSUsWithTelemetry approach.
func enrichPSUsFromSensors(psus []models.PSU, sensors []models.SensorReading) []models.PSU {
if len(psus) == 0 || len(sensors) == 0 {
return psus
}
for i := range psus {
slot, err := strconv.Atoi(psus[i].Slot)
if err != nil {
continue
}
for _, s := range sensors {
m := psuSensorSlotPattern.FindStringSubmatch(s.Name)
if len(m) < 2 {
continue
}
sensorSlot, err := strconv.Atoi(m[1])
if err != nil || sensorSlot != slot {
continue
}
nameLower := strings.ToLower(s.Name)
switch {
case isPSUInputPower(nameLower):
psus[i].InputPowerW = int(s.Value)
case isPSUOutputPower(nameLower):
psus[i].OutputPowerW = int(s.Value)
case isPSUInputVoltage(nameLower):
psus[i].InputVoltage = s.Value
}
}
}
return psus
}
func isPSUInputPower(name string) bool {
return strings.Contains(name, "input power") ||
strings.Contains(name, "input watts") ||
strings.Contains(name, "_pin") ||
strings.Contains(name, " pin")
}
func isPSUOutputPower(name string) bool {
return strings.Contains(name, "output power") ||
strings.Contains(name, "output watts") ||
strings.Contains(name, "_pout") ||
strings.Contains(name, " pout")
}
func isPSUInputVoltage(name string) bool {
return strings.Contains(name, "input voltage") ||
strings.Contains(name, "ac voltage") ||
strings.Contains(name, "_vin") ||
strings.Contains(name, " vin")
}
// mapDiskHealthStatus maps an XCC disk healthStatus integer to a canonical status
// string. Mirrors bee's mapRAIDDriveStatus logic.
// XCC codes: 1=Warning, 2=Normal, 3=Critical, 4=PredictiveFailure; 0=Unknown.
func mapDiskHealthStatus(code int, stateStr string) string {
switch code {
case 2:
return "OK"
case 1, 4:
return "Warning"
case 3:
return "Critical"
default:
if stateStr != "" {
return stateStr
}
return "Unknown"
}
}
// classifySensorType returns a sensor category based on bee's classification logic:
// fan / temperature / power / voltage / current / other.
func classifySensorType(name, unit string) string {
u := strings.ToLower(strings.TrimSpace(unit))
switch u {
case "rpm":
return "fan"
case "c", "celsius", "°c":
return "temperature"
case "w", "watts":
return "power"
case "v", "volts":
return "voltage"
case "a", "amps":
return "current"
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "fan"):
return "fan"
case strings.Contains(n, "temp"):
return "temperature"
case strings.Contains(n, "power") || strings.Contains(n, " pwr"):
return "power"
case strings.Contains(n, "volt") || strings.Contains(n, " vin") || strings.Contains(n, " vout"):
return "voltage"
case strings.Contains(n, "curr") || strings.Contains(n, " amp"):
return "current"
default:
return "other"
}
}
// cleanXCCValue strips XCC placeholder strings, returning "" for non-values.
// Mirrors bee's cleanDMIValue for IPMI/XCC context.
func cleanXCCValue(v string) string {
v = strings.TrimSpace(v)
switch strings.ToLower(v) {
case "", "n/a", "na", "none", "unknown", "not available",
"not applicable", "not present", "not specified", "-":
return ""
}
return v
}
// --- Helpers ---
func xccSeverity(s, message string) models.Severity {
if isUnqualifiedDIMM(message) {
return models.SeverityWarning
}
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 isUnqualifiedDIMM(value string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
}
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)
}

View File

@@ -0,0 +1,398 @@
package lenovo_xcc
import (
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
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 := &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 := &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 := &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 := &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 := &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 := &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 := &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 := &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 := &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 := &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 := &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)
}
}
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
content := []byte(`{
"items": [{
"memory": [{
"memory_name": "DIMM A1",
"memory_status": "Unqualified DIMM",
"memory_type": "DDR5",
"memory_capacity": 32
}]
}]
}`)
memory, events := parseDIMMs(content)
if len(memory) != 1 {
t.Fatalf("expected 1 DIMM, got %d", len(memory))
}
if len(events) != 1 {
t.Fatalf("expected 1 warning event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
if events[0].SensorName != "DIMM A1" {
t.Fatalf("unexpected sensor name: %q", events[0].SensorName)
}
}
func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
if got := xccSeverity("I", "System found Unqualified DIMM in slot DIMM A1"); got != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", got)
}
}
func TestParseBasicSysInfo_CleansPlaceholderValuesAndSetsTargetHost(t *testing.T) {
result := &models.AnalysisResult{Hardware: &models.HardwareConfig{}}
content := []byte(`{
"items": [{
"machine_name": " sr650v3-node01 ",
"machine_typemodel": " 7D76CTO1WW ",
"serial_number": " Not Specified ",
"uuid": "N/A"
}]
}`)
parseBasicSysInfo(content, result)
if result.TargetHost != "sr650v3-node01" {
t.Fatalf("unexpected target host: %q", result.TargetHost)
}
if result.Hardware.BoardInfo.ProductName != "7D76CTO1WW" {
t.Fatalf("unexpected product name: %q", result.Hardware.BoardInfo.ProductName)
}
if result.Hardware.BoardInfo.SerialNumber != "" {
t.Fatalf("expected serial number to be cleaned, got %q", result.Hardware.BoardInfo.SerialNumber)
}
if result.Hardware.BoardInfo.UUID != "" {
t.Fatalf("expected UUID to be cleaned, got %q", result.Hardware.BoardInfo.UUID)
}
}
func TestEnrichBoardFromFRU_SystemBoardManufacturerOnly(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{},
FRU: []models.FRUInfo{
{Description: "Power Supply 1", Manufacturer: "Ignore Me"},
{Description: "System Board", Manufacturer: " Lenovo "},
},
}
enrichBoardFromFRU(result)
if result.Hardware.BoardInfo.Manufacturer != "Lenovo" {
t.Fatalf("unexpected manufacturer: %q", result.Hardware.BoardInfo.Manufacturer)
}
}
func TestEnrichPSUsFromSensors_AssignsTelemetryBySlot(t *testing.T) {
psus := []models.PSU{
{Slot: "1"},
{Slot: "2"},
}
sensors := []models.SensorReading{
{Name: "PSU1 Input Power", Value: 430},
{Name: "Power Supply 1 Output Power", Value: 390},
{Name: "PWS1 AC Voltage", Value: 230.5},
{Name: "PSU2 Input Power", Value: 0},
{Name: "PSU3 Input Power", Value: 999},
{Name: "Fan 1", Value: 12000},
}
got := enrichPSUsFromSensors(psus, sensors)
if got[0].InputPowerW != 430 {
t.Fatalf("unexpected PSU1 input power: %d", got[0].InputPowerW)
}
if got[0].OutputPowerW != 390 {
t.Fatalf("unexpected PSU1 output power: %d", got[0].OutputPowerW)
}
if got[0].InputVoltage != 230.5 {
t.Fatalf("unexpected PSU1 input voltage: %v", got[0].InputVoltage)
}
if got[1].InputPowerW != 0 || got[1].OutputPowerW != 0 || got[1].InputVoltage != 0 {
t.Fatalf("unexpected telemetry assigned to PSU2: %+v", got[1])
}
}
func TestMapDiskHealthStatus(t *testing.T) {
tests := []struct {
name string
code int
stateStr string
want string
}{
{name: "normal", code: 2, stateStr: "Online", want: "OK"},
{name: "warning", code: 1, stateStr: "Online", want: "Warning"},
{name: "predictive failure", code: 4, stateStr: "Online", want: "Warning"},
{name: "critical", code: 3, stateStr: "Failed", want: "Critical"},
{name: "fallback state", code: 0, stateStr: "Rebuilding", want: "Rebuilding"},
{name: "unknown", code: 0, stateStr: "", want: "Unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapDiskHealthStatus(tt.code, tt.stateStr); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestClassifySensorType(t *testing.T) {
tests := []struct {
name string
in string
unit string
want string
}{
{name: "unit rpm", in: "Fan 1", unit: "RPM", want: "fan"},
{name: "unit celsius", in: "CPU Temp", unit: "C", want: "temperature"},
{name: "unit watts", in: "PSU1 Input Power", unit: "W", want: "power"},
{name: "unit volts", in: "PWS1 AC Voltage", unit: "V", want: "voltage"},
{name: "unit amps", in: "PSU1 Current", unit: "A", want: "current"},
{name: "name fallback", in: "GPU Temp", unit: "", want: "temperature"},
{name: "other", in: "Presence", unit: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifySensorType(tt.in, tt.unit); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCleanXCCValue(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: " Lenovo ", want: "Lenovo"},
{in: "N/A", want: ""},
{in: " not specified ", want: ""},
{in: "-", want: ""},
}
for _, tt := range tests {
if got := cleanXCCValue(tt.in); got != tt.want {
t.Fatalf("cleanXCCValue(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

View File

@@ -14,6 +14,7 @@ import (
_ "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/xigmanas"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc"
// Generic fallback parser (must be last for lowest priority)
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"

BIN
logpile

Binary file not shown.

View File

@@ -128,6 +128,7 @@ echo ""
# Show next steps
echo -e "${YELLOW}Next steps:${NC}"
echo " 1. Create git tag:"
echo " # LOGPile release tags use vN.M, for example: v1.12"
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
echo ""
echo " 2. Push tag to remote:"