export: align reanimator and enrich redfish metrics
This commit is contained in:
@@ -30,25 +30,41 @@ Implementation files:
|
||||
- `internal/exporter/reanimator_models.go`
|
||||
- `internal/exporter/reanimator_converter.go`
|
||||
- `internal/server/handlers.go`
|
||||
- `bible-local/docs/hardware-ingest-contract.md`
|
||||
|
||||
Conversion rules:
|
||||
- canonical source is `hardware.devices`
|
||||
- canonical source is merged canonical inventory derived from `hardware.devices` plus legacy hardware slices
|
||||
- output must conform to the strict Reanimator ingest contract in `docs/hardware-ingest-contract.md`
|
||||
- timestamps are RFC3339
|
||||
- status is normalized to Reanimator-friendly values
|
||||
- missing PCIe serials may be generated from board serial + slot
|
||||
- missing CPU serials may be generated from board serial + socket
|
||||
- CPU `firmware` field means CPU microcode, not generic processor firmware inventory
|
||||
- `NULL`-style board manufacturer/product values are treated as absent
|
||||
- optional component telemetry/health fields are exported when LOGPile already has the data
|
||||
- partial `hardware.devices` must not suppress components still present only in legacy parser/collector fields
|
||||
- `present` is not serialized for exported components; presence is expressed by the existence of the component record itself
|
||||
|
||||
## Inclusion rules
|
||||
|
||||
Included:
|
||||
- empty memory slots (`present=false`) for topology visibility
|
||||
- PCIe-class devices even when serial must be synthesized
|
||||
- contract `v2.4` component telemetry and health fields when source data exists
|
||||
- hardware sensors grouped into `fans`, `power`, `temperatures`, `other`
|
||||
- Redfish linked metric docs that carry component telemetry: `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`, `EnvironmentMetrics`, `Metrics`
|
||||
|
||||
Excluded:
|
||||
- memory with missing serial number
|
||||
- memory with `present=false` or `status=Empty`
|
||||
- CPUs with `present=false`
|
||||
- storage without `serial_number`
|
||||
- storage with `present=false`
|
||||
- power supplies without `serial_number`
|
||||
- power supplies with `present=false`
|
||||
- non-present network adapters
|
||||
- non-present PCIe / GPU devices
|
||||
- device-bound firmware duplicated at top-level firmware list
|
||||
- any field not present in the strict ingest contract
|
||||
|
||||
## Batch convert
|
||||
|
||||
@@ -61,3 +77,8 @@ Behavior:
|
||||
- each file is parsed independently
|
||||
- one bad file must not fail the whole batch if at least one conversion succeeds
|
||||
- result artifact is temporary and deleted after download
|
||||
|
||||
## CSV export
|
||||
|
||||
`GET /api/export/csv` uses the same merged canonical inventory as Reanimator export,
|
||||
with legacy network-card fallback kept only for records that still have no canonical device match.
|
||||
|
||||
@@ -299,4 +299,159 @@ not the firmware inventory ID. The patterns were dead code from the moment they
|
||||
correct prefix+digit checks (`"gpu" + digit`, `"nic" + digit`) and explicit string checks
|
||||
(`"nvmecontroller"`, `"power supply"`, `"software inventory"`).
|
||||
|
||||
## ADL-020 — Dell TSR device-bound firmware filtered via FQDD; InfiniBand routed to NetworkAdapters
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Context:** Dell TSR `sysinfo_DCIM_SoftwareIdentity.xml` lists firmware for every installed
|
||||
component. `parseSoftwareIdentityXML` dumped all of these into `hardware.firmware` without
|
||||
filtering, so device-bound entries such as `"Mellanox Network Adapter"` (FQDD `InfiniBand.Slot.1-1`)
|
||||
and `"PERC H755 Front"` (FQDD `RAID.SL.3-1`) appeared in the reanimator export alongside system
|
||||
firmware like BIOS and iDRAC. Confirmed on PowerEdge R6625 (8VS2LG4).
|
||||
|
||||
Additionally, `DCIM_InfiniBandView` was not handled in the parser switch, so Mellanox ConnectX-6
|
||||
appeared only as a PCIe device with `model: "16x or x16"` (from `DataBusWidth` fallback).
|
||||
`parseControllerView` called `addFirmware` with description `"storage controller"` instead of the
|
||||
FQDD, so the FQDD-based filter in the exporter could not remove it.
|
||||
|
||||
**Decision:**
|
||||
1. `isDeviceBoundFirmwareFQDD` extended with `"infiniband."` and `"fc."` prefixes; `"raid.backplane."`
|
||||
broadened to `"raid."` to cover `RAID.SL.*`, `RAID.Integrated.*`, etc.
|
||||
2. `DCIM_InfiniBandView` routed to `parseNICView` → device appears as `NetworkAdapter` with correct
|
||||
firmware, MAC address, and VendorID/DeviceID.
|
||||
3. `"InfiniBand."` added to `pcieFQDDNoisePrefix` to suppress the duplicate `DCIM_PCIDeviceView`
|
||||
entry (DataBusWidth-only, no useful data).
|
||||
4. `parseControllerView` now passes `fqdd` as the `addFirmware` description so the FQDD filter
|
||||
removes the entry in the exporter.
|
||||
5. `parsePCIeDeviceView` now prioritises `props["description"]` (chip model, e.g. `"MT28908 Family
|
||||
[ConnectX-6]"`) over `props["devicedescription"]` (location string) for `pcie.Description`.
|
||||
6. `convertPCIeDevices` model fallback order: `PartNumber → Description → DeviceClass`.
|
||||
|
||||
**Consequences:**
|
||||
- `hardware.firmware` contains only system-level entries; NIC/RAID/storage-controller firmware
|
||||
lives on the respective device record.
|
||||
- `TestParseDellInfiniBandView` and `TestIsDeviceBoundFirmwareFQDD` guard the regression.
|
||||
- Any future Dell TSR device class whose FQDD prefix is not yet in the prefix list may still leak;
|
||||
extend `isDeviceBoundFirmwareFQDD` and add a test case when encountered.
|
||||
|
||||
---
|
||||
|
||||
## ADL-021 — pci.ids enrichment: chip model and vendor resolved from PCI IDs when source data is generic or missing
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Context:**
|
||||
Dell TSR `DCIM_InfiniBandView.ProductName` reports a generic marketing name ("Mellanox Network
|
||||
Adapter") instead of the precise chip identifier ("MT28908 Family [ConnectX-6]"). The actual
|
||||
chip model is available in `pci.ids` by VendorID:DeviceID (15B3:101B). Vendor name may also be
|
||||
absent when no `VendorName` / `Manufacturer` property is present.
|
||||
|
||||
The general rule was established: *if model is not found in source data but PCI IDs are known,
|
||||
resolve model from `pci.ids`*. This rule applies broadly across all export paths.
|
||||
|
||||
**Decision (two-layer enrichment):**
|
||||
1. **Parser layer (Dell, `parseNICView`):** When `VendorID != 0 && DeviceID != 0`, prefer
|
||||
`pciids.DeviceName(vendorID, deviceID)` over the product name from logs. This makes the chip
|
||||
identifier the primary model for NIC/InfiniBand adapters (more specific than marketing name).
|
||||
Fill `Vendor` from `pciids.VendorName(vendorID)` when the vendor field is otherwise empty.
|
||||
Same fallback applied in `parsePCIeDeviceView` for empty `Description`.
|
||||
2. **Exporter layer (`convertPCIeFromDevices`):** General rule — when `d.Model == ""` after all
|
||||
legacy fallbacks and `VendorID != 0 && DeviceID != 0`, set `model = pciids.DeviceName(...)`.
|
||||
Also fill empty `manufacturer` from `pciids.VendorName(...)`. This covers all parsers/sources.
|
||||
|
||||
**Consequences:**
|
||||
- Mellanox InfiniBand slot now reports `model: "MT28908 Family [ConnectX-6]"` and
|
||||
`manufacturer: "Mellanox Technologies"` in the reanimator export.
|
||||
- For NICs where pci.ids has no entry, the original product name is kept (pci.ids returns "").
|
||||
- `TestParseDellInfiniBandView` asserts the model and vendor from pci.ids.
|
||||
|
||||
---
|
||||
|
||||
## ADL-022 — CPUAffinity parsed into NUMANode for PCIe, NIC, and controller devices
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Context:**
|
||||
Dell TSR DCIM view classes report `CPUAffinity` for NIC, InfiniBand, PCIe, and controller
|
||||
devices. Values are "1", "2" (NUMA node index), or "Not Applicable" (for devices that bridge
|
||||
both CPUs or have no CPU affinity). This data is needed for topology-aware diagnostics.
|
||||
|
||||
**Decision:**
|
||||
- Add `NUMANode int` (JSON: `"numa_node,omitempty"`) to `models.PCIeDevice`,
|
||||
`models.NetworkAdapter`, `models.HardwareDevice`, and `ReanimatorPCIe`.
|
||||
- Parse from `props["cpuaffinity"]` using `parseIntLoose`: numeric values ("1", "2") map
|
||||
directly; "Not Applicable" returns 0 (omitted via `omitempty`).
|
||||
- Thread through `buildDevicesFromLegacy` (PCIe and NIC sections) and `convertPCIeFromDevices`.
|
||||
- `parseControllerView` also parses CPUAffinity since RAID controllers have NUMA affinity.
|
||||
|
||||
**Consequences:**
|
||||
- `numa_node: 1` or `2` appears in reanimator export for devices with known affinity.
|
||||
- Value 0 / absent means "not reported" — covers both "Not Applicable" and sources that don't
|
||||
provide CPUAffinity at all.
|
||||
- `TestParseDellCPUAffinity` verifies numeric values parsed correctly and "Not Applicable"→0.
|
||||
|
||||
---
|
||||
|
||||
## ADL-023 — Reanimator export must match ingest contract exactly
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Context:**
|
||||
LOGPile's Reanimator export had drifted from the strict ingest contract. It emitted fields that
|
||||
Reanimator does not currently accept (`status_at_collection`, `numa_node`),
|
||||
while missing fields and sections now present in the contract (`hardware.sensors`,
|
||||
`pcie_devices[].mac_addresses`). Memory export rules also diverged from the ingest side: empty or
|
||||
serial-less DIMMs were still exported.
|
||||
|
||||
**Decision:**
|
||||
- Treat the Reanimator ingest contract as the authoritative schema for `GET /api/export/reanimator`.
|
||||
- Emit only fields present in the current upstream contract revision.
|
||||
- Add `hardware.sensors`, `pcie_devices[].mac_addresses`, `pcie_devices[].numa_node`, and
|
||||
upstream-approved component telemetry/health fields.
|
||||
- Leave out fields that are still not part of the upstream contract.
|
||||
- Map internal `source_type=archive` to external `source_type=logfile`.
|
||||
- Skip memory entries that are empty, not present, or missing serial numbers.
|
||||
- Generate CPU and PCIe serials only in the forms allowed by the contract.
|
||||
- Mirror the applied contract in `bible-local/docs/hardware-ingest-contract.md`.
|
||||
|
||||
**Consequences:**
|
||||
- Some previously exported diagnostic fields are intentionally dropped from the Reanimator payload
|
||||
until the upstream contract adds them.
|
||||
- Internal models may retain richer fields than the current export schema.
|
||||
- `hardware.devices` is canonical only after merge with legacy hardware slices; partial parser-owned
|
||||
canonical records must not hide CPUs, memory, storage, NICs, or PSUs still stored in legacy
|
||||
fields.
|
||||
- CSV and Reanimator exports must use the same merged canonical inventory to avoid divergent export
|
||||
contents across surfaces.
|
||||
- Future exporter changes must update both the code and the mirrored contract document together.
|
||||
|
||||
---
|
||||
|
||||
## ADL-024 — Component presence is implicit; Redfish linked metrics are part of replay correctness
|
||||
|
||||
**Date:** 2026-03-15
|
||||
**Context:**
|
||||
The upstream ingest contract allows `present`, but current export semantics do not need to send
|
||||
`present=true` for populated components. At the same time, several important Redfish component
|
||||
telemetry fields were only available through linked metric resources such as `ProcessorMetrics`,
|
||||
`MemoryMetrics`, and `DriveMetrics`. Without collecting and replaying these linked documents,
|
||||
live collection and raw snapshot replay still underreported component health fields.
|
||||
|
||||
**Decision:**
|
||||
- Do not serialize `present=true` in Reanimator export. Presence is represented by the presence of
|
||||
the component record itself.
|
||||
- Do not export component records marked `present=false`.
|
||||
- Interpret CPU `firmware` in Reanimator payload as CPU microcode.
|
||||
- Treat Redfish linked metric resources `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`,
|
||||
`EnvironmentMetrics`, and generic `Metrics` as part of analyzer correctness when they are linked
|
||||
from component resources.
|
||||
- Replay logic must merge these linked metric resources back into CPU, memory, storage, PCIe, GPU,
|
||||
NIC, and PSU component `Details` the same way live collection expects them to be used.
|
||||
|
||||
**Consequences:**
|
||||
- Reanimator payloads are smaller and avoid redundant `present=true` noise while still excluding
|
||||
empty slots and absent components.
|
||||
- Any future exporter change that reintroduces serialized component presence needs an explicit
|
||||
contract review.
|
||||
- Raw Redfish snapshot completeness now includes linked per-component metric resources, not only
|
||||
top-level inventory members.
|
||||
- CPU microcode is no longer expected in top-level `hardware.firmware`; it belongs on the CPU
|
||||
component record.
|
||||
|
||||
<!-- Add new decisions below this line using the format above -->
|
||||
|
||||
@@ -21,6 +21,7 @@ Keep top-level docs minimal and put maintained architecture/API contracts here.
|
||||
| [05-collectors.md](05-collectors.md) | Live collection behavior |
|
||||
| [06-parsers.md](06-parsers.md) | Archive parser framework and vendor coverage |
|
||||
| [07-exporters.md](07-exporters.md) | Raw export, Reanimator export, batch convert |
|
||||
| [docs/hardware-ingest-contract.md](docs/hardware-ingest-contract.md) | Reanimator ingest schema mirrored locally |
|
||||
| [08-build-release.md](08-build-release.md) | Build and release workflow |
|
||||
| [09-testing.md](09-testing.md) | Test expectations and regression rules |
|
||||
| [10-decisions.md](10-decisions.md) | Architectural Decision Log |
|
||||
|
||||
330
bible-local/docs/hardware-ingest-contract.md
Normal file
330
bible-local/docs/hardware-ingest-contract.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Hardware Ingest Contract
|
||||
|
||||
Version: 2.4
|
||||
Updated: 2026-03-15
|
||||
Source: Reanimator Core `hardware-ingest-contract.md`
|
||||
|
||||
This file mirrors the external Reanimator hardware-ingest contract that LOGPile targets.
|
||||
The Reanimator endpoint uses strict JSON decoding. Any field not listed here must not be emitted.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```http
|
||||
POST /ingest/hardware
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
The ingest request is asynchronous.
|
||||
Accepted response:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"job_id": "job_01J..."
|
||||
}
|
||||
```
|
||||
|
||||
Final result is available from:
|
||||
|
||||
```http
|
||||
GET /ingest/hardware/jobs/{job_id}
|
||||
```
|
||||
|
||||
## Top-level payload
|
||||
|
||||
```json
|
||||
{
|
||||
"filename": "redfish://10.10.10.103",
|
||||
"source_type": "api",
|
||||
"protocol": "redfish",
|
||||
"target_host": "10.10.10.103",
|
||||
"collected_at": "2026-02-10T15:30:00Z",
|
||||
"hardware": {
|
||||
"board": {},
|
||||
"firmware": [],
|
||||
"cpus": [],
|
||||
"memory": [],
|
||||
"storage": [],
|
||||
"pcie_devices": [],
|
||||
"power_supplies": [],
|
||||
"sensors": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Top-level rules
|
||||
|
||||
- `collected_at` is required and must be RFC3339
|
||||
- `hardware.board.serial_number` is required
|
||||
- `source_type` allowed values: `api`, `logfile`, `manual`
|
||||
- `protocol` allowed values: `redfish`, `ipmi`, `snmp`, `ssh`
|
||||
- Unknown JSON keys are rejected by Reanimator
|
||||
|
||||
## Shared component status fields
|
||||
|
||||
Allowed on `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`:
|
||||
|
||||
- `status`
|
||||
- `status_checked_at`
|
||||
- `status_changed_at`
|
||||
- `status_history`
|
||||
- `error_description`
|
||||
|
||||
`status_history[]` items:
|
||||
|
||||
- `status`
|
||||
- `changed_at`
|
||||
- `details`
|
||||
|
||||
Do not emit `status_at_collection`; it is not part of the current strict ingest schema.
|
||||
|
||||
## `hardware.board`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `serial_number` required
|
||||
- `manufacturer`
|
||||
- `product_name`
|
||||
- `part_number`
|
||||
- `uuid`
|
||||
|
||||
String values equal to `"NULL"` should be omitted.
|
||||
|
||||
## `hardware.firmware`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `device_name`
|
||||
- `version`
|
||||
|
||||
Only system-level firmware belongs here.
|
||||
Device-bound firmware must stay on the relevant device record and must not be duplicated at the top level.
|
||||
|
||||
## `hardware.cpus`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `socket`
|
||||
- `model`
|
||||
- `manufacturer`
|
||||
- `cores`
|
||||
- `threads`
|
||||
- `frequency_mhz`
|
||||
- `max_frequency_mhz`
|
||||
- `temperature_c`
|
||||
- `power_w`
|
||||
- `throttled`
|
||||
- `correctable_error_count`
|
||||
- `uncorrectable_error_count`
|
||||
- `life_remaining_pct`
|
||||
- `life_used_pct`
|
||||
- `serial_number`
|
||||
- `firmware`
|
||||
- `present`
|
||||
- shared status fields
|
||||
|
||||
Exporter rule:
|
||||
- if CPU serial is missing, generate `{board_serial}-CPU-{socket}`
|
||||
|
||||
## `hardware.memory`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `slot`
|
||||
- `location`
|
||||
- `present`
|
||||
- `serial_number`
|
||||
- `part_number`
|
||||
- `manufacturer`
|
||||
- `size_mb`
|
||||
- `type`
|
||||
- `max_speed_mhz`
|
||||
- `current_speed_mhz`
|
||||
- `temperature_c`
|
||||
- `correctable_ecc_error_count`
|
||||
- `uncorrectable_ecc_error_count`
|
||||
- `life_remaining_pct`
|
||||
- `life_used_pct`
|
||||
- `spare_blocks_remaining_pct`
|
||||
- `performance_degraded`
|
||||
- `data_loss_detected`
|
||||
- shared status fields
|
||||
|
||||
Exporter rules:
|
||||
- skip memory items with missing `serial_number`
|
||||
- skip memory items with `present=false`
|
||||
- skip memory items with `status=Empty`
|
||||
|
||||
## `hardware.storage`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `slot`
|
||||
- `serial_number`
|
||||
- `model`
|
||||
- `manufacturer`
|
||||
- `type`
|
||||
- `interface`
|
||||
- `size_gb`
|
||||
- `temperature_c`
|
||||
- `power_on_hours`
|
||||
- `power_cycles`
|
||||
- `unsafe_shutdowns`
|
||||
- `media_errors`
|
||||
- `error_log_entries`
|
||||
- `written_bytes`
|
||||
- `read_bytes`
|
||||
- `life_used_pct`
|
||||
- `firmware`
|
||||
- `present`
|
||||
- `remaining_endurance_pct`
|
||||
- `life_remaining_pct`
|
||||
- `available_spare_pct`
|
||||
- `reallocated_sectors`
|
||||
- `current_pending_sectors`
|
||||
- `offline_uncorrectable`
|
||||
- shared status fields
|
||||
|
||||
Exporter rule:
|
||||
- skip storage items with missing `serial_number`
|
||||
|
||||
## `hardware.pcie_devices`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `slot`
|
||||
- `vendor_id`
|
||||
- `device_id`
|
||||
- `numa_node`
|
||||
- `temperature_c`
|
||||
- `power_w`
|
||||
- `life_remaining_pct`
|
||||
- `life_used_pct`
|
||||
- `ecc_corrected_total`
|
||||
- `ecc_uncorrected_total`
|
||||
- `hw_slowdown`
|
||||
- `battery_charge_pct`
|
||||
- `battery_health_pct`
|
||||
- `battery_temperature_c`
|
||||
- `battery_voltage_v`
|
||||
- `battery_replace_required`
|
||||
- `sfp_temperature_c`
|
||||
- `sfp_tx_power_dbm`
|
||||
- `sfp_rx_power_dbm`
|
||||
- `sfp_voltage_v`
|
||||
- `sfp_bias_ma`
|
||||
- `bdf`
|
||||
- `device_class`
|
||||
- `manufacturer`
|
||||
- `model`
|
||||
- `serial_number`
|
||||
- `firmware`
|
||||
- `link_width`
|
||||
- `link_speed`
|
||||
- `max_link_width`
|
||||
- `max_link_speed`
|
||||
- `mac_addresses`
|
||||
- `present`
|
||||
- shared status fields
|
||||
|
||||
Known `device_class` values:
|
||||
|
||||
- `MassStorageController`
|
||||
- `StorageController`
|
||||
- `NetworkController`
|
||||
- `EthernetController`
|
||||
- `FibreChannelController`
|
||||
- `VideoController`
|
||||
- `ProcessingAccelerator`
|
||||
- `DisplayController`
|
||||
|
||||
Exporter rules:
|
||||
|
||||
- if PCIe serial is missing or placeholder-like, generate `{board_serial}-PCIE-{slot}`
|
||||
- `numa_node` is allowed again in the upstream contract
|
||||
- network adapters should emit `mac_addresses` when available
|
||||
- do not emit fields outside the upstream contract
|
||||
|
||||
## `hardware.power_supplies`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `slot`
|
||||
- `present`
|
||||
- `serial_number`
|
||||
- `part_number`
|
||||
- `model`
|
||||
- `vendor`
|
||||
- `wattage_w`
|
||||
- `firmware`
|
||||
- `input_type`
|
||||
- `input_voltage`
|
||||
- `input_power_w`
|
||||
- `output_power_w`
|
||||
- `temperature_c`
|
||||
- `life_remaining_pct`
|
||||
- `life_used_pct`
|
||||
- shared status fields
|
||||
|
||||
Exporter rule:
|
||||
- skip PSUs with missing `serial_number`
|
||||
|
||||
## `hardware.sensors`
|
||||
|
||||
Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"fans": [],
|
||||
"power": [],
|
||||
"temperatures": [],
|
||||
"other": []
|
||||
}
|
||||
```
|
||||
|
||||
### `sensors.fans`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `name` required
|
||||
- `location`
|
||||
- `rpm`
|
||||
- `status`
|
||||
|
||||
### `sensors.power`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `name` required
|
||||
- `location`
|
||||
- `voltage_v`
|
||||
- `current_a`
|
||||
- `power_w`
|
||||
- `status`
|
||||
|
||||
### `sensors.temperatures`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `name` required
|
||||
- `location`
|
||||
- `celsius`
|
||||
- `threshold_warning_celsius`
|
||||
- `threshold_critical_celsius`
|
||||
- `status`
|
||||
|
||||
### `sensors.other`
|
||||
|
||||
Allowed fields:
|
||||
|
||||
- `name` required
|
||||
- `location`
|
||||
- `value`
|
||||
- `unit`
|
||||
- `status`
|
||||
|
||||
Sensor rules:
|
||||
|
||||
- dedupe within one payload by `(section, name)`, keeping the first item
|
||||
- skip sensors without `name`
|
||||
- the current LOGPile exporter maps generic `SensorReading` values into these four groups heuristically
|
||||
@@ -417,11 +417,13 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,14 +444,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Some implementations return drive fields right in storage member object.
|
||||
if looksLikeDrive(member) {
|
||||
out = append(out, parseDrive(member))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||
}
|
||||
|
||||
// Supermicro/RAID implementations can expose physical disks under chassis enclosures
|
||||
@@ -459,7 +463,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
if looksLikeDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
@@ -477,7 +482,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||
}) {
|
||||
if looksLikeDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,7 +499,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
if !ok || !looksLikeDrive(devDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(devDoc))
|
||||
out = append(out, parseDriveWithSupplementalDocs(devDoc))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,7 +514,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
if !looksLikeDrive(driveDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
for _, chassisPath := range chassisPaths {
|
||||
@@ -519,7 +526,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
if !looksLikeDrive(driveDoc) {
|
||||
continue
|
||||
}
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,7 +583,11 @@ func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client,
|
||||
continue
|
||||
}
|
||||
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, pcieDoc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
||||
}
|
||||
nics = append(nics, nic)
|
||||
}
|
||||
@@ -591,7 +603,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
|
||||
// Redfish 2022+/X14+ commonly uses PowerSubsystem as the primary source.
|
||||
if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
|
||||
for _, doc := range memberDocs {
|
||||
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
||||
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -604,7 +617,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
||||
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -654,6 +668,37 @@ func redfishLinkedPath(doc map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) getLinkedSupplementalDocs(
|
||||
ctx context.Context,
|
||||
client *http.Client,
|
||||
req Request,
|
||||
baseURL string,
|
||||
doc map[string]interface{},
|
||||
keys ...string,
|
||||
) []map[string]interface{} {
|
||||
if len(doc) == 0 || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
seen := make(map[string]struct{})
|
||||
for _, key := range keys {
|
||||
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU {
|
||||
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
|
||||
for _, systemPath := range systemPaths {
|
||||
@@ -681,7 +726,11 @@ func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client,
|
||||
continue
|
||||
}
|
||||
|
||||
gpu := parseGPU(doc, functionDocs, idx)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
|
||||
idx++
|
||||
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
||||
continue
|
||||
@@ -723,7 +772,11 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
||||
if looksLikeGPU(doc, functionDocs) {
|
||||
continue
|
||||
}
|
||||
dev := parsePCIeDevice(doc, functionDocs)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
|
||||
if isUnidentifiablePCIeDevice(dev) {
|
||||
continue
|
||||
}
|
||||
@@ -738,7 +791,8 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
|
||||
continue
|
||||
}
|
||||
for idx, fn := range functionDocs {
|
||||
dev := parsePCIeFunction(fn, idx+1)
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")
|
||||
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
|
||||
out = append(out, dev)
|
||||
}
|
||||
}
|
||||
@@ -1333,7 +1387,7 @@ func shouldAdaptiveNVMeProbe(collectionDoc map[string]interface{}) bool {
|
||||
// RoT and similar component chassis that expose an empty /Drives collection.
|
||||
func chassisTypeCanHaveNVMe(chassisType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(chassisType)) {
|
||||
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
|
||||
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
|
||||
"component", // ERoT, IRoT, BMC, FPGA sub-chassis
|
||||
"zone": // HGX_Chassis_0 fabric zone
|
||||
return false
|
||||
@@ -1945,9 +1999,15 @@ func shouldCrawlPath(path string) bool {
|
||||
if strings.Contains(normalized, "/Memory/") {
|
||||
after := strings.SplitN(normalized, "/Memory/", 2)
|
||||
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
|
||||
// Keep direct DIMM resources (/Memory/<slot>) but skip nested subresources
|
||||
// like /Memory/<slot>/Assembly and /Memory/<slot>/MemoryMetrics.
|
||||
return false
|
||||
// Keep direct DIMM resources and selected metrics subresources, but skip
|
||||
// unrelated nested branches like Assembly.
|
||||
return strings.HasSuffix(normalized, "/MemoryMetrics")
|
||||
}
|
||||
}
|
||||
if strings.Contains(normalized, "/Processors/") {
|
||||
after := strings.SplitN(normalized, "/Processors/", 2)
|
||||
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
|
||||
return strings.HasSuffix(normalized, "/ProcessorMetrics")
|
||||
}
|
||||
}
|
||||
// Non-inventory top-level service branches.
|
||||
@@ -2551,6 +2611,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
|
||||
L2CacheKB: l2,
|
||||
L3CacheKB: l3,
|
||||
Status: mapStatus(doc["Status"]),
|
||||
Details: redfishCPUDetails(doc),
|
||||
})
|
||||
}
|
||||
return cpus
|
||||
@@ -2614,12 +2675,102 @@ func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
|
||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||
PartNumber: asString(doc["PartNumber"]),
|
||||
Status: mapStatus(doc["Status"]),
|
||||
Details: redfishMemoryDetails(doc),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redfishCPUDetails(doc map[string]interface{}) map[string]any {
|
||||
return redfishCPUDetailsAcrossDocs(doc)
|
||||
}
|
||||
|
||||
func redfishCPUDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
|
||||
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
|
||||
details := make(map[string]any)
|
||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
|
||||
))
|
||||
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
|
||||
))
|
||||
addBoolDetail(details, "throttled", redfishFirstBoolAcrossDocs(lookupDocs,
|
||||
"Throttled", "ThermalThrottled", "PerformanceThrottled",
|
||||
))
|
||||
addInt64Detail(details, "correctable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"CorrectableErrorCount", "CorrectableErrors", "CorrectableECCErrorCount",
|
||||
))
|
||||
addInt64Detail(details, "uncorrectable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"UncorrectableErrorCount", "UncorrectableErrors", "UncorrectableECCErrorCount",
|
||||
))
|
||||
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeRemainingPercent", "PredictedLifeLeftPercent",
|
||||
))
|
||||
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeUsedPercent", "PercentageLifeUsed",
|
||||
))
|
||||
for _, lookupDoc := range lookupDocs {
|
||||
if microcode, ok := redfishLookupValue(lookupDoc, "MicrocodeVersion"); ok {
|
||||
if s := strings.TrimSpace(asString(microcode)); s != "" {
|
||||
details["microcode"] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if microcode, ok := redfishLookupValue(lookupDoc, "Microcode"); ok {
|
||||
if s := strings.TrimSpace(asString(microcode)); s != "" {
|
||||
details["microcode"] = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func redfishMemoryDetails(doc map[string]interface{}) map[string]any {
|
||||
return redfishMemoryDetailsAcrossDocs(doc)
|
||||
}
|
||||
|
||||
func redfishMemoryDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
|
||||
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
|
||||
details := make(map[string]any)
|
||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
|
||||
))
|
||||
addInt64Detail(details, "correctable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"CorrectableECCErrorCount", "CorrectableErrorCount", "CorrectableErrors",
|
||||
))
|
||||
addInt64Detail(details, "uncorrectable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"UncorrectableECCErrorCount", "UncorrectableErrorCount", "UncorrectableErrors",
|
||||
))
|
||||
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeRemainingPercent", "PredictedLifeLeftPercent",
|
||||
))
|
||||
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeUsedPercent", "PercentageLifeUsed",
|
||||
))
|
||||
addFloatDetail(details, "spare_blocks_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SpareBlocksRemainingPercent", "SpareBlocksRemainingPct",
|
||||
))
|
||||
addBoolDetail(details, "performance_degraded", redfishFirstBoolAcrossDocs(lookupDocs,
|
||||
"PerformanceDegraded", "Degraded",
|
||||
))
|
||||
addBoolDetail(details, "data_loss_detected", redfishFirstBoolAcrossDocs(lookupDocs,
|
||||
"DataLossDetected", "DataLoss",
|
||||
))
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func parseDrive(doc map[string]interface{}) models.Storage {
|
||||
return parseDriveWithSupplementalDocs(doc)
|
||||
}
|
||||
|
||||
func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) models.Storage {
|
||||
sizeGB := 0
|
||||
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
|
||||
sizeGB = int(capBytes / (1024 * 1024 * 1024))
|
||||
@@ -2644,6 +2795,7 @@ func parseDrive(doc map[string]interface{}) models.Storage {
|
||||
Firmware: asString(doc["Revision"]),
|
||||
Interface: asString(doc["Protocol"]),
|
||||
Present: true,
|
||||
Details: redfishDriveDetailsWithSupplementalDocs(doc, supplementalDocs...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2744,6 +2896,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
|
||||
Location: location,
|
||||
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
|
||||
BDF: asString(doc["BDF"]),
|
||||
Model: strings.TrimSpace(model),
|
||||
Vendor: strings.TrimSpace(vendor),
|
||||
VendorID: vendorID,
|
||||
@@ -2753,6 +2906,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
|
||||
Firmware: firmware,
|
||||
PortCount: portCount,
|
||||
Status: mapStatus(doc["Status"]),
|
||||
Details: redfishPCIeDetails(doc, nil),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2786,23 +2940,53 @@ func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}) {
|
||||
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) {
|
||||
if nic == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(nic.BDF) == "" {
|
||||
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
|
||||
}
|
||||
if nic.VendorID == 0 {
|
||||
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
|
||||
}
|
||||
if nic.DeviceID == 0 {
|
||||
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
|
||||
}
|
||||
if nic.LinkWidth == 0 {
|
||||
nic.LinkWidth = asInt(pcieDoc["CurrentLinkWidth"])
|
||||
}
|
||||
if nic.MaxLinkWidth == 0 {
|
||||
nic.MaxLinkWidth = asInt(pcieDoc["MaxLinkWidth"])
|
||||
}
|
||||
if strings.TrimSpace(nic.LinkSpeed) == "" {
|
||||
nic.LinkSpeed = firstNonEmpty(asString(pcieDoc["CurrentLinkSpeedGTs"]), asString(pcieDoc["CurrentLinkSpeed"]))
|
||||
}
|
||||
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
|
||||
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if strings.TrimSpace(nic.BDF) == "" {
|
||||
nic.BDF = asString(fn["FunctionId"])
|
||||
}
|
||||
if nic.VendorID == 0 {
|
||||
nic.VendorID = asHexOrInt(fn["VendorId"])
|
||||
}
|
||||
if nic.DeviceID == 0 {
|
||||
nic.DeviceID = asHexOrInt(fn["DeviceId"])
|
||||
}
|
||||
if nic.LinkWidth == 0 {
|
||||
nic.LinkWidth = asInt(fn["CurrentLinkWidth"])
|
||||
}
|
||||
if nic.MaxLinkWidth == 0 {
|
||||
nic.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
|
||||
}
|
||||
if strings.TrimSpace(nic.LinkSpeed) == "" {
|
||||
nic.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
|
||||
}
|
||||
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
|
||||
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(nic.Vendor) == "" {
|
||||
nic.Vendor = pciids.VendorName(nic.VendorID)
|
||||
@@ -2812,9 +2996,14 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
|
||||
nic.Model = resolved
|
||||
}
|
||||
}
|
||||
nic.Details = mergeGenericDetails(nic.Details, redfishPCIeDetailsWithSupplementalDocs(pcieDoc, functionDocs, supplementalDocs))
|
||||
}
|
||||
|
||||
func parsePSU(doc map[string]interface{}, idx int) models.PSU {
|
||||
return parsePSUWithSupplementalDocs(doc, idx)
|
||||
}
|
||||
|
||||
func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplementalDocs ...map[string]interface{}) models.PSU {
|
||||
status := mapStatus(doc["Status"])
|
||||
present := true
|
||||
if statusMap, ok := doc["Status"].(map[string]interface{}); ok {
|
||||
@@ -2852,10 +3041,334 @@ func parsePSU(doc map[string]interface{}, idx int) models.PSU {
|
||||
InputPowerW: asInt(doc["PowerInputWatts"]),
|
||||
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
|
||||
InputVoltage: asFloat(doc["LineInputVoltage"]),
|
||||
Details: redfishPSUDetailsWithSupplementalDocs(doc, supplementalDocs...),
|
||||
}
|
||||
}
|
||||
|
||||
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
|
||||
return redfishDriveDetailsWithSupplementalDocs(doc)
|
||||
}
|
||||
|
||||
func redfishDriveDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
|
||||
details := make(map[string]any)
|
||||
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
|
||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
|
||||
))
|
||||
addInt64Detail(details, "power_on_hours", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"PowerOnHours", "PowerOnHour",
|
||||
))
|
||||
addInt64Detail(details, "power_cycles", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"PowerCycles", "PowerCycleCount",
|
||||
))
|
||||
addInt64Detail(details, "unsafe_shutdowns", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"UnsafeShutdowns", "UnsafeShutdownCount",
|
||||
))
|
||||
addInt64Detail(details, "media_errors", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"MediaErrors", "MediaErrorCount",
|
||||
))
|
||||
addInt64Detail(details, "error_log_entries", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"ErrorLogEntries", "ErrorLogEntryCount",
|
||||
))
|
||||
addInt64Detail(details, "written_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"WrittenBytes", "BytesWritten",
|
||||
))
|
||||
addInt64Detail(details, "read_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"ReadBytes", "BytesRead",
|
||||
))
|
||||
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"PredictedMediaLifeLeftPercent", "LifeRemainingPercent", "PercentageDriveLifeUsedInverse", "PercentLifeRemaining",
|
||||
))
|
||||
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"PercentageDriveLifeUsed", "LifeUsedPercent", "PercentageUsed", "PercentLifeUsed",
|
||||
))
|
||||
addFloatDetail(details, "available_spare_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"AvailableSparePercent", "AvailableSpare", "PercentAvailableSpare",
|
||||
))
|
||||
addInt64Detail(details, "reallocated_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"ReallocatedSectors", "ReallocatedSectorCount",
|
||||
))
|
||||
addInt64Detail(details, "current_pending_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"CurrentPendingSectors", "CurrentPendingSectorCount",
|
||||
))
|
||||
addInt64Detail(details, "offline_uncorrectable", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"OfflineUncorrectable", "OfflineUncorrectableSectorCount",
|
||||
))
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func redfishPSUDetails(doc map[string]interface{}) map[string]any {
|
||||
return redfishPSUDetailsWithSupplementalDocs(doc)
|
||||
}
|
||||
|
||||
func redfishPSUDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
|
||||
details := make(map[string]any)
|
||||
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
|
||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"TemperatureCelsius", "TemperatureC", "Temperature",
|
||||
))
|
||||
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeRemainingPercent", "PredictedLifeLeftPercent",
|
||||
))
|
||||
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeUsedPercent", "PercentageLifeUsed",
|
||||
))
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func redfishPCIeDetails(doc map[string]interface{}, functionDocs []map[string]interface{}) map[string]any {
|
||||
return redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, nil)
|
||||
}
|
||||
|
||||
func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) map[string]any {
|
||||
lookupDocs := make([]map[string]interface{}, 0, 1+len(functionDocs)+len(supplementalDocs))
|
||||
lookupDocs = append(lookupDocs, doc)
|
||||
lookupDocs = append(lookupDocs, functionDocs...)
|
||||
lookupDocs = append(lookupDocs, supplementalDocs...)
|
||||
details := make(map[string]any)
|
||||
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"TemperatureCelsius", "TemperatureC", "Temperature",
|
||||
))
|
||||
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
|
||||
))
|
||||
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeRemainingPercent", "PredictedLifeLeftPercent",
|
||||
))
|
||||
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"LifeUsedPercent", "PercentageLifeUsed",
|
||||
))
|
||||
addInt64Detail(details, "ecc_corrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"ECCCorrectedTotal", "CorrectableECCErrorCount", "CorrectableErrorCount",
|
||||
))
|
||||
addInt64Detail(details, "ecc_uncorrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
|
||||
"ECCUncorrectedTotal", "UncorrectableECCErrorCount", "UncorrectableErrorCount",
|
||||
))
|
||||
addBoolDetail(details, "hw_slowdown", redfishFirstBoolAcrossDocs(lookupDocs,
|
||||
"HWSlowdown", "HardwareSlowdown",
|
||||
))
|
||||
addFloatDetail(details, "battery_charge_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"BatteryChargePercent", "BatteryChargePct",
|
||||
))
|
||||
addFloatDetail(details, "battery_health_pct", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"BatteryHealthPercent", "BatteryHealthPct",
|
||||
))
|
||||
addFloatDetail(details, "battery_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"BatteryTemperatureCelsius", "BatteryTemperatureC",
|
||||
))
|
||||
addFloatDetail(details, "battery_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"BatteryVoltage", "BatteryVoltageV",
|
||||
))
|
||||
addBoolDetail(details, "battery_replace_required", redfishFirstBoolAcrossDocs(lookupDocs,
|
||||
"BatteryReplaceRequired", "ReplaceBattery",
|
||||
))
|
||||
addFloatDetail(details, "sfp_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SFPTemperatureCelsius", "SFPTemperatureC", "TransceiverTemperatureCelsius",
|
||||
))
|
||||
addFloatDetail(details, "sfp_tx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SFPTXPowerDBm", "SFPTransmitPowerDBm", "TxPowerDBm",
|
||||
))
|
||||
addFloatDetail(details, "sfp_rx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SFPRXPowerDBm", "SFPReceivePowerDBm", "RxPowerDBm",
|
||||
))
|
||||
addFloatDetail(details, "sfp_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SFPVoltageV", "TransceiverVoltageV",
|
||||
))
|
||||
addFloatDetail(details, "sfp_bias_ma", redfishFirstNumericAcrossDocs(lookupDocs,
|
||||
"SFPBiasMA", "BiasCurrentMA", "LaserBiasCurrentMA",
|
||||
))
|
||||
if len(details) == 0 {
|
||||
return nil
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if f := asFloat(v); f != 0 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstNumericAcrossDocs(docs []map[string]interface{}, keys ...string) float64 {
|
||||
for _, doc := range docs {
|
||||
if v := redfishFirstNumeric(doc, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstNumericWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) float64 {
|
||||
if v := redfishFirstNumeric(doc, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if v := redfishFirstNumeric(fn, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstInt64(doc map[string]interface{}, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if n := asInt64(v); n != 0 {
|
||||
return n
|
||||
}
|
||||
if n := int64(asInt(v)); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstInt64AcrossDocs(docs []map[string]interface{}, keys ...string) int64 {
|
||||
for _, doc := range docs {
|
||||
if v := redfishFirstInt64(doc, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstInt64WithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) int64 {
|
||||
if v := redfishFirstInt64(doc, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if v := redfishFirstInt64(fn, keys...); v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *bool {
|
||||
for _, doc := range docs {
|
||||
if v := redfishFirstBool(doc, keys...); v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
|
||||
if doc == nil || strings.TrimSpace(key) == "" {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok := doc[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
|
||||
if v, ok := redfishLookupNestedValue(oem, key); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func redfishFirstBool(doc map[string]interface{}, keys ...string) *bool {
|
||||
for _, key := range keys {
|
||||
if v, ok := redfishLookupValue(doc, key); ok {
|
||||
if b, ok := asBoolPtr(v); ok {
|
||||
return &b
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redfishFirstBoolWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) *bool {
|
||||
if v := redfishFirstBool(doc, keys...); v != nil {
|
||||
return v
|
||||
}
|
||||
for _, fn := range functionDocs {
|
||||
if v := redfishFirstBool(fn, keys...); v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redfishLookupNestedValue(doc map[string]interface{}, key string) (any, bool) {
|
||||
if doc == nil {
|
||||
return nil, false
|
||||
}
|
||||
if v, ok := doc[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
for _, value := range doc {
|
||||
nested, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, ok := redfishLookupNestedValue(nested, key); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func addFloatDetail(dst map[string]any, key string, value float64) {
|
||||
if value == 0 {
|
||||
return
|
||||
}
|
||||
dst[key] = value
|
||||
}
|
||||
|
||||
func addInt64Detail(dst map[string]any, key string, value int64) {
|
||||
if value == 0 {
|
||||
return
|
||||
}
|
||||
dst[key] = value
|
||||
}
|
||||
|
||||
func addBoolDetail(dst map[string]any, key string, value *bool) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
dst[key] = *value
|
||||
}
|
||||
|
||||
func asBoolPtr(v any) (bool, bool) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, true
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(x)) {
|
||||
case "true", "yes", "enabled", "1":
|
||||
return true, true
|
||||
case "false", "no", "disabled", "0":
|
||||
return false, true
|
||||
}
|
||||
case float64:
|
||||
return x != 0, true
|
||||
case int:
|
||||
return x != 0, true
|
||||
case int64:
|
||||
return x != 0, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{}, idx int) models.GPU {
|
||||
return parseGPUWithSupplementalDocs(doc, functionDocs, nil, idx)
|
||||
}
|
||||
|
||||
func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.GPU {
|
||||
slot := firstNonEmpty(
|
||||
redfishLocationLabel(doc["Slot"]),
|
||||
redfishLocationLabel(doc["Location"]),
|
||||
@@ -2876,6 +3389,7 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
|
||||
PartNumber: asString(doc["PartNumber"]),
|
||||
Firmware: asString(doc["FirmwareVersion"]),
|
||||
Status: mapStatus(doc["Status"]),
|
||||
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
||||
}
|
||||
|
||||
if bdf := asString(doc["BDF"]); bdf != "" {
|
||||
@@ -2928,6 +3442,10 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
|
||||
}
|
||||
|
||||
func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) models.PCIeDevice {
|
||||
return parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, nil)
|
||||
}
|
||||
|
||||
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
|
||||
dev := models.PCIeDevice{
|
||||
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
|
||||
BDF: asString(doc["BDF"]),
|
||||
@@ -2937,6 +3455,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
|
||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||
VendorID: asHexOrInt(doc["VendorId"]),
|
||||
DeviceID: asHexOrInt(doc["DeviceId"]),
|
||||
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
|
||||
}
|
||||
if strings.TrimSpace(dev.BDF) == "" {
|
||||
dev.BDF = buildBDFfromOemPublic(doc)
|
||||
@@ -2992,6 +3511,10 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
|
||||
}
|
||||
|
||||
func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
|
||||
return parsePCIeFunctionWithSupplementalDocs(doc, nil, idx)
|
||||
}
|
||||
|
||||
func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.PCIeDevice {
|
||||
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
|
||||
if slot == "" {
|
||||
slot = fmt.Sprintf("PCIeFn%d", idx)
|
||||
@@ -3009,6 +3532,7 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
|
||||
LinkSpeed: firstNonEmpty(asString(doc["CurrentLinkSpeedGTs"]), asString(doc["CurrentLinkSpeed"])),
|
||||
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
|
||||
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
|
||||
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
|
||||
}
|
||||
if isGenericPCIeClassLabel(dev.DeviceClass) {
|
||||
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
|
||||
@@ -3552,8 +4076,10 @@ func storageIdentityKey(item models.Storage) string {
|
||||
|
||||
func richerStorageEntry(a, b models.Storage) models.Storage {
|
||||
if storageRichnessScore(b) > storageRichnessScore(a) {
|
||||
b.Details = mergeGenericDetails(b.Details, a.Details)
|
||||
return b
|
||||
}
|
||||
a.Details = mergeGenericDetails(a.Details, b.Details)
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -3815,6 +4341,9 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
|
||||
if normalizeRedfishIdentityField(nic.Firmware) != "" {
|
||||
score += 8
|
||||
}
|
||||
if looksLikeCanonicalBDF(strings.TrimSpace(nic.BDF)) {
|
||||
score += 10
|
||||
}
|
||||
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
|
||||
score += 6
|
||||
}
|
||||
@@ -3827,6 +4356,12 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
|
||||
if nic.PortCount > 0 {
|
||||
score += 4
|
||||
}
|
||||
if nic.LinkWidth > 0 || nic.MaxLinkWidth > 0 {
|
||||
score += 4
|
||||
}
|
||||
if strings.TrimSpace(nic.LinkSpeed) != "" || strings.TrimSpace(nic.MaxLinkSpeed) != "" {
|
||||
score += 4
|
||||
}
|
||||
if len(nic.MACAddresses) > 0 {
|
||||
score += 4
|
||||
}
|
||||
@@ -3853,6 +4388,9 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
|
||||
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
|
||||
out.Location = donor.Location
|
||||
}
|
||||
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
|
||||
out.BDF = donor.BDF
|
||||
}
|
||||
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
|
||||
out.Model = donor.Model
|
||||
}
|
||||
@@ -3883,6 +4421,18 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
|
||||
if strings.TrimSpace(out.PortType) == "" && strings.TrimSpace(donor.PortType) != "" {
|
||||
out.PortType = donor.PortType
|
||||
}
|
||||
if out.LinkWidth == 0 && donor.LinkWidth > 0 {
|
||||
out.LinkWidth = donor.LinkWidth
|
||||
}
|
||||
if strings.TrimSpace(out.LinkSpeed) == "" && strings.TrimSpace(donor.LinkSpeed) != "" {
|
||||
out.LinkSpeed = donor.LinkSpeed
|
||||
}
|
||||
if out.MaxLinkWidth == 0 && donor.MaxLinkWidth > 0 {
|
||||
out.MaxLinkWidth = donor.MaxLinkWidth
|
||||
}
|
||||
if strings.TrimSpace(out.MaxLinkSpeed) == "" && strings.TrimSpace(donor.MaxLinkSpeed) != "" {
|
||||
out.MaxLinkSpeed = donor.MaxLinkSpeed
|
||||
}
|
||||
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
|
||||
out.Status = donor.Status
|
||||
}
|
||||
@@ -3890,6 +4440,7 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
|
||||
if len(donor.MACAddresses) > 0 {
|
||||
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
||||
}
|
||||
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4041,6 +4592,7 @@ func mergePCIeDeviceEntries(a, b models.PCIeDevice) models.PCIeDevice {
|
||||
if len(donor.MACAddresses) > 0 {
|
||||
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
|
||||
}
|
||||
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4147,9 +4699,25 @@ func mergePSUEntries(a, b models.PSU) models.PSU {
|
||||
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
|
||||
out.TemperatureC = donor.TemperatureC
|
||||
}
|
||||
out.Details = mergeGenericDetails(out.Details, donor.Details)
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeGenericDetails(primary, secondary map[string]any) map[string]any {
|
||||
if len(secondary) == 0 {
|
||||
return primary
|
||||
}
|
||||
if primary == nil {
|
||||
primary = make(map[string]any, len(secondary))
|
||||
}
|
||||
for key, value := range secondary {
|
||||
if _, ok := primary[key]; !ok {
|
||||
primary[key] = value
|
||||
}
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]models.StorageVolume, 0, len(items))
|
||||
|
||||
@@ -62,8 +62,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
if emit != nil {
|
||||
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
|
||||
}
|
||||
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
|
||||
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
|
||||
processors := r.collectProcessors(primarySystem)
|
||||
memory := r.collectMemory(primarySystem)
|
||||
storageDevices := r.collectStorage(primarySystem)
|
||||
storageVolumes := r.collectStorageVolumes(primarySystem)
|
||||
|
||||
@@ -101,8 +101,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
|
||||
RawPayloads: cloneRawPayloads(rawPayloads),
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: boardInfo,
|
||||
CPUs: parseCPUs(processors),
|
||||
Memory: parseMemory(memory),
|
||||
CPUs: processors,
|
||||
Memory: memory,
|
||||
Storage: storageDevices,
|
||||
Volumes: storageVolumes,
|
||||
PCIeDevices: pcieDevices,
|
||||
@@ -977,6 +977,77 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
|
||||
if len(doc) == 0 || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
seen := make(map[string]struct{})
|
||||
for _, key := range keys {
|
||||
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
supplementalDoc, err := r.getJSON(path)
|
||||
if err != nil || len(supplementalDoc) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, supplementalDoc)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU {
|
||||
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors"))
|
||||
if err != nil || len(memberDocs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.CPU, 0, len(memberDocs))
|
||||
socketIdx := 0
|
||||
for _, doc := range memberDocs {
|
||||
if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" &&
|
||||
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
|
||||
continue
|
||||
}
|
||||
cpu := parseCPUs([]map[string]interface{}{doc})[0]
|
||||
if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" {
|
||||
cpu.Socket = socketIdx
|
||||
if cpu.Details == nil {
|
||||
cpu.Details = map[string]any{}
|
||||
}
|
||||
cpu.Details["socket"] = cpu.Socket
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "ProcessorMetrics", "EnvironmentMetrics", "Metrics")
|
||||
if len(supplementalDocs) > 0 {
|
||||
cpu.Details = mergeGenericDetails(cpu.Details, redfishCPUDetailsAcrossDocs(doc, supplementalDocs...))
|
||||
}
|
||||
out = append(out, cpu)
|
||||
socketIdx++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryDIMM {
|
||||
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Memory"))
|
||||
if err != nil || len(memberDocs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]models.MemoryDIMM, 0, len(memberDocs))
|
||||
for _, doc := range memberDocs {
|
||||
dimm := parseMemory([]map[string]interface{}{doc})[0]
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics")
|
||||
if len(supplementalDocs) > 0 {
|
||||
dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...))
|
||||
}
|
||||
out = append(out, dimm)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storage {
|
||||
var out []models.Storage
|
||||
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
|
||||
@@ -987,12 +1058,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
if !isVirtualStorageDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1014,13 +1087,15 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
||||
continue
|
||||
}
|
||||
if !isVirtualStorageDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if looksLikeDrive(member) {
|
||||
out = append(out, parseDrive(member))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||
}
|
||||
|
||||
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
|
||||
@@ -1028,7 +1103,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
||||
if err == nil {
|
||||
for _, driveDoc := range driveDocs {
|
||||
if looksLikeDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
@@ -1045,7 +1121,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
|
||||
"/Storage/IntelVROC/Controllers/1/Drives",
|
||||
}) {
|
||||
if looksLikeDrive(driveDoc) {
|
||||
out = append(out, parseDrive(driveDoc))
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,7 +1242,11 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
|
||||
continue
|
||||
}
|
||||
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
|
||||
}
|
||||
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
|
||||
if len(nic.MACAddresses) == 0 {
|
||||
@@ -1184,7 +1265,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
||||
for _, chassisPath := range chassisPaths {
|
||||
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
|
||||
for _, doc := range memberDocs {
|
||||
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1195,7 +1277,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1227,7 +1310,11 @@ func (r redfishSnapshotReader) collectGPUs(systemPaths, chassisPaths []string) [
|
||||
if !looksLikeGPU(doc, functionDocs) {
|
||||
continue
|
||||
}
|
||||
gpu := parseGPU(doc, functionDocs, idx)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
|
||||
idx++
|
||||
if shouldSkipGenericGPUDuplicate(out, gpu) {
|
||||
continue
|
||||
@@ -1265,7 +1352,11 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
||||
if looksLikeGPU(doc, functionDocs) {
|
||||
continue
|
||||
}
|
||||
dev := parsePCIeDevice(doc, functionDocs)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
|
||||
for _, fn := range functionDocs {
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
|
||||
}
|
||||
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
|
||||
if isUnidentifiablePCIeDevice(dev) {
|
||||
continue
|
||||
}
|
||||
@@ -1278,7 +1369,8 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
|
||||
continue
|
||||
}
|
||||
for idx, fn := range functionDocs {
|
||||
dev := parsePCIeFunction(fn, idx+1)
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
|
||||
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
|
||||
out = append(out, dev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,18 +584,32 @@ func TestEnrichNICFromPCIeFunctions(t *testing.T) {
|
||||
}
|
||||
functionDocs := []map[string]interface{}{
|
||||
{
|
||||
"VendorId": "0x15b3",
|
||||
"DeviceId": "0x1021",
|
||||
"FunctionId": "0000:17:00.0",
|
||||
"VendorId": "0x15b3",
|
||||
"DeviceId": "0x1021",
|
||||
"CurrentLinkWidth": 16,
|
||||
"CurrentLinkSpeedGTs": "32 GT/s",
|
||||
"MaxLinkWidth": 16,
|
||||
"MaxLinkSpeedGTs": "32 GT/s",
|
||||
},
|
||||
}
|
||||
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
|
||||
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, nil)
|
||||
if nic.VendorID != 0x15b3 || nic.DeviceID != 0x1021 {
|
||||
t.Fatalf("unexpected NIC IDs: vendor=%#x device=%#x", nic.VendorID, nic.DeviceID)
|
||||
}
|
||||
if nic.Location != "PCIe Slot 1 (1)" {
|
||||
t.Fatalf("unexpected NIC location: %q", nic.Location)
|
||||
}
|
||||
if nic.BDF != "0000:17:00.0" {
|
||||
t.Fatalf("unexpected NIC BDF: %q", nic.BDF)
|
||||
}
|
||||
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
|
||||
t.Fatalf("unexpected NIC link width state: current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
|
||||
}
|
||||
if nic.LinkSpeed != "32 GT/s" || nic.MaxLinkSpeed != "32 GT/s" {
|
||||
t.Fatalf("unexpected NIC link speed state: current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
|
||||
@@ -704,6 +718,208 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
|
||||
cpus := parseCPUs([]map[string]interface{}{
|
||||
{
|
||||
"Id": "CPU0",
|
||||
"Model": "Intel Xeon",
|
||||
"CorrectableErrors": 7,
|
||||
"TemperatureCelsius": 63,
|
||||
"Oem": map[string]interface{}{
|
||||
"VendorX": map[string]interface{}{
|
||||
"MicrocodeVersion": "0x2b000643",
|
||||
"UncorrectableErrors": 1,
|
||||
"ThermalThrottled": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(cpus) != 1 || cpus[0].Details == nil {
|
||||
t.Fatalf("expected CPU details, got %+v", cpus)
|
||||
}
|
||||
if cpus[0].Details["microcode"] != "0x2b000643" {
|
||||
t.Fatalf("expected CPU microcode detail, got %#v", cpus[0].Details)
|
||||
}
|
||||
if cpus[0].Details["correctable_error_count"] != int64(7) || cpus[0].Details["uncorrectable_error_count"] != int64(1) {
|
||||
t.Fatalf("expected CPU error counters, got %#v", cpus[0].Details)
|
||||
}
|
||||
if cpus[0].Details["throttled"] != true || cpus[0].Details["temperature_c"] != 63.0 {
|
||||
t.Fatalf("expected CPU thermal details, got %#v", cpus[0].Details)
|
||||
}
|
||||
|
||||
dimms := parseMemory([]map[string]interface{}{
|
||||
{
|
||||
"Id": "DIMM0",
|
||||
"DeviceLocator": "CPU0_C0D0",
|
||||
"CapacityMiB": 32768,
|
||||
"SerialNumber": "DIMM-001",
|
||||
"Oem": map[string]interface{}{
|
||||
"VendorX": map[string]interface{}{
|
||||
"CorrectableECCErrorCount": 12,
|
||||
"UncorrectableECCErrorCount": 2,
|
||||
"TemperatureC": 41.5,
|
||||
"SpareBlocksRemainingPercent": 88,
|
||||
"PerformanceDegraded": true,
|
||||
"DataLossDetected": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if len(dimms) != 1 || dimms[0].Details == nil {
|
||||
t.Fatalf("expected DIMM details, got %+v", dimms)
|
||||
}
|
||||
if dimms[0].Details["correctable_ecc_error_count"] != int64(12) || dimms[0].Details["uncorrectable_ecc_error_count"] != int64(2) {
|
||||
t.Fatalf("expected DIMM ECC counters, got %#v", dimms[0].Details)
|
||||
}
|
||||
if dimms[0].Details["temperature_c"] != 41.5 || dimms[0].Details["spare_blocks_remaining_pct"] != 88.0 {
|
||||
t.Fatalf("expected DIMM telemetry details, got %#v", dimms[0].Details)
|
||||
}
|
||||
if dimms[0].Details["performance_degraded"] != true || dimms[0].Details["data_loss_detected"] != false {
|
||||
t.Fatalf("expected DIMM boolean health details, got %#v", dimms[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T) {
|
||||
rawPayloads := map[string]any{
|
||||
"redfish_tree": map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
"Processors": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Processors",
|
||||
},
|
||||
"Memory": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Memory",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Processors": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Processors/CPU0": map[string]interface{}{
|
||||
"Id": "CPU0",
|
||||
"ProcessorType": "CPU",
|
||||
"Model": "Intel Xeon",
|
||||
"ProcessorMetrics": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics": map[string]interface{}{
|
||||
"CorrectableErrors": 10,
|
||||
"ThermalThrottled": true,
|
||||
"MicrocodeVersion": "0x2b000643",
|
||||
"TemperatureCelsius": 66,
|
||||
},
|
||||
"/redfish/v1/Systems/1/Memory": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Memory/DIMM0": map[string]interface{}{
|
||||
"Id": "DIMM0",
|
||||
"DeviceLocator": "CPU0_C0D0",
|
||||
"CapacityMiB": 32768,
|
||||
"SerialNumber": "DIMM-001",
|
||||
"MemoryMetrics": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
|
||||
"CorrectableECCErrorCount": 14,
|
||||
"TemperatureCelsius": 42,
|
||||
"PerformanceDegraded": true,
|
||||
"SpareBlocksRemainingPercent": 91,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
|
||||
}
|
||||
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Details == nil {
|
||||
t.Fatalf("expected CPU details from replay metrics, got %+v", result.Hardware.CPUs)
|
||||
}
|
||||
if result.Hardware.CPUs[0].Details["correctable_error_count"] != int64(10) || result.Hardware.CPUs[0].Details["microcode"] != "0x2b000643" {
|
||||
t.Fatalf("expected CPU replay metrics details, got %#v", result.Hardware.CPUs[0].Details)
|
||||
}
|
||||
if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].Details == nil {
|
||||
t.Fatalf("expected memory details from replay metrics, got %+v", result.Hardware.Memory)
|
||||
}
|
||||
if result.Hardware.Memory[0].Details["correctable_ecc_error_count"] != int64(14) || result.Hardware.Memory[0].Details["performance_degraded"] != true {
|
||||
t.Fatalf("expected DIMM replay metrics details, got %#v", result.Hardware.Memory[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
|
||||
rawPayloads := map[string]any{
|
||||
"redfish_tree": map[string]interface{}{
|
||||
"/redfish/v1": map[string]interface{}{},
|
||||
"/redfish/v1/Systems": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1": map[string]interface{}{
|
||||
"Id": "1",
|
||||
"Storage": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/RAID1": map[string]interface{}{
|
||||
"Id": "RAID1",
|
||||
"Drives": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/RAID1/Drives": map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0"},
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0": map[string]interface{}{
|
||||
"Id": "Drive0",
|
||||
"Model": "NVMe SSD",
|
||||
"SerialNumber": "SSD-001",
|
||||
"DriveMetrics": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics",
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
|
||||
"PowerOnHours": 1001,
|
||||
"MediaErrors": 3,
|
||||
"AvailableSparePercent": 92,
|
||||
"TemperatureCelsius": 37,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
|
||||
}
|
||||
if len(result.Hardware.Storage) != 1 || result.Hardware.Storage[0].Details == nil {
|
||||
t.Fatalf("expected storage details from replay drive metrics, got %+v", result.Hardware.Storage)
|
||||
}
|
||||
if result.Hardware.Storage[0].Details["power_on_hours"] != int64(1001) || result.Hardware.Storage[0].Details["media_errors"] != int64(3) {
|
||||
t.Fatalf("expected drive metrics counters, got %#v", result.Hardware.Storage[0].Details)
|
||||
}
|
||||
if result.Hardware.Storage[0].Details["available_spare_pct"] != 92.0 || result.Hardware.Storage[0].Details["temperature_c"] != 37.0 {
|
||||
t.Fatalf("expected drive metrics telemetry, got %#v", result.Hardware.Storage[0].Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
|
||||
collection := map[string]interface{}{
|
||||
"Members": []interface{}{
|
||||
@@ -725,6 +941,186 @@ func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
|
||||
drive := parseDrive(map[string]interface{}{
|
||||
"Id": "Drive0",
|
||||
"Model": "NVMe SSD",
|
||||
"SerialNumber": "SSD-001",
|
||||
"TemperatureCelsius": 38.5,
|
||||
"PowerOnHours": 12450,
|
||||
"UnsafeShutdowns": 3,
|
||||
"PredictedMediaLifeLeftPercent": 91,
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"AvailableSparePercent": 87,
|
||||
},
|
||||
},
|
||||
})
|
||||
if drive.Details == nil {
|
||||
t.Fatalf("expected drive details to be populated")
|
||||
}
|
||||
if got := drive.Details["temperature_c"]; got != 38.5 {
|
||||
t.Fatalf("expected drive temperature detail 38.5, got %#v", got)
|
||||
}
|
||||
if got := drive.Details["power_on_hours"]; got != int64(12450) {
|
||||
t.Fatalf("expected drive power_on_hours detail, got %#v", got)
|
||||
}
|
||||
if got := drive.Details["life_remaining_pct"]; got != 91.0 {
|
||||
t.Fatalf("expected drive life_remaining_pct detail, got %#v", got)
|
||||
}
|
||||
if got := drive.Details["available_spare_pct"]; got != 87.0 {
|
||||
t.Fatalf("expected drive available_spare_pct detail from Oem/Public, got %#v", got)
|
||||
}
|
||||
|
||||
driveOEM := parseDrive(map[string]interface{}{
|
||||
"Id": "Drive1",
|
||||
"Model": "NVMe SSD 2",
|
||||
"SerialNumber": "SSD-002",
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"temperature": 19,
|
||||
"PercentAvailableSpare": 93,
|
||||
"PercentageUsed": 7,
|
||||
},
|
||||
},
|
||||
})
|
||||
if driveOEM.Details == nil {
|
||||
t.Fatalf("expected oem drive details to be populated")
|
||||
}
|
||||
if got := driveOEM.Details["temperature_c"]; got != 19.0 {
|
||||
t.Fatalf("expected lowercase OEM drive temperature 19, got %#v", got)
|
||||
}
|
||||
if got := driveOEM.Details["available_spare_pct"]; got != 93.0 {
|
||||
t.Fatalf("expected OEM available_spare_pct 93, got %#v", got)
|
||||
}
|
||||
if got := driveOEM.Details["life_used_pct"]; got != 7.0 {
|
||||
t.Fatalf("expected OEM life_used_pct 7, got %#v", got)
|
||||
}
|
||||
|
||||
psu := parsePSU(map[string]interface{}{
|
||||
"MemberId": "PSU0",
|
||||
"SerialNumber": "PSU-001",
|
||||
"TemperatureCelsius": 41,
|
||||
"Oem": map[string]interface{}{
|
||||
"Public": map[string]interface{}{
|
||||
"LifeRemainingPercent": 96,
|
||||
},
|
||||
},
|
||||
}, 1)
|
||||
if psu.Details == nil {
|
||||
t.Fatalf("expected psu details to be populated")
|
||||
}
|
||||
if got := psu.Details["temperature_c"]; got != 41.0 {
|
||||
t.Fatalf("expected psu temperature detail 41, got %#v", got)
|
||||
}
|
||||
if got := psu.Details["life_remaining_pct"]; got != 96.0 {
|
||||
t.Fatalf("expected psu life_remaining_pct detail, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGPUPCIeAndNIC_CollectComponentMetricsIntoDetails(t *testing.T) {
|
||||
functionDocs := []map[string]interface{}{
|
||||
{
|
||||
"FunctionId": "0000:17:00.0",
|
||||
"VendorId": "0x10de",
|
||||
"DeviceId": "0x2331",
|
||||
"TemperatureCelsius": 48.5,
|
||||
"PowerConsumedWatts": 315.0,
|
||||
"ECCCorrectedTotal": 12,
|
||||
"BatteryHealthPercent": 87,
|
||||
"SFPTemperatureCelsius": 36.2,
|
||||
},
|
||||
}
|
||||
gpu := parseGPU(map[string]interface{}{
|
||||
"Id": "GPU0",
|
||||
"Model": "NVIDIA H100",
|
||||
"Manufacturer": "NVIDIA",
|
||||
}, functionDocs, 1)
|
||||
if gpu.Details == nil || gpu.Details["temperature_c"] != 48.5 || gpu.Details["power_w"] != 315.0 {
|
||||
t.Fatalf("expected gpu details from function docs, got %#v", gpu.Details)
|
||||
}
|
||||
|
||||
pcie := parsePCIeDevice(map[string]interface{}{
|
||||
"Id": "NIC1",
|
||||
}, []map[string]interface{}{
|
||||
{
|
||||
"FunctionId": "0000:18:00.0",
|
||||
"VendorId": "0x15b3",
|
||||
"DeviceId": "0x1021",
|
||||
"SFPTXPowerDBm": -1.8,
|
||||
"SFPRXPowerDBm": -2.1,
|
||||
"SFPBiasMA": 5.5,
|
||||
"BatteryReplaceRequired": true,
|
||||
},
|
||||
})
|
||||
if pcie.Details == nil || pcie.Details["sfp_tx_power_dbm"] != -1.8 || pcie.Details["battery_replace_required"] != true {
|
||||
t.Fatalf("expected pcie details from function docs, got %#v", pcie.Details)
|
||||
}
|
||||
|
||||
nic := parseNIC(map[string]interface{}{"Id": "1"})
|
||||
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
|
||||
{
|
||||
"FunctionId": "0000:19:00.0",
|
||||
"SFPTemperatureCelsius": 34.0,
|
||||
},
|
||||
}, nil)
|
||||
if nic.Details == nil || nic.Details["sfp_temperature_c"] != 34.0 {
|
||||
t.Fatalf("expected nic details from linked pcie function, got %#v", nic.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
|
||||
drive := parseDriveWithSupplementalDocs(
|
||||
map[string]interface{}{
|
||||
"Id": "Drive0",
|
||||
"SerialNumber": "SSD-001",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"PowerOnHours": 5001,
|
||||
"MediaErrors": 2,
|
||||
"TemperatureC": 39.5,
|
||||
"LifeUsedPercent": 9,
|
||||
},
|
||||
)
|
||||
if drive.Details == nil || drive.Details["power_on_hours"] != int64(5001) || drive.Details["temperature_c"] != 39.5 {
|
||||
t.Fatalf("expected drive details from supplemental metrics, got %#v", drive.Details)
|
||||
}
|
||||
|
||||
psu := parsePSUWithSupplementalDocs(
|
||||
map[string]interface{}{
|
||||
"MemberId": "PSU0",
|
||||
"SerialNumber": "PSU-001",
|
||||
},
|
||||
1,
|
||||
map[string]interface{}{
|
||||
"Temperature": 44,
|
||||
"LifeRemainingPercent": 97,
|
||||
},
|
||||
)
|
||||
if psu.Details == nil || psu.Details["temperature_c"] != 44.0 || psu.Details["life_remaining_pct"] != 97.0 {
|
||||
t.Fatalf("expected psu details from supplemental metrics, got %#v", psu.Details)
|
||||
}
|
||||
|
||||
gpu := parseGPUWithSupplementalDocs(
|
||||
map[string]interface{}{
|
||||
"Id": "GPU0",
|
||||
"Model": "NVIDIA H100",
|
||||
"Manufacturer": "NVIDIA",
|
||||
},
|
||||
nil,
|
||||
[]map[string]interface{}{
|
||||
{
|
||||
"PowerConsumptionWatts": 305.0,
|
||||
"HWSlowdown": true,
|
||||
},
|
||||
},
|
||||
1,
|
||||
)
|
||||
if gpu.Details == nil || gpu.Details["power_w"] != 305.0 || gpu.Details["hw_slowdown"] != true {
|
||||
t.Fatalf("expected gpu details from supplemental metrics, got %#v", gpu.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t *testing.T) {
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
|
||||
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
|
||||
@@ -1621,11 +2017,11 @@ func TestCollectGPUsFromProcessors_SupermicroHGX(t *testing.T) {
|
||||
},
|
||||
},
|
||||
"/redfish/v1/Chassis/1/PCIeDevices/GPU1": map[string]interface{}{
|
||||
"Id": "GPU1",
|
||||
"Name": "GPU1",
|
||||
"Model": "NVIDIA H200",
|
||||
"Manufacturer": "NVIDIA",
|
||||
"SerialNumber": "SN001",
|
||||
"Id": "GPU1",
|
||||
"Name": "GPU1",
|
||||
"Model": "NVIDIA H200",
|
||||
"Manufacturer": "NVIDIA",
|
||||
"SerialNumber": "SN001",
|
||||
"FirmwareVersion": "96.00.D9.00.02",
|
||||
"PCIeFunctions": map[string]interface{}{
|
||||
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
|
||||
@@ -1810,15 +2206,18 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldCrawlPath_MemorySubresourcesAreSkipped(t *testing.T) {
|
||||
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
|
||||
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
|
||||
t.Fatalf("expected direct DIMM resource to be crawlable")
|
||||
}
|
||||
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly") {
|
||||
t.Fatalf("expected DIMM assembly subresource to be skipped")
|
||||
}
|
||||
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
|
||||
t.Fatalf("expected DIMM metrics subresource to be skipped")
|
||||
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
|
||||
t.Fatalf("expected DIMM metrics subresource to be crawlable")
|
||||
}
|
||||
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
|
||||
t.Fatalf("expected CPU metrics subresource to be crawlable")
|
||||
}
|
||||
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
|
||||
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")
|
||||
|
||||
@@ -66,104 +66,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
}
|
||||
}
|
||||
|
||||
// CPUs
|
||||
for _, cpu := range e.result.Hardware.CPUs {
|
||||
if !hasUsableSerial(cpu.SerialNumber) {
|
||||
seenCanonical := make(map[string]struct{})
|
||||
for _, dev := range canonicalDevicesForExport(e.result.Hardware) {
|
||||
if !hasUsableSerial(dev.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
cpu.Model,
|
||||
strings.TrimSpace(cpu.SerialNumber),
|
||||
"",
|
||||
"CPU",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Memory
|
||||
for _, mem := range e.result.Hardware.Memory {
|
||||
if !hasUsableSerial(mem.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
location := mem.Location
|
||||
if location == "" {
|
||||
location = mem.Slot
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
mem.PartNumber,
|
||||
strings.TrimSpace(mem.SerialNumber),
|
||||
mem.Manufacturer,
|
||||
location,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Storage
|
||||
for _, stor := range e.result.Hardware.Storage {
|
||||
if !hasUsableSerial(stor.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
stor.Model,
|
||||
strings.TrimSpace(stor.SerialNumber),
|
||||
stor.Manufacturer,
|
||||
stor.Slot,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// GPUs
|
||||
for _, gpu := range e.result.Hardware.GPUs {
|
||||
if !hasUsableSerial(gpu.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
component := gpu.Model
|
||||
if component == "" {
|
||||
component = "GPU"
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
component,
|
||||
strings.TrimSpace(gpu.SerialNumber),
|
||||
gpu.Manufacturer,
|
||||
gpu.Slot,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// PCIe devices
|
||||
for _, pcie := range e.result.Hardware.PCIeDevices {
|
||||
if !hasUsableSerial(pcie.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
pcie.DeviceClass,
|
||||
strings.TrimSpace(pcie.SerialNumber),
|
||||
pcie.Manufacturer,
|
||||
pcie.Slot,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Network adapters
|
||||
for _, nic := range e.result.Hardware.NetworkAdapters {
|
||||
if !hasUsableSerial(nic.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
location := nic.Location
|
||||
if location == "" {
|
||||
location = nic.Slot
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
nic.Model,
|
||||
strings.TrimSpace(nic.SerialNumber),
|
||||
nic.Vendor,
|
||||
location,
|
||||
}); err != nil {
|
||||
serial := strings.TrimSpace(dev.SerialNumber)
|
||||
seenCanonical[serial] = struct{}{}
|
||||
component, manufacturer, location := csvFieldsFromCanonicalDevice(dev)
|
||||
if err := writer.Write([]string{component, serial, manufacturer, location}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -173,26 +84,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
|
||||
if !hasUsableSerial(nic.SerialNumber) {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
nic.Model,
|
||||
strings.TrimSpace(nic.SerialNumber),
|
||||
"",
|
||||
"Network",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Power supplies
|
||||
for _, psu := range e.result.Hardware.PowerSupply {
|
||||
if !hasUsableSerial(psu.SerialNumber) {
|
||||
serial := strings.TrimSpace(nic.SerialNumber)
|
||||
if _, ok := seenCanonical[serial]; ok {
|
||||
continue
|
||||
}
|
||||
if err := writer.Write([]string{
|
||||
psu.Model,
|
||||
strings.TrimSpace(psu.SerialNumber),
|
||||
psu.Vendor,
|
||||
psu.Slot,
|
||||
nic.Model,
|
||||
serial,
|
||||
"",
|
||||
"Network",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -221,3 +121,52 @@ func hasUsableSerial(serial string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func csvFieldsFromCanonicalDevice(dev models.HardwareDevice) (component, manufacturer, location string) {
|
||||
component = firstNonEmptyString(
|
||||
dev.Model,
|
||||
dev.PartNumber,
|
||||
dev.DeviceClass,
|
||||
dev.Kind,
|
||||
)
|
||||
manufacturer = firstNonEmptyString(dev.Manufacturer, inferCSVVendor(dev))
|
||||
location = firstNonEmptyString(dev.Location, dev.Slot, dev.BDF, dev.Kind)
|
||||
|
||||
switch dev.Kind {
|
||||
case models.DeviceKindCPU:
|
||||
if component == "" {
|
||||
component = "CPU"
|
||||
}
|
||||
if location == "" {
|
||||
location = "CPU"
|
||||
}
|
||||
case models.DeviceKindMemory:
|
||||
component = firstNonEmptyString(dev.PartNumber, dev.Model, "Memory")
|
||||
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
|
||||
if location == "" {
|
||||
location = firstNonEmptyString(dev.Slot, dev.BDF, "PCIe")
|
||||
}
|
||||
case models.DeviceKindPSU:
|
||||
component = firstNonEmptyString(dev.Model, "Power Supply")
|
||||
}
|
||||
|
||||
return component, manufacturer, location
|
||||
}
|
||||
|
||||
func inferCSVVendor(dev models.HardwareDevice) string {
|
||||
switch dev.Kind {
|
||||
case models.DeviceKindCPU:
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -210,7 +210,7 @@ func TestConvertCPUs(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
|
||||
result := convertCPUs(cpus, "2026-02-10T15:30:00Z", "BOARD-001")
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
||||
@@ -227,6 +227,9 @@ func TestConvertCPUs(t *testing.T) {
|
||||
if result[0].Status != "Unknown" {
|
||||
t.Errorf("expected Unknown status, got %q", result[0].Status)
|
||||
}
|
||||
if result[0].SerialNumber != "BOARD-001-CPU-0" {
|
||||
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMemory(t *testing.T) {
|
||||
@@ -247,17 +250,13 @@ func TestConvertMemory(t *testing.T) {
|
||||
|
||||
result := convertMemory(memory, "2026-02-10T15:30:00Z")
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 memory modules, got %d", len(result))
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 populated memory module, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].Status != "OK" {
|
||||
t.Errorf("expected OK status for first module, got %q", result[0].Status)
|
||||
}
|
||||
|
||||
if result[1].Status != "Empty" {
|
||||
t.Errorf("expected Empty status for second module, got %q", result[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStorage(t *testing.T) {
|
||||
@@ -289,6 +288,48 @@ func TestConvertStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertStorage_RemainingEndurance(t *testing.T) {
|
||||
pct100 := 100
|
||||
pct3 := 3
|
||||
storage := []models.Storage{
|
||||
{
|
||||
Slot: "0",
|
||||
Model: "HFS480G3H2X069N",
|
||||
SerialNumber: "ESEAN5254I030B26B",
|
||||
Present: true,
|
||||
RemainingEndurancePct: &pct100,
|
||||
},
|
||||
{
|
||||
Slot: "1",
|
||||
Model: "HFS480G3H2X069N",
|
||||
SerialNumber: "ESEAN5254I030B26C",
|
||||
Present: true,
|
||||
// no endurance data
|
||||
},
|
||||
{
|
||||
Slot: "2",
|
||||
Model: "HFS480G3H2X069N",
|
||||
SerialNumber: "ESEAN5254I030B26D",
|
||||
Present: true,
|
||||
RemainingEndurancePct: &pct3,
|
||||
},
|
||||
}
|
||||
|
||||
result := convertStorage(storage, "2026-03-15T00:00:00Z")
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 results, got %d", len(result))
|
||||
}
|
||||
if result[0].RemainingEndurancePct == nil || *result[0].RemainingEndurancePct != 100 {
|
||||
t.Errorf("slot 0: expected remaining_endurance_pct=100, got %v", result[0].RemainingEndurancePct)
|
||||
}
|
||||
if result[1].RemainingEndurancePct != nil {
|
||||
t.Errorf("slot 1: expected remaining_endurance_pct absent, got %v", *result[1].RemainingEndurancePct)
|
||||
}
|
||||
if result[2].RemainingEndurancePct == nil || *result[2].RemainingEndurancePct != 3 {
|
||||
t.Errorf("slot 2: expected remaining_endurance_pct=3, got %v", result[2].RemainingEndurancePct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPCIeDevices(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
PCIeDevices: []models.PCIeDevice{
|
||||
@@ -329,16 +370,16 @@ func TestConvertPCIeDevices(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
||||
|
||||
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
|
||||
if len(result) != 4 {
|
||||
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
||||
}
|
||||
|
||||
// Check that serial is empty for second PCIe device (no auto-generation)
|
||||
if result[1].SerialNumber != "" {
|
||||
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
|
||||
// Check that serial is generated for second PCIe device
|
||||
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
|
||||
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
|
||||
}
|
||||
|
||||
// Check GPU was included
|
||||
@@ -346,8 +387,8 @@ func TestConvertPCIeDevices(t *testing.T) {
|
||||
for _, dev := range result {
|
||||
if dev.SerialNumber == "GPU-001" {
|
||||
foundGPU = true
|
||||
if dev.DeviceClass != "DisplayController" {
|
||||
t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass)
|
||||
if dev.DeviceClass != "VideoController" {
|
||||
t.Errorf("expected GPU device_class VideoController, got %q", dev.DeviceClass)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -375,14 +416,14 @@ func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 PCIe device, got %d", len(result))
|
||||
}
|
||||
|
||||
if result[0].SerialNumber != "" {
|
||||
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
|
||||
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
|
||||
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
|
||||
}
|
||||
if result[0].Firmware != "96.10.6D.00.01" {
|
||||
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
|
||||
@@ -408,12 +449,12 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
|
||||
}
|
||||
if result[0].DeviceClass != "DisplayController" {
|
||||
t.Fatalf("expected GPU record with DisplayController class, got %q", result[0].DeviceClass)
|
||||
if result[0].DeviceClass != "VideoController" {
|
||||
t.Fatalf("expected GPU record with VideoController class, got %q", result[0].DeviceClass)
|
||||
}
|
||||
if result[0].Status != "OK" {
|
||||
t.Fatalf("expected GPU status OK, got %q", result[0].Status)
|
||||
@@ -441,7 +482,7 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
|
||||
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 converted GPU, got %d", len(result))
|
||||
}
|
||||
@@ -452,9 +493,6 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
|
||||
if result[0].StatusHistory[0].ChangedAt != "2026-01-12T15:05:18Z" {
|
||||
t.Fatalf("unexpected history changed_at: %q", result[0].StatusHistory[0].ChangedAt)
|
||||
}
|
||||
if result[0].StatusAtCollect == nil || result[0].StatusAtCollect.At != "2026-02-10T15:30:00Z" {
|
||||
t.Fatalf("expected status_at_collection to be populated from collected_at")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPowerSupplies(t *testing.T) {
|
||||
@@ -518,8 +556,8 @@ func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(payload), `"source_type"`) {
|
||||
t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload))
|
||||
if !strings.Contains(string(payload), `"source_type":"logfile"`) {
|
||||
t.Fatalf("expected archive source_type to map to logfile, got %s", string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,9 +726,6 @@ func TestConvertToReanimator_StatusFallbackUsesCollectedAt(t *testing.T) {
|
||||
if got.StatusCheckedAt != wantTs {
|
||||
t.Fatalf("expected status_checked_at=%q, got %q", wantTs, got.StatusCheckedAt)
|
||||
}
|
||||
if got.StatusAtCollect == nil || got.StatusAtCollect.At != wantTs {
|
||||
t.Fatalf("expected status_at_collection.at=%q, got %#v", wantTs, got.StatusAtCollect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||
@@ -698,6 +733,9 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||
Filename: "fw-filter-test.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
CPUs: []models.CPU{
|
||||
{Socket: 0, Model: "Intel Xeon Gold"},
|
||||
},
|
||||
Firmware: []models.FirmwareInfo{
|
||||
{DeviceName: "BIOS", Version: "1.0.0"},
|
||||
{DeviceName: "BMC", Version: "2.0.0"},
|
||||
@@ -735,6 +773,58 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
|
||||
if _, exists := got["NVSwitch NVSWITCH0 (965-25612-0002-000)"]; exists {
|
||||
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
|
||||
}
|
||||
if len(out.Hardware.CPUs) != 1 {
|
||||
t.Fatalf("expected 1 CPU entry, got %d", len(out.Hardware.CPUs))
|
||||
}
|
||||
if out.Hardware.CPUs[0].Firmware != "0x2b000643" {
|
||||
t.Fatalf("expected CPU firmware field to carry microcode, got %q", out.Hardware.CPUs[0].Firmware)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertToReanimator_FirmwareExcludesDellFQDDEntries verifies that Dell TSR
|
||||
// SoftwareIdentity firmware entries whose Description contains a device-bound FQDD
|
||||
// (InfiniBand.Slot.*, RAID.SL.*, etc.) are filtered from hardware.firmware.
|
||||
//
|
||||
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" (FQDD
|
||||
// InfiniBand.Slot.1-1) and "PERC H755 Front" (FQDD RAID.SL.3-1) leaked into
|
||||
// hardware.firmware. (2026-03-15)
|
||||
func TestConvertToReanimator_FirmwareExcludesDellFQDDEntries(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "dell-fw-filter-test.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "8VS2LG4"},
|
||||
Firmware: []models.FirmwareInfo{
|
||||
// system-level — must be kept
|
||||
{DeviceName: "BIOS", Version: "1.15.3", Description: "system bios"},
|
||||
{DeviceName: "iDRAC", Version: "7.20.80.50", Description: "idrac card"},
|
||||
{DeviceName: "Lifecycle Controller", Version: "7.20.80.50", Description: "idrac lifecycle"},
|
||||
// device-bound via FQDD — must be filtered
|
||||
{DeviceName: "Mellanox Network Adapter", Version: "20.39.35.60", Description: "InfiniBand.Slot.1-1"},
|
||||
{DeviceName: "PERC H755 Front", Version: "52.30.0-6115", Description: "RAID.SL.3-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
|
||||
got := make(map[string]string, len(out.Hardware.Firmware))
|
||||
for _, fw := range out.Hardware.Firmware {
|
||||
got[fw.DeviceName] = fw.Version
|
||||
}
|
||||
|
||||
for _, keep := range []string{"BIOS", "iDRAC", "Lifecycle Controller"} {
|
||||
if _, ok := got[keep]; !ok {
|
||||
t.Errorf("expected %q in hardware.firmware, but it was missing", keep)
|
||||
}
|
||||
}
|
||||
for _, drop := range []string{"Mellanox Network Adapter", "PERC H755 Front"} {
|
||||
if _, ok := got[drop]; ok {
|
||||
t.Errorf("%q must not appear in hardware.firmware (device-bound FQDD)", drop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
|
||||
@@ -774,9 +864,65 @@ func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
|
||||
func TestConvertToReanimator_MergesCanonicalAndLegacyDevices(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "merged.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
Devices: []models.HardwareDevice{
|
||||
{
|
||||
Kind: models.DeviceKindPCIe,
|
||||
Slot: "PCIe 3",
|
||||
Model: "RAID Controller",
|
||||
DeviceClass: "raid_controller",
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
CPUs: []models.CPU{
|
||||
{Socket: 0, Model: "Xeon Platinum", SerialNumber: "CPU-001"},
|
||||
},
|
||||
Memory: []models.MemoryDIMM{
|
||||
{Slot: "DIMM0", Location: "DIMM0", Present: true, SizeMB: 32768, Type: "DDR5", SerialNumber: "MEM-001"},
|
||||
},
|
||||
Storage: []models.Storage{
|
||||
{Slot: "U.2-1", Type: "NVMe", Model: "Drive1", SerialNumber: "SSD-001", Present: true},
|
||||
},
|
||||
PowerSupply: []models.PSU{
|
||||
{Slot: "PSU0", Model: "PSU", SerialNumber: "PSU-001", Present: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
if len(out.Hardware.CPUs) != 1 {
|
||||
t.Fatalf("expected cpu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.CPUs))
|
||||
}
|
||||
if len(out.Hardware.Memory) != 1 {
|
||||
t.Fatalf("expected memory from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Memory))
|
||||
}
|
||||
if len(out.Hardware.Storage) != 1 {
|
||||
t.Fatalf("expected storage from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Storage))
|
||||
}
|
||||
if len(out.Hardware.PowerSupplies) != 1 {
|
||||
t.Fatalf("expected psu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.PowerSupplies))
|
||||
}
|
||||
if len(out.Hardware.PCIeDevices) != 1 {
|
||||
t.Fatalf("expected supplemental canonical pcie device to remain present, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "vitals.json",
|
||||
Sensors: []models.SensorReading{
|
||||
{Name: "FAN1", Type: "fan", Value: 4200, Unit: "RPM", Status: "OK"},
|
||||
{Name: "12V Rail", Type: "voltage", Value: 12.1, Unit: "V", Status: "OK"},
|
||||
{Name: "CPU0 Temp", Type: "temperature", Value: 71, Unit: "C", Status: "Warning"},
|
||||
{Name: "Humidity", Type: "humidity", Value: 38.5, Unit: "%", Status: "OK"},
|
||||
},
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
Devices: []models.HardwareDevice{
|
||||
@@ -786,11 +932,6 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
|
||||
Model: "B200 180GB HBM3e",
|
||||
SerialNumber: "GPU-001",
|
||||
BDF: "0000:17:00.0",
|
||||
Details: map[string]any{
|
||||
"temperature": 71,
|
||||
"power": 350,
|
||||
"voltage": 12.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindPSU,
|
||||
@@ -815,26 +956,38 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
|
||||
t.Fatalf("expected one pcie device, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
pcie := out.Hardware.PCIeDevices[0]
|
||||
if pcie.TemperatureC != 71 {
|
||||
t.Fatalf("expected GPU temperature 71C, got %d", pcie.TemperatureC)
|
||||
}
|
||||
if pcie.PowerW != 350 {
|
||||
t.Fatalf("expected GPU power 350W, got %d", pcie.PowerW)
|
||||
}
|
||||
if pcie.VoltageV != 12.2 {
|
||||
t.Fatalf("expected device voltage 12.2V, got %.2f", pcie.VoltageV)
|
||||
if pcie.TemperatureC != 0 {
|
||||
t.Fatalf("expected canonical GPU telemetry to stay off the component unless sourced from details/gpu path, got %.2f", pcie.TemperatureC)
|
||||
}
|
||||
|
||||
if len(out.Hardware.PowerSupplies) != 1 {
|
||||
t.Fatalf("expected one PSU, got %d", len(out.Hardware.PowerSupplies))
|
||||
}
|
||||
psu := out.Hardware.PowerSupplies[0]
|
||||
if psu.InputPowerW != 1400 {
|
||||
t.Fatalf("expected PSU input power 1400W, got %.2f", psu.InputPowerW)
|
||||
}
|
||||
if psu.TemperatureC != 44 {
|
||||
t.Fatalf("expected PSU temperature 44C, got %d", psu.TemperatureC)
|
||||
t.Fatalf("expected PSU temperature 44C, got %.2f", psu.TemperatureC)
|
||||
}
|
||||
if out.Hardware.Sensors == nil {
|
||||
t.Fatalf("expected sensors section")
|
||||
}
|
||||
if len(out.Hardware.Sensors.Fans) != 1 || out.Hardware.Sensors.Fans[0].RPM != 4200 {
|
||||
t.Fatalf("expected fan sensor export, got %#v", out.Hardware.Sensors.Fans)
|
||||
}
|
||||
if len(out.Hardware.Sensors.Power) != 1 || out.Hardware.Sensors.Power[0].VoltageV != 12.1 {
|
||||
t.Fatalf("expected power sensor export, got %#v", out.Hardware.Sensors.Power)
|
||||
}
|
||||
if len(out.Hardware.Sensors.Temperatures) != 1 || out.Hardware.Sensors.Temperatures[0].Celsius != 71 {
|
||||
t.Fatalf("expected temperature sensor export, got %#v", out.Hardware.Sensors.Temperatures)
|
||||
}
|
||||
if len(out.Hardware.Sensors.Other) != 1 || out.Hardware.Sensors.Other[0].Unit != "%" {
|
||||
t.Fatalf("expected other sensor export, got %#v", out.Hardware.Sensors.Other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
|
||||
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "dedup-vitals.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
@@ -872,11 +1025,283 @@ func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
|
||||
t.Fatalf("expected deduped one pcie entry, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
got := out.Hardware.PCIeDevices[0]
|
||||
if got.DeviceClass != "VideoController" {
|
||||
t.Fatalf("expected GPU to export as VideoController, got %q", got.DeviceClass)
|
||||
}
|
||||
if got.TemperatureC != 67 {
|
||||
t.Fatalf("expected deduped GPU temperature 67C, got %d", got.TemperatureC)
|
||||
t.Fatalf("expected deduped GPU temperature 67C, got %.2f", got.TemperatureC)
|
||||
}
|
||||
if got.PowerW != 330 {
|
||||
t.Fatalf("expected deduped GPU power 330W, got %d", got.PowerW)
|
||||
t.Fatalf("expected deduped GPU power 330W, got %.2f", got.PowerW)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_DedupesLooseCanonicalNICAndPCIeEntries(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "loose-dedup.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
PCIeDevices: []models.PCIeDevice{
|
||||
{
|
||||
Slot: "Slot 4",
|
||||
DeviceClass: "NetworkController",
|
||||
VendorID: 0x15b3,
|
||||
DeviceID: 0x1021,
|
||||
Manufacturer: "Mellanox",
|
||||
PartNumber: "MCX623106AC-CDAT",
|
||||
},
|
||||
},
|
||||
NetworkAdapters: []models.NetworkAdapter{
|
||||
{
|
||||
Slot: "Slot 4",
|
||||
Model: "ConnectX-6",
|
||||
VendorID: 0x15b3,
|
||||
DeviceID: 0x1021,
|
||||
Vendor: "Mellanox",
|
||||
MACAddresses: []string{"00:11:22:33:44:55"},
|
||||
PortCount: 2,
|
||||
Present: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
if len(out.Hardware.PCIeDevices) != 1 {
|
||||
t.Fatalf("expected one merged loose-key pcie entry, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
got := out.Hardware.PCIeDevices[0]
|
||||
if got.Model == "" {
|
||||
t.Fatalf("expected merged pcie entry to retain a model, got empty")
|
||||
}
|
||||
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
|
||||
t.Fatalf("expected MACs from NIC side after loose merge, got %#v", got.MACAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "contract-v24.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
Devices: []models.HardwareDevice{
|
||||
{
|
||||
Kind: models.DeviceKindCPU,
|
||||
Slot: "CPU0",
|
||||
Model: "INTEL(R) XEON(R) GOLD 6530",
|
||||
Details: map[string]any{
|
||||
"socket": 0,
|
||||
"temperature_c": 61.5,
|
||||
"power_w": 182.0,
|
||||
"throttled": false,
|
||||
"correctable_error_count": int64(4),
|
||||
"uncorrectable_error_count": int64(1),
|
||||
"life_remaining_pct": 98.5,
|
||||
"life_used_pct": 1.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindMemory,
|
||||
Slot: "DIMM_A1",
|
||||
SerialNumber: "MEM-001",
|
||||
Present: boolPtr(true),
|
||||
SizeMB: 32768,
|
||||
Type: "DDR5",
|
||||
Details: map[string]any{
|
||||
"temperature_c": 43.0,
|
||||
"correctable_ecc_error_count": int64(2),
|
||||
"uncorrectable_ecc_error_count": int64(0),
|
||||
"life_remaining_pct": 99.0,
|
||||
"spare_blocks_remaining_pct": 97.0,
|
||||
"performance_degraded": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindStorage,
|
||||
Slot: "U.2-1",
|
||||
SerialNumber: "SSD-001",
|
||||
Model: "PM9A3",
|
||||
Present: boolPtr(true),
|
||||
Details: map[string]any{
|
||||
"temperature_c": 38.5,
|
||||
"power_on_hours": int64(12450),
|
||||
"unsafe_shutdowns": int64(3),
|
||||
"written_bytes": int64(9876543210),
|
||||
"life_remaining_pct": 91.0,
|
||||
"available_spare_pct": 88.0,
|
||||
"offline_uncorrectable": int64(0),
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindPCIe,
|
||||
Slot: "PCIeCard2",
|
||||
SerialNumber: "NIC-001",
|
||||
DeviceClass: "EthernetController",
|
||||
NUMANode: 1,
|
||||
Details: map[string]any{
|
||||
"temperature_c": 48.5,
|
||||
"power_w": 18.2,
|
||||
"life_remaining_pct": 95.0,
|
||||
"ecc_corrected_total": int64(12),
|
||||
"battery_health_pct": 87.0,
|
||||
"sfp_temperature_c": 36.2,
|
||||
"sfp_tx_power_dbm": -1.8,
|
||||
"sfp_rx_power_dbm": -2.1,
|
||||
"sfp_bias_ma": 5.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: models.DeviceKindPSU,
|
||||
Slot: "PSU0",
|
||||
SerialNumber: "PSU-001",
|
||||
Present: boolPtr(true),
|
||||
Details: map[string]any{
|
||||
"life_remaining_pct": 97.0,
|
||||
"life_used_pct": 3.0,
|
||||
},
|
||||
TemperatureC: 39,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
|
||||
if got := out.Hardware.CPUs[0]; got.TemperatureC != 61.5 || got.PowerW != 182.0 || got.Throttled == nil || *got.Throttled {
|
||||
t.Fatalf("unexpected CPU telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.Memory[0]; got.TemperatureC != 43.0 || got.CorrectableECCErrorCount != 2 || got.PerformanceDegraded == nil || *got.PerformanceDegraded {
|
||||
t.Fatalf("unexpected memory telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
|
||||
t.Fatalf("unexpected storage telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.PCIeDevices[0]; got.NUMANode != 1 || got.TemperatureC != 48.5 || got.PowerW != 18.2 || got.SFPTemperatureC != 36.2 {
|
||||
t.Fatalf("unexpected PCIe telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 39 || got.LifeRemainingPct != 97.0 || got.LifeUsedPct != 3.0 {
|
||||
t.Fatalf("unexpected PSU telemetry: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "legacy-details.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
Storage: []models.Storage{
|
||||
{
|
||||
Slot: "Drive0",
|
||||
Type: "NVMe",
|
||||
Model: "NVMe SSD",
|
||||
SerialNumber: "SSD-001",
|
||||
Present: true,
|
||||
Details: map[string]any{
|
||||
"temperature_c": 38.5,
|
||||
"power_on_hours": int64(12450),
|
||||
"life_remaining_pct": 91.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
PowerSupply: []models.PSU{
|
||||
{
|
||||
Slot: "PSU0",
|
||||
Model: "PSU",
|
||||
SerialNumber: "PSU-001",
|
||||
Present: true,
|
||||
Details: map[string]any{
|
||||
"temperature_c": 41.0,
|
||||
"life_remaining_pct": 96.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := ConvertToReanimator(input)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
|
||||
t.Fatalf("expected storage details from legacy model to survive canonical conversion, got %+v", got)
|
||||
}
|
||||
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 41.0 || got.LifeRemainingPct != 96.0 {
|
||||
t.Fatalf("expected psu details from legacy model to survive canonical conversion, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator_PreservesLegacyPCIeAndNICDetails(t *testing.T) {
|
||||
input := &models.AnalysisResult{
|
||||
Filename: "legacy-pcie-details.json",
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
|
||||
PCIeDevices: []models.PCIeDevice{
|
||||
{
|
||||
Slot: "PCIe 1",
|
||||
BDF: "0000:17:00.0",
|
||||
VendorID: 0x10de,
|
||||
DeviceID: 0x2331,
|
||||
PartNumber: "H100",
|
||||
Manufacturer: "NVIDIA",
|
||||
SerialNumber: "GPU-001",
|
||||
Details: map[string]any{
|
||||
"temperature_c": 48.5,
|
||||
"power_w": 315.0,
|
||||
"ecc_corrected_total": int64(12),
|
||||
"battery_health_pct": 87.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
NetworkAdapters: []models.NetworkAdapter{
|
||||
{
|
||||
Slot: "Slot 4",
|
||||
BDF: "0000:18:00.0",
|
||||
VendorID: 0x15b3,
|
||||
DeviceID: 0x1021,
|
||||
Model: "ConnectX-6",
|
||||
SerialNumber: "NIC-001",
|
||||
Present: true,
|
||||
Details: map[string]any{
|
||||
"sfp_temperature_c": 34.0,
|
||||
"sfp_tx_power_dbm": -1.8,
|
||||
"sfp_rx_power_dbm": -2.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 devices, got %d", len(out.Hardware.PCIeDevices))
|
||||
}
|
||||
foundGPU := false
|
||||
foundNIC := false
|
||||
for _, dev := range out.Hardware.PCIeDevices {
|
||||
switch dev.SerialNumber {
|
||||
case "GPU-001":
|
||||
foundGPU = true
|
||||
if dev.TemperatureC != 48.5 || dev.PowerW != 315.0 || dev.ECCCorrectedTotal != 12 || dev.BatteryHealthPct != 87.0 {
|
||||
t.Fatalf("expected GPU telemetry preserved, got %+v", dev)
|
||||
}
|
||||
case "NIC-001":
|
||||
foundNIC = true
|
||||
if dev.SFPTemperatureC != 34.0 || dev.SFPTXPowerDBm != -1.8 || dev.SFPRXPowerDBm != -2.1 {
|
||||
t.Fatalf("expected NIC sfp telemetry preserved, got %+v", dev)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundGPU || !foundNIC {
|
||||
t.Fatalf("expected both gpu and nic pcie-class exports, got %+v", out.Hardware.PCIeDevices)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,3 +1357,42 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsDeviceBoundFirmwareFQDD verifies that Dell TSR SoftwareIdentity FQDD strings
|
||||
// (stored in FirmwareInfo.Description) correctly identify device-bound entries.
|
||||
//
|
||||
// Regression guard: "InfiniBand.Slot.1-1" (Mellanox ConnectX-6) and "RAID.SL.3-1"
|
||||
// (PERC H755 Front) were not filtered because only "raid.backplane." was listed and
|
||||
// "infiniband." was absent. Both firmware entries leaked into hardware.firmware on
|
||||
// PowerEdge R6625 (8VS2LG4). (2026-03-15)
|
||||
func TestIsDeviceBoundFirmwareFQDD(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc string
|
||||
want bool
|
||||
}{
|
||||
// Dell TSR SoftwareIdentity FQDDs — device-bound, must be excluded
|
||||
{"InfiniBand.Slot.1-1", true}, // Mellanox ConnectX-6
|
||||
{"InfiniBand.Slot.2-1", true}, // any InfiniBand slot
|
||||
{"RAID.SL.3-1", true}, // PERC H755 Front
|
||||
{"RAID.Integrated.1-1", true}, // embedded RAID controller
|
||||
{"RAID.Backplane.Firmware.0", true}, // backplane (previously covered)
|
||||
{"NIC.Integrated.1-1-1", true}, // embedded NIC
|
||||
{"NIC.Slot.1-1-1", true}, // slotted NIC
|
||||
{"PSU.Slot.1", true}, // PSU
|
||||
{"Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1", true},
|
||||
{"GPU.Slot.1-1", true},
|
||||
{"FC.Slot.1-1", true}, // Fibre Channel HBA
|
||||
// System-level descriptions — must NOT be excluded
|
||||
{"system bios", false},
|
||||
{"idrac lifecycle", false},
|
||||
{"idrac card", false},
|
||||
{"storage controller", false}, // legacy description before fqdd fix
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := isDeviceBoundFirmwareFQDD(tc.desc)
|
||||
if got != tc.want {
|
||||
t.Errorf("isDeviceBoundFirmwareFQDD(%q) = %v, want %v", tc.desc, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type ReanimatorHardware struct {
|
||||
Storage []ReanimatorStorage `json:"storage,omitempty"`
|
||||
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
|
||||
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
|
||||
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorBoard represents motherboard/server information
|
||||
@@ -36,11 +37,6 @@ type ReanimatorFirmware struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type ReanimatorStatusAtCollection struct {
|
||||
Status string `json:"status"`
|
||||
At string `json:"at"`
|
||||
}
|
||||
|
||||
type ReanimatorStatusHistoryEntry struct {
|
||||
Status string `json:"status"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
@@ -49,90 +45,136 @@ type ReanimatorStatusHistoryEntry struct {
|
||||
|
||||
// ReanimatorCPU represents processor information
|
||||
type ReanimatorCPU struct {
|
||||
Socket int `json:"socket"`
|
||||
Model string `json:"model"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Threads int `json:"threads,omitempty"`
|
||||
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Socket int `json:"socket"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Threads int `json:"threads,omitempty"`
|
||||
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
Throttled *bool `json:"throttled,omitempty"`
|
||||
CorrectableErrorCount int64 `json:"correctable_error_count,omitempty"`
|
||||
UncorrectableErrorCount int64 `json:"uncorrectable_error_count,omitempty"`
|
||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorMemory represents a memory module (DIMM)
|
||||
type ReanimatorMemory struct {
|
||||
Slot string `json:"slot"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Present bool `json:"present"`
|
||||
SizeMB int `json:"size_mb,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
|
||||
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
SizeMB int `json:"size_mb,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
|
||||
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
CorrectableECCErrorCount int64 `json:"correctable_ecc_error_count,omitempty"`
|
||||
UncorrectableECCErrorCount int64 `json:"uncorrectable_ecc_error_count,omitempty"`
|
||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||
SpareBlocksRemainingPct float64 `json:"spare_blocks_remaining_pct,omitempty"`
|
||||
PerformanceDegraded *bool `json:"performance_degraded,omitempty"`
|
||||
DataLossDetected *bool `json:"data_loss_detected,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorStorage represents a storage device
|
||||
type ReanimatorStorage struct {
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Model string `json:"model"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present bool `json:"present"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Model string `json:"model"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerOnHours int64 `json:"power_on_hours,omitempty"`
|
||||
PowerCycles int64 `json:"power_cycles,omitempty"`
|
||||
UnsafeShutdowns int64 `json:"unsafe_shutdowns,omitempty"`
|
||||
MediaErrors int64 `json:"media_errors,omitempty"`
|
||||
ErrorLogEntries int64 `json:"error_log_entries,omitempty"`
|
||||
WrittenBytes int64 `json:"written_bytes,omitempty"`
|
||||
ReadBytes int64 `json:"read_bytes,omitempty"`
|
||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"`
|
||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||
AvailableSparePct float64 `json:"available_spare_pct,omitempty"`
|
||||
ReallocatedSectors int64 `json:"reallocated_sectors,omitempty"`
|
||||
CurrentPendingSectors int64 `json:"current_pending_sectors,omitempty"`
|
||||
OfflineUncorrectable int64 `json:"offline_uncorrectable,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorPCIe represents a PCIe device
|
||||
type ReanimatorPCIe struct {
|
||||
Slot string `json:"slot"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
DeviceClass string `json:"device_class,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
LinkWidth int `json:"link_width,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
PowerW int `json:"power_w,omitempty"`
|
||||
VoltageV float64 `json:"voltage_v,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||
ECCCorrectedTotal int64 `json:"ecc_corrected_total,omitempty"`
|
||||
ECCUncorrectedTotal int64 `json:"ecc_uncorrected_total,omitempty"`
|
||||
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
|
||||
BatteryChargePct float64 `json:"battery_charge_pct,omitempty"`
|
||||
BatteryHealthPct float64 `json:"battery_health_pct,omitempty"`
|
||||
BatteryTemperatureC float64 `json:"battery_temperature_c,omitempty"`
|
||||
BatteryVoltageV float64 `json:"battery_voltage_v,omitempty"`
|
||||
BatteryReplaceRequired *bool `json:"battery_replace_required,omitempty"`
|
||||
SFPTemperatureC float64 `json:"sfp_temperature_c,omitempty"`
|
||||
SFPTXPowerDBm float64 `json:"sfp_tx_power_dbm,omitempty"`
|
||||
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
|
||||
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
|
||||
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
DeviceClass string `json:"device_class,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
LinkWidth int `json:"link_width,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// ReanimatorPSU represents a power supply unit
|
||||
type ReanimatorPSU struct {
|
||||
Slot string `json:"slot"`
|
||||
Present bool `json:"present"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
@@ -141,13 +183,54 @@ type ReanimatorPSU struct {
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
InputPowerW int `json:"input_power_w,omitempty"`
|
||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||
InputPowerW float64 `json:"input_power_w,omitempty"`
|
||||
OutputPowerW float64 `json:"output_power_w,omitempty"`
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
|
||||
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
|
||||
StatusCheckedAt string `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt string `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
type ReanimatorSensors struct {
|
||||
Fans []ReanimatorFanSensor `json:"fans,omitempty"`
|
||||
Power []ReanimatorPowerSensor `json:"power,omitempty"`
|
||||
Temperatures []ReanimatorTemperatureSensor `json:"temperatures,omitempty"`
|
||||
Other []ReanimatorOtherSensor `json:"other,omitempty"`
|
||||
}
|
||||
|
||||
type ReanimatorFanSensor struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location,omitempty"`
|
||||
RPM int `json:"rpm,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ReanimatorPowerSensor struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location,omitempty"`
|
||||
VoltageV float64 `json:"voltage_v,omitempty"`
|
||||
CurrentA float64 `json:"current_a,omitempty"`
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ReanimatorTemperatureSensor struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Celsius float64 `json:"celsius,omitempty"`
|
||||
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
|
||||
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type ReanimatorOtherSensor struct {
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ const (
|
||||
|
||||
// AnalysisResult contains all parsed data from an archive
|
||||
type AnalysisResult struct {
|
||||
Filename string `json:"filename"`
|
||||
SourceType string `json:"source_type,omitempty"` // archive | api
|
||||
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
||||
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
||||
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||
Events []Event `json:"events"`
|
||||
FRU []FRUInfo `json:"fru"`
|
||||
Sensors []SensorReading `json:"sensors"`
|
||||
Hardware *HardwareConfig `json:"hardware"`
|
||||
Filename string `json:"filename"`
|
||||
SourceType string `json:"source_type,omitempty"` // archive | api
|
||||
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
||||
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
||||
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||
Events []Event `json:"events"`
|
||||
FRU []FRUInfo `json:"fru"`
|
||||
Sensors []SensorReading `json:"sensors"`
|
||||
Hardware *HardwareConfig `json:"hardware"`
|
||||
}
|
||||
|
||||
// Event represents a single log event
|
||||
@@ -110,43 +110,45 @@ const (
|
||||
|
||||
// HardwareDevice is canonical device inventory used across UI and exports.
|
||||
type HardwareDevice struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
DeviceClass string `json:"device_class,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
SizeMB int `json:"size_mb,omitempty"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Threads int `json:"threads,omitempty"`
|
||||
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
|
||||
PortCount int `json:"port_count,omitempty"`
|
||||
PortType string `json:"port_type,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
LinkWidth int `json:"link_width,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
InputPowerW int `json:"input_power_w,omitempty"`
|
||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Slot string `json:"slot,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
DeviceClass string `json:"device_class,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present *bool `json:"present,omitempty"`
|
||||
SizeMB int `json:"size_mb,omitempty"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Threads int `json:"threads,omitempty"`
|
||||
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
|
||||
PortCount int `json:"port_count,omitempty"`
|
||||
PortType string `json:"port_type,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
LinkWidth int `json:"link_width,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
InputPowerW int `json:"input_power_w,omitempty"`
|
||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
@@ -167,14 +169,14 @@ type FirmwareInfo struct {
|
||||
|
||||
// BoardInfo represents motherboard/system information
|
||||
type BoardInfo struct {
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
BMCMACAddress string `json:"bmc_mac_address,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
ProductName string `json:"product_name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
BMCMACAddress string `json:"bmc_mac_address,omitempty"`
|
||||
}
|
||||
|
||||
// CPU represents processor information
|
||||
@@ -194,11 +196,12 @@ type CPU struct {
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryDIMM represents a memory module
|
||||
@@ -218,31 +221,34 @@ type MemoryDIMM struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Ranks int `json:"ranks,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Storage represents a storage device
|
||||
type Storage struct {
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SizeGB int `json:"size_gb"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present bool `json:"present"`
|
||||
Location string `json:"location,omitempty"` // Front/Rear
|
||||
BackplaneID int `json:"backplane_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SizeGB int `json:"size_gb"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Present bool `json:"present"`
|
||||
Location string `json:"location,omitempty"` // Front/Rear
|
||||
BackplaneID int `json:"backplane_id,omitempty"`
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||
Status string `json:"status,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
@@ -250,15 +256,15 @@ type Storage struct {
|
||||
|
||||
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
|
||||
type StorageVolume struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Controller string `json:"controller,omitempty"`
|
||||
RAIDLevel string `json:"raid_level,omitempty"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Bootable bool `json:"bootable,omitempty"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Controller string `json:"controller,omitempty"`
|
||||
RAIDLevel string `json:"raid_level,omitempty"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Bootable bool `json:"bootable,omitempty"`
|
||||
Encrypted bool `json:"encrypted,omitempty"`
|
||||
}
|
||||
|
||||
// PCIeDevice represents a PCIe device
|
||||
@@ -277,13 +283,15 @@ type PCIeDevice struct {
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// NIC represents a network interface card
|
||||
@@ -298,25 +306,26 @@ type NIC struct {
|
||||
|
||||
// PSU represents a power supply unit
|
||||
type PSU struct {
|
||||
Slot string `json:"slot"`
|
||||
Present bool `json:"present"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
InputPowerW int `json:"input_power_w,omitempty"`
|
||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
Slot string `json:"slot"`
|
||||
Present bool `json:"present"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
InputType string `json:"input_type,omitempty"`
|
||||
InputPowerW int `json:"input_power_w,omitempty"`
|
||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
@@ -353,11 +362,12 @@ type GPU struct {
|
||||
CurrentLinkSpeed string `json:"current_link_speed,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkAdapter represents a network adapter with detailed info
|
||||
@@ -365,6 +375,7 @@ type NetworkAdapter struct {
|
||||
Slot string `json:"slot"`
|
||||
Location string `json:"location"`
|
||||
Present bool `json:"present"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
@@ -376,11 +387,17 @@ type NetworkAdapter struct {
|
||||
PortCount int `json:"port_count,omitempty"`
|
||||
PortType string `json:"port_type,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
LinkWidth int `json:"link_width,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
|
||||
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
|
||||
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
81
internal/parser/vendors/dell/parser.go
vendored
81
internal/parser/vendors/dell/parser.go
vendored
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
|
||||
)
|
||||
|
||||
const parserVersion = "3.0"
|
||||
@@ -199,7 +200,7 @@ func parseDCIMViewXML(content []byte, result *models.AnalysisResult) {
|
||||
parsePowerSupplyView(props, result)
|
||||
case "DCIM_PCIDeviceView":
|
||||
parsePCIeDeviceView(props, result)
|
||||
case "DCIM_NICView":
|
||||
case "DCIM_NICView", "DCIM_InfiniBandView":
|
||||
parseNICView(props, result)
|
||||
case "DCIM_VideoView":
|
||||
parseVideoView(props, result)
|
||||
@@ -374,6 +375,10 @@ func parsePhysicalDiskView(props map[string]string, result *models.AnalysisResul
|
||||
Location: strings.TrimSpace(props["devicedescription"]),
|
||||
Status: normalizeStatus(firstNonEmpty(props["raidstatus"], props["primarystatus"])),
|
||||
}
|
||||
if v := strings.TrimSpace(props["remainingratedwriteendurance"]); v != "" {
|
||||
n := parseIntLoose(v)
|
||||
st.RemainingEndurancePct = &n
|
||||
}
|
||||
result.Hardware.Storage = append(result.Hardware.Storage, st)
|
||||
}
|
||||
|
||||
@@ -437,11 +442,18 @@ var pcieFQDDNoisePrefix = []string{
|
||||
"SMBus.Embedded.",
|
||||
"AHCI.Embedded.",
|
||||
"Video.Embedded.",
|
||||
"NIC.Embedded.",
|
||||
// All NIC FQDD classes are parsed from DCIM_NICView / DCIM_InfiniBandView into
|
||||
// NetworkAdapters with model, MAC, firmware, and VendorID/DeviceID. The
|
||||
// DCIM_PCIDeviceView duplicate carries only DataBusWidth ("Unknown", "16x or x16")
|
||||
// and no useful extra data, so suppress it here.
|
||||
"NIC.",
|
||||
"InfiniBand.",
|
||||
}
|
||||
|
||||
func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult) {
|
||||
desc := strings.TrimSpace(firstNonEmpty(props["devicedescription"], props["description"]))
|
||||
// "description" is the chip/device model (e.g. "MT28908 Family [ConnectX-6]"); prefer
|
||||
// it over "devicedescription" which is the location string ("InfiniBand in Slot 1 Port 1").
|
||||
desc := strings.TrimSpace(firstNonEmpty(props["description"], props["devicedescription"]))
|
||||
fqdd := strings.TrimSpace(firstNonEmpty(props["fqdd"], props["instanceid"]))
|
||||
if desc == "" && fqdd == "" {
|
||||
return
|
||||
@@ -451,14 +463,26 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
|
||||
return
|
||||
}
|
||||
}
|
||||
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
|
||||
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
|
||||
manufacturer := strings.TrimSpace(props["manufacturer"])
|
||||
|
||||
// General rule: if chip model not found in logs but PCI IDs are known, resolve from pci.ids
|
||||
if desc == "" && vendorID != 0 && deviceID != 0 {
|
||||
desc = pciids.DeviceName(vendorID, deviceID)
|
||||
}
|
||||
if manufacturer == "" && vendorID != 0 {
|
||||
manufacturer = pciids.VendorName(vendorID)
|
||||
}
|
||||
|
||||
p := models.PCIeDevice{
|
||||
Slot: fqdd,
|
||||
Description: desc,
|
||||
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
|
||||
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
|
||||
VendorID: vendorID,
|
||||
DeviceID: deviceID,
|
||||
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
|
||||
DeviceClass: strings.TrimSpace(props["databuswidth"]),
|
||||
Manufacturer: strings.TrimSpace(props["manufacturer"]),
|
||||
Manufacturer: manufacturer,
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
|
||||
@@ -471,15 +495,31 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
||||
return
|
||||
}
|
||||
mac := strings.TrimSpace(firstNonEmpty(props["currentmacaddress"], props["permanentmacaddress"]))
|
||||
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
|
||||
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
|
||||
vendor := strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"]))
|
||||
|
||||
// Prefer pci.ids chip model over generic ProductName when PCI IDs are available.
|
||||
// Dell TSR often reports a marketing name (e.g. "Mellanox Network Adapter") while
|
||||
// pci.ids has the precise chip identifier (e.g. "MT28908 Family [ConnectX-6]").
|
||||
if vendorID != 0 && deviceID != 0 {
|
||||
if chipModel := pciids.DeviceName(vendorID, deviceID); chipModel != "" {
|
||||
model = chipModel
|
||||
}
|
||||
if vendor == "" {
|
||||
vendor = pciids.VendorName(vendorID)
|
||||
}
|
||||
}
|
||||
|
||||
n := models.NetworkAdapter{
|
||||
Slot: fqdd,
|
||||
Location: strings.TrimSpace(firstNonEmpty(props["devicedescription"], fqdd)),
|
||||
Present: true,
|
||||
Model: model,
|
||||
Description: strings.TrimSpace(props["protocol"]),
|
||||
Vendor: strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"])),
|
||||
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
|
||||
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
|
||||
Vendor: vendor,
|
||||
VendorID: vendorID,
|
||||
DeviceID: deviceID,
|
||||
SerialNumber: strings.TrimSpace(props["serialnumber"]),
|
||||
PartNumber: strings.TrimSpace(props["partnumber"]),
|
||||
Firmware: strings.TrimSpace(firstNonEmpty(
|
||||
@@ -489,6 +529,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
||||
props["controllerbiosversion"],
|
||||
)),
|
||||
PortCount: inferPortCountFromFQDD(fqdd),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
if mac != "" {
|
||||
@@ -542,10 +583,11 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
|
||||
DeviceClass: "storage-controller",
|
||||
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
|
||||
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
})
|
||||
|
||||
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], "storage controller")
|
||||
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], firstNonEmpty(fqdd, "storage controller"))
|
||||
}
|
||||
|
||||
func parseControllerBatteryView(props map[string]string, result *models.AnalysisResult) {
|
||||
@@ -1131,6 +1173,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
|
||||
}
|
||||
setIfEmpty(&dst.Location, src.Location)
|
||||
setIfEmpty(&dst.Status, src.Status)
|
||||
dst.Details = mergeDellDetails(dst.Details, src.Details)
|
||||
}
|
||||
|
||||
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||
@@ -1202,6 +1245,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
|
||||
dst.InputVoltage = src.InputVoltage
|
||||
}
|
||||
setIfEmpty(&dst.InputType, src.InputType)
|
||||
dst.Details = mergeDellDetails(dst.Details, src.Details)
|
||||
}
|
||||
|
||||
func mergeDellDetails(primary, secondary map[string]any) map[string]any {
|
||||
if len(secondary) == 0 {
|
||||
return primary
|
||||
}
|
||||
if primary == nil {
|
||||
primary = make(map[string]any, len(secondary))
|
||||
}
|
||||
for key, value := range secondary {
|
||||
if _, ok := primary[key]; !ok {
|
||||
primary[key] = value
|
||||
}
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
||||
|
||||
256
internal/parser/vendors/dell/parser_test.go
vendored
256
internal/parser/vendors/dell/parser_test.go
vendored
@@ -204,6 +204,262 @@ func TestParseNestedTSRZip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDellPhysicalDiskEndurance verifies that RemainingRatedWriteEndurance from
|
||||
// DCIM_PhysicalDiskView is parsed into Storage.RemainingEndurancePct.
|
||||
func TestParseDellPhysicalDiskEndurance(t *testing.T) {
|
||||
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
|
||||
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Slot"><VALUE>0</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Model"><VALUE>HFS480G3H2X069N</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="SerialNumber"><VALUE>ESEAN5254I030B26B</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="SizeInBytes"><VALUE>479559942144</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="MediaType"><VALUE>Solid State Drive</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="BusProtocol"><VALUE>SATA</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Revision"><VALUE>DZ03</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="RemainingRatedWriteEndurance"><VALUE>100</VALUE><DisplayValue>100 %</DisplayValue></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>1</VALUE><DisplayValue>OK</DisplayValue></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.1:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Slot"><VALUE>1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Model"><VALUE>TOSHIBA MG08ADA800E</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="SerialNumber"><VALUE>X1G0A0YXFVVG</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="SizeInBytes"><VALUE>8001563222016</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="MediaType"><VALUE>Hard Disk Drive</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="BusProtocol"><VALUE>SAS</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Revision"><VALUE>0104</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
</SIMPLEREQ></MESSAGE></CIM>`
|
||||
|
||||
inner := makeZipArchive(t, map[string][]byte{
|
||||
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
|
||||
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
|
||||
})
|
||||
|
||||
p := &Parser{}
|
||||
result, err := p.Parse([]parser.ExtractedFile{
|
||||
{Path: "signature", Content: []byte("ok")},
|
||||
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if len(result.Hardware.Storage) != 2 {
|
||||
t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
|
||||
ssd := result.Hardware.Storage[0]
|
||||
if ssd.RemainingEndurancePct == nil {
|
||||
t.Fatalf("SSD slot 0: expected RemainingEndurancePct to be set")
|
||||
}
|
||||
if *ssd.RemainingEndurancePct != 100 {
|
||||
t.Errorf("SSD slot 0: expected RemainingEndurancePct=100, got %d", *ssd.RemainingEndurancePct)
|
||||
}
|
||||
|
||||
hdd := result.Hardware.Storage[1]
|
||||
if hdd.RemainingEndurancePct != nil {
|
||||
t.Errorf("HDD slot 1: expected RemainingEndurancePct absent, got %d", *hdd.RemainingEndurancePct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDellInfiniBandView verifies that DCIM_InfiniBandView entries are parsed as
|
||||
// NetworkAdapters (not PCIe devices) and that the corresponding SoftwareIdentity firmware
|
||||
// entry with FQDD "InfiniBand.Slot.*" does not leak into hardware.firmware.
|
||||
//
|
||||
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" version
|
||||
// "20.39.35.60" appeared in hardware.firmware because DCIM_InfiniBandView was ignored
|
||||
// (device ended up only in PCIeDevices with model "16x or x16") and SoftwareIdentity
|
||||
// FQDD "InfiniBand.Slot.1-1" was not filtered. (2026-03-15)
|
||||
func TestParseDellInfiniBandView(t *testing.T) {
|
||||
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
|
||||
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="CurrentMACAddress"><VALUE>00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="FamilyVersion"><VALUE>20.39.35.60</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="EFIVersion"><VALUE>14.32.17</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Description"><VALUE>MT28908 Family [ConnectX-6]</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Manufacturer"><VALUE>Mellanox Technologies</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="DataBusWidth"><DisplayValue>16x or x16</DisplayValue></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ProductName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ControllerFirmwareVersion"><VALUE>52.30.0-6115</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
</SIMPLEREQ></MESSAGE></CIM>`
|
||||
|
||||
const swXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
|
||||
<PROPERTY NAME="ElementName"><VALUE>Mellanox Network Adapter - 00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="VersionString"><VALUE>20.39.35.60</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
|
||||
<PROPERTY NAME="ElementName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="VersionString"><VALUE>52.30.0-6115</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
|
||||
<PROPERTY NAME="ElementName"><VALUE>BIOS</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="FQDD"><VALUE>BIOS.Setup.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="VersionString"><VALUE>1.15.3</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
</SIMPLEREQ></MESSAGE></CIM>`
|
||||
|
||||
inner := makeZipArchive(t, map[string][]byte{
|
||||
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
|
||||
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
|
||||
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_SoftwareIdentity.xml": []byte(swXML),
|
||||
})
|
||||
|
||||
p := &Parser{}
|
||||
result, err := p.Parse([]parser.ExtractedFile{
|
||||
{Path: "signature", Content: []byte("ok")},
|
||||
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
// InfiniBand adapter must appear as a NetworkAdapter, not a PCIe device.
|
||||
if len(result.Hardware.NetworkAdapters) != 1 {
|
||||
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
|
||||
}
|
||||
nic := result.Hardware.NetworkAdapters[0]
|
||||
if nic.Slot != "InfiniBand.Slot.1-1" {
|
||||
t.Errorf("unexpected NIC slot: %q", nic.Slot)
|
||||
}
|
||||
if nic.Firmware != "20.39.35.60" {
|
||||
t.Errorf("unexpected NIC firmware: %q", nic.Firmware)
|
||||
}
|
||||
if len(nic.MACAddresses) == 0 || nic.MACAddresses[0] != "00:1C:FD:D7:5A:E6" {
|
||||
t.Errorf("unexpected NIC MAC: %v", nic.MACAddresses)
|
||||
}
|
||||
// pci.ids enrichment: VendorID=0x15B3, DeviceID=0x101B → chip model + vendor name.
|
||||
if nic.Model != "MT28908 Family [ConnectX-6]" {
|
||||
t.Errorf("NIC model = %q, want MT28908 Family [ConnectX-6] (from pci.ids)", nic.Model)
|
||||
}
|
||||
if nic.Vendor != "Mellanox Technologies" {
|
||||
t.Errorf("NIC vendor = %q, want Mellanox Technologies (from pci.ids)", nic.Vendor)
|
||||
}
|
||||
|
||||
// InfiniBand FQDD must NOT appear in PCIe devices.
|
||||
for _, pcie := range result.Hardware.PCIeDevices {
|
||||
if pcie.Slot == "InfiniBand.Slot.1-1" {
|
||||
t.Errorf("InfiniBand.Slot.1-1 must not appear in PCIeDevices")
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware entries from SoftwareIdentity and parseControllerView must carry the FQDD
|
||||
// as their Description so the exporter's isDeviceBoundFirmwareFQDD filter can remove them.
|
||||
fqddByName := make(map[string]string)
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
fqddByName[fw.DeviceName] = fw.Description
|
||||
}
|
||||
if desc := fqddByName["Mellanox Network Adapter"]; desc != "InfiniBand.Slot.1-1" {
|
||||
t.Errorf("Mellanox firmware Description = %q, want InfiniBand.Slot.1-1 for FQDD filter", desc)
|
||||
}
|
||||
if desc := fqddByName["PERC H755 Front"]; desc != "RAID.SL.3-1" {
|
||||
t.Errorf("PERC H755 Front firmware Description = %q, want RAID.SL.3-1 for FQDD filter", desc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDellCPUAffinity verifies that CPUAffinity is parsed into NUMANode for
|
||||
// NIC, PCIe, and controller views. "Not Applicable" must result in NUMANode=0.
|
||||
func TestParseDellCPUAffinity(t *testing.T) {
|
||||
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
|
||||
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Model"><VALUE>PowerEdge R750</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ServiceTag"><VALUE>TESTST1</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_NICView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>NIC.Slot.2-1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ProductName"><VALUE>Some NIC</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="CPUAffinity"><VALUE>1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>RAID.Slot.1-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="ProductName"><VALUE>PERC H755</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="CPUAffinity"><VALUE>Not Applicable</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
|
||||
<PROPERTY NAME="FQDD"><VALUE>Slot.7-1</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="Description"><VALUE>Some PCIe Card</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
|
||||
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
|
||||
</INSTANCE></VALUE.NAMEDINSTANCE>
|
||||
</SIMPLEREQ></MESSAGE></CIM>`
|
||||
|
||||
inner := makeZipArchive(t, map[string][]byte{
|
||||
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R750","ServiceTag":"TESTST1"}`),
|
||||
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
|
||||
})
|
||||
|
||||
p := &Parser{}
|
||||
result, err := p.Parse([]parser.ExtractedFile{
|
||||
{Path: "signature", Content: []byte("ok")},
|
||||
{Path: "TSR_TESTST1.pl.zip", Content: inner},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
|
||||
// NIC CPUAffinity=1 → NUMANode=1
|
||||
nicBySlot := make(map[string]int)
|
||||
for _, nic := range result.Hardware.NetworkAdapters {
|
||||
nicBySlot[nic.Slot] = nic.NUMANode
|
||||
}
|
||||
if nicBySlot["NIC.Slot.2-1-1"] != 1 {
|
||||
t.Errorf("NIC.Slot.2-1-1 NUMANode = %d, want 1", nicBySlot["NIC.Slot.2-1-1"])
|
||||
}
|
||||
if nicBySlot["InfiniBand.Slot.1-1"] != 2 {
|
||||
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %d, want 2", nicBySlot["InfiniBand.Slot.1-1"])
|
||||
}
|
||||
|
||||
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → NUMANode=0
|
||||
pcieBySlot := make(map[string]int)
|
||||
for _, pcie := range result.Hardware.PCIeDevices {
|
||||
pcieBySlot[pcie.Slot] = pcie.NUMANode
|
||||
}
|
||||
if pcieBySlot["Slot.7-1"] != 2 {
|
||||
t.Errorf("Slot.7-1 NUMANode = %d, want 2", pcieBySlot["Slot.7-1"])
|
||||
}
|
||||
if pcieBySlot["RAID.Slot.1-1"] != 0 {
|
||||
t.Errorf("RAID.Slot.1-1 NUMANode = %d, want 0 (Not Applicable)", pcieBySlot["RAID.Slot.1-1"])
|
||||
}
|
||||
}
|
||||
|
||||
func makeZipArchive(t *testing.T, files map[string][]byte) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
|
||||
17
internal/parser/vendors/h3c/parser.go
vendored
17
internal/parser/vendors/h3c/parser.go
vendored
@@ -3024,6 +3024,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
|
||||
}
|
||||
setStorageString(&dst.Location, src.Location)
|
||||
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
|
||||
dst.Details = mergeH3CDetails(dst.Details, src.Details)
|
||||
}
|
||||
|
||||
func setStorageString(dst *string, value string) {
|
||||
@@ -3275,6 +3276,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
|
||||
setStorageString(&dst.PartNumber, src.PartNumber)
|
||||
setStorageString(&dst.Firmware, src.Firmware)
|
||||
setStorageString(&dst.Status, src.Status)
|
||||
dst.Details = mergeH3CDetails(dst.Details, src.Details)
|
||||
}
|
||||
|
||||
func mergeH3CDetails(primary, secondary map[string]any) map[string]any {
|
||||
if len(secondary) == 0 {
|
||||
return primary
|
||||
}
|
||||
if primary == nil {
|
||||
primary = make(map[string]any, len(secondary))
|
||||
}
|
||||
for key, value := range secondary {
|
||||
if _, ok := primary[key]; !ok {
|
||||
primary[key] = value
|
||||
}
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
||||
|
||||
290
internal/parser/vendors/inspur/component.go
vendored
290
internal/parser/vendors/inspur/component.go
vendored
@@ -100,10 +100,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
// Replace memory data with detailed info from component.log
|
||||
hw.Memory = nil
|
||||
var merged []models.MemoryDIMM
|
||||
seen := make(map[string]int)
|
||||
for _, existing := range hw.Memory {
|
||||
key := inspurMemoryKey(existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = len(merged)
|
||||
merged = append(merged, existing)
|
||||
}
|
||||
for _, mem := range memInfo.MemModules {
|
||||
hw.Memory = append(hw.Memory, models.MemoryDIMM{
|
||||
item := models.MemoryDIMM{
|
||||
Slot: mem.MemModSlot,
|
||||
Location: mem.MemModSlot,
|
||||
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
|
||||
@@ -117,8 +125,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
|
||||
PartNumber: strings.TrimSpace(mem.MemModPartNum),
|
||||
Status: mem.Status,
|
||||
Ranks: mem.MemModRanks,
|
||||
})
|
||||
}
|
||||
key := inspurMemoryKey(item)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergeInspurMemoryDIMM(&merged[idx], item)
|
||||
continue
|
||||
}
|
||||
if key != "" {
|
||||
seen[key] = len(merged)
|
||||
}
|
||||
merged = append(merged, item)
|
||||
}
|
||||
hw.Memory = merged
|
||||
}
|
||||
|
||||
// PSURESTInfo represents the RESTful PSU info structure
|
||||
@@ -159,10 +177,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear existing PSU data and populate with RESTful data
|
||||
hw.PowerSupply = nil
|
||||
var merged []models.PSU
|
||||
seen := make(map[string]int)
|
||||
for _, existing := range hw.PowerSupply {
|
||||
key := inspurPSUKey(existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = len(merged)
|
||||
merged = append(merged, existing)
|
||||
}
|
||||
for _, psu := range psuInfo.PowerSupplies {
|
||||
hw.PowerSupply = append(hw.PowerSupply, models.PSU{
|
||||
item := models.PSU{
|
||||
Slot: fmt.Sprintf("PSU%d", psu.ID),
|
||||
Present: psu.Present == 1,
|
||||
Model: strings.TrimSpace(psu.Model),
|
||||
@@ -178,8 +204,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
|
||||
InputVoltage: psu.PSInVolt,
|
||||
OutputVoltage: psu.PSOutVolt,
|
||||
TemperatureC: psu.PSUMaxTemp,
|
||||
})
|
||||
}
|
||||
key := inspurPSUKey(item)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergeInspurPSU(&merged[idx], item)
|
||||
continue
|
||||
}
|
||||
if key != "" {
|
||||
seen[key] = len(merged)
|
||||
}
|
||||
merged = append(merged, item)
|
||||
}
|
||||
hw.PowerSupply = merged
|
||||
}
|
||||
|
||||
// HDDRESTInfo represents the RESTful HDD info structure
|
||||
@@ -357,7 +393,16 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
hw.NetworkAdapters = nil
|
||||
var merged []models.NetworkAdapter
|
||||
seen := make(map[string]int)
|
||||
for _, existing := range hw.NetworkAdapters {
|
||||
key := inspurNICKey(existing)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = len(merged)
|
||||
merged = append(merged, existing)
|
||||
}
|
||||
for _, adapter := range netInfo.SysAdapters {
|
||||
var macs []string
|
||||
for _, port := range adapter.Ports {
|
||||
@@ -377,7 +422,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
||||
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
|
||||
}
|
||||
|
||||
hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{
|
||||
item := models.NetworkAdapter{
|
||||
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
|
||||
Location: adapter.Location,
|
||||
Present: adapter.Present == 1,
|
||||
@@ -392,8 +437,231 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
|
||||
PortType: adapter.PortType,
|
||||
MACAddresses: macs,
|
||||
Status: adapter.Status,
|
||||
})
|
||||
}
|
||||
key := inspurNICKey(item)
|
||||
if idx, ok := seen[key]; ok {
|
||||
mergeInspurNIC(&merged[idx], item)
|
||||
continue
|
||||
}
|
||||
if slotIdx := inspurFindNICBySlot(merged, item.Slot); slotIdx >= 0 {
|
||||
mergeInspurNIC(&merged[slotIdx], item)
|
||||
if key != "" {
|
||||
seen[key] = slotIdx
|
||||
}
|
||||
continue
|
||||
}
|
||||
if key != "" {
|
||||
seen[key] = len(merged)
|
||||
}
|
||||
merged = append(merged, item)
|
||||
}
|
||||
hw.NetworkAdapters = merged
|
||||
}
|
||||
|
||||
func inspurMemoryKey(item models.MemoryDIMM) string {
|
||||
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Location)))
|
||||
}
|
||||
|
||||
func mergeInspurMemoryDIMM(dst *models.MemoryDIMM, src models.MemoryDIMM) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(dst.Slot) == "" {
|
||||
dst.Slot = src.Slot
|
||||
}
|
||||
if strings.TrimSpace(dst.Location) == "" {
|
||||
dst.Location = src.Location
|
||||
}
|
||||
dst.Present = dst.Present || src.Present
|
||||
if dst.SizeMB == 0 {
|
||||
dst.SizeMB = src.SizeMB
|
||||
}
|
||||
if strings.TrimSpace(dst.Type) == "" {
|
||||
dst.Type = src.Type
|
||||
}
|
||||
if strings.TrimSpace(dst.Technology) == "" {
|
||||
dst.Technology = src.Technology
|
||||
}
|
||||
if dst.MaxSpeedMHz == 0 {
|
||||
dst.MaxSpeedMHz = src.MaxSpeedMHz
|
||||
}
|
||||
if dst.CurrentSpeedMHz == 0 {
|
||||
dst.CurrentSpeedMHz = src.CurrentSpeedMHz
|
||||
}
|
||||
if strings.TrimSpace(dst.Manufacturer) == "" {
|
||||
dst.Manufacturer = src.Manufacturer
|
||||
}
|
||||
if strings.TrimSpace(dst.SerialNumber) == "" {
|
||||
dst.SerialNumber = src.SerialNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.PartNumber) == "" {
|
||||
dst.PartNumber = src.PartNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.Status) == "" {
|
||||
dst.Status = src.Status
|
||||
}
|
||||
if dst.Ranks == 0 {
|
||||
dst.Ranks = src.Ranks
|
||||
}
|
||||
}
|
||||
|
||||
func inspurPSUKey(item models.PSU) string {
|
||||
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Model)))
|
||||
}
|
||||
|
||||
func mergeInspurPSU(dst *models.PSU, src models.PSU) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(dst.Slot) == "" {
|
||||
dst.Slot = src.Slot
|
||||
}
|
||||
dst.Present = dst.Present || src.Present
|
||||
if strings.TrimSpace(dst.Model) == "" {
|
||||
dst.Model = src.Model
|
||||
}
|
||||
if strings.TrimSpace(dst.Vendor) == "" {
|
||||
dst.Vendor = src.Vendor
|
||||
}
|
||||
if dst.WattageW == 0 {
|
||||
dst.WattageW = src.WattageW
|
||||
}
|
||||
if strings.TrimSpace(dst.SerialNumber) == "" {
|
||||
dst.SerialNumber = src.SerialNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.PartNumber) == "" {
|
||||
dst.PartNumber = src.PartNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.Firmware) == "" {
|
||||
dst.Firmware = src.Firmware
|
||||
}
|
||||
if strings.TrimSpace(dst.Status) == "" {
|
||||
dst.Status = src.Status
|
||||
}
|
||||
if strings.TrimSpace(dst.InputType) == "" {
|
||||
dst.InputType = src.InputType
|
||||
}
|
||||
if dst.InputPowerW == 0 {
|
||||
dst.InputPowerW = src.InputPowerW
|
||||
}
|
||||
if dst.OutputPowerW == 0 {
|
||||
dst.OutputPowerW = src.OutputPowerW
|
||||
}
|
||||
if dst.InputVoltage == 0 {
|
||||
dst.InputVoltage = src.InputVoltage
|
||||
}
|
||||
if dst.OutputVoltage == 0 {
|
||||
dst.OutputVoltage = src.OutputVoltage
|
||||
}
|
||||
if dst.TemperatureC == 0 {
|
||||
dst.TemperatureC = src.TemperatureC
|
||||
}
|
||||
}
|
||||
|
||||
func inspurNICKey(item models.NetworkAdapter) string {
|
||||
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, strings.Join(item.MACAddresses, ","), item.Slot, item.Location)))
|
||||
}
|
||||
|
||||
func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
|
||||
if dst == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(dst.Slot) == "" {
|
||||
dst.Slot = src.Slot
|
||||
}
|
||||
if strings.TrimSpace(dst.Location) == "" {
|
||||
dst.Location = src.Location
|
||||
}
|
||||
dst.Present = dst.Present || src.Present
|
||||
if strings.TrimSpace(dst.BDF) == "" {
|
||||
dst.BDF = src.BDF
|
||||
}
|
||||
if strings.TrimSpace(dst.Model) == "" {
|
||||
dst.Model = src.Model
|
||||
}
|
||||
if strings.TrimSpace(dst.Description) == "" {
|
||||
dst.Description = src.Description
|
||||
}
|
||||
if strings.TrimSpace(dst.Vendor) == "" {
|
||||
dst.Vendor = src.Vendor
|
||||
}
|
||||
if dst.VendorID == 0 {
|
||||
dst.VendorID = src.VendorID
|
||||
}
|
||||
if dst.DeviceID == 0 {
|
||||
dst.DeviceID = src.DeviceID
|
||||
}
|
||||
if strings.TrimSpace(dst.SerialNumber) == "" {
|
||||
dst.SerialNumber = src.SerialNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.PartNumber) == "" {
|
||||
dst.PartNumber = src.PartNumber
|
||||
}
|
||||
if strings.TrimSpace(dst.Firmware) == "" {
|
||||
dst.Firmware = src.Firmware
|
||||
}
|
||||
if dst.PortCount == 0 {
|
||||
dst.PortCount = src.PortCount
|
||||
}
|
||||
if strings.TrimSpace(dst.PortType) == "" {
|
||||
dst.PortType = src.PortType
|
||||
}
|
||||
if dst.LinkWidth == 0 {
|
||||
dst.LinkWidth = src.LinkWidth
|
||||
}
|
||||
if strings.TrimSpace(dst.LinkSpeed) == "" {
|
||||
dst.LinkSpeed = src.LinkSpeed
|
||||
}
|
||||
if dst.MaxLinkWidth == 0 {
|
||||
dst.MaxLinkWidth = src.MaxLinkWidth
|
||||
}
|
||||
if strings.TrimSpace(dst.MaxLinkSpeed) == "" {
|
||||
dst.MaxLinkSpeed = src.MaxLinkSpeed
|
||||
}
|
||||
if dst.NUMANode == 0 {
|
||||
dst.NUMANode = src.NUMANode
|
||||
}
|
||||
if strings.TrimSpace(dst.Status) == "" {
|
||||
dst.Status = src.Status
|
||||
}
|
||||
for _, mac := range src.MACAddresses {
|
||||
mac = strings.TrimSpace(mac)
|
||||
if mac == "" {
|
||||
continue
|
||||
}
|
||||
found := false
|
||||
for _, existing := range dst.MACAddresses {
|
||||
if strings.EqualFold(strings.TrimSpace(existing), mac) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
dst.MACAddresses = append(dst.MACAddresses, mac)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func inspurFindNICBySlot(items []models.NetworkAdapter, slot string) int {
|
||||
slot = strings.ToLower(strings.TrimSpace(slot))
|
||||
if slot == "" {
|
||||
return -1
|
||||
}
|
||||
for i := range items {
|
||||
if strings.ToLower(strings.TrimSpace(items[i].Slot)) == slot {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func inspurFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseFanSensors(text string) []models.SensorReading {
|
||||
|
||||
58
internal/parser/vendors/inspur/component_test.go
vendored
58
internal/parser/vendors/inspur/component_test.go
vendored
@@ -51,6 +51,64 @@ RESTful fan`
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkAdapterInfo_MergesIntoExistingInventory(t *testing.T) {
|
||||
text := `RESTful Network Adapter info:
|
||||
{
|
||||
"sys_adapters": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "NIC1",
|
||||
"Location": "#CPU0_PCIE4",
|
||||
"present": 1,
|
||||
"slot": 4,
|
||||
"vendor_id": 32902,
|
||||
"device_id": 5409,
|
||||
"vendor": "Mellanox",
|
||||
"model": "ConnectX-6",
|
||||
"fw_ver": "22.1.0",
|
||||
"status": "OK",
|
||||
"sn": "",
|
||||
"pn": "",
|
||||
"port_num": 2,
|
||||
"port_type": "QSFP",
|
||||
"ports": [
|
||||
{ "id": 1, "mac_addr": "00:11:22:33:44:55" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
RESTful fan`
|
||||
|
||||
hw := &models.HardwareConfig{
|
||||
NetworkAdapters: []models.NetworkAdapter{
|
||||
{
|
||||
Slot: "Slot 4",
|
||||
BDF: "0000:17:00.0",
|
||||
SerialNumber: "NIC-SN-1",
|
||||
Present: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
parseNetworkAdapterInfo(text, hw)
|
||||
|
||||
if len(hw.NetworkAdapters) != 1 {
|
||||
t.Fatalf("expected merged single adapter, got %d", len(hw.NetworkAdapters))
|
||||
}
|
||||
got := hw.NetworkAdapters[0]
|
||||
if got.BDF != "0000:17:00.0" {
|
||||
t.Fatalf("expected existing BDF to survive merge, got %q", got.BDF)
|
||||
}
|
||||
if got.Model != "ConnectX-6" {
|
||||
t.Fatalf("expected model from component log, got %q", got.Model)
|
||||
}
|
||||
if got.SerialNumber != "NIC-SN-1" {
|
||||
t.Fatalf("expected serial from existing inventory to survive merge, got %q", got.SerialNumber)
|
||||
}
|
||||
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
|
||||
t.Fatalf("expected MAC addresses from component log, got %#v", got.MACAddresses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) {
|
||||
text := `RESTful PSU info:
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user