Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ce0251ce4 | ||
|
|
994d46f3b3 | ||
|
|
ee3e8a6e33 | ||
|
|
e2c81758b5 | ||
|
|
6b52a1876f | ||
|
|
3e3c48bc08 | ||
|
|
cd864c3d6c | ||
|
|
5128ac5303 | ||
|
|
53cda82c79 | ||
|
|
a18d8fe648 | ||
| 6ab0f4eb20 | |||
| 57de3ba6eb | |||
| 47ff1c3796 | |||
| 1c4a3b0c09 | |||
| 10c381c8ec | |||
| 440959483e | |||
|
|
f3836a34cc | ||
|
|
ba9a52a61a | ||
|
|
27373aa104 | ||
|
|
4f7b5b826a | ||
|
|
dfd64550cf | ||
|
|
9505303d1d | ||
|
|
f2c04cf0e8 | ||
|
|
ca457ac72b | ||
|
|
78d0e26fd0 | ||
|
|
88e4e8dd49 | ||
|
|
cf9cf5d0cf | ||
| aba7a54990 | |||
| 835df2676c | |||
| b86d51c921 | |||
|
|
a82fb227e5 | ||
| c9969fc3da | |||
| 89b6701f43 | |||
| b04877549a | |||
| 8ca173c99b | |||
| f19a3454fa | |||
|
|
becdca1d7e | ||
|
|
e10440ae32 | ||
| 5c2a21aff1 | |||
|
|
9df13327aa | ||
|
|
7e9af89c46 | ||
|
|
db74df9994 | ||
|
|
bb82387d48 |
2
bible
2
bible
Submodule bible updated: 52444350c1...1977730d93
@@ -34,6 +34,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
|
|||||||
- NVIDIA HGX Field Diagnostics
|
- NVIDIA HGX Field Diagnostics
|
||||||
- NVIDIA Bug Report
|
- NVIDIA Bug Report
|
||||||
- Unraid
|
- Unraid
|
||||||
|
- xFusion iBMC dump / file export
|
||||||
- XigmaNAS
|
- XigmaNAS
|
||||||
- Generic fallback parser
|
- Generic fallback parser
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ Responses:
|
|||||||
|
|
||||||
Optional request field:
|
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
|
- `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`
|
### `POST /api/collect/probe`
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ Request fields passed from the server:
|
|||||||
- credential field (`password` or token)
|
- credential field (`password` or token)
|
||||||
- `tls_mode`
|
- `tls_mode`
|
||||||
- optional `power_on_if_host_off`
|
- optional `power_on_if_host_off`
|
||||||
|
- optional `debug_payloads` for extended diagnostics
|
||||||
|
|
||||||
### Core rule
|
### Core rule
|
||||||
|
|
||||||
@@ -35,18 +36,38 @@ If the collector adds a fallback, probe, or normalization rule, replay must mirr
|
|||||||
|
|
||||||
### Preflight and host power
|
### Preflight and host power
|
||||||
|
|
||||||
- `Probe()` may be used before collection to verify API connectivity and current host `PowerState`
|
- `Probe()` is used before collection to verify API connectivity and report current host `PowerState`
|
||||||
- if the host is off and the user chose power-on, the collector may issue `ComputerSystem.Reset`
|
- if the host is off, the collector logs a warning and proceeds with collection; inventory data may
|
||||||
with `ResetType=On`
|
be incomplete when the host is powered off
|
||||||
- power-on attempts are bounded and logged
|
- power-on and power-off are not performed by the collector
|
||||||
- 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
|
### Skip hung requests
|
||||||
- if the collector powered on the host itself for collection, it must attempt to power it back off
|
|
||||||
after collection completes
|
Redfish collection uses a two-level context model:
|
||||||
- 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
|
- `ctx` — job lifetime context, cancelled only on explicit job cancel
|
||||||
- all power-control decisions and attempts must be visible in the collection log so they are
|
- `collectCtx` — collection phase context, derived from `ctx`; covers snapshot, prefetch, and plan-B
|
||||||
preserved in raw-export bundles
|
|
||||||
|
`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
|
### Discovery model
|
||||||
|
|
||||||
@@ -159,3 +180,10 @@ When changing collection logic:
|
|||||||
Status: mock scaffold only.
|
Status: mock scaffold only.
|
||||||
|
|
||||||
It remains registered for protocol completeness, but it is not a real collection path.
|
It remains registered for protocol completeness, but it is not a real collection path.
|
||||||
|
The project is Redfish-first for live collection:
|
||||||
|
- Redfish already covers the current product goals for inventory, sensors, and hardware event logs
|
||||||
|
- the live architecture depends on replayable `raw_payloads.redfish_tree`
|
||||||
|
- a generic IPMI collector would require a separate raw snapshot and replay contract
|
||||||
|
|
||||||
|
IPMI should be reconsidered only as a narrow fallback for real field cases where Redfish is
|
||||||
|
missing or unreliable for a specific capability such as SEL, FRU, or sensors.
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
|
|||||||
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
|
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
|
||||||
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
|
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
|
||||||
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
|
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
|
||||||
|
| `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
|
||||||
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
|
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
|
||||||
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
|
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
|
||||||
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
|
| `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 |
|
| `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory |
|
||||||
| `generic` | fallback | Low-confidence text fallback when nothing else matches |
|
| `generic` | fallback | Low-confidence text fallback when nothing else matches |
|
||||||
|
|
||||||
@@ -148,6 +150,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`)
|
### Generic text fallback (`generic`)
|
||||||
|
|
||||||
**Status:** Ready (v1.0.0).
|
**Status:** Ready (v1.0.0).
|
||||||
@@ -170,9 +195,11 @@ entire internal `zbb` schema.
|
|||||||
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
|
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
|
||||||
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
|
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
|
||||||
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
|
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
|
||||||
|
| Lenovo XCC mini-log | `lenovo_xcc` | Ready | ThinkSystem SR650 V3 XCC mini-log ZIP |
|
||||||
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
|
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
|
||||||
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
|
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
|
||||||
| Unraid | `unraid` | Ready | Unraid diagnostics archives |
|
| Unraid | `unraid` | Ready | Unraid diagnostics archives |
|
||||||
|
| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles |
|
||||||
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
|
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
|
||||||
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
|
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
|
||||||
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |
|
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
| `GET /api/export/csv` | CSV | Serial-number export |
|
| `GET /api/export/csv` | CSV | Serial-number export |
|
||||||
| `GET /api/export/json` | raw-export ZIP bundle | Reopen and re-analyze later |
|
| `GET /api/export/json` | raw-export ZIP bundle | Reopen and re-analyze later |
|
||||||
| `GET /api/export/reanimator` | JSON | Reanimator hardware payload |
|
| `GET /api/export/reanimator` | JSON | Reanimator hardware payload |
|
||||||
|
| `GET /chart/current?print=true` | HTML (auto-print) | Print/PDF version of the report — opens in new tab, calls `window.print()` |
|
||||||
| `POST /api/convert` | async ZIP artifact | Batch archive-to-Reanimator conversion |
|
| `POST /api/convert` | async ZIP artifact | Batch archive-to-Reanimator conversion |
|
||||||
|
|
||||||
## Raw export
|
## Raw export
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ Current behavior:
|
|||||||
7. Packages any already-present binaries from `bin/`
|
7. Packages any already-present binaries from `bin/`
|
||||||
8. Generates `SHA256SUMS.txt`
|
8. Generates `SHA256SUMS.txt`
|
||||||
|
|
||||||
|
Release tag format:
|
||||||
|
- project release tags use `vN.M`
|
||||||
|
- do not create `vN.M.P` tags for LOGPile releases
|
||||||
|
- release artifacts and `main.version` inherit the exact git tag string
|
||||||
|
|
||||||
Important limitation:
|
Important limitation:
|
||||||
- `scripts/release.sh` does not run `make build-all` for you
|
- `scripts/release.sh` does not run `make build-all` for you
|
||||||
- if you want Linux or additional macOS archives in the release directory, build them before running the script
|
- if you want Linux or additional macOS archives in the release directory, build them before running the script
|
||||||
|
|||||||
@@ -1045,3 +1045,156 @@ logical volumes.
|
|||||||
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
|
- 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`.
|
`LOM/NIC` or `SAS/SATA Storage Controller`.
|
||||||
- `part_number` remains available separately for model identity, without polluting the class field.
|
- `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-044 — LOGPile project release tags use `vN.M`
|
||||||
|
|
||||||
|
**Date:** 2026-04-13
|
||||||
|
**Context:** The repository accumulated release tags in `vN.M.P` form, while the shared module
|
||||||
|
versioning contract in `bible/rules/patterns/module-versioning/contract.md` standardizes version
|
||||||
|
shape as `N.M`. Release tooling reads the git tag verbatim into build metadata and release
|
||||||
|
artifacts, so inconsistent tag shape leaks directly into packaged versions.
|
||||||
|
**Decision:** Use `vN.M` for LOGPile project release tags going forward. Do not create new
|
||||||
|
`vN.M.P` tags for repository releases. Build metadata, release directory names, and release notes
|
||||||
|
continue to inherit the exact git tag string from `git describe --tags`.
|
||||||
|
**Consequences:**
|
||||||
|
- Future project releases have a two-component version string such as `v1.12`.
|
||||||
|
- Release artifacts and `--version` output stay aligned with the tag shape without extra mapping.
|
||||||
|
- Existing historical `vN.M.P` tags remain as-is unless explicitly rewritten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-045 — Generic live IPMI collector is deferred; Redfish remains the only production live path
|
||||||
|
|
||||||
|
**Date:** 2026-04-22
|
||||||
|
**Context:** Sprint issue `#12` proposed a generic IPMI collector for SEL/FRU/sensors. By this
|
||||||
|
point LOGPile already has a production Redfish pipeline with replayable raw snapshots, profile-
|
||||||
|
driven acquisition, and normalized event/sensor/inventory extraction. Redfish also already covers
|
||||||
|
the current product goals better than IPMI for live collection: richer inventory, structured
|
||||||
|
resource relationships, and vendor log access via `LogServices`, including SEL-style logs on many
|
||||||
|
implementations.
|
||||||
|
|
||||||
|
**Decision:** Do not build a generic live IPMI collector now. Keep `ipmi_mock.go` only as a
|
||||||
|
protocol placeholder in the registry and UI/API contract. Treat Redfish as the only production
|
||||||
|
live collection path. Revisit IPMI only if real field evidence shows that a specific target class
|
||||||
|
cannot provide required data over Redfish. If revisited, prefer a narrow fallback scope such as
|
||||||
|
`IPMI SEL fallback`, `IPMI FRU fallback`, or `IPMI sensor fallback` rather than a second full
|
||||||
|
collector architecture.
|
||||||
|
|
||||||
|
**Consequences:**
|
||||||
|
- Issue `#12` is closed as deferred/not planned, not as implemented.
|
||||||
|
- Live collection architecture stays centered on replayable `raw_payloads.redfish_tree`.
|
||||||
|
- The codebase avoids introducing a second generic live-ingest/replay contract for IPMI data.
|
||||||
|
- Future IPMI work must be justified by concrete Redfish gaps on real hardware, not by protocol
|
||||||
|
symmetry alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-046 — The web shell delegates report rendering to `internal/chart`
|
||||||
|
|
||||||
|
**Date:** 2026-04-22
|
||||||
|
**Context:** The frontend had two competing report paths: the embedded `internal/chart` viewer and
|
||||||
|
an older client-side renderer in `web/static/js/app.js` for config, firmware, sensors, serials,
|
||||||
|
events, and parse errors. That duplication left dead controls in the shell and made the report
|
||||||
|
source of truth ambiguous.
|
||||||
|
**Decision:** The `web/` frontend shell is responsible only for data intake, job control, and
|
||||||
|
top-level actions. The report itself must be rendered exclusively through `internal/chart`.
|
||||||
|
Do not keep parallel report sections, filters, or table renderers in shell JavaScript.
|
||||||
|
**Consequences:**
|
||||||
|
- The browser UI has a single report rendering path: `/chart/current` inside the embedded viewer.
|
||||||
|
- Report-level filtering or extra report sections must be implemented in `internal/chart`, not in
|
||||||
|
`web/static/js/app.js`.
|
||||||
|
- Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression.
|
||||||
|
|||||||
21
bible-local/BACKLOG.md
Normal file
21
bible-local/BACKLOG.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Backlog
|
||||||
|
|
||||||
|
## [sfp_modules] Поддержка per-port SFP/QSFP модулей в экспорте Reanimator
|
||||||
|
|
||||||
|
**Приоритет:** низкий (до выхода Reanimator v3.0, пока deprecated sfp_* скаляры ещё принимаются)
|
||||||
|
|
||||||
|
**Контекст:**
|
||||||
|
Reanimator Hardware Ingest Contract v2.11 вводит массив `pcie_devices[].sfp_modules[]` для передачи данных SFP/QSFP-модулей по портам. Старые скалярные поля (`sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma`) помечены deprecated и будут удалены в v3.0. Для многопортовых NIC (ConnectX-6 Dx, Intel X710 и подобных) текущая реализация теряет данные — коллектор берёт первое найденное значение и не знает о портах.
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
- Коллектор (`internal/collector/redfish.go`, `redfishPCIeDetailsWithSupplementalDocs`) собирает SFP как 5 скалярных `float64` на устройство через `redfishFirstNumericAcrossDocs`
|
||||||
|
- Внутренняя модель (`internal/models/models.go`, struct `PCIeDevice`) не имеет SFP-полей — всё хранится в `Details map[string]any`
|
||||||
|
- Конвертер (`internal/exporter/reanimator_converter.go`, строки 864–868) читает скаляры из `Details` и кладёт в deprecated поля `ReanimatorPCIe`
|
||||||
|
|
||||||
|
**Что нужно сделать:**
|
||||||
|
1. **Исследование** — проверить, отдают ли реальные Redfish-источники SFP-данные per-port и в каком виде (прежде чем менять модель)
|
||||||
|
2. **Коллектор** (`redfish.go`) — если Redfish отдаёт per-port данные, собирать их в массив с индексом порта
|
||||||
|
3. **Внутренняя модель** (`models.go`) — добавить `SFPModules []SFPModule` в `PCIeDevice`
|
||||||
|
4. **Экспорт** (`reanimator_models.go`, `reanimator_converter.go`) — добавить `ReanimatorSFPModule`, смапить `SFPModules` в `sfp_modules[]`; убрать deprecated скаляры
|
||||||
|
|
||||||
|
**Триггер для реализации:** анонс Reanimator v3.0 с удалением deprecated sfp_* полей.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
title: Hardware Ingest JSON Contract
|
title: Hardware Ingest JSON Contract
|
||||||
version: "2.7"
|
version: "2.11"
|
||||||
updated: "2026-03-15"
|
updated: "2026-06-19"
|
||||||
maintainer: Reanimator Core
|
maintainer: Reanimator Core
|
||||||
audience: external-integrators, ai-agents
|
audience: external-integrators, ai-agents
|
||||||
language: ru
|
language: ru
|
||||||
@@ -9,7 +9,7 @@ language: ru
|
|||||||
|
|
||||||
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
||||||
|
|
||||||
Версия: **2.7** · Дата: **2026-03-15**
|
Версия: **2.11** · Дата: **2026-06-19**
|
||||||
|
|
||||||
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
||||||
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
||||||
@@ -22,6 +22,10 @@ language: ru
|
|||||||
|
|
||||||
| Версия | Дата | Изменения |
|
| Версия | Дата | Изменения |
|
||||||
|--------|------|-----------|
|
|--------|------|-----------|
|
||||||
|
| 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]` с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` помечены как deprecated (принимаются, но `sfp_modules[]` имеет приоритет) |
|
||||||
|
| 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя |
|
||||||
|
| 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine |
|
||||||
|
| 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям |
|
||||||
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал |
|
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал |
|
||||||
| 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
|
| 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
|
||||||
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
|
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
|
||||||
@@ -132,7 +136,8 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"pcie_devices": [ ... ],
|
"pcie_devices": [ ... ],
|
||||||
"power_supplies": [ ... ],
|
"power_supplies": [ ... ],
|
||||||
"sensors": { ... },
|
"sensors": { ... },
|
||||||
"event_logs": [ ... ]
|
"event_logs": [ ... ],
|
||||||
|
"platform_config": { ... }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -343,6 +348,9 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
||||||
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
|
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
|
||||||
| `size_gb` | int | нет | Размер в ГБ |
|
| `size_gb` | int | нет | Размер в ГБ |
|
||||||
|
| `logical_block_size_bytes` | int64 | нет | Логический размер пользовательского блока данных, например `512` или `4096` |
|
||||||
|
| `physical_block_size_bytes` | int64 | нет | Физический размер блока, если известен, например `4096` |
|
||||||
|
| `metadata_bytes_per_block` | int64 | нет | Metadata / protection bytes на логический блок, например `0` или `8` |
|
||||||
| `temperature_c` | float | нет | Температура накопителя, °C (telemetry) |
|
| `temperature_c` | float | нет | Температура накопителя, °C (telemetry) |
|
||||||
| `power_on_hours` | int64 | нет | Время работы, часы |
|
| `power_on_hours` | int64 | нет | Время работы, часы |
|
||||||
| `power_cycles` | int64 | нет | Количество циклов питания |
|
| `power_cycles` | int64 | нет | Количество циклов питания |
|
||||||
@@ -363,6 +371,11 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
|
|
||||||
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
|
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
|
||||||
|
|
||||||
|
Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
|
||||||
|
- `logical_block_size_bytes = 512`
|
||||||
|
- `metadata_bytes_per_block = 8`
|
||||||
|
- `physical_block_size_bytes = 512` или `4096`, если известен физический размер блока
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"storage": [
|
"storage": [
|
||||||
{
|
{
|
||||||
@@ -370,6 +383,9 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"type": "NVMe",
|
"type": "NVMe",
|
||||||
"model": "INTEL SSDPF2KX076T1",
|
"model": "INTEL SSDPF2KX076T1",
|
||||||
"size_gb": 7680,
|
"size_gb": 7680,
|
||||||
|
"logical_block_size_bytes": 512,
|
||||||
|
"physical_block_size_bytes": 4096,
|
||||||
|
"metadata_bytes_per_block": 8,
|
||||||
"temperature_c": 38.5,
|
"temperature_c": 38.5,
|
||||||
"power_on_hours": 12450,
|
"power_on_hours": 12450,
|
||||||
"unsafe_shutdowns": 3,
|
"unsafe_shutdowns": 3,
|
||||||
@@ -407,11 +423,12 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
|
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
|
||||||
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
|
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
|
||||||
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
|
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
|
||||||
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C |
|
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C *(deprecated since 2.11)* |
|
||||||
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm |
|
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm *(deprecated since 2.11)* |
|
||||||
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm |
|
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm *(deprecated since 2.11)* |
|
||||||
| `sfp_voltage_v` | float | нет | Напряжение SFP, В |
|
| `sfp_voltage_v` | float | нет | Напряжение SFP, В *(deprecated since 2.11)* |
|
||||||
| `sfp_bias_ma` | float | нет | Bias current SFP, мА |
|
| `sfp_bias_ma` | float | нет | Bias current SFP, мА *(deprecated since 2.11)* |
|
||||||
|
| `sfp_modules` | array | нет | Установленные SFP/QSFP-модули по портам (см. sfp_modules[]) |
|
||||||
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
|
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
|
||||||
| `device_class` | string | нет | Класс устройства (см. список ниже) |
|
| `device_class` | string | нет | Класс устройства (см. список ниже) |
|
||||||
| `manufacturer` | string | нет | Производитель |
|
| `manufacturer` | string | нет | Производитель |
|
||||||
@@ -429,10 +446,43 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
`numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases.
|
`numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases.
|
||||||
Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента.
|
Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента.
|
||||||
|
|
||||||
|
**Deprecated поля sfp_\*:** Скалярные поля `sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma` продолжают приниматься, но помечены как deprecated since 2.11. Если в payload одновременно присутствуют `sfp_modules[]` и deprecated sfp_-скаляры — приоритет у `sfp_modules[]`, скаляры игнорируются. Deprecated поля будут удалены в версии 3.0.
|
||||||
|
|
||||||
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
|
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
|
||||||
|
|
||||||
`slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`.
|
`slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`.
|
||||||
|
|
||||||
|
#### pcie_devices[].sfp_modules[]
|
||||||
|
|
||||||
|
Необязательный массив установленных SFP/QSFP-модулей для данного PCIe-устройства. Один элемент — один порт. Используйте для многопортовых NIC (ConnectX-6 Dx, Intel X710, Mellanox HDR и др.).
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|
|------|-----|-------------|----------|
|
||||||
|
| `port` | int | **да** | Номер порта на NIC (0-based). Ключ дедупликации внутри устройства |
|
||||||
|
| `identifier` | string | нет | Тип модуля: `SFP`, `SFP+`, `SFP28`, `QSFP+`, `QSFP28`, `QSFP-DD`, `DAC` |
|
||||||
|
| `connector` | string | нет | Тип разъёма: `LC`, `MPO`, `RJ45`, `DAC`, `AOC`, `No separable connector` |
|
||||||
|
| `vendor` | string | нет | Производитель модуля из EEPROM |
|
||||||
|
| `part_number` | string | нет | Партномер из EEPROM |
|
||||||
|
| `serial_number` | string | нет | Серийный номер из EEPROM |
|
||||||
|
| `revision` | string | нет | Ревизия из EEPROM |
|
||||||
|
| `wavelength_nm` | int | нет | Длина волны, нм (0 для DAC/медных кабелей) |
|
||||||
|
| `transceiver_type` | string | нет | `10GBase-SR`, `10GBase-LR`, `25GBase-SR`, `100GBase-SR4`, `DAC`, … |
|
||||||
|
| `temperature_c` | float | нет | Температура модуля, °C (DOM telemetry) |
|
||||||
|
| `voltage_v` | float | нет | Напряжение питания, В (DOM telemetry) |
|
||||||
|
| `tx_power_dbm` | float | нет | TX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `rx_power_dbm` | float | нет | RX оптическая мощность, dBm (DOM telemetry) |
|
||||||
|
| `bias_ma` | float | нет | Bias current, мА (DOM telemetry) |
|
||||||
|
|
||||||
|
**Ключ дедупликации:** `(pcie_devices[].slot, sfp_modules[].port)`.
|
||||||
|
|
||||||
|
**Правила ingest:**
|
||||||
|
- При каждом импорте — полная замена `sfp_modules[]` для данного `pcie_devices[].slot` (upsert всего массива целиком).
|
||||||
|
- Если `sfp_modules` отсутствует или `null` — существующие данные SFP не трогать.
|
||||||
|
- Если `sfp_modules: []` (пустой массив) — трактовать как «модули не обнаружены», очистить сохранённые данные.
|
||||||
|
- Дубли по `port` внутри одного `pcie_devices[]` — невалидны, endpoint возвращает `400` с описанием поля.
|
||||||
|
- Модули без `serial_number` допустимы (многие DAC-кабели не имеют SN); сохраняются по ключу `(slot, port)`.
|
||||||
|
- Изменение `serial_number` или `part_number` модуля на порту создаёт событие `COMPONENT_CHANGED` для PCIe-устройства с описанием «SFP module replaced on port N».
|
||||||
|
|
||||||
**Значения `device_class`:**
|
**Значения `device_class`:**
|
||||||
|
|
||||||
| Значение | Назначение |
|
| Значение | Назначение |
|
||||||
@@ -457,16 +507,47 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
"numa_node": 0,
|
"numa_node": 0,
|
||||||
"temperature_c": 48.5,
|
"temperature_c": 48.5,
|
||||||
"power_w": 18.2,
|
"power_w": 18.2,
|
||||||
"sfp_temperature_c": 36.2,
|
|
||||||
"sfp_tx_power_dbm": -1.8,
|
|
||||||
"sfp_rx_power_dbm": -2.1,
|
|
||||||
"device_class": "EthernetController",
|
"device_class": "EthernetController",
|
||||||
"manufacturer": "Intel",
|
"manufacturer": "Mellanox",
|
||||||
"model": "X710 10GbE",
|
"model": "ConnectX-6 Dx",
|
||||||
"serial_number": "K65472-003",
|
"serial_number": "MT2012X12345",
|
||||||
"firmware": "9.20 0x8000d4ae",
|
"firmware": "22.35.2010",
|
||||||
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
||||||
"status": "OK"
|
"status": "OK",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09999",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 36.4,
|
||||||
|
"voltage_v": 3.29,
|
||||||
|
"tx_power_dbm": -1.8,
|
||||||
|
"rx_power_dbm": -2.1,
|
||||||
|
"bias_ma": 7.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"port": 1,
|
||||||
|
"identifier": "QSFP28",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Mellanox",
|
||||||
|
"part_number": "MFA1A00-C003",
|
||||||
|
"serial_number": "MT2124VS09998",
|
||||||
|
"revision": "A",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "100GBase-SR4",
|
||||||
|
"temperature_c": 35.9,
|
||||||
|
"voltage_v": 3.28,
|
||||||
|
"tx_power_dbm": -1.9,
|
||||||
|
"rx_power_dbm": -2.3,
|
||||||
|
"bias_ma": 7.1
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -592,7 +673,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора в рамках секции |
|
| `name` | string | **да** | Уникальное имя сенсора в рамках секции |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `rpm` | int | нет | Обороты, RPM |
|
| `rpm` | int | нет | Обороты, RPM |
|
||||||
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
|
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
|
||||||
|
|
||||||
@@ -601,7 +681,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `voltage_v` | float | нет | Напряжение, В |
|
| `voltage_v` | float | нет | Напряжение, В |
|
||||||
| `current_a` | float | нет | Ток, А |
|
| `current_a` | float | нет | Ток, А |
|
||||||
| `power_w` | float | нет | Мощность, Вт |
|
| `power_w` | float | нет | Мощность, Вт |
|
||||||
@@ -612,7 +691,6 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `celsius` | float | нет | Температура, °C |
|
| `celsius` | float | нет | Температура, °C |
|
||||||
| `threshold_warning_celsius` | float | нет | Порог Warning, °C |
|
| `threshold_warning_celsius` | float | нет | Порог Warning, °C |
|
||||||
| `threshold_critical_celsius` | float | нет | Порог Critical, °C |
|
| `threshold_critical_celsius` | float | нет | Порог Critical, °C |
|
||||||
@@ -623,29 +701,29 @@ PSU без `serial_number` игнорируется.
|
|||||||
| Поле | Тип | Обязательно | Описание |
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|------|-----|-------------|----------|
|
|------|-----|-------------|----------|
|
||||||
| `name` | string | **да** | Уникальное имя сенсора |
|
| `name` | string | **да** | Уникальное имя сенсора |
|
||||||
| `location` | string | нет | Физическое расположение |
|
|
||||||
| `value` | float | нет | Значение |
|
| `value` | float | нет | Значение |
|
||||||
| `unit` | string | нет | Единица измерения |
|
| `unit` | string | нет | Единица измерения |
|
||||||
| `status` | string | нет | Статус |
|
| `status` | string | нет | Статус |
|
||||||
|
|
||||||
**Правила sensors:**
|
**Правила sensors:**
|
||||||
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
|
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
|
||||||
|
- `location` для сенсоров передавать не нужно и не следует: в Reanimator location/slot используется только для проверки перемещения и установки компонентов, а не для last-known-value sensor ingest.
|
||||||
- Сенсоры без `name` игнорируются.
|
- Сенсоры без `name` игнорируются.
|
||||||
- При каждом импорте значения перезаписываются (upsert по ключу).
|
- При каждом импорте значения перезаписываются (upsert по ключу).
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"sensors": {
|
"sensors": {
|
||||||
"fans": [
|
"fans": [
|
||||||
{ "name": "FAN1", "location": "Front", "rpm": 4200, "status": "OK" },
|
{ "name": "FAN1", "rpm": 4200, "status": "OK" },
|
||||||
{ "name": "FAN_CPU0", "location": "CPU0", "rpm": 5600, "status": "OK" }
|
{ "name": "FAN_CPU0", "rpm": 5600, "status": "OK" }
|
||||||
],
|
],
|
||||||
"power": [
|
"power": [
|
||||||
{ "name": "12V Rail", "location": "Mainboard", "voltage_v": 12.06, "status": "OK" },
|
{ "name": "12V Rail", "voltage_v": 12.06, "status": "OK" },
|
||||||
{ "name": "PSU0 Input", "location": "PSU0", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
|
{ "name": "PSU0 Input", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
|
||||||
],
|
],
|
||||||
"temperatures": [
|
"temperatures": [
|
||||||
{ "name": "CPU0 Temp", "location": "CPU0", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
|
{ "name": "CPU0 Temp", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
|
||||||
{ "name": "Inlet Temp", "location": "Front", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
|
{ "name": "Inlet Temp", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
|
||||||
],
|
],
|
||||||
"other": [
|
"other": [
|
||||||
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
|
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
|
||||||
@@ -655,6 +733,31 @@ PSU без `serial_number` игнорируется.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Секция platform_config
|
||||||
|
|
||||||
|
Необязательный объект с произвольными ключами — настройки платформы как есть из источника (BIOS, Redfish, IPMI).
|
||||||
|
|
||||||
|
| Поле | Тип | Обязательно | Описание |
|
||||||
|
|------|-----|-------------|----------|
|
||||||
|
| `platform_config` | object | нет | Произвольный объект: ключи — строки, значения — строки, числа, булевы |
|
||||||
|
|
||||||
|
**Правила platform_config:**
|
||||||
|
- Содержимое объекта не валидируется: передавайте параметры как есть.
|
||||||
|
- При каждом импорте хранится latest-snapshot per machine; история изменений по каждому ключу накапливается отдельно.
|
||||||
|
- Если секция отсутствует или равна `null` — данные платформы не обновляются.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"platform_config": {
|
||||||
|
"SecureBoot": "Enabled",
|
||||||
|
"BiosVersion": "06.08.05",
|
||||||
|
"TpmEnabled": true,
|
||||||
|
"NumaEnabled": false,
|
||||||
|
"HyperThreading": "Enabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Обработка статусов компонентов
|
## Обработка статусов компонентов
|
||||||
|
|
||||||
| Статус | Поведение |
|
| Статус | Поведение |
|
||||||
@@ -756,7 +859,24 @@ PSU без `serial_number` игнорируется.
|
|||||||
"model": "X710 10GbE",
|
"model": "X710 10GbE",
|
||||||
"serial_number": "K65472-003",
|
"serial_number": "K65472-003",
|
||||||
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
|
||||||
"status": "OK"
|
"status": "OK",
|
||||||
|
"sfp_modules": [
|
||||||
|
{
|
||||||
|
"port": 0,
|
||||||
|
"identifier": "SFP+",
|
||||||
|
"connector": "LC",
|
||||||
|
"vendor": "Intel",
|
||||||
|
"part_number": "FTLX8574D3BCV-IT",
|
||||||
|
"serial_number": "FNS123456789",
|
||||||
|
"wavelength_nm": 850,
|
||||||
|
"transceiver_type": "10GBase-SR",
|
||||||
|
"temperature_c": 34.1,
|
||||||
|
"voltage_v": 3.30,
|
||||||
|
"tx_power_dbm": -2.5,
|
||||||
|
"rx_power_dbm": -3.0,
|
||||||
|
"bias_ma": 6.8
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"power_supplies": [
|
"power_supplies": [
|
||||||
@@ -787,6 +907,12 @@ PSU без `serial_number` игнорируется.
|
|||||||
"other": [
|
"other": [
|
||||||
{ "name": "System Humidity", "value": 38.5, "unit": "%" }
|
{ "name": "System Humidity", "value": 38.5, "unit": "%" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"platform_config": {
|
||||||
|
"SecureBoot": "Enabled",
|
||||||
|
"BiosVersion": "06.08.05",
|
||||||
|
"TpmEnabled": true,
|
||||||
|
"HyperThreading": "Enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
@@ -42,13 +43,14 @@ func main() {
|
|||||||
PreloadFile: *file,
|
PreloadFile: *file,
|
||||||
AppVersion: version,
|
AppVersion: version,
|
||||||
AppCommit: commit,
|
AppCommit: commit,
|
||||||
|
ChartVersion: detectChartVersion(),
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := server.New(cfg)
|
srv := server.New(cfg)
|
||||||
|
|
||||||
url := fmt.Sprintf("http://localhost:%d", *port)
|
url := fmt.Sprintf("http://localhost:%d", *port)
|
||||||
log.Printf("LOGPile starting on %s", url)
|
slog.Info("LOGPile starting", "url", url)
|
||||||
log.Printf("Registered parsers: %v", parser.ListParsers())
|
slog.Info("registered parsers", "parsers", parser.ListParsers())
|
||||||
|
|
||||||
// Open browser automatically
|
// Open browser automatically
|
||||||
if !*noBrowser {
|
if !*noBrowser {
|
||||||
@@ -59,7 +61,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := runServer(srv); err != nil {
|
if err := runServer(srv); err != nil {
|
||||||
log.Printf("FATAL: %v", err)
|
slog.Error("fatal error", "err", err)
|
||||||
maybeWaitForCrashInput(*holdOnCrash)
|
maybeWaitForCrashInput(*holdOnCrash)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -88,10 +90,19 @@ func openBrowser(url string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("Failed to open browser: %v", err)
|
slog.Warn("failed to open browser", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detectChartVersion() string {
|
||||||
|
cmd := exec.Command("git", "-C", "internal/chart", "describe", "--tags", "--always", "--dirty", "--abbrev=7")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
func maybeWaitForCrashInput(enabled bool) {
|
func maybeWaitForCrashInput(enabled bool) {
|
||||||
if !enabled || !isInteractiveConsole() {
|
if !enabled || !isInteractiveConsole() {
|
||||||
return
|
return
|
||||||
|
|||||||
Submodule internal/chart updated: 2fb01d30a6...8c80591531
@@ -19,9 +19,9 @@ func (c *IPMIMockConnector) Protocol() string {
|
|||||||
|
|
||||||
func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
|
func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
|
||||||
steps := []Progress{
|
steps := []Progress{
|
||||||
{Status: "running", Progress: 20, Message: "IPMI: подключение к BMC..."},
|
{Status: "running", Progress: 20, Message: "IPMI: connecting to BMC..."},
|
||||||
{Status: "running", Progress: 55, Message: "IPMI: чтение инвентаря..."},
|
{Status: "running", Progress: 55, Message: "IPMI: reading inventory..."},
|
||||||
{Status: "running", Progress: 85, Message: "IPMI: нормализация данных..."},
|
{Status: "running", Progress: 85, Message: "IPMI: normalizing data..."},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, step := range steps {
|
for _, step := range steps {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ package collector
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,15 +50,55 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, systemPath := range systemPaths {
|
for _, systemPath := range systemPaths {
|
||||||
collectFrom(joinPath(systemPath, "/LogServices"), isHardwareLogService)
|
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, systemPath, "LogServices") {
|
||||||
|
collectFrom(logServicesPath, isHardwareLogService)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Managers hold the IPMI SEL on AMI/MSI BMCs — include only the "SEL" service.
|
// Managers hold the IPMI SEL on AMI/MSI BMCs — include only the "SEL" service.
|
||||||
for _, managerPath := range managerPaths {
|
for _, managerPath := range managerPaths {
|
||||||
collectFrom(joinPath(managerPath, "/LogServices"), isManagerSELService)
|
for _, logServicesPath := range c.redfishLinkedCollectionPaths(ctx, client, req, baseURL, managerPath, "LogServices") {
|
||||||
|
collectFrom(logServicesPath, isManagerSELService)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
log.Printf("redfish: collected %d hardware log entries (Systems+Managers SEL, window=7d)", len(out))
|
slog.Info("redfish: collected hardware log entries", "count", len(out), "window", "7d")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *RedfishConnector) redfishLinkedCollectionPaths(
|
||||||
|
ctx context.Context,
|
||||||
|
client *http.Client,
|
||||||
|
req Request,
|
||||||
|
baseURL, resourcePath, linkKey string,
|
||||||
|
) []string {
|
||||||
|
resourcePath = normalizeRedfishPath(resourcePath)
|
||||||
|
if resourcePath == "" || strings.TrimSpace(linkKey) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, 2)
|
||||||
|
var out []string
|
||||||
|
add := func(path string) {
|
||||||
|
path = normalizeRedfishPath(path)
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[path]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
out = append(out, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(joinPath(resourcePath, "/"+strings.TrimSpace(linkKey)))
|
||||||
|
|
||||||
|
resourceDoc, err := c.getJSON(ctx, client, req, baseURL, resourcePath)
|
||||||
|
if err == nil {
|
||||||
|
if linked := redfishLinkedPath(resourceDoc, linkKey); linked != "" {
|
||||||
|
add(linked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -182,7 +222,7 @@ func redfishLogServiceEntriesPath(svc map[string]interface{}) string {
|
|||||||
// Audit, authentication, and session events are excluded.
|
// Audit, authentication, and session events are excluded.
|
||||||
func isHardwareLogEntry(entry map[string]interface{}) bool {
|
func isHardwareLogEntry(entry map[string]interface{}) bool {
|
||||||
entryType := strings.TrimSpace(asString(entry["EntryType"]))
|
entryType := strings.TrimSpace(asString(entry["EntryType"]))
|
||||||
if strings.EqualFold(entryType, "Oem") {
|
if strings.EqualFold(entryType, "Oem") && !strings.EqualFold(strings.TrimSpace(asString(entry["OemRecordFormat"])), "Lenovo") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +402,9 @@ func parseIPMIDumpKV(message string) map[string]string {
|
|||||||
// AMI/MSI BMCs often set Severity="OK" on all SEL records regardless of content,
|
// AMI/MSI BMCs often set Severity="OK" on all SEL records regardless of content,
|
||||||
// so we fall back to inferring severity from SensorType when the explicit field is unhelpful.
|
// so we fall back to inferring severity from SensorType when the explicit field is unhelpful.
|
||||||
func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
|
func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
|
||||||
|
if redfishLogEntryLooksLikeWarning(entry) {
|
||||||
|
return models.SeverityWarning
|
||||||
|
}
|
||||||
// Newer Redfish uses MessageSeverity; older uses Severity.
|
// Newer Redfish uses MessageSeverity; older uses Severity.
|
||||||
raw := strings.ToLower(firstNonEmpty(
|
raw := strings.ToLower(firstNonEmpty(
|
||||||
strings.TrimSpace(asString(entry["MessageSeverity"])),
|
strings.TrimSpace(asString(entry["MessageSeverity"])),
|
||||||
@@ -380,6 +423,16 @@ func redfishLogEntrySeverity(entry map[string]interface{}) models.Severity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func redfishLogEntryLooksLikeWarning(entry map[string]interface{}) bool {
|
||||||
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{
|
||||||
|
asString(entry["Message"]),
|
||||||
|
asString(entry["Name"]),
|
||||||
|
asString(entry["SensorType"]),
|
||||||
|
asString(entry["EntryCode"]),
|
||||||
|
}, " ")))
|
||||||
|
return strings.Contains(joined, "unqualified dimm")
|
||||||
|
}
|
||||||
|
|
||||||
// redfishSeverityFromSensorType infers event severity from the IPMI/Redfish SensorType string.
|
// redfishSeverityFromSensorType infers event severity from the IPMI/Redfish SensorType string.
|
||||||
func redfishSeverityFromSensorType(sensorType string) models.Severity {
|
func redfishSeverityFromSensorType(sensorType string) models.Severity {
|
||||||
switch strings.ToLower(sensorType) {
|
switch strings.ToLower(sensorType) {
|
||||||
|
|||||||
125
internal/collector/redfish_logentries_test.go
Normal file
125
internal/collector/redfish_logentries_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectRedfishLogEntries_UsesLinkedManagerLogServicesPath(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
register := func(path string, payload interface{}) {
|
||||||
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
register("/redfish/v1/Managers/1", map[string]interface{}{
|
||||||
|
"Id": "1",
|
||||||
|
"LogServices": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1/LogServices",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/LogServices", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/LogServices/SEL", map[string]interface{}{
|
||||||
|
"Id": "SEL",
|
||||||
|
"Entries": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/LogServices/SEL/Entries", map[string]interface{}{
|
||||||
|
"Members": []map[string]string{
|
||||||
|
{"@odata.id": "/redfish/v1/Systems/1/LogServices/SEL/Entries/1"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
register("/redfish/v1/Systems/1/LogServices/SEL/Entries/1", map[string]interface{}{
|
||||||
|
"Id": "1",
|
||||||
|
"Created": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
"Message": "System found Unqualified DIMM in slot DIMM A1",
|
||||||
|
"MessageSeverity": "OK",
|
||||||
|
"SensorType": "Memory",
|
||||||
|
"EntryType": "Event",
|
||||||
|
})
|
||||||
|
|
||||||
|
ts := httptest.NewServer(mux)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := NewRedfishConnector()
|
||||||
|
got := c.collectRedfishLogEntries(context.Background(), ts.Client(), Request{
|
||||||
|
Host: ts.URL,
|
||||||
|
Port: 443,
|
||||||
|
Protocol: "redfish",
|
||||||
|
Username: "admin",
|
||||||
|
AuthType: "password",
|
||||||
|
Password: "secret",
|
||||||
|
TLSMode: "strict",
|
||||||
|
}, ts.URL, nil, []string{"/redfish/v1/Managers/1"})
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 collected log entry, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0]["Message"] != "System found Unqualified DIMM in slot DIMM A1" {
|
||||||
|
t.Fatalf("unexpected collected message: %#v", got[0]["Message"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRedfishLogEntries_UnqualifiedDIMMBecomesWarning(t *testing.T) {
|
||||||
|
rawPayloads := map[string]any{
|
||||||
|
"redfish_log_entries": []any{
|
||||||
|
map[string]any{
|
||||||
|
"Id": "sel-1",
|
||||||
|
"Created": "2026-04-13T12:00:00Z",
|
||||||
|
"Message": "System found Unqualified DIMM in slot DIMM A1",
|
||||||
|
"MessageSeverity": "OK",
|
||||||
|
"SensorType": "Memory",
|
||||||
|
"EntryType": "Event",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 event, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Severity != models.SeverityWarning {
|
||||||
|
t.Fatalf("expected warning severity, got %q", events[0].Severity)
|
||||||
|
}
|
||||||
|
if events[0].Description != "System found Unqualified DIMM in slot DIMM A1" {
|
||||||
|
t.Fatalf("unexpected description: %q", events[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRedfishLogEntries_LenovoOEMEntryIsKept(t *testing.T) {
|
||||||
|
rawPayloads := map[string]any{
|
||||||
|
"redfish_log_entries": []any{
|
||||||
|
map[string]any{
|
||||||
|
"Id": "plat-55",
|
||||||
|
"Created": "2026-04-13T12:00:00Z",
|
||||||
|
"Message": "DIMM A1 is unqualified",
|
||||||
|
"MessageSeverity": "Warning",
|
||||||
|
"SensorType": "Memory",
|
||||||
|
"EntryType": "Oem",
|
||||||
|
"OemRecordFormat": "Lenovo",
|
||||||
|
"EntryCode": "Assert",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events := parseRedfishLogEntries(rawPayloads, time.Date(2026, 4, 13, 12, 30, 0, 0, time.UTC))
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 Lenovo OEM event, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Severity != models.SeverityWarning {
|
||||||
|
t.Fatalf("expected warning severity, got %q", events[0].Severity)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/collector/redfish_planb_test.go
Normal file
57
internal/collector/redfish_planb_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package collector
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,7 +32,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
|||||||
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
|
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
|
||||||
}
|
}
|
||||||
if _, err := r.getJSON("/redfish/v1"); err != nil {
|
if _, err := r.getJSON("/redfish/v1"); err != nil {
|
||||||
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
|
slog.Warn("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1")
|
||||||
@@ -219,7 +219,7 @@ func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time {
|
|||||||
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
|
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
|
||||||
if ts, err := time.Parse(layout, raw); err == nil {
|
if ts, err := time.Parse(layout, raw); err == nil {
|
||||||
t := ts.UTC()
|
t := ts.UTC()
|
||||||
log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339))
|
slog.Info("redfish replay: inventory last modified", "at", t.Format(time.RFC3339), "source", "InventoryData/Status.LastModifiedTime")
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1244,15 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
|||||||
}
|
}
|
||||||
return out
|
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 pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
|
||||||
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
|
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
|
||||||
@@ -1256,6 +1265,33 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
|||||||
return nil
|
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{} {
|
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
|
||||||
if len(doc) == 0 || len(keys) == 0 {
|
if len(doc) == 0 || len(keys) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.Ne
|
|||||||
// the real NIC that came from Chassis/NetworkAdapters (e.g. "RISER 5
|
// 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
|
// slot 1 (7)"). Try to find the real NIC via the Links.NetworkAdapter
|
||||||
// cross-reference before creating a ghost entry.
|
// 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
|
idx = linkedIdx
|
||||||
ok = true
|
ok = true
|
||||||
}
|
}
|
||||||
@@ -75,13 +75,25 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, doc := range adapterDocs {
|
for _, doc := range adapterDocs {
|
||||||
nic := parseNIC(doc)
|
nics = append(nics, r.buildNICFromAdapterDoc(doc))
|
||||||
for _, pciePath := range networkAdapterPCIeDevicePaths(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)
|
pcieDoc, err := r.getJSON(pciePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
||||||
|
for _, adapterFnDoc := range adapterFunctionDocs {
|
||||||
|
functionDocs = append(functionDocs, r.getLinkedPCIeFunctions(adapterFnDoc)...)
|
||||||
|
}
|
||||||
|
functionDocs = dedupeJSONDocsByPath(functionDocs)
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||||
@@ -89,12 +101,25 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
|
|||||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
||||||
}
|
}
|
||||||
if len(nic.MACAddresses) == 0 {
|
if len(nic.MACAddresses) == 0 {
|
||||||
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc)
|
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, adapterDoc)
|
||||||
}
|
}
|
||||||
nics = append(nics, nic)
|
return nic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r redfishSnapshotReader) getNetworkAdapterFunctionDocs(adapterDoc map[string]interface{}) []map[string]interface{} {
|
||||||
|
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return dedupeNetworkAdapters(nics)
|
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 {
|
func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []string) []models.PCIeDevice {
|
||||||
@@ -116,13 +141,16 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
if looksLikeGPU(doc, functionDocs) {
|
if looksLikeGPU(doc, functionDocs) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if replayPCIeDeviceBackedByCanonicalNIC(doc, functionDocs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||||
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
|
supplementalDocs = append(supplementalDocs, r.getChassisScopedPCIeSupplementalDocs(doc)...)
|
||||||
for _, fn := range functionDocs {
|
for _, fn := range functionDocs {
|
||||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||||
}
|
}
|
||||||
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
|
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
|
||||||
if isUnidentifiablePCIeDevice(dev) {
|
if shouldSkipReplayPCIeDevice(doc, dev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, dev)
|
out = append(out, dev)
|
||||||
@@ -136,12 +164,134 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
|||||||
for idx, fn := range functionDocs {
|
for idx, fn := range functionDocs {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
|
||||||
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
|
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
|
||||||
|
if shouldSkipReplayPCIeDevice(fn, dev) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, dev)
|
out = append(out, dev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dedupePCIeDevices(out)
|
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") {
|
||||||
|
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{} {
|
func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} {
|
||||||
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
docPath := normalizeRedfishPath(asString(doc["@odata.id"]))
|
||||||
chassisPath := chassisPathForPCIeDoc(docPath)
|
chassisPath := chassisPathForPCIeDoc(docPath)
|
||||||
@@ -341,8 +491,9 @@ func redfishManagerInterfaceScore(summary map[string]any) int {
|
|||||||
|
|
||||||
// findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an
|
// findNICIndexByLinkedNetworkAdapter resolves a NetworkInterface document to an
|
||||||
// existing NIC in bySlot by following Links.NetworkAdapter → the Chassis
|
// existing NIC in bySlot by following Links.NetworkAdapter → the Chassis
|
||||||
// NetworkAdapter doc → its slot label. Returns -1 if no match is found.
|
// NetworkAdapter doc and reconstructing the canonical NIC identity. Returns -1
|
||||||
func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[string]interface{}, bySlot map[string]int) int {
|
// 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{})
|
links, ok := iface["Links"].(map[string]interface{})
|
||||||
if !ok {
|
if !ok {
|
||||||
return -1
|
return -1
|
||||||
@@ -359,15 +510,58 @@ func (r redfishSnapshotReader) findNICIndexByLinkedNetworkAdapter(iface map[stri
|
|||||||
if err != nil || len(adapterDoc) == 0 {
|
if err != nil || len(adapterDoc) == 0 {
|
||||||
return -1
|
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 slot := strings.ToLower(strings.TrimSpace(adapterNIC.Slot)); slot != "" {
|
||||||
if idx, ok := bySlot[slot]; ok {
|
if idx, ok := bySlot[slot]; ok {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for idx, nic := range existing {
|
||||||
|
if networkAdaptersShareMACs(nic, adapterNIC) {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
}
|
||||||
return -1
|
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
|
// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions
|
||||||
// collection linked from a NetworkAdapter document and populates the NIC's
|
// collection linked from a NetworkAdapter document and populates the NIC's
|
||||||
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.
|
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.
|
||||||
|
|||||||
@@ -265,9 +265,6 @@ func TestRedfishConnectorProbe(t *testing.T) {
|
|||||||
if got.HostPowerState != "Off" {
|
if got.HostPowerState != "Off" {
|
||||||
t.Fatalf("expected power state Off, got %q", got.HostPowerState)
|
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) {
|
func TestRedfishConnectorProbe_FallsBackToPowerSummary(t *testing.T) {
|
||||||
@@ -330,225 +327,6 @@ func TestRedfishConnectorProbe_FallsBackToPowerSummary(t *testing.T) {
|
|||||||
if got.HostPowerState != "On" {
|
if got.HostPowerState != "On" {
|
||||||
t.Fatalf("expected power state On, got %q", got.HostPowerState)
|
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) {
|
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) {
|
func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
|
||||||
nic := parseNIC(map[string]interface{}{
|
nic := parseNIC(map[string]interface{}{
|
||||||
"Id": "1",
|
"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) {
|
func TestParseNIC_DropsUnrealisticPortCount(t *testing.T) {
|
||||||
nic := parseNIC(map[string]interface{}{
|
nic := parseNIC(map[string]interface{}{
|
||||||
"Id": "1",
|
"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) {
|
func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) {
|
||||||
got := parseBoardInfo(map[string]interface{}{
|
got := parseBoardInfo(map[string]interface{}{
|
||||||
"Manufacturer": "NULL",
|
"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) {
|
func TestParseGPU_UsesNestedOemSerialNumber(t *testing.T) {
|
||||||
doc := map[string]interface{}{
|
doc := map[string]interface{}{
|
||||||
"Id": "GPU4",
|
"Id": "GPU4",
|
||||||
@@ -3527,8 +3865,11 @@ func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
|
|||||||
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
|
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
|
||||||
t.Fatalf("expected CPU metrics subresource to be crawlable")
|
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") {
|
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") {
|
if !shouldCrawlPath("/redfish/v1/Fabrics/HGX_NVLinkFabric_0/Switches/NVSwitch_0") {
|
||||||
t.Fatalf("expected NVSwitch fabric resource to be crawlable")
|
t.Fatalf("expected NVSwitch fabric resource to be crawlable")
|
||||||
|
|||||||
@@ -326,6 +326,95 @@ func TestBuildAnalysisDirectives_SupermicroEnablesStorageRecovery(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.T) {
|
||||||
|
match := MatchProfiles(MatchSignals{
|
||||||
|
SystemManufacturer: "Lenovo",
|
||||||
|
ChassisManufacturer: "Lenovo",
|
||||||
|
OEMNamespaces: []string{"Lenovo"},
|
||||||
|
})
|
||||||
|
if match.Mode != ModeMatched {
|
||||||
|
t.Fatalf("expected matched mode, got %q", match.Mode)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, profile := range match.Profiles {
|
||||||
|
if profile.Name() == "lenovo" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("expected lenovo profile to be selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the acquisition plan excludes noisy Lenovo-specific snapshot paths.
|
||||||
|
plan := BuildAcquisitionPlan(MatchSignals{
|
||||||
|
SystemManufacturer: "Lenovo",
|
||||||
|
ChassisManufacturer: "Lenovo",
|
||||||
|
OEMNamespaces: []string{"Lenovo"},
|
||||||
|
})
|
||||||
|
wantExcluded := []string{
|
||||||
|
"/Sensors/",
|
||||||
|
"/Oem/Lenovo/LEDs/",
|
||||||
|
"/Oem/Lenovo/Slots/",
|
||||||
|
"/Oem/Lenovo/Configuration",
|
||||||
|
"/NetworkProtocol/Oem/Lenovo/",
|
||||||
|
"/VirtualMedia/",
|
||||||
|
"/ThermalSubsystem/Fans/",
|
||||||
|
}
|
||||||
|
for _, want := range wantExcluded {
|
||||||
|
found := false
|
||||||
|
for _, ex := range plan.Tuning.SnapshotExcludeContains {
|
||||||
|
if ex == want {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected SnapshotExcludeContains to include %q, got %v", want, plan.Tuning.SnapshotExcludeContains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAcquisitionPlan_LenovoFiltersNonInventoryChassisBranches(t *testing.T) {
|
||||||
|
signals := MatchSignals{
|
||||||
|
SystemManufacturer: "Lenovo",
|
||||||
|
ChassisManufacturer: "Lenovo",
|
||||||
|
OEMNamespaces: []string{"Lenovo"},
|
||||||
|
ResourceHints: []string{
|
||||||
|
"/redfish/v1/Chassis/1/Power",
|
||||||
|
"/redfish/v1/Chassis/1/Thermal",
|
||||||
|
"/redfish/v1/Chassis/1/NetworkAdapters",
|
||||||
|
"/redfish/v1/Chassis/3",
|
||||||
|
"/redfish/v1/Chassis/IO_Board",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
match := MatchProfiles(signals)
|
||||||
|
plan := BuildAcquisitionPlan(signals)
|
||||||
|
resolved := ResolveAcquisitionPlan(match, plan, DiscoveredResources{
|
||||||
|
ChassisPaths: []string{
|
||||||
|
"/redfish/v1/Chassis/1",
|
||||||
|
"/redfish/v1/Chassis/3",
|
||||||
|
"/redfish/v1/Chassis/IO_Board",
|
||||||
|
},
|
||||||
|
}, signals)
|
||||||
|
|
||||||
|
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/1/Power") {
|
||||||
|
t.Fatal("expected primary Lenovo chassis power path to remain critical")
|
||||||
|
}
|
||||||
|
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3/Power") {
|
||||||
|
t.Fatal("did not expect non-inventory Lenovo backplane chassis power path")
|
||||||
|
}
|
||||||
|
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/IO_Board/Assembly") {
|
||||||
|
t.Fatal("did not expect IO board assembly path without inventory hints")
|
||||||
|
}
|
||||||
|
if containsString(resolved.Plan.PlanBPaths, "/redfish/v1/Chassis/3/Assembly") {
|
||||||
|
t.Fatal("did not expect non-inventory Lenovo chassis plan-b target")
|
||||||
|
}
|
||||||
|
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3") {
|
||||||
|
t.Fatal("expected chassis root to remain discoverable even when suffixes are filtered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) {
|
func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) {
|
||||||
signals := MatchSignals{
|
signals := MatchSignals{
|
||||||
SystemManufacturer: "Micro-Star International Co., Ltd.",
|
SystemManufacturer: "Micro-Star International Co., Ltd.",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func inspurGroupOEMPlatformsProfile() Profile {
|
|||||||
matchFn: func(s MatchSignals) int {
|
matchFn: func(s MatchSignals) int {
|
||||||
topologyScore := 0
|
topologyScore := 0
|
||||||
boardScore := 0
|
boardScore := 0
|
||||||
|
manufacturerScore := 0
|
||||||
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
|
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
|
||||||
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
|
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
|
||||||
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
|
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
|
||||||
@@ -62,10 +63,17 @@ func inspurGroupOEMPlatformsProfile() Profile {
|
|||||||
if anySignalContains(s, "GetServerAllUSBStatus") {
|
if anySignalContains(s, "GetServerAllUSBStatus") {
|
||||||
boardScore += 8
|
boardScore += 8
|
||||||
}
|
}
|
||||||
if topologyScore == 0 || boardScore == 0 {
|
// Manufacturer alone is sufficient for standard Inspur servers (e.g. NF-series
|
||||||
|
// storage servers) that lack GPU/outboard-PCIe topology signals. Score 60 is
|
||||||
|
// the minimum to enter matched mode; topology+board can push it higher.
|
||||||
|
if containsFold(s.SystemManufacturer, "inspur") || containsFold(s.ChassisManufacturer, "inspur") {
|
||||||
|
manufacturerScore = 60
|
||||||
|
}
|
||||||
|
total := manufacturerScore + topologyScore + boardScore
|
||||||
|
if total < 60 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return min(topologyScore+boardScore, 100)
|
return min(total, 100)
|
||||||
},
|
},
|
||||||
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
|
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
|
||||||
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")
|
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")
|
||||||
|
|||||||
@@ -118,6 +118,52 @@ func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *tes
|
|||||||
assertProfileSelected(t, match, "inspur-group-oem-platforms")
|
assertProfileSelected(t, match, "inspur-group-oem-platforms")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCollectSignalsFromTree_InspurGroupOEMPlatformsMatchesViaManufacturer covers standard
|
||||||
|
// Inspur storage servers (e.g. NF5280M6) that have no outboard PCIe / GPU topology but
|
||||||
|
// do expose Manufacturer="Inspur" in their System document.
|
||||||
|
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsMatchesViaManufacturer(t *testing.T) {
|
||||||
|
// Minimal tree: no GPU cards, no OEM firmware hints — only System Manufacturer.
|
||||||
|
tree := map[string]interface{}{
|
||||||
|
"/redfish/v1": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1",
|
||||||
|
},
|
||||||
|
"/redfish/v1/Systems": map[string]interface{}{
|
||||||
|
"Members": []interface{}{
|
||||||
|
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Systems/1",
|
||||||
|
"Manufacturer": "Inspur",
|
||||||
|
"Model": "NF5280M6",
|
||||||
|
},
|
||||||
|
"/redfish/v1/Chassis": map[string]interface{}{
|
||||||
|
"Members": []interface{}{
|
||||||
|
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Chassis/1": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Chassis/1",
|
||||||
|
},
|
||||||
|
"/redfish/v1/Managers": map[string]interface{}{
|
||||||
|
"Members": []interface{}{
|
||||||
|
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/redfish/v1/Managers/1": map[string]interface{}{
|
||||||
|
"@odata.id": "/redfish/v1/Managers/1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
signals := CollectSignalsFromTree(tree)
|
||||||
|
match := MatchProfiles(signals)
|
||||||
|
|
||||||
|
if match.Mode != ModeMatched {
|
||||||
|
t.Fatalf("expected matched mode for Inspur NF-series, got %q (scores: %v)", match.Mode, match.Scores)
|
||||||
|
}
|
||||||
|
assertProfileSelected(t, match, "inspur-group-oem-platforms")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
|
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
|
||||||
examples := []string{
|
examples := []string{
|
||||||
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",
|
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",
|
||||||
|
|||||||
175
internal/collector/redfishprofile/profile_lenovo.go
Normal file
175
internal/collector/redfishprofile/profile_lenovo.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package redfishprofile
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func lenovoProfile() Profile {
|
||||||
|
return staticProfile{
|
||||||
|
name: "lenovo",
|
||||||
|
priority: 20,
|
||||||
|
safeForFallback: true,
|
||||||
|
matchFn: func(s MatchSignals) int {
|
||||||
|
score := 0
|
||||||
|
if containsFold(s.SystemManufacturer, "lenovo") ||
|
||||||
|
containsFold(s.ChassisManufacturer, "lenovo") {
|
||||||
|
score += 80
|
||||||
|
}
|
||||||
|
for _, ns := range s.OEMNamespaces {
|
||||||
|
if containsFold(ns, "lenovo") {
|
||||||
|
score += 30
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lenovo XClarity Controller (XCC) is the BMC product line.
|
||||||
|
if containsFold(s.ServiceRootProduct, "xclarity") ||
|
||||||
|
containsFold(s.ServiceRootProduct, "xcc") {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
return min(score, 100)
|
||||||
|
},
|
||||||
|
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
|
||||||
|
// Lenovo XCC BMC exposes Chassis/1/Sensors with hundreds of individual
|
||||||
|
// sensor member documents (e.g. Chassis/1/Sensors/101L1). These are
|
||||||
|
// not used by any LOGPile parser — thermal/power data is read from
|
||||||
|
// the aggregate Chassis/*/Thermal and Chassis/*/Power endpoints. On
|
||||||
|
// a real server they largely return errors, wasting many minutes.
|
||||||
|
// Lenovo OEM subtrees under Oem/Lenovo/LEDs and Oem/Lenovo/Slots also
|
||||||
|
// enumerate dozens of individual documents not relevant to inventory.
|
||||||
|
ensureSnapshotExcludeContains(plan,
|
||||||
|
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
|
||||||
|
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
|
||||||
|
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
|
||||||
|
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
|
||||||
|
"/Oem/Lenovo/History", // historical telemetry
|
||||||
|
"/Oem/Lenovo/Configuration", // BMC config service, not inventory
|
||||||
|
"/Oem/Lenovo/DateTimeService", // BMC time service config
|
||||||
|
"/Oem/Lenovo/GroupService", // XCC fleet/group management state
|
||||||
|
"/Oem/Lenovo/Recipients", // alert recipient config
|
||||||
|
"/Oem/Lenovo/RemoteControl", // remote-media/session management
|
||||||
|
"/Oem/Lenovo/RemoteMap", // remote-media mapping config
|
||||||
|
"/Oem/Lenovo/SecureKeyLifecycleService", // key lifecycle/cert config
|
||||||
|
"/Oem/Lenovo/ServerProfile", // profile export/import config
|
||||||
|
"/Oem/Lenovo/ServiceData", // support/service metadata
|
||||||
|
"/Oem/Lenovo/SsoCertificates", // SSO certificate config
|
||||||
|
"/Oem/Lenovo/SystemGuard", // snapshot/history service
|
||||||
|
"/Oem/Lenovo/Watchdogs", // watchdog config
|
||||||
|
"/Oem/Lenovo/ScheduledPower", // power scheduling config
|
||||||
|
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
|
||||||
|
"/NetworkProtocol/Oem/Lenovo/", // DNS/LDAP/SMTP/SNMP manager config
|
||||||
|
"/PortForwardingMap/", // network port forwarding config
|
||||||
|
"/VirtualMedia/", // virtual media inventory/config, not hardware
|
||||||
|
"/Boot/Certificates", // secure boot certificate stores, not inventory
|
||||||
|
"/ThermalSubsystem/Fans/", // per-fan member docs; replay uses aggregate Thermal only
|
||||||
|
)
|
||||||
|
// Lenovo XCC BMC is typically slow (p95 latency often 3-5s even under
|
||||||
|
// normal load). Set rate thresholds that don't over-throttle on the
|
||||||
|
// first few requests, and give the ETA estimator a realistic baseline.
|
||||||
|
ensureRatePolicy(plan, AcquisitionRatePolicy{
|
||||||
|
TargetP95LatencyMS: 2000,
|
||||||
|
ThrottleP95LatencyMS: 4000,
|
||||||
|
MinSnapshotWorkers: 2,
|
||||||
|
MinPrefetchWorkers: 1,
|
||||||
|
DisablePrefetchOnErrors: true,
|
||||||
|
})
|
||||||
|
ensureETABaseline(plan, AcquisitionETABaseline{
|
||||||
|
DiscoverySeconds: 15,
|
||||||
|
SnapshotSeconds: 120,
|
||||||
|
PrefetchSeconds: 30,
|
||||||
|
CriticalPlanBSeconds: 40,
|
||||||
|
ProfilePlanBSeconds: 20,
|
||||||
|
})
|
||||||
|
addPlanNote(plan, "lenovo xcc acquisition extensions enabled: noisy sensor/oem paths excluded from snapshot")
|
||||||
|
},
|
||||||
|
refineAcquisition: func(resolved *ResolvedAcquisitionPlan, discovered DiscoveredResources, signals MatchSignals) {
|
||||||
|
allowedChassis := lenovoAllowedInventoryChassis(discovered.ChassisPaths, signals.ResourceHints)
|
||||||
|
resolved.SeedPaths = filterLenovoChassisInventoryPaths(resolved.SeedPaths, allowedChassis)
|
||||||
|
resolved.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.CriticalPaths, allowedChassis)
|
||||||
|
resolved.Plan.SeedPaths = filterLenovoChassisInventoryPaths(resolved.Plan.SeedPaths, allowedChassis)
|
||||||
|
resolved.Plan.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.Plan.CriticalPaths, allowedChassis)
|
||||||
|
resolved.Plan.PlanBPaths = filterLenovoChassisInventoryPaths(resolved.Plan.PlanBPaths, allowedChassis)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lenovoAllowedInventoryChassis(chassisPaths, resourceHints []string) map[string]struct{} {
|
||||||
|
allowed := make(map[string]struct{}, len(chassisPaths))
|
||||||
|
for _, chassisPath := range chassisPaths {
|
||||||
|
normalized := normalizePath(chassisPath)
|
||||||
|
if normalized == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalized == "/redfish/v1/Chassis/1" {
|
||||||
|
allowed[normalized] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, hint := range resourceHints {
|
||||||
|
hint = normalizePath(hint)
|
||||||
|
if !strings.HasPrefix(hint, normalized+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lenovoHintLooksLikeChassisInventory(hint) {
|
||||||
|
allowed[normalized] = struct{}{}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
func lenovoHintLooksLikeChassisInventory(path string) bool {
|
||||||
|
for _, suffix := range []string{
|
||||||
|
"/Power",
|
||||||
|
"/PowerSubsystem",
|
||||||
|
"/PowerSubsystem/PowerSupplies",
|
||||||
|
"/Thermal",
|
||||||
|
"/ThresholdSensors",
|
||||||
|
"/DiscreteSensors",
|
||||||
|
"/SensorsList",
|
||||||
|
"/NetworkAdapters",
|
||||||
|
"/PCIeDevices",
|
||||||
|
"/Drives",
|
||||||
|
"/Assembly",
|
||||||
|
} {
|
||||||
|
if strings.HasSuffix(path, suffix) || strings.Contains(path, suffix+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterLenovoChassisInventoryPaths(paths []string, allowedChassis map[string]struct{}) []string {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
normalized := normalizePath(path)
|
||||||
|
chassis := lenovoPathChassisRoot(normalized)
|
||||||
|
if chassis == "" {
|
||||||
|
out = append(out, normalized)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalized == chassis {
|
||||||
|
out = append(out, normalized)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := allowedChassis[chassis]; ok {
|
||||||
|
out = append(out, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupeSorted(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lenovoPathChassisRoot(path string) string {
|
||||||
|
const prefix = "/redfish/v1/Chassis/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(path, prefix)
|
||||||
|
if rest == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||||
|
return prefix + rest[:idx]
|
||||||
|
}
|
||||||
|
return prefix + rest
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ func BuiltinProfiles() []Profile {
|
|||||||
supermicroProfile(),
|
supermicroProfile(),
|
||||||
dellProfile(),
|
dellProfile(),
|
||||||
hpeProfile(),
|
hpeProfile(),
|
||||||
|
lenovoProfile(),
|
||||||
inspurGroupOEMPlatformsProfile(),
|
inspurGroupOEMPlatformsProfile(),
|
||||||
hgxProfile(),
|
hgxProfile(),
|
||||||
xfusionProfile(),
|
xfusionProfile(),
|
||||||
@@ -226,6 +227,10 @@ func ensurePrefetchPolicy(plan *AcquisitionPlan, policy AcquisitionPrefetchPolic
|
|||||||
addPlanPaths(&plan.Tuning.PrefetchPolicy.ExcludeContains, policy.ExcludeContains...)
|
addPlanPaths(&plan.Tuning.PrefetchPolicy.ExcludeContains, policy.ExcludeContains...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureSnapshotExcludeContains(plan *AcquisitionPlan, patterns ...string) {
|
||||||
|
addPlanPaths(&plan.Tuning.SnapshotExcludeContains, patterns...)
|
||||||
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
if a < b {
|
if a < b {
|
||||||
return a
|
return a
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type AcquisitionScopedPathPolicy struct {
|
|||||||
type AcquisitionTuning struct {
|
type AcquisitionTuning struct {
|
||||||
SnapshotMaxDocuments int
|
SnapshotMaxDocuments int
|
||||||
SnapshotWorkers int
|
SnapshotWorkers int
|
||||||
|
SnapshotExcludeContains []string
|
||||||
PrefetchEnabled *bool
|
PrefetchEnabled *bool
|
||||||
PrefetchWorkers int
|
PrefetchWorkers int
|
||||||
NVMePostProbeEnabled *bool
|
NVMePostProbeEnabled *bool
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ type Request struct {
|
|||||||
Password string
|
Password string
|
||||||
Token string
|
Token string
|
||||||
TLSMode string
|
TLSMode string
|
||||||
PowerOnIfHostOff bool
|
|
||||||
StopHostAfterCollect bool
|
|
||||||
DebugPayloads bool
|
DebugPayloads bool
|
||||||
|
SkipHungCh <-chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
@@ -67,7 +66,6 @@ type ProbeResult struct {
|
|||||||
Protocol string
|
Protocol string
|
||||||
HostPowerState string
|
HostPowerState string
|
||||||
HostPoweredOn bool
|
HostPoweredOn bool
|
||||||
PowerControlAvailable bool
|
|
||||||
SystemPath string
|
SystemPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ func New(result *models.AnalysisResult) *Exporter {
|
|||||||
|
|
||||||
// ExportCSV exports serial numbers to CSV format
|
// ExportCSV exports serial numbers to CSV format
|
||||||
func (e *Exporter) ExportCSV(w io.Writer) error {
|
func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||||
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
writer := csv.NewWriter(w)
|
writer := csv.NewWriter(w)
|
||||||
|
writer.Comma = ';'
|
||||||
defer writer.Flush()
|
defer writer.Flush()
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
@@ -170,3 +174,42 @@ func firstNonEmptyString(values ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportLogsCSV writes all recognized events as a semicolon-delimited UTF-8 CSV readable in Excel.
|
||||||
|
func ExportLogsCSV(w io.Writer, result *models.AnalysisResult) error {
|
||||||
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writer := csv.NewWriter(w)
|
||||||
|
writer.Comma = ';'
|
||||||
|
defer writer.Flush()
|
||||||
|
|
||||||
|
if err := writer.Write([]string{"timestamp", "source", "severity", "sensor_type", "sensor_name", "event_type", "id", "description", "raw_data"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range result.Events {
|
||||||
|
ts := ""
|
||||||
|
if !e.Timestamp.IsZero() {
|
||||||
|
ts = e.Timestamp.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
if err := writer.Write([]string{
|
||||||
|
ts,
|
||||||
|
e.Source,
|
||||||
|
string(e.Severity),
|
||||||
|
e.SensorType,
|
||||||
|
e.SensorName,
|
||||||
|
e.EventType,
|
||||||
|
e.ID,
|
||||||
|
e.Description,
|
||||||
|
e.RawData,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) {
|
|||||||
t.Fatalf("ExportCSV failed: %v", err)
|
t.Fatalf("ExportCSV failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll()
|
b := buf.Bytes()
|
||||||
|
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||||
|
b = b[3:] // strip UTF-8 BOM
|
||||||
|
}
|
||||||
|
r := csv.NewReader(bytes.NewReader(b))
|
||||||
|
r.Comma = ';'
|
||||||
|
rows, err := r.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read csv: %v", err)
|
t.Fatalf("read csv: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
|
|||||||
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
|
||||||
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
|
||||||
Sensors: convertSensors(result.Sensors),
|
Sensors: convertSensors(result.Sensors),
|
||||||
|
BMCEventSummary: buildBMCEventSummary(result.Events, collectedAt),
|
||||||
EventLogs: convertEventLogs(result.Events, collectedAt),
|
EventLogs: convertEventLogs(result.Events, collectedAt),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -159,6 +160,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
}
|
}
|
||||||
for _, stor := range hw.Storage {
|
for _, stor := range hw.Storage {
|
||||||
present := stor.Present
|
present := stor.Present
|
||||||
|
storDetails := mergeDetailMaps(nil, stor.Details)
|
||||||
|
if stor.LogicalBlockSizeBytes != 0 {
|
||||||
|
storDetails = mergeDetailMaps(storDetails, map[string]any{"logical_block_size_bytes": stor.LogicalBlockSizeBytes})
|
||||||
|
}
|
||||||
|
if stor.PhysicalBlockSizeBytes != 0 {
|
||||||
|
storDetails = mergeDetailMaps(storDetails, map[string]any{"physical_block_size_bytes": stor.PhysicalBlockSizeBytes})
|
||||||
|
}
|
||||||
|
if stor.MetadataBytesPerBlock != 0 {
|
||||||
|
storDetails = mergeDetailMaps(storDetails, map[string]any{"metadata_bytes_per_block": stor.MetadataBytesPerBlock})
|
||||||
|
}
|
||||||
appendDevice(models.HardwareDevice{
|
appendDevice(models.HardwareDevice{
|
||||||
Kind: models.DeviceKindStorage,
|
Kind: models.DeviceKindStorage,
|
||||||
Slot: stor.Slot,
|
Slot: stor.Slot,
|
||||||
@@ -177,27 +188,41 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
StatusAtCollect: stor.StatusAtCollect,
|
StatusAtCollect: stor.StatusAtCollect,
|
||||||
StatusHistory: stor.StatusHistory,
|
StatusHistory: stor.StatusHistory,
|
||||||
ErrorDescription: stor.ErrorDescription,
|
ErrorDescription: stor.ErrorDescription,
|
||||||
Details: mergeDetailMaps(nil, stor.Details),
|
Details: storDetails,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, pcie := range hw.PCIeDevices {
|
for _, pcie := range hw.PCIeDevices {
|
||||||
// Use PartNumber as model when available; fall back to chip description.
|
// Priority: PartNumber (vendor P/N) > Model (product name) > Description (chip label).
|
||||||
// Description contains the chip/product name (e.g. "BCM57414 NetXtreme-E …")
|
pcieModel := firstNonEmptyString(pcie.PartNumber, pcie.Model, pcie.Description)
|
||||||
// while PartNumber is a part/product code. Prefer PartNumber when set.
|
|
||||||
pcieModel := pcie.PartNumber
|
|
||||||
if pcieModel == "" {
|
|
||||||
pcieModel = pcie.Description
|
|
||||||
}
|
|
||||||
details := mergeDetailMaps(nil, pcie.Details)
|
details := mergeDetailMaps(nil, pcie.Details)
|
||||||
pcieFirmware := stringFromDetailMap(details, "firmware")
|
// Firmware: prefer direct field, fall back to details, then NVSwitch lookup.
|
||||||
|
pcieFirmware := firstNonEmptyString(pcie.Firmware, stringFromDetailMap(details, "firmware"))
|
||||||
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
|
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
|
||||||
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
|
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
|
||||||
|
}
|
||||||
if pcieFirmware != "" {
|
if pcieFirmware != "" {
|
||||||
details = mergeDetailMaps(details, map[string]any{
|
details = mergeDetailMaps(details, map[string]any{"firmware": pcieFirmware})
|
||||||
"firmware": pcieFirmware,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// Telemetry fields: put into details so convertPCIeFromDevices can pick them up.
|
||||||
|
if pcie.TemperatureC != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"temperature_c": *pcie.TemperatureC})
|
||||||
}
|
}
|
||||||
|
if pcie.PowerW != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"power_w": *pcie.PowerW})
|
||||||
|
}
|
||||||
|
if pcie.ECCCorrectedTotal != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"ecc_corrected_total": *pcie.ECCCorrectedTotal})
|
||||||
|
}
|
||||||
|
if pcie.ECCUncorrectedTotal != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"ecc_uncorrected_total": *pcie.ECCUncorrectedTotal})
|
||||||
|
}
|
||||||
|
if pcie.HWSlowdown != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"hw_slowdown": *pcie.HWSlowdown})
|
||||||
|
}
|
||||||
|
if pcie.IOMMUGroup != nil {
|
||||||
|
details = mergeDetailMaps(details, map[string]any{"iommu_group": *pcie.IOMMUGroup})
|
||||||
|
}
|
||||||
|
present := pcie.Present
|
||||||
appendDevice(models.HardwareDevice{
|
appendDevice(models.HardwareDevice{
|
||||||
Kind: models.DeviceKindPCIe,
|
Kind: models.DeviceKindPCIe,
|
||||||
Slot: pcie.Slot,
|
Slot: pcie.Slot,
|
||||||
@@ -209,11 +234,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
PartNumber: pcie.PartNumber,
|
PartNumber: pcie.PartNumber,
|
||||||
Manufacturer: pcie.Manufacturer,
|
Manufacturer: pcie.Manufacturer,
|
||||||
SerialNumber: pcie.SerialNumber,
|
SerialNumber: pcie.SerialNumber,
|
||||||
|
MACAddresses: append([]string(nil), pcie.MACAddresses...),
|
||||||
LinkWidth: pcie.LinkWidth,
|
LinkWidth: pcie.LinkWidth,
|
||||||
LinkSpeed: pcie.LinkSpeed,
|
LinkSpeed: pcie.LinkSpeed,
|
||||||
MaxLinkWidth: pcie.MaxLinkWidth,
|
MaxLinkWidth: pcie.MaxLinkWidth,
|
||||||
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
||||||
NUMANode: pcie.NUMANode,
|
NUMANode: pcie.NUMANode,
|
||||||
|
Present: present,
|
||||||
Status: pcie.Status,
|
Status: pcie.Status,
|
||||||
StatusCheckedAt: pcie.StatusCheckedAt,
|
StatusCheckedAt: pcie.StatusCheckedAt,
|
||||||
StatusChangedAt: pcie.StatusChangedAt,
|
StatusChangedAt: pcie.StatusChangedAt,
|
||||||
@@ -747,6 +774,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
Firmware: d.Firmware,
|
Firmware: d.Firmware,
|
||||||
Interface: d.Interface,
|
Interface: d.Interface,
|
||||||
Present: &presentValue,
|
Present: &presentValue,
|
||||||
|
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
|
||||||
|
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
|
||||||
|
MetadataBytesPerBlock: int64FromDetailMap(d.Details, "metadata_bytes_per_block"),
|
||||||
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
|
||||||
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
|
||||||
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
|
||||||
@@ -818,6 +848,7 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string)
|
|||||||
VendorID: d.VendorID,
|
VendorID: d.VendorID,
|
||||||
DeviceID: d.DeviceID,
|
DeviceID: d.DeviceID,
|
||||||
NUMANode: d.NUMANode,
|
NUMANode: d.NUMANode,
|
||||||
|
IOMMUGroup: intPtrFromDetailMap(d.Details, "iommu_group"),
|
||||||
TemperatureC: temperatureC,
|
TemperatureC: temperatureC,
|
||||||
PowerW: powerW,
|
PowerW: powerW,
|
||||||
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
|
||||||
@@ -1204,7 +1235,7 @@ func normalizeEventLogSource(source string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(source)) {
|
switch strings.ToLower(strings.TrimSpace(source)) {
|
||||||
case "redfish":
|
case "redfish":
|
||||||
return "redfish"
|
return "redfish"
|
||||||
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
|
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
|
||||||
return "bmc"
|
return "bmc"
|
||||||
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
|
||||||
return "host"
|
return "host"
|
||||||
@@ -1961,7 +1992,10 @@ func pcieDedupKey(item ReanimatorPCIe) string {
|
|||||||
slot := strings.ToLower(strings.TrimSpace(item.Slot))
|
slot := strings.ToLower(strings.TrimSpace(item.Slot))
|
||||||
serial := strings.ToLower(strings.TrimSpace(item.SerialNumber))
|
serial := strings.ToLower(strings.TrimSpace(item.SerialNumber))
|
||||||
bdf := strings.ToLower(strings.TrimSpace(item.BDF))
|
bdf := strings.ToLower(strings.TrimSpace(item.BDF))
|
||||||
if slot != "" {
|
// Generic slot names (e.g. "PCIe Device" from HGX BMC) are not unique
|
||||||
|
// hardware positions — multiple distinct devices share the same name.
|
||||||
|
// Fall through to serial/BDF so they are not incorrectly collapsed.
|
||||||
|
if slot != "" && !isGenericPCIeSlotName(slot) {
|
||||||
return "slot:" + slot
|
return "slot:" + slot
|
||||||
}
|
}
|
||||||
if serial != "" {
|
if serial != "" {
|
||||||
@@ -1970,9 +2004,22 @@ func pcieDedupKey(item ReanimatorPCIe) string {
|
|||||||
if bdf != "" {
|
if bdf != "" {
|
||||||
return "bdf:" + bdf
|
return "bdf:" + bdf
|
||||||
}
|
}
|
||||||
|
if slot != "" {
|
||||||
|
return "slot:" + slot
|
||||||
|
}
|
||||||
return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model))
|
return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isGenericPCIeSlotName reports whether slot is a generic device-type label
|
||||||
|
// rather than a unique hardware position identifier.
|
||||||
|
func isGenericPCIeSlotName(slot string) bool {
|
||||||
|
switch slot {
|
||||||
|
case "pcie device", "pcie slot", "pcie":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func pcieQualityScore(item ReanimatorPCIe) int {
|
func pcieQualityScore(item ReanimatorPCIe) int {
|
||||||
score := 0
|
score := 0
|
||||||
if strings.TrimSpace(item.SerialNumber) != "" {
|
if strings.TrimSpace(item.SerialNumber) != "" {
|
||||||
@@ -2077,6 +2124,17 @@ func parseSocketFromSlot(slot string) int {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func intPtrFromDetailMap(details map[string]any, key string) *int {
|
||||||
|
if details == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := details[key]; !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := intFromDetailMap(details, key)
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
func intFromDetailMap(details map[string]any, key string) int {
|
func intFromDetailMap(details map[string]any, key string) int {
|
||||||
if details == nil {
|
if details == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -2246,10 +2304,8 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string {
|
|||||||
|
|
||||||
func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
|
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
|
||||||
case "", "network", "network controller", "networkcontroller":
|
case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller":
|
||||||
return "NetworkController"
|
return "NetworkController"
|
||||||
case "ethernet", "ethernet controller", "ethernetcontroller":
|
|
||||||
return "EthernetController"
|
|
||||||
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
|
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
|
||||||
return "FibreChannelController"
|
return "FibreChannelController"
|
||||||
case "display", "displaycontroller", "display controller", "vga":
|
case "display", "displaycontroller", "display controller", "vga":
|
||||||
@@ -2270,8 +2326,6 @@ func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
|
|||||||
func normalizeNetworkDeviceClass(portType, model, description string) string {
|
func normalizeNetworkDeviceClass(portType, model, description string) string {
|
||||||
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
|
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(joined, "ethernet"):
|
|
||||||
return "EthernetController"
|
|
||||||
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
|
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
|
||||||
return "FibreChannelController"
|
return "FibreChannelController"
|
||||||
default:
|
default:
|
||||||
@@ -2404,3 +2458,76 @@ func inferTargetHost(targetHost, filename string) string {
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildBMCEventSummary produces a summary table of Critical/Warning BMC events
|
||||||
|
// with their resolution status derived from Assert/Deassert pairs.
|
||||||
|
func buildBMCEventSummary(events []models.Event, collectedAt string) []ReanimatorBMCEventRow {
|
||||||
|
type assertKey struct {
|
||||||
|
id string
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
type eventPair struct {
|
||||||
|
assertEvent *models.Event
|
||||||
|
deassertEvent *models.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs := make(map[assertKey]*eventPair)
|
||||||
|
order := make([]assertKey, 0)
|
||||||
|
|
||||||
|
for i := range events {
|
||||||
|
e := &events[i]
|
||||||
|
if e.Severity != models.SeverityCritical && e.Severity != models.SeverityWarning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := assertKey{id: e.ID, desc: e.Description}
|
||||||
|
p, exists := pairs[key]
|
||||||
|
if !exists {
|
||||||
|
p = &eventPair{}
|
||||||
|
pairs[key] = p
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(e.EventType) {
|
||||||
|
case "deassert":
|
||||||
|
if p.deassertEvent == nil || e.Timestamp.After(p.deassertEvent.Timestamp) {
|
||||||
|
p.deassertEvent = e
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if p.assertEvent == nil || e.Timestamp.Before(p.assertEvent.Timestamp) {
|
||||||
|
p.assertEvent = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]ReanimatorBMCEventRow, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
p := pairs[key]
|
||||||
|
ref := p.assertEvent
|
||||||
|
if ref == nil {
|
||||||
|
ref = p.deassertEvent
|
||||||
|
}
|
||||||
|
if ref == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "Active"
|
||||||
|
resolvedAt := ""
|
||||||
|
if p.deassertEvent != nil {
|
||||||
|
status = "Resolved"
|
||||||
|
resolvedAt = formatEventLogTime(p.deassertEvent.Timestamp, collectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, ReanimatorBMCEventRow{
|
||||||
|
Severity: normalizeEventLogSeverity(ref.Severity),
|
||||||
|
Component: strings.ToUpper(strings.TrimSpace(ref.SensorType)),
|
||||||
|
MessageID: strings.TrimSpace(ref.ID),
|
||||||
|
Timestamp: formatEventLogTime(ref.Timestamp, collectedAt),
|
||||||
|
Description: strings.TrimSpace(ref.Description),
|
||||||
|
Status: status,
|
||||||
|
ResolvedAt: resolvedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|||||||
@@ -733,6 +733,42 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertPCIeDevices_PreservesAllGPUsWithGenericSlot(t *testing.T) {
|
||||||
|
// Supermicro HGX BMC reports all GPU PCIe devices with Name "PCIe Device" —
|
||||||
|
// a generic label that is not a unique hardware position. All 8 GPUs must
|
||||||
|
// be preserved; dedup by generic slot name must not collapse them into one.
|
||||||
|
gpus := make([]models.GPU, 8)
|
||||||
|
serials := []string{
|
||||||
|
"1654925165720", "1654925166160", "1654925165942", "1654925165271",
|
||||||
|
"1654925165719", "1654925165252", "1654925165304", "1654925165587",
|
||||||
|
}
|
||||||
|
for i, sn := range serials {
|
||||||
|
gpus[i] = models.GPU{
|
||||||
|
Slot: "PCIe Device",
|
||||||
|
Model: "B200 180GB HBM3e",
|
||||||
|
Manufacturer: "NVIDIA",
|
||||||
|
SerialNumber: sn,
|
||||||
|
PartNumber: "2901-886-A1",
|
||||||
|
Status: "OK",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{GPUs: gpus}
|
||||||
|
result := convertPCIeDevices(hw, "2026-04-13T10:00:00Z")
|
||||||
|
if len(result) != 8 {
|
||||||
|
t.Fatalf("expected 8 GPU entries (one per serial), got %d", len(result))
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, r := range result {
|
||||||
|
if seen[r.SerialNumber] {
|
||||||
|
t.Fatalf("duplicate serial %q in PCIe result", r.SerialNumber)
|
||||||
|
}
|
||||||
|
seen[r.SerialNumber] = true
|
||||||
|
if r.DeviceClass != "VideoController" {
|
||||||
|
t.Fatalf("expected VideoController device class, got %q", r.DeviceClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
|
func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
|
||||||
hw := &models.HardwareConfig{
|
hw := &models.HardwareConfig{
|
||||||
GPUs: []models.GPU{
|
GPUs: []models.GPU{
|
||||||
@@ -1733,6 +1769,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) {
|
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
|
||||||
input := &models.AnalysisResult{
|
input := &models.AnalysisResult{
|
||||||
Filename: "legacy-details.json",
|
Filename: "legacy-details.json",
|
||||||
|
|||||||
@@ -20,7 +20,20 @@ type ReanimatorHardware struct {
|
|||||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||||
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||||
|
BMCEventSummary []ReanimatorBMCEventRow `json:"bmc_event_summary,omitempty"`
|
||||||
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
|
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
|
||||||
|
PlatformConfig map[string]any `json:"platform_config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorBMCEventRow is one row in the BMC critical/warning event summary table.
|
||||||
|
type ReanimatorBMCEventRow struct {
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Component string `json:"component"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResolvedAt string `json:"resolved_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReanimatorBoard represents motherboard/server information
|
// ReanimatorBoard represents motherboard/server information
|
||||||
@@ -110,6 +123,9 @@ type ReanimatorStorage struct {
|
|||||||
Firmware string `json:"firmware,omitempty"`
|
Firmware string `json:"firmware,omitempty"`
|
||||||
Interface string `json:"interface,omitempty"`
|
Interface string `json:"interface,omitempty"`
|
||||||
Present *bool `json:"present,omitempty"`
|
Present *bool `json:"present,omitempty"`
|
||||||
|
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||||
|
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
|
||||||
|
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
|
||||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||||
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
||||||
PowerCycles int64 `json:"power_cycles,omitempty"`
|
PowerCycles int64 `json:"power_cycles,omitempty"`
|
||||||
@@ -139,6 +155,7 @@ type ReanimatorPCIe struct {
|
|||||||
VendorID int `json:"vendor_id,omitempty"`
|
VendorID int `json:"vendor_id,omitempty"`
|
||||||
DeviceID int `json:"device_id,omitempty"`
|
DeviceID int `json:"device_id,omitempty"`
|
||||||
NUMANode int `json:"numa_node,omitempty"`
|
NUMANode int `json:"numa_node,omitempty"`
|
||||||
|
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||||
PowerW float64 `json:"power_w,omitempty"`
|
PowerW float64 `json:"power_w,omitempty"`
|
||||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ type AnalysisResult struct {
|
|||||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||||
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status)
|
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status)
|
||||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||||
|
CollectionErrors []CollectionError `json:"collection_errors,omitempty"` // BMC-reported failures to collect specific sections
|
||||||
Events []Event `json:"events"`
|
Events []Event `json:"events"`
|
||||||
FRU []FRUInfo `json:"fru"`
|
FRU []FRUInfo `json:"fru"`
|
||||||
Sensors []SensorReading `json:"sensors"`
|
Sensors []SensorReading `json:"sensors"`
|
||||||
Hardware *HardwareConfig `json:"hardware"`
|
Hardware *HardwareConfig `json:"hardware"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CollectionError represents a BMC-reported failure to collect a specific data section.
|
||||||
|
// Populated by vendor parsers when the source explicitly returns an error response
|
||||||
|
// instead of structured data (e.g. {"error":"...","code":1458} in Inspur component.log).
|
||||||
|
type CollectionError struct {
|
||||||
|
Section string `json:"section"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Event represents a single log event
|
// Event represents a single log event
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -245,6 +255,9 @@ type Storage struct {
|
|||||||
Location string `json:"location,omitempty"` // Front/Rear
|
Location string `json:"location,omitempty"` // Front/Rear
|
||||||
BackplaneID int `json:"backplane_id,omitempty"`
|
BackplaneID int `json:"backplane_id,omitempty"`
|
||||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||||
|
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||||
|
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
|
||||||
|
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Details map[string]any `json:"details,omitempty"`
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
|
||||||
@@ -266,6 +279,7 @@ type StorageVolume struct {
|
|||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Bootable bool `json:"bootable,omitempty"`
|
Bootable bool `json:"bootable,omitempty"`
|
||||||
Encrypted bool `json:"encrypted,omitempty"`
|
Encrypted bool `json:"encrypted,omitempty"`
|
||||||
|
Drives []string `json:"drives,omitempty"` // member drive names/labels
|
||||||
}
|
}
|
||||||
|
|
||||||
// PCIeDevice represents a PCIe device
|
// PCIeDevice represents a PCIe device
|
||||||
@@ -277,6 +291,8 @@ type PCIeDevice struct {
|
|||||||
BDF string `json:"bdf"`
|
BDF string `json:"bdf"`
|
||||||
DeviceClass string `json:"device_class"`
|
DeviceClass string `json:"device_class"`
|
||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
LinkWidth int `json:"link_width"`
|
LinkWidth int `json:"link_width"`
|
||||||
LinkSpeed string `json:"link_speed"`
|
LinkSpeed string `json:"link_speed"`
|
||||||
MaxLinkWidth int `json:"max_link_width"`
|
MaxLinkWidth int `json:"max_link_width"`
|
||||||
@@ -285,8 +301,17 @@ type PCIeDevice struct {
|
|||||||
SerialNumber string `json:"serial_number,omitempty"`
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||||
|
Present *bool `json:"present,omitempty"`
|
||||||
|
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
|
||||||
|
// GPU telemetry fields (populated by bee audit for GPU devices)
|
||||||
|
TemperatureC *float64 `json:"temperature_c,omitempty"`
|
||||||
|
PowerW *float64 `json:"power_w,omitempty"`
|
||||||
|
ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"`
|
||||||
|
ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"`
|
||||||
|
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
|
||||||
|
|
||||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const maxSingleFileSize = 10 * 1024 * 1024
|
const maxSingleFileSize = 10 * 1024 * 1024
|
||||||
|
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
|
||||||
const maxZipArchiveSize = 50 * 1024 * 1024
|
const maxZipArchiveSize = 50 * 1024 * 1024
|
||||||
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
const maxGzipDecompressedSize = 50 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
var supportedArchiveExt = map[string]struct{}{
|
var supportedArchiveExt = map[string]struct{}{
|
||||||
".ahs": {},
|
".ahs": {},
|
||||||
".gz": {},
|
".gz": {},
|
||||||
@@ -47,7 +49,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".ahs":
|
case ".ahs":
|
||||||
return extractSingleFile(archivePath)
|
return extractSingleFileWithLimit(archivePath, maxSingleFileSizeLarge)
|
||||||
case ".gz", ".tgz":
|
case ".gz", ".tgz":
|
||||||
return extractTarGz(archivePath)
|
return extractTarGz(archivePath)
|
||||||
case ".tar", ".sds":
|
case ".tar", ".sds":
|
||||||
@@ -55,7 +57,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
|
|||||||
case ".zip":
|
case ".zip":
|
||||||
return extractZip(archivePath)
|
return extractZip(archivePath)
|
||||||
case ".txt", ".log":
|
case ".txt", ".log":
|
||||||
return extractSingleFile(archivePath)
|
return extractSingleFileWithLimit(archivePath, maxSingleFileSize)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
||||||
}
|
}
|
||||||
@@ -70,7 +72,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
|||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".ahs":
|
case ".ahs":
|
||||||
return extractSingleFileFromReader(r, filename)
|
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSizeLarge)
|
||||||
case ".gz", ".tgz":
|
case ".gz", ".tgz":
|
||||||
return extractTarGzFromReader(r, filename)
|
return extractTarGzFromReader(r, filename)
|
||||||
case ".tar", ".sds":
|
case ".tar", ".sds":
|
||||||
@@ -78,7 +80,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
|
|||||||
case ".zip":
|
case ".zip":
|
||||||
return extractZipFromReader(r)
|
return extractZipFromReader(r)
|
||||||
case ".txt", ".log":
|
case ".txt", ".log":
|
||||||
return extractSingleFileFromReader(r, filename)
|
return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSize)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
return nil, fmt.Errorf("unsupported archive format: %s", ext)
|
||||||
}
|
}
|
||||||
@@ -337,7 +339,7 @@ func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFile(path string) ([]ExtractedFile, error) {
|
func extractSingleFileWithLimit(path string, limit int64) ([]ExtractedFile, error) {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stat file: %w", err)
|
return nil, fmt.Errorf("stat file: %w", err)
|
||||||
@@ -348,7 +350,7 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
files, err := extractSingleFileFromReader(f, filepath.Base(path))
|
files, err := extractSingleFileFromReaderWithLimit(f, filepath.Base(path), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -358,14 +360,14 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
|
func extractSingleFileFromReaderWithLimit(r io.Reader, filename string, limit int64) ([]ExtractedFile, error) {
|
||||||
content, err := io.ReadAll(io.LimitReader(r, maxSingleFileSize+1))
|
content, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read file content: %w", err)
|
return nil, fmt.Errorf("read file content: %w", err)
|
||||||
}
|
}
|
||||||
truncated := len(content) > maxSingleFileSize
|
truncated := int64(len(content)) > limit
|
||||||
if truncated {
|
if truncated {
|
||||||
content = content[:maxSingleFileSize]
|
content = content[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
file := ExtractedFile{
|
file := ExtractedFile{
|
||||||
@@ -376,7 +378,7 @@ func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile,
|
|||||||
file.Truncated = true
|
file.Truncated = true
|
||||||
file.TruncatedMessage = fmt.Sprintf(
|
file.TruncatedMessage = fmt.Sprintf(
|
||||||
"file exceeded %d bytes and was truncated",
|
"file exceeded %d bytes and was truncated",
|
||||||
maxSingleFileSize,
|
limit,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
internal/parser/vendors/h3c/parser.go
vendored
54
internal/parser/vendors/h3c/parser.go
vendored
@@ -2867,9 +2867,9 @@ func parseKeyValueBlocks(content string) []map[string]string {
|
|||||||
|
|
||||||
func findCPUIndex(items []models.CPU, target models.CPU) int {
|
func findCPUIndex(items []models.CPU, target models.CPU) int {
|
||||||
targetSocket := target.Socket
|
targetSocket := target.Socket
|
||||||
targetPPIN := strings.ToLower(strings.TrimSpace(target.PPIN))
|
targetPPIN := strings.TrimSpace(target.PPIN)
|
||||||
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
targetSerial := strings.TrimSpace(target.SerialNumber)
|
||||||
targetModel := strings.ToLower(strings.TrimSpace(target.Model))
|
targetModel := strings.TrimSpace(target.Model)
|
||||||
|
|
||||||
for i := range items {
|
for i := range items {
|
||||||
cpu := items[i]
|
cpu := items[i]
|
||||||
@@ -2880,18 +2880,18 @@ func findCPUIndex(items []models.CPU, target models.CPU) int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ppin := strings.ToLower(strings.TrimSpace(cpu.PPIN))
|
ppin := strings.TrimSpace(cpu.PPIN)
|
||||||
if targetPPIN != "" && ppin != "" && targetPPIN == ppin {
|
if targetPPIN != "" && ppin != "" && strings.EqualFold(targetPPIN, ppin) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
serial := strings.ToLower(strings.TrimSpace(cpu.SerialNumber))
|
serial := strings.TrimSpace(cpu.SerialNumber)
|
||||||
if targetSerial != "" && serial != "" && targetSerial == serial {
|
if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
model := strings.ToLower(strings.TrimSpace(cpu.Model))
|
model := strings.TrimSpace(cpu.Model)
|
||||||
if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && model == targetModel {
|
if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && strings.EqualFold(model, targetModel) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2931,15 +2931,15 @@ func mergeCPU(dst *models.CPU, src models.CPU) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int {
|
func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int {
|
||||||
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
targetSerial := strings.TrimSpace(target.SerialNumber)
|
||||||
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
targetSlot := strings.TrimSpace(target.Slot)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
serial := strings.TrimSpace(items[i].SerialNumber)
|
||||||
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
slot := strings.TrimSpace(items[i].Slot)
|
||||||
if targetSerial != "" && serial != "" && targetSerial == serial {
|
if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2993,15 +2993,15 @@ func dedupeStorage(items []models.Storage) []models.Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findStorageIndex(items []models.Storage, target models.Storage) int {
|
func findStorageIndex(items []models.Storage, target models.Storage) int {
|
||||||
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
targetSerial := strings.TrimSpace(target.SerialNumber)
|
||||||
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
targetSlot := strings.TrimSpace(target.Slot)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
serial := strings.TrimSpace(items[i].SerialNumber)
|
||||||
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
slot := strings.TrimSpace(items[i].Slot)
|
||||||
if targetSerial != "" && serial != "" && targetSerial == serial {
|
if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3248,15 +3248,15 @@ func isPSUEmpty(p models.PSU) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func findPSUIndex(items []models.PSU, target models.PSU) int {
|
func findPSUIndex(items []models.PSU, target models.PSU) int {
|
||||||
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber))
|
targetSerial := strings.TrimSpace(target.SerialNumber)
|
||||||
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot))
|
targetSlot := strings.TrimSpace(target.Slot)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber))
|
serial := strings.TrimSpace(items[i].SerialNumber)
|
||||||
slot := strings.ToLower(strings.TrimSpace(items[i].Slot))
|
slot := strings.TrimSpace(items[i].Slot)
|
||||||
if targetSerial != "" && serial != "" && targetSerial == serial {
|
if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot {
|
if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
@@ -214,8 +214,10 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
|
|||||||
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
|
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
|
||||||
start := offset + ahsHeaderSize
|
start := offset + ahsHeaderSize
|
||||||
end := start + size
|
end := start + size
|
||||||
|
truncated := false
|
||||||
if size < 0 || end > len(data) {
|
if size < 0 || end > len(data) {
|
||||||
return nil, fmt.Errorf("invalid payload size for %q", name)
|
end = len(data)
|
||||||
|
truncated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := append([]byte(nil), data[start:end]...)
|
payload := append([]byte(nil), data[start:end]...)
|
||||||
@@ -235,6 +237,9 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
|
|||||||
Content: content,
|
Content: content,
|
||||||
Compressed: compressed,
|
Compressed: compressed,
|
||||||
})
|
})
|
||||||
|
if truncated {
|
||||||
|
break
|
||||||
|
}
|
||||||
offset = end
|
offset = end
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,7 +997,7 @@ func parseEvents(tokens []string) []models.Event {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if looksLikeEventMessage(tokens[j]) {
|
if looksLikeEventMessage(tokens[j]) {
|
||||||
message = tokens[j]
|
message = trimEventJunk(tokens[j])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1173,7 +1178,7 @@ func looksLikeServerModel(v string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
lower := strings.ToLower(v)
|
lower := strings.ToLower(v)
|
||||||
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline")
|
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") || strings.Contains(lower, "alletra")
|
||||||
}
|
}
|
||||||
|
|
||||||
func looksLikeCPUVendor(v string) bool {
|
func looksLikeCPUVendor(v string) bool {
|
||||||
@@ -1464,7 +1469,19 @@ func fabricIDFromPath(path string) string {
|
|||||||
func inferSeverity(message string) models.Severity {
|
func inferSeverity(message string) models.Severity {
|
||||||
lower := strings.ToLower(message)
|
lower := strings.ToLower(message)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"):
|
case strings.Contains(lower, "critical"):
|
||||||
|
return models.SeverityCritical
|
||||||
|
case strings.Contains(lower, " down"),
|
||||||
|
strings.Contains(lower, "warning"),
|
||||||
|
strings.Contains(lower, "fail"),
|
||||||
|
strings.Contains(lower, "error"),
|
||||||
|
strings.Contains(lower, "server reset"),
|
||||||
|
strings.Contains(lower, "server power"),
|
||||||
|
strings.Contains(lower, "power restored"),
|
||||||
|
strings.Contains(lower, "ilo reset"),
|
||||||
|
strings.Contains(lower, "ilo restarted"),
|
||||||
|
strings.Contains(lower, "pcr measurements"),
|
||||||
|
strings.Contains(lower, "hardware data received from uefi"):
|
||||||
return models.SeverityWarning
|
return models.SeverityWarning
|
||||||
default:
|
default:
|
||||||
return models.SeverityInfo
|
return models.SeverityInfo
|
||||||
@@ -1478,21 +1495,73 @@ func inferEventType(message string) string {
|
|||||||
return "Login"
|
return "Login"
|
||||||
case strings.Contains(lower, "logout"):
|
case strings.Contains(lower, "logout"):
|
||||||
return "Logout"
|
return "Logout"
|
||||||
case strings.Contains(lower, "network"):
|
case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
|
||||||
return "Network"
|
return "Network"
|
||||||
case strings.Contains(lower, "license"):
|
case strings.Contains(lower, "license"):
|
||||||
return "License"
|
return "License"
|
||||||
|
case strings.Contains(lower, "backup operation"), strings.Contains(lower, "remote console"):
|
||||||
|
return "Management"
|
||||||
|
case strings.Contains(lower, "server power"), strings.Contains(lower, "power restored"), strings.Contains(lower, "power off"), strings.Contains(lower, "server reset"), strings.Contains(lower, "ilo reset"), strings.Contains(lower, "ilo restarted"):
|
||||||
|
return "Power"
|
||||||
|
case strings.Contains(lower, "storage"), strings.Contains(lower, "volume"), strings.Contains(lower, "drive"), strings.Contains(lower, "firmware"):
|
||||||
|
return "Hardware"
|
||||||
|
case strings.Contains(lower, "certificate"), strings.Contains(lower, "pcr measurements"), strings.Contains(lower, "hardware data"), strings.Contains(lower, "security"):
|
||||||
|
return "Security"
|
||||||
default:
|
default:
|
||||||
return "Event"
|
return "Event"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trimEventJunk strips trailing single-byte frame markers written by iLO into
|
||||||
|
// binary .zbb log records. These markers are printable ASCII (letters, *, +, ')
|
||||||
|
// that appear immediately after the sentence-ending punctuation or a digit.
|
||||||
|
func trimEventJunk(s string) string {
|
||||||
|
if len(s) < 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
last := s[len(s)-1]
|
||||||
|
prev := s[len(s)-2]
|
||||||
|
isJunk := (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') ||
|
||||||
|
last == '*' || last == '+' || last == '\''
|
||||||
|
prevIsBoundary := prev == '.' || prev == '!' || prev == '"' || prev == ')' ||
|
||||||
|
(prev >= '0' && prev <= '9')
|
||||||
|
if isJunk && prevIsBoundary {
|
||||||
|
return s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func looksLikeEventMessage(v string) bool {
|
func looksLikeEventMessage(v string) bool {
|
||||||
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
|
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
// JSON document accidentally extracted — skip
|
||||||
|
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Numbered list items (e.g. "2.Perform the iLO reset.") are instructions, not events
|
||||||
|
if len(v) > 2 && v[0] >= '1' && v[0] <= '9' && v[1] == '.' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
lower := strings.ToLower(v)
|
lower := strings.ToLower(v)
|
||||||
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state")
|
return strings.Contains(lower, "login") ||
|
||||||
|
strings.Contains(lower, "logout") ||
|
||||||
|
strings.Contains(lower, "link") ||
|
||||||
|
strings.Contains(lower, "license") ||
|
||||||
|
strings.Contains(lower, "security state") ||
|
||||||
|
strings.Contains(lower, "server power") ||
|
||||||
|
strings.Contains(lower, "server reset") ||
|
||||||
|
strings.Contains(lower, "power restored") ||
|
||||||
|
strings.Contains(lower, "power off") ||
|
||||||
|
strings.Contains(lower, "storage") ||
|
||||||
|
strings.Contains(lower, "firmware") ||
|
||||||
|
strings.Contains(lower, "certificate") ||
|
||||||
|
strings.Contains(lower, "backup operation") ||
|
||||||
|
strings.Contains(lower, "pcr measurements") ||
|
||||||
|
strings.Contains(lower, "hardware data") ||
|
||||||
|
strings.Contains(lower, "ilo reset") ||
|
||||||
|
strings.Contains(lower, "ilo restarted") ||
|
||||||
|
strings.Contains(lower, "remote console")
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeModel(v string) string {
|
func sanitizeModel(v string) string {
|
||||||
|
|||||||
@@ -153,6 +153,29 @@ func TestParseAHSInventory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAHSTruncatedEntry(t *testing.T) {
|
||||||
|
p := &Parser{}
|
||||||
|
// Build archive where the last entry's declared size exceeds available data.
|
||||||
|
archive := makeAHSArchive(t, []ahsTestEntry{
|
||||||
|
{Name: "CUST_INFO.DAT", Payload: []byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421")},
|
||||||
|
{Name: "0000150-2025-11-27.zbb", Payload: []byte("some content")},
|
||||||
|
})
|
||||||
|
// Corrupt the size field of the second entry to exceed len(archive).
|
||||||
|
secondHeaderOffset := ahsHeaderSize + len([]byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421"))
|
||||||
|
binary.LittleEndian.PutUint32(archive[secondHeaderOffset+8:secondHeaderOffset+12], 0xFFFFFFFF)
|
||||||
|
|
||||||
|
result, err := p.Parse([]parser.ExtractedFile{{
|
||||||
|
Path: "HPE_CZ2D1X0GS3_20251127.ahs",
|
||||||
|
Content: archive,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected graceful handling of truncated entry, got error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected non-nil result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseExampleAHS(t *testing.T) {
|
func TestParseExampleAHS(t *testing.T) {
|
||||||
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
|
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
|
||||||
content, err := os.ReadFile(path)
|
content, err := os.ReadFile(path)
|
||||||
|
|||||||
5
internal/parser/vendors/inspur/asset.go
vendored
5
internal/parser/vendors/inspur/asset.go
vendored
@@ -117,7 +117,6 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse CPU info
|
// Parse CPU info
|
||||||
seenMicrocode := make(map[string]bool)
|
|
||||||
for i, cpu := range asset.CpuInfo {
|
for i, cpu := range asset.CpuInfo {
|
||||||
config.CPUs = append(config.CPUs, models.CPU{
|
config.CPUs = append(config.CPUs, models.CPU{
|
||||||
Socket: i,
|
Socket: i,
|
||||||
@@ -133,13 +132,11 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
|||||||
PPIN: cpu.PPIN,
|
PPIN: cpu.PPIN,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Add CPU microcode to firmware list (deduplicated)
|
if cpu.MicroCodeVer != "" {
|
||||||
if cpu.MicroCodeVer != "" && !seenMicrocode[cpu.MicroCodeVer] {
|
|
||||||
config.Firmware = append(config.Firmware, models.FirmwareInfo{
|
config.Firmware = append(config.Firmware, models.FirmwareInfo{
|
||||||
DeviceName: fmt.Sprintf("CPU%d Microcode", i),
|
DeviceName: fmt.Sprintf("CPU%d Microcode", i),
|
||||||
Version: cpu.MicroCodeVer,
|
Version: cpu.MicroCodeVer,
|
||||||
})
|
})
|
||||||
seenMicrocode[cpu.MicroCodeVer] = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
internal/parser/vendors/inspur/component.go
vendored
141
internal/parser/vendors/inspur/component.go
vendored
@@ -19,6 +19,11 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) {
|
|||||||
|
|
||||||
text := string(content)
|
text := string(content)
|
||||||
|
|
||||||
|
// Parse RESTful CPU info — fallback when asset.json is absent
|
||||||
|
if len(hw.CPUs) == 0 {
|
||||||
|
parseCPUInfo(text, hw)
|
||||||
|
}
|
||||||
|
|
||||||
// Parse RESTful Memory info (detailed memory data)
|
// Parse RESTful Memory info (detailed memory data)
|
||||||
parseMemoryInfo(text, hw)
|
parseMemoryInfo(text, hw)
|
||||||
|
|
||||||
@@ -51,6 +56,52 @@ func ParseComponentLogEvents(content []byte) []models.Event {
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseComponentLogCollectionErrors detects BMC-reported collection failures in component.log.
|
||||||
|
// When a RESTful section returns {"error":"...","code":N} instead of structured data,
|
||||||
|
// the BMC itself failed to collect that subsystem — the parser emits a CollectionError
|
||||||
|
// so the UI can surface it explicitly rather than showing an empty section.
|
||||||
|
func ParseComponentLogCollectionErrors(content []byte) []models.CollectionError {
|
||||||
|
type bmcErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of section name (for display) → regex that captures its JSON payload.
|
||||||
|
// Sections that return arrays use \[ ... \]; object sections use \{ ... \}.
|
||||||
|
// We only probe sections that are expected to have structured hardware data.
|
||||||
|
sections := []struct {
|
||||||
|
name string
|
||||||
|
re *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{"HDD", regexp.MustCompile(`RESTful HDD info:\s*(\{[^\n]*\})`)},
|
||||||
|
{"PCIe Devices", regexp.MustCompile(`RESTful PCIE Device info:\s*(\{[^\n]*\})`)},
|
||||||
|
{"Network Adapters", regexp.MustCompile(`RESTful Network Adapter info:\s*(\{[^\n]*\})`)},
|
||||||
|
{"Disk Backplane", regexp.MustCompile(`RESTful diskbackplane info:\s*(\{[^\n]*\})`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(content)
|
||||||
|
var out []models.CollectionError
|
||||||
|
for _, s := range sections {
|
||||||
|
m := s.re.FindStringSubmatch(text)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var errResp bmcErrorResponse
|
||||||
|
if err := json.Unmarshal([]byte(m[1]), &errResp); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(errResp.Error) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, models.CollectionError{
|
||||||
|
Section: s.name,
|
||||||
|
Message: errResp.Error,
|
||||||
|
Code: errResp.Code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ParseComponentLogSensors extracts sensor readings from component.log JSON sections.
|
// ParseComponentLogSensors extracts sensor readings from component.log JSON sections.
|
||||||
func ParseComponentLogSensors(content []byte) []models.SensorReading {
|
func ParseComponentLogSensors(content []byte) []models.SensorReading {
|
||||||
text := string(content)
|
text := string(content)
|
||||||
@@ -61,6 +112,68 @@ func ParseComponentLogSensors(content []byte) []models.SensorReading {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CPURESTInfo represents the RESTful CPU info structure in component.log
|
||||||
|
type CPURESTInfo struct {
|
||||||
|
Processors []struct {
|
||||||
|
ProcID int `json:"proc_id"`
|
||||||
|
CPUID string `json:"PROC_ID"` // uppercase key — prevents case-insensitive collision with proc_id
|
||||||
|
Manufacturer string `json:"Manufacturer"`
|
||||||
|
MaxSpeedMHz int `json:"MaxSpeedMHz"`
|
||||||
|
ConfigStatus int `json:"configStatus"`
|
||||||
|
ProcName string `json:"proc_name"`
|
||||||
|
ProcStatus int `json:"proc_status"`
|
||||||
|
ProcSpeed int `json:"proc_speed"`
|
||||||
|
CoreCount int `json:"proc_core_count"`
|
||||||
|
ThreadCount int `json:"proc_thread_count"`
|
||||||
|
TDP int `json:"proc_tdp"`
|
||||||
|
L1Cache int `json:"proc_l1cache_size"`
|
||||||
|
L2Cache int `json:"proc_l2cache_size"`
|
||||||
|
L3Cache int `json:"proc_l3cache_size"`
|
||||||
|
MicroCode string `json:"micro_code"`
|
||||||
|
PPIN string `json:"ppin"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"processors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUInfo(text string, hw *models.HardwareConfig) {
|
||||||
|
re := regexp.MustCompile(`RESTful CPU info:\s*(\{[\s\S]*?\})\s*RESTful Memory`)
|
||||||
|
match := re.FindStringSubmatch(text)
|
||||||
|
if match == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonStr := strings.ReplaceAll(match[1], "\n", "")
|
||||||
|
var cpuInfo CPURESTInfo
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &cpuInfo); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, proc := range cpuInfo.Processors {
|
||||||
|
if proc.ProcStatus != 1 && proc.ConfigStatus != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hw.CPUs = append(hw.CPUs, models.CPU{
|
||||||
|
Socket: proc.ProcID,
|
||||||
|
Model: strings.TrimSpace(proc.ProcName),
|
||||||
|
Cores: proc.CoreCount,
|
||||||
|
Threads: proc.ThreadCount,
|
||||||
|
FrequencyMHz: proc.ProcSpeed,
|
||||||
|
MaxFreqMHz: proc.MaxSpeedMHz,
|
||||||
|
L1CacheKB: proc.L1Cache,
|
||||||
|
L2CacheKB: proc.L2Cache,
|
||||||
|
L3CacheKB: proc.L3Cache,
|
||||||
|
TDP: proc.TDP,
|
||||||
|
PPIN: proc.PPIN,
|
||||||
|
})
|
||||||
|
if proc.MicroCode != "" {
|
||||||
|
hw.Firmware = append(hw.Firmware, models.FirmwareInfo{
|
||||||
|
DeviceName: fmt.Sprintf("CPU%d Microcode", proc.ProcID),
|
||||||
|
Version: proc.MicroCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryRESTInfo represents the RESTful Memory info structure
|
// MemoryRESTInfo represents the RESTful Memory info structure
|
||||||
type MemoryRESTInfo struct {
|
type MemoryRESTInfo struct {
|
||||||
MemModules []struct {
|
MemModules []struct {
|
||||||
@@ -114,7 +227,8 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
|||||||
item := models.MemoryDIMM{
|
item := models.MemoryDIMM{
|
||||||
Slot: mem.MemModSlot,
|
Slot: mem.MemModSlot,
|
||||||
Location: mem.MemModSlot,
|
Location: mem.MemModSlot,
|
||||||
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
|
// status=1 with a known serial/part is definitely present even if BMC reports size=0
|
||||||
|
Present: mem.MemModStatus == 1 && (mem.MemModSize > 0 || strings.TrimSpace(mem.MemModSerial) != "" || strings.TrimSpace(mem.MemModPartNum) != ""),
|
||||||
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
|
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
|
||||||
Type: mem.MemModType,
|
Type: mem.MemModType,
|
||||||
Technology: strings.TrimSpace(mem.MemModTechnology),
|
Technology: strings.TrimSpace(mem.MemModTechnology),
|
||||||
@@ -136,6 +250,25 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
|||||||
}
|
}
|
||||||
merged = append(merged, item)
|
merged = append(merged, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a present DIMM has size=0 (BMC firmware glitch), infer size from
|
||||||
|
// another present DIMM with the same part number in the same batch.
|
||||||
|
partSize := make(map[string]int)
|
||||||
|
for _, m := range merged {
|
||||||
|
if m.Present && m.SizeMB > 0 && strings.TrimSpace(m.PartNumber) != "" {
|
||||||
|
partSize[strings.TrimSpace(m.PartNumber)] = m.SizeMB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range merged {
|
||||||
|
if merged[i].Present && merged[i].SizeMB == 0 {
|
||||||
|
if pn := strings.TrimSpace(merged[i].PartNumber); pn != "" {
|
||||||
|
if sz, ok := partSize[pn]; ok {
|
||||||
|
merged[i].SizeMB = sz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hw.Memory = merged
|
hw.Memory = merged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +296,7 @@ type PSURESTInfo struct {
|
|||||||
|
|
||||||
func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
||||||
// Find RESTful PSU info section
|
// Find RESTful PSU info section
|
||||||
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
|
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
|
||||||
match := re.FindStringSubmatch(text)
|
match := re.FindStringSubmatch(text)
|
||||||
if match == nil {
|
if match == nil {
|
||||||
return
|
return
|
||||||
@@ -793,7 +926,7 @@ func parseDiskBackplaneSensors(text string) []models.SensorReading {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parsePSUSummarySensors(text string) []models.SensorReading {
|
func parsePSUSummarySensors(text string) []models.SensorReading {
|
||||||
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
|
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
|
||||||
match := re.FindStringSubmatch(text)
|
match := re.FindStringSubmatch(text)
|
||||||
if match == nil {
|
if match == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -941,7 +1074,7 @@ func extractComponentFirmware(text string, hw *models.HardwareConfig) {
|
|||||||
// Skip extracting from component.log to avoid duplicates
|
// Skip extracting from component.log to avoid duplicates
|
||||||
|
|
||||||
// Extract PSU firmware from RESTful PSU info
|
// Extract PSU firmware from RESTful PSU info
|
||||||
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
|
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
|
||||||
if match := rePSU.FindStringSubmatch(text); match != nil {
|
if match := rePSU.FindStringSubmatch(text); match != nil {
|
||||||
jsonStr := strings.ReplaceAll(match[1], "\n", "")
|
jsonStr := strings.ReplaceAll(match[1], "\n", "")
|
||||||
var psuInfo PSURESTInfo
|
var psuInfo PSURESTInfo
|
||||||
|
|||||||
83
internal/parser/vendors/inspur/cpu_mem_fix_test.go
vendored
Normal file
83
internal/parser/vendors/inspur/cpu_mem_fix_test.go
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cpuMemComponentLog = `RESTful version info:
|
||||||
|
[]
|
||||||
|
RESTful CPU info:
|
||||||
|
{ "processors": [ { "proc_id": 0, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_used_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "47149E2253E81688", "status": "OK" }, { "proc_id": 1, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "475AC1221D41F557", "status": "OK" } ] }
|
||||||
|
RESTful Memory info:
|
||||||
|
{ "mem_modules": [ { "mem_mod_id": 0, "config_status": 1, "mem_mod_slot": "CPU0_C0D0", "mem_mod_status": 1, "mem_mod_size": 32, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "S1440202433526FC12", "mem_mod_ranks": 2, "status": "OK" }, { "mem_mod_id": 16, "config_status": 1, "mem_mod_slot": "CPU1_C0D0", "mem_mod_status": 1, "mem_mod_size": 0, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "K0UX000401205D2037", "mem_mod_ranks": 2, "status": "OK" } ], "total_memory_count": 2, "present_memory_count": 2, "mem_total_mem_size": 32 }
|
||||||
|
RESTful HDD info:
|
||||||
|
[]
|
||||||
|
RESTful PSU info:
|
||||||
|
{ "power_supplies": [] }
|
||||||
|
RESTful Network Adapter info:
|
||||||
|
{ "sys_adapters": [] }
|
||||||
|
RESTful fan info:
|
||||||
|
{ "fans": [] }
|
||||||
|
RESTful diskbackplane info:
|
||||||
|
[]
|
||||||
|
BMC done
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseCPUInfo_FromComponentLog(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
ParseComponentLog([]byte(cpuMemComponentLog), hw)
|
||||||
|
|
||||||
|
if len(hw.CPUs) != 2 {
|
||||||
|
t.Fatalf("expected 2 CPUs, got %d", len(hw.CPUs))
|
||||||
|
}
|
||||||
|
if !strings.Contains(hw.CPUs[0].Model, "Gold 6330") {
|
||||||
|
t.Errorf("unexpected CPU model: %s", hw.CPUs[0].Model)
|
||||||
|
}
|
||||||
|
if hw.CPUs[0].Cores != 28 {
|
||||||
|
t.Errorf("expected 28 cores, got %d", hw.CPUs[0].Cores)
|
||||||
|
}
|
||||||
|
if hw.CPUs[0].PPIN != "47149E2253E81688" {
|
||||||
|
t.Errorf("unexpected PPIN: %s", hw.CPUs[0].PPIN)
|
||||||
|
}
|
||||||
|
if hw.CPUs[1].PPIN != "475AC1221D41F557" {
|
||||||
|
t.Errorf("unexpected CPU1 PPIN: %s", hw.CPUs[1].PPIN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMemoryInfo_PresentWithZeroSize(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
ParseComponentLog([]byte(cpuMemComponentLog), hw)
|
||||||
|
|
||||||
|
presentCount := 0
|
||||||
|
for _, m := range hw.Memory {
|
||||||
|
if m.Present {
|
||||||
|
presentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if presentCount != 2 {
|
||||||
|
t.Errorf("expected 2 present DIMMs, got %d", presentCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find CPU1_C0D0 (size=0 but serial present — size should be inferred from same part number)
|
||||||
|
found := false
|
||||||
|
for _, m := range hw.Memory {
|
||||||
|
if m.Slot == "CPU1_C0D0" {
|
||||||
|
found = true
|
||||||
|
if !m.Present {
|
||||||
|
t.Error("CPU1_C0D0 should be Present=true despite size=0")
|
||||||
|
}
|
||||||
|
if m.SerialNumber != "K0UX000401205D2037" {
|
||||||
|
t.Errorf("wrong serial: %s", m.SerialNumber)
|
||||||
|
}
|
||||||
|
if m.SizeMB != 32768 {
|
||||||
|
t.Errorf("expected SizeMB=32768 inferred from part number, got %d", m.SizeMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("CPU1_C0D0 not found in memory list")
|
||||||
|
}
|
||||||
|
}
|
||||||
4
internal/parser/vendors/inspur/gpu_status.go
vendored
4
internal/parser/vendors/inspur/gpu_status.go
vendored
@@ -56,10 +56,12 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event)
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range relevantEvents {
|
for _, e := range relevantEvents {
|
||||||
|
// Deassert means the alarm was cleared: all GPUs return to OK.
|
||||||
|
isDeassert := strings.EqualFold(strings.TrimSpace(e.EventType), "Deassert")
|
||||||
faultySet := extractFaultyGPUSet(e.Description)
|
faultySet := extractFaultyGPUSet(e.Description)
|
||||||
for idx, gpu := range gpuByIndex {
|
for idx, gpu := range gpuByIndex {
|
||||||
newStatus := "OK"
|
newStatus := "OK"
|
||||||
if faultySet[idx] {
|
if !isDeassert && faultySet[idx] {
|
||||||
newStatus = "Critical"
|
newStatus = "Critical"
|
||||||
lastCriticalDetails[idx] = strings.TrimSpace(e.Description)
|
lastCriticalDetails[idx] = strings.TrimSpace(e.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,40 @@ func TestApplyGPUStatusFromEvents_UsesLatestEventAsCurrentStatusAndKeepsHistory(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyGPUStatusFromEvents_DeassertClearsAllGPUs(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
GPUs: []models.GPU{
|
||||||
|
{Slot: "#GPU1"},
|
||||||
|
{Slot: "#GPU3"},
|
||||||
|
{Slot: "#GPU5"},
|
||||||
|
{Slot: "#GPU6"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
events := []models.Event{
|
||||||
|
{
|
||||||
|
ID: "17FFB002",
|
||||||
|
EventType: "Assert",
|
||||||
|
Timestamp: time.Date(2026, 5, 27, 13, 6, 56, 0, time.FixedZone("UTC+8", 8*3600)),
|
||||||
|
Description: "PCIe Present mismatch BIOS Scan, BIOS miss F_GPU1 F_GPU3 F_GPU5 F_GPU6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "17FFB002",
|
||||||
|
EventType: "Deassert",
|
||||||
|
Timestamp: time.Date(2026, 5, 27, 13, 15, 56, 0, time.FixedZone("UTC+8", 8*3600)),
|
||||||
|
Description: "PCIe Present mismatch BIOS Scan, BIOS miss F_GPU1 F_GPU3 F_GPU5 F_GPU6",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyGPUStatusFromEvents(hw, events)
|
||||||
|
|
||||||
|
for _, gpu := range hw.GPUs {
|
||||||
|
if gpu.Status != "OK" {
|
||||||
|
t.Fatalf("expected %s to recover to OK after Deassert, got %q", gpu.Slot, gpu.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
|
func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
|
||||||
content := []byte(`{ "MESSAGE": "|2026-01-12T23:05:18+08:00|PCIE|Assert|Critical|17FFB002|PCIe Present mismatch BIOS miss F_GPU6 - Assert|" }`)
|
content := []byte(`{ "MESSAGE": "|2026-01-12T23:05:18+08:00|PCIE|Assert|Critical|17FFB002|PCIe Present mismatch BIOS miss F_GPU6 - Assert|" }`)
|
||||||
|
|
||||||
|
|||||||
2
internal/parser/vendors/inspur/idl.go
vendored
2
internal/parser/vendors/inspur/idl.go
vendored
@@ -48,7 +48,7 @@ func ParseIDLLog(content []byte) []models.Event {
|
|||||||
description = cleanDescription(description)
|
description = cleanDescription(description)
|
||||||
|
|
||||||
// Create unique key for deduplication
|
// Create unique key for deduplication
|
||||||
eventKey := eventID + "|" + description
|
eventKey := eventID + "|" + eventType + "|" + description
|
||||||
if seenEvents[eventKey] {
|
if seenEvents[eventKey] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
25
internal/parser/vendors/inspur/parser.go
vendored
25
internal/parser/vendors/inspur/parser.go
vendored
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// parserVersion - version of this parser module
|
// parserVersion - version of this parser module
|
||||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||||
const parserVersion = "1.8"
|
const parserVersion = "2.1"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -163,6 +163,26 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
// (fan RPM, backplane temperature, PSU summary power, etc.).
|
// (fan RPM, backplane temperature, PSU summary power, etc.).
|
||||||
componentSensors := ParseComponentLogSensors(f.Content)
|
componentSensors := ParseComponentLogSensors(f.Content)
|
||||||
result.Sensors = mergeSensorReadings(result.Sensors, componentSensors)
|
result.Sensors = mergeSensorReadings(result.Sensors, componentSensors)
|
||||||
|
|
||||||
|
// Record sections where BMC itself returned an error instead of data,
|
||||||
|
// and mirror each one into the Events stream so they appear in the log viewer.
|
||||||
|
// Source is set to "BMC/<section>" so the viewer can show the specific module.
|
||||||
|
for _, ce := range ParseComponentLogCollectionErrors(f.Content) {
|
||||||
|
result.CollectionErrors = append(result.CollectionErrors, ce)
|
||||||
|
desc := ce.Message
|
||||||
|
if ce.Code != 0 {
|
||||||
|
desc = fmt.Sprintf("%s (code %d)", ce.Message, ce.Code)
|
||||||
|
}
|
||||||
|
result.Events = append(result.Events, models.Event{
|
||||||
|
ID: fmt.Sprintf("bmc_collection_error_%s", strings.ToLower(strings.ReplaceAll(ce.Section, " ", "_"))),
|
||||||
|
Timestamp: time.Time{}, // no timestamp available
|
||||||
|
Source: fmt.Sprintf("BMC/%s", ce.Section),
|
||||||
|
SensorType: "bmc_collection_error",
|
||||||
|
EventType: "Collection Error",
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
|
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
|
||||||
@@ -214,6 +234,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
if result.Hardware != nil {
|
if result.Hardware != nil {
|
||||||
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
applyGPUStatusFromEvents(result.Hardware, result.Events)
|
||||||
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
|
||||||
|
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
|
||||||
|
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
|
||||||
|
enrichStorageFromSOLSmartd(files, result.Hardware)
|
||||||
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
|
||||||
// These override redis/component.log serials which may be stale after disk replacement.
|
// These override redis/component.log serials which may be stale after disk replacement.
|
||||||
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)
|
||||||
|
|||||||
247
internal/parser/vendors/inspur/sol_smartd.go
vendored
Normal file
247
internal/parser/vendors/inspur/sol_smartd.go
vendored
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log.
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB
|
||||||
|
var solSmartdDeviceRe = regexp.MustCompile(
|
||||||
|
`Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
type solSmartdDevice struct {
|
||||||
|
Model string
|
||||||
|
Serial string
|
||||||
|
Firmware string
|
||||||
|
SizeGB int
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
|
||||||
|
// Deduplicates by serial number (case-insensitive); preserves first-seen order.
|
||||||
|
func parseSOLSmartdDevices(content []byte) []solSmartdDevice {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []solSmartdDevice
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(content), "\n") {
|
||||||
|
m := solSmartdDeviceRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial := strings.TrimSpace(m[2])
|
||||||
|
if serial == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(serial)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
|
||||||
|
sizeGB := parseSolSizeGB(m[4], m[5])
|
||||||
|
out = append(out, solSmartdDevice{
|
||||||
|
Model: strings.TrimSpace(m[1]),
|
||||||
|
Serial: serial,
|
||||||
|
Firmware: strings.TrimSpace(m[3]),
|
||||||
|
SizeGB: sizeGB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSolSizeGB converts smartd size string ("480", "3.84") + unit ("GB", "TB") to integer GB.
|
||||||
|
// Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions.
|
||||||
|
func parseSolSizeGB(value, unit string) int {
|
||||||
|
f, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil || f <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if strings.EqualFold(unit, "TB") {
|
||||||
|
f *= 1000
|
||||||
|
}
|
||||||
|
return int(f + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrichStorageFromSOLSmartd enriches the storage inventory using disk info from
|
||||||
|
// SOLHostCapture.log (smartd startup messages). Both the log/ and runningdata/ copies
|
||||||
|
// are processed; serials are deduplicated across both files.
|
||||||
|
//
|
||||||
|
// Enrichment priority:
|
||||||
|
// 1. Exact model match to existing entries that are missing a serial.
|
||||||
|
// 2. Positional assignment to present placeholder slots (no model, no serial).
|
||||||
|
// 3. New entries added for any remaining devices.
|
||||||
|
func enrichStorageFromSOLSmartd(files []parser.ExtractedFile, hw *models.HardwareConfig) {
|
||||||
|
if hw == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
solFiles := parser.FindFileByPattern(files, "SOLHostCapture.log")
|
||||||
|
if len(solFiles) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique devices from all SOL log copies.
|
||||||
|
seenSerial := make(map[string]struct{})
|
||||||
|
var devices []solSmartdDevice
|
||||||
|
for _, f := range solFiles {
|
||||||
|
for _, d := range parseSOLSmartdDevices(f.Content) {
|
||||||
|
key := strings.ToLower(d.Serial)
|
||||||
|
if _, ok := seenSerial[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenSerial[key] = struct{}{}
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip devices whose serial already appears in the storage inventory.
|
||||||
|
existingSerials := make(map[string]struct{}, len(hw.Storage))
|
||||||
|
for _, dev := range hw.Storage {
|
||||||
|
sn := strings.ToLower(strings.TrimSpace(dev.SerialNumber))
|
||||||
|
if sn != "" {
|
||||||
|
existingSerials[sn] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var newDevices []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
if _, ok := existingSerials[strings.ToLower(d.Serial)]; !ok {
|
||||||
|
newDevices = append(newDevices, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newDevices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 1: enrich existing entries that match by model (first-match wins per device).
|
||||||
|
remaining := solEnrichByModel(hw, newDevices)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: assign to present placeholder slots (present=true, no model, no serial).
|
||||||
|
remaining = solEnrichByPlaceholder(hw, remaining)
|
||||||
|
if len(remaining) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: add as new storage entries without a slot assignment.
|
||||||
|
for _, d := range remaining {
|
||||||
|
hw.Storage = append(hw.Storage, solMakeStorage(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// solEnrichByModel fills SerialNumber (and optionally Firmware/SizeGB) on existing storage
|
||||||
|
// entries whose model matches the smartd model exactly. Returns unmatched devices.
|
||||||
|
func solEnrichByModel(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
|
||||||
|
var unmatched []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
matched := false
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(hw.Storage[i].Model), d.Model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hw.Storage[i].SerialNumber = d.Serial
|
||||||
|
if strings.TrimSpace(hw.Storage[i].Firmware) == "" {
|
||||||
|
hw.Storage[i].Firmware = d.Firmware
|
||||||
|
}
|
||||||
|
if hw.Storage[i].SizeGB == 0 {
|
||||||
|
hw.Storage[i].SizeGB = d.SizeGB
|
||||||
|
}
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
unmatched = append(unmatched, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
// solEnrichByPlaceholder assigns smartd devices to present storage entries that have
|
||||||
|
// neither a model nor a serial number, sorted by slot name. Returns unmatched devices.
|
||||||
|
func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
|
||||||
|
type slot struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
var placeholders []slot
|
||||||
|
for i := range hw.Storage {
|
||||||
|
if !hw.Storage[i].Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(hw.Storage[i].Model) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placeholders = append(placeholders, slot{index: i, name: hw.Storage[i].Slot})
|
||||||
|
}
|
||||||
|
sort.Slice(placeholders, func(i, j int) bool {
|
||||||
|
return placeholders[i].name < placeholders[j].name
|
||||||
|
})
|
||||||
|
|
||||||
|
pi := 0
|
||||||
|
var unmatched []solSmartdDevice
|
||||||
|
for _, d := range devices {
|
||||||
|
if pi >= len(placeholders) {
|
||||||
|
unmatched = append(unmatched, d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := placeholders[pi].index
|
||||||
|
pi++
|
||||||
|
hw.Storage[idx].SerialNumber = d.Serial
|
||||||
|
hw.Storage[idx].Model = d.Model
|
||||||
|
hw.Storage[idx].Firmware = d.Firmware
|
||||||
|
if hw.Storage[idx].SizeGB == 0 {
|
||||||
|
hw.Storage[idx].SizeGB = d.SizeGB
|
||||||
|
}
|
||||||
|
hw.Storage[idx].Type = solStorageType(d.Model)
|
||||||
|
if hw.Storage[idx].Manufacturer == "" {
|
||||||
|
hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model)
|
||||||
|
}
|
||||||
|
if hw.Storage[idx].Interface == "" {
|
||||||
|
hw.Storage[idx].Interface = "SATA"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unmatched
|
||||||
|
}
|
||||||
|
|
||||||
|
func solMakeStorage(d solSmartdDevice) models.Storage {
|
||||||
|
return models.Storage{
|
||||||
|
Model: d.Model,
|
||||||
|
SerialNumber: d.Serial,
|
||||||
|
Firmware: d.Firmware,
|
||||||
|
SizeGB: d.SizeGB,
|
||||||
|
Type: solStorageType(d.Model),
|
||||||
|
Manufacturer: extractStorageManufacturer(d.Model),
|
||||||
|
Interface: "SATA",
|
||||||
|
Present: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// solStorageType infers SSD vs HDD from the model string.
|
||||||
|
// Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD".
|
||||||
|
func solStorageType(model string) string {
|
||||||
|
upper := strings.ToUpper(model)
|
||||||
|
if strings.Contains(upper, "SSD") ||
|
||||||
|
strings.HasPrefix(upper, "MTFDD") ||
|
||||||
|
strings.HasPrefix(upper, "MICRON_5") {
|
||||||
|
return "SSD"
|
||||||
|
}
|
||||||
|
return "HDD"
|
||||||
|
}
|
||||||
191
internal/parser/vendors/inspur/sol_smartd_test.go
vendored
Normal file
191
internal/parser/vendors/inspur/sol_smartd_test.go
vendored
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package inspur
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const solSmartdSample = `
|
||||||
|
[ 17.219818] smartd[3321]: Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
|
||||||
|
[ 17.553024] smartd[3321]: Device: /dev/sdc [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F172DB3, WWN:5-00a075-14f172db3, FW:D4DK403, 3.84 TB
|
||||||
|
[ 17.553331] smartd[3321]: Device: /dev/sde [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC80F, WWN:5-00a075-1400dc80f, FW:D4CM003, 480 GB
|
||||||
|
[ 17.553709] smartd[3321]: Device: /dev/sdh [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F57DAB8, WWN:5-00a075-14f57dab8, FW:D4DK403, 3.84 TB
|
||||||
|
[ 17.886180] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.Micron-2310400DC7E3.ata.state
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseSOLSmartdDevices_Dedup(t *testing.T) {
|
||||||
|
devices := parseSOLSmartdDevices([]byte(solSmartdSample))
|
||||||
|
if len(devices) != 4 {
|
||||||
|
t.Fatalf("expected 4 unique devices, got %d: %v", len(devices), devices)
|
||||||
|
}
|
||||||
|
// order matches first-seen
|
||||||
|
if devices[0].Serial != "2310400DC7E3" {
|
||||||
|
t.Errorf("first device serial: got %q, want 2310400DC7E3", devices[0].Serial)
|
||||||
|
}
|
||||||
|
if devices[0].SizeGB != 480 {
|
||||||
|
t.Errorf("first device size: got %d, want 480", devices[0].SizeGB)
|
||||||
|
}
|
||||||
|
if devices[1].SizeGB != 3840 {
|
||||||
|
t.Errorf("TB device size: got %d, want 3840", devices[1].SizeGB)
|
||||||
|
}
|
||||||
|
if devices[1].Firmware != "D4DK403" {
|
||||||
|
t.Errorf("firmware: got %q, want D4DK403", devices[1].Firmware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) {
|
||||||
|
content := `
|
||||||
|
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state
|
||||||
|
[ 17.040843] smartd[3321]: Device: /dev/sda [SAT], not found in smartd database 7.3/5319.
|
||||||
|
[ 17.040865] smartd[3321]: Device: /dev/sda [SAT], is SMART capable. Adding to "monitor" list.
|
||||||
|
`
|
||||||
|
devices := parseSOLSmartdDevices([]byte(content))
|
||||||
|
if len(devices) != 0 {
|
||||||
|
t.Errorf("expected 0 devices, got %d", len(devices))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSolSizeGB(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
value, unit string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"480", "GB", 480},
|
||||||
|
{"1.92", "TB", 1920},
|
||||||
|
{"3.84", "TB", 3840},
|
||||||
|
{"1", "TB", 1000},
|
||||||
|
{"0", "GB", 0},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseSolSizeGB(c.value, c.unit)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("parseSolSizeGB(%q, %q) = %d, want %d", c.value, c.unit, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolStorageType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"MTFDDAK3T8TGA-1BC1ZABDA", "SSD"},
|
||||||
|
{"Micron_5400_MTFDDAK480TGA", "SSD"},
|
||||||
|
{"INTEL SSDSC2KB019TZ", "SSD"},
|
||||||
|
{"SEAGATE ST4000NM0115", "HDD"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := solStorageType(c.model)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("solStorageType(%q) = %q, want %q", c.model, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_ModelMatch(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
|
||||||
|
{Slot: "BP0:1", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
// The two existing slots must have received serials via model match.
|
||||||
|
for _, s := range hw.Storage[:2] {
|
||||||
|
if s.SerialNumber == "" {
|
||||||
|
t.Errorf("slot %q: expected serial to be assigned via model match", s.Slot)
|
||||||
|
}
|
||||||
|
if s.SizeGB != 3576 {
|
||||||
|
t.Errorf("slot %q: size should be preserved, got %d", s.Slot, s.SizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The two unmatched Micron entries should be added as new storage entries.
|
||||||
|
if len(hw.Storage) != 4 {
|
||||||
|
t.Errorf("expected 4 total storage entries (2 existing + 2 new Micron), got %d", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_PlaceholderSlots(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", Present: true},
|
||||||
|
{Slot: "BP0:1", Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
for _, s := range hw.Storage {
|
||||||
|
if s.SerialNumber == "" {
|
||||||
|
t.Errorf("slot %q: expected serial to be assigned", s.Slot)
|
||||||
|
}
|
||||||
|
if s.Model == "" {
|
||||||
|
t.Errorf("slot %q: expected model to be assigned", s.Slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_SkipsExistingSerial(t *testing.T) {
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{
|
||||||
|
Path: "onekeylog/log/sollog/SOLHostCapture.log",
|
||||||
|
Content: []byte(solSmartdSample),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{Slot: "BP0:0", SerialNumber: "2310400DC7E3", Present: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
before := len(hw.Storage)
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
// BP0:0 should still have original serial unchanged
|
||||||
|
if hw.Storage[0].SerialNumber != "2310400DC7E3" {
|
||||||
|
t.Errorf("existing serial was changed: got %q", hw.Storage[0].SerialNumber)
|
||||||
|
}
|
||||||
|
// Remaining 3 devices should be added as new entries
|
||||||
|
if len(hw.Storage) <= before {
|
||||||
|
t.Errorf("expected new entries to be added, got %d (same as before)", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichStorageFromSOLSmartd_MergesTwoFiles(t *testing.T) {
|
||||||
|
// Two SOL files with partial overlap; combined unique serials = 3
|
||||||
|
file1 := `[ 17.0] smartd[1]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`
|
||||||
|
file2 := strings.Join([]string{
|
||||||
|
`[ 17.0] smartd[2]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`,
|
||||||
|
`[ 17.1] smartd[2]: Device: /dev/sdb [SAT], ModelB, S/N:SN002, WWN:w, FW:fw2, 480 GB`,
|
||||||
|
`[ 17.2] smartd[2]: Device: /dev/sdc [SAT], ModelC, S/N:SN003, WWN:w, FW:fw3, 480 GB`,
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
files := []parser.ExtractedFile{
|
||||||
|
{Path: "log/sollog/SOLHostCapture.log", Content: []byte(file1)},
|
||||||
|
{Path: "runningdata/var/sollog/SOLHostCapture.log", Content: []byte(file2)},
|
||||||
|
}
|
||||||
|
hw := &models.HardwareConfig{}
|
||||||
|
|
||||||
|
enrichStorageFromSOLSmartd(files, hw)
|
||||||
|
|
||||||
|
if len(hw.Storage) != 3 {
|
||||||
|
t.Fatalf("expected 3 unique storage entries, got %d", len(hw.Storage))
|
||||||
|
}
|
||||||
|
}
|
||||||
1021
internal/parser/vendors/lenovo_xcc/parser.go
vendored
Normal file
1021
internal/parser/vendors/lenovo_xcc/parser.go
vendored
Normal file
File diff suppressed because it is too large
Load Diff
506
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
Normal file
506
internal/parser/vendors/lenovo_xcc/parser_test.go
vendored
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
package lenovo_xcc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exampleArchive = "/Users/mchusavitin/Documents/git/logpile/example/7D76CTO1WW_JF0002KT_xcc_mini-log_20260413-122150.zip"
|
||||||
|
|
||||||
|
func TestDetect_LenovoXCCMiniLog(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
score := p.Detect(files)
|
||||||
|
if score < 80 {
|
||||||
|
t.Errorf("expected Detect score >= 80 for XCC mini-log archive, got %d", score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_BasicSysInfo(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, err := p.Parse(files)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil result or hardware")
|
||||||
|
}
|
||||||
|
|
||||||
|
hw := result.Hardware
|
||||||
|
if hw.BoardInfo.SerialNumber == "" {
|
||||||
|
t.Error("BoardInfo.SerialNumber is empty")
|
||||||
|
}
|
||||||
|
if hw.BoardInfo.ProductName == "" {
|
||||||
|
t.Error("BoardInfo.ProductName is empty")
|
||||||
|
}
|
||||||
|
t.Logf("BoardInfo: serial=%s model=%s uuid=%s", hw.BoardInfo.SerialNumber, hw.BoardInfo.ProductName, hw.BoardInfo.UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_CPUs(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.CPUs) == 0 {
|
||||||
|
t.Error("expected at least one CPU, got none")
|
||||||
|
}
|
||||||
|
for i, cpu := range result.Hardware.CPUs {
|
||||||
|
t.Logf("CPU[%d]: socket=%d model=%q cores=%d threads=%d freq=%dMHz", i, cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Memory(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Memory) == 0 {
|
||||||
|
t.Error("expected memory DIMMs, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Memory: %d DIMMs", len(result.Hardware.Memory))
|
||||||
|
for i, m := range result.Hardware.Memory {
|
||||||
|
t.Logf("DIMM[%d]: slot=%s present=%v size=%dMB sn=%s", i, m.Slot, m.Present, m.SizeMB, m.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Storage(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Storage: %d disks", len(result.Hardware.Storage))
|
||||||
|
for i, s := range result.Hardware.Storage {
|
||||||
|
t.Logf("Disk[%d]: slot=%s model=%q size=%dGB sn=%s", i, s.Slot, s.Model, s.SizeGB, s.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_PCIeCards(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("PCIe cards: %d", len(result.Hardware.PCIeDevices))
|
||||||
|
for i, c := range result.Hardware.PCIeDevices {
|
||||||
|
t.Logf("Card[%d]: slot=%s desc=%q bdf=%s", i, c.Slot, c.Description, c.BDF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_PSUs(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.PowerSupply) == 0 {
|
||||||
|
t.Error("expected PSUs, got none")
|
||||||
|
}
|
||||||
|
for i, p := range result.Hardware.PowerSupply {
|
||||||
|
t.Logf("PSU[%d]: slot=%s wattage=%dW status=%s sn=%s", i, p.Slot, p.WattageW, p.Status, p.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Sensors(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Sensors) == 0 {
|
||||||
|
t.Error("expected sensors, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Sensors: %d", len(result.Sensors))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Events(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Events) == 0 {
|
||||||
|
t.Error("expected events, got none")
|
||||||
|
}
|
||||||
|
t.Logf("Events: %d", len(result.Events))
|
||||||
|
for i, e := range result.Events {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf("Event[%d]: severity=%s ts=%s desc=%q", i, e.Severity, e.Timestamp.Format("2006-01-02T15:04:05"), e.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_FRU(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("FRU: %d entries", len(result.FRU))
|
||||||
|
for i, f := range result.FRU {
|
||||||
|
t.Logf("FRU[%d]: desc=%q product=%q serial=%q", i, f.Description, f.ProductName, f.SerialNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Firmware) == 0 {
|
||||||
|
t.Error("expected firmware entries, got none")
|
||||||
|
}
|
||||||
|
for i, f := range result.Hardware.Firmware {
|
||||||
|
t.Logf("FW[%d]: name=%q version=%q buildtime=%q", i, f.DeviceName, f.Version, f.BuildTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) {
|
||||||
|
files, err := parser.ExtractArchive(exampleArchive)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("example archive not available: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Parser{}
|
||||||
|
result, _ := p.Parse(files)
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
t.Fatal("Parse returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Hardware.Volumes) == 0 {
|
||||||
|
t.Error("expected at least one VROC volume, got none")
|
||||||
|
}
|
||||||
|
for i, v := range result.Hardware.Volumes {
|
||||||
|
t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v",
|
||||||
|
i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives)
|
||||||
|
if v.RAIDLevel == "" {
|
||||||
|
t.Errorf("Volume[%d]: RAIDLevel is empty", i)
|
||||||
|
}
|
||||||
|
if v.Status == "" {
|
||||||
|
t.Errorf("Volume[%d]: Status is empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVolumes_IntelVROC(t *testing.T) {
|
||||||
|
content := []byte(`{
|
||||||
|
"identifier": "storage.id",
|
||||||
|
"items": [{
|
||||||
|
"volumes": [{
|
||||||
|
"id": 1,
|
||||||
|
"name": "",
|
||||||
|
"drives": "M.2 Drive 0, M.2 Drive 1",
|
||||||
|
"rdlvlstr": "RAID 1",
|
||||||
|
"capacityStr": "893.750 GiB",
|
||||||
|
"status": 3,
|
||||||
|
"statusStr": "Optimal"
|
||||||
|
}],
|
||||||
|
"totalCapacityStr": "893.750 GiB"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
vols := parseVolumes(content)
|
||||||
|
if len(vols) != 1 {
|
||||||
|
t.Fatalf("expected 1 volume, got %d", len(vols))
|
||||||
|
}
|
||||||
|
v := vols[0]
|
||||||
|
if v.ID != "1" {
|
||||||
|
t.Errorf("expected ID=1, got %q", v.ID)
|
||||||
|
}
|
||||||
|
if v.RAIDLevel != "RAID 1" {
|
||||||
|
t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel)
|
||||||
|
}
|
||||||
|
if v.Status != "Optimal" {
|
||||||
|
t.Errorf("expected Status=Optimal, got %q", v.Status)
|
||||||
|
}
|
||||||
|
if v.Controller != "Intel VROC" {
|
||||||
|
t.Errorf("expected Controller=Intel VROC, got %q", v.Controller)
|
||||||
|
}
|
||||||
|
if len(v.Drives) != 2 {
|
||||||
|
t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives)
|
||||||
|
}
|
||||||
|
if v.SizeGB < 900 || v.SizeGB > 1000 {
|
||||||
|
t.Errorf("expected SizeGB ~960, got %d", v.SizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
|
||||||
|
content := []byte(`{
|
||||||
|
"items": [{
|
||||||
|
"memory": [{
|
||||||
|
"memory_name": "DIMM A1",
|
||||||
|
"memory_status": "Unqualified DIMM",
|
||||||
|
"memory_type": "DDR5",
|
||||||
|
"memory_capacity": 32
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
memory, events := parseDIMMs(content)
|
||||||
|
if len(memory) != 1 {
|
||||||
|
t.Fatalf("expected 1 DIMM, got %d", len(memory))
|
||||||
|
}
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("expected 1 warning event, got %d", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Severity != models.SeverityWarning {
|
||||||
|
t.Fatalf("expected warning severity, got %q", events[0].Severity)
|
||||||
|
}
|
||||||
|
if events[0].SensorName != "DIMM A1" {
|
||||||
|
t.Fatalf("unexpected sensor name: %q", events[0].SensorName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
|
||||||
|
if got := xccSeverity("I", "System found Unqualified DIMM in slot DIMM A1"); got != models.SeverityWarning {
|
||||||
|
t.Fatalf("expected warning severity, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Events: []models.Event{
|
||||||
|
{
|
||||||
|
Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC),
|
||||||
|
Severity: models.SeverityWarning,
|
||||||
|
Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{
|
||||||
|
Slot: "DIMM 3",
|
||||||
|
Present: true,
|
||||||
|
SerialNumber: "80CE042328460C5D88",
|
||||||
|
Status: "Normal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDIMMWarningsFromEvents(result)
|
||||||
|
|
||||||
|
dimm := result.Hardware.Memory[0]
|
||||||
|
if dimm.Status != "Warning" {
|
||||||
|
t.Fatalf("expected DIMM status Warning, got %q", dimm.Status)
|
||||||
|
}
|
||||||
|
if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description {
|
||||||
|
t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription)
|
||||||
|
}
|
||||||
|
if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) {
|
||||||
|
t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt)
|
||||||
|
}
|
||||||
|
if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" {
|
||||||
|
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBasicSysInfo_CleansPlaceholderValuesAndSetsTargetHost(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{Hardware: &models.HardwareConfig{}}
|
||||||
|
content := []byte(`{
|
||||||
|
"items": [{
|
||||||
|
"machine_name": " sr650v3-node01 ",
|
||||||
|
"machine_typemodel": " 7D76CTO1WW ",
|
||||||
|
"serial_number": " Not Specified ",
|
||||||
|
"uuid": "N/A"
|
||||||
|
}]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
parseBasicSysInfo(content, result)
|
||||||
|
|
||||||
|
if result.TargetHost != "sr650v3-node01" {
|
||||||
|
t.Fatalf("unexpected target host: %q", result.TargetHost)
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.ProductName != "7D76CTO1WW" {
|
||||||
|
t.Fatalf("unexpected product name: %q", result.Hardware.BoardInfo.ProductName)
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.SerialNumber != "" {
|
||||||
|
t.Fatalf("expected serial number to be cleaned, got %q", result.Hardware.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
if result.Hardware.BoardInfo.UUID != "" {
|
||||||
|
t.Fatalf("expected UUID to be cleaned, got %q", result.Hardware.BoardInfo.UUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichBoardFromFRU_SystemBoardManufacturerOnly(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Hardware: &models.HardwareConfig{},
|
||||||
|
FRU: []models.FRUInfo{
|
||||||
|
{Description: "Power Supply 1", Manufacturer: "Ignore Me"},
|
||||||
|
{Description: "System Board", Manufacturer: " Lenovo "},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichBoardFromFRU(result)
|
||||||
|
|
||||||
|
if result.Hardware.BoardInfo.Manufacturer != "Lenovo" {
|
||||||
|
t.Fatalf("unexpected manufacturer: %q", result.Hardware.BoardInfo.Manufacturer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnrichPSUsFromSensors_AssignsTelemetryBySlot(t *testing.T) {
|
||||||
|
psus := []models.PSU{
|
||||||
|
{Slot: "1"},
|
||||||
|
{Slot: "2"},
|
||||||
|
}
|
||||||
|
sensors := []models.SensorReading{
|
||||||
|
{Name: "PSU1 Input Power", Value: 430},
|
||||||
|
{Name: "Power Supply 1 Output Power", Value: 390},
|
||||||
|
{Name: "PWS1 AC Voltage", Value: 230.5},
|
||||||
|
{Name: "PSU2 Input Power", Value: 0},
|
||||||
|
{Name: "PSU3 Input Power", Value: 999},
|
||||||
|
{Name: "Fan 1", Value: 12000},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := enrichPSUsFromSensors(psus, sensors)
|
||||||
|
|
||||||
|
if got[0].InputPowerW != 430 {
|
||||||
|
t.Fatalf("unexpected PSU1 input power: %d", got[0].InputPowerW)
|
||||||
|
}
|
||||||
|
if got[0].OutputPowerW != 390 {
|
||||||
|
t.Fatalf("unexpected PSU1 output power: %d", got[0].OutputPowerW)
|
||||||
|
}
|
||||||
|
if got[0].InputVoltage != 230.5 {
|
||||||
|
t.Fatalf("unexpected PSU1 input voltage: %v", got[0].InputVoltage)
|
||||||
|
}
|
||||||
|
if got[1].InputPowerW != 0 || got[1].OutputPowerW != 0 || got[1].InputVoltage != 0 {
|
||||||
|
t.Fatalf("unexpected telemetry assigned to PSU2: %+v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapDiskHealthStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
code int
|
||||||
|
stateStr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "normal", code: 2, stateStr: "Online", want: "OK"},
|
||||||
|
{name: "warning", code: 1, stateStr: "Online", want: "Warning"},
|
||||||
|
{name: "predictive failure", code: 4, stateStr: "Online", want: "Warning"},
|
||||||
|
{name: "critical", code: 3, stateStr: "Failed", want: "Critical"},
|
||||||
|
{name: "fallback state", code: 0, stateStr: "Rebuilding", want: "Rebuilding"},
|
||||||
|
{name: "unknown", code: 0, stateStr: "", want: "Unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := mapDiskHealthStatus(tt.code, tt.stateStr); got != tt.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifySensorType(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
unit string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "unit rpm", in: "Fan 1", unit: "RPM", want: "fan"},
|
||||||
|
{name: "unit celsius", in: "CPU Temp", unit: "C", want: "temperature"},
|
||||||
|
{name: "unit watts", in: "PSU1 Input Power", unit: "W", want: "power"},
|
||||||
|
{name: "unit volts", in: "PWS1 AC Voltage", unit: "V", want: "voltage"},
|
||||||
|
{name: "unit amps", in: "PSU1 Current", unit: "A", want: "current"},
|
||||||
|
{name: "name fallback", in: "GPU Temp", unit: "", want: "temperature"},
|
||||||
|
{name: "other", in: "Presence", unit: "", want: "other"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := classifySensorType(tt.in, tt.unit); got != tt.want {
|
||||||
|
t.Fatalf("got %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanXCCValue(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{in: " Lenovo ", want: "Lenovo"},
|
||||||
|
{in: "N/A", want: ""},
|
||||||
|
{in: " not specified ", want: ""},
|
||||||
|
{in: "-", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := cleanXCCValue(tt.in); got != tt.want {
|
||||||
|
t.Fatalf("cleanXCCValue(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2781
internal/parser/vendors/pciids/pci.ids
vendored
2781
internal/parser/vendors/pciids/pci.ids
vendored
File diff suppressed because it is too large
Load Diff
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -14,6 +14,7 @@ import (
|
|||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
||||||
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/lenovo_xcc"
|
||||||
|
|
||||||
// Generic fallback parser (must be last for lowest priority)
|
// Generic fallback parser (must be last for lowest priority)
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"
|
||||||
|
|||||||
434
internal/parser/vendors/xfusion/hardware.go
vendored
434
internal/parser/vendors/xfusion/hardware.go
vendored
@@ -10,6 +10,33 @@ import (
|
|||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"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 ──────────────────────────────────────────────────────────────────────
|
// ── FRU ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
|
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
|
||||||
@@ -338,9 +365,9 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
|
|||||||
|
|
||||||
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
|
// ── 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.
|
// 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)
|
sections := splitPipeSections(content)
|
||||||
|
|
||||||
// Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info
|
// 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
|
// OCP Card Info: NIC cards
|
||||||
for i, row := range sections["ocp card info"] {
|
for _, row := range sections["ocp card info"] {
|
||||||
desc := strings.TrimSpace(row["card desc"])
|
slot := strings.TrimSpace(row["slot"])
|
||||||
sn := strings.TrimSpace(row["serialnumber"])
|
pcie := slotPCIe[slot]
|
||||||
nics = append(nics, models.NIC{
|
nicCards = append(nicCards, xfusionNICCard{
|
||||||
Name: fmt.Sprintf("OCP%d", i+1),
|
Slot: slot,
|
||||||
Model: desc,
|
Model: strings.TrimSpace(row["card desc"]),
|
||||||
SerialNumber: sn,
|
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
|
// splitPipeSections parses a multi-section file where each section starts with a
|
||||||
@@ -462,6 +494,301 @@ func parseHexInt(s string) int {
|
|||||||
return int(n)
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── PSU ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// parsePSUInfo parses the pipe-delimited psu_info.txt.
|
// 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) {
|
func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
|
||||||
// File may contain multiple controller blocks; parse key:value pairs from each.
|
// File may contain multiple controller blocks; parse key:value pairs from each.
|
||||||
// We only look at the first occurrence of each key (first controller).
|
// 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)
|
text := string(content)
|
||||||
blocks := strings.Split(text, "RAID Controller #")
|
blocks := strings.Split(text, "RAID Controller #")
|
||||||
for _, block := range blocks[1:] { // skip pre-block preamble
|
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"])
|
name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
|
||||||
firmware := fields["Firmware Version"]
|
firmware := fields["Firmware Version"]
|
||||||
if name != "" && firmware != "" {
|
if name != "" && firmware != "" {
|
||||||
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
appendXFusionFirmware(result, seen, models.FirmwareInfo{
|
||||||
DeviceName: name,
|
DeviceName: name,
|
||||||
Description: fields["Controller Name"],
|
Description: fields["Controller Name"],
|
||||||
Version: firmware,
|
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.
|
// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
|
||||||
func parseDiskInfo(content []byte) *models.Storage {
|
func parseDiskInfo(content []byte) *models.Storage {
|
||||||
fields := parseKeyValueBlock(content)
|
fields := parseKeyValueBlock(content)
|
||||||
|
|||||||
45
internal/parser/vendors/xfusion/parser.go
vendored
45
internal/parser/vendors/xfusion/parser.go
vendored
@@ -13,7 +13,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
const parserVersion = "1.0"
|
const parserVersion = "1.1"
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
parser.Register(&Parser{})
|
parser.Register(&Parser{})
|
||||||
@@ -34,11 +34,15 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
|||||||
path := strings.ToLower(f.Path)
|
path := strings.ToLower(f.Path)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(path, "appdump/frudata/fruinfo.txt"):
|
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"):
|
case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"):
|
||||||
confidence += 20
|
confidence += 10
|
||||||
case strings.Contains(path, "appdump/card_manage/card_info"):
|
case strings.Contains(path, "appdump/card_manage/card_info"):
|
||||||
confidence += 20
|
confidence += 20
|
||||||
|
case strings.Contains(path, "logdump/netcard/netcard_info.txt"):
|
||||||
|
confidence += 20
|
||||||
}
|
}
|
||||||
if confidence >= 100 {
|
if confidence >= 100 {
|
||||||
return 100
|
return 100
|
||||||
@@ -54,17 +58,21 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
FRU: make([]models.FRUInfo, 0),
|
FRU: make([]models.FRUInfo, 0),
|
||||||
Sensors: make([]models.SensorReading, 0),
|
Sensors: make([]models.SensorReading, 0),
|
||||||
Hardware: &models.HardwareConfig{
|
Hardware: &models.HardwareConfig{
|
||||||
|
Firmware: make([]models.FirmwareInfo, 0),
|
||||||
|
Devices: make([]models.HardwareDevice, 0),
|
||||||
CPUs: make([]models.CPU, 0),
|
CPUs: make([]models.CPU, 0),
|
||||||
Memory: make([]models.MemoryDIMM, 0),
|
Memory: make([]models.MemoryDIMM, 0),
|
||||||
Storage: make([]models.Storage, 0),
|
Storage: make([]models.Storage, 0),
|
||||||
|
Volumes: make([]models.StorageVolume, 0),
|
||||||
|
PCIeDevices: make([]models.PCIeDevice, 0),
|
||||||
GPUs: make([]models.GPU, 0),
|
GPUs: make([]models.GPU, 0),
|
||||||
NetworkCards: make([]models.NIC, 0),
|
NetworkCards: make([]models.NIC, 0),
|
||||||
|
NetworkAdapters: make([]models.NetworkAdapter, 0),
|
||||||
PowerSupply: make([]models.PSU, 0),
|
PowerSupply: make([]models.PSU, 0),
|
||||||
Firmware: make([]models.FirmwareInfo, 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)
|
parseFRUInfo(f.Content, result)
|
||||||
}
|
}
|
||||||
if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil {
|
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 {
|
if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil {
|
||||||
result.Hardware.Memory = parseMemInfo(f.Content)
|
result.Hardware.Memory = parseMemInfo(f.Content)
|
||||||
}
|
}
|
||||||
|
var nicCards []xfusionNICCard
|
||||||
if f := findByPath(files, "appdump/card_manage/card_info"); f != nil {
|
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.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 {
|
if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil {
|
||||||
result.Hardware.PowerSupply = parsePSUInfo(f.Content)
|
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 {
|
if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil {
|
||||||
parseStorageControllerInfo(f.Content, result)
|
parseStorageControllerInfo(f.Content, result)
|
||||||
}
|
}
|
||||||
|
if f := findByPath(files, "rtosdump/versioninfo/app_revision.txt"); f != nil {
|
||||||
|
parseAppRevision(f.Content, result)
|
||||||
|
}
|
||||||
for _, f := range findDiskInfoFiles(files) {
|
for _, f := range findDiskInfoFiles(files) {
|
||||||
disk := parseDiskInfo(f.Content)
|
disk := parseDiskInfo(f.Content)
|
||||||
if disk != nil {
|
if disk != nil {
|
||||||
@@ -99,6 +120,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
|
|
||||||
result.Protocol = "ipmi"
|
result.Protocol = "ipmi"
|
||||||
result.SourceType = models.SourceTypeArchive
|
result.SourceType = models.SourceTypeArchive
|
||||||
|
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -113,6 +135,15 @@ func findByPath(files []parser.ExtractedFile, substring string) *parser.Extracte
|
|||||||
return nil
|
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.
|
// findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files.
|
||||||
func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
|
func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
|
||||||
var out []parser.ExtractedFile
|
var out []parser.ExtractedFile
|
||||||
|
|||||||
113
internal/parser/vendors/xfusion/parser_test.go
vendored
113
internal/parser/vendors/xfusion/parser_test.go
vendored
@@ -1,8 +1,10 @@
|
|||||||
package xfusion
|
package xfusion
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"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) {
|
func TestParse_G5500V7_BoardInfo(t *testing.T) {
|
||||||
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
||||||
p := &Parser{}
|
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) {
|
func TestParse_G5500V7_PSUs(t *testing.T) {
|
||||||
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
|
||||||
p := &Parser{}
|
p := &Parser{}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ func TestParserParseExample(t *testing.T) {
|
|||||||
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
|
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
|
||||||
raw, err := os.ReadFile(examplePath)
|
raw, err := os.ReadFile(examplePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Skipf("example file %s not present", examplePath)
|
||||||
|
}
|
||||||
t.Fatalf("read example file: %v", err)
|
t.Fatalf("read example file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) {
|
|||||||
t.Fatalf("expected chart title in body, got %q", body)
|
t.Fatalf("expected chart title in body, got %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `/chart/static/view.css`) {
|
if !strings.Contains(body, `/chart/static/view.css`) {
|
||||||
t.Fatalf("expected rewritten chart static path, got %q", body)
|
t.Fatalf("expected rewritten chart css path, got %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `/chart/static/view.js`) {
|
||||||
|
t.Fatalf("expected rewritten chart js path, got %q", body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "Snapshot Metadata") {
|
if !strings.Contains(body, "Snapshot Metadata") {
|
||||||
t.Fatalf("expected rendered chart output, got %q", body)
|
t.Fatalf("expected rendered chart output, got %q", body)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,6 +24,7 @@ func newCollectTestServer() (*Server, *httptest.Server) {
|
|||||||
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||||
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||||
|
mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
|
||||||
return s, httptest.NewServer(mux)
|
return s, httptest.NewServer(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +32,17 @@ func TestCollectProbe(t *testing.T) {
|
|||||||
_, ts := newCollectTestServer()
|
_, ts := newCollectTestServer()
|
||||||
defer ts.Close()
|
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))
|
resp, err := http.Post(ts.URL+"/api/collect/probe", "application/json", bytes.NewBufferString(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("post collect probe failed: %v", err)
|
t.Fatalf("post collect probe failed: %v", err)
|
||||||
@@ -53,9 +66,6 @@ func TestCollectProbe(t *testing.T) {
|
|||||||
if payload.HostPowerState != "Off" {
|
if payload.HostPowerState != "Off" {
|
||||||
t.Fatalf("expected host power state Off, got %q", payload.HostPowerState)
|
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) {
|
func TestCollectLifecycleToTerminal(t *testing.T) {
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ func (c *mockConnector) Probe(ctx context.Context, req collector.Request) (*coll
|
|||||||
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
if strings.Contains(strings.ToLower(req.Host), "fail") {
|
||||||
return nil, context.DeadlineExceeded
|
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{
|
return &collector.ProbeResult{
|
||||||
Reachable: true,
|
Reachable: true,
|
||||||
Protocol: c.protocol,
|
Protocol: c.protocol,
|
||||||
HostPowerState: map[bool]string{true: "On", false: "Off"}[!strings.Contains(strings.ToLower(req.Host), "off")],
|
HostPowerState: map[bool]string{true: "On", false: "Off"}[hostPoweredOn],
|
||||||
HostPoweredOn: !strings.Contains(strings.ToLower(req.Host), "off"),
|
HostPoweredOn: hostPoweredOn,
|
||||||
PowerControlAvailable: true,
|
|
||||||
SystemPath: "/redfish/v1/Systems/1",
|
SystemPath: "/redfish/v1/Systems/1",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ type CollectRequest struct {
|
|||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
TLSMode string `json:"tls_mode"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +27,6 @@ type CollectProbeResponse struct {
|
|||||||
Protocol string `json:"protocol,omitempty"`
|
Protocol string `json:"protocol,omitempty"`
|
||||||
HostPowerState string `json:"host_power_state,omitempty"`
|
HostPowerState string `json:"host_power_state,omitempty"`
|
||||||
HostPoweredOn bool `json:"host_powered_on"`
|
HostPoweredOn bool `json:"host_powered_on"`
|
||||||
PowerControlAvailable bool `json:"power_control_available"`
|
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +39,15 @@ type CollectJobResponse struct {
|
|||||||
|
|
||||||
type CollectJobStatusResponse struct {
|
type CollectJobStatusResponse struct {
|
||||||
JobID string `json:"job_id"`
|
JobID string `json:"job_id"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Progress *int `json:"progress,omitempty"`
|
Progress *int `json:"progress,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
CurrentPhase string `json:"current_phase,omitempty"`
|
CurrentPhase string `json:"current_phase,omitempty"`
|
||||||
ETASeconds *int `json:"eta_seconds,omitempty"`
|
ETASeconds *int `json:"eta_seconds,omitempty"`
|
||||||
Logs []string `json:"logs,omitempty"`
|
Logs []string `json:"logs,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Result map[string]interface{} `json:"result,omitempty"`
|
||||||
ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"`
|
ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"`
|
||||||
ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"`
|
ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"`
|
||||||
DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"`
|
DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"`
|
||||||
@@ -66,12 +66,15 @@ type CollectRequestMeta struct {
|
|||||||
|
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID string
|
ID string
|
||||||
|
Type string
|
||||||
Status string
|
Status string
|
||||||
Progress int
|
Progress int
|
||||||
|
Message string
|
||||||
CurrentPhase string
|
CurrentPhase string
|
||||||
ETASeconds int
|
ETASeconds int
|
||||||
Logs []string
|
Logs []string
|
||||||
Error string
|
Error string
|
||||||
|
Result map[string]interface{}
|
||||||
ActiveModules []CollectModuleStatus
|
ActiveModules []CollectModuleStatus
|
||||||
ModuleScores []CollectModuleStatus
|
ModuleScores []CollectModuleStatus
|
||||||
DebugInfo *CollectDebugInfo
|
DebugInfo *CollectDebugInfo
|
||||||
@@ -79,6 +82,7 @@ type Job struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
RequestMeta CollectRequestMeta
|
RequestMeta CollectRequestMeta
|
||||||
cancel func()
|
cancel func()
|
||||||
|
skipFn func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type CollectModuleStatus struct {
|
type CollectModuleStatus struct {
|
||||||
@@ -109,11 +113,14 @@ func (j *Job) toStatusResponse() CollectJobStatusResponse {
|
|||||||
progress := j.Progress
|
progress := j.Progress
|
||||||
resp := CollectJobStatusResponse{
|
resp := CollectJobStatusResponse{
|
||||||
JobID: j.ID,
|
JobID: j.ID,
|
||||||
|
Type: j.Type,
|
||||||
Status: j.Status,
|
Status: j.Status,
|
||||||
Progress: &progress,
|
Progress: &progress,
|
||||||
|
Message: j.Message,
|
||||||
CurrentPhase: j.CurrentPhase,
|
CurrentPhase: j.CurrentPhase,
|
||||||
Logs: append([]string(nil), j.Logs...),
|
Logs: append([]string(nil), j.Logs...),
|
||||||
Error: j.Error,
|
Error: j.Error,
|
||||||
|
Result: j.Result,
|
||||||
ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...),
|
ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...),
|
||||||
ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...),
|
ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...),
|
||||||
DebugInfo: cloneCollectDebugInfo(j.DebugInfo),
|
DebugInfo: cloneCollectDebugInfo(j.DebugInfo),
|
||||||
|
|||||||
@@ -243,6 +243,8 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
Source: "network_adapters",
|
Source: "network_adapters",
|
||||||
Slot: nic.Slot,
|
Slot: nic.Slot,
|
||||||
Location: nic.Location,
|
Location: nic.Location,
|
||||||
|
BDF: nic.BDF,
|
||||||
|
DeviceClass: "NetworkController",
|
||||||
VendorID: nic.VendorID,
|
VendorID: nic.VendorID,
|
||||||
DeviceID: nic.DeviceID,
|
DeviceID: nic.DeviceID,
|
||||||
Model: nic.Model,
|
Model: nic.Model,
|
||||||
@@ -253,6 +255,11 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
PortCount: nic.PortCount,
|
PortCount: nic.PortCount,
|
||||||
PortType: nic.PortType,
|
PortType: nic.PortType,
|
||||||
MACAddresses: nic.MACAddresses,
|
MACAddresses: nic.MACAddresses,
|
||||||
|
LinkWidth: nic.LinkWidth,
|
||||||
|
LinkSpeed: nic.LinkSpeed,
|
||||||
|
MaxLinkWidth: nic.MaxLinkWidth,
|
||||||
|
MaxLinkSpeed: nic.MaxLinkSpeed,
|
||||||
|
NUMANode: nic.NUMANode,
|
||||||
Present: &present,
|
Present: &present,
|
||||||
Status: nic.Status,
|
Status: nic.Status,
|
||||||
StatusCheckedAt: nic.StatusCheckedAt,
|
StatusCheckedAt: nic.StatusCheckedAt,
|
||||||
|
|||||||
@@ -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) {
|
func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
|
||||||
hw := &models.HardwareConfig{
|
hw := &models.HardwareConfig{
|
||||||
Memory: []models.MemoryDIMM{
|
Memory: []models.MemoryDIMM{
|
||||||
@@ -139,7 +174,7 @@ func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
|
|||||||
|
|
||||||
spec := buildSpecification(hw)
|
spec := buildSpecification(hw)
|
||||||
for _, line := range spec {
|
for _, line := range spec {
|
||||||
if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
|
if line.Category == "Memory" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
func TestHandleGetConfig_ReturnsCanonicalHardware(t *testing.T) {
|
||||||
srv := &Server{}
|
srv := &Server{}
|
||||||
srv.SetResult(&models.AnalysisResult{
|
srv.SetResult(&models.AnalysisResult{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,30 +38,39 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
tmplContent, err := WebFS.ReadFile("templates/index.html")
|
tmplContent, err := WebFS.ReadFile("templates/index.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
s.htmlError(w, "Template not found", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := template.New("index").Parse(string(tmplContent))
|
tmpl, err := template.New("index").Parse(string(tmplContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Template parse error", http.StatusInternalServerError)
|
s.htmlError(w, "Template parse error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
tmpl.Execute(w, map[string]string{
|
tmpl.Execute(w, map[string]string{
|
||||||
"AppVersion": s.config.AppVersion,
|
"AppVersion": normalizeDisplayVersion(s.config.AppVersion),
|
||||||
"AppCommit": s.config.AppCommit,
|
"AppCommit": s.config.AppCommit,
|
||||||
|
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDisplayVersion(v string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(v, "v")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
title := chartTitle(result)
|
title := chartTitle(result)
|
||||||
if result == nil || result.Hardware == nil {
|
if result == nil || result.Hardware == nil {
|
||||||
html, err := chartviewer.RenderHTML(nil, title)
|
html, err := chartviewer.RenderHTML(nil, title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to render viewer", http.StatusInternalServerError)
|
s.htmlError(w, "failed to render viewer", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -70,13 +80,13 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
snapshotBytes, err := currentReanimatorSnapshotBytes(result)
|
snapshotBytes, err := currentReanimatorSnapshotBytes(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
|
s.htmlError(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
html, err := chartviewer.RenderHTML(snapshotBytes, title)
|
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +137,9 @@ func chartTitle(result *models.AnalysisResult) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rewriteChartStaticPaths(html []byte) []byte {
|
func rewriteChartStaticPaths(html []byte) []byte {
|
||||||
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
|
html = bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
|
||||||
|
html = bytes.ReplaceAll(html, []byte(`src="/static/view.js"`), []byte(`src="/chart/static/view.js"`))
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -382,7 +394,7 @@ func uniqueSortedExtensions(exts []string) []string {
|
|||||||
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil {
|
if result == nil {
|
||||||
jsonResponse(w, []interface{}{})
|
jsonList(w, []interface{}{}, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,18 +407,18 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
return events[i].Timestamp.After(events[j].Timestamp)
|
return events[i].Timestamp.After(events[j].Timestamp)
|
||||||
})
|
})
|
||||||
|
|
||||||
jsonResponse(w, events)
|
jsonList(w, events, len(events))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil {
|
if result == nil {
|
||||||
jsonResponse(w, []interface{}{})
|
jsonList(w, []interface{}{}, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sensors := append([]models.SensorReading{}, result.Sensors...)
|
sensors := append([]models.SensorReading{}, result.Sensors...)
|
||||||
sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...)
|
sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...)
|
||||||
jsonResponse(w, sensors)
|
jsonList(w, sensors, len(sensors))
|
||||||
}
|
}
|
||||||
|
|
||||||
func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading {
|
func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading {
|
||||||
@@ -520,7 +532,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
float64(cpu.FrequencyMHz)/1000,
|
float64(cpu.FrequencyMHz)/1000,
|
||||||
cpu.Cores,
|
cpu.Cores,
|
||||||
intFromDetails(cpu.Details, "tdp_w"))
|
intFromDetails(cpu.Details, "tdp_w"))
|
||||||
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
|
spec = append(spec, SpecLine{Category: "CPU", Name: name, Quantity: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory - group by size, type and frequency (only installed modules)
|
// Memory - group by size, type and frequency (only installed modules)
|
||||||
@@ -555,7 +567,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
memGroups[key]++
|
memGroups[key]++
|
||||||
}
|
}
|
||||||
for key, count := range memGroups {
|
for key, count := range memGroups {
|
||||||
spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count})
|
spec = append(spec, SpecLine{Category: "Memory", Name: key, Quantity: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage - group by type and capacity
|
// Storage - group by type and capacity
|
||||||
@@ -573,7 +585,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
storGroups[key]++
|
storGroups[key]++
|
||||||
}
|
}
|
||||||
for key, count := range storGroups {
|
for key, count := range storGroups {
|
||||||
spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count})
|
spec = append(spec, SpecLine{Category: "Storage", Name: key, Quantity: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PCIe devices - group by device class/name and manufacturer
|
// PCIe devices - group by device class/name and manufacturer
|
||||||
@@ -596,7 +608,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
}
|
}
|
||||||
for key, count := range pcieGroups {
|
for key, count := range pcieGroups {
|
||||||
pcie := pcieDetails[key]
|
pcie := pcieDetails[key]
|
||||||
category := "PCIe устройство"
|
category := "PCIe Device"
|
||||||
name := key
|
name := key
|
||||||
|
|
||||||
// Determine category based on device class or known GPU names
|
// Determine category based on device class or known GPU names
|
||||||
@@ -605,11 +617,11 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
||||||
|
|
||||||
if isGPU {
|
if isGPU {
|
||||||
category = "Графический процессор"
|
category = "GPU"
|
||||||
} else if isNetwork {
|
} else if isNetwork {
|
||||||
category = "Сетевой адаптер"
|
category = "Network Adapter"
|
||||||
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
|
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
|
||||||
category = "Контроллер"
|
category = "Controller"
|
||||||
}
|
}
|
||||||
|
|
||||||
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
|
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
|
||||||
@@ -630,7 +642,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for key, count := range psuGroups {
|
for key, count := range psuGroups {
|
||||||
spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count})
|
spec = append(spec, SpecLine{Category: "Power Supply", Name: key, Quantity: count})
|
||||||
}
|
}
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
@@ -651,7 +663,7 @@ func nonEmptyStrings(values ...string) []string {
|
|||||||
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil {
|
if result == nil {
|
||||||
jsonResponse(w, []interface{}{})
|
jsonList(w, []interface{}{}, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +713,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, serials)
|
jsonList(w, serials, len(serials))
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
|
func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
|
||||||
@@ -755,11 +767,12 @@ func hasUsableFirmwareVersion(version string) bool {
|
|||||||
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil || result.Hardware == nil {
|
if result == nil || result.Hardware == nil {
|
||||||
jsonResponse(w, []interface{}{})
|
jsonList(w, []interface{}{}, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, buildFirmwareEntries(result.Hardware))
|
entries := buildFirmwareEntries(result.Hardware)
|
||||||
|
jsonList(w, entries, len(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
type parseErrorEntry struct {
|
type parseErrorEntry struct {
|
||||||
@@ -844,6 +857,28 @@ func (s *Server) handleGetParseErrors(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BMC-reported collection failures surfaced by vendor parsers.
|
||||||
|
if result != nil {
|
||||||
|
for _, ce := range result.CollectionErrors {
|
||||||
|
msg := strings.TrimSpace(ce.Message)
|
||||||
|
if msg == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detail := ""
|
||||||
|
if ce.Code != 0 {
|
||||||
|
detail = fmt.Sprintf("code %d", ce.Code)
|
||||||
|
}
|
||||||
|
add(parseErrorEntry{
|
||||||
|
Source: "bmc",
|
||||||
|
Category: "bmc_collection_error",
|
||||||
|
Severity: "warning",
|
||||||
|
Path: ce.Section,
|
||||||
|
Message: msg,
|
||||||
|
Detail: detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
sort.Slice(items, func(i, j int) bool {
|
||||||
if items[i].Severity != items[j].Severity {
|
if items[i].Severity != items[j].Severity {
|
||||||
// error > warning > info
|
// error > warning > info
|
||||||
@@ -906,8 +941,7 @@ func looksLikeErrorLogLine(line string) bool {
|
|||||||
if s == "" {
|
if s == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.Contains(s, "ошибка") ||
|
return strings.Contains(s, "error") ||
|
||||||
strings.Contains(s, "error") ||
|
|
||||||
strings.Contains(s, "failed") ||
|
strings.Contains(s, "failed") ||
|
||||||
strings.Contains(s, "timeout") ||
|
strings.Contains(s, "timeout") ||
|
||||||
strings.Contains(s, "deadline exceeded")
|
strings.Contains(s, "deadline exceeded")
|
||||||
@@ -942,7 +976,7 @@ func parseErrorSeverityFromMessage(msg string) string {
|
|||||||
if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") {
|
if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") {
|
||||||
return "info"
|
return "info"
|
||||||
}
|
}
|
||||||
if strings.Contains(s, "ошибка") || strings.Contains(s, "error") || strings.Contains(s, "failed") {
|
if strings.Contains(s, "error") || strings.Contains(s, "failed") {
|
||||||
return "warning"
|
return "warning"
|
||||||
}
|
}
|
||||||
return "info"
|
return "info"
|
||||||
@@ -1200,6 +1234,13 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
|||||||
exp.ExportCSV(w)
|
exp.ExportCSV(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExportLogsCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := s.GetResult()
|
||||||
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "logs.csv")))
|
||||||
|
exporter.ExportLogsCSV(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
|
|
||||||
@@ -1281,7 +1322,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
tempDir, err := os.MkdirTemp("", "logpile-convert-input-*")
|
tempDir, err := os.MkdirTemp("", "logpile-convert-input-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, "Не удалось создать временную директорию", http.StatusInternalServerError)
|
jsonError(w, "Failed to create temp directory", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1328,7 +1369,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
if len(inputFiles) == 0 {
|
if len(inputFiles) == 0 {
|
||||||
_ = os.RemoveAll(tempDir)
|
_ = os.RemoveAll(tempDir)
|
||||||
jsonError(w, "Нет файлов поддерживаемого типа для конвертации", http.StatusBadRequest)
|
jsonError(w, "No supported files to convert", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,9 +1382,9 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
|
|||||||
TLSMode: "insecure",
|
TLSMode: "insecure",
|
||||||
})
|
})
|
||||||
s.markConvertJob(job.ID)
|
s.markConvertJob(job.ID)
|
||||||
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Запущена пакетная конвертация: %d файлов", len(inputFiles)))
|
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Batch conversion started: %d files", len(inputFiles)))
|
||||||
if skipped > 0 {
|
if skipped > 0 {
|
||||||
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Пропущено неподдерживаемых файлов: %d", skipped))
|
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Skipped unsupported files: %d", skipped))
|
||||||
}
|
}
|
||||||
s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "")
|
s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "")
|
||||||
|
|
||||||
@@ -1371,7 +1412,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
|
|||||||
|
|
||||||
resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip")
|
resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "не удалось создать zip")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "failed to create zip")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resultPath := resultFile.Name()
|
resultPath := resultFile.Name()
|
||||||
@@ -1383,7 +1424,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
|
|||||||
totalProcess := len(inputFiles)
|
totalProcess := len(inputFiles)
|
||||||
|
|
||||||
for i, in := range inputFiles {
|
for i, in := range inputFiles {
|
||||||
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Обработка %s", in.Name))
|
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Processing %s", in.Name))
|
||||||
payload, err := os.ReadFile(in.Path)
|
payload, err := os.ReadFile(in.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
||||||
@@ -1436,13 +1477,13 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
|
|||||||
if success == 0 {
|
if success == 0 {
|
||||||
_ = zw.Close()
|
_ = zw.Close()
|
||||||
_ = os.Remove(resultPath)
|
_ = os.Remove(resultPath)
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось конвертировать ни один файл")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to convert any file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
summaryLines := []string{fmt.Sprintf("Конвертировано %d из %d файлов", success, total)}
|
summaryLines := []string{fmt.Sprintf("Converted %d of %d files", success, total)}
|
||||||
if skipped > 0 {
|
if skipped > 0 {
|
||||||
summaryLines = append(summaryLines, fmt.Sprintf("Пропущено неподдерживаемых: %d", skipped))
|
summaryLines = append(summaryLines, fmt.Sprintf("Skipped unsupported: %d", skipped))
|
||||||
}
|
}
|
||||||
summaryLines = append(summaryLines, failures...)
|
summaryLines = append(summaryLines, failures...)
|
||||||
if entry, err := zw.Create("convert-summary.txt"); err == nil {
|
if entry, err := zw.Create("convert-summary.txt"); err == nil {
|
||||||
@@ -1450,7 +1491,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
|
|||||||
}
|
}
|
||||||
if err := zw.Close(); err != nil {
|
if err := zw.Close(); err != nil {
|
||||||
_ = os.Remove(resultPath)
|
_ = os.Remove(resultPath)
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось упаковать результаты")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to pack results")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,7 +1644,7 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
job := s.jobManager.CreateJob(req)
|
job := s.jobManager.CreateJob(req)
|
||||||
s.jobManager.AppendJobLog(job.ID, "Клиент: "+s.ClientVersionString())
|
s.jobManager.AppendJobLog(job.ID, "Client: "+s.ClientVersionString())
|
||||||
s.startCollectionJob(job.ID, req)
|
s.startCollectionJob(job.ID, req)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1632,7 +1673,7 @@ func pingHost(host string, port int, total, need int) (bool, string) {
|
|||||||
}
|
}
|
||||||
n := int(successes.Load())
|
n := int(successes.Load())
|
||||||
if n < need {
|
if n < need {
|
||||||
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need)
|
return false, fmt.Sprintf("Host unreachable: only %d of %d connection attempts to %s succeeded (minimum required: %d)", n, total, addr, need)
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
@@ -1649,12 +1690,12 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
connector, ok := s.getCollector(req.Protocol)
|
connector, ok := s.getCollector(req.Protocol)
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest)
|
jsonError(w, "Protocol connector not registered", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
prober, ok := connector.(collector.Prober)
|
prober, ok := connector.(collector.Prober)
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest)
|
jsonError(w, "Connection probe not supported for this protocol", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1668,31 +1709,26 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result, err := prober.Probe(ctx, toCollectorRequest(req))
|
result, err := prober.Probe(ctx, toCollectorRequest(req))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest)
|
jsonError(w, "Connection probe failed: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
message := "Связь с BMC установлена"
|
message := "BMC connection established"
|
||||||
if result != nil {
|
if result != nil {
|
||||||
switch {
|
if result.HostPoweredOn {
|
||||||
case !result.HostPoweredOn && result.PowerControlAvailable:
|
message = "BMC connection established, host is powered on."
|
||||||
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором."
|
} else {
|
||||||
case !result.HostPoweredOn:
|
message = "BMC connection established, host is powered off. Inventory data may be incomplete."
|
||||||
message = "Связь с BMC установлена, host выключен."
|
|
||||||
default:
|
|
||||||
message = "Связь с BMC установлена, host включен."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hostPowerState := ""
|
hostPowerState := ""
|
||||||
hostPoweredOn := false
|
hostPoweredOn := false
|
||||||
powerControlAvailable := false
|
|
||||||
reachable := false
|
reachable := false
|
||||||
if result != nil {
|
if result != nil {
|
||||||
reachable = result.Reachable
|
reachable = result.Reachable
|
||||||
hostPowerState = strings.TrimSpace(result.HostPowerState)
|
hostPowerState = strings.TrimSpace(result.HostPowerState)
|
||||||
hostPoweredOn = result.HostPoweredOn
|
hostPoweredOn = result.HostPoweredOn
|
||||||
powerControlAvailable = result.PowerControlAvailable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResponse(w, CollectProbeResponse{
|
jsonResponse(w, CollectProbeResponse{
|
||||||
@@ -1700,7 +1736,6 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
|||||||
Protocol: req.Protocol,
|
Protocol: req.Protocol,
|
||||||
HostPowerState: hostPowerState,
|
HostPowerState: hostPowerState,
|
||||||
HostPoweredOn: hostPoweredOn,
|
HostPoweredOn: hostPoweredOn,
|
||||||
PowerControlAvailable: powerControlAvailable,
|
|
||||||
Message: message,
|
Message: message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1737,6 +1772,22 @@ func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, job.toStatusResponse())
|
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) {
|
func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
|
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
|
||||||
@@ -1744,11 +1795,16 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skipCh := make(chan struct{})
|
||||||
|
var skipOnce sync.Once
|
||||||
|
skipFn := func() { skipOnce.Do(func() { close(skipCh) }) }
|
||||||
|
s.jobManager.AttachJobSkip(jobID, skipFn)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
connector, ok := s.getCollector(req.Protocol)
|
connector, ok := s.getCollector(req.Protocol)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Protocol connector not registered")
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
s.jobManager.AppendJobLog(jobID, "Collection completed with error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1811,7 +1867,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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
@@ -1820,7 +1878,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
s.jobManager.AppendJobLog(jobID, "Collection completed with error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1830,7 +1888,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
|||||||
|
|
||||||
applyCollectSourceMetadata(result, req)
|
applyCollectSourceMetadata(result, req)
|
||||||
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
||||||
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
s.jobManager.AppendJobLog(jobID, "Collection completed")
|
||||||
s.SetResult(result)
|
s.SetResult(result)
|
||||||
s.SetDetectedVendor(req.Protocol)
|
s.SetDetectedVendor(req.Protocol)
|
||||||
if job, ok := s.jobManager.GetJob(jobID); ok {
|
if job, ok := s.jobManager.GetJob(jobID); ok {
|
||||||
@@ -2035,8 +2093,6 @@ func toCollectorRequest(req CollectRequest) collector.Request {
|
|||||||
Password: req.Password,
|
Password: req.Password,
|
||||||
Token: req.Token,
|
Token: req.Token,
|
||||||
TLSMode: req.TLSMode,
|
TLSMode: req.TLSMode,
|
||||||
PowerOnIfHostOff: req.PowerOnIfHostOff,
|
|
||||||
StopHostAfterCollect: req.StopHostAfterCollect,
|
|
||||||
DebugPayloads: req.DebugPayloads,
|
DebugPayloads: req.DebugPayloads,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2092,6 +2148,27 @@ func jsonError(w http.ResponseWriter, message string, code int) {
|
|||||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) htmlError(w http.ResponseWriter, message string, code int) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
version := normalizeDisplayVersion(s.config.AppVersion)
|
||||||
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error %d</title></head>`+
|
||||||
|
`<body><h1>Error %d</h1><p>%s</p>`+
|
||||||
|
`<footer style="margin-top:2em;color:#999;font-size:12px">LOGPile %s</footer></body></html>`,
|
||||||
|
code, code, template.HTMLEscapeString(message), template.HTMLEscapeString(version))
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonList(w http.ResponseWriter, items interface{}, totalCount int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"items": items,
|
||||||
|
"total_count": totalCount,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": totalCount,
|
||||||
|
"total_pages": 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// isGPUDevice checks if device class indicates a GPU
|
// isGPUDevice checks if device class indicates a GPU
|
||||||
func isGPUDevice(deviceClass string) bool {
|
func isGPUDevice(deviceClass string) bool {
|
||||||
// Standard PCI class names
|
// Standard PCI class names
|
||||||
|
|||||||
@@ -51,17 +51,20 @@ func TestHandleGetSerials_WithGPUs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
var serials []struct {
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
Component string `json:"component"`
|
Component string `json:"component"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
SerialNumber string `json:"serial_number"`
|
SerialNumber string `json:"serial_number"`
|
||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil {
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
t.Fatalf("Failed to decode response: %v", err)
|
t.Fatalf("Failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
|
serials := resp.Items
|
||||||
|
|
||||||
// Check that we have GPU entries
|
// Check that we have GPU entries
|
||||||
gpuCount := 0
|
gpuCount := 0
|
||||||
@@ -115,13 +118,16 @@ func TestHandleGetSerials_WithoutGPUSerials(t *testing.T) {
|
|||||||
srv.handleGetSerials(w, req)
|
srv.handleGetSerials(w, req)
|
||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
var serials []struct {
|
var resp struct {
|
||||||
|
Items []struct {
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil {
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
t.Fatalf("Failed to decode response: %v", err)
|
t.Fatalf("Failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
|
serials := resp.Items
|
||||||
|
|
||||||
// Check that GPUs without serial numbers are not included
|
// Check that GPUs without serial numbers are not included
|
||||||
for _, s := range serials {
|
for _, s := range serials {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -22,9 +23,11 @@ func (m *JobManager) CreateJob(req CollectRequest) *Job {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
job := &Job{
|
job := &Job{
|
||||||
ID: generateJobID(),
|
ID: generateJobID(),
|
||||||
|
Type: req.Protocol,
|
||||||
Status: CollectStatusQueued,
|
Status: CollectStatusQueued,
|
||||||
Progress: 0,
|
Progress: 0,
|
||||||
Logs: []string{formatCollectLogLine(now, "Задача поставлена в очередь")},
|
Message: "Job queued",
|
||||||
|
Logs: []string{formatCollectLogLine(now, "Job queued")},
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
RequestMeta: CollectRequestMeta{
|
RequestMeta: CollectRequestMeta{
|
||||||
@@ -66,7 +69,7 @@ func (m *JobManager) CancelJob(id string) (*Job, bool) {
|
|||||||
job.Status = CollectStatusCanceled
|
job.Status = CollectStatusCanceled
|
||||||
job.Error = ""
|
job.Error = ""
|
||||||
job.UpdatedAt = time.Now().UTC()
|
job.UpdatedAt = time.Now().UTC()
|
||||||
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Сбор отменен пользователем"))
|
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Collection canceled by user"))
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelFn := job.cancel
|
cancelFn := job.cancel
|
||||||
@@ -122,6 +125,7 @@ func (m *JobManager) AppendJobLog(id, message string) (*Job, bool) {
|
|||||||
job.Logs = append(job.Logs, message)
|
job.Logs = append(job.Logs, message)
|
||||||
job.UpdatedAt = time.Now().UTC()
|
job.UpdatedAt = time.Now().UTC()
|
||||||
job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message)
|
job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message)
|
||||||
|
job.Message = message
|
||||||
|
|
||||||
cloned := cloneJob(job)
|
cloned := cloneJob(job)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -175,6 +179,55 @@ func (m *JobManager) UpdateJobDebugInfo(id string, info *CollectDebugInfo) (*Job
|
|||||||
return cloned, true
|
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, "Skipping stalled requests on user request"))
|
||||||
|
cloned := cloneJob(job)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if skipFn != nil {
|
||||||
|
skipFn()
|
||||||
|
}
|
||||||
|
return cloned, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JobManager) SetJobResult(id string, result map[string]interface{}) bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
job, ok := m.jobs[id]
|
||||||
|
if !ok || job == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
job.Result = result
|
||||||
|
job.UpdatedAt = time.Now().UTC()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool {
|
func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@@ -228,6 +281,10 @@ func cloneJob(job *Job) *Job {
|
|||||||
cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo)
|
cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo)
|
||||||
cloned.CurrentPhase = job.CurrentPhase
|
cloned.CurrentPhase = job.CurrentPhase
|
||||||
cloned.ETASeconds = job.ETASeconds
|
cloned.ETASeconds = job.ETASeconds
|
||||||
|
if job.Result != nil {
|
||||||
|
cloned.Result = maps.Clone(job.Result)
|
||||||
|
}
|
||||||
cloned.cancel = nil
|
cloned.cancel = nil
|
||||||
|
cloned.skipFn = nil
|
||||||
return &cloned
|
return &cloned
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Config struct {
|
|||||||
PreloadFile string
|
PreloadFile string
|
||||||
AppVersion string
|
AppVersion string
|
||||||
AppCommit string
|
AppCommit string
|
||||||
|
ChartVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -90,6 +91,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
||||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
||||||
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
||||||
|
s.mux.HandleFunc("GET /api/export/logs-csv", s.handleExportLogsCSV)
|
||||||
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
|
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
|
||||||
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
|
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
|
||||||
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)
|
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)
|
||||||
@@ -99,6 +101,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
|
||||||
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
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}/cancel", s.handleCollectCancel)
|
||||||
|
s.mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ func newFlowTestServer() (*Server, *httptest.Server) {
|
|||||||
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
|
||||||
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
|
||||||
|
mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
|
||||||
return s, httptest.NewServer(mux)
|
return s, httptest.NewServer(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
releases/v1.21/RELEASE_NOTES.md
Normal file
62
releases/v1.21/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# logpile v1.21
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-15
|
||||||
|
Тег: `v1.21`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
### Inspur/Kaytus (onekeylog) — серийные номера дисков из SOLHostCapture.log
|
||||||
|
|
||||||
|
Когда RAID-контроллер (например, Microchip PM8204-2GB) подключён напрямую через PCIe,
|
||||||
|
BMC возвращает пустой массив в секции `RESTful HDD info`. Серийники дисков теперь
|
||||||
|
восстанавливаются из вывода smartd в `SOLHostCapture.log`:
|
||||||
|
|
||||||
|
- Обрабатываются оба экземпляра файла (`log/sollog/` и `runningdata/var/sollog/`),
|
||||||
|
серийники дедуплицируются по обоим источникам.
|
||||||
|
- Три прохода обогащения: совпадение по модели → позиционное заполнение пустых
|
||||||
|
backplane-слотов → добавление новых записей.
|
||||||
|
- Определяется тип (SSD/HDD), производитель, прошивка и ёмкость.
|
||||||
|
|
||||||
|
### Inspur/Kaytus — корректное определение live-сбора на NF-серверах
|
||||||
|
|
||||||
|
NF-серверы хранения (например, NF5280M6) не имеют GPU-топологии, из-за чего
|
||||||
|
Redfish-коллектор раньше не мог идентифицировать их как Inspur и переходил в
|
||||||
|
режим fallback с AMI-профилем, пробуя несуществующие пути `/Oem/Ami`.
|
||||||
|
|
||||||
|
Добавлено определение по `SystemManufacturer` / `ChassisManufacturer`: значение
|
||||||
|
`"Inspur"` теперь даёт 60 очков — достаточно для входа в matched-режим без
|
||||||
|
GPU-сигналов.
|
||||||
|
|
||||||
|
### Inspur/Kaytus — исправление IDL-событий GPU (Assert/Deassert)
|
||||||
|
|
||||||
|
- Deassert-события больше не отбрасываются как дубликаты Assert — в ключ дедупликации
|
||||||
|
добавлен `EventType`.
|
||||||
|
- Deassert корректно снимает критический статус GPU: раньше GPUы оставались в Critical
|
||||||
|
даже после сброса аварии.
|
||||||
|
- В экспорт Reanimator добавлена секция `bmc_event_summary` — дедуплицированная таблица
|
||||||
|
критических и предупреждающих событий со статусом Active/Resolved на основе пар
|
||||||
|
Assert/Deassert.
|
||||||
|
|
||||||
|
### UI — кнопка PDF
|
||||||
|
|
||||||
|
Добавлена кнопка «PDF» в шапку отчёта. При нажатии отчёт открывается в новой
|
||||||
|
вкладке, откуда можно сохранить в PDF через системный диалог печати браузера.
|
||||||
|
|
||||||
|
### Внутренние изменения (bible-контракты)
|
||||||
|
|
||||||
|
- Идентификаторы нормализованы через `strings.EqualFold` (H3C-парсер).
|
||||||
|
- CSV-экспорт: UTF-8 BOM + разделитель `;`.
|
||||||
|
- Все русскоязычные строки в исходниках переведены на английский (ADL-007).
|
||||||
|
- `Job` расширен полями `Type`, `Message`, `Result`.
|
||||||
|
- List-эндпоинты обёрнуты в конверт `{items, total_count, page, per_page, total_pages}`.
|
||||||
|
- Страницы ошибок рендерят footer с версией.
|
||||||
|
- Логирование переведено на `log/slog` со структурированными атрибутами.
|
||||||
|
|
||||||
|
### pci.ids обновлён
|
||||||
|
|
||||||
|
База идентификаторов PCI-устройств обновлена до актуальной версии от 2026-06-15.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
60
releases/v1.22/RELEASE_NOTES.md
Normal file
60
releases/v1.22/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# logpile v1.22
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-19
|
||||||
|
Тег: `v1.22`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
### HPE iLO AHS — новый парсер
|
||||||
|
|
||||||
|
Добавлена поддержка файлов `*.ahs` (Active Health System), экспортируемых
|
||||||
|
из веб-интерфейса iLO. Парсер извлекает:
|
||||||
|
|
||||||
|
- **Инвентарь оборудования**: плата, процессоры, память, диски, сетевые
|
||||||
|
адаптеры, блоки питания, backplane, RAID-контроллеры.
|
||||||
|
- **Прошивки**: iLO, System ROM, SPS, TPM, SPLD, контроллеры, NIC, backplane —
|
||||||
|
из основного бинарного контейнера и XML-сертификата `bcert.pkg`.
|
||||||
|
- **События**: разбор `.zbb`-файлов с журналом iLO; определение типа и
|
||||||
|
серьёзности по тексту сообщения; очистка однобайтовых frame-сепараторов
|
||||||
|
из концов строк.
|
||||||
|
- **Устойчивость к битым файлам**: если последняя запись в AHS-контейнере
|
||||||
|
обрезана (объявленный размер выходит за границу файла), парсер обрабатывает
|
||||||
|
данные частично вместо возврата ошибки.
|
||||||
|
- Добавлено распознавание модельного ряда **Alletra Storage Server** (ранее
|
||||||
|
`ProductName` оставался пустым).
|
||||||
|
|
||||||
|
### Экспорт логов в CSV («Logs Export»)
|
||||||
|
|
||||||
|
Новая кнопка «**Logs Export**» в шапке интерфейса выгружает все
|
||||||
|
распознанные события (без какой-либо фильтрации) в CSV-файл:
|
||||||
|
|
||||||
|
- Разделитель — точка с запятой (`;`), кодировка — UTF-8 с BOM.
|
||||||
|
- Файл открывается в Excel без дополнительных настроек импорта.
|
||||||
|
- Колонки: `timestamp`, `source`, `severity`, `sensor_type`, `sensor_name`,
|
||||||
|
`event_type`, `id`, `description`, `raw_data`.
|
||||||
|
|
||||||
|
Кнопка «PDF» удалена.
|
||||||
|
|
||||||
|
### Исправления в Reanimator-экспорте
|
||||||
|
|
||||||
|
- `event_logs` в JSON-экспорте Reanimator больше не оказывается пустым для
|
||||||
|
HPE iLO AHS: источник `"HPE iLO"` теперь корректно нормализуется в `"bmc"`.
|
||||||
|
|
||||||
|
### Исправления chart viewer
|
||||||
|
|
||||||
|
- JavaScript `view.js` не загружался в LOGPile из-за отсутствия перезаписи
|
||||||
|
пути `/static/view.js` → `/chart/static/view.js`. Исправлено; фильтры
|
||||||
|
по колонкам в таблицах теперь работают.
|
||||||
|
- Субмодуль chart обновлён до **v2.7**: фильтры вынесены в отдельную строку
|
||||||
|
под заголовком, исправлена минимальная ширина колонок.
|
||||||
|
|
||||||
|
### Обновления зависимостей
|
||||||
|
|
||||||
|
- **pci.ids** (база PCI-устройств) обновлена. Коллектор скорректирован под
|
||||||
|
переименование `0x8086:0x28c0`: `"Volume Management Device NVMe RAID
|
||||||
|
Controller"` → `"Volume Management Device (VMD)"`.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
23
releases/v1.23/RELEASE_NOTES.md
Normal file
23
releases/v1.23/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# logpile v1.23
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-19
|
||||||
|
Тег: `v1.23`
|
||||||
|
|
||||||
|
## Что нового
|
||||||
|
|
||||||
|
### Исправление: HPE iLO AHS файлы больше 10 МБ не обрезаются
|
||||||
|
|
||||||
|
AHS-файлы могут весить сотни мегабайт (типичный пример — 104 МБ). Универсальный
|
||||||
|
лимит в 10 МБ молча обрезал их, из-за чего парсер видел лишь начало файла и
|
||||||
|
извлекал неполный список событий.
|
||||||
|
|
||||||
|
Теперь лимит зависит от расширения: `.ahs` — до **1 ГБ**, прочие
|
||||||
|
одиночные файлы (`.txt`, `.log`) — прежние 10 МБ.
|
||||||
|
|
||||||
|
Для AHS-файла размером 104 МБ количество распознанных событий увеличивается
|
||||||
|
с ~529 до ~12 600.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
@@ -128,6 +128,7 @@ echo ""
|
|||||||
# Show next steps
|
# Show next steps
|
||||||
echo -e "${YELLOW}Next steps:${NC}"
|
echo -e "${YELLOW}Next steps:${NC}"
|
||||||
echo " 1. Create git tag:"
|
echo " 1. Create git tag:"
|
||||||
|
echo " # LOGPile release tags use vN.M, for example: v1.12"
|
||||||
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
|
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
|
||||||
echo ""
|
echo ""
|
||||||
echo " 2. Push tag to remote:"
|
echo " 2. Push tag to remote:"
|
||||||
|
|||||||
2
third_party/pciids
vendored
2
third_party/pciids
vendored
Submodule third_party/pciids updated: 82b1a68f47...a18f209e39
File diff suppressed because it is too large
Load Diff
1284
web/static/js/app.js
1284
web/static/js/app.js
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -7,57 +7,64 @@
|
|||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="page-header">
|
||||||
<div class="app-header-row">
|
<div class="page-header-brand">
|
||||||
<div class="app-header-brand">
|
<p class="page-eyebrow">Diagnostic Workbench</p>
|
||||||
<h1>LOGPile <span class="header-domain">mchus.pro</span></h1>
|
<h1>LOGPile</h1>
|
||||||
<p>Анализатор диагностических данных BMC/IPMI</p>
|
<p class="page-subtitle">BMC diagnostic data analyzer</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="header-log-meta" class="header-log-meta hidden">
|
<div id="header-log-meta" class="header-log-meta hidden">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button>
|
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
|
||||||
<button id="header-raw-btn" class="hidden" onclick="exportData('json')">Export Raw Data</button>
|
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
|
||||||
<button id="header-reanimator-btn" class="hidden" onclick="exportData('reanimator')">Экспорт Reanimator</button>
|
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
|
||||||
<button id="restart-btn" onclick="restartApp()">Перезапуск</button>
|
<button id="header-logs-csv-btn" class="header-action hidden" onclick="exportData('logs-csv')">Logs Export</button>
|
||||||
<button id="exit-btn" onclick="exitApp()">Выход</button>
|
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
|
||||||
</div>
|
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main class="page-main">
|
||||||
<section id="upload-section">
|
<section id="upload-section" class="control-deck">
|
||||||
<div class="source-switch" role="tablist" aria-label="Источник данных">
|
<div class="source-switch" role="tablist" aria-label="Data source">
|
||||||
<button type="button" class="source-switch-btn active" data-source-type="archive">Архив</button>
|
<button type="button" class="source-switch-btn active" data-source-type="archive">Archive</button>
|
||||||
<button type="button" class="source-switch-btn" data-source-type="api">API</button>
|
<button type="button" class="source-switch-btn" data-source-type="api">API</button>
|
||||||
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
|
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="archive-source-content">
|
<div id="archive-source-content" class="surface-panel upload-panel">
|
||||||
<div class="upload-area" id="drop-zone">
|
<h2>Open Archive</h2>
|
||||||
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
|
<p>Upload a support archive, plain log, or raw JSON snapshot to open the hardware report.</p>
|
||||||
|
<div class="upload-area upload-dropzone" id="drop-zone">
|
||||||
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
|
||||||
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
|
<span class="upload-kicker">Archive Import</span>
|
||||||
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p>
|
<strong>Drop a file here</strong>
|
||||||
|
<span class="upload-copy">LOGPile will parse it and open the report immediately.</span>
|
||||||
|
<div class="upload-actions">
|
||||||
|
<button type="button" onclick="document.getElementById('file-input').click()">Select File</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Supported formats: `.ahs`, `.tar.gz`, `.tar`, `.tgz`, `.sds`, `.zip`, `.json`, `.txt`, `.log`</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="upload-status"></div>
|
<div id="upload-status"></div>
|
||||||
<div id="parsers-info" class="parsers-info"></div>
|
<div id="parsers-info" class="parsers-info"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="api-source-content" class="api-placeholder hidden">
|
<div id="api-source-content" class="surface-panel upload-panel hidden">
|
||||||
|
<h2>BMC API</h2>
|
||||||
|
<p>Validate access and start live collection through the production Redfish pipeline.</p>
|
||||||
<form id="api-connect-form" novalidate>
|
<form id="api-connect-form" novalidate>
|
||||||
<h3>Подключение к BMC API</h3>
|
|
||||||
<div id="api-form-errors" class="form-errors hidden"></div>
|
<div id="api-form-errors" class="form-errors hidden"></div>
|
||||||
|
|
||||||
<div class="api-form-grid">
|
<div class="api-form-grid">
|
||||||
<label class="api-form-field" for="api-host">
|
<label class="api-form-field" for="api-host">
|
||||||
<span>Host</span>
|
<span>Host</span>
|
||||||
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 или bmc.example.local">
|
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 or bmc.example.local">
|
||||||
<span class="field-error" data-error-for="host"></span>
|
<span class="field-error" data-error-for="host"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" for="api-port">
|
<label class="api-form-field" for="api-port">
|
||||||
<span>Порт</span>
|
<span>Port</span>
|
||||||
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
|
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
|
||||||
<span class="field-error" data-error-for="port"></span>
|
<span class="field-error" data-error-for="port"></span>
|
||||||
</label>
|
</label>
|
||||||
@@ -69,55 +76,52 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="api-form-field" id="api-password-field" for="api-password">
|
<label class="api-form-field" id="api-password-field" for="api-password">
|
||||||
<span>Пароль</span>
|
<span>Password</span>
|
||||||
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
<input id="api-password" name="password" type="password" autocomplete="current-password">
|
||||||
<span class="field-error" data-error-for="password"></span>
|
<span class="field-error" data-error-for="password"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-connect-btn" type="button">Подключиться</button>
|
<button id="api-connect-btn" type="button">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="api-connect-status" class="api-connect-status"></div>
|
<div id="api-connect-status" class="api-connect-status"></div>
|
||||||
<div id="api-probe-options" class="api-probe-options hidden">
|
<div id="api-probe-options" class="api-probe-options hidden">
|
||||||
<label class="api-form-checkbox" for="api-power-on">
|
<div id="api-host-off-warning" class="api-host-off-warning hidden">
|
||||||
<input id="api-power-on" name="power_on_if_host_off" type="checkbox">
|
⚠ Host is powered off. Inventory data may be incomplete.
|
||||||
<span>Включить перед сбором</span>
|
</div>
|
||||||
</label>
|
|
||||||
<label class="api-form-checkbox" for="api-power-off">
|
|
||||||
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
|
|
||||||
<span>Выключить после сбора</span>
|
|
||||||
</label>
|
|
||||||
<div class="api-probe-options-separator"></div>
|
|
||||||
<label class="api-form-checkbox" for="api-debug-payloads">
|
<label class="api-form-checkbox" for="api-debug-payloads">
|
||||||
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
|
||||||
<span>Сбор расширенных метрик для отладки</span>
|
<span>Collect extended diagnostics</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<button id="api-collect-btn" type="submit">Собрать</button>
|
<button id="api-collect-btn" type="submit">Collect</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
<section id="api-job-status" class="job-status hidden" aria-live="polite">
|
||||||
<div class="job-status-header">
|
<div class="job-status-header">
|
||||||
<h4>Статус задачи сбора</h4>
|
<h4>Collection Job Status</h4>
|
||||||
<button id="cancel-job-btn" type="button">Отменить</button>
|
<div class="job-status-actions">
|
||||||
|
<button id="skip-hung-btn" type="button" class="hidden" title="Abort hung requests and continue with analysis of collected data">Skip Hung Requests</button>
|
||||||
|
<button id="cancel-job-btn" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-status-meta">
|
<div class="job-status-meta">
|
||||||
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
|
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
|
||||||
<div>
|
<div>
|
||||||
<span class="meta-label">Статус:</span>
|
<span class="meta-label">Status:</span>
|
||||||
<span id="job-status-value" class="job-status-badge">Queued</span>
|
<span id="job-status-value" class="job-status-badge">Queued</span>
|
||||||
</div>
|
</div>
|
||||||
<div><span class="meta-label">Этап:</span> <span id="job-progress-value">Сбор данных...</span></div>
|
<div><span class="meta-label">Stage:</span> <span id="job-progress-value">Collecting data...</span></div>
|
||||||
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
|
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-progress" aria-label="Прогресс задачи">
|
<div class="job-progress" aria-label="Job progress">
|
||||||
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
|
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-active-modules" class="job-active-modules hidden">
|
<div id="job-active-modules" class="job-active-modules hidden">
|
||||||
<p class="meta-label">Активные модули:</p>
|
<p class="meta-label">Active modules:</p>
|
||||||
<div id="job-active-modules-list" class="job-module-chips"></div>
|
<div id="job-active-modules-list" class="job-module-chips"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="job-debug-info" class="job-debug-info hidden">
|
<div id="job-debug-info" class="job-debug-info hidden">
|
||||||
@@ -126,23 +130,23 @@
|
|||||||
<div id="job-phase-telemetry" class="job-phase-telemetry"></div>
|
<div id="job-phase-telemetry" class="job-phase-telemetry"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="job-status-logs">
|
<div class="job-status-logs">
|
||||||
<p class="meta-label">Журнал шагов:</p>
|
<p class="meta-label">Step log:</p>
|
||||||
<ul id="job-logs-list"></ul>
|
<ul id="job-logs-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="convert-source-content" class="api-placeholder hidden">
|
<div id="convert-source-content" class="surface-panel upload-panel hidden">
|
||||||
<h3>Пакетная выгрузка Reanimator</h3>
|
<h2>Batch Convert</h2>
|
||||||
<p>Выберите папку с файлами поддерживаемого типа. Для каждого файла будет создан отдельный экспорт Reanimator.</p>
|
<p>Select a folder with supported files. A separate Reanimator export will be produced for each file.</p>
|
||||||
<div class="api-form-actions">
|
<div class="api-form-actions">
|
||||||
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
|
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
|
||||||
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Выбрать папку</button>
|
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Choose Folder</button>
|
||||||
<button id="convert-run-btn" type="button">Конвертировать в Reanimator</button>
|
<button id="convert-run-btn" type="button">Convert to Reanimator</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
|
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
|
||||||
<div class="convert-progress-meta">
|
<div class="convert-progress-meta">
|
||||||
<span id="convert-progress-label">Подготовка...</span>
|
<span id="convert-progress-label">Preparing...</span>
|
||||||
<span id="convert-progress-value">0%</span>
|
<span id="convert-progress-value">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="convert-progress-track">
|
<div class="convert-progress-track">
|
||||||
@@ -155,26 +159,43 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="data-section" class="hidden">
|
<section id="data-section" class="hidden">
|
||||||
<section class="result-panel">
|
<section class="viewer-panel">
|
||||||
<div class="audit-viewer-shell">
|
<div class="audit-viewer-shell">
|
||||||
<iframe
|
<iframe
|
||||||
id="audit-viewer-frame"
|
id="audit-viewer-frame"
|
||||||
class="audit-viewer-frame"
|
class="audit-viewer-frame"
|
||||||
title="Reanimator chart viewer"
|
title="Hardware report"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
scrolling="no"
|
scrolling="no"
|
||||||
referrerpolicy="same-origin">
|
referrerpolicy="same-origin">
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="parse-errors-section" class="parse-errors-section hidden">
|
||||||
|
<div class="parse-errors-header" onclick="toggleParseErrors()">
|
||||||
|
<span id="parse-errors-title">Collection warnings</span>
|
||||||
|
<span id="parse-errors-toggle" class="parse-errors-toggle">▲</span>
|
||||||
|
</div>
|
||||||
|
<div id="parse-errors-body" class="parse-errors-body">
|
||||||
|
<table class="parse-errors-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Section</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="parse-errors-rows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer class="page-footer">
|
||||||
<div class="footer-buttons">
|
|
||||||
</div>
|
|
||||||
<div class="footer-info">
|
<div class="footer-info">
|
||||||
<p>Автор: <a href="https://mchus.pro" target="_blank">mchus.pro</a> | <a href="https://git.mchus.pro/mchus/logpile" target="_blank">Git Repository</a>{{if .AppVersion}} | v{{.AppVersion}}{{end}}</p>
|
<p>{{if .AppVersion}}LOGPile {{.AppVersion}}{{end}}{{if and .AppVersion .ChartVersion}} · {{end}}{{if .ChartVersion}}Chart {{.ChartVersion}}{{end}}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user