8 Commits

Author SHA1 Message Date
Mikhail Chusavitin
3547ef9083 Skip placeholder firmware versions in API output 2026-03-26 18:42:54 +03:00
Mikhail Chusavitin
99f0d6217c Improve Multillect Redfish replay and power detection 2026-03-26 18:41:02 +03:00
Mikhail Chusavitin
8acbba3cc9 feat: add reanimator easy bee parser 2026-03-25 19:36:13 +03:00
Mikhail Chusavitin
8942991f0c Add Inspur Group OEM Redfish profile 2026-03-25 15:08:40 +03:00
Mikhail Chusavitin
9b71c4a95f chore: update bible submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:22:33 +03:00
Mikhail Chusavitin
125f77ef69 feat: adaptive BMC readiness check + ghost NIC dedup fix + empty collection plan-B retry
BMC readiness after power-on (waitForStablePoweredOnHost):
- After initial 1m stabilization, poll BMC inventory readiness before collecting
- Ready if MemorySummary.TotalSystemMemoryGiB > 0 OR PCIeDevices.Members non-empty
- On failure: wait +60s, retry; on second failure: wait +120s, retry; then warn and proceed
- Configurable via LOGPILE_REDFISH_BMC_READY_WAITS (default: 60s,120s)

Empty critical collection plan-B retry (EnableEmptyCriticalCollectionRetry):
- Hardware inventory collections that returned Members=[] are now re-probed in plan-B
- Covers PCIeDevices, NetworkAdapters, Processors, Drives, Storage, EthernetInterfaces
- Enabled by default in generic profile (applies to all vendors)

Ghost NIC dedup fix (enrichNICsFromNetworkInterfaces):
- NetworkInterface entries (e.g. Id=2) that don't match existing NIC slots are now
  resolved via Links.NetworkAdapter cross-reference to the real Chassis NIC
- Prevents duplicate ghost entries (slot=2 "Network Device View") from appearing
  alongside real NICs (slot="RISER 5 slot 1 (7)") with the same MAC addresses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:19:36 +03:00
Mikhail Chusavitin
063b08d5fb feat: redesign collection UI + add StopHostAfterCollect + TCP ping pre-probe
- Single "Подключиться" button flow: probe first, then show collect options
- Power management checkboxes: power on before / stop after collect
- Modal confirmation when enabling shutdown on already-powered-on host
- StopHostAfterCollect flag: host shuts down only when explicitly requested
- TCP ping (10 attempts, min 3 successes) before Redfish probe
- Debug payloads checkbox (Oem/Ami/Inventory/Crc, off by default)
- Remove platform_config BIOS settings collection (unreliable on AMI)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:50:01 +03:00
Mikhail Chusavitin
e3ff1745fc feat: clean up collection job log UI
- Filter out debug noise (plan-B per-path lines, heartbeats, timing stats, telemetry)
- Strip server-side nanosecond timestamp prefix from displayed messages
- Transform snapshot progress lines to show current path instead of doc count + ETA
- Humanize recurring message patterns (plan-B summary, prefetch, snapshot total)
- Raw collect.log and raw_export.json are unaffected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:50:13 +03:00
26 changed files with 2974 additions and 151 deletions

2
bible

Submodule bible updated: 0c829182a1...52444350c1

View File

@@ -27,6 +27,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
## Current vendor coverage
- Dell TSR
- Reanimator Easy Bee support bundles
- H3C SDS G5/G6
- Inspur / Kaytus
- NVIDIA HGX Field Diagnostics

View File

@@ -100,6 +100,13 @@ Live Redfish collection must expose profile-match diagnostics:
- the collect page should render active modules as chips from structured status data, not by
parsing log lines
Profile matching may use stable platform grammar signals in addition to vendor strings:
- discovered member/resource naming from lightweight discovery collections
- firmware inventory member IDs
- OEM action names and linked target paths embedded in discovery documents
- replay-only snapshot hints such as OEM assembly/type markers when they are present in
`raw_payloads.redfish_tree`
On replay, profile-derived analysis directives may enable vendor-specific inventory linking
helpers such as processor-GPU fallback, chassis-ID alias resolution, and bounded storage recovery.
Replay should now resolve a structured analysis plan inside `redfishprofile/`, analogous to the

View File

@@ -50,6 +50,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| Vendor ID | Input family | Notes |
|-----------|--------------|-------|
| `dell` | TSR ZIP archives | Broad hardware, firmware, sensors, lifecycle events |
| `easy_bee` | `bee-support-*.tar.gz` | Imports embedded `export/bee-audit.json` snapshot from reanimator-easy-bee bundles |
| `h3c_g5` | H3C SDS G5 bundles | INI/XML/CSV-driven hardware and event parsing |
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
@@ -139,6 +140,7 @@ with content markers (e.g. `Unraid kernel build`, parity data markers).
| Vendor | ID | Status | Tested on |
|--------|----|--------|-----------|
| Dell TSR | `dell` | Ready | TSR nested zip archives |
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |

View File

@@ -918,3 +918,61 @@ hardware change.
- Hardware event history (last 7 days) visible in Reanimator `EventLogs` section.
- No impact on existing inventory pipeline or offline archive replay (archives without `redfish_log_entries` key silently skip parsing).
- Adds extra HTTP requests during live collection (sequential, after tree-walk completes).
---
## ADL-036 — Redfish profile matching may use platform grammar hints beyond vendor strings
**Date:** 2026-03-25
**Context:**
Some BMCs expose unusable `Manufacturer` / `Model` values (`NULL`, placeholders, or generic SoC
names) while still exposing a stable platform-specific Redfish grammar: repeated member names,
firmware inventory IDs, OEM action names, and target-path quirks. Matching only on vendor
strings forced such systems into fallback mode even when the platform shape was consistent.
**Decision:**
- Extend `redfishprofile.MatchSignals` with doc-derived hint tokens collected from discovery docs
and replay snapshots.
- Allow profile matchers to score on stable platform grammar such as:
- collection member naming (`outboardPCIeCard*`, drive slot grammars)
- firmware inventory member IDs
- OEM action/type markers and linked target paths
- During live collection, gather only lightweight extra hint collections needed for matching
(`NetworkInterfaces`, `NetworkAdapters`, `Drives`, `UpdateService/FirmwareInventory`), not slow
deep inventory branches.
- Keep such profiles out of fallback aggregation unless they are proven safe as broad additive
hints.
**Consequences:**
- Platform-family profiles can activate even when vendor strings are absent or set to `NULL`.
- Matching logic becomes more robust for OEM BMC implementations that differ mainly by Redfish
grammar rather than by explicit vendor strings.
- Live collection gains a small amount of extra discovery I/O to harvest stable member IDs, but
avoids slow deep probes such as `Assembly` just for profile selection.
---
## ADL-037 — easy-bee archives are parsed from the embedded bee-audit snapshot
**Date:** 2026-03-25
**Context:**
`reanimator-easy-bee` support bundles already contain a normalized hardware snapshot in
`export/bee-audit.json` plus supporting logs and techdump files. Rebuilding the same inventory
from raw `techdump/` files inside LOGPile would duplicate parser logic and create drift between
the producer utility and archive importer.
**Decision:**
- Add a dedicated `easy_bee` vendor parser for `bee-support-*.tar.gz` bundles.
- Detect the bundle by `manifest.txt` (`bee_version=...`) plus `export/bee-audit.json`.
- Parse the archive from the embedded snapshot first; treat `techdump/` and runtime files as
secondary context only.
- Normalize snapshot-only fields needed by LOGPile, notably:
- flatten `hardware.sensors` groups into `[]SensorReading`
- turn runtime issues/status into `[]Event`
- synthesize a board FRU entry when the snapshot does not include FRU data
**Consequences:**
- LOGPile stays aligned with the schema emitted by `reanimator-easy-bee`.
- Adding support required only a thin archive adapter instead of a full hardware parser.
- If the upstream utility changes the embedded snapshot schema, the `easy_bee` adapter is the
only place that must be updated.

View File

@@ -110,7 +110,7 @@ func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult
if err != nil {
return nil, fmt.Errorf("redfish system: %w", err)
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
powerState := redfishSystemPowerState(systemDoc)
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
@@ -147,6 +147,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
snapshotClient := c.httpClientWithTimeout(req, redfishSnapshotRequestTimeout())
prefetchClient := c.httpClientWithTimeout(req, redfishPrefetchRequestTimeout())
criticalClient := c.httpClientWithTimeout(req, redfishCriticalRequestTimeout())
hintClient := c.httpClientWithTimeout(req, 4*time.Second)
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
@@ -159,14 +160,11 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
systemPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Systems", "/redfish/v1/Systems/1")
primarySystem := firstNonEmptyPath(systemPaths, "/redfish/v1/Systems/1")
poweredOnByCollector := false
if primarySystem != "" {
if on, changed := c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit); on {
poweredOnByCollector = changed
}
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
}
defer func() {
if !poweredOnByCollector || primarySystem == "" {
if primarySystem == "" || !req.StopHostAfterCollect {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
@@ -181,7 +179,8 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
chassisDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryChassis)
managerDoc, _ := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, primaryManager)
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
hintDocs := c.collectProfileHintDocs(discoveryCtx, hintClient, req, baseURL, primarySystem, primaryChassis)
signals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints, hintDocs...)
matchResult := redfishprofile.MatchProfiles(signals)
acquisitionPlan := redfishprofile.BuildAcquisitionPlan(signals)
telemetrySummary := telemetry.Snapshot()
@@ -313,6 +312,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
// Collect hardware event logs separately (not part of tree-walk to avoid bloat).
rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths)
var debugPayloads map[string]any
if req.DebugPayloads {
debugPayloads = c.collectDebugPayloads(ctx, snapshotClient, req, baseURL, systemPaths)
}
rawPayloads := map[string]any{
"redfish_tree": rawTree,
"redfish_profiles": map[string]any{
@@ -418,6 +421,9 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
if len(rawLogEntries) > 0 {
rawPayloads["redfish_log_entries"] = rawLogEntries
}
if len(debugPayloads) > 0 {
rawPayloads["redfish_debug_payloads"] = debugPayloads
}
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
@@ -488,7 +494,7 @@ func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, cli
return false, false
}
powerState := strings.TrimSpace(asString(systemDoc["PowerState"]))
powerState := redfishSystemPowerState(systemDoc)
if isRedfishHostPoweredOn(powerState) {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
@@ -565,11 +571,12 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
})
}
timer := time.NewTimer(stabilizationDelay)
defer timer.Stop()
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
timer.Stop()
}
}
if emit != nil {
@@ -579,7 +586,92 @@ func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, clien
Message: "Redfish: повторная проверка PowerState после стабилизации host",
})
}
return c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second)
if !c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, 5*time.Second) {
return false
}
// After the initial stabilization wait, the BMC may still be populating its
// hardware inventory (PCIeDevices, memory summary). Poll readiness with
// increasing back-off (default: +60s, +120s), then warn and proceed.
readinessWaits := redfishBMCReadinessWaits()
for attempt, extraWait := range readinessWaits {
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
if ready {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
})
}
return true
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC не готов (%s), ожидание %s (попытка %d/%d)", reason, extraWait, attempt+1, len(readinessWaits)),
})
}
timer := time.NewTimer(extraWait)
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
timer.Stop()
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: повторная проверка готовности BMC (%d/%d)...", attempt+1, len(readinessWaits)),
})
}
}
ready, reason := c.isBMCInventoryReady(ctx, client, req, baseURL, systemPath)
if !ready {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: WARNING — BMC не подтвердил готовность (%s), сбор может быть неполным", reason),
})
}
} else if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: BMC готов (%s)", reason),
})
}
return true
}
// isBMCInventoryReady checks whether the BMC has finished populating its
// hardware inventory after a power-on. Returns (ready, reason).
// It considers the BMC ready if either the system memory summary reports
// a non-zero total or the PCIeDevices collection is non-empty.
func (c *RedfishConnector) isBMCInventoryReady(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string) (bool, string) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
return false, "не удалось прочитать System"
}
if summary, ok := systemDoc["MemorySummary"].(map[string]interface{}); ok {
if asFloat(summary["TotalSystemMemoryGiB"]) > 0 {
return true, "MemorySummary заполнен"
}
}
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/PCIeDevices"))
if err == nil {
if asInt(pcieDoc["Members@odata.count"]) > 0 {
return true, "PCIeDevices не пуст"
}
if members, ok := pcieDoc["Members"].([]interface{}); ok && len(members) > 0 {
return true, "PCIeDevices не пуст"
}
}
return false, "MemorySummary=0, PCIeDevices пуст"
}
func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
@@ -618,6 +710,20 @@ func (c *RedfishConnector) restoreHostPowerAfterCollection(ctx context.Context,
}
}
// collectDebugPayloads fetches vendor-specific diagnostic endpoints on a best-effort basis.
// Results are stored in rawPayloads["redfish_debug_payloads"] and exported with the bundle.
// Enabled only when Request.DebugPayloads is true.
func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths []string) map[string]any {
out := map[string]any{}
for _, systemPath := range systemPaths {
// AMI/MSI: inventory CRC groups — reveals which groups are supported by this BMC.
if doc, err := c.getJSON(ctx, client, req, baseURL, joinPath(systemPath, "/Oem/Ami/Inventory/Crc")); err == nil {
out[joinPath(systemPath, "/Oem/Ami/Inventory/Crc")] = doc
}
}
return out
}
// invalidateRedfishInventory POSTs to the AMI/MSI InventoryCrc endpoint to zero out
// all known CRC groups before a host power-on. This causes the BMC to accept fresh
// inventory from the host after boot, preventing stale inventory (ghost GPUs, wrong
@@ -630,8 +736,6 @@ func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, clien
{"CPU": 0},
{"DIMM": 0},
{"PCIE": 0},
{"CERTIFICATES": 0},
{"SECUREBOOT": 0},
},
}
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
@@ -649,7 +753,7 @@ func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *ht
for {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err == nil {
if isRedfishHostPoweredOn(strings.TrimSpace(asString(systemDoc["PowerState"]))) == wantOn {
if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn {
return true
}
}
@@ -682,6 +786,19 @@ func isRedfishHostPoweredOn(state string) bool {
}
}
func redfishSystemPowerState(systemDoc map[string]interface{}) string {
if len(systemDoc) == 0 {
return ""
}
if state := strings.TrimSpace(asString(systemDoc["PowerState"])); state != "" {
return state
}
if summary, ok := systemDoc["PowerSummary"].(map[string]interface{}); ok {
return strings.TrimSpace(asString(summary["PowerState"]))
}
return ""
}
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
if systemDoc == nil {
return ""
@@ -1455,6 +1572,33 @@ func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http
return nil
}
func (c *RedfishConnector) collectProfileHintDocs(ctx context.Context, client *http.Client, req Request, baseURL, systemPath, chassisPath string) []map[string]interface{} {
paths := []string{
"/redfish/v1/UpdateService/FirmwareInventory",
joinPath(systemPath, "/NetworkInterfaces"),
joinPath(chassisPath, "/Drives"),
joinPath(chassisPath, "/NetworkAdapters"),
}
seen := make(map[string]struct{}, len(paths))
docs := make([]map[string]interface{}, 0, len(paths))
for _, path := range paths {
path = normalizeRedfishPath(path)
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
doc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil {
continue
}
docs = append(docs, doc)
}
return docs
}
func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, tuning redfishprofile.AcquisitionTuning, emit ProgressFn) (map[string]interface{}, []map[string]interface{}, redfishPostProbeMetrics, string) {
maxDocuments := redfishSnapshotMaxDocuments(tuning)
workers := redfishSnapshotWorkers(tuning)
@@ -2091,6 +2235,25 @@ func looksLikeRedfishResource(doc map[string]interface{}) bool {
return false
}
// isHardwareInventoryCollectionPath reports whether the path is a hardware
// inventory collection that is expected to have members when the machine is
// powered on and the BMC has finished initializing.
func isHardwareInventoryCollectionPath(p string) bool {
for _, suffix := range []string{
"/PCIeDevices",
"/NetworkAdapters",
"/Processors",
"/Drives",
"/Storage",
"/EthernetInterfaces",
} {
if strings.HasSuffix(p, suffix) {
return true
}
}
return false
}
func shouldSlowProbeCriticalCollection(p string, tuning redfishprofile.AcquisitionTuning) bool {
p = normalizeRedfishPath(p)
if !tuning.RecoveryPolicy.EnableCriticalSlowProbe {
@@ -2411,6 +2574,25 @@ func redfishPowerOnStabilizationDelay() time.Duration {
return 60 * time.Second
}
// redfishBMCReadinessWaits returns the extra wait durations used when polling
// BMC inventory readiness after power-on. Defaults: [60s, 120s].
// Override with LOGPILE_REDFISH_BMC_READY_WAITS (comma-separated durations,
// e.g. "60s,120s").
func redfishBMCReadinessWaits() []time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_BMC_READY_WAITS")); v != "" {
var out []time.Duration
for _, part := range strings.Split(v, ",") {
if d, err := time.ParseDuration(strings.TrimSpace(part)); err == nil && d >= 0 {
out = append(out, d)
}
}
if len(out) > 0 {
return out
}
}
return []time.Duration{60 * time.Second, 120 * time.Second}
}
func redfishSnapshotMemoryRequestTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_MEMORY_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
@@ -3015,6 +3197,38 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
addTarget(memberPath)
}
}
// Re-probe critical hardware collections that were successfully fetched but
// returned no members. This happens when the BMC hasn't finished enumerating
// hardware at collection time (e.g. PCIeDevices or NetworkAdapters empty right
// after power-on). Only hardware inventory collection suffixes are retried.
if tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry {
for _, p := range criticalPaths {
p = normalizeRedfishPath(p)
if p == "" {
continue
}
if _, queued := seenTargets[p]; queued {
continue
}
docAny, ok := rawTree[p]
if !ok {
continue
}
doc, ok := docAny.(map[string]interface{})
if !ok {
continue
}
if redfishCollectionHasExplicitMembers(doc) {
continue
}
if !isHardwareInventoryCollectionPath(p) {
continue
}
addTarget(p)
}
}
if len(targets) == 0 {
return 0
}
@@ -3304,14 +3518,20 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
}
}
l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc)
publicSerial := redfishCPUPublicSerial(doc)
serial := normalizeRedfishIdentityField(asString(doc["SerialNumber"]))
if serial == "" && publicSerial == "" {
serial = findFirstNormalizedStringByKeys(doc, "SerialNumber")
}
cpus = append(cpus, models.CPU{
Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Cores: asInt(doc["TotalCores"]),
Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
FrequencyMHz: int(redfishFirstNumeric(doc, "OperatingSpeedMHz", "CurrentSpeedMHz", "FrequencyMHz")),
MaxFreqMHz: int(redfishFirstNumeric(doc, "MaxSpeedMHz", "TurboEnableMaxSpeedMHz", "TurboDisableMaxSpeedMHz")),
PPIN: firstNonEmpty(findFirstNormalizedStringByKeys(doc, "PPIN", "ProtectedIdentificationNumber"), publicSerial),
SerialNumber: serial,
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
@@ -3322,6 +3542,12 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
return cpus
}
func redfishCPUPublicSerial(doc map[string]interface{}) string {
oem, _ := doc["Oem"].(map[string]interface{})
public, _ := oem["Public"].(map[string]interface{})
return normalizeRedfishIdentityField(asString(public["SerialNumber"]))
}
// parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the
// Redfish ProcessorMemory array (Processor.v1_x spec).
func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) {
@@ -3770,7 +3996,7 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
Present: present,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Vendor: asString(doc["Manufacturer"]),
WattageW: asInt(doc["PowerCapacityWatts"]),
WattageW: redfishPSUNominalWattage(doc),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
@@ -3783,6 +4009,25 @@ func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplemen
}
}
func redfishPSUNominalWattage(doc map[string]interface{}) int {
if ranges, ok := doc["InputRanges"].([]interface{}); ok {
best := 0
for _, rawRange := range ranges {
rangeDoc, ok := rawRange.(map[string]interface{})
if !ok {
continue
}
if wattage := asInt(rangeDoc["OutputWattage"]); wattage > best {
best = wattage
}
}
if best > 0 {
return best
}
}
return asInt(doc["PowerCapacityWatts"])
}
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
return redfishDriveDetailsWithSupplementalDocs(doc)
}

View File

@@ -31,8 +31,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
}
serviceRootDoc, err := r.getJSON("/redfish/v1")
if err != nil {
if _, err := r.getJSON("/redfish/v1"); err != nil {
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
}
@@ -61,8 +60,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
fruDoc = chassisFRUDoc
}
boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths)
resourceHints := append(append([]string{}, systemPaths...), append(chassisPaths, managerPaths...)...)
profileSignals := redfishprofile.CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc, resourceHints)
profileSignals := redfishprofile.CollectSignalsFromTree(tree)
profileMatch := redfishprofile.MatchProfiles(profileSignals)
analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{
SystemPaths: systemPaths,
@@ -98,20 +96,26 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc)
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths)
bmcManagementSummary := r.collectBMCManagementSummary(managerPaths)
boardInfo.BMCMACAddress = strings.TrimSpace(firstNonEmpty(
asString(bmcManagementSummary["mac_address"]),
r.collectBMCMAC(managerPaths),
))
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree)
logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt)
sensorHintSummary, sensorHintEvents := r.collectSensorsListHints(chassisPaths, collectedAt)
bmcManagementEvent := buildBMCManagementSummaryEvent(bmcManagementSummary, collectedAt)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
InventoryLastModifiedAt: inventoryLastModifiedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
SourceTimezone: sourceTimezone,
Events: append(append(append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+len(sensorHintEvents)+2), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), sensorHintEvents...), bmcManagementEvent...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
@@ -157,6 +161,12 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if strings.TrimSpace(sourceTimezone) != "" {
result.RawPayloads["source_timezone"] = sourceTimezone
}
if len(sensorHintSummary) > 0 {
result.RawPayloads["redfish_sensor_hints"] = sensorHintSummary
}
if len(bmcManagementSummary) > 0 {
result.RawPayloads["redfish_bmc_network_summary"] = bmcManagementSummary
}
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
return result, nil
}
@@ -326,6 +336,153 @@ func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
}
}
func (r redfishSnapshotReader) collectSensorsListHints(chassisPaths []string, collectedAt time.Time) (map[string]any, []models.Event) {
summary := make(map[string]any)
var events []models.Event
var presentDIMMs []string
dimmTotal := 0
dimmPresent := 0
physicalDriveSlots := 0
activePhysicalDriveSlots := 0
logicalDriveStatus := ""
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/SensorsList"))
if err != nil || len(doc) == 0 {
continue
}
sensors, ok := doc["SensorsList"].([]interface{})
if !ok {
continue
}
for _, item := range sensors {
sensor, ok := item.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(asString(sensor["SensorName"]))
sensorType := strings.TrimSpace(asString(sensor["SensorType"]))
status := strings.TrimSpace(asString(sensor["Status"]))
switch {
case strings.HasPrefix(name, "DIMM") && strings.HasSuffix(name, "_Status") && strings.EqualFold(sensorType, "Memory"):
dimmTotal++
if redfishSlotStatusLooksPresent(status) {
dimmPresent++
presentDIMMs = append(presentDIMMs, strings.TrimSuffix(name, "_Status"))
}
case strings.EqualFold(sensorType, "Drive Slot"):
if strings.EqualFold(name, "Logical_Drive") {
logicalDriveStatus = firstNonEmpty(logicalDriveStatus, status)
continue
}
physicalDriveSlots++
if redfishSlotStatusLooksPresent(status) {
activePhysicalDriveSlots++
}
}
}
}
if dimmTotal > 0 {
sort.Strings(presentDIMMs)
summary["memory_slots"] = map[string]any{
"total": dimmTotal,
"present_count": dimmPresent,
"present_slots": presentDIMMs,
"source": "SensorsList",
}
events = append(events, models.Event{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: fmt.Sprintf("Memory slot sensors report %d populated positions out of %d", dimmPresent, dimmTotal),
RawData: firstNonEmpty(strings.Join(presentDIMMs, ", "), "no populated DIMM slots reported"),
})
}
if physicalDriveSlots > 0 || logicalDriveStatus != "" {
summary["drive_slots"] = map[string]any{
"physical_total": physicalDriveSlots,
"physical_active_count": activePhysicalDriveSlots,
"logical_drive_status": logicalDriveStatus,
"source": "SensorsList",
}
rawParts := []string{
fmt.Sprintf("physical_active=%d/%d", activePhysicalDriveSlots, physicalDriveSlots),
}
if logicalDriveStatus != "" {
rawParts = append(rawParts, "logical_drive="+logicalDriveStatus)
}
events = append(events, models.Event{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: fmt.Sprintf("Drive slot sensors report %d active physical slots out of %d", activePhysicalDriveSlots, physicalDriveSlots),
RawData: strings.Join(rawParts, "; "),
})
}
return summary, events
}
func buildBMCManagementSummaryEvent(summary map[string]any, collectedAt time.Time) []models.Event {
if len(summary) == 0 {
return nil
}
desc := fmt.Sprintf(
"BMC management interface %s link=%s ip=%s",
firstNonEmpty(asString(summary["interface_id"]), "unknown"),
firstNonEmpty(asString(summary["link_status"]), "unknown"),
firstNonEmpty(asString(summary["ipv4_address"]), "n/a"),
)
rawParts := make([]string, 0, 8)
for _, part := range []string{
"mac_address=" + strings.TrimSpace(asString(summary["mac_address"])),
"speed_mbps=" + strings.TrimSpace(asString(summary["speed_mbps"])),
"lldp_chassis_name=" + strings.TrimSpace(asString(summary["lldp_chassis_name"])),
"lldp_port_desc=" + strings.TrimSpace(asString(summary["lldp_port_desc"])),
"lldp_port_id=" + strings.TrimSpace(asString(summary["lldp_port_id"])),
"ipv4_gateway=" + strings.TrimSpace(asString(summary["ipv4_gateway"])),
} {
if !strings.HasSuffix(part, "=") {
rawParts = append(rawParts, part)
}
}
if vlan := asInt(summary["lldp_vlan_id"]); vlan > 0 {
rawParts = append(rawParts, fmt.Sprintf("lldp_vlan_id=%d", vlan))
}
if asBool(summary["ncsi_enabled"]) {
rawParts = append(rawParts, "ncsi_enabled=true")
}
return []models.Event{
{
Timestamp: replayEventTimestamp(collectedAt),
Source: "Redfish",
EventType: "Collection Info",
Severity: models.SeverityInfo,
Description: desc,
RawData: strings.Join(rawParts, "; "),
},
}
}
func redfishSlotStatusLooksPresent(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "ok", "enabled", "present", "warning", "critical":
return true
default:
return false
}
}
func replayEventTimestamp(collectedAt time.Time) time.Time {
if !collectedAt.IsZero() {
return collectedAt
}
return time.Now()
}
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
if err != nil || len(docs) == 0 {
@@ -858,6 +1015,9 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
if err != nil {
continue
}
if redfishFallbackMemberLooksLikePlaceholder(collectionPath, doc) {
continue
}
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
doc["@odata.id"] = normalizeRedfishPath(p)
}
@@ -866,6 +1026,135 @@ func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string,
return out, nil
}
func redfishFallbackMemberLooksLikePlaceholder(collectionPath string, doc map[string]interface{}) bool {
if len(doc) == 0 {
return true
}
path := normalizeRedfishPath(collectionPath)
switch {
case strings.HasSuffix(path, "/NetworkAdapters"):
return redfishNetworkAdapterPlaceholderDoc(doc)
case strings.HasSuffix(path, "/PCIeDevices"):
return redfishPCIePlaceholderDoc(doc)
case strings.Contains(path, "/Storage"):
return redfishStoragePlaceholderDoc(doc)
default:
return false
}
}
func redfishNetworkAdapterPlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
return redfishDocHasOnlyAllowedKeys(doc,
"@odata.context",
"@odata.id",
"@odata.type",
"Id",
"Name",
)
}
func redfishPCIePlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
return redfishDocHasOnlyAllowedKeys(doc,
"@odata.context",
"@odata.id",
"@odata.type",
"Id",
"Name",
)
}
func redfishStoragePlaceholderDoc(doc map[string]interface{}) bool {
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
asHexOrInt(doc["VendorId"]) != 0 ||
asHexOrInt(doc["DeviceId"]) != 0 {
return false
}
if !redfishDocHasOnlyAllowedKeys(doc,
"@odata.id",
"@odata.type",
"Drives",
"Drives@odata.count",
"LogicalDisk",
"PhysicalDisk",
"Name",
) {
return false
}
return redfishFieldIsEmptyCollection(doc["Drives"]) &&
redfishFieldIsZeroLike(doc["Drives@odata.count"]) &&
redfishFieldIsEmptyCollection(doc["LogicalDisk"]) &&
redfishFieldIsEmptyCollection(doc["PhysicalDisk"])
}
func redfishDocHasOnlyAllowedKeys(doc map[string]interface{}, allowed ...string) bool {
if len(doc) == 0 {
return false
}
allowedSet := make(map[string]struct{}, len(allowed))
for _, key := range allowed {
allowedSet[key] = struct{}{}
}
for key := range doc {
if _, ok := allowedSet[key]; !ok {
return false
}
}
return true
}
func redfishFieldIsEmptyCollection(v any) bool {
switch x := v.(type) {
case nil:
return true
case []interface{}:
return len(x) == 0
default:
return false
}
}
func redfishFieldIsZeroLike(v any) bool {
switch x := v.(type) {
case nil:
return true
case int:
return x == 0
case int32:
return x == 0
case int64:
return x == 0
case float64:
return x == 0
case string:
x = strings.TrimSpace(x)
return x == "" || x == "0"
default:
return false
}
}
func cloneRawPayloads(src map[string]any) map[string]any {
if len(src) == 0 {
return nil

View File

@@ -26,6 +26,16 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne
continue
}
idx, ok := bySlot[strings.ToLower(strings.TrimSpace(slot))]
if !ok {
// The NetworkInterface Id (e.g. "2") may not match the display slot of
// the real NIC that came from Chassis/NetworkAdapters (e.g. "RISER 5
// slot 1 (7)"). Try to find the real NIC via the Links.NetworkAdapter
// cross-reference before creating a ghost entry.
if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, bySlot); linkedIdx >= 0 {
idx = linkedIdx
ok = true
}
}
if !ok {
*nics = append(*nics, models.NetworkAdapter{
Slot: slot,
@@ -155,12 +165,25 @@ func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[stri
return out
}
// collectBMCMAC returns the MAC address of the first active BMC management
// interface found in Managers/*/EthernetInterfaces. Returns empty string if
// no MAC is available.
// collectBMCMAC returns the MAC address of the best BMC management interface
// found in Managers/*/EthernetInterfaces. Prefer an active link with an IP
// address over a passive sideband interface.
func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
summary := r.collectBMCManagementSummary(managerPaths)
if len(summary) == 0 {
return ""
}
return strings.ToUpper(strings.TrimSpace(asString(summary["mac_address"])))
}
func (r redfishSnapshotReader) collectBMCManagementSummary(managerPaths []string) map[string]any {
bestScore := -1
var best map[string]any
for _, managerPath := range managerPaths {
members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces"))
collectionPath := joinPath(managerPath, "/EthernetInterfaces")
collectionDoc, _ := r.getJSON(collectionPath)
ncsiEnabled, lldpMode, lldpByEth := redfishManagerEthernetCollectionHints(collectionDoc)
members, err := r.getCollectionMembers(collectionPath)
if err != nil || len(members) == 0 {
continue
}
@@ -172,12 +195,170 @@ func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") {
continue
}
return strings.ToUpper(mac)
ifaceID := strings.TrimSpace(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
summary := map[string]any{
"manager_path": managerPath,
"interface_id": ifaceID,
"hostname": strings.TrimSpace(asString(doc["HostName"])),
"fqdn": strings.TrimSpace(asString(doc["FQDN"])),
"mac_address": strings.ToUpper(mac),
"link_status": strings.TrimSpace(asString(doc["LinkStatus"])),
"speed_mbps": asInt(doc["SpeedMbps"]),
"interface_name": strings.TrimSpace(asString(doc["Name"])),
"interface_desc": strings.TrimSpace(asString(doc["Description"])),
"ncsi_enabled": ncsiEnabled,
"lldp_mode": lldpMode,
"ipv4_address": redfishManagerIPv4Field(doc, "Address"),
"ipv4_gateway": redfishManagerIPv4Field(doc, "Gateway"),
"ipv4_subnet": redfishManagerIPv4Field(doc, "SubnetMask"),
"ipv6_address": redfishManagerIPv6Field(doc, "Address"),
"link_is_active": strings.EqualFold(strings.TrimSpace(asString(doc["LinkStatus"])), "LinkActive"),
"interface_score": 0,
}
if lldp, ok := lldpByEth[strings.ToLower(ifaceID)]; ok {
summary["lldp_chassis_name"] = lldp["ChassisName"]
summary["lldp_port_desc"] = lldp["PortDesc"]
summary["lldp_port_id"] = lldp["PortId"]
if vlan := asInt(lldp["VlanId"]); vlan > 0 {
summary["lldp_vlan_id"] = vlan
}
}
score := redfishManagerInterfaceScore(summary)
summary["interface_score"] = score
if score > bestScore {
bestScore = score
best = summary
}
}
}
return best
}
func redfishManagerEthernetCollectionHints(collectionDoc map[string]interface{}) (bool, string, map[string]map[string]interface{}) {
lldpByEth := make(map[string]map[string]interface{})
if len(collectionDoc) == 0 {
return false, "", lldpByEth
}
oem, _ := collectionDoc["Oem"].(map[string]interface{})
public, _ := oem["Public"].(map[string]interface{})
ncsiEnabled := asBool(public["NcsiEnabled"])
lldp, _ := public["LLDP"].(map[string]interface{})
lldpMode := strings.TrimSpace(asString(lldp["LLDPMode"]))
if members, ok := lldp["Members"].([]interface{}); ok {
for _, item := range members {
member, ok := item.(map[string]interface{})
if !ok {
continue
}
ethIndex := strings.ToLower(strings.TrimSpace(asString(member["EthIndex"])))
if ethIndex == "" {
continue
}
lldpByEth[ethIndex] = member
}
}
return ncsiEnabled, lldpMode, lldpByEth
}
func redfishManagerIPv4Field(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""
}
for _, field := range []string{"IPv4Addresses", "IPv4StaticAddresses"} {
list, ok := doc[field].([]interface{})
if !ok {
continue
}
for _, item := range list {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
value := strings.TrimSpace(asString(entry[key]))
if value != "" {
return value
}
}
}
return ""
}
func redfishManagerIPv6Field(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""
}
list, ok := doc["IPv6Addresses"].([]interface{})
if !ok {
return ""
}
for _, item := range list {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
value := strings.TrimSpace(asString(entry[key]))
if value != "" {
return value
}
}
return ""
}
func redfishManagerInterfaceScore(summary map[string]any) int {
score := 0
if strings.EqualFold(strings.TrimSpace(asString(summary["link_status"])), "LinkActive") {
score += 100
}
if strings.TrimSpace(asString(summary["ipv4_address"])) != "" {
score += 40
}
if strings.TrimSpace(asString(summary["ipv6_address"])) != "" {
score += 10
}
if strings.TrimSpace(asString(summary["mac_address"])) != "" {
score += 10
}
if asInt(summary["speed_mbps"]) > 0 {
score += 5
}
if ifaceID := strings.ToLower(strings.TrimSpace(asString(summary["interface_id"]))); ifaceID != "" && !strings.HasPrefix(ifaceID, "usb") {
score += 3
}
if asBool(summary["ncsi_enabled"]) {
score += 1
}
return score
}
// findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an
// existing NIC in bySlot by following Links.NetworkAdapter → the Chassis
// NetworkAdapter doc → its slot label. Returns -1 if no match is found.
func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, bySlot map[string]int) int {
links, ok := iface["Links"].(map[string]interface{})
if !ok {
return -1
}
adapterRef, ok := links["NetworkAdapter"].(map[string]interface{})
if !ok {
return -1
}
adapterPath := normalizeRedfishPath(asString(adapterRef["@odata.id"]))
if adapterPath == "" {
return -1
}
adapterDoc, err := r.getJSON(adapterPath)
if err != nil || len(adapterDoc) == 0 {
return -1
}
adapterNIC := parseNIC(adapterDoc)
if slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" {
if idx, ok := bySlot[slot]; ok {
return idx
}
}
return -1
}
// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions
// collection linked from a NetworkAdapter document and populates the NIC's
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.

View File

@@ -270,8 +270,74 @@ func TestRedfishConnectorProbe(t *testing.T) {
}
}
func TestRedfishConnectorProbe_FallsBackToPowerSummary(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", map[string]interface{}{"Name": "ServiceRoot"})
register("/redfish/v1/Systems", map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
})
register("/redfish/v1/Systems/1", map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerSummary": map[string]interface{}{
"PowerState": "On",
},
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On", "ForceOff"},
},
},
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
connector := NewRedfishConnector()
port := 443
host := ""
if u, err := url.Parse(ts.URL); err == nil {
host = u.Hostname()
if p := u.Port(); p != "" {
fmt.Sscanf(p, "%d", &port)
}
}
got, err := connector.Probe(context.Background(), Request{
Host: host,
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
})
if err != nil {
t.Fatalf("probe failed: %v", err)
}
if got == nil || !got.Reachable {
t.Fatalf("expected reachable probe result, got %+v", got)
}
if !got.HostPoweredOn {
t.Fatalf("expected powered on host from PowerSummary")
}
if got.HostPowerState != "On" {
t.Fatalf("expected power state On, got %q", got.HostPowerState)
}
if !got.PowerControlAvailable {
t.Fatalf("expected power control available")
}
}
func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "Off"
resetCalls := 0
@@ -282,6 +348,9 @@ func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": powerState,
"MemorySummary": map[string]interface{}{
"TotalSystemMemoryGiB": 128,
},
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
@@ -329,6 +398,7 @@ func TestEnsureHostPowerForCollection_WaitsForStablePowerOn(t *testing.T) {
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "Off"
@@ -383,6 +453,104 @@ func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization
}
}
func TestEnsureHostPowerForCollection_UsesPowerSummaryState(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
powerState := "On"
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerSummary": map[string]interface{}{
"PowerState": powerState,
},
"MemorySummary": map[string]interface{}{
"TotalSystemMemoryGiB": 128,
},
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
hostOn, changed := c.ensureHostPowerForCollection(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
PowerOnIfHostOff: true,
}, ts.URL, "/redfish/v1/Systems/1", nil)
if !hostOn || changed {
t.Fatalf("expected already-on host from PowerSummary, got hostOn=%v changed=%v", hostOn, changed)
}
}
func TestWaitForHostPowerState_UsesPowerSummaryState(t *testing.T) {
powerState := "Off"
mux := http.NewServeMux()
mux.HandleFunc("/redfish/v1/Systems/1", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
current := powerState
if powerState == "Off" {
powerState = "On"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerSummary": map[string]interface{}{
"PowerState": current,
},
})
})
ts := httptest.NewTLSServer(mux)
defer ts.Close()
u, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse server url: %v", err)
}
port := 443
if u.Port() != "" {
fmt.Sscanf(u.Port(), "%d", &port)
}
c := NewRedfishConnector()
ok := c.waitForHostPowerState(context.Background(), c.httpClientWithTimeout(Request{TLSMode: "insecure"}, 5*time.Second), Request{
Host: u.Hostname(),
Protocol: "redfish",
Port: port,
Username: "admin",
AuthType: "password",
Password: "secret",
TLSMode: "insecure",
}, ts.URL, "/redfish/v1/Systems/1", true, 3*time.Second)
if !ok {
t.Fatalf("expected waitForHostPowerState to use PowerSummary")
}
}
func TestParsePCIeDeviceSlot_FromNestedRedfishSlotLocation(t *testing.T) {
doc := map[string]interface{}{
"Id": "NIC1",
@@ -483,6 +651,271 @@ func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testi
}
}
func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersSkipsPlaceholderNumericDocs(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
"Storage": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage"},
},
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Systems/1/Storage/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/1",
"@odata.type": "#Storage.v1_7_1.Storage",
"Drives": []interface{}{},
"Drives@odata.count": "0",
"LogicalDisk": []interface{}{},
"PhysicalDisk": []interface{}{},
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
"NetworkAdapters": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters"},
"PCIeDevices": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices"},
},
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Chassis/1/NetworkAdapters/1": map[string]interface{}{
"@odata.context": "/redfish/v1/$metadata#Chassis/Members/1/NetworkAdapters/Members/$entity",
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/1",
"@odata.type": "#NetworkAdapter.v1_0_0.Networkadapter",
"Id": "1",
"Name": "1",
},
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices",
"Members": []interface{}{},
"Members@odata.count": 0,
},
"/redfish/v1/Chassis/1/PCIeDevices/1": map[string]interface{}{
"@odata.context": "/redfish/v1/$metadata#PCIeDevice.PCIeDevice",
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/1",
"@odata.type": "#PCIeDevice.v1_4_0.PCIeDevice",
"Id": "1",
"Name": "PCIe Device",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if got.Hardware == nil {
t.Fatalf("expected hardware")
}
if len(got.Hardware.NetworkAdapters) != 0 {
t.Fatalf("expected placeholder network adapters to be skipped, got %d", len(got.Hardware.NetworkAdapters))
}
if len(got.Hardware.PCIeDevices) != 0 {
t.Fatalf("expected placeholder PCIe devices to be skipped, got %d", len(got.Hardware.PCIeDevices))
}
if len(got.Hardware.Storage) != 0 {
t.Fatalf("expected placeholder storage members to be skipped, got %d", len(got.Hardware.Storage))
}
}
func TestReplayRedfishFromRawPayloads_PrefersActiveBMCInterfaceForBoardMAC(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}},
},
"/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Managers/1/EthernetInterfaces": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1/EthernetInterfaces/eth1"},
},
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"NcsiEnabled": true,
"LLDP": map[string]interface{}{
"LLDPMode": "Rx",
"Members": []interface{}{
map[string]interface{}{
"EthIndex": "eth1",
"ChassisName": "castor.netwell.local",
"PortDesc": "ge-0/0/17",
"PortId": "531",
"VlanId": 20,
},
},
},
},
},
},
"/redfish/v1/Managers/1/EthernetInterfaces/eth0": map[string]interface{}{
"Id": "eth0",
"MACAddress": "00:25:6c:70:00:13",
"LinkStatus": "NoLink",
"SpeedMbps": 65535,
},
"/redfish/v1/Managers/1/EthernetInterfaces/eth1": map[string]interface{}{
"Id": "eth1",
"MACAddress": "00:25:6c:70:00:12",
"LinkStatus": "LinkActive",
"SpeedMbps": 1000,
"IPv4Addresses": []interface{}{
map[string]interface{}{
"Address": "172.16.41.42",
"Gateway": "172.16.41.1",
"SubnetMask": "255.255.255.0",
},
},
},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if got.Hardware == nil {
t.Fatalf("expected hardware")
}
if got.Hardware.BoardInfo.BMCMACAddress != "00:25:6C:70:00:12" {
t.Fatalf("expected active BMC MAC from eth1, got %q", got.Hardware.BoardInfo.BMCMACAddress)
}
summary, ok := got.RawPayloads["redfish_bmc_network_summary"].(map[string]any)
if !ok {
t.Fatalf("expected redfish_bmc_network_summary")
}
if summary["interface_id"] != "eth1" {
t.Fatalf("expected eth1 summary, got %#v", summary["interface_id"])
}
}
func TestReplayRedfishFromRawPayloads_AddsSensorsListHintSummary(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"}},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Multillect",
"Model": "MLT-S06",
"SerialNumber": "430044262001626",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"}},
},
"/redfish/v1/Chassis/1": map[string]interface{}{"Id": "1"},
"/redfish/v1/Chassis/1/SensorsList": map[string]interface{}{
"SensorsList": []interface{}{
map[string]interface{}{"SensorName": "DIMM000_Status", "SensorType": "Memory", "Status": "OK"},
map[string]interface{}{"SensorName": "DIMM001_Status", "SensorType": "Memory", "Status": "nop"},
map[string]interface{}{"SensorName": "DIMM100_Status", "SensorType": "Memory", "Status": "OK"},
map[string]interface{}{"SensorName": "HDD0_F_Status", "SensorType": "Drive Slot", "Status": "nop"},
map[string]interface{}{"SensorName": "NVME0_F_Status", "SensorType": "Drive Slot", "Status": "nop"},
map[string]interface{}{"SensorName": "Logical_Drive", "SensorType": "Drive Slot", "Status": "OK"},
},
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"}},
},
"/redfish/v1/Managers/1": map[string]interface{}{"Id": "1"},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
hints, ok := got.RawPayloads["redfish_sensor_hints"].(map[string]any)
if !ok {
t.Fatalf("expected redfish_sensor_hints")
}
memHints, ok := hints["memory_slots"].(map[string]any)
if !ok {
t.Fatalf("expected memory_slots hint")
}
if asInt(memHints["present_count"]) != 2 {
t.Fatalf("expected 2 present memory slot hints, got %#v", memHints["present_count"])
}
driveHints, ok := hints["drive_slots"].(map[string]any)
if !ok {
t.Fatalf("expected drive_slots hint")
}
if asInt(driveHints["physical_total"]) != 2 {
t.Fatalf("expected 2 physical drive slots, got %#v", driveHints["physical_total"])
}
if driveHints["logical_drive_status"] != "OK" {
t.Fatalf("expected logical drive status OK, got %#v", driveHints["logical_drive_status"])
}
foundMemoryEvent := false
foundDriveEvent := false
for _, ev := range got.Events {
if strings.Contains(ev.Description, "Memory slot sensors report 2 populated positions out of 3") {
foundMemoryEvent = true
}
if strings.Contains(ev.Description, "Drive slot sensors report 0 active physical slots out of 2") {
foundDriveEvent = true
}
}
if !foundMemoryEvent {
t.Fatalf("expected memory slot hint event")
}
if !foundDriveEvent {
t.Fatalf("expected drive slot hint event")
}
}
func TestReplayRedfishFromRawPayloads_PreservesSourceTimezoneAndUTCCollectedAt(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
@@ -933,7 +1366,9 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
"Manufacturer": "vendor0",
"SerialNumber": "N/A",
"Oem": map[string]interface{}{
"SerialNumber": "SN-OK-001",
"VendorX": map[string]interface{}{
"SerialNumber": "SN-OK-001",
},
},
}
@@ -973,6 +1408,38 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
}
}
func TestParseCPU_UsesPublicSerialAsPPINAndCurrentSpeedMHz(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
"Id": "CPU0",
"Model": "Intel Xeon",
"TotalCores": 48,
"TotalThreads": 96,
"MaxSpeedMHz": 4000,
"OperatingSpeedMHz": 0,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"SerialNumber": "6FB5241E81CECDFD",
"CurrentSpeedMHz": 2700,
},
},
},
})
if len(cpus) != 1 {
t.Fatalf("expected one CPU, got %d", len(cpus))
}
if cpus[0].PPIN != "6FB5241E81CECDFD" {
t.Fatalf("expected PPIN from Oem.Public.SerialNumber, got %+v", cpus[0])
}
if cpus[0].SerialNumber != "" {
t.Fatalf("expected empty CPU serial number when only Public serial exists, got %+v", cpus[0])
}
if cpus[0].FrequencyMHz != 2700 {
t.Fatalf("expected CPU frequency from Oem.Public.CurrentSpeedMHz, got %+v", cpus[0])
}
}
func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
@@ -1682,7 +2149,7 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing.
}}
got := r.collectStorage("/redfish/v1/Systems/1", redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true},
Directives: redfishprofile.AnalysisDirectives{EnableKnownStorageControllerRecovery: true},
KnownStorageDriveCollections: []string{"/Storage/IntelVROC/Drives"},
})
if len(got) != 1 {
@@ -2352,6 +2819,20 @@ func TestAppendPSU_MergesRicherDuplicate(t *testing.T) {
}
}
func TestRedfishPSUNominalWattage_PrefersInputRangeOutputWattage(t *testing.T) {
doc := map[string]interface{}{
"PowerCapacityWatts": 22600,
"InputRanges": []interface{}{
map[string]interface{}{"OutputWattage": 2700},
map[string]interface{}{"OutputWattage": 3200},
},
}
if got := redfishPSUNominalWattage(doc); got != 3200 {
t.Fatalf("redfishPSUNominalWattage() = %d, want 3200", got)
}
}
func TestReplayCollectGPUs_DropsModelOnlyPlaceholderWhenConcreteDiscoveredLater(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Systems/1/GraphicsControllers": map[string]interface{}{
@@ -2672,7 +3153,7 @@ func TestCollectGPUsFromProcessors_SupermicroHGXUsesChassisAliasSerial(t *testin
gpus := r.collectGPUs(systemPaths, chassisPaths, testAnalysisPlan(redfishprofile.AnalysisDirectives{EnableGenericGraphicsControllerDedup: true}))
gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus, redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true},
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableProcessorGPUChassisAlias: true},
ProcessorGPUChassisLookupModes: []string{"hgx-alias"},
})
@@ -2710,7 +3191,7 @@ func TestCollectGPUsFromProcessors_MSIUsesIndexedChassisLookup(t *testing.T) {
[]string{"/redfish/v1/Chassis/GPU1"},
nil,
redfishprofile.ResolvedAnalysisPlan{
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true},
Directives: redfishprofile.AnalysisDirectives{EnableProcessorGPUFallback: true, EnableMSIProcessorGPUChassisLookup: true},
ProcessorGPUChassisLookupModes: []string{"msi-index"},
},
)

View File

@@ -102,6 +102,7 @@ func genericProfile() Profile {
ensureRecoveryPolicy(plan, AcquisitionRecoveryPolicy{
EnableCriticalCollectionMemberRetry: true,
EnableCriticalSlowProbe: true,
EnableEmptyCriticalCollectionRetry: true,
})
ensureRatePolicy(plan, AcquisitionRatePolicy{
TargetP95LatencyMS: 900,

View File

@@ -0,0 +1,149 @@
package redfishprofile
import (
"regexp"
"strings"
)
var (
outboardCardHintRe = regexp.MustCompile(`/outboardPCIeCard\d+(?:/|$)`)
obDriveHintRe = regexp.MustCompile(`/Drives/OB\d+$`)
fpDriveHintRe = regexp.MustCompile(`/Drives/FP00HDD\d+$`)
vrFirmwareHintRe = regexp.MustCompile(`^CPU\d+_PVCC.*_VR$`)
)
var inspurGroupOEMFirmwareHints = map[string]struct{}{
"Front_HDD_CPLD0": {},
"MainBoard0CPLD": {},
"MainBoardCPLD": {},
"PDBBoardCPLD": {},
"SCMCPLD": {},
"SWBoardCPLD": {},
}
func inspurGroupOEMPlatformsProfile() Profile {
return staticProfile{
name: "inspur-group-oem-platforms",
priority: 25,
safeForFallback: false,
matchFn: func(s MatchSignals) int {
topologyScore := 0
boardScore := 0
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
fpDrives := matchedPathTokens(s.ResourceHints, "", fpDriveHintRe)
firmwareNames, vrFirmwareNames := inspurGroupOEMFirmwareMatches(s.ResourceHints)
if len(chassisOutboard) > 0 {
topologyScore += 20
}
if len(systemOutboard) > 0 {
topologyScore += 10
}
switch {
case len(obDrives) > 0 && len(fpDrives) > 0:
topologyScore += 15
}
switch {
case len(firmwareNames) >= 2:
boardScore += 15
}
switch {
case len(vrFirmwareNames) >= 2:
boardScore += 10
}
if anySignalContains(s, "COMMONbAssembly") {
boardScore += 12
}
if anySignalContains(s, "EnvironmentMetrcs") {
boardScore += 8
}
if anySignalContains(s, "GetServerAllUSBStatus") {
boardScore += 8
}
if topologyScore == 0 || boardScore == 0 {
return 0
}
return min(topologyScore+boardScore, 100)
},
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")
},
applyAnalysisDirectives: func(d *AnalysisDirectives, _ MatchSignals) {
d.EnableGenericGraphicsControllerDedup = true
},
}
}
func matchedPathTokens(paths []string, requiredPrefix string, re *regexp.Regexp) []string {
seen := make(map[string]struct{})
for _, rawPath := range paths {
path := normalizePath(rawPath)
if path == "" || (requiredPrefix != "" && !strings.HasPrefix(path, requiredPrefix)) {
continue
}
token := re.FindString(path)
if token == "" {
continue
}
token = strings.Trim(token, "/")
if token == "" {
continue
}
seen[token] = struct{}{}
}
out := make([]string, 0, len(seen))
for token := range seen {
out = append(out, token)
}
return dedupeSorted(out)
}
func inspurGroupOEMFirmwareMatches(paths []string) ([]string, []string) {
firmwareNames := make(map[string]struct{})
vrNames := make(map[string]struct{})
for _, rawPath := range paths {
path := normalizePath(rawPath)
if !strings.HasPrefix(path, "/redfish/v1/UpdateService/FirmwareInventory/") {
continue
}
name := strings.TrimSpace(path[strings.LastIndex(path, "/")+1:])
if name == "" {
continue
}
if _, ok := inspurGroupOEMFirmwareHints[name]; ok {
firmwareNames[name] = struct{}{}
}
if vrFirmwareHintRe.MatchString(name) {
vrNames[name] = struct{}{}
}
}
return mapKeysSorted(firmwareNames), mapKeysSorted(vrNames)
}
func anySignalContains(signals MatchSignals, needle string) bool {
needle = strings.TrimSpace(needle)
if needle == "" {
return false
}
for _, signal := range signals.ResourceHints {
if strings.Contains(signal, needle) {
return true
}
}
for _, signal := range signals.DocHints {
if strings.Contains(signal, needle) {
return true
}
}
return false
}
func mapKeysSorted(items map[string]struct{}) []string {
out := make([]string, 0, len(items))
for item := range items {
out = append(out, item)
}
return dedupeSorted(out)
}

View File

@@ -0,0 +1,182 @@
package redfishprofile
import (
"archive/zip"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *testing.T) {
tree := map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"@odata.id": "/redfish/v1",
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"USB": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Oem/Public/GetServerAllUSBStatus",
},
},
},
"NetworkInterfaces": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces",
},
},
"/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/outboardPCIeCard1"},
},
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1",
"Actions": map[string]interface{}{
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"NvGpuPowerLimitWatts": map[string]interface{}{
"target": "/redfish/v1/Chassis/1/GPU/EnvironmentMetrcs",
},
},
},
},
"Drives": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/Drives",
},
"NetworkAdapters": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters",
},
},
"/redfish/v1/Chassis/1/Drives": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/OB01"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Drives/FP00HDD00"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/outboardPCIeCard1"},
},
},
"/redfish/v1/Chassis/1/Assembly": map[string]interface{}{
"Assemblies": []interface{}{
map[string]interface{}{
"Oem": map[string]interface{}{
"COMMONb": map[string]interface{}{
"COMMONbAssembly": map[string]interface{}{
"@odata.type": "#COMMONbAssembly.v1_0_0.COMMONbAssembly",
},
},
},
},
},
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Actions": map[string]interface{}{
"Oem": map[string]interface{}{
"#PublicManager.ExportConfFile": map[string]interface{}{
"target": "/redfish/v1/Managers/1/Actions/Oem/Public/ExportConfFile",
},
},
},
},
"/redfish/v1/UpdateService/FirmwareInventory": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/Front_HDD_CPLD0"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/SCMCPLD"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU0_PVCCD_HV_VR"},
map[string]interface{}{"@odata.id": "/redfish/v1/UpdateService/FirmwareInventory/CPU1_PVCCIN_VR"},
},
},
}
signals := CollectSignalsFromTree(tree)
match := MatchProfiles(signals)
if match.Mode != ModeMatched {
t.Fatalf("expected matched mode, got %q", match.Mode)
}
assertProfileSelected(t, match, "inspur-group-oem-platforms")
}
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
examples := []string{
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",
"2026-03-11 (SYS-821GE-TNHR) - A514359X5C08846.zip",
"2026-03-15 (CG480-S5063) - P5T0006091.zip",
"2026-03-18 (CG290-S3063) - PAT0011258.zip",
"2024-04-25 (AS -4124GQ-TNMI) - S490387X4418273.zip",
}
for _, name := range examples {
t.Run(name, func(t *testing.T) {
tree := loadRawExportTreeFromExampleZip(t, name)
match := MatchProfiles(CollectSignalsFromTree(tree))
assertProfileNotSelected(t, match, "inspur-group-oem-platforms")
})
}
}
func loadRawExportTreeFromExampleZip(t *testing.T, name string) map[string]interface{} {
t.Helper()
path := filepath.Join("..", "..", "..", "example", name)
f, err := os.Open(path)
if err != nil {
t.Fatalf("open example zip %s: %v", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
t.Fatalf("stat example zip %s: %v", path, err)
}
zr, err := zip.NewReader(f, info.Size())
if err != nil {
t.Fatalf("read example zip %s: %v", path, err)
}
for _, file := range zr.File {
if file.Name != "raw_export.json" {
continue
}
rc, err := file.Open()
if err != nil {
t.Fatalf("open %s in %s: %v", file.Name, path, err)
}
defer rc.Close()
var payload struct {
Source struct {
RawPayloads struct {
RedfishTree map[string]interface{} `json:"redfish_tree"`
} `json:"raw_payloads"`
} `json:"source"`
}
if err := json.NewDecoder(rc).Decode(&payload); err != nil {
t.Fatalf("decode raw_export.json from %s: %v", path, err)
}
if len(payload.Source.RawPayloads.RedfishTree) == 0 {
t.Fatalf("example %s has empty redfish_tree", path)
}
return payload.Source.RawPayloads.RedfishTree
}
t.Fatalf("raw_export.json not found in %s", path)
return nil
}

View File

@@ -55,6 +55,7 @@ func BuiltinProfiles() []Profile {
msiProfile(),
supermicroProfile(),
dellProfile(),
inspurGroupOEMPlatformsProfile(),
hgxProfile(),
xfusionProfile(),
}
@@ -205,6 +206,9 @@ func ensureRecoveryPolicy(plan *AcquisitionPlan, policy AcquisitionRecoveryPolic
if policy.EnableProfilePlanB {
plan.Tuning.RecoveryPolicy.EnableProfilePlanB = true
}
if policy.EnableEmptyCriticalCollectionRetry {
plan.Tuning.RecoveryPolicy.EnableEmptyCriticalCollectionRetry = true
}
}
func ensureScopedPathPolicy(plan *AcquisitionPlan, policy AcquisitionScopedPathPolicy) {

View File

@@ -2,7 +2,14 @@ package redfishprofile
import "strings"
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string) MatchSignals {
func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string]interface{}, resourceHints []string, hintDocs ...map[string]interface{}) MatchSignals {
resourceHints = append([]string{}, resourceHints...)
docHints := make([]string, 0)
for _, doc := range append([]map[string]interface{}{serviceRootDoc, systemDoc, chassisDoc, managerDoc}, hintDocs...) {
embeddedPaths, embeddedHints := collectDocSignalHints(doc)
resourceHints = append(resourceHints, embeddedPaths...)
docHints = append(docHints, embeddedHints...)
}
signals := MatchSignals{
ServiceRootVendor: lookupString(serviceRootDoc, "Vendor"),
ServiceRootProduct: lookupString(serviceRootDoc, "Product"),
@@ -13,6 +20,7 @@ func CollectSignals(serviceRootDoc, systemDoc, chassisDoc, managerDoc map[string
ChassisModel: lookupString(chassisDoc, "Model"),
ManagerManufacturer: lookupString(managerDoc, "Manufacturer"),
ResourceHints: resourceHints,
DocHints: docHints,
}
signals.OEMNamespaces = dedupeSorted(append(
oemNamespaces(serviceRootDoc),
@@ -50,6 +58,7 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
managerPath := memberPath("/redfish/v1/Managers", "/redfish/v1/Managers/1")
resourceHints := make([]string, 0, len(tree))
hintDocs := make([]map[string]interface{}, 0, len(tree))
for path := range tree {
path = strings.TrimSpace(path)
if path == "" {
@@ -57,6 +66,13 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
}
resourceHints = append(resourceHints, path)
}
for _, v := range tree {
doc, ok := v.(map[string]interface{})
if !ok {
continue
}
hintDocs = append(hintDocs, doc)
}
return CollectSignals(
getDoc("/redfish/v1"),
@@ -64,9 +80,72 @@ func CollectSignalsFromTree(tree map[string]interface{}) MatchSignals {
getDoc(chassisPath),
getDoc(managerPath),
resourceHints,
hintDocs...,
)
}
func collectDocSignalHints(doc map[string]interface{}) ([]string, []string) {
if len(doc) == 0 {
return nil, nil
}
paths := make([]string, 0)
hints := make([]string, 0)
var walk func(any)
walk = func(v any) {
switch x := v.(type) {
case map[string]interface{}:
for rawKey, child := range x {
key := strings.TrimSpace(rawKey)
if key != "" {
hints = append(hints, key)
}
if s, ok := child.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
switch key {
case "@odata.id", "target":
paths = append(paths, s)
case "@odata.type":
hints = append(hints, s)
default:
if isInterestingSignalString(s) {
hints = append(hints, s)
if strings.HasPrefix(s, "/") {
paths = append(paths, s)
}
}
}
}
}
walk(child)
}
case []interface{}:
for _, child := range x {
walk(child)
}
}
}
walk(doc)
return paths, hints
}
func isInterestingSignalString(s string) bool {
switch {
case strings.HasPrefix(s, "/"):
return true
case strings.HasPrefix(s, "#"):
return true
case strings.Contains(s, "COMMONb"):
return true
case strings.Contains(s, "EnvironmentMetrcs"):
return true
case strings.Contains(s, "GetServerAllUSBStatus"):
return true
default:
return false
}
}
func lookupString(doc map[string]interface{}, key string) string {
if len(doc) == 0 {
return ""

View File

@@ -17,6 +17,7 @@ type MatchSignals struct {
ManagerManufacturer string
OEMNamespaces []string
ResourceHints []string
DocHints []string
}
type AcquisitionPlan struct {
@@ -90,6 +91,7 @@ type AcquisitionRecoveryPolicy struct {
EnableCriticalCollectionMemberRetry bool
EnableCriticalSlowProbe bool
EnableProfilePlanB bool
EnableEmptyCriticalCollectionRetry bool
}
type AcquisitionPrefetchPolicy struct {
@@ -109,12 +111,12 @@ type AnalysisDirectives struct {
}
type ResolvedAnalysisPlan struct {
Match MatchResult
Directives AnalysisDirectives
Notes []string
ProcessorGPUChassisLookupModes []string
KnownStorageDriveCollections []string
KnownStorageVolumeCollections []string
Match MatchResult
Directives AnalysisDirectives
Notes []string
ProcessorGPUChassisLookupModes []string
KnownStorageDriveCollections []string
KnownStorageVolumeCollections []string
}
type Profile interface {
@@ -145,6 +147,7 @@ type ProfileScore struct {
func normalizeSignals(signals MatchSignals) MatchSignals {
signals.OEMNamespaces = dedupeSorted(signals.OEMNamespaces)
signals.ResourceHints = dedupeSorted(signals.ResourceHints)
signals.DocHints = dedupeSorted(signals.DocHints)
return signals
}

View File

@@ -15,7 +15,9 @@ type Request struct {
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
PowerOnIfHostOff bool
StopHostAfterCollect bool
DebugPayloads bool
}
type Progress struct {

View File

@@ -43,13 +43,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
TargetHost: targetHost,
CollectedAt: collectedAt,
Hardware: ReanimatorHardware{
Board: convertBoard(result.Hardware.BoardInfo),
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Board: convertBoard(result.Hardware.BoardInfo),
Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)),
CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))),
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors),
EventLogs: convertEventLogs(result.Events, collectedAt),
},

View File

@@ -0,0 +1,601 @@
package easy_bee
import (
"encoding/json"
"fmt"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.0"
func init() {
parser.Register(&Parser{})
}
// Parser imports support bundles produced by reanimator-easy-bee.
// These archives embed a ready-to-use hardware snapshot in export/bee-audit.json.
type Parser struct{}
func (p *Parser) Name() string {
return "Reanimator Easy Bee Parser"
}
func (p *Parser) Vendor() string {
return "easy_bee"
}
func (p *Parser) Version() string {
return parserVersion
}
func (p *Parser) Detect(files []parser.ExtractedFile) int {
confidence := 0
hasManifest := false
hasBeeAudit := false
hasRuntimeHealth := false
hasTechdump := false
hasBundlePrefix := false
for _, f := range files {
path := strings.ToLower(strings.TrimSpace(f.Path))
content := strings.ToLower(string(f.Content))
if !hasBundlePrefix && strings.Contains(path, "bee-support-") {
hasBundlePrefix = true
confidence += 5
}
if (strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") &&
strings.Contains(content, "bee_version=") {
hasManifest = true
confidence += 35
if strings.Contains(content, "export_dir=") {
confidence += 10
}
}
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
hasBeeAudit = true
confidence += 55
}
if hasBundlePrefix && (strings.HasSuffix(path, "/export/runtime-health.json") || path == "runtime-health.json") {
hasRuntimeHealth = true
confidence += 10
}
if hasBundlePrefix && !hasTechdump && strings.Contains(path, "/export/techdump/") {
hasTechdump = true
confidence += 10
}
}
if hasManifest && hasBeeAudit {
return 100
}
if hasBeeAudit && (hasRuntimeHealth || hasTechdump) {
confidence += 10
}
if confidence > 100 {
return 100
}
return confidence
}
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
snapshotFile := findSnapshotFile(files)
if snapshotFile == nil {
return nil, fmt.Errorf("easy-bee snapshot not found")
}
var snapshot beeSnapshot
if err := json.Unmarshal(snapshotFile.Content, &snapshot); err != nil {
return nil, fmt.Errorf("decode %s: %w", snapshotFile.Path, err)
}
manifest := parseManifest(files)
result := &models.AnalysisResult{
SourceType: strings.TrimSpace(snapshot.SourceType),
Protocol: strings.TrimSpace(snapshot.Protocol),
TargetHost: firstNonEmpty(snapshot.TargetHost, manifest.Host),
SourceTimezone: strings.TrimSpace(snapshot.SourceTimezone),
CollectedAt: chooseCollectedAt(snapshot, manifest),
InventoryLastModifiedAt: snapshot.InventoryLastModifiedAt,
RawPayloads: snapshot.RawPayloads,
Events: make([]models.Event, 0),
FRU: append([]models.FRUInfo(nil), snapshot.FRU...),
Sensors: make([]models.SensorReading, 0),
Hardware: &models.HardwareConfig{
Firmware: append([]models.FirmwareInfo(nil), snapshot.Hardware.Firmware...),
BoardInfo: snapshot.Hardware.Board,
Devices: append([]models.HardwareDevice(nil), snapshot.Hardware.Devices...),
CPUs: append([]models.CPU(nil), snapshot.Hardware.CPUs...),
Memory: append([]models.MemoryDIMM(nil), snapshot.Hardware.Memory...),
Storage: append([]models.Storage(nil), snapshot.Hardware.Storage...),
Volumes: append([]models.StorageVolume(nil), snapshot.Hardware.Volumes...),
PCIeDevices: normalizePCIeDevices(snapshot.Hardware.PCIeDevices),
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...),
},
}
result.Events = append(result.Events, snapshot.Events...)
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
result.Sensors = append(result.Sensors, snapshot.Sensors...)
result.Sensors = append(result.Sensors, flattenSensorGroups(snapshot.Hardware.Sensors)...)
if len(result.FRU) == 0 {
if boardFRU, ok := buildBoardFRU(snapshot.Hardware.Board); ok {
result.FRU = append(result.FRU, boardFRU)
}
}
if result.Hardware == nil || (result.Hardware.BoardInfo.SerialNumber == "" &&
len(result.Hardware.CPUs) == 0 &&
len(result.Hardware.Memory) == 0 &&
len(result.Hardware.Storage) == 0 &&
len(result.Hardware.PCIeDevices) == 0 &&
len(result.Hardware.Devices) == 0) {
return nil, fmt.Errorf("unsupported easy-bee snapshot format")
}
return result, nil
}
type beeSnapshot struct {
SourceType string `json:"source_type,omitempty"`
Protocol string `json:"protocol,omitempty"`
TargetHost string `json:"target_host,omitempty"`
SourceTimezone string `json:"source_timezone,omitempty"`
CollectedAt time.Time `json:"collected_at,omitempty"`
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"`
Events []models.Event `json:"events,omitempty"`
FRU []models.FRUInfo `json:"fru,omitempty"`
Sensors []models.SensorReading `json:"sensors,omitempty"`
Hardware beeHardware `json:"hardware"`
Runtime beeRuntime `json:"runtime,omitempty"`
}
type beeHardware struct {
Board models.BoardInfo `json:"board"`
Firmware []models.FirmwareInfo `json:"firmware,omitempty"`
Devices []models.HardwareDevice `json:"devices,omitempty"`
CPUs []models.CPU `json:"cpus,omitempty"`
Memory []models.MemoryDIMM `json:"memory,omitempty"`
Storage []models.Storage `json:"storage,omitempty"`
Volumes []models.StorageVolume `json:"volumes,omitempty"`
PCIeDevices []models.PCIeDevice `json:"pcie_devices,omitempty"`
GPUs []models.GPU `json:"gpus,omitempty"`
NetworkCards []models.NIC `json:"network_cards,omitempty"`
NetworkAdapters []models.NetworkAdapter `json:"network_adapters,omitempty"`
PowerSupply []models.PSU `json:"power_supplies,omitempty"`
Sensors beeSensorGroups `json:"sensors,omitempty"`
EventLogs []beeEventLog `json:"event_logs,omitempty"`
}
type beeSensorGroups struct {
Fans []beeFanSensor `json:"fans,omitempty"`
Power []beePowerSensor `json:"power,omitempty"`
Temperatures []beeTemperatureSensor `json:"temperatures,omitempty"`
Other []beeOtherSensor `json:"other,omitempty"`
}
type beeFanSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
RPM int `json:"rpm,omitempty"`
Status string `json:"status,omitempty"`
}
type beePowerSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
CurrentA float64 `json:"current_a,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Status string `json:"status,omitempty"`
}
type beeTemperatureSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Celsius float64 `json:"celsius,omitempty"`
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
Status string `json:"status,omitempty"`
}
type beeOtherSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Status string `json:"status,omitempty"`
}
type beeRuntime struct {
Status string `json:"status,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
NetworkStatus string `json:"network_status,omitempty"`
Issues []beeRuntimeIssue `json:"issues,omitempty"`
Services []beeRuntimeStatus `json:"services,omitempty"`
Interfaces []beeInterface `json:"interfaces,omitempty"`
}
type beeRuntimeIssue struct {
Code string `json:"code,omitempty"`
Severity string `json:"severity,omitempty"`
Description string `json:"description,omitempty"`
}
type beeRuntimeStatus struct {
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
}
type beeInterface struct {
Name string `json:"name,omitempty"`
State string `json:"state,omitempty"`
IPv4 []string `json:"ipv4,omitempty"`
Outcome string `json:"outcome,omitempty"`
}
type beeEventLog struct {
Source string `json:"source,omitempty"`
EventTime string `json:"event_time,omitempty"`
Severity string `json:"severity,omitempty"`
MessageID string `json:"message_id,omitempty"`
Message string `json:"message,omitempty"`
RawPayload map[string]any `json:"raw_payload,omitempty"`
}
type manifestMetadata struct {
Host string
GeneratedAtUTC time.Time
}
func findSnapshotFile(files []parser.ExtractedFile) *parser.ExtractedFile {
for i := range files {
path := strings.ToLower(strings.TrimSpace(files[i].Path))
if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" {
return &files[i]
}
}
for i := range files {
path := strings.ToLower(strings.TrimSpace(files[i].Path))
if strings.HasSuffix(path, ".json") && strings.Contains(path, "reanimator") {
return &files[i]
}
}
return nil
}
func parseManifest(files []parser.ExtractedFile) manifestMetadata {
var meta manifestMetadata
for _, f := range files {
path := strings.ToLower(strings.TrimSpace(f.Path))
if !(strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") {
continue
}
lines := strings.Split(string(f.Content), "\n")
for _, line := range lines {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "host":
meta.Host = strings.TrimSpace(value)
case "generated_at_utc":
if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
meta.GeneratedAtUTC = ts.UTC()
}
}
}
break
}
return meta
}
func chooseCollectedAt(snapshot beeSnapshot, manifest manifestMetadata) time.Time {
switch {
case !snapshot.CollectedAt.IsZero():
return snapshot.CollectedAt.UTC()
case !snapshot.Runtime.CheckedAt.IsZero():
return snapshot.Runtime.CheckedAt.UTC()
case !manifest.GeneratedAtUTC.IsZero():
return manifest.GeneratedAtUTC.UTC()
default:
return time.Time{}
}
}
func convertRuntimeToEvents(runtime beeRuntime, fallback time.Time) []models.Event {
events := make([]models.Event, 0)
ts := runtime.CheckedAt
if ts.IsZero() {
ts = fallback
}
if status := strings.TrimSpace(runtime.Status); status != "" {
desc := "Bee runtime status: " + status
if networkStatus := strings.TrimSpace(runtime.NetworkStatus); networkStatus != "" {
desc += " (network: " + networkStatus + ")"
}
events = append(events, models.Event{
Timestamp: ts,
Source: "Bee Runtime",
EventType: "Runtime Status",
Severity: mapSeverity(status),
Description: desc,
})
}
for _, issue := range runtime.Issues {
desc := strings.TrimSpace(issue.Description)
if desc == "" {
desc = "Bee runtime issue"
}
events = append(events, models.Event{
Timestamp: ts,
Source: "Bee Runtime",
EventType: "Runtime Issue",
Severity: mapSeverity(issue.Severity),
Description: desc,
RawData: strings.TrimSpace(issue.Code),
})
}
for _, svc := range runtime.Services {
status := strings.TrimSpace(svc.Status)
if status == "" || strings.EqualFold(status, "active") {
continue
}
events = append(events, models.Event{
Timestamp: ts,
Source: "systemd",
EventType: "Service Status",
Severity: mapSeverity(status),
Description: fmt.Sprintf("%s is %s", strings.TrimSpace(svc.Name), status),
})
}
for _, iface := range runtime.Interfaces {
state := strings.TrimSpace(iface.State)
outcome := strings.TrimSpace(iface.Outcome)
if state == "" && outcome == "" {
continue
}
if strings.EqualFold(state, "up") && strings.EqualFold(outcome, "lease_acquired") {
continue
}
desc := fmt.Sprintf("interface %s state=%s outcome=%s", strings.TrimSpace(iface.Name), state, outcome)
events = append(events, models.Event{
Timestamp: ts,
Source: "network",
EventType: "Interface Status",
Severity: models.SeverityWarning,
Description: strings.TrimSpace(desc),
})
}
return events
}
func convertEventLogs(items []beeEventLog) []models.Event {
events := make([]models.Event, 0, len(items))
for _, item := range items {
message := strings.TrimSpace(item.Message)
if message == "" {
continue
}
ts := parseEventTime(item.EventTime)
rawData := strings.TrimSpace(item.MessageID)
events = append(events, models.Event{
Timestamp: ts,
Source: firstNonEmpty(strings.TrimSpace(item.Source), "Reanimator"),
EventType: "Event Log",
Severity: mapSeverity(item.Severity),
Description: message,
RawData: rawData,
})
}
return events
}
func parseEventTime(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
layouts := []string{time.RFC3339Nano, time.RFC3339}
for _, layout := range layouts {
if ts, err := time.Parse(layout, raw); err == nil {
return ts.UTC()
}
}
return time.Time{}
}
func flattenSensorGroups(groups beeSensorGroups) []models.SensorReading {
result := make([]models.SensorReading, 0, len(groups.Fans)+len(groups.Power)+len(groups.Temperatures)+len(groups.Other))
for _, fan := range groups.Fans {
result = append(result, models.SensorReading{
Name: sensorName(fan.Name, fan.Location),
Type: "fan",
Value: float64(fan.RPM),
Unit: "RPM",
Status: strings.TrimSpace(fan.Status),
})
}
for _, power := range groups.Power {
name := sensorName(power.Name, power.Location)
status := strings.TrimSpace(power.Status)
if power.PowerW != 0 {
result = append(result, models.SensorReading{
Name: name,
Type: "power",
Value: power.PowerW,
Unit: "W",
Status: status,
})
}
if power.VoltageV != 0 {
result = append(result, models.SensorReading{
Name: name + " Voltage",
Type: "voltage",
Value: power.VoltageV,
Unit: "V",
Status: status,
})
}
if power.CurrentA != 0 {
result = append(result, models.SensorReading{
Name: name + " Current",
Type: "current",
Value: power.CurrentA,
Unit: "A",
Status: status,
})
}
}
for _, temp := range groups.Temperatures {
result = append(result, models.SensorReading{
Name: sensorName(temp.Name, temp.Location),
Type: "temperature",
Value: temp.Celsius,
Unit: "C",
Status: strings.TrimSpace(temp.Status),
})
}
for _, other := range groups.Other {
result = append(result, models.SensorReading{
Name: sensorName(other.Name, other.Location),
Type: "other",
Value: other.Value,
Unit: strings.TrimSpace(other.Unit),
Status: strings.TrimSpace(other.Status),
})
}
return result
}
func sensorName(name, location string) string {
name = strings.TrimSpace(name)
location = strings.TrimSpace(location)
if name == "" {
return location
}
if location == "" {
return name
}
return name + " [" + location + "]"
}
func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
out := append([]models.PCIeDevice(nil), items...)
for i := range out {
slot := strings.TrimSpace(out[i].Slot)
if out[i].BDF == "" && looksLikeBDF(slot) {
out[i].BDF = slot
}
if out[i].Slot == "" && out[i].BDF != "" {
out[i].Slot = out[i].BDF
}
}
return out
}
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
out := append([]models.NetworkAdapter(nil), items...)
for i := range out {
slot := strings.TrimSpace(out[i].Slot)
if out[i].BDF == "" && looksLikeBDF(slot) {
out[i].BDF = slot
}
if out[i].Slot == "" && out[i].BDF != "" {
out[i].Slot = out[i].BDF
}
}
return out
}
func looksLikeBDF(value string) bool {
value = strings.TrimSpace(value)
if len(value) != len("0000:00:00.0") {
return false
}
for i, r := range value {
switch i {
case 4, 7:
if r != ':' {
return false
}
case 10:
if r != '.' {
return false
}
default:
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
}
return true
}
func buildBoardFRU(board models.BoardInfo) (models.FRUInfo, bool) {
if strings.TrimSpace(board.SerialNumber) == "" &&
strings.TrimSpace(board.Manufacturer) == "" &&
strings.TrimSpace(board.ProductName) == "" &&
strings.TrimSpace(board.PartNumber) == "" {
return models.FRUInfo{}, false
}
return models.FRUInfo{
Description: "System Board",
Manufacturer: strings.TrimSpace(board.Manufacturer),
ProductName: strings.TrimSpace(board.ProductName),
SerialNumber: strings.TrimSpace(board.SerialNumber),
PartNumber: strings.TrimSpace(board.PartNumber),
}, true
}
func mapSeverity(raw string) models.Severity {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "critical", "crit", "error", "failed", "failure":
return models.SeverityCritical
case "warning", "warn", "partial", "degraded", "inactive", "activating", "deactivating":
return models.SeverityWarning
default:
return models.SeverityInfo
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,219 @@
package easy_bee
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/parser"
)
func TestDetectBeeSupportArchive(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "bee-support-debian-20260325-162030/manifest.txt",
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
},
{
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
Content: []byte(`{"hardware":{"board":{"serial_number":"SN-BEE-001"}}}`),
},
{
Path: "bee-support-debian-20260325-162030/export/runtime-health.json",
Content: []byte(`{"status":"PARTIAL"}`),
},
}
if got := p.Detect(files); got < 90 {
t.Fatalf("expected high confidence detect score, got %d", got)
}
}
func TestDetectRejectsNonBeeArchive(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "random/manifest.txt",
Content: []byte("host=test\n"),
},
{
Path: "random/export/runtime-health.json",
Content: []byte(`{"status":"OK"}`),
},
}
if got := p.Detect(files); got != 0 {
t.Fatalf("expected detect score 0, got %d", got)
}
}
func TestParseBeeAuditSnapshot(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "bee-support-debian-20260325-162030/manifest.txt",
Content: []byte("bee_version=1.0.0\nhost=debian\ngenerated_at_utc=2026-03-25T16:20:30Z\nexport_dir=/appdata/bee/export\n"),
},
{
Path: "bee-support-debian-20260325-162030/export/bee-audit.json",
Content: []byte(`{
"source_type": "manual",
"target_host": "debian",
"collected_at": "2026-03-25T16:08:09Z",
"runtime": {
"status": "PARTIAL",
"checked_at": "2026-03-25T16:07:56Z",
"network_status": "OK",
"issues": [
{
"code": "nvidia_kernel_module_missing",
"severity": "warning",
"description": "NVIDIA kernel module is not loaded."
}
],
"services": [
{
"name": "bee-web",
"status": "inactive"
}
]
},
"hardware": {
"board": {
"manufacturer": "Supermicro",
"product_name": "AS-4124GQ-TNMI",
"serial_number": "S490387X4418273",
"part_number": "H12DGQ-NT6",
"uuid": "d868ae00-a61f-11ee-8000-7cc255e10309"
},
"firmware": [
{
"device_name": "BIOS",
"version": "2.8"
}
],
"cpus": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"socket": 1,
"model": "AMD EPYC 7763 64-Core Processor",
"cores": 64,
"threads": 128,
"frequency_mhz": 2450,
"max_frequency_mhz": 3525
}
],
"memory": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "P1-DIMMA1",
"location": "P0_Node0_Channel0_Dimm0",
"present": true,
"size_mb": 32768,
"type": "DDR4",
"max_speed_mhz": 3200,
"current_speed_mhz": 2933,
"manufacturer": "SK Hynix",
"serial_number": "80AD01224887286666",
"part_number": "HMA84GR7DJR4N-XN"
}
],
"storage": [
{
"status": "Unknown",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "nvme0n1",
"type": "NVMe",
"model": "KCD6XLUL960G",
"serial_number": "2470A00XT5M8",
"interface": "NVMe",
"present": true
}
],
"pcie_devices": [
{
"status": "OK",
"status_checked_at": "2026-03-25T16:08:09Z",
"slot": "0000:05:00.0",
"vendor_id": 5555,
"device_id": 4123,
"device_class": "EthernetController",
"manufacturer": "Mellanox Technologies",
"model": "MT28908 Family [ConnectX-6]",
"link_width": 16,
"link_speed": "Gen4",
"max_link_width": 16,
"max_link_speed": "Gen4",
"mac_addresses": ["94:6d:ae:9a:75:4a"],
"present": true
}
],
"sensors": {
"power": [
{
"name": "PPT",
"location": "amdgpu-pci-1100",
"power_w": 95
}
],
"temperatures": [
{
"name": "Composite",
"location": "nvme-pci-0600",
"celsius": 28.85,
"threshold_warning_celsius": 72.85,
"threshold_critical_celsius": 81.85,
"status": "OK"
}
]
}
}
}`),
},
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if result.Hardware == nil {
t.Fatal("expected hardware to be populated")
}
if result.TargetHost != "debian" {
t.Fatalf("expected target host debian, got %q", result.TargetHost)
}
wantCollectedAt := time.Date(2026, 3, 25, 16, 8, 9, 0, time.UTC)
if !result.CollectedAt.Equal(wantCollectedAt) {
t.Fatalf("expected collected_at %s, got %s", wantCollectedAt, result.CollectedAt)
}
if result.Hardware.BoardInfo.SerialNumber != "S490387X4418273" {
t.Fatalf("unexpected board serial %q", result.Hardware.BoardInfo.SerialNumber)
}
if len(result.Hardware.CPUs) != 1 {
t.Fatalf("expected 1 cpu, got %d", len(result.Hardware.CPUs))
}
if len(result.Hardware.Memory) != 1 {
t.Fatalf("expected 1 dimm, got %d", len(result.Hardware.Memory))
}
if len(result.Hardware.Storage) != 1 {
t.Fatalf("expected 1 storage device, got %d", len(result.Hardware.Storage))
}
if len(result.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected 1 pcie device, got %d", len(result.Hardware.PCIeDevices))
}
if result.Hardware.PCIeDevices[0].BDF != "0000:05:00.0" {
t.Fatalf("expected BDF to be normalized from slot, got %q", result.Hardware.PCIeDevices[0].BDF)
}
if len(result.Sensors) != 2 {
t.Fatalf("expected 2 flattened sensors, got %d", len(result.Sensors))
}
if len(result.Events) < 3 {
t.Fatalf("expected runtime events to be created, got %d", len(result.Events))
}
if len(result.FRU) == 0 {
t.Fatal("expected board FRU fallback to be populated")
}
}

View File

@@ -5,6 +5,7 @@ package vendors
import (
// Import vendor modules to trigger their init() registration
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/dell"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/easy_bee"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/h3c"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/inspur"
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"

View File

@@ -19,7 +19,9 @@ type CollectRequest struct {
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"`
DebugPayloads bool `json:"debug_payloads,omitempty"`
}
type CollectProbeResponse struct {

View File

@@ -10,6 +10,7 @@ import (
"fmt"
"html/template"
"io"
"net"
"net/http"
"os"
"path/filepath"
@@ -17,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"git.mchus.pro/mchus/logpile/internal/collector"
@@ -715,6 +717,19 @@ func hasUsableSerial(serial string) bool {
}
}
func hasUsableFirmwareVersion(version string) bool {
v := strings.TrimSpace(version)
if v == "" {
return false
}
switch strings.ToUpper(v) {
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
return false
default:
return true
}
}
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil || result.Hardware == nil {
@@ -942,7 +957,7 @@ func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
component = strings.TrimSpace(component)
model = strings.TrimSpace(model)
version = strings.TrimSpace(version)
if component == "" || version == "" {
if component == "" || !hasUsableFirmwareVersion(version) {
return
}
if model == "" {
@@ -1574,6 +1589,32 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
}
// pingHost dials host:port up to total times with 2s timeout each, returns true if
// at least need attempts succeeded.
func pingHost(host string, port int, total, need int) (bool, string) {
addr := fmt.Sprintf("%s:%d", host, port)
var successes atomic.Int32
done := make(chan struct{}, total)
for i := 0; i < total; i++ {
go func() {
defer func() { done <- struct{}{} }()
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
conn.Close()
successes.Add(1)
}
}()
}
for i := 0; i < total; i++ {
<-done
}
n := int(successes.Load())
if n < need {
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need)
}
return true, ""
}
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
var req CollectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -1595,6 +1636,11 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
return
}
if ok, msg := pingHost(req.Host, req.Port, 10, 3); !ok {
jsonError(w, msg, http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
@@ -1959,15 +2005,17 @@ 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,
PowerOnIfHostOff: req.PowerOnIfHostOff,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
StopHostAfterCollect: req.StopHostAfterCollect,
DebugPayloads: req.DebugPayloads,
}
}

View File

@@ -62,3 +62,22 @@ func TestBuildFirmwareEntries_IncludesGPUFirmwareFallback(t *testing.T) {
t.Fatalf("expected GPU firmware entry from hardware.gpus fallback")
}
}
func TestBuildFirmwareEntries_SkipsPlaceholderVersions(t *testing.T) {
hw := &models.HardwareConfig{
Firmware: []models.FirmwareInfo{
{DeviceName: "BMC", Version: "3.13.42P13"},
{DeviceName: "Front_BP_1", Version: "NA"},
{DeviceName: "Rear_BP_0", Version: "N/A"},
{DeviceName: "HDD_BP", Version: "-"},
},
}
entries := buildFirmwareEntries(hw)
if len(entries) != 1 {
t.Fatalf("expected only usable firmware entries, got %#v", entries)
}
if entries[0].Component != "BMC" || entries[0].Version != "3.13.42P13" {
t.Fatalf("unexpected remaining firmware entry: %#v", entries[0])
}
}

View File

@@ -210,7 +210,6 @@ main {
pointer-events: none;
}
#api-check-btn,
#api-connect-btn,
#api-power-on-collect-btn,
#api-collect-off-btn,
@@ -229,7 +228,6 @@ main {
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-check-btn:hover,
#api-connect-btn:hover,
#api-power-on-collect-btn:hover,
#api-collect-off-btn:hover,
@@ -242,7 +240,6 @@ main {
#convert-run-btn:disabled,
#convert-folder-btn:disabled,
#api-check-btn:disabled,
#api-connect-btn:disabled,
#api-power-on-collect-btn:disabled,
#api-collect-off-btn:disabled,
@@ -252,6 +249,127 @@ main {
cursor: not-allowed;
}
#api-collect-btn {
background: #1f8f4c;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.6rem 1.25rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#api-collect-btn:hover {
background: #176e3a;
}
#api-collect-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-probe-options {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.api-form-checkbox {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.9rem;
cursor: pointer;
user-select: none;
}
.api-form-checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.api-form-checkbox input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.api-form-checkbox span {
color: #444;
}
.api-form-checkbox-sub {
padding-left: 0.25rem;
opacity: 0.8;
}
.api-probe-options-separator {
margin: 0.5rem 0;
border-top: 1px solid #e2e8f0;
}
.api-confirm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.api-confirm-modal {
background: #fff;
border-radius: 10px;
padding: 1.5rem 1.75rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.api-confirm-modal p {
margin-bottom: 1.1rem;
font-size: 0.95rem;
color: #333;
line-height: 1.5;
}
.api-confirm-modal-actions {
display: flex;
gap: 0.6rem;
justify-content: flex-end;
}
.api-confirm-modal-actions button {
border: none;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.api-confirm-modal-actions .btn-cancel {
background: #e2e8f0;
color: #333;
}
.api-confirm-modal-actions .btn-cancel:hover {
background: #cbd5e1;
}
.api-confirm-modal-actions .btn-confirm {
background: #dc3545;
color: #fff;
}
.api-confirm-modal-actions .btn-confirm:hover {
background: #b02a37;
}
.api-connect-status {
margin-top: 0.75rem;
font-size: 0.85rem;

View File

@@ -91,14 +91,18 @@ function initApiSource() {
}
const cancelJobButton = document.getElementById('cancel-job-btn');
const checkButton = document.getElementById('api-check-btn');
const collectOffButton = document.getElementById('api-collect-off-btn');
const powerOnCollectButton = document.getElementById('api-power-on-collect-btn');
const connectButton = document.getElementById('api-connect-btn');
const collectButton = document.getElementById('api-collect-btn');
const powerOffCheckbox = document.getElementById('api-power-off');
const fieldNames = ['host', 'port', 'username', 'password'];
apiForm.addEventListener('submit', (event) => {
event.preventDefault();
startCollectionFromCurrentProbe(false);
if (apiProbeResult && apiProbeResult.reachable) {
startCollectionWithOptions();
} else {
startApiProbe();
}
});
if (cancelJobButton) {
@@ -106,21 +110,29 @@ function initApiSource() {
cancelCollectionJob();
});
}
if (checkButton) {
checkButton.addEventListener('click', () => {
if (connectButton) {
connectButton.addEventListener('click', () => {
startApiProbe();
});
}
if (collectOffButton) {
collectOffButton.addEventListener('click', () => {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(false);
if (collectButton) {
collectButton.addEventListener('click', () => {
startCollectionWithOptions();
});
}
if (powerOnCollectButton) {
powerOnCollectButton.addEventListener('click', () => {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(true);
if (powerOffCheckbox) {
powerOffCheckbox.addEventListener('change', () => {
if (!powerOffCheckbox.checked) {
return;
}
// If host was already on when probed, warn before enabling shutdown
if (apiProbeResult && apiProbeResult.host_powered_on) {
showConfirmModal(
'Хост был включён до начала сбора. Вы уверены, что хотите выключить его после завершения сбора?',
() => { /* confirmed — leave checked */ },
() => { powerOffCheckbox.checked = false; }
);
}
});
}
@@ -151,11 +163,42 @@ function initApiSource() {
renderCollectionJob();
}
function showConfirmModal(message, onConfirm, onCancel) {
const backdrop = document.createElement('div');
backdrop.className = 'api-confirm-modal-backdrop';
backdrop.innerHTML = `
<div class="api-confirm-modal" role="dialog" aria-modal="true">
<p>${escapeHtml(message)}</p>
<div class="api-confirm-modal-actions">
<button class="btn-cancel">Отмена</button>
<button class="btn-confirm">Да, выключить</button>
</div>
</div>
`;
document.body.appendChild(backdrop);
const close = () => document.body.removeChild(backdrop);
backdrop.querySelector('.btn-cancel').addEventListener('click', () => {
close();
if (onCancel) onCancel();
});
backdrop.querySelector('.btn-confirm').addEventListener('click', () => {
close();
if (onConfirm) onConfirm();
});
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
close();
if (onCancel) onCancel();
}
});
}
function startApiProbe() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
resetApiProbeState();
return;
}
@@ -163,7 +206,7 @@ function startApiProbe() {
apiConnectPayload = payload;
resetApiProbeState();
setApiFormBlocked(true);
renderApiConnectStatus(true, { ...payload, password: '***' });
renderApiConnectStatus(true);
fetch('/api/collect/probe', {
method: 'POST',
@@ -181,7 +224,7 @@ function startApiProbe() {
})
.catch((err) => {
resetApiProbeState();
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Проверка подключения не удалась';
@@ -195,12 +238,11 @@ function startApiProbe() {
});
}
function startCollectionFromCurrentProbe(powerOnIfHostOff) {
function startCollectionWithOptions() {
const { isValid, payload, errors } = validateCollectForm();
renderFormErrors(errors);
if (!isValid) {
renderApiConnectStatus(false, null);
resetApiProbeState();
renderApiConnectStatus(false);
return;
}
@@ -213,71 +255,78 @@ function startCollectionFromCurrentProbe(powerOnIfHostOff) {
return;
}
clearApiPowerDecisionTimer();
payload.power_on_if_host_off = Boolean(powerOnIfHostOff);
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
const debugPayloads = document.getElementById('api-debug-payloads');
payload.power_on_if_host_off = powerOnCheckbox ? powerOnCheckbox.checked : false;
payload.stop_host_after_collect = powerOffCheckbox ? powerOffCheckbox.checked : false;
payload.debug_payloads = debugPayloads ? debugPayloads.checked : false;
startCollectionJob(payload);
}
function renderApiProbeState() {
const collectButton = document.getElementById('api-connect-btn');
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
const status = document.getElementById('api-connect-status');
const decision = document.getElementById('api-power-decision');
const decisionText = document.getElementById('api-power-decision-text');
if (!collectButton || !status || !decision || !decisionText) {
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
if (!connectButton || !probeOptions || !status) {
return;
}
decision.classList.add('hidden');
clearApiPowerDecisionTimer();
collectButton.disabled = !apiProbeResult || !apiProbeResult.reachable;
if (!apiProbeResult || !apiProbeResult.reachable) {
status.textContent = 'Проверка подключения не пройдена.';
status.className = 'api-connect-status error';
probeOptions.classList.add('hidden');
connectButton.textContent = 'Подключиться';
return;
}
if (apiProbeResult.host_powered_on) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включен.';
const hostOn = apiProbeResult.host_powered_on;
const powerControlAvailable = apiProbeResult.power_control_available;
if (hostOn) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
status.className = 'api-connect-status success';
collectButton.disabled = false;
return;
} else {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
status.className = 'api-connect-status warning';
}
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host выключен.';
status.className = 'api-connect-status warning';
if (!apiProbeResult.power_control_available) {
collectButton.disabled = false;
return;
}
probeOptions.classList.remove('hidden');
decision.classList.remove('hidden');
let secondsLeft = 5;
const updateDecisionText = () => {
decisionText.textContent = `Если не выбрать действие, сбор начнется без включения через ${secondsLeft} сек.`;
};
updateDecisionText();
apiPowerDecisionTimer = window.setInterval(() => {
secondsLeft -= 1;
if (secondsLeft <= 0) {
clearApiPowerDecisionTimer();
startCollectionFromCurrentProbe(false);
return;
// "Включить" checkbox
if (powerOnCheckbox) {
if (hostOn) {
// Host already on — checkbox is checked and disabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = true;
} else {
// Host off — default: checked (will power on), enabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = !powerControlAvailable;
}
updateDecisionText();
}, 1000);
}
// "Выключить" checkbox — default: unchecked
if (powerOffCheckbox) {
powerOffCheckbox.checked = false;
powerOffCheckbox.disabled = !powerControlAvailable;
}
connectButton.textContent = 'Переподключиться';
}
function resetApiProbeState() {
apiProbeResult = null;
clearApiPowerDecisionTimer();
const collectButton = document.getElementById('api-connect-btn');
const decision = document.getElementById('api-power-decision');
if (collectButton) {
collectButton.disabled = true;
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
if (connectButton) {
connectButton.textContent = 'Подключиться';
}
if (decision) {
decision.classList.add('hidden');
if (probeOptions) {
probeOptions.classList.add('hidden');
}
}
@@ -368,7 +417,7 @@ function renderFormErrors(errors) {
summary.innerHTML = `<strong>Исправьте ошибки в форме:</strong><ul>${messages.map(msg => `<li>${escapeHtml(msg)}</li>`).join('')}</ul>`;
}
function renderApiConnectStatus(isValid, payload) {
function renderApiConnectStatus(isValid) {
const status = document.getElementById('api-connect-status');
if (!status) {
return;
@@ -380,16 +429,8 @@ function renderApiConnectStatus(isValid, payload) {
return;
}
const payloadPreview = { ...payload };
if (payloadPreview.password) {
payloadPreview.password = '***';
}
if (payloadPreview.token) {
payloadPreview.token = '***';
}
status.textContent = `Payload сформирован: ${JSON.stringify(payloadPreview)}`;
status.className = 'api-connect-status success';
status.textContent = 'Подключение...';
status.className = 'api-connect-status info';
}
function clearApiConnectStatus() {
@@ -440,7 +481,7 @@ function startCollectionJob(payload) {
.catch((err) => {
setApiFormBlocked(false);
clearApiConnectStatus();
renderApiConnectStatus(false, null);
renderApiConnectStatus(false);
const status = document.getElementById('api-connect-status');
if (status) {
status.textContent = err.message || 'Ошибка запуска задачи';
@@ -523,14 +564,61 @@ function appendJobLog(message) {
return;
}
const time = new Date().toLocaleTimeString('ru-RU', { hour12: false });
const parsed = parseServerLogLine(message);
if (isCollectLogNoise(parsed.message)) {
// Still count toward log length so syncServerLogs offset stays correct,
// but mark as hidden so renderCollectionJob skips it.
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: parsed.message,
hidden: true
});
return;
}
collectionJob.logs.push({
id: ++collectionJobLogCounter,
time,
message
time: parsed.time || new Date().toLocaleTimeString('ru-RU', { hour12: false }),
message: humanizeCollectLogMessage(parsed.message)
});
}
// Transform technical log messages into human-readable form for the UI.
// The original messages are preserved in collect.log / raw_export.
function humanizeCollectLogMessage(msg) {
// "Redfish snapshot: документов=520, ETA≈16s, корни=Chassis(294), Systems(114), последний=/redfish/v1/..."
// → "Snapshot: /Chassis/Self/PCIeDevices/00_34_04"
let m = msg.match(/snapshot:\s+документов=\d+[^,]*,.*последний=(\S+)/i);
if (m) {
const path = m[1].replace(/^\.\.\./, '').replace(/^\/redfish\/v1/, '') || m[1];
return `Snapshot: ${path}`;
}
// "Redfish snapshot: собрано N документов"
m = msg.match(/snapshot:\s+собрано\s+(\d+)\s+документов/i);
if (m) {
return `Snapshot: итого ${m[1]} документов`;
}
// "Redfish: plan-B завершен за 30s (targets=18, recovered=0)"
m = msg.match(/plan-B завершен за ([^(]+)\(targets=(\d+),\s*recovered=(\d+)\)/i);
if (m) {
const recovered = parseInt(m[3], 10);
const suffix = recovered > 0 ? `, восстановлено ${m[3]}` : '';
return `Plan-B: завершен за ${m[1].trim()}${suffix}`;
}
// "Redfish: prefetch критичных endpoint (адаптивно 9/72)..."
m = msg.match(/prefetch критичных endpoint[^(]*\(([^)]+)\)/i);
if (m) {
return `Prefetch критичных endpoint (${m[1]})`;
}
// Strip "Redfish: " / "Redfish snapshot: " prefix — redundant in context
return msg.replace(/^Redfish(?:\s+snapshot)?:\s+/i, '');
}
function renderCollectionJob() {
const jobStatusBlock = document.getElementById('api-job-status');
const jobIdValue = document.getElementById('job-id-value');
@@ -576,9 +664,11 @@ function renderCollectionJob() {
renderJobActiveModules(activeModulesBlock, activeModulesList);
renderJobDebugInfo(debugInfoBlock, debugSummary, phaseTelemetryNode);
logsList.innerHTML = [...collectionJob.logs].reverse().map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
)).join('');
logsList.innerHTML = [...collectionJob.logs].reverse()
.filter((log) => !log.hidden)
.map((log) => (
`<li><span class="log-time">${escapeHtml(log.time)}</span><span class="log-message">${escapeHtml(log.message)}</span></li>`
)).join('');
cancelButton.disabled = isTerminal;
setApiFormBlocked(!isTerminal);
@@ -668,6 +758,38 @@ function syncServerLogs(logs) {
}
}
// Patterns for log lines that are internal debug noise and should not be shown in the UI.
const _collectLogNoisePatterns = [
/plan-B \(\d+\/\d+/, // individual plan-B step lines
/plan-B топ веток/,
/snapshot: heartbeat/,
/snapshot: post-probe коллекций \(/,
/snapshot: топ веток/,
/prefetch завершен/,
/cooldown перед повторным добором/,
/Redfish telemetry:/,
/redfish-postprobe-metrics:/,
/redfish-prefetch-metrics:/,
/redfish-collect:/,
/redfish-profile-plan:/,
/redfish replay:/,
];
function isCollectLogNoise(message) {
return _collectLogNoisePatterns.some((re) => re.test(message));
}
// Strip the server-side RFC3339Nano timestamp prefix from a log line and return {time, message}.
function parseServerLogLine(raw) {
const m = String(raw).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)/s);
if (!m) {
return { time: null, message: String(raw).trim() };
}
const d = new Date(m[1]);
const time = isNaN(d) ? null : d.toLocaleTimeString('ru-RU', { hour12: false });
return { time, message: m[2].trim() };
}
function normalizeJobStatus(status) {
return String(status || '').trim().toLowerCase();
}

View File

@@ -76,16 +76,25 @@
</div>
<div class="api-form-actions">
<button id="api-check-btn" type="button">Проверить</button>
<button id="api-connect-btn" type="submit" disabled>Собрать</button>
<button id="api-connect-btn" type="button">Подключиться</button>
</div>
<div id="api-connect-status" class="api-connect-status"></div>
<div id="api-power-decision" class="api-connect-status hidden">
<strong>Host выключен.</strong>
<p id="api-power-decision-text">Если не выбрать действие, сбор начнется без включения через 5 секунд.</p>
<div id="api-probe-options" class="api-probe-options hidden">
<label class="api-form-checkbox" for="api-power-on">
<input id="api-power-on" name="power_on_if_host_off" type="checkbox">
<span>Включить перед сбором</span>
</label>
<label class="api-form-checkbox" for="api-power-off">
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
<span>Выключить после сбора</span>
</label>
<div class="api-probe-options-separator"></div>
<label class="api-form-checkbox" for="api-debug-payloads">
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
<span>Сбор расширенных метрик для отладки</span>
</label>
<div class="api-form-actions">
<button id="api-power-on-collect-btn" type="button">Включить и собрать</button>
<button id="api-collect-off-btn" type="button">Собирать выключенный</button>
<button id="api-collect-btn" type="submit">Собрать</button>
</div>
</div>
</form>