8 Commits

Author SHA1 Message Date
f19a3454fa fix(redfish): gate hgx diagnostic plan-b by debug toggle 2026-04-13 14:45:41 +03:00
Mikhail Chusavitin
becdca1d7e fix(redfish): read PCIeInterface link width for GPU PCIe devices
parseGPUWithSupplementalDocs did not read PCIeInterface from the device
doc, only from function docs. xFusion GPU PCIeCard entries carry link
width/speed in PCIeInterface (LanesInUse/Maxlanes/PCIeType/MaxPCIeType)
so GPU link width was always empty for xFusion servers.

Also apply the xFusion OEM function-level fallback for GPU function docs,
consistent with the NIC and PCIeDevice paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:35:29 +03:00
Mikhail Chusavitin
e10440ae32 fix(redfish): collect PCIe link width from xFusion servers
xFusion iBMC exposes PCIe link width in two non-standard ways:
- PCIeInterface uses "Maxlanes" (lowercase 'l') instead of "MaxLanes"
- PCIeFunction docs carry width/speed in Oem.xFusion.LinkWidth ("X8"),
  Oem.xFusion.LinkWidthAbility, Oem.xFusion.LinkSpeed, and
  Oem.xFusion.LinkSpeedAbility rather than the standard CurrentLinkWidth int

Add redfishEnrichFromOEMxFusionPCIeLink and parseXFusionLinkWidth helpers,
apply them as fallbacks in NIC and PCIeDevice enrichment paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 13:35:29 +03:00
5c2a21aff1 chore: update bible and chart submodules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 12:17:40 +03:00
Mikhail Chusavitin
9df13327aa feat(collect): remove power-on/off, add skip-hung for Redfish collection
Remove power-on and power-off functionality from the Redfish collector;
keep host power-state detection and show a warning in the UI when the
host is powered off before collection starts.

Add a "Пропустить зависшие" (skip hung) button that lets the user abort
stuck Redfish collection phases without losing already-collected data.
Introduces a two-level context model in Collect(): the outer job context
covers the full lifecycle including replay; an inner collectCtx covers
snapshot, prefetch, and plan-B phases only. Closing the skipCh cancels
collectCtx immediately — aborts all in-flight HTTP requests and exits
plan-B loops — then replay runs on whatever rawTree was collected.

Signal path: UI → POST /api/collect/{id}/skip → JobManager.SkipJob()
→ close(skipCh) → goroutine in Collect() → cancelCollect().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 13:12:38 +03:00
Mikhail Chusavitin
7e9af89c46 Add xFusion file-export parser support 2026-04-04 15:07:10 +03:00
Mikhail Chusavitin
db74df9994 fix(redfish): trim MSI replay noise and unify NIC classes 2026-04-01 17:49:00 +03:00
Mikhail Chusavitin
bb82387d48 fix(redfish): narrow MSI PCIeFunctions crawl 2026-04-01 16:50:51 +03:00
31 changed files with 2150 additions and 840 deletions

2
bible

Submodule bible updated: 52444350c1...456c1f022c

View File

@@ -34,6 +34,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
- NVIDIA HGX Field Diagnostics
- NVIDIA Bug Report
- Unraid
- xFusion iBMC dump / file export
- XigmaNAS
- Generic fallback parser

View File

@@ -58,6 +58,7 @@ Responses:
Optional request field:
- `power_on_if_host_off`: when `true`, Redfish collection may power on the host before collection if preflight found it powered off
- `debug_payloads`: when `true`, collector keeps extra diagnostic payloads and enables extended plan-B retries for slow HGX component inventory branches (`Assembly`, `Accelerators`, `Drives`, `NetworkAdapters`, `PCIeDevices`)
### `POST /api/collect/probe`

View File

@@ -27,6 +27,7 @@ Request fields passed from the server:
- credential field (`password` or token)
- `tls_mode`
- optional `power_on_if_host_off`
- optional `debug_payloads` for extended diagnostics
### Core rule
@@ -35,18 +36,38 @@ If the collector adds a fallback, probe, or normalization rule, replay must mirr
### Preflight and host power
- `Probe()` may be used before collection to verify API connectivity and current host `PowerState`
- if the host is off and the user chose power-on, the collector may issue `ComputerSystem.Reset`
with `ResetType=On`
- power-on attempts are bounded and logged
- after a successful power-on, the collector waits an extra stabilization window, then checks
`PowerState` again and only starts collection if the host is still on
- if the collector powered on the host itself for collection, it must attempt to power it back off
after collection completes
- if the host was already on before collection, the collector must not power it off afterward
- if power-on fails, collection still continues against the powered-off host
- all power-control decisions and attempts must be visible in the collection log so they are
preserved in raw-export bundles
- `Probe()` is used before collection to verify API connectivity and report current host `PowerState`
- if the host is off, the collector logs a warning and proceeds with collection; inventory data may
be incomplete when the host is powered off
- power-on and power-off are not performed by the collector
### Skip hung requests
Redfish collection uses a two-level context model:
- `ctx` — job lifetime context, cancelled only on explicit job cancel
- `collectCtx` — collection phase context, derived from `ctx`; covers snapshot, prefetch, and plan-B
`collectCtx` is cancelled when the user presses "Пропустить зависшие" (skip hung).
On skip, all in-flight HTTP requests in the current phase are aborted immediately via context
cancellation, the crawler and plan-B loops exit, and execution proceeds to the replay phase using
whatever was collected in `rawTree`. The result is partial but valid.
The skip signal travels: UI button → `POST /api/collect/{id}/skip``JobManager.SkipJob()`
closes `skipCh` → goroutine in `Collect()``cancelCollect()`.
The skip button is visible during `running` state and hidden once the job reaches a terminal state.
### Extended diagnostics toggle
The live collect form exposes a user-facing checkbox for extended diagnostics.
- default collection prioritizes inventory completeness and bounded runtime
- when extended diagnostics is off, heavy HGX component-chassis critical plan-B retries
(`Assembly`, `Accelerators`, `Drives`, `NetworkAdapters`, `PCIeDevices`) are skipped
- when extended diagnostics is on, those retries are allowed and extra debug payloads are collected
This toggle is intended for operator-driven deep diagnostics on problematic hosts, not for the default path.
### Discovery model

View File

@@ -58,6 +58,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
| `xfusion` | xFusion iBMC `tar.gz` dump / file export | AppDump + RTOSDump + LogDump merge for hardware and firmware |
| `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory |
| `generic` | fallback | Low-confidence text fallback when nothing else matches |
@@ -148,6 +149,29 @@ entire internal `zbb` schema.
---
### xFusion iBMC Dump / File Export (`xfusion`)
**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports.
**Archive format:** `tar.gz` dump exported from the iBMC UI, including `AppDump/`, `RTOSDump/`,
and `LogDump/` trees.
**Detection:** `AppDump/FruData/fruinfo.txt`, `AppDump/card_manage/card_info`,
`RTOSDump/versioninfo/app_revision.txt`, and `LogDump/netcard/netcard_info.txt`.
**Extracted data (current):**
- Board / FRU inventory from `fruinfo.txt`
- CPU inventory from `CpuMem/cpu_info`
- Memory DIMM inventory from `CpuMem/mem_info`
- GPU inventory from `card_info`
- OCP NIC inventory by merging `card_info` with `LogDump/netcard/netcard_info.txt`
- PSU inventory from `BMC/psu_info.txt`
- Physical storage from `StorageMgnt/PhysicalDrivesInfo/*/disk_info`
- System firmware entries from `RTOSDump/versioninfo/app_revision.txt`
- Maintenance events from `LogDump/maintenance_log`
---
### Generic text fallback (`generic`)
**Status:** Ready (v1.0.0).
@@ -173,6 +197,7 @@ entire internal `zbb` schema.
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
| Unraid | `unraid` | Ready | Unraid diagnostics archives |
| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles |
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |

View File

@@ -1045,3 +1045,95 @@ logical volumes.
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
`LOM/NIC` or `SAS/SATA Storage Controller`.
- `part_number` remains available separately for model identity, without polluting the class field.
---
## ADL-041 — Redfish replay drops topology-only PCIe noise classes from canonical inventory
**Date:** 2026-04-01
**Context:** Some Redfish BMCs, especially MSI/AMI GPU systems, expose a very wide PCIe topology
tree under `Chassis/*/PCIeDevices/*`. Besides real endpoint devices, the replay sees bridge stages,
CPU-side helper functions, IMC/mesh signal-processing nodes, USB/SPI side controllers, and GPU
display-function duplicates reported as generic `Display Device`. Keeping all of them in
`hardware.pcie_devices` pollutes downstream exports such as Reanimator and hides the actual
endpoint inventory signal.
**Decision:**
- Filter topology-only PCIe records during Redfish replay, not in the UI layer.
- Drop PCIe entries with replay-resolved classes:
- `Bridge`
- `Processor`
- `SignalProcessingController`
- `SerialBusController`
- Drop `DisplayController` entries when the source Redfish PCIe document is the generic MSI-style
`Description: "Display Device"` duplicate.
- Drop PCIe network endpoints when their PCIe functions already link to `NetworkDeviceFunctions`,
because those devices are represented canonically in `hardware.network_adapters`.
- When `Systems/*/NetworkInterfaces/*` links back to a chassis `NetworkAdapter`, match against the
fully enriched chassis NIC identity to avoid creating a second ghost NIC row with the raw
`NetworkAdapter_*` slot/name.
- Treat generic Redfish object names such as `NetworkAdapter_*` and `PCIeDevice_*` as placeholder
models and replace them from PCI IDs when a concrete vendor/device match exists.
- Drop MSI-style storage service PCIe endpoints whose resolved device names are only
`Volume Management Device NVMe RAID Controller` or `PCIe Switch management endpoint`; storage
inventory already comes from the Redfish storage tree.
- Normalize Ethernet-class NICs into the single exported class `NetworkController`; do not split
`EthernetController` into a separate top-level inventory section.
- Keep endpoint classes such as `NetworkController`, `MassStorageController`, and dedicated GPU
inventory coming from `hardware.gpus`.
**Consequences:**
- `hardware.pcie_devices` becomes closer to real endpoint inventory instead of raw PCIe topology.
- Reanimator exports stop showing MSI bridge/processor/display duplicate noise.
- Reanimator exports no longer duplicate the same MSI NIC as both `PCIeDevice_*` and
`NetworkAdapter_*`.
- Replay no longer creates extra NIC rows from `Systems/NetworkInterfaces` when the same adapter
was already normalized from `Chassis/NetworkAdapters`.
- MSI VMD / PCIe switch storage service endpoints no longer pollute PCIe inventory.
- UI/Reanimator group all Ethernet NICs under the same `NETWORKCONTROLLER` section.
- Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names.
- The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level
troubleshooting if topology details are ever needed.
---
## ADL-042 — xFusion file-export archives merge AppDump inventory with RTOS/Log snapshots
**Date:** 2026-04-04
**Context:** xFusion iBMC `tar.gz` exports expose the base inventory in `AppDump/`, but the most
useful NIC and firmware details live elsewhere: NIC firmware/MAC snapshots in
`LogDump/netcard/netcard_info.txt` and system firmware versions in
`RTOSDump/versioninfo/app_revision.txt`. Parsing only `AppDump/` left xFusion uploads detectable but
incomplete for UI and Reanimator consumers.
**Decision:**
- Treat xFusion file-export `tar.gz` bundles as a first-class archive parser input.
- Merge OCP NIC identity from `AppDump/card_manage/card_info` with the latest per-slot snapshot
from `LogDump/netcard/netcard_info.txt` to produce `hardware.network_adapters`.
- Import system-level firmware from `RTOSDump/versioninfo/app_revision.txt` into
`hardware.firmware`.
- Allow FRU fallback from `RTOSDump/versioninfo/fruinfo.txt` when `AppDump/FruData/fruinfo.txt`
is absent.
**Consequences:**
- xFusion uploads now preserve NIC BDF, MAC, firmware, and serial identity in normalized output.
- System firmware such as BIOS and iBMC versions survives xFusion file exports.
- xFusion archives participate more reliably in canonical device/export flows without special UI
cases.
---
## ADL-043 — Extended HGX diagnostic plan-B is opt-in from the live collect form
**Date:** 2026-04-13
**Context:** Some Supermicro HGX Redfish targets expose slow or hanging component-chassis inventory
collections during critical plan-B, especially under `Chassis/HGX_*` for `Assembly`,
`Accelerators`, `Drives`, `NetworkAdapters`, and `PCIeDevices`. Default collection should not
block operators on deep diagnostic retries that are useful mainly for troubleshooting.
**Decision:** Keep the normal snapshot/replay path unchanged, but gate those heavy HGX
component-chassis critical plan-B retries behind the existing live-collect `debug_payloads` flag,
presented in the UI as "Сбор расширенных данных для диагностики".
**Consequences:**
- Default live collection skips those heavy diagnostic plan-B retries and reaches replay faster.
- Operators can explicitly opt into the slower diagnostic path when they need deeper collection.
- The same user-facing toggle continues to enable extra debug payload capture for troubleshooting.

View File

@@ -112,12 +112,11 @@ func (c *RedfishConnector) Probe(ctx context.Context, req Request) (*ProbeResult
}
powerState := redfishSystemPowerState(systemDoc)
return &ProbeResult{
Reachable: true,
Protocol: "redfish",
HostPowerState: powerState,
HostPoweredOn: isRedfishHostPoweredOn(powerState),
PowerControlAvailable: redfishResetActionTarget(systemDoc) != "",
SystemPath: primarySystem,
Reachable: true,
Protocol: "redfish",
HostPowerState: powerState,
HostPoweredOn: isRedfishHostPoweredOn(powerState),
SystemPath: primarySystem,
}, nil
}
@@ -160,17 +159,6 @@ 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")
if primarySystem != "" {
c.ensureHostPowerForCollection(ctx, snapshotClient, req, baseURL, primarySystem, emit)
}
defer func() {
if primarySystem == "" || !req.StopHostAfterCollect {
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
c.restoreHostPowerAfterCollection(shutdownCtx, snapshotClient, req, baseURL, primarySystem, emit)
}()
chassisPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := c.discoverMemberPaths(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1/Managers", "/redfish/v1/Managers/1")
primaryChassis := firstNonEmptyPath(chassisPaths, "/redfish/v1/Chassis/1")
@@ -269,12 +257,35 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
}
// collectCtx covers all data-fetching phases (snapshot, prefetch, plan-B).
// Cancelling it via the skip signal aborts only the collection phases while
// leaving the replay phase intact so results from already-fetched data are preserved.
collectCtx, cancelCollect := context.WithCancel(ctx)
defer cancelCollect()
if req.SkipHungCh != nil {
go func() {
select {
case <-req.SkipHungCh:
if emit != nil {
emit(Progress{
Status: "running",
Progress: 97,
Message: "Redfish: пропуск зависших запросов, анализ уже собранных данных...",
})
}
log.Printf("redfish: skip-hung triggered, cancelling collection phases")
cancelCollect()
case <-ctx.Done():
}
}()
}
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
rawTree, fetchErrors, postProbeMetrics, snapshotTimingSummary := c.collectRawRedfishTree(withRedfishTelemetryPhase(ctx, "snapshot"), snapshotClient, req, baseURL, seedPaths, acquisitionPlan.Tuning, emit)
rawTree, fetchErrors, postProbeMetrics, snapshotTimingSummary := c.collectRawRedfishTree(withRedfishTelemetryPhase(collectCtx, "snapshot"), snapshotClient, req, baseURL, seedPaths, acquisitionPlan.Tuning, emit)
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
fetchErrMap := redfishFetchErrorListToMap(fetchErrors)
prefetchedCritical, prefetchMetrics := c.prefetchCriticalRedfishDocs(withRedfishTelemetryPhase(ctx, "prefetch"), prefetchClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit)
prefetchedCritical, prefetchMetrics := c.prefetchCriticalRedfishDocs(withRedfishTelemetryPhase(collectCtx, "prefetch"), prefetchClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit)
for p, doc := range prefetchedCritical {
if _, exists := rawTree[p]; exists {
continue
@@ -295,10 +306,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
prefetchMetrics.Duration.Round(time.Millisecond),
firstNonEmpty(prefetchMetrics.SkipReason, "-"),
)
if recoveredN := c.recoverCriticalRedfishDocsPlanB(withRedfishTelemetryPhase(ctx, "critical_plan_b"), criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit); recoveredN > 0 {
if recoveredN := c.recoverCriticalRedfishDocsPlanB(withRedfishTelemetryPhase(collectCtx, "critical_plan_b"), criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit); recoveredN > 0 {
c.debugSnapshotf("critical plan-b recovered docs=%d", recoveredN)
}
if recoveredN := c.recoverProfilePlanBDocs(withRedfishTelemetryPhase(ctx, "profile_plan_b"), criticalClient, req, baseURL, acquisitionPlan, rawTree, emit); recoveredN > 0 {
if recoveredN := c.recoverProfilePlanBDocs(withRedfishTelemetryPhase(collectCtx, "profile_plan_b"), criticalClient, req, baseURL, acquisitionPlan, rawTree, emit); recoveredN > 0 {
c.debugSnapshotf("profile plan-b recovered docs=%d", recoveredN)
}
// Hide transient fetch errors for endpoints that were eventually recovered into rawTree.
@@ -485,231 +496,6 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
return result, nil
}
func (c *RedfishConnector) ensureHostPowerForCollection(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) (hostOn bool, poweredOnByCollector bool) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: "Redfish: не удалось проверить PowerState host, сбор продолжается без power-control"})
}
return false, false
}
powerState := redfishSystemPowerState(systemDoc)
if isRedfishHostPoweredOn(powerState) {
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host включен (%s)", firstNonEmpty(powerState, "On"))})
}
return true, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 18, Message: fmt.Sprintf("Redfish: host выключен (%s)", firstNonEmpty(powerState, "Off"))})
}
if !req.PowerOnIfHostOff {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: включение host не запрошено, сбор продолжается на выключенном host"})
}
return false, false
}
// Invalidate all inventory CRC groups before powering on so the BMC accepts
// fresh inventory from the host after boot. Best-effort: failure is logged but
// does not block power-on.
c.invalidateRedfishInventory(ctx, client, req, baseURL, systemPath, emit)
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "On", "ForceOn")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: action ComputerSystem.Reset недоступен, сбор продолжается на выключенном host"})
}
return false, false
}
waitWindows := []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second}
for i, waitFor := range waitWindows {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: попытка включения host (%d/%d), ожидание %s", i+1, len(waitWindows), waitFor)})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: fmt.Sprintf("Redfish: включение host не удалось (%v)", err)})
}
continue
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, true, waitFor) {
if !c.waitForStablePoweredOnHost(ctx, client, req, baseURL, systemPath, emit) {
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host включился, но не подтвердил стабильное состояние; сбор продолжается на выключенном host"})
}
return false, false
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host успешно включен и стабилен перед сбором"})
}
return true, true
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: fmt.Sprintf("Redfish: host не включился за %s", waitFor)})
}
}
if emit != nil {
emit(Progress{Status: "running", Progress: 20, Message: "Redfish: host не удалось включить, сбор продолжается на выключенном host"})
}
return false, false
}
func (c *RedfishConnector) waitForStablePoweredOnHost(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) bool {
stabilizationDelay := redfishPowerOnStabilizationDelay()
if stabilizationDelay > 0 {
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: fmt.Sprintf("Redfish: host включен, ожидание стабилизации %s перед началом сбора", stabilizationDelay),
})
}
timer := time.NewTimer(stabilizationDelay)
select {
case <-ctx.Done():
timer.Stop()
return false
case <-timer.C:
timer.Stop()
}
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 20,
Message: "Redfish: повторная проверка PowerState после стабилизации host",
})
}
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) {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось повторно прочитать system перед выключением host"})
}
return
}
resetTarget := redfishResetActionTarget(systemDoc)
resetType := redfishPickResetType(systemDoc, "GracefulShutdown", "ForceOff", "PushPowerButton")
if resetTarget == "" || resetType == "" {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключение host после сбора недоступно"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: выключаем host после завершения сбора"})
}
if err := c.postJSON(ctx, client, req, baseURL, resetTarget, map[string]any{"ResetType": resetType}); err != nil {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: fmt.Sprintf("Redfish: не удалось выключить host после сбора (%v)", err)})
}
return
}
if c.waitForHostPowerState(ctx, client, req, baseURL, systemPath, false, 20*time.Second) {
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: host выключен после завершения сбора"})
}
return
}
if emit != nil {
emit(Progress{Status: "running", Progress: 100, Message: "Redfish: не удалось подтвердить выключение host после сбора"})
}
}
// 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.
@@ -724,50 +510,6 @@ func (c *RedfishConnector) collectDebugPayloads(ctx context.Context, client *htt
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
// BIOS version, etc.) from persisting across hardware changes.
// Best-effort: any error is logged and the call silently returns.
func (c *RedfishConnector) invalidateRedfishInventory(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, emit ProgressFn) {
crcPath := joinPath(systemPath, "/Oem/Ami/Inventory/Crc")
body := map[string]any{
"GroupCrcList": []map[string]any{
{"CPU": 0},
{"DIMM": 0},
{"PCIE": 0},
},
}
if err := c.postJSON(ctx, client, req, baseURL, crcPath, body); err != nil {
log.Printf("redfish: inventory invalidation skipped (not AMI/MSI or endpoint unavailable): %v", err)
return
}
log.Printf("redfish: inventory CRC groups invalidated at %s before host power-on", crcPath)
if emit != nil {
emit(Progress{Status: "running", Progress: 19, Message: "Redfish: инвентарь BMC инвалидирован перед включением host (все CRC группы сброшены)"})
}
}
func (c *RedfishConnector) waitForHostPowerState(ctx context.Context, client *http.Client, req Request, baseURL, systemPath string, wantOn bool, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
systemDoc, err := c.getJSON(ctx, client, req, baseURL, systemPath)
if err == nil {
if isRedfishHostPoweredOn(redfishSystemPowerState(systemDoc)) == wantOn {
return true
}
}
if time.Now().After(deadline) {
return false
}
select {
case <-ctx.Done():
return false
case <-time.After(1 * time.Second):
}
}
}
func firstNonEmptyPath(paths []string, fallback string) string {
for _, p := range paths {
if strings.TrimSpace(p) != "" {
@@ -799,49 +541,6 @@ func redfishSystemPowerState(systemDoc map[string]interface{}) string {
return ""
}
func redfishResetActionTarget(systemDoc map[string]interface{}) string {
if systemDoc == nil {
return ""
}
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
target := strings.TrimSpace(asString(reset["target"]))
if target != "" {
return target
}
odataID := strings.TrimSpace(asString(systemDoc["@odata.id"]))
if odataID == "" {
return ""
}
return joinPath(odataID, "/Actions/ComputerSystem.Reset")
}
func redfishPickResetType(systemDoc map[string]interface{}, preferred ...string) string {
actions, _ := systemDoc["Actions"].(map[string]interface{})
reset, _ := actions["#ComputerSystem.Reset"].(map[string]interface{})
allowedRaw, _ := reset["ResetType@Redfish.AllowableValues"].([]interface{})
if len(allowedRaw) == 0 {
if len(preferred) > 0 {
return preferred[0]
}
return ""
}
allowed := make([]string, 0, len(allowedRaw))
for _, item := range allowedRaw {
if v := strings.TrimSpace(asString(item)); v != "" {
allowed = append(allowed, v)
}
}
for _, want := range preferred {
for _, have := range allowed {
if strings.EqualFold(want, have) {
return have
}
}
}
return ""
}
func (c *RedfishConnector) postJSON(ctx context.Context, client *http.Client, req Request, baseURL, resourcePath string, payload map[string]any) error {
body, err := json.Marshal(payload)
if err != nil {
@@ -1288,12 +987,17 @@ func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client,
}
for _, doc := range adapterDocs {
nic := parseNIC(doc)
adapterFunctionDocs := c.getNetworkAdapterFunctionDocs(ctx, client, req, baseURL, doc)
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
pcieDoc, err := c.getJSON(ctx, client, req, baseURL, pciePath)
if err != nil {
continue
}
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
for _, adapterFnDoc := range adapterFunctionDocs {
functionDocs = append(functionDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, adapterFnDoc)...)
}
functionDocs = dedupeJSONDocsByPath(functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
@@ -2592,34 +2296,6 @@ func redfishCriticalSlowGap() time.Duration {
return 1200 * time.Millisecond
}
func redfishPowerOnStabilizationDelay() time.Duration {
if v := strings.TrimSpace(os.Getenv("LOGPILE_REDFISH_POWERON_STABILIZATION")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
return d
}
}
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 {
@@ -2810,6 +2486,14 @@ func shouldCrawlPath(path string) bool {
if isAllowedNVSwitchFabricPath(normalized) {
return true
}
if strings.Contains(normalized, "/Chassis/") &&
strings.Contains(normalized, "/PCIeDevices/") &&
strings.HasSuffix(normalized, "/PCIeFunctions") {
// Avoid crawling entire chassis PCIeFunctions collections. Concrete member
// docs can still be reached through direct links such as
// NetworkDeviceFunction Links.PCIeFunction.
return false
}
if strings.Contains(normalized, "/Memory/") {
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
@@ -2982,6 +2666,15 @@ func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *h
}
return out
}
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
memberDoc, err := c.getJSON(ctx, client, req, baseURL, memberPath)
if err == nil {
return []map[string]interface{}{memberDoc}
}
}
}
}
// Some implementations expose a collection object in PCIeFunctions.@odata.id.
@@ -2997,6 +2690,22 @@ func (c *RedfishConnector) getLinkedPCIeFunctions(ctx context.Context, client *h
return nil
}
func (c *RedfishConnector) getNetworkAdapterFunctionDocs(ctx context.Context, client *http.Client, req Request, baseURL string, adapterDoc map[string]interface{}) []map[string]interface{} {
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
if !ok {
return nil
}
colPath := asString(ndfCol["@odata.id"])
if colPath == "" {
return nil
}
funcDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, colPath)
if err != nil {
return nil
}
return funcDocs
}
func (c *RedfishConnector) getCollectionMembers(ctx context.Context, client *http.Client, req Request, baseURL, collectionPath string) ([]map[string]interface{}, error) {
collection, err := c.getJSON(ctx, client, req, baseURL, collectionPath)
if err != nil {
@@ -3165,11 +2874,16 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
timings := newRedfishPathTimingCollector(4)
var targets []string
seenTargets := make(map[string]struct{})
skippedDiagnosticTargets := 0
addTarget := func(path string) {
path = normalizeRedfishPath(path)
if path == "" {
return
}
if !shouldIncludeCriticalPlanBPath(req, path) {
skippedDiagnosticTargets++
return
}
if _, ok := seenTargets[path]; ok {
return
}
@@ -3255,6 +2969,13 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
return 0
}
if emit != nil {
if skippedDiagnosticTargets > 0 {
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: расширенная диагностика выключена, пропущено %d тяжелых diagnostic endpoint", skippedDiagnosticTargets),
})
}
totalETA := redfishCriticalCooldown() + estimatePlanBETA(len(targets))
emit(Progress{
Status: "running",
@@ -3360,6 +3081,39 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
return recovered
}
func shouldIncludeCriticalPlanBPath(req Request, path string) bool {
if req.DebugPayloads {
return true
}
return !isExtendedDiagnosticCriticalPlanBPath(path)
}
func isExtendedDiagnosticCriticalPlanBPath(path string) bool {
path = normalizeRedfishPath(path)
if path == "" {
return false
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 5 || parts[0] != "redfish" || parts[1] != "v1" || parts[2] != "Chassis" {
return false
}
if !strings.HasPrefix(parts[3], "HGX_") {
return false
}
for _, suffix := range []string{
"/Accelerators",
"/Assembly",
"/Drives",
"/NetworkAdapters",
"/PCIeDevices",
} {
if strings.HasSuffix(path, suffix) {
return true
}
}
return false
}
func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client *http.Client, req Request, baseURL string, plan redfishprofile.AcquisitionPlan, rawTree map[string]interface{}, emit ProgressFn) int {
if len(plan.PlanBPaths) == 0 || plan.Mode == redfishprofile.ModeFallback || !plan.Tuning.RecoveryPolicy.EnableProfilePlanB {
return 0
@@ -3879,7 +3633,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
}
if pcieIf, ok := ctrl["PCIeInterface"].(map[string]interface{}); ok && linkWidth == 0 && maxLinkWidth == 0 && linkSpeed == "" && maxLinkSpeed == "" {
linkWidth = asInt(pcieIf["LanesInUse"])
maxLinkWidth = asInt(pcieIf["MaxLanes"])
maxLinkWidth = firstNonZeroInt(asInt(pcieIf["MaxLanes"]), asInt(pcieIf["Maxlanes"]))
linkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
maxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
@@ -3992,6 +3746,9 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
}
if nic.LinkWidth == 0 || nic.MaxLinkWidth == 0 || nic.LinkSpeed == "" || nic.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(pcieDoc, &nic.LinkWidth, &nic.MaxLinkWidth, &nic.LinkSpeed, &nic.MaxLinkSpeed)
}
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
nic.SerialNumber = findFirstNormalizedStringByKeys(pcieDoc, "SerialNumber")
}
@@ -4023,6 +3780,9 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if nic.LinkWidth == 0 || nic.MaxLinkWidth == 0 || nic.LinkSpeed == "" || nic.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &nic.LinkWidth, &nic.MaxLinkWidth, &nic.LinkSpeed, &nic.MaxLinkSpeed)
}
if normalizeRedfishIdentityField(nic.SerialNumber) == "" {
nic.SerialNumber = findFirstNormalizedStringByKeys(fn, "SerialNumber")
}
@@ -4589,6 +4349,21 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
gpu.DeviceID = asHexOrInt(doc["DeviceId"])
}
if pcieIf, ok := doc["PCIeInterface"].(map[string]interface{}); ok {
if gpu.CurrentLinkWidth == 0 {
gpu.CurrentLinkWidth = asInt(pcieIf["LanesInUse"])
}
if gpu.MaxLinkWidth == 0 {
gpu.MaxLinkWidth = firstNonZeroInt(asInt(pcieIf["MaxLanes"]), asInt(pcieIf["Maxlanes"]))
}
if gpu.CurrentLinkSpeed == "" {
gpu.CurrentLinkSpeed = firstNonEmpty(asString(pcieIf["PCIeType"]), asString(pcieIf["CurrentLinkSpeedGTs"]), asString(pcieIf["CurrentLinkSpeed"]))
}
if gpu.MaxLinkSpeed == "" {
gpu.MaxLinkSpeed = firstNonEmpty(asString(pcieIf["MaxPCIeType"]), asString(pcieIf["MaxLinkSpeedGTs"]), asString(pcieIf["MaxLinkSpeed"]))
}
}
for _, fn := range functionDocs {
if gpu.BDF == "" {
gpu.BDF = sanitizeRedfishBDF(asString(fn["FunctionId"]))
@@ -4611,6 +4386,9 @@ func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map
if gpu.CurrentLinkSpeed == "" {
gpu.CurrentLinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if gpu.CurrentLinkWidth == 0 || gpu.MaxLinkWidth == 0 || gpu.CurrentLinkSpeed == "" || gpu.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &gpu.CurrentLinkWidth, &gpu.MaxLinkWidth, &gpu.CurrentLinkSpeed, &gpu.MaxLinkSpeed)
}
}
if isMissingOrRawPCIModel(gpu.Model) {
@@ -4671,6 +4449,9 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc
if dev.MaxLinkSpeed == "" {
dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
if dev.LinkWidth == 0 || dev.MaxLinkWidth == 0 || dev.LinkSpeed == "" || dev.MaxLinkSpeed == "" {
redfishEnrichFromOEMxFusionPCIeLink(fn, &dev.LinkWidth, &dev.MaxLinkWidth, &dev.LinkSpeed, &dev.MaxLinkSpeed)
}
}
if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) {
dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass)
@@ -4755,6 +4536,9 @@ func isMissingOrRawPCIModel(model string) bool {
if l == "unknown" || l == "n/a" || l == "na" || l == "none" {
return true
}
if isGenericRedfishInventoryName(l) {
return true
}
if strings.HasPrefix(l, "0x") && len(l) <= 6 {
return true
}
@@ -4773,6 +4557,26 @@ func isMissingOrRawPCIModel(model string) bool {
return false
}
func isGenericRedfishInventoryName(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
switch {
case value == "":
return false
case value == "networkadapter", strings.HasPrefix(value, "networkadapter_"), strings.HasPrefix(value, "networkadapter "):
return true
case value == "pciedevice", strings.HasPrefix(value, "pciedevice_"), strings.HasPrefix(value, "pciedevice "):
return true
case value == "pciefunction", strings.HasPrefix(value, "pciefunction_"), strings.HasPrefix(value, "pciefunction "):
return true
case value == "ethernetinterface", strings.HasPrefix(value, "ethernetinterface_"), strings.HasPrefix(value, "ethernetinterface "):
return true
case value == "networkport", strings.HasPrefix(value, "networkport_"), strings.HasPrefix(value, "networkport "):
return true
default:
return false
}
}
// isUnidentifiablePCIeDevice returns true for PCIe topology entries that carry no
// useful inventory information: generic class (SingleFunction/MultiFunction), no
// resolved model or serial, and no PCI vendor/device IDs for future resolution.
@@ -4897,6 +4701,59 @@ func buildBDFfromOemPublic(doc map[string]interface{}) string {
return fmt.Sprintf("%04x:%02x:%02x.%x", segment, bus, dev, fn)
}
// redfishEnrichFromOEMxFusionPCIeLink fills in missing PCIe link width/speed
// from the xFusion OEM namespace. xFusion reports link width as a string like
// "X8" in Oem.xFusion.LinkWidth / Oem.xFusion.LinkWidthAbility, and link speed
// as a string like "Gen4 (16.0GT/s)" in Oem.xFusion.LinkSpeed /
// Oem.xFusion.LinkSpeedAbility. These fields appear on PCIeFunction docs.
func redfishEnrichFromOEMxFusionPCIeLink(doc map[string]interface{}, linkWidth, maxLinkWidth *int, linkSpeed, maxLinkSpeed *string) {
oem, _ := doc["Oem"].(map[string]interface{})
if oem == nil {
return
}
xf, _ := oem["xFusion"].(map[string]interface{})
if xf == nil {
return
}
if *linkWidth == 0 {
*linkWidth = parseXFusionLinkWidth(asString(xf["LinkWidth"]))
}
if *maxLinkWidth == 0 {
*maxLinkWidth = parseXFusionLinkWidth(asString(xf["LinkWidthAbility"]))
}
if strings.TrimSpace(*linkSpeed) == "" {
*linkSpeed = strings.TrimSpace(asString(xf["LinkSpeed"]))
}
if strings.TrimSpace(*maxLinkSpeed) == "" {
*maxLinkSpeed = strings.TrimSpace(asString(xf["LinkSpeedAbility"]))
}
}
// parseXFusionLinkWidth converts an xFusion link-width string like "X8" or
// "x16" to the integer lane count. Returns 0 for unrecognised values.
func parseXFusionLinkWidth(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
s = strings.TrimPrefix(strings.ToUpper(s), "X")
v := asInt(s)
if v <= 0 {
return 0
}
return v
}
// firstNonZeroInt returns the first argument that is non-zero.
func firstNonZeroInt(vals ...int) int {
for _, v := range vals {
if v != 0 {
return v
}
}
return 0
}
func normalizeRedfishIdentityField(v string) string {
v = strings.TrimSpace(v)
if v == "" {
@@ -5612,6 +5469,9 @@ func normalizeNetworkAdapterModel(nic models.NetworkAdapter) string {
if model == "" {
return ""
}
if isMissingOrRawPCIModel(model) {
return ""
}
slot := strings.TrimSpace(nic.Slot)
if slot != "" && strings.EqualFold(slot, model) {
return ""

View File

@@ -0,0 +1,57 @@
package collector
import "testing"
func TestShouldIncludeCriticalPlanBPath(t *testing.T) {
tests := []struct {
name string
req Request
path string
want bool
}{
{
name: "skip hgx erot pcie without extended diagnostics",
req: Request{},
path: "/redfish/v1/Chassis/HGX_ERoT_NVSwitch_0/PCIeDevices",
want: false,
},
{
name: "skip hgx chassis assembly without extended diagnostics",
req: Request{},
path: "/redfish/v1/Chassis/HGX_Chassis_0/Assembly",
want: false,
},
{
name: "keep standard chassis inventory without extended diagnostics",
req: Request{},
path: "/redfish/v1/Chassis/1/PCIeDevices",
want: true,
},
{
name: "keep nvme storage backplane drives without extended diagnostics",
req: Request{},
path: "/redfish/v1/Chassis/NVMeSSD.0.Group.0.StorageBackplane/Drives",
want: true,
},
{
name: "keep system processors without extended diagnostics",
req: Request{},
path: "/redfish/v1/Systems/HGX_Baseboard_0/Processors",
want: true,
},
{
name: "include hgx erot pcie when extended diagnostics enabled",
req: Request{DebugPayloads: true},
path: "/redfish/v1/Chassis/HGX_ERoT_NVSwitch_0/PCIeDevices",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldIncludeCriticalPlanBPath(tt.req, tt.path); got != tt.want {
t.Fatalf("shouldIncludeCriticalPlanBPath(%q) = %v, want %v", tt.path, got, tt.want)
}
})
}
}

View File

@@ -1244,6 +1244,15 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
}
return out
}
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
memberDoc, err := r.getJSON(memberPath)
if err == nil {
return []map[string]interface{}{memberDoc}
}
}
}
}
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
@@ -1256,6 +1265,33 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
return nil
}
func dedupeJSONDocsByPath(docs []map[string]interface{}) []map[string]interface{} {
if len(docs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(docs))
out := make([]map[string]interface{}, 0, len(docs))
for _, doc := range docs {
if len(doc) == 0 {
continue
}
key := normalizeRedfishPath(asString(doc["@odata.id"]))
if key == "" {
payload, err := json.Marshal(doc)
if err != nil {
continue
}
key = string(payload)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, doc)
}
return out
}
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 {
return nil

View File

@@ -31,7 +31,7 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne
// 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 {
if linkedIdx := r.findNICIndexByLinkedNetworkAdapter(iface, *nics, bySlot); linkedIdx >= 0 {
idx = linkedIdx
ok = true
}
@@ -75,28 +75,53 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
continue
}
for _, doc := range adapterDocs {
nic := parseNIC(doc)
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
pcieDoc, err := r.getJSON(pciePath)
if err != nil {
continue
}
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
if len(nic.MACAddresses) == 0 {
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc)
}
nics = append(nics, nic)
nics = append(nics, r.buildNICFromAdapterDoc(doc))
}
}
return dedupeNetworkAdapters(nics)
}
func (r redfishSnapshotReader) buildNICFromAdapterDoc(adapterDoc map[string]interface{}) models.NetworkAdapter {
nic := parseNIC(adapterDoc)
adapterFunctionDocs := r.getNetworkAdapterFunctionDocs(adapterDoc)
for _, pciePath := range networkAdapterPCIeDevicePaths(adapterDoc) {
pcieDoc, err := r.getJSON(pciePath)
if err != nil {
continue
}
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
for _, adapterFnDoc := range adapterFunctionDocs {
functionDocs = append(functionDocs, r.getLinkedPCIeFunctions(adapterFnDoc)...)
}
functionDocs = dedupeJSONDocsByPath(functionDocs)
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
if len(nic.MACAddresses) == 0 {
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, adapterDoc)
}
return nic
}
func (r redfishSnapshotReader) getNetworkAdapterFunctionDocs(adapterDoc map[string]interface{}) []map[string]interface{} {
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
if !ok {
return nil
}
colPath := asString(ndfCol["@odata.id"])
if colPath == "" {
return nil
}
funcDocs, err := r.getCollectionMembers(colPath)
if err != nil {
return nil
}
return funcDocs
}
func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []string) []models.PCIeDevice {
collections := make([]string, 0, len(systemPaths)+len(chassisPaths))
for _, systemPath := range systemPaths {
@@ -116,13 +141,16 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
if looksLikeGPU(doc, functionDocs) {
continue
}
if replayPCIeDeviceBackedByCanonicalNIC(doc, functionDocs) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
if shouldSkipReplayPCIeDevice(doc, dev) {
continue
}
out = append(out, dev)
@@ -136,12 +164,134 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
for idx, fn := range functionDocs {
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
if shouldSkipReplayPCIeDevice(fn, dev) {
continue
}
out = append(out, dev)
}
}
return dedupePCIeDevices(out)
}
func shouldSkipReplayPCIeDevice(doc map[string]interface{}, dev models.PCIeDevice) bool {
if isUnidentifiablePCIeDevice(dev) {
return true
}
if replayNetworkFunctionBackedByCanonicalNIC(doc, dev) {
return true
}
if isReplayStorageServiceEndpoint(doc, dev) {
return true
}
if isReplayNoisePCIeClass(dev.DeviceClass) {
return true
}
if isReplayDisplayDeviceDuplicate(doc, dev) {
return true
}
return false
}
func replayPCIeDeviceBackedByCanonicalNIC(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
if !looksLikeReplayNetworkPCIeDevice(doc, functionDocs) {
return false
}
for _, fn := range functionDocs {
if hasRedfishLinkedMember(fn, "NetworkDeviceFunctions") {
return true
}
}
return false
}
func replayNetworkFunctionBackedByCanonicalNIC(doc map[string]interface{}, dev models.PCIeDevice) bool {
if !looksLikeReplayNetworkClass(dev.DeviceClass) {
return false
}
return hasRedfishLinkedMember(doc, "NetworkDeviceFunctions")
}
func looksLikeReplayNetworkPCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
for _, fn := range functionDocs {
if looksLikeReplayNetworkClass(asString(fn["DeviceClass"])) {
return true
}
}
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
asString(doc["DeviceType"]),
asString(doc["Description"]),
asString(doc["Name"]),
asString(doc["Model"]),
}, " ")))
return strings.Contains(joined, "network")
}
func looksLikeReplayNetworkClass(class string) bool {
class = strings.ToLower(strings.TrimSpace(class))
return strings.Contains(class, "network") || strings.Contains(class, "ethernet")
}
func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeDevice) bool {
class := strings.ToLower(strings.TrimSpace(dev.DeviceClass))
if class != "massstoragecontroller" && class != "mass storage controller" {
return false
}
name := strings.ToLower(strings.TrimSpace(firstNonEmpty(
dev.PartNumber,
asString(doc["PartNumber"]),
asString(doc["Description"]),
)))
if strings.Contains(name, "pcie switch management endpoint") {
return true
}
if strings.Contains(name, "volume management device nvme raid controller") {
return true
}
return false
}
func hasRedfishLinkedMember(doc map[string]interface{}, key string) bool {
links, ok := doc["Links"].(map[string]interface{})
if !ok {
return false
}
if asInt(links[key+"@odata.count"]) > 0 {
return true
}
linked, ok := links[key]
if !ok {
return false
}
switch v := linked.(type) {
case []interface{}:
return len(v) > 0
case map[string]interface{}:
if asString(v["@odata.id"]) != "" {
return true
}
return len(v) > 0
default:
return false
}
}
func isReplayNoisePCIeClass(class string) bool {
switch strings.ToLower(strings.TrimSpace(class)) {
case "bridge", "processor", "signalprocessingcontroller", "signal processing controller", "serialbuscontroller", "serial bus controller":
return true
default:
return false
}
}
func isReplayDisplayDeviceDuplicate(doc map[string]interface{}, dev models.PCIeDevice) bool {
class := strings.ToLower(strings.TrimSpace(dev.DeviceClass))
if class != "displaycontroller" && class != "display controller" {
return false
}
return strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device")
}
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
chassisPath := chassisPathForPCIeDoc(docPath)
@@ -341,8 +491,9 @@ func redfishManagerInterfaceScore(summary map[string]any) int {
// 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 {
// NetworkAdapter doc and reconstructing the canonical NIC identity. Returns -1
// if no match is found.
func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, existing []models.NetworkAdapter, bySlot map[string]int) int {
links, ok := iface["Links"].(map[string]interface{})
if !ok {
return -1
@@ -359,15 +510,58 @@ func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[stri
if err != nil || len(adapterDoc) == 0 {
return -1
}
adapterNIC := parseNIC(adapterDoc)
adapterNIC := r.buildNICFromAdapterDoc(adapterDoc)
if serial := normalizeRedfishIdentityField(adapterNIC.SerialNumber); serial != "" {
for idx, nic := range existing {
if strings.EqualFold(normalizeRedfishIdentityField(nic.SerialNumber), serial) {
return idx
}
}
}
if bdf := strings.TrimSpace(adapterNIC.BDF); bdf != "" {
for idx, nic := range existing {
if strings.EqualFold(strings.TrimSpace(nic.BDF), bdf) {
return idx
}
}
}
if slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" {
if idx, ok := bySlot[slot]; ok {
return idx
}
}
for idx, nic := range existing {
if networkAdaptersShareMACs(nic, adapterNIC) {
return idx
}
}
return -1
}
func networkAdaptersShareMACs(a, b models.NetworkAdapter) bool {
if len(a.MACAddresses) == 0 || len(b.MACAddresses) == 0 {
return false
}
seen := make(map[string]struct{}, len(a.MACAddresses))
for _, mac := range a.MACAddresses {
normalized := strings.ToUpper(strings.TrimSpace(mac))
if normalized == "" {
continue
}
seen[normalized] = struct{}{}
}
for _, mac := range b.MACAddresses {
normalized := strings.ToUpper(strings.TrimSpace(mac))
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
return true
}
}
return false
}
// 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

@@ -265,9 +265,6 @@ func TestRedfishConnectorProbe(t *testing.T) {
if got.HostPowerState != "Off" {
t.Fatalf("expected power state Off, got %q", got.HostPowerState)
}
if !got.PowerControlAvailable {
t.Fatalf("expected power control available")
}
}
func TestRedfishConnectorProbe_FallsBackToPowerSummary(t *testing.T) {
@@ -330,225 +327,6 @@ func TestRedfishConnectorProbe_FallsBackToPowerSummary(t *testing.T) {
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
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",
"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"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
resetCalls++
powerState = "On"
w.WriteHeader(http.StatusOK)
})
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 stable power-on result, got hostOn=%v changed=%v", hostOn, changed)
}
if resetCalls != 1 {
t.Fatalf("expected one reset call, got %d", resetCalls)
}
}
func TestEnsureHostPowerForCollection_FailsIfHostDoesNotStayOnAfterStabilization(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_POWERON_STABILIZATION", "1ms")
t.Setenv("LOGPILE_REDFISH_BMC_READY_WAITS", "1ms,1ms")
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 == "On" {
powerState = "Off"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"PowerState": current,
"Actions": map[string]interface{}{
"#ComputerSystem.Reset": map[string]interface{}{
"target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset",
"ResetType@Redfish.AllowableValues": []interface{}{"On"},
},
},
})
})
mux.HandleFunc("/redfish/v1/Systems/1/Actions/ComputerSystem.Reset", func(w http.ResponseWriter, r *http.Request) {
powerState = "On"
w.WriteHeader(http.StatusOK)
})
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 unstable power-on result to fail, got hostOn=%v changed=%v", hostOn, changed)
}
}
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) {
@@ -1287,6 +1065,229 @@ func TestEnrichNICFromPCIeFunctions_FillsMissingIdentityFromFunctionDoc(t *testi
}
}
func TestReplayCollectNICs_UsesNetworkDeviceFunctionPCIeFunctionLink(t *testing.T) {
tree := map[string]interface{}{
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1": map[string]interface{}{
"Id": "DevType7_NIC1",
"Name": "NetworkAdapter_1",
"Controllers": []interface{}{
map[string]interface{}{
"ControllerCapabilities": map[string]interface{}{
"NetworkPortCount": 2,
},
"Links": map[string]interface{}{
"PCIeDevices": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00"},
},
},
},
},
"NetworkDeviceFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions",
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0": map[string]interface{}{
"Id": "Function0",
"Links": map[string]interface{}{
"PCIeFunction": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0",
},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00": map[string]interface{}{
"Id": "00_0F_00",
"Name": "PCIeDevice_00_0F_00",
"Manufacturer": "Mellanox Technologies",
"FirmwareVersion": "26.43.25.66",
"Slot": map[string]interface{}{
"Location": map[string]interface{}{
"PartLocation": map[string]interface{}{
"ServiceLabel": "RISER4",
},
},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0": map[string]interface{}{
"Id": "Function0",
"FunctionId": "0000:0f:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x101f",
"SerialNumber": "MT2412X00001",
"PartNumber": "MCX623432AC-GDA_Ax",
},
}
r := redfishSnapshotReader{tree: tree}
nics := r.collectNICs([]string{"/redfish/v1/Chassis/1"})
if len(nics) != 1 {
t.Fatalf("expected one NIC, got %d", len(nics))
}
if nics[0].Slot != "RISER4" {
t.Fatalf("expected slot from PCIe device, got %q", nics[0].Slot)
}
if nics[0].SerialNumber != "MT2412X00001" {
t.Fatalf("expected serial from NetworkDeviceFunction PCIeFunction link, got %q", nics[0].SerialNumber)
}
if nics[0].PartNumber != "MCX623432AC-GDA_Ax" {
t.Fatalf("expected part number from linked PCIeFunction, got %q", nics[0].PartNumber)
}
if nics[0].BDF != "0000:0f:00.0" {
t.Fatalf("expected BDF from linked PCIeFunction, got %q", nics[0].BDF)
}
if nics[0].Model != "MT2894 Family [ConnectX-6 Lx]" {
t.Fatalf("expected model resolved from PCI IDs, got %q", nics[0].Model)
}
}
func TestReplayEnrichNICsFromNetworkInterfaces_DoesNotCreateGhostForLinkedAdapter(t *testing.T) {
tree := map[string]interface{}{
"/redfish/v1/Chassis/1/NetworkAdapters": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1",
"Id": "DevType7_NIC1",
"Name": "NetworkAdapter_1",
"Controllers": []interface{}{
map[string]interface{}{
"ControllerCapabilities": map[string]interface{}{
"NetworkPortCount": 1,
},
"Links": map[string]interface{}{
"PCIeDevices": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00"},
},
},
},
map[string]interface{}{
"ControllerCapabilities": map[string]interface{}{
"NetworkPortCount": 1,
},
"Links": map[string]interface{}{
"PCIeDevices": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00"},
},
},
},
},
"NetworkDeviceFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions",
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function1"},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0": map[string]interface{}{
"Id": "Function0",
"Ethernet": map[string]interface{}{
"MACAddress": "CC:40:F3:D6:9E:DE",
"PermanentMACAddress": "CC:40:F3:D6:9E:DE",
},
"Links": map[string]interface{}{
"PCIeFunction": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0",
},
},
},
"/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function1": map[string]interface{}{
"Id": "Function1",
"Ethernet": map[string]interface{}{
"MACAddress": "CC:40:F3:D6:9E:DF",
"PermanentMACAddress": "CC:40:F3:D6:9E:DF",
},
"Links": map[string]interface{}{
"PCIeFunction": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1",
},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00": map[string]interface{}{
"Id": "00_0F_00",
"Name": "PCIeDevice_00_0F_00",
"Manufacturer": "Mellanox Technologies",
"FirmwareVersion": "26.43.25.66",
"Slot": map[string]interface{}{
"Location": map[string]interface{}{
"PartLocation": map[string]interface{}{
"ServiceLabel": "RISER4",
},
},
},
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function0": map[string]interface{}{
"FunctionId": "0000:0f:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x101f",
"DeviceClass": "NetworkController",
"SerialNumber": "N/A",
},
"/redfish/v1/Chassis/1/PCIeDevices/00_0F_00/PCIeFunctions/Function1": map[string]interface{}{
"FunctionId": "0000:0f:00.1",
"VendorId": "0x15b3",
"DeviceId": "0x101f",
"DeviceClass": "NetworkController",
},
"/redfish/v1/Systems/1/NetworkInterfaces": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/NetworkInterfaces/DevType7_NIC1"},
},
},
"/redfish/v1/Systems/1/NetworkInterfaces/DevType7_NIC1": map[string]interface{}{
"Id": "DevType7_NIC1",
"Name": "NetworkAdapter_1",
"Links": map[string]interface{}{
"NetworkAdapter": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1",
},
},
"Status": map[string]interface{}{
"Health": "OK",
"State": "Disabled",
},
},
}
r := redfishSnapshotReader{tree: tree}
nics := r.collectNICs([]string{"/redfish/v1/Chassis/1"})
r.enrichNICsFromNetworkInterfaces(&nics, []string{"/redfish/v1/Systems/1"})
if len(nics) != 1 {
t.Fatalf("expected linked network interface to reuse existing NIC, got %d: %+v", len(nics), nics)
}
if nics[0].Slot != "RISER4" {
t.Fatalf("expected enriched slot to stay canonical, got %q", nics[0].Slot)
}
if nics[0].Model != "MT2894 Family [ConnectX-6 Lx]" {
t.Fatalf("expected resolved Mellanox model, got %q", nics[0].Model)
}
if len(nics[0].MACAddresses) != 2 {
t.Fatalf("expected both MACs to stay on one NIC, got %+v", nics[0].MACAddresses)
}
}
func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
@@ -1340,6 +1341,48 @@ func TestParseNIC_PrefersControllerSlotLabelAndPCIeInterface(t *testing.T) {
}
}
func TestParseNIC_xFusionMaxlanesAndOEMLinkWidth(t *testing.T) {
// xFusion uses "Maxlanes" (lowercase 'l') in PCIeInterface, not "MaxLanes".
// xFusion also stores per-function link width as Oem.xFusion.LinkWidth = "X8".
nic := parseNIC(map[string]interface{}{
"Id": "OCPCard1",
"Model": "ConnectX-6 Lx",
"Controllers": []interface{}{
map[string]interface{}{
"PCIeInterface": map[string]interface{}{
"LanesInUse": 8,
"Maxlanes": 8, // xFusion uses lowercase 'l'
"PCIeType": "Gen4",
"MaxPCIeType": "Gen4",
},
},
},
})
if nic.LinkWidth != 8 || nic.MaxLinkWidth != 8 {
t.Fatalf("expected link widths 8/8 from xFusion Maxlanes, got current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
}
// enrichNICFromPCIe: OEM xFusion LinkWidth on a PCIeFunction doc.
nic2 := models.NetworkAdapter{}
fnDoc := map[string]interface{}{
"Oem": map[string]interface{}{
"xFusion": map[string]interface{}{
"LinkWidth": "X8",
"LinkWidthAbility": "X8",
"LinkSpeed": "Gen4 (16.0GT/s)",
"LinkSpeedAbility": "Gen4 (16.0GT/s)",
},
},
}
enrichNICFromPCIe(&nic2, map[string]interface{}{}, []map[string]interface{}{fnDoc}, nil)
if nic2.LinkWidth != 8 || nic2.MaxLinkWidth != 8 {
t.Fatalf("expected link width 8 from xFusion OEM LinkWidth, got current=%d max=%d", nic2.LinkWidth, nic2.MaxLinkWidth)
}
if nic2.LinkSpeed != "Gen4 (16.0GT/s)" || nic2.MaxLinkSpeed != "Gen4 (16.0GT/s)" {
t.Fatalf("expected link speed from xFusion OEM LinkSpeed, got current=%q max=%q", nic2.LinkSpeed, nic2.MaxLinkSpeed)
}
}
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
nic := parseNIC(map[string]interface{}{
"Id": "1",
@@ -2388,6 +2431,279 @@ func TestReplayCollectGPUs_DoesNotCollapseOnPlaceholderSerialAndSkipsNIC(t *test
}
}
func TestReplayCollectPCIeDevices_SkipsMSITopologyNoiseClasses(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/bridge": map[string]interface{}{
"Id": "bridge",
"Name": "Bridge",
"Description": "Bridge Device",
"Manufacturer": "Intel Corporation",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/bridge/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "Bridge",
"VendorId": "0x8086",
"DeviceId": "0x0db0",
},
"/redfish/v1/Chassis/1/PCIeDevices/processor": map[string]interface{}{
"Id": "processor",
"Name": "Processor",
"Description": "Processor Device",
"Manufacturer": "Intel Corporation",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/processor/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "Processor",
"VendorId": "0x8086",
"DeviceId": "0x4944",
},
"/redfish/v1/Chassis/1/PCIeDevices/signal": map[string]interface{}{
"Id": "signal",
"Name": "Signal",
"Manufacturer": "Intel Corporation",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/signal/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "SignalProcessingController",
"VendorId": "0x8086",
"DeviceId": "0x3254",
},
"/redfish/v1/Chassis/1/PCIeDevices/serial": map[string]interface{}{
"Id": "serial",
"Name": "Serial",
"Manufacturer": "Renesas",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/serial/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "SerialBusController",
"VendorId": "0x1912",
"DeviceId": "0x0014",
},
"/redfish/v1/Chassis/1/PCIeDevices/display": map[string]interface{}{
"Id": "display",
"Name": "Display",
"Description": "Display Device",
"Manufacturer": "NVIDIA Corporation",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/display/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "DisplayController",
"VendorId": "0x10de",
"DeviceId": "0x233b",
},
"/redfish/v1/Chassis/1/PCIeDevices/network": map[string]interface{}{
"Id": "network",
"Name": "NIC",
"Description": "Network Device",
"Manufacturer": "Mellanox Technologies",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/network/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "NetworkController",
"VendorId": "0x15b3",
"DeviceId": "0x101f",
},
"/redfish/v1/Chassis/1/PCIeDevices/storage": map[string]interface{}{
"Id": "storage",
"Name": "Storage",
"Description": "Storage Device",
"Manufacturer": "Intel Corporation",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/storage/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "MassStorageController",
"VendorId": "0x1234",
"DeviceId": "0x5678",
},
}}
got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"})
if len(got) != 2 {
t.Fatalf("expected only endpoint PCIe devices to remain, got %d: %+v", len(got), got)
}
classes := map[string]bool{}
for _, dev := range got {
classes[dev.DeviceClass] = true
}
if !classes["NetworkController"] || !classes["MassStorageController"] {
t.Fatalf("expected network and storage PCIe devices to remain, got %+v", got)
}
if classes["Bridge"] || classes["Processor"] || classes["SignalProcessingController"] || classes["SerialBusController"] || classes["DisplayController"] {
t.Fatalf("expected MSI topology noise classes to be filtered, got %+v", got)
}
}
func TestReplayCollectPCIeDevices_SkipsNICsAlreadyRepresentedAsNetworkAdapters(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/nic": map[string]interface{}{
"Id": "nic",
"Name": "PCIeDevice_00_39_00",
"Description": "Network Device",
"Manufacturer": "Mellanox Technologies",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/nic/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "NetworkController",
"VendorId": "0x15b3",
"DeviceId": "0x101f",
"Links": map[string]interface{}{
"NetworkDeviceFunctions": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/NetworkAdapters/NIC1/NetworkDeviceFunctions/Function0"},
},
"NetworkDeviceFunctions@odata.count": 1,
},
},
}}
got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"})
if len(got) != 0 {
t.Fatalf("expected network-backed PCIe duplicate to be skipped, got %+v", got)
}
}
func TestReplayCollectPCIeDevices_SkipsStorageServiceEndpoints(t *testing.T) {
r := redfishSnapshotReader{tree: map[string]interface{}{
"/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt"},
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/vmd": map[string]interface{}{
"Id": "vmd",
"Description": "Storage Device",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/vmd/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "MassStorageController",
"VendorId": "0x8086",
"DeviceId": "0x28c0",
},
"/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt": map[string]interface{}{
"Id": "switch-mgmt",
"Description": "Storage Device",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/switch-mgmt/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "MassStorageController",
"VendorId": "0x1000",
"DeviceId": "0x00b2",
},
"/redfish/v1/Chassis/1/PCIeDevices/hba": map[string]interface{}{
"Id": "hba",
"Description": "Storage Device",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions",
},
},
"/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions/1"},
},
},
"/redfish/v1/Chassis/1/PCIeDevices/hba/PCIeFunctions/1": map[string]interface{}{
"DeviceClass": "MassStorageController",
"VendorId": "0x1234",
"DeviceId": "0x5678",
},
}}
got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"})
if len(got) != 1 {
t.Fatalf("expected only non-service storage controller to remain, got %+v", got)
}
if got[0].VendorID != 0x1234 || got[0].DeviceID != 0x5678 {
t.Fatalf("expected generic HBA to remain, got %+v", got[0])
}
}
func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) {
got := parseBoardInfo(map[string]interface{}{
"Manufacturer": "NULL",
@@ -2499,6 +2815,28 @@ func TestReplayCollectGPUs_DedupUsesRedfishPathBeforeHeuristics(t *testing.T) {
}
}
func TestParseGPU_xFusionPCIeInterfaceMaxlanes(t *testing.T) {
// xFusion GPU PCIeDevices (PCIeCard1..N) carry link width in PCIeInterface
// with "Maxlanes" (lowercase 'l') rather than "MaxLanes".
doc := map[string]interface{}{
"Id": "PCIeCard1",
"Model": "RTX PRO 6000",
"PCIeInterface": map[string]interface{}{
"LanesInUse": 16,
"Maxlanes": 16,
"PCIeType": "Gen5",
"MaxPCIeType": "Gen5",
},
}
gpu := parseGPU(doc, nil, 1)
if gpu.CurrentLinkWidth != 16 || gpu.MaxLinkWidth != 16 {
t.Fatalf("expected link widths 16/16 from PCIeInterface, got current=%d max=%d", gpu.CurrentLinkWidth, gpu.MaxLinkWidth)
}
if gpu.CurrentLinkSpeed != "Gen5" || gpu.MaxLinkSpeed != "Gen5" {
t.Fatalf("expected link speeds Gen5/Gen5 from PCIeInterface, got current=%q max=%q", gpu.CurrentLinkSpeed, gpu.MaxLinkSpeed)
}
}
func TestParseGPU_UsesNestedOemSerialNumber(t *testing.T) {
doc := map[string]interface{}{
"Id": "GPU4",
@@ -3527,8 +3865,11 @@ func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
t.Fatalf("expected CPU metrics subresource to be crawlable")
}
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions") {
t.Fatalf("expected broad chassis PCIeFunctions collection to be skipped")
}
if !shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
t.Fatalf("expected chassis pciefunctions resource to be crawlable for NIC/GPU identity recovery")
t.Fatalf("expected direct chassis PCIeFunction member to remain crawlable")
}
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0") {
t.Fatalf("expected NVSwitch fabric resource to be crawlable")

View File

@@ -15,9 +15,8 @@ type Request struct {
Password string
Token string
TLSMode string
PowerOnIfHostOff bool
StopHostAfterCollect bool
DebugPayloads bool
DebugPayloads bool
SkipHungCh <-chan struct{}
}
type Progress struct {
@@ -65,10 +64,9 @@ type PhaseTelemetry struct {
type ProbeResult struct {
Reachable bool
Protocol string
HostPowerState string
HostPoweredOn bool
PowerControlAvailable bool
SystemPath string
HostPowerState string
HostPoweredOn bool
SystemPath string
}
type Connector interface {

View File

@@ -2246,10 +2246,8 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string {
func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
case "", "network", "network controller", "networkcontroller":
case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller":
return "NetworkController"
case "ethernet", "ethernet controller", "ethernetcontroller":
return "EthernetController"
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
return "FibreChannelController"
case "display", "displaycontroller", "display controller", "vga":
@@ -2270,8 +2268,6 @@ func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
func normalizeNetworkDeviceClass(portType, model, description string) string {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
switch {
case strings.Contains(joined, "ethernet"):
return "EthernetController"
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
return "FibreChannelController"
default:

View File

@@ -1733,6 +1733,43 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
}
}
func TestConvertToReanimator_UnifiesEthernetAndNetworkControllers(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindPCIe,
Slot: "PCIe1",
DeviceClass: "EthernetController",
Present: boolPtr(true),
SerialNumber: "ETH-001",
},
{
Kind: models.DeviceKindNetwork,
Slot: "NIC1",
Model: "Ethernet Adapter",
Present: boolPtr(true),
SerialNumber: "NIC-001",
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected two pcie-class exports, got %d", len(out.Hardware.PCIeDevices))
}
for _, dev := range out.Hardware.PCIeDevices {
if dev.DeviceClass != "NetworkController" {
t.Fatalf("expected unified NetworkController class, got %+v", dev)
}
}
}
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
input := &models.AnalysisResult{
Filename: "legacy-details.json",

View File

@@ -10,6 +10,33 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser"
)
type xfusionNICCard struct {
Slot string
Model string
ProductName string
Vendor string
VendorID int
DeviceID int
BDF string
SerialNumber string
PartNumber string
}
type xfusionNetcardPort struct {
BDF string
MAC string
ActualMAC string
}
type xfusionNetcardSnapshot struct {
Timestamp time.Time
Slot string
ProductName string
Manufacturer string
Firmware string
Ports []xfusionNetcardPort
}
// ── FRU ──────────────────────────────────────────────────────────────────────
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
@@ -232,15 +259,15 @@ func parseCPUInfo(content []byte) []models.CPU {
}
cpus = append(cpus, models.CPU{
Socket: socketNum,
Model: model,
Cores: cores,
Threads: threads,
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
Socket: socketNum,
Model: model,
Cores: cores,
Threads: threads,
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
SerialNumber: sn,
Status: "ok",
Status: "ok",
})
}
return cpus
@@ -338,9 +365,9 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
// parseCardInfo parses card_info file, extracting GPU and NIC entries.
// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory.
// The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table.
func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) {
func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) {
sections := splitPipeSections(content)
// Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info
@@ -396,17 +423,22 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) {
}
// OCP Card Info: NIC cards
for i, row := range sections["ocp card info"] {
desc := strings.TrimSpace(row["card desc"])
sn := strings.TrimSpace(row["serialnumber"])
nics = append(nics, models.NIC{
Name: fmt.Sprintf("OCP%d", i+1),
Model: desc,
SerialNumber: sn,
for _, row := range sections["ocp card info"] {
slot := strings.TrimSpace(row["slot"])
pcie := slotPCIe[slot]
nicCards = append(nicCards, xfusionNICCard{
Slot: slot,
Model: strings.TrimSpace(row["card desc"]),
ProductName: strings.TrimSpace(row["card desc"]),
VendorID: parseHexInt(row["vender id"]),
DeviceID: parseHexInt(row["device id"]),
BDF: pcie.bdf,
SerialNumber: strings.TrimSpace(row["serialnumber"]),
PartNumber: strings.TrimSpace(row["partnum"]),
})
}
return gpus, nics
return gpus, nicCards
}
// splitPipeSections parses a multi-section file where each section starts with a
@@ -462,6 +494,301 @@ func parseHexInt(s string) int {
return int(n)
}
func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot {
if len(content) == 0 {
return nil
}
var snapshots []xfusionNetcardSnapshot
var current *xfusionNetcardSnapshot
var currentPort *xfusionNetcardPort
flushPort := func() {
if current == nil || currentPort == nil {
return
}
current.Ports = append(current.Ports, *currentPort)
currentPort = nil
}
flushSnapshot := func() {
if current == nil || !current.hasData() {
return
}
flushPort()
snapshots = append(snapshots, *current)
current = nil
}
for _, rawLine := range strings.Split(string(content), "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
flushPort()
continue
}
if ts, ok := parseXFusionUTCTimestamp(line); ok {
if current == nil {
current = &xfusionNetcardSnapshot{Timestamp: ts}
continue
}
if current.hasData() {
flushSnapshot()
current = &xfusionNetcardSnapshot{Timestamp: ts}
continue
}
current.Timestamp = ts
continue
}
if current == nil {
current = &xfusionNetcardSnapshot{}
}
if port := parseNetcardPortHeader(line); port != nil {
flushPort()
currentPort = port
continue
}
if currentPort != nil {
if value, ok := parseSimpleKV(line, "MacAddr"); ok {
currentPort.MAC = value
continue
}
if value, ok := parseSimpleKV(line, "ActualMac"); ok {
currentPort.ActualMAC = value
continue
}
}
if value, ok := parseSimpleKV(line, "ProductName"); ok {
current.ProductName = value
continue
}
if value, ok := parseSimpleKV(line, "Manufacture"); ok {
current.Manufacturer = value
continue
}
if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok {
current.Firmware = value
continue
}
if value, ok := parseSimpleKV(line, "SlotId"); ok {
current.Slot = value
}
}
flushSnapshot()
bestIndexBySlot := make(map[string]int)
for i, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
if slot == "" {
continue
}
prevIdx, exists := bestIndexBySlot[slot]
if !exists || snapshot.isBetterThan(snapshots[prevIdx]) {
bestIndexBySlot[slot] = i
}
}
ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot))
for i, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
bestIdx, ok := bestIndexBySlot[slot]
if !ok || bestIdx != i {
continue
}
ordered = append(ordered, snapshot)
delete(bestIndexBySlot, slot)
}
return ordered
}
func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) {
bySlotCard := make(map[string]xfusionNICCard, len(cards))
bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots))
orderedSlots := make([]string, 0, len(cards)+len(snapshots))
seenSlots := make(map[string]struct{}, len(cards)+len(snapshots))
for _, card := range cards {
slot := strings.TrimSpace(card.Slot)
if slot == "" {
continue
}
bySlotCard[slot] = card
if _, seen := seenSlots[slot]; !seen {
orderedSlots = append(orderedSlots, slot)
seenSlots[slot] = struct{}{}
}
}
for _, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
if slot == "" {
continue
}
bySlotSnapshot[slot] = snapshot
if _, seen := seenSlots[slot]; !seen {
orderedSlots = append(orderedSlots, slot)
seenSlots[slot] = struct{}{}
}
}
adapters := make([]models.NetworkAdapter, 0, len(orderedSlots))
legacyNICs := make([]models.NIC, 0, len(orderedSlots))
for _, slot := range orderedSlots {
card := bySlotCard[slot]
snapshot := bySlotSnapshot[slot]
model := firstNonEmpty(card.Model, snapshot.ProductName)
description := ""
if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) {
description = strings.TrimSpace(snapshot.ProductName)
}
macs := snapshot.macAddresses()
bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF)
firmware := normalizeXFusionValue(snapshot.Firmware)
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
portCount := len(snapshot.Ports)
if portCount == 0 && len(macs) > 0 {
portCount = len(macs)
}
if portCount == 0 {
portCount = 1
}
adapters = append(adapters, models.NetworkAdapter{
Slot: slot,
Location: "OCP",
Present: true,
BDF: bdf,
Model: model,
Description: description,
Vendor: manufacturer,
VendorID: card.VendorID,
DeviceID: card.DeviceID,
SerialNumber: card.SerialNumber,
PartNumber: card.PartNumber,
Firmware: firmware,
PortCount: portCount,
PortType: "ethernet",
MACAddresses: macs,
Status: "ok",
})
legacyNICs = append(legacyNICs, models.NIC{
Name: fmt.Sprintf("OCP%s", slot),
Model: model,
Description: description,
MACAddress: firstNonEmpty(macs...),
SerialNumber: card.SerialNumber,
})
}
return adapters, legacyNICs
}
func parseXFusionUTCTimestamp(line string) (time.Time, bool) {
ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line))
if err != nil {
return time.Time{}, false
}
return ts, true
}
func parseNetcardPortHeader(line string) *xfusionNetcardPort {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") {
return nil
}
joined := strings.Join(fields[1:], " ")
if !strings.HasPrefix(strings.ToLower(joined), "bdf:") {
return nil
}
return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])}
}
func parseSimpleKV(line, key string) (string, bool) {
idx := strings.Index(line, ":")
if idx < 0 {
return "", false
}
gotKey := strings.TrimSpace(line[:idx])
if !strings.EqualFold(gotKey, key) {
return "", false
}
return strings.TrimSpace(line[idx+1:]), true
}
func normalizeXFusionValue(value string) string {
value = strings.TrimSpace(value)
switch strings.ToUpper(value) {
case "", "N/A", "NA", "UNKNOWN":
return ""
default:
return value
}
}
func (s xfusionNetcardSnapshot) hasData() bool {
return strings.TrimSpace(s.Slot) != "" ||
strings.TrimSpace(s.ProductName) != "" ||
strings.TrimSpace(s.Manufacturer) != "" ||
strings.TrimSpace(s.Firmware) != "" ||
len(s.Ports) > 0
}
func (s xfusionNetcardSnapshot) score() int {
score := len(s.Ports)
if normalizeXFusionValue(s.Firmware) != "" {
score += 10
}
score += len(s.macAddresses()) * 2
return score
}
func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool {
if s.score() != other.score() {
return s.score() > other.score()
}
if !s.Timestamp.Equal(other.Timestamp) {
return s.Timestamp.After(other.Timestamp)
}
return len(s.Ports) > len(other.Ports)
}
func (s xfusionNetcardSnapshot) primaryBDF() string {
for _, port := range s.Ports {
if bdf := strings.TrimSpace(port.BDF); bdf != "" {
return bdf
}
}
return ""
}
func (s xfusionNetcardSnapshot) macAddresses() []string {
out := make([]string, 0, len(s.Ports))
seen := make(map[string]struct{}, len(s.Ports))
for _, port := range s.Ports {
for _, candidate := range []string{port.ActualMAC, port.MAC} {
mac := normalizeMAC(candidate)
if mac == "" {
continue
}
if _, exists := seen[mac]; exists {
continue
}
seen[mac] = struct{}{}
out = append(out, mac)
break
}
}
return out
}
func normalizeMAC(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
switch value {
case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00":
return ""
default:
return value
}
}
// ── PSU ───────────────────────────────────────────────────────────────────────
// parsePSUInfo parses the pipe-delimited psu_info.txt.
@@ -525,6 +852,11 @@ func parsePSUInfo(content []byte) []models.PSU {
func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
// File may contain multiple controller blocks; parse key:value pairs from each.
// We only look at the first occurrence of each key (first controller).
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
for _, fw := range result.Hardware.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
seen[key] = struct{}{}
}
text := string(content)
blocks := strings.Split(text, "RAID Controller #")
for _, block := range blocks[1:] { // skip pre-block preamble
@@ -532,7 +864,7 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
firmware := fields["Firmware Version"]
if name != "" && firmware != "" {
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
appendXFusionFirmware(result, seen, models.FirmwareInfo{
DeviceName: name,
Description: fields["Controller Name"],
Version: firmware,
@@ -541,6 +873,86 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
}
}
func parseAppRevision(content []byte, result *models.AnalysisResult) {
type firmwareLine struct {
deviceName string
description string
buildKey string
}
known := map[string]firmwareLine{
"Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"},
"Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"},
"CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"},
"SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"},
"Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"},
"Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"},
"Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"},
}
values := parseAlignedKeyValues(content)
if result.Hardware.BoardInfo.ProductName == "" {
if productName := values["Product Name"]; productName != "" {
result.Hardware.BoardInfo.ProductName = productName
}
}
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
for _, fw := range result.Hardware.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
seen[key] = struct{}{}
}
for key, meta := range known {
version := normalizeXFusionValue(values[key])
if version == "" {
continue
}
appendXFusionFirmware(result, seen, models.FirmwareInfo{
DeviceName: meta.deviceName,
Description: meta.description,
Version: version,
BuildTime: normalizeXFusionValue(values[meta.buildKey]),
})
}
}
func parseAlignedKeyValues(content []byte) map[string]string {
values := make(map[string]string)
for _, rawLine := range strings.Split(string(content), "\n") {
line := strings.TrimRight(rawLine, "\r")
if !strings.Contains(line, ":") {
continue
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimRight(line[:idx], " \t")
value := strings.TrimSpace(line[idx+1:])
if key == "" || value == "" || values[key] != "" {
continue
}
values[key] = value
}
return values
}
func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) {
if result == nil || result.Hardware == nil {
return
}
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
if key == "" {
return
}
if _, exists := seen[key]; exists {
return
}
seen[key] = struct{}{}
result.Hardware.Firmware = append(result.Hardware.Firmware, fw)
}
// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
func parseDiskInfo(content []byte) *models.Storage {
fields := parseKeyValueBlock(content)

View File

@@ -13,7 +13,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser"
)
const parserVersion = "1.0"
const parserVersion = "1.1"
func init() {
parser.Register(&Parser{})
@@ -34,11 +34,15 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
path := strings.ToLower(f.Path)
switch {
case strings.Contains(path, "appdump/frudata/fruinfo.txt"):
confidence += 60
confidence += 50
case strings.Contains(path, "rtosdump/versioninfo/app_revision.txt"):
confidence += 30
case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"):
confidence += 20
confidence += 10
case strings.Contains(path, "appdump/card_manage/card_info"):
confidence += 20
case strings.Contains(path, "logdump/netcard/netcard_info.txt"):
confidence += 20
}
if confidence >= 100 {
return 100
@@ -54,17 +58,21 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
FRU: make([]models.FRUInfo, 0),
Sensors: make([]models.SensorReading, 0),
Hardware: &models.HardwareConfig{
CPUs: make([]models.CPU, 0),
Memory: make([]models.MemoryDIMM, 0),
Storage: make([]models.Storage, 0),
GPUs: make([]models.GPU, 0),
NetworkCards: make([]models.NIC, 0),
PowerSupply: make([]models.PSU, 0),
Firmware: make([]models.FirmwareInfo, 0),
Firmware: make([]models.FirmwareInfo, 0),
Devices: make([]models.HardwareDevice, 0),
CPUs: make([]models.CPU, 0),
Memory: make([]models.MemoryDIMM, 0),
Storage: make([]models.Storage, 0),
Volumes: make([]models.StorageVolume, 0),
PCIeDevices: make([]models.PCIeDevice, 0),
GPUs: make([]models.GPU, 0),
NetworkCards: make([]models.NIC, 0),
NetworkAdapters: make([]models.NetworkAdapter, 0),
PowerSupply: make([]models.PSU, 0),
},
}
if f := findByPath(files, "appdump/frudata/fruinfo.txt"); f != nil {
if f := findByAnyPath(files, "appdump/frudata/fruinfo.txt", "rtosdump/versioninfo/fruinfo.txt"); f != nil {
parseFRUInfo(f.Content, result)
}
if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil {
@@ -76,10 +84,20 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil {
result.Hardware.Memory = parseMemInfo(f.Content)
}
var nicCards []xfusionNICCard
if f := findByPath(files, "appdump/card_manage/card_info"); f != nil {
gpus, nics := parseCardInfo(f.Content)
gpus, cards := parseCardInfo(f.Content)
result.Hardware.GPUs = gpus
result.Hardware.NetworkCards = nics
nicCards = cards
}
if f := findByPath(files, "logdump/netcard/netcard_info.txt"); f != nil || len(nicCards) > 0 {
var content []byte
if f != nil {
content = f.Content
}
adapters, legacyNICs := mergeNetworkAdapters(nicCards, parseNetcardInfo(content))
result.Hardware.NetworkAdapters = adapters
result.Hardware.NetworkCards = legacyNICs
}
if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil {
result.Hardware.PowerSupply = parsePSUInfo(f.Content)
@@ -87,6 +105,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil {
parseStorageControllerInfo(f.Content, result)
}
if f := findByPath(files, "rtosdump/versioninfo/app_revision.txt"); f != nil {
parseAppRevision(f.Content, result)
}
for _, f := range findDiskInfoFiles(files) {
disk := parseDiskInfo(f.Content)
if disk != nil {
@@ -99,6 +120,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Protocol = "ipmi"
result.SourceType = models.SourceTypeArchive
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result, nil
}
@@ -113,6 +135,15 @@ func findByPath(files []parser.ExtractedFile, substring string) *parser.Extracte
return nil
}
func findByAnyPath(files []parser.ExtractedFile, substrings ...string) *parser.ExtractedFile {
for _, substring := range substrings {
if f := findByPath(files, substring); f != nil {
return f
}
}
return nil
}
// findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files.
func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
var out []parser.ExtractedFile

View File

@@ -1,8 +1,10 @@
package xfusion
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
@@ -26,6 +28,29 @@ func TestDetect_G5500V7(t *testing.T) {
}
}
func TestDetect_ServerFileExportMarkers(t *testing.T) {
p := &Parser{}
score := p.Detect([]parser.ExtractedFile{
{Path: "dump_info/RTOSDump/versioninfo/app_revision.txt", Content: []byte("Product Name: G5500 V7")},
{Path: "dump_info/LogDump/netcard/netcard_info.txt", Content: []byte("2026-02-04 03:54:06 UTC")},
{Path: "dump_info/AppDump/card_manage/card_info", Content: []byte("OCP Card Info")},
})
if score < 70 {
t.Fatalf("expected Detect score >= 70 for xFusion file export markers, got %d", score)
}
}
func TestDetect_Negative(t *testing.T) {
p := &Parser{}
score := p.Detect([]parser.ExtractedFile{
{Path: "logs/messages.txt", Content: []byte("plain text")},
{Path: "inventory.json", Content: []byte(`{"vendor":"other"}`)},
})
if score != 0 {
t.Fatalf("expected Detect score 0 for non-xFusion input, got %d", score)
}
}
func TestParse_G5500V7_BoardInfo(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}
@@ -126,6 +151,94 @@ func TestParse_G5500V7_NICs(t *testing.T) {
}
}
func TestParse_ServerFileExport_NetworkAdaptersAndFirmware(t *testing.T) {
p := &Parser{}
files := []parser.ExtractedFile{
{
Path: "dump_info/AppDump/card_manage/card_info",
Content: []byte(strings.TrimSpace(`
Pcie Card Info
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
OCP Card Info
Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum
1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 |
`)),
},
{
Path: "dump_info/LogDump/netcard/netcard_info.txt",
Content: []byte(strings.TrimSpace(`
2026-02-04 03:54:06 UTC
ProductName :XC385
Manufacture :XFUSION
FirmwareVersion :26.39.2048
SlotId :1
Port0 BDF:0000:27:00.0
MacAddr:44:1A:4C:16:E8:03
ActualMac:44:1A:4C:16:E8:03
Port1 BDF:0000:27:00.1
MacAddr:00:00:00:00:00:00
ActualMac:44:1A:4C:16:E8:04
`)),
},
{
Path: "dump_info/RTOSDump/versioninfo/app_revision.txt",
Content: []byte(strings.TrimSpace(`
------------------- iBMC INFO -------------------
Active iBMC Version: (U68)3.08.05.85
Active iBMC Built: 16:46:26 Jan 4 2026
SDK Version: 13.16.30.16
SDK Built: 07:55:18 Dec 12 2025
Active BIOS Version: (U6216)01.02.08.17
Active BIOS Built: 00:00:00 Jan 05 2026
Product Name: G5500 V7
`)),
},
}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if result.Protocol != "ipmi" || result.SourceType != models.SourceTypeArchive {
t.Fatalf("unexpected source metadata: protocol=%q source_type=%q", result.Protocol, result.SourceType)
}
if result.Hardware == nil {
t.Fatal("Hardware is nil")
}
if len(result.Hardware.NetworkAdapters) != 1 {
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
}
adapter := result.Hardware.NetworkAdapters[0]
if adapter.BDF != "0000:27:00.0" {
t.Fatalf("expected network adapter BDF 0000:27:00.0, got %q", adapter.BDF)
}
if adapter.Firmware != "26.39.2048" {
t.Fatalf("expected network adapter firmware 26.39.2048, got %q", adapter.Firmware)
}
if adapter.SerialNumber != "02Y238X6RC000058" {
t.Fatalf("expected network adapter serial from card_info, got %q", adapter.SerialNumber)
}
if len(adapter.MACAddresses) != 2 || adapter.MACAddresses[0] != "44:1A:4C:16:E8:03" || adapter.MACAddresses[1] != "44:1A:4C:16:E8:04" {
t.Fatalf("unexpected MAC addresses: %#v", adapter.MACAddresses)
}
fwByDevice := make(map[string]models.FirmwareInfo)
for _, fw := range result.Hardware.Firmware {
fwByDevice[fw.DeviceName] = fw
}
if fwByDevice["iBMC"].Version != "(U68)3.08.05.85" {
t.Fatalf("expected iBMC firmware from app_revision.txt, got %#v", fwByDevice["iBMC"])
}
if fwByDevice["BIOS"].Version != "(U6216)01.02.08.17" {
t.Fatalf("expected BIOS firmware from app_revision.txt, got %#v", fwByDevice["BIOS"])
}
if result.Hardware.BoardInfo.ProductName != "G5500 V7" {
t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName)
}
}
func TestParse_G5500V7_PSUs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{}

View File

@@ -44,6 +44,9 @@ func TestParserParseExample(t *testing.T) {
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
raw, err := os.ReadFile(examplePath)
if err != nil {
if os.IsNotExist(err) {
t.Skipf("example file %s not present", examplePath)
}
t.Fatalf("read example file: %v", err)
}

View File

@@ -3,6 +3,8 @@ package server
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
@@ -22,6 +24,7 @@ func newCollectTestServer() (*Server, *httptest.Server) {
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
return s, httptest.NewServer(mux)
}
@@ -29,7 +32,17 @@ func TestCollectProbe(t *testing.T) {
_, ts := newCollectTestServer()
defer ts.Close()
body := `{"host":"bmc-off.local","protocol":"redfish","port":443,"username":"admin","auth_type":"password","password":"secret","tls_mode":"strict"}`
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen probe target: %v", err)
}
defer ln.Close()
addr, ok := ln.Addr().(*net.TCPAddr)
if !ok {
t.Fatalf("unexpected listener address type: %T", ln.Addr())
}
body := fmt.Sprintf(`{"host":"127.0.0.1","protocol":"redfish","port":%d,"username":"admin-off","auth_type":"password","password":"secret","tls_mode":"strict"}`, addr.Port)
resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body))
if err != nil {
t.Fatalf("post collect probe failed: %v", err)
@@ -53,9 +66,6 @@ func TestCollectProbe(t *testing.T) {
if payload.HostPowerState != "Off" {
t.Fatalf("expected host power state Off, got %q", payload.HostPowerState)
}
if !payload.PowerControlAvailable {
t.Fatalf("expected power control to be available")
}
}
func TestCollectLifecycleToTerminal(t *testing.T) {

View File

@@ -21,13 +21,16 @@ func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*coll
if strings.Contains(strings.ToLower(req.Host), "fail") {
return nil, context.DeadlineExceeded
}
hostPoweredOn := true
if strings.Contains(strings.ToLower(req.Host), "off") || strings.Contains(strings.ToLower(req.Username), "off") {
hostPoweredOn = false
}
return &collector.ProbeResult{
Reachable: true,
Protocol: c.protocol,
HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")],
HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"),
PowerControlAvailable: true,
SystemPath: "/redfish/v1/Systems/1",
Reachable: true,
Protocol: c.protocol,
HostPowerState: map[bool]string{true: "On", false: "Off"}[hostPoweredOn],
HostPoweredOn: hostPoweredOn,
SystemPath: "/redfish/v1/Systems/1",
}, nil
}

View File

@@ -19,18 +19,15 @@ 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"`
StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"`
DebugPayloads bool `json:"debug_payloads,omitempty"`
DebugPayloads bool `json:"debug_payloads,omitempty"`
}
type CollectProbeResponse struct {
Reachable bool `json:"reachable"`
Protocol string `json:"protocol,omitempty"`
HostPowerState string `json:"host_power_state,omitempty"`
HostPoweredOn bool `json:"host_powered_on"`
PowerControlAvailable bool `json:"power_control_available"`
Message string `json:"message,omitempty"`
HostPowerState string `json:"host_power_state,omitempty"`
HostPoweredOn bool `json:"host_powered_on"`
Message string `json:"message,omitempty"`
}
type CollectJobResponse struct {
@@ -78,7 +75,8 @@ type Job struct {
CreatedAt time.Time
UpdatedAt time.Time
RequestMeta CollectRequestMeta
cancel func()
cancel func()
skipFn func()
}
type CollectModuleStatus struct {

View File

@@ -243,6 +243,8 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
Source: "network_adapters",
Slot: nic.Slot,
Location: nic.Location,
BDF: nic.BDF,
DeviceClass: "NetworkController",
VendorID: nic.VendorID,
DeviceID: nic.DeviceID,
Model: nic.Model,
@@ -253,6 +255,11 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
PortCount: nic.PortCount,
PortType: nic.PortType,
MACAddresses: nic.MACAddresses,
LinkWidth: nic.LinkWidth,
LinkSpeed: nic.LinkSpeed,
MaxLinkWidth: nic.MaxLinkWidth,
MaxLinkSpeed: nic.MaxLinkSpeed,
NUMANode: nic.NUMANode,
Present: &present,
Status: nic.Status,
StatusCheckedAt: nic.StatusCheckedAt,

View File

@@ -122,6 +122,41 @@ func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T
}
}
func TestBuildHardwareDevices_NetworkAdapterPreservesPCIeMetadata(t *testing.T) {
hw := &models.HardwareConfig{
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "1",
Location: "OCP",
Present: true,
BDF: "0000:27:00.0",
Model: "ConnectX-6 Lx",
VendorID: 0x15b3,
DeviceID: 0x101f,
SerialNumber: "NIC-001",
Firmware: "26.39.2048",
MACAddresses: []string{"44:1A:4C:16:E8:03", "44:1A:4C:16:E8:04"},
LinkWidth: 16,
LinkSpeed: "32 GT/s",
NUMANode: 1,
Status: "ok",
},
},
}
devices := BuildHardwareDevices(hw)
for _, d := range devices {
if d.Kind != models.DeviceKindNetwork {
continue
}
if d.BDF != "0000:27:00.0" || d.LinkWidth != 16 || d.LinkSpeed != "32 GT/s" || d.NUMANode != 1 {
t.Fatalf("expected network PCIe metadata to be preserved, got %+v", d)
}
return
}
t.Fatal("expected network device in canonical inventory")
}
func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{
@@ -223,6 +258,31 @@ func TestBuildHardwareDevices_SkipsFirmwareOnlyNumericSlots(t *testing.T) {
}
}
func TestBuildHardwareDevices_NetworkDevicesUseUnifiedControllerClass(t *testing.T) {
hw := &models.HardwareConfig{
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "NIC1",
Model: "Ethernet Adapter",
Vendor: "Intel",
Present: true,
},
},
}
devices := BuildHardwareDevices(hw)
for _, d := range devices {
if d.Kind != models.DeviceKindNetwork {
continue
}
if d.DeviceClass != "NetworkController" {
t.Fatalf("expected unified network controller class, got %+v", d)
}
return
}
t.Fatalf("expected one canonical network device")
}
func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
srv := &Server{}
srv.SetResult(&models.AnalysisResult{

View File

@@ -18,6 +18,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
@@ -1674,34 +1675,28 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
message := "Связь с BMC установлена"
if result != nil {
switch {
case !result.HostPoweredOn && result.PowerControlAvailable:
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором."
case !result.HostPoweredOn:
message = "Связь с BMC установлена, host выключен."
default:
message = "Связь с BMC установлена, host включен."
if result.HostPoweredOn {
message = "Связь с BMC установлена, host включён."
} else {
message = "Связь с BMC установлена, host выключен. Данные инвентаря могут быть неполными."
}
}
hostPowerState := ""
hostPoweredOn := false
powerControlAvailable := false
reachable := false
if result != nil {
reachable = result.Reachable
hostPowerState = strings.TrimSpace(result.HostPowerState)
hostPoweredOn = result.HostPoweredOn
powerControlAvailable = result.PowerControlAvailable
}
jsonResponse(w, CollectProbeResponse{
Reachable: reachable,
Protocol: req.Protocol,
HostPowerState: hostPowerState,
HostPoweredOn: hostPoweredOn,
PowerControlAvailable: powerControlAvailable,
Message: message,
Reachable: reachable,
Protocol: req.Protocol,
HostPowerState: hostPowerState,
HostPoweredOn: hostPoweredOn,
Message: message,
})
}
@@ -1737,6 +1732,22 @@ func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, job.toStatusResponse())
}
func (s *Server) handleCollectSkip(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(r.PathValue("id"))
if !isValidCollectJobID(jobID) {
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
return
}
job, ok := s.jobManager.SkipJob(jobID)
if !ok {
jsonError(w, "Collect job not found", http.StatusNotFound)
return
}
jsonResponse(w, job.toStatusResponse())
}
func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
ctx, cancel := context.WithCancel(context.Background())
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
@@ -1744,6 +1755,11 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
return
}
skipCh := make(chan struct{})
var skipOnce sync.Once
skipFn := func() { skipOnce.Do(func() { close(skipCh) }) }
s.jobManager.AttachJobSkip(jobID, skipFn)
go func() {
connector, ok := s.getCollector(req.Protocol)
if !ok {
@@ -1811,7 +1827,9 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
}
}
result, err := connector.Collect(ctx, toCollectorRequest(req), emitProgress)
collectorReq := toCollectorRequest(req)
collectorReq.SkipHungCh = skipCh
result, err := connector.Collect(ctx, collectorReq, emitProgress)
if err != nil {
if ctx.Err() != nil {
return
@@ -2035,9 +2053,7 @@ func toCollectorRequest(req CollectRequest) collector.Request {
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
StopHostAfterCollect: req.StopHostAfterCollect,
DebugPayloads: req.DebugPayloads,
DebugPayloads: req.DebugPayloads,
}
}

View File

@@ -175,6 +175,43 @@ func (m *JobManager) UpdateJobDebugInfo(id string, info *CollectDebugInfo) (*Job
return cloned, true
}
func (m *JobManager) AttachJobSkip(id string, skipFn func()) bool {
m.mu.Lock()
defer m.mu.Unlock()
job, ok := m.jobs[id]
if !ok || job == nil || isTerminalCollectStatus(job.Status) {
return false
}
job.skipFn = skipFn
return true
}
func (m *JobManager) SkipJob(id string) (*Job, bool) {
m.mu.Lock()
job, ok := m.jobs[id]
if !ok || job == nil {
m.mu.Unlock()
return nil, false
}
if isTerminalCollectStatus(job.Status) {
cloned := cloneJob(job)
m.mu.Unlock()
return cloned, true
}
skipFn := job.skipFn
job.skipFn = nil
job.UpdatedAt = time.Now().UTC()
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Пропуск зависших запросов по команде пользователя"))
cloned := cloneJob(job)
m.mu.Unlock()
if skipFn != nil {
skipFn()
}
return cloned, true
}
func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool {
m.mu.Lock()
defer m.mu.Unlock()
@@ -229,5 +266,6 @@ func cloneJob(job *Job) *Job {
cloned.CurrentPhase = job.CurrentPhase
cloned.ETASeconds = job.ETASeconds
cloned.cancel = nil
cloned.skipFn = nil
return &cloned
}

View File

@@ -99,6 +99,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
s.mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
}
func (s *Server) Run() error {

View File

@@ -24,6 +24,7 @@ func newFlowTestServer() (*Server, *httptest.Server) {
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
return s, httptest.NewServer(mux)
}

View File

@@ -211,8 +211,6 @@ main {
}
#api-connect-btn,
#api-power-on-collect-btn,
#api-collect-off-btn,
#convert-folder-btn,
#convert-run-btn,
#cancel-job-btn,
@@ -229,8 +227,6 @@ main {
}
#api-connect-btn:hover,
#api-power-on-collect-btn:hover,
#api-collect-off-btn:hover,
#convert-folder-btn:hover,
#convert-run-btn:hover,
#cancel-job-btn:hover,
@@ -241,8 +237,6 @@ main {
#convert-run-btn:disabled,
#convert-folder-btn:disabled,
#api-connect-btn:disabled,
#api-power-on-collect-btn:disabled,
#api-collect-off-btn:disabled,
#cancel-job-btn:disabled,
.upload-area button:disabled {
opacity: 0.6;
@@ -311,64 +305,19 @@ main {
border-top: 1px solid #e2e8f0;
}
.api-confirm-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
.api-host-off-warning {
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;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-size: 0.875rem;
color: #92400e;
font-weight: 500;
}
.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;
@@ -445,6 +394,33 @@ main {
cursor: default;
}
.job-status-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
#skip-hung-btn {
background: #f59e0b;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.5rem 0.9rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
}
#skip-hung-btn:hover {
background: #d97706;
}
#skip-hung-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.job-status-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));

View File

@@ -91,9 +91,9 @@ function initApiSource() {
}
const cancelJobButton = document.getElementById('cancel-job-btn');
const skipHungButton = document.getElementById('skip-hung-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) => {
@@ -110,6 +110,11 @@ function initApiSource() {
cancelCollectionJob();
});
}
if (skipHungButton) {
skipHungButton.addEventListener('click', () => {
skipHungCollectionJob();
});
}
if (connectButton) {
connectButton.addEventListener('click', () => {
startApiProbe();
@@ -120,22 +125,6 @@ function initApiSource() {
startCollectionWithOptions();
});
}
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; }
);
}
});
}
fieldNames.forEach((fieldName) => {
const field = apiForm.elements.namedItem(fieldName);
if (!field) {
@@ -163,36 +152,6 @@ 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();
@@ -255,11 +214,7 @@ function startCollectionWithOptions() {
return;
}
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);
}
@@ -268,8 +223,6 @@ function renderApiProbeState() {
const connectButton = document.getElementById('api-connect-btn');
const probeOptions = document.getElementById('api-probe-options');
const status = document.getElementById('api-connect-status');
const powerOnCheckbox = document.getElementById('api-power-on');
const powerOffCheckbox = document.getElementById('api-power-off');
if (!connectButton || !probeOptions || !status) {
return;
}
@@ -283,7 +236,6 @@ function renderApiProbeState() {
}
const hostOn = apiProbeResult.host_powered_on;
const powerControlAvailable = apiProbeResult.power_control_available;
if (hostOn) {
status.textContent = apiProbeResult.message || 'Связь с BMC есть, host включён.';
@@ -295,25 +247,15 @@ function renderApiProbeState() {
probeOptions.classList.remove('hidden');
// "Включить" checkbox
if (powerOnCheckbox) {
const hostOffWarning = document.getElementById('api-host-off-warning');
if (hostOffWarning) {
if (hostOn) {
// Host already on — checkbox is checked and disabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = true;
hostOffWarning.classList.add('hidden');
} else {
// Host off — default: checked (will power on), enabled
powerOnCheckbox.checked = true;
powerOnCheckbox.disabled = !powerControlAvailable;
hostOffWarning.classList.remove('hidden');
}
}
// "Выключить" checkbox — default: unchecked
if (powerOffCheckbox) {
powerOffCheckbox.checked = false;
powerOffCheckbox.disabled = !powerControlAvailable;
}
connectButton.textContent = 'Переподключиться';
}
@@ -535,6 +477,36 @@ function pollCollectionJobStatus() {
});
}
function skipHungCollectionJob() {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
return;
}
const btn = document.getElementById('skip-hung-btn');
if (btn) {
btn.disabled = true;
btn.textContent = 'Пропуск...';
}
fetch(`/api/collect/${encodeURIComponent(collectionJob.id)}/skip`, {
method: 'POST'
})
.then(async (response) => {
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.error || 'Не удалось пропустить зависшие запросы');
}
syncServerLogs(body.logs);
renderCollectionJob();
})
.catch((err) => {
appendJobLog(`Ошибка пропуска: ${err.message}`);
if (btn) {
btn.disabled = false;
btn.textContent = 'Пропустить зависшие';
}
renderCollectionJob();
});
}
function cancelCollectionJob() {
if (!collectionJob || isCollectionJobTerminal(collectionJob.status)) {
return;
@@ -671,6 +643,19 @@ function renderCollectionJob() {
)).join('');
cancelButton.disabled = isTerminal;
const skipBtn = document.getElementById('skip-hung-btn');
if (skipBtn) {
const isCollecting = !isTerminal && collectionJob.status === 'running';
if (isCollecting) {
skipBtn.classList.remove('hidden');
} else {
skipBtn.classList.add('hidden');
skipBtn.disabled = false;
skipBtn.textContent = 'Пропустить зависшие';
}
}
setApiFormBlocked(!isTerminal);
}

View File

@@ -80,18 +80,12 @@
</div>
<div id="api-connect-status" class="api-connect-status"></div>
<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>
<div id="api-host-off-warning" class="api-host-off-warning hidden">
&#9888; Host выключен — данные инвентаря могут быть неполными
</div>
<label class="api-form-checkbox" for="api-debug-payloads">
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
<span>Сбор расширенных метрик для отладки</span>
<span>Сбор расширенных данных для диагностики</span>
</label>
<div class="api-form-actions">
<button id="api-collect-btn" type="submit">Собрать</button>
@@ -102,7 +96,10 @@
<section id="api-job-status" class="job-status hidden" aria-live="polite">
<div class="job-status-header">
<h4>Статус задачи сбора</h4>
<button id="cancel-job-btn" type="button">Отменить</button>
<div class="job-status-actions">
<button id="skip-hung-btn" type="button" class="hidden" title="Прервать зависшие запросы и перейти к анализу собранных данных">Пропустить зависшие</button>
<button id="cancel-job-btn" type="button">Отменить</button>
</div>
</div>
<div class="job-status-meta">
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>