43 Commits

Author SHA1 Message Date
Mikhail Chusavitin
4ce0251ce4 docs(bible-local): add backlog with sfp_modules implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:39:28 +03:00
Mikhail Chusavitin
994d46f3b3 docs: update hardware ingest contract to v2.11
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:28:38 +03:00
Mikhail Chusavitin
ee3e8a6e33 docs: release notes for v1.23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:21:35 +03:00
Mikhail Chusavitin
e2c81758b5 fix(parser): raise file size limit for .ahs to 1 GB
AHS files can exceed 100 MB; the previous 10 MB universal cap silently
truncated them and caused incomplete event parsing. Per-extension limits
are now used: .ahs gets 1 GB, all other single-file types keep 10 MB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:20:57 +03:00
Mikhail Chusavitin
6b52a1876f docs: release notes for v1.22
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:13:42 +03:00
Mikhail Chusavitin
3e3c48bc08 feat(hpe-ilo): parse AHS files, fix event logs export, add logs CSV export
- HPE iLO AHS parser: handle truncated last entry gracefully, recognize
  Alletra product line, expand event type/severity inference, trim iLO
  frame separators from event messages
- Fix event_logs always 0 in Reanimator export: normalizeEventLogSource
  now maps "HPE iLO" → "bmc"
- Fix chart JS not loading in LOGPile: rewriteChartStaticPaths now also
  rewrites src="/static/view.js" → /chart/static/view.js
- Add "Logs Export" button (CSV, semicolon-delimited, UTF-8 BOM) and
  remove PDF button
- Fix collector test broken by pciids rename of Intel VMD device
- Update submodules: chart v2.7, pciids, bible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 15:11:46 +03:00
Mikhail Chusavitin
cd864c3d6c docs: release notes for v1.21
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:56:17 +03:00
Mikhail Chusavitin
5128ac5303 fix(server): remove PrintMode from RenderOptions after chart submodule update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:53:55 +03:00
Mikhail Chusavitin
53cda82c79 chore: update submodules (bible, chart, pciids)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:52:51 +03:00
Mikhail Chusavitin
a18d8fe648 feat(inspur): enrich storage serials from SOLHostCapture.log smartd output
When the BMC HDD API returns an empty array (RAID controller attached via
PCIe, e.g. PM8204-2GB), disk serial numbers are now recovered from smartd
startup messages in SOLHostCapture.log.

Enrichment runs in three passes: model-match on existing slots, positional
fill of empty backplane placeholders, then new entries for any remainder.
Both log/ and runningdata/var/ copies are merged with serial deduplication.

Parser version bumped to 2.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 17:52:07 +03:00
6ab0f4eb20 chore: update bible submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:36:53 +03:00
57de3ba6eb chore: align codebase with bible engineering contracts
- identifier-normalization: use strings.EqualFold in h3c/parser.go
- import-export: CSV now uses UTF-8 BOM and semicolon delimiter
- go-code-style: translate all Russian source strings to English (ADL-007)
- go-background-tasks: add Type, Message, Result fields to Job struct
- go-api: wrap list endpoints in {items, total_count, page, per_page, total_pages}
- module-structure: rename helpers.go → context_sleep.go
- build-version-display: htmlError renders version footer on error pages
- go-logging: migrate all log.Printf calls to log/slog with structured attrs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:35:39 +03:00
47ff1c3796 fix(inspur): detect standard Inspur servers via SystemManufacturer
NF-series storage servers (e.g. NF5280M6) have no GPU/outboard-PCIe
topology, so the previous score gate (topologyScore==0 || boardScore==0
→ return 0) always produced score=0 despite SystemManufacturer="Inspur"
being available. These servers fell into mode=fallback, activating the
AMI profile and probing /Oem/Ami paths that don't exist on the BMC.

Add manufacturer-based detection: SystemManufacturer or
ChassisManufacturer containing "inspur" contributes 60 points —
enough to enter matched mode on its own. GPU servers with full
topology+board signals still score higher as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 03:58:55 +03:00
1c4a3b0c09 feat(ui): add PDF export via browser print
Adds a PDF button to the report header. Clicking it opens
/chart/current?print=true in a new tab, which auto-triggers
window.print() so the user can save to PDF via the browser dialog.

- chart submodule bumped: PrintMode support (no filter JS, auto-print,
  @media print CSS)
- handlers.go: passes PrintMode=true when ?print=true query param is set
- index.html: PDF button alongside Raw Data / Reanimator
- app.js: printReport() helper; button shown/hidden with other exports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:24:01 +03:00
10c381c8ec chore: update bible and pci.ids submodules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 03:40:15 +03:00
440959483e fix(inspur): correctly handle PCIe Assert/Deassert GPU fault events
Three related fixes for IDL event processing:

1. idl.go: include EventType in dedup key so Deassert events are no
   longer silently dropped as duplicates of their Assert counterparts.

2. gpu_status.go: treat Deassert events as clearing all GPU faults —
   previously the code re-applied the same faulty GPU set from the
   description, leaving GPUs stuck in Critical even after alarm cleared.

3. reanimator_models/converter: add bmc_event_summary section to the
   Reanimator export — a deduplicated Critical/Warning event table with
   Active/Resolved status derived from Assert/Deassert pairs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 03:38:04 +03:00
Mikhail Chusavitin
f3836a34cc chore: update chart submodule to v2.0 and refresh pci.ids (2026-05-21)
chart: feat(viewer): replace severity dropdown with per-column header filters
pci.ids: 2026-02-17 → 2026-05-21

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:37:26 +03:00
Mikhail Chusavitin
ba9a52a61a fix(ui): parse-errors panel full width
Removed max-width/padding constraints — panel now stretches to grid
column width like the viewer-panel above it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:32:47 +03:00
Mikhail Chusavitin
27373aa104 feat: surface BMC collection errors in parse-errors panel and event log
When Inspur component.log sections return {"error":"...","code":N} instead
of hardware data, the parser now:
- stores them in AnalysisResult.CollectionErrors (new model field)
- mirrors each one into result.Events with Source="BMC/<section>"
  so the chart viewer event table shows the specific BMC module
- feeds them into /api/parse-errors as bmc_collection_error entries

UI adds a collapsible "Collection diagnostics" panel below the chart
iframe (outside /chart) that appears when /api/parse-errors returns
any items; resets on data clear.

Affected sections in this dump: HDD (1458), PCIe Devices (1458),
Network Adapters (1458), Disk Backplane.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:30:01 +03:00
Mikhail Chusavitin
4f7b5b826a fix(inspur): fix PSU section regex when PCIE section precedes Network
The PSU regex used "RESTful Network" as its end anchor, but in standard
Inspur component.log layout the PCIE Device section sits between PSU and
Network Adapter. The lazy [\s\S]*? captured across the PCIE error block,
producing invalid JSON and silently dropping all PSU data.

Changed anchor to RESTful (?:PCIE|Network) — matches whichever section
immediately follows PSU in a given archive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:21:13 +03:00
Mikhail Chusavitin
dfd64550cf fix(inspur): infer DIMM size from part number when BMC reports size=0
When BMC firmware fails to read capacity for a present DIMM, size_mb stays
0. If another DIMM with the same part number in the same batch has a known
size, use it to fill the gap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:18:15 +03:00
Mikhail Chusavitin
9505303d1d fix(inspur): show microcode version for every CPU, not just the first
Dedup by version caused CPU1 Microcode to be omitted when both CPUs run
the same version, leaving the firmware column blank for the second socket.
Each CPU gets its own firmware entry keyed by index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:15:28 +03:00
Mikhail Chusavitin
f2c04cf0e8 fix(inspur): parse CPUs from component.log and fix DIMM present detection
Two bugs in onekeylog archives that lack asset.json:

- CPU count was always 0: ParseComponentLog never parsed the "RESTful CPU
  info" section. Added parseCPUInfo as a fallback when hw.CPUs is empty
  (asset.json remains the primary source when present). Also worked around
  a Go JSON case-insensitive collision between "proc_id" (int) and
  "PROC_ID" (string CPUID) by adding an explicit PROC_ID field with an
  exact-case tag.

- Only 1 of 2 DIMMs shown: Present condition required mem_mod_size > 0,
  but some BMC firmware reports size=0 for a physically installed module
  while still providing serial and part number. Now treats a DIMM as
  present when status=1 and any of size/serial/partnum is non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:13:55 +03:00
Mikhail Chusavitin
ca457ac72b fix(exporter): propagate iommu_group through PCIe export pipeline
IOMMUGroup was added to models.PCIeDevice but never wired into the
converter — missing from Details in buildDevicesFromLegacy, no field
in ReanimatorPCIe, and convertPCIeFromDevices never read it.

Add IOMMUGroup *int to ReanimatorPCIe, propagate through Details,
add intPtrFromDetailMap helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 16:05:30 +03:00
Mikhail Chusavitin
78d0e26fd0 feat: sync with hardware ingest contract v2.10
- PCIeDevice: add model, firmware, present, iommu_group, telemetry fields
  (temperature_c, power_w, ecc_corrected_total, ecc_uncorrected_total,
  hw_slowdown) — were silently dropped on JSON parse, breaking bee audit display
- buildDevicesFromLegacy: use pcie.Model as fallback (PartNumber > Model >
  Description), copy MACAddresses/Present/Firmware, propagate telemetry into
  Details so convertPCIeFromDevices picks them up
- Storage: add logical_block_size_bytes, physical_block_size_bytes,
  metadata_bytes_per_block (contract v2.10, 2026-04-29) to models, exporter
  struct and converter pipeline
- ReanimatorHardware: add platform_config map[string]any (contract v2.9)
- Update internal/chart submodule to v2.0 (contract 2.10 viewer support:
  event_logs section, platform_config section, storage block size columns)
- Update bible submodule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 15:55:26 +03:00
Mikhail Chusavitin
88e4e8dd49 Trim noisy Lenovo Redfish collection paths 2026-04-30 15:55:26 +03:00
Mikhail Chusavitin
cf9cf5d0cf Improve Lenovo XCC inventory enrichment 2026-04-30 15:55:26 +03:00
aba7a54990 feat(parser): lenovo xcc vroc volume parsing - v1.2
Parse inventory_volume.log: Intel VROC (VMD) RAID volumes including
RAID level, capacity (GiB/TiB support added), status and member drives.
Add Drives []string to StorageVolume model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:00:27 +03:00
835df2676c docs: defer generic IPMI live collector
Closes #12
2026-04-22 20:51:58 +03:00
b86d51c921 chore: close completed Redfish profile framework issue
Fixes #14
2026-04-22 20:45:42 +03:00
Mikhail Chusavitin
a82fb227e5 submodule update 2026-04-16 15:33:48 +03:00
c9969fc3da feat(parser): lenovo xcc warnings and redfish logs - v1.1 2026-04-13 20:34:04 +03:00
89b6701f43 feat(parser): add Lenovo XCC mini-log parser 2026-04-13 20:20:37 +03:00
b04877549a feat(collector): add Lenovo XCC profile to skip noisy snapshot paths
Lenovo ThinkSystem SR650 V3 (and similar XCC-based servers) caused
collection runs of 23+ minutes because the BMC exposes two large high-
error-rate subtrees in the snapshot BFS:

  - Chassis/1/Sensors: 315 individual sensor members, 282/315 failing,
    ~3.7s per request → ~19 minutes wasted. These documents are never
    read by any LOGPile parser (thermal/power data comes from aggregate
    Chassis/*/Thermal and Chassis/*/Power endpoints).

  - Chassis/1/Oem/Lenovo: 75 requests (LEDs×47, Slots×26, etc.),
    68/75 failing → 8+ minutes wasted on non-inventory data.

Add a Lenovo profile (matched on SystemManufacturer/OEMNamespace "Lenovo")
that sets SnapshotExcludeContains to block individual sensor documents and
non-inventory Lenovo OEM subtrees from the snapshot BFS queue. Also sets
rate policy thresholds appropriate for XCC BMC latency (p95 often 3-5s).

Add SnapshotExcludeContains []string to AcquisitionTuning and check it
in the snapshot enqueue closure in redfish.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:29:04 +03:00
8ca173c99b fix(exporter): preserve all HGX GPUs with generic PCIe slot name
Supermicro HGX BMC reports all 8 B200 GPU PCIe devices with Name
"PCIe Device" — a generic label shared by every GPU, not a unique
hardware position. pcieDedupKey used slot as the primary key, so all
8 GPUs collapsed to one entry in the UI (the first, serial 1654925165720).

Add isGenericPCIeSlotName to detect non-positional slot labels and fall
through to serial/BDF for dedup instead, preserving each GPU separately.
Positional slots (#GPU0, SLOT-NIC1, etc.) continue to use slot-first dedup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:05:49 +03:00
f19a3454fa fix(redfish): gate hgx diagnostic plan-b by debug toggle 2026-04-13 14:45:41 +03:00
Mikhail Chusavitin
becdca1d7e fix(redfish): read PCIeInterface link width for GPU PCIe devices
parseGPUWithSupplementalDocs did not read PCIeInterface from the device
doc, only from function docs. xFusion GPU PCIeCard entries carry link
width/speed in PCIeInterface (LanesInUse/Maxlanes/PCIeType/MaxPCIeType)
so GPU link width was always empty for xFusion servers.

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

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

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

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

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

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

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

2
bible

Submodule bible updated: 52444350c1...1977730d93

View File

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

View File

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

View File

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

View File

@@ -55,9 +55,11 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene
| `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files | | `h3c_g6` | H3C SDS G6 bundles | Similar flow with G6-specific files |
| `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON | | `hpe_ilo_ahs` | HPE iLO Active Health System (`.ahs`) | Proprietary `ABJR` container with gzip-compressed `zbb` members; parser combines SMBIOS-style inventory strings and embedded Redfish storage JSON |
| `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment | | `inspur` | onekeylog archives | FRU/SDR plus optional Redis enrichment |
| `lenovo_xcc` | Lenovo XCC mini-log ZIP archives | JSON inventory + platform event logs |
| `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input | | `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections | | `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing | | `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing |
| `xfusion` | xFusion iBMC `tar.gz` dump / file export | AppDump + RTOSDump + LogDump merge for hardware and firmware |
| `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory | | `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory |
| `generic` | fallback | Low-confidence text fallback when nothing else matches | | `generic` | fallback | Low-confidence text fallback when nothing else matches |
@@ -148,6 +150,29 @@ entire internal `zbb` schema.
--- ---
### xFusion iBMC Dump / File Export (`xfusion`)
**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports.
**Archive format:** `tar.gz` dump exported from the iBMC UI, including `AppDump/`, `RTOSDump/`,
and `LogDump/` trees.
**Detection:** `AppDump/FruData/fruinfo.txt`, `AppDump/card_manage/card_info`,
`RTOSDump/versioninfo/app_revision.txt`, and `LogDump/netcard/netcard_info.txt`.
**Extracted data (current):**
- Board / FRU inventory from `fruinfo.txt`
- CPU inventory from `CpuMem/cpu_info`
- Memory DIMM inventory from `CpuMem/mem_info`
- GPU inventory from `card_info`
- OCP NIC inventory by merging `card_info` with `LogDump/netcard/netcard_info.txt`
- PSU inventory from `BMC/psu_info.txt`
- Physical storage from `StorageMgnt/PhysicalDrivesInfo/*/disk_info`
- System firmware entries from `RTOSDump/versioninfo/app_revision.txt`
- Maintenance events from `LogDump/maintenance_log`
---
### Generic text fallback (`generic`) ### Generic text fallback (`generic`)
**Status:** Ready (v1.0.0). **Status:** Ready (v1.0.0).
@@ -170,9 +195,11 @@ entire internal `zbb` schema.
| Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles | | Reanimator Easy Bee | `easy_bee` | Ready | `bee-support-*.tar.gz` support bundles |
| HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports | | HPE iLO AHS | `hpe_ilo_ahs` | Ready | iLO 6 `.ahs` exports |
| Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog | | Inspur / Kaytus | `inspur` | Ready | KR4268X2 onekeylog |
| Lenovo XCC mini-log | `lenovo_xcc` | Ready | ThinkSystem SR650 V3 XCC mini-log ZIP |
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers | | NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems | | NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
| Unraid | `unraid` | Ready | Unraid diagnostics archives | | Unraid | `unraid` | Ready | Unraid diagnostics archives |
| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles |
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs | | XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives | | H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives | | H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |

View File

@@ -7,6 +7,7 @@
| `GET /api/export/csv` | CSV | Serial-number export | | `GET /api/export/csv` | CSV | Serial-number export |
| `GET /api/export/json` | raw-export ZIP bundle | Reopen and re-analyze later | | `GET /api/export/json` | raw-export ZIP bundle | Reopen and re-analyze later |
| `GET /api/export/reanimator` | JSON | Reanimator hardware payload | | `GET /api/export/reanimator` | JSON | Reanimator hardware payload |
| `GET /chart/current?print=true` | HTML (auto-print) | Print/PDF version of the report — opens in new tab, calls `window.print()` |
| `POST /api/convert` | async ZIP artifact | Batch archive-to-Reanimator conversion | | `POST /api/convert` | async ZIP artifact | Batch archive-to-Reanimator conversion |
## Raw export ## Raw export

View File

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

View File

@@ -1045,3 +1045,156 @@ logical volumes.
- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as - HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as
`LOM/NIC` or `SAS/SATA Storage Controller`. `LOM/NIC` or `SAS/SATA Storage Controller`.
- `part_number` remains available separately for model identity, without polluting the class field. - `part_number` remains available separately for model identity, without polluting the class field.
---
## ADL-041 — Redfish replay drops topology-only PCIe noise classes from canonical inventory
**Date:** 2026-04-01
**Context:** Some Redfish BMCs, especially MSI/AMI GPU systems, expose a very wide PCIe topology
tree under `Chassis/*/PCIeDevices/*`. Besides real endpoint devices, the replay sees bridge stages,
CPU-side helper functions, IMC/mesh signal-processing nodes, USB/SPI side controllers, and GPU
display-function duplicates reported as generic `Display Device`. Keeping all of them in
`hardware.pcie_devices` pollutes downstream exports such as Reanimator and hides the actual
endpoint inventory signal.
**Decision:**
- Filter topology-only PCIe records during Redfish replay, not in the UI layer.
- Drop PCIe entries with replay-resolved classes:
- `Bridge`
- `Processor`
- `SignalProcessingController`
- `SerialBusController`
- Drop `DisplayController` entries when the source Redfish PCIe document is the generic MSI-style
`Description: "Display Device"` duplicate.
- Drop PCIe network endpoints when their PCIe functions already link to `NetworkDeviceFunctions`,
because those devices are represented canonically in `hardware.network_adapters`.
- When `Systems/*/NetworkInterfaces/*` links back to a chassis `NetworkAdapter`, match against the
fully enriched chassis NIC identity to avoid creating a second ghost NIC row with the raw
`NetworkAdapter_*` slot/name.
- Treat generic Redfish object names such as `NetworkAdapter_*` and `PCIeDevice_*` as placeholder
models and replace them from PCI IDs when a concrete vendor/device match exists.
- Drop MSI-style storage service PCIe endpoints whose resolved device names are only
`Volume Management Device NVMe RAID Controller` or `PCIe Switch management endpoint`; storage
inventory already comes from the Redfish storage tree.
- Normalize Ethernet-class NICs into the single exported class `NetworkController`; do not split
`EthernetController` into a separate top-level inventory section.
- Keep endpoint classes such as `NetworkController`, `MassStorageController`, and dedicated GPU
inventory coming from `hardware.gpus`.
**Consequences:**
- `hardware.pcie_devices` becomes closer to real endpoint inventory instead of raw PCIe topology.
- Reanimator exports stop showing MSI bridge/processor/display duplicate noise.
- Reanimator exports no longer duplicate the same MSI NIC as both `PCIeDevice_*` and
`NetworkAdapter_*`.
- Replay no longer creates extra NIC rows from `Systems/NetworkInterfaces` when the same adapter
was already normalized from `Chassis/NetworkAdapters`.
- MSI VMD / PCIe switch storage service endpoints no longer pollute PCIe inventory.
- UI/Reanimator group all Ethernet NICs under the same `NETWORKCONTROLLER` section.
- Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names.
- The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level
troubleshooting if topology details are ever needed.
---
## ADL-042 — xFusion file-export archives merge AppDump inventory with RTOS/Log snapshots
**Date:** 2026-04-04
**Context:** xFusion iBMC `tar.gz` exports expose the base inventory in `AppDump/`, but the most
useful NIC and firmware details live elsewhere: NIC firmware/MAC snapshots in
`LogDump/netcard/netcard_info.txt` and system firmware versions in
`RTOSDump/versioninfo/app_revision.txt`. Parsing only `AppDump/` left xFusion uploads detectable but
incomplete for UI and Reanimator consumers.
**Decision:**
- Treat xFusion file-export `tar.gz` bundles as a first-class archive parser input.
- Merge OCP NIC identity from `AppDump/card_manage/card_info` with the latest per-slot snapshot
from `LogDump/netcard/netcard_info.txt` to produce `hardware.network_adapters`.
- Import system-level firmware from `RTOSDump/versioninfo/app_revision.txt` into
`hardware.firmware`.
- Allow FRU fallback from `RTOSDump/versioninfo/fruinfo.txt` when `AppDump/FruData/fruinfo.txt`
is absent.
**Consequences:**
- xFusion uploads now preserve NIC BDF, MAC, firmware, and serial identity in normalized output.
- System firmware such as BIOS and iBMC versions survives xFusion file exports.
- xFusion archives participate more reliably in canonical device/export flows without special UI
cases.
---
## ADL-043 — Extended HGX diagnostic plan-B is opt-in from the live collect form
**Date:** 2026-04-13
**Context:** Some Supermicro HGX Redfish targets expose slow or hanging component-chassis inventory
collections during critical plan-B, especially under `Chassis/HGX_*` for `Assembly`,
`Accelerators`, `Drives`, `NetworkAdapters`, and `PCIeDevices`. Default collection should not
block operators on deep diagnostic retries that are useful mainly for troubleshooting.
**Decision:** Keep the normal snapshot/replay path unchanged, but gate those heavy HGX
component-chassis critical plan-B retries behind the existing live-collect `debug_payloads` flag,
presented in the UI as "Сбор расширенных данных для диагностики".
**Consequences:**
- Default live collection skips those heavy diagnostic plan-B retries and reaches replay faster.
- Operators can explicitly opt into the slower diagnostic path when they need deeper collection.
- The same user-facing toggle continues to enable extra debug payload capture for troubleshooting.
---
## ADL-044 — LOGPile project release tags use `vN.M`
**Date:** 2026-04-13
**Context:** The repository accumulated release tags in `vN.M.P` form, while the shared module
versioning contract in `bible/rules/patterns/module-versioning/contract.md` standardizes version
shape as `N.M`. Release tooling reads the git tag verbatim into build metadata and release
artifacts, so inconsistent tag shape leaks directly into packaged versions.
**Decision:** Use `vN.M` for LOGPile project release tags going forward. Do not create new
`vN.M.P` tags for repository releases. Build metadata, release directory names, and release notes
continue to inherit the exact git tag string from `git describe --tags`.
**Consequences:**
- Future project releases have a two-component version string such as `v1.12`.
- Release artifacts and `--version` output stay aligned with the tag shape without extra mapping.
- Existing historical `vN.M.P` tags remain as-is unless explicitly rewritten.
---
## ADL-045 — Generic live IPMI collector is deferred; Redfish remains the only production live path
**Date:** 2026-04-22
**Context:** Sprint issue `#12` proposed a generic IPMI collector for SEL/FRU/sensors. By this
point LOGPile already has a production Redfish pipeline with replayable raw snapshots, profile-
driven acquisition, and normalized event/sensor/inventory extraction. Redfish also already covers
the current product goals better than IPMI for live collection: richer inventory, structured
resource relationships, and vendor log access via `LogServices`, including SEL-style logs on many
implementations.
**Decision:** Do not build a generic live IPMI collector now. Keep `ipmi_mock.go` only as a
protocol placeholder in the registry and UI/API contract. Treat Redfish as the only production
live collection path. Revisit IPMI only if real field evidence shows that a specific target class
cannot provide required data over Redfish. If revisited, prefer a narrow fallback scope such as
`IPMI SEL fallback`, `IPMI FRU fallback`, or `IPMI sensor fallback` rather than a second full
collector architecture.
**Consequences:**
- Issue `#12` is closed as deferred/not planned, not as implemented.
- Live collection architecture stays centered on replayable `raw_payloads.redfish_tree`.
- The codebase avoids introducing a second generic live-ingest/replay contract for IPMI data.
- Future IPMI work must be justified by concrete Redfish gaps on real hardware, not by protocol
symmetry alone.
---
## ADL-046 — The web shell delegates report rendering to `internal/chart`
**Date:** 2026-04-22
**Context:** The frontend had two competing report paths: the embedded `internal/chart` viewer and
an older client-side renderer in `web/static/js/app.js` for config, firmware, sensors, serials,
events, and parse errors. That duplication left dead controls in the shell and made the report
source of truth ambiguous.
**Decision:** The `web/` frontend shell is responsible only for data intake, job control, and
top-level actions. The report itself must be rendered exclusively through `internal/chart`.
Do not keep parallel report sections, filters, or table renderers in shell JavaScript.
**Consequences:**
- The browser UI has a single report rendering path: `/chart/current` inside the embedded viewer.
- Report-level filtering or extra report sections must be implemented in `internal/chart`, not in
`web/static/js/app.js`.
- Removing legacy DOM renderers from the shell is a correctness fix, not a behavior regression.

21
bible-local/BACKLOG.md Normal file
View File

@@ -0,0 +1,21 @@
# Backlog
## [sfp_modules] Поддержка per-port SFP/QSFP модулей в экспорте Reanimator
**Приоритет:** низкий (до выхода Reanimator v3.0, пока deprecated sfp_* скаляры ещё принимаются)
**Контекст:**
Reanimator Hardware Ingest Contract v2.11 вводит массив `pcie_devices[].sfp_modules[]` для передачи данных SFP/QSFP-модулей по портам. Старые скалярные поля (`sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma`) помечены deprecated и будут удалены в v3.0. Для многопортовых NIC (ConnectX-6 Dx, Intel X710 и подобных) текущая реализация теряет данные — коллектор берёт первое найденное значение и не знает о портах.
**Текущее состояние:**
- Коллектор (`internal/collector/redfish.go`, `redfishPCIeDetailsWithSupplementalDocs`) собирает SFP как 5 скалярных `float64` на устройство через `redfishFirstNumericAcrossDocs`
- Внутренняя модель (`internal/models/models.go`, struct `PCIeDevice`) не имеет SFP-полей — всё хранится в `Details map[string]any`
- Конвертер (`internal/exporter/reanimator_converter.go`, строки 864868) читает скаляры из `Details` и кладёт в deprecated поля `ReanimatorPCIe`
**Что нужно сделать:**
1. **Исследование** — проверить, отдают ли реальные Redfish-источники SFP-данные per-port и в каком виде (прежде чем менять модель)
2. **Коллектор** (`redfish.go`) — если Redfish отдаёт per-port данные, собирать их в массив с индексом порта
3. **Внутренняя модель** (`models.go`) — добавить `SFPModules []SFPModule` в `PCIeDevice`
4. **Экспорт** (`reanimator_models.go`, `reanimator_converter.go`) — добавить `ReanimatorSFPModule`, смапить `SFPModules` в `sfp_modules[]`; убрать deprecated скаляры
**Триггер для реализации:** анонс Reanimator v3.0 с удалением deprecated sfp_* полей.

View File

@@ -1,7 +1,7 @@
--- ---
title: Hardware Ingest JSON Contract title: Hardware Ingest JSON Contract
version: "2.7" version: "2.11"
updated: "2026-03-15" updated: "2026-06-19"
maintainer: Reanimator Core maintainer: Reanimator Core
audience: external-integrators, ai-agents audience: external-integrators, ai-agents
language: ru language: ru
@@ -9,7 +9,7 @@ language: ru
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения # Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
Версия: **2.7** · Дата: **2026-03-15** Версия: **2.11** · Дата: **2026-06-19**
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения). Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов. Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
@@ -22,6 +22,10 @@ language: ru
| Версия | Дата | Изменения | | Версия | Дата | Изменения |
|--------|------|-----------| |--------|------|-----------|
| 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]` с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` помечены как deprecated (принимаются, но `sfp_modules[]` имеет приоритет) |
| 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя |
| 2.9 | 2026-03-19 | Добавлена необязательная секция `hardware.platform_config` — произвольный объект с настройками платформы (BIOS/Redfish); хранится как latest-snapshot per machine |
| 2.8 | 2026-03-15 | Поле `location` удалено из всех `sensors.*`; сенсоры передаются только по `name` и измеренным значениям |
| 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал | | 2.7 | 2026-03-15 | Явно запрещён синтез данных в `event_logs`; интеграторы не должны придумывать серийные номера компонентов, если источник их не отдал |
| 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline | | 2.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) | | 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
@@ -132,7 +136,8 @@ GET /ingest/hardware/jobs/{job_id}
"pcie_devices": [ ... ], "pcie_devices": [ ... ],
"power_supplies": [ ... ], "power_supplies": [ ... ],
"sensors": { ... }, "sensors": { ... },
"event_logs": [ ... ] "event_logs": [ ... ],
"platform_config": { ... }
} }
} }
``` ```
@@ -343,6 +348,9 @@ GET /ingest/hardware/jobs/{job_id}
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` | | `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` | | `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
| `size_gb` | int | нет | Размер в ГБ | | `size_gb` | int | нет | Размер в ГБ |
| `logical_block_size_bytes` | int64 | нет | Логический размер пользовательского блока данных, например `512` или `4096` |
| `physical_block_size_bytes` | int64 | нет | Физический размер блока, если известен, например `4096` |
| `metadata_bytes_per_block` | int64 | нет | Metadata / protection bytes на логический блок, например `0` или `8` |
| `temperature_c` | float | нет | Температура накопителя, °C (telemetry) | | `temperature_c` | float | нет | Температура накопителя, °C (telemetry) |
| `power_on_hours` | int64 | нет | Время работы, часы | | `power_on_hours` | int64 | нет | Время работы, часы |
| `power_cycles` | int64 | нет | Количество циклов питания | | `power_cycles` | int64 | нет | Количество циклов питания |
@@ -363,6 +371,11 @@ GET /ingest/hardware/jobs/{job_id}
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`. Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
- `logical_block_size_bytes = 512`
- `metadata_bytes_per_block = 8`
- `physical_block_size_bytes = 512` или `4096`, если известен физический размер блока
```json ```json
"storage": [ "storage": [
{ {
@@ -370,6 +383,9 @@ GET /ingest/hardware/jobs/{job_id}
"type": "NVMe", "type": "NVMe",
"model": "INTEL SSDPF2KX076T1", "model": "INTEL SSDPF2KX076T1",
"size_gb": 7680, "size_gb": 7680,
"logical_block_size_bytes": 512,
"physical_block_size_bytes": 4096,
"metadata_bytes_per_block": 8,
"temperature_c": 38.5, "temperature_c": 38.5,
"power_on_hours": 12450, "power_on_hours": 12450,
"unsafe_shutdowns": 3, "unsafe_shutdowns": 3,
@@ -407,11 +423,12 @@ GET /ingest/hardware/jobs/{job_id}
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C | | `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В | | `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap | | `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C | | `sfp_temperature_c` | float | нет | Температура SFP/optic, °C *(deprecated since 2.11)* |
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm | | `sfp_tx_power_dbm` | float | нет | TX optical power, dBm *(deprecated since 2.11)* |
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm | | `sfp_rx_power_dbm` | float | нет | RX optical power, dBm *(deprecated since 2.11)* |
| `sfp_voltage_v` | float | нет | Напряжение SFP, В | | `sfp_voltage_v` | float | нет | Напряжение SFP, В *(deprecated since 2.11)* |
| `sfp_bias_ma` | float | нет | Bias current SFP, мА | | `sfp_bias_ma` | float | нет | Bias current SFP, мА *(deprecated since 2.11)* |
| `sfp_modules` | array | нет | Установленные SFP/QSFP-модули по портам (см. sfp_modules[]) |
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` | | `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
| `device_class` | string | нет | Класс устройства (см. список ниже) | | `device_class` | string | нет | Класс устройства (см. список ниже) |
| `manufacturer` | string | нет | Производитель | | `manufacturer` | string | нет | Производитель |
@@ -429,10 +446,43 @@ GET /ingest/hardware/jobs/{job_id}
`numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases. `numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases.
Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента. Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента.
**Deprecated поля sfp_\*:** Скалярные поля `sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma` продолжают приниматься, но помечены как deprecated since 2.11. Если в payload одновременно присутствуют `sfp_modules[]` и deprecated sfp_-скаляры — приоритет у `sfp_modules[]`, скаляры игнорируются. Deprecated поля будут удалены в версии 3.0.
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF. **Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
`slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`. `slot` — единственный канонический адрес компонента. Для PCIe в `slot` передавайте BDF. Поле `bdf` сохраняется только как переходный alias на входе и не должно использоваться как отдельная координата рядом со `slot`.
#### pcie_devices[].sfp_modules[]
Необязательный массив установленных SFP/QSFP-модулей для данного PCIe-устройства. Один элемент — один порт. Используйте для многопортовых NIC (ConnectX-6 Dx, Intel X710, Mellanox HDR и др.).
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `port` | int | **да** | Номер порта на NIC (0-based). Ключ дедупликации внутри устройства |
| `identifier` | string | нет | Тип модуля: `SFP`, `SFP+`, `SFP28`, `QSFP+`, `QSFP28`, `QSFP-DD`, `DAC` |
| `connector` | string | нет | Тип разъёма: `LC`, `MPO`, `RJ45`, `DAC`, `AOC`, `No separable connector` |
| `vendor` | string | нет | Производитель модуля из EEPROM |
| `part_number` | string | нет | Партномер из EEPROM |
| `serial_number` | string | нет | Серийный номер из EEPROM |
| `revision` | string | нет | Ревизия из EEPROM |
| `wavelength_nm` | int | нет | Длина волны, нм (0 для DAC/медных кабелей) |
| `transceiver_type` | string | нет | `10GBase-SR`, `10GBase-LR`, `25GBase-SR`, `100GBase-SR4`, `DAC`, … |
| `temperature_c` | float | нет | Температура модуля, °C (DOM telemetry) |
| `voltage_v` | float | нет | Напряжение питания, В (DOM telemetry) |
| `tx_power_dbm` | float | нет | TX оптическая мощность, dBm (DOM telemetry) |
| `rx_power_dbm` | float | нет | RX оптическая мощность, dBm (DOM telemetry) |
| `bias_ma` | float | нет | Bias current, мА (DOM telemetry) |
**Ключ дедупликации:** `(pcie_devices[].slot, sfp_modules[].port)`.
**Правила ingest:**
- При каждом импорте — полная замена `sfp_modules[]` для данного `pcie_devices[].slot` (upsert всего массива целиком).
- Если `sfp_modules` отсутствует или `null` — существующие данные SFP не трогать.
- Если `sfp_modules: []` (пустой массив) — трактовать как «модули не обнаружены», очистить сохранённые данные.
- Дубли по `port` внутри одного `pcie_devices[]` — невалидны, endpoint возвращает `400` с описанием поля.
- Модули без `serial_number` допустимы (многие DAC-кабели не имеют SN); сохраняются по ключу `(slot, port)`.
- Изменение `serial_number` или `part_number` модуля на порту создаёт событие `COMPONENT_CHANGED` для PCIe-устройства с описанием «SFP module replaced on port N».
**Значения `device_class`:** **Значения `device_class`:**
| Значение | Назначение | | Значение | Назначение |
@@ -457,16 +507,47 @@ GET /ingest/hardware/jobs/{job_id}
"numa_node": 0, "numa_node": 0,
"temperature_c": 48.5, "temperature_c": 48.5,
"power_w": 18.2, "power_w": 18.2,
"sfp_temperature_c": 36.2,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
"device_class": "EthernetController", "device_class": "EthernetController",
"manufacturer": "Intel", "manufacturer": "Mellanox",
"model": "X710 10GbE", "model": "ConnectX-6 Dx",
"serial_number": "K65472-003", "serial_number": "MT2012X12345",
"firmware": "9.20 0x8000d4ae", "firmware": "22.35.2010",
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"], "mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
"status": "OK" "status": "OK",
"sfp_modules": [
{
"port": 0,
"identifier": "QSFP28",
"connector": "LC",
"vendor": "Mellanox",
"part_number": "MFA1A00-C003",
"serial_number": "MT2124VS09999",
"revision": "A",
"wavelength_nm": 850,
"transceiver_type": "100GBase-SR4",
"temperature_c": 36.4,
"voltage_v": 3.29,
"tx_power_dbm": -1.8,
"rx_power_dbm": -2.1,
"bias_ma": 7.2
},
{
"port": 1,
"identifier": "QSFP28",
"connector": "LC",
"vendor": "Mellanox",
"part_number": "MFA1A00-C003",
"serial_number": "MT2124VS09998",
"revision": "A",
"wavelength_nm": 850,
"transceiver_type": "100GBase-SR4",
"temperature_c": 35.9,
"voltage_v": 3.28,
"tx_power_dbm": -1.9,
"rx_power_dbm": -2.3,
"bias_ma": 7.1
}
]
} }
] ]
``` ```
@@ -592,7 +673,6 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание | | Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------| |------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора в рамках секции | | `name` | string | **да** | Уникальное имя сенсора в рамках секции |
| `location` | string | нет | Физическое расположение |
| `rpm` | int | нет | Обороты, RPM | | `rpm` | int | нет | Обороты, RPM |
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` | | `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
@@ -601,7 +681,6 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание | | Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------| |------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора | | `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `voltage_v` | float | нет | Напряжение, В | | `voltage_v` | float | нет | Напряжение, В |
| `current_a` | float | нет | Ток, А | | `current_a` | float | нет | Ток, А |
| `power_w` | float | нет | Мощность, Вт | | `power_w` | float | нет | Мощность, Вт |
@@ -612,7 +691,6 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание | | Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------| |------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора | | `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `celsius` | float | нет | Температура, °C | | `celsius` | float | нет | Температура, °C |
| `threshold_warning_celsius` | float | нет | Порог Warning, °C | | `threshold_warning_celsius` | float | нет | Порог Warning, °C |
| `threshold_critical_celsius` | float | нет | Порог Critical, °C | | `threshold_critical_celsius` | float | нет | Порог Critical, °C |
@@ -623,29 +701,29 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание | | Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------| |------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора | | `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `value` | float | нет | Значение | | `value` | float | нет | Значение |
| `unit` | string | нет | Единица измерения | | `unit` | string | нет | Единица измерения |
| `status` | string | нет | Статус | | `status` | string | нет | Статус |
**Правила sensors:** **Правила sensors:**
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение. - Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
- `location` для сенсоров передавать не нужно и не следует: в Reanimator location/slot используется только для проверки перемещения и установки компонентов, а не для last-known-value sensor ingest.
- Сенсоры без `name` игнорируются. - Сенсоры без `name` игнорируются.
- При каждом импорте значения перезаписываются (upsert по ключу). - При каждом импорте значения перезаписываются (upsert по ключу).
```json ```json
"sensors": { "sensors": {
"fans": [ "fans": [
{ "name": "FAN1", "location": "Front", "rpm": 4200, "status": "OK" }, { "name": "FAN1", "rpm": 4200, "status": "OK" },
{ "name": "FAN_CPU0", "location": "CPU0", "rpm": 5600, "status": "OK" } { "name": "FAN_CPU0", "rpm": 5600, "status": "OK" }
], ],
"power": [ "power": [
{ "name": "12V Rail", "location": "Mainboard", "voltage_v": 12.06, "status": "OK" }, { "name": "12V Rail", "voltage_v": 12.06, "status": "OK" },
{ "name": "PSU0 Input", "location": "PSU0", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" } { "name": "PSU0 Input", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
], ],
"temperatures": [ "temperatures": [
{ "name": "CPU0 Temp", "location": "CPU0", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" }, { "name": "CPU0 Temp", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
{ "name": "Inlet Temp", "location": "Front", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" } { "name": "Inlet Temp", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
], ],
"other": [ "other": [
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" } { "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
@@ -655,6 +733,31 @@ PSU без `serial_number` игнорируется.
--- ---
## Секция platform_config
Необязательный объект с произвольными ключами — настройки платформы как есть из источника (BIOS, Redfish, IPMI).
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `platform_config` | object | нет | Произвольный объект: ключи — строки, значения — строки, числа, булевы |
**Правила platform_config:**
- Содержимое объекта не валидируется: передавайте параметры как есть.
- При каждом импорте хранится latest-snapshot per machine; история изменений по каждому ключу накапливается отдельно.
- Если секция отсутствует или равна `null` — данные платформы не обновляются.
```json
"platform_config": {
"SecureBoot": "Enabled",
"BiosVersion": "06.08.05",
"TpmEnabled": true,
"NumaEnabled": false,
"HyperThreading": "Enabled"
}
```
---
## Обработка статусов компонентов ## Обработка статусов компонентов
| Статус | Поведение | | Статус | Поведение |
@@ -756,7 +859,24 @@ PSU без `serial_number` игнорируется.
"model": "X710 10GbE", "model": "X710 10GbE",
"serial_number": "K65472-003", "serial_number": "K65472-003",
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"], "mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
"status": "OK" "status": "OK",
"sfp_modules": [
{
"port": 0,
"identifier": "SFP+",
"connector": "LC",
"vendor": "Intel",
"part_number": "FTLX8574D3BCV-IT",
"serial_number": "FNS123456789",
"wavelength_nm": 850,
"transceiver_type": "10GBase-SR",
"temperature_c": 34.1,
"voltage_v": 3.30,
"tx_power_dbm": -2.5,
"rx_power_dbm": -3.0,
"bias_ma": 6.8
}
]
} }
], ],
"power_supplies": [ "power_supplies": [
@@ -787,6 +907,12 @@ PSU без `serial_number` игнорируется.
"other": [ "other": [
{ "name": "System Humidity", "value": 38.5, "unit": "%" } { "name": "System Humidity", "value": 38.5, "unit": "%" }
] ]
},
"platform_config": {
"SecureBoot": "Enabled",
"BiosVersion": "06.08.05",
"TpmEnabled": true,
"HyperThreading": "Enabled"
} }
} }
} }

View File

@@ -4,10 +4,11 @@ import (
"bufio" "bufio"
"flag" "flag"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
"time" "time"
"git.mchus.pro/mchus/logpile/internal/parser" "git.mchus.pro/mchus/logpile/internal/parser"
@@ -42,13 +43,14 @@ func main() {
PreloadFile: *file, PreloadFile: *file,
AppVersion: version, AppVersion: version,
AppCommit: commit, AppCommit: commit,
ChartVersion: detectChartVersion(),
} }
srv := server.New(cfg) srv := server.New(cfg)
url := fmt.Sprintf("http://localhost:%d", *port) url := fmt.Sprintf("http://localhost:%d", *port)
log.Printf("LOGPile starting on %s", url) slog.Info("LOGPile starting", "url", url)
log.Printf("Registered parsers: %v", parser.ListParsers()) slog.Info("registered parsers", "parsers", parser.ListParsers())
// Open browser automatically // Open browser automatically
if !*noBrowser { if !*noBrowser {
@@ -59,7 +61,7 @@ func main() {
} }
if err := runServer(srv); err != nil { if err := runServer(srv); err != nil {
log.Printf("FATAL: %v", err) slog.Error("fatal error", "err", err)
maybeWaitForCrashInput(*holdOnCrash) maybeWaitForCrashInput(*holdOnCrash)
os.Exit(1) os.Exit(1)
} }
@@ -88,10 +90,19 @@ func openBrowser(url string) {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
log.Printf("Failed to open browser: %v", err) slog.Warn("failed to open browser", "err", err)
} }
} }
func detectChartVersion() string {
cmd := exec.Command("git", "-C", "internal/chart", "describe", "--tags", "--always", "--dirty", "--abbrev=7")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func maybeWaitForCrashInput(enabled bool) { func maybeWaitForCrashInput(enabled bool) {
if !enabled || !isInteractiveConsole() { if !enabled || !isInteractiveConsole() {
return return

View File

@@ -19,9 +19,9 @@ func (c *IPMIMockConnector) Protocol() string {
func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) { func (c *IPMIMockConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
steps := []Progress{ steps := []Progress{
{Status: "running", Progress: 20, Message: "IPMI: подключение к BMC..."}, {Status: "running", Progress: 20, Message: "IPMI: connecting to BMC..."},
{Status: "running", Progress: 55, Message: "IPMI: чтение инвентаря..."}, {Status: "running", Progress: 55, Message: "IPMI: reading inventory..."},
{Status: "running", Progress: 85, Message: "IPMI: нормализация данных..."}, {Status: "running", Progress: 85, Message: "IPMI: normalizing data..."},
} }
for _, step := range steps { for _, step := range steps {

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ package collector
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log/slog"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -32,7 +32,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."}) emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
} }
if _, err := r.getJSON("/redfish/v1"); err != nil { if _, err := r.getJSON("/redfish/v1"); err != nil {
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err) slog.Warn("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults", "err", err)
} }
systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1") systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1")
@@ -219,7 +219,7 @@ func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time {
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} { for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
if ts, err := time.Parse(layout, raw); err == nil { if ts, err := time.Parse(layout, raw); err == nil {
t := ts.UTC() t := ts.UTC()
log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339)) slog.Info("redfish replay: inventory last modified", "at", t.Format(time.RFC3339), "source", "InventoryData/Status.LastModifiedTime")
return t return t
} }
} }
@@ -1244,6 +1244,15 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
} }
return out return out
} }
if ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
memberDoc, err := r.getJSON(memberPath)
if err == nil {
return []map[string]interface{}{memberDoc}
}
}
}
} }
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok { if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" { if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
@@ -1256,6 +1265,33 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
return nil return nil
} }
func dedupeJSONDocsByPath(docs []map[string]interface{}) []map[string]interface{} {
if len(docs) == 0 {
return nil
}
seen := make(map[string]struct{}, len(docs))
out := make([]map[string]interface{}, 0, len(docs))
for _, doc := range docs {
if len(doc) == 0 {
continue
}
key := normalizeRedfishPath(asString(doc["@odata.id"]))
if key == "" {
payload, err := json.Marshal(doc)
if err != nil {
continue
}
key = string(payload)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, doc)
}
return out
}
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} { func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 { if len(doc) == 0 || len(keys) == 0 {
return nil return nil

View File

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

View File

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

View File

@@ -326,6 +326,95 @@ func TestBuildAnalysisDirectives_SupermicroEnablesStorageRecovery(t *testing.T)
} }
} }
func TestMatchProfiles_LenovoXCCSelectsMatchedModeAndExcludesSensors(t *testing.T) {
match := MatchProfiles(MatchSignals{
SystemManufacturer: "Lenovo",
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
})
if match.Mode != ModeMatched {
t.Fatalf("expected matched mode, got %q", match.Mode)
}
found := false
for _, profile := range match.Profiles {
if profile.Name() == "lenovo" {
found = true
break
}
}
if !found {
t.Fatal("expected lenovo profile to be selected")
}
// Verify the acquisition plan excludes noisy Lenovo-specific snapshot paths.
plan := BuildAcquisitionPlan(MatchSignals{
SystemManufacturer: "Lenovo",
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
})
wantExcluded := []string{
"/Sensors/",
"/Oem/Lenovo/LEDs/",
"/Oem/Lenovo/Slots/",
"/Oem/Lenovo/Configuration",
"/NetworkProtocol/Oem/Lenovo/",
"/VirtualMedia/",
"/ThermalSubsystem/Fans/",
}
for _, want := range wantExcluded {
found := false
for _, ex := range plan.Tuning.SnapshotExcludeContains {
if ex == want {
found = true
break
}
}
if !found {
t.Errorf("expected SnapshotExcludeContains to include %q, got %v", want, plan.Tuning.SnapshotExcludeContains)
}
}
}
func TestResolveAcquisitionPlan_LenovoFiltersNonInventoryChassisBranches(t *testing.T) {
signals := MatchSignals{
SystemManufacturer: "Lenovo",
ChassisManufacturer: "Lenovo",
OEMNamespaces: []string{"Lenovo"},
ResourceHints: []string{
"/redfish/v1/Chassis/1/Power",
"/redfish/v1/Chassis/1/Thermal",
"/redfish/v1/Chassis/1/NetworkAdapters",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}
match := MatchProfiles(signals)
plan := BuildAcquisitionPlan(signals)
resolved := ResolveAcquisitionPlan(match, plan, DiscoveredResources{
ChassisPaths: []string{
"/redfish/v1/Chassis/1",
"/redfish/v1/Chassis/3",
"/redfish/v1/Chassis/IO_Board",
},
}, signals)
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/1/Power") {
t.Fatal("expected primary Lenovo chassis power path to remain critical")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3/Power") {
t.Fatal("did not expect non-inventory Lenovo backplane chassis power path")
}
if containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/IO_Board/Assembly") {
t.Fatal("did not expect IO board assembly path without inventory hints")
}
if containsString(resolved.Plan.PlanBPaths, "/redfish/v1/Chassis/3/Assembly") {
t.Fatal("did not expect non-inventory Lenovo chassis plan-b target")
}
if !containsString(resolved.CriticalPaths, "/redfish/v1/Chassis/3") {
t.Fatal("expected chassis root to remain discoverable even when suffixes are filtered")
}
}
func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) { func TestMatchProfiles_OrderingIsDeterministic(t *testing.T) {
signals := MatchSignals{ signals := MatchSignals{
SystemManufacturer: "Micro-Star International Co., Ltd.", SystemManufacturer: "Micro-Star International Co., Ltd.",

View File

@@ -29,6 +29,7 @@ func inspurGroupOEMPlatformsProfile() Profile {
matchFn: func(s MatchSignals) int { matchFn: func(s MatchSignals) int {
topologyScore := 0 topologyScore := 0
boardScore := 0 boardScore := 0
manufacturerScore := 0
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe) chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe) systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe) obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
@@ -62,10 +63,17 @@ func inspurGroupOEMPlatformsProfile() Profile {
if anySignalContains(s, "GetServerAllUSBStatus") { if anySignalContains(s, "GetServerAllUSBStatus") {
boardScore += 8 boardScore += 8
} }
if topologyScore == 0 || boardScore == 0 { // Manufacturer alone is sufficient for standard Inspur servers (e.g. NF-series
// storage servers) that lack GPU/outboard-PCIe topology signals. Score 60 is
// the minimum to enter matched mode; topology+board can push it higher.
if containsFold(s.SystemManufacturer, "inspur") || containsFold(s.ChassisManufacturer, "inspur") {
manufacturerScore = 60
}
total := manufacturerScore + topologyScore + boardScore
if total < 60 {
return 0 return 0
} }
return min(topologyScore+boardScore, 100) return min(total, 100)
}, },
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) { extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched") addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")

View File

@@ -118,6 +118,52 @@ func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *tes
assertProfileSelected(t, match, "inspur-group-oem-platforms") assertProfileSelected(t, match, "inspur-group-oem-platforms")
} }
// TestCollectSignalsFromTree_InspurGroupOEMPlatformsMatchesViaManufacturer covers standard
// Inspur storage servers (e.g. NF5280M6) that have no outboard PCIe / GPU topology but
// do expose Manufacturer="Inspur" in their System document.
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsMatchesViaManufacturer(t *testing.T) {
// Minimal tree: no GPU cards, no OEM firmware hints — only System Manufacturer.
tree := map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"@odata.id": "/redfish/v1",
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1",
"Manufacturer": "Inspur",
"Model": "NF5280M6",
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"@odata.id": "/redfish/v1/Managers/1",
},
}
signals := CollectSignalsFromTree(tree)
match := MatchProfiles(signals)
if match.Mode != ModeMatched {
t.Fatalf("expected matched mode for Inspur NF-series, got %q (scores: %v)", match.Mode, match.Scores)
}
assertProfileSelected(t, match, "inspur-group-oem-platforms")
}
func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) { func TestCollectSignalsFromTree_InspurGroupOEMPlatformsDoesNotFalsePositiveOnExampleRawExports(t *testing.T) {
examples := []string{ examples := []string{
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip", "2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",

View File

@@ -0,0 +1,175 @@
package redfishprofile
import "strings"
func lenovoProfile() Profile {
return staticProfile{
name: "lenovo",
priority: 20,
safeForFallback: true,
matchFn: func(s MatchSignals) int {
score := 0
if containsFold(s.SystemManufacturer, "lenovo") ||
containsFold(s.ChassisManufacturer, "lenovo") {
score += 80
}
for _, ns := range s.OEMNamespaces {
if containsFold(ns, "lenovo") {
score += 30
break
}
}
// Lenovo XClarity Controller (XCC) is the BMC product line.
if containsFold(s.ServiceRootProduct, "xclarity") ||
containsFold(s.ServiceRootProduct, "xcc") {
score += 30
}
return min(score, 100)
},
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
// Lenovo XCC BMC exposes Chassis/1/Sensors with hundreds of individual
// sensor member documents (e.g. Chassis/1/Sensors/101L1). These are
// not used by any LOGPile parser — thermal/power data is read from
// the aggregate Chassis/*/Thermal and Chassis/*/Power endpoints. On
// a real server they largely return errors, wasting many minutes.
// Lenovo OEM subtrees under Oem/Lenovo/LEDs and Oem/Lenovo/Slots also
// enumerate dozens of individual documents not relevant to inventory.
ensureSnapshotExcludeContains(plan,
"/Sensors/", // individual sensor docs (Chassis/1/Sensors/NNN)
"/Oem/Lenovo/LEDs/", // individual LED status entries (~47 per server)
"/Oem/Lenovo/Slots/", // individual slot detail entries (~26 per server)
"/Oem/Lenovo/Metrics/", // operational metrics, not inventory
"/Oem/Lenovo/History", // historical telemetry
"/Oem/Lenovo/Configuration", // BMC config service, not inventory
"/Oem/Lenovo/DateTimeService", // BMC time service config
"/Oem/Lenovo/GroupService", // XCC fleet/group management state
"/Oem/Lenovo/Recipients", // alert recipient config
"/Oem/Lenovo/RemoteControl", // remote-media/session management
"/Oem/Lenovo/RemoteMap", // remote-media mapping config
"/Oem/Lenovo/SecureKeyLifecycleService", // key lifecycle/cert config
"/Oem/Lenovo/ServerProfile", // profile export/import config
"/Oem/Lenovo/ServiceData", // support/service metadata
"/Oem/Lenovo/SsoCertificates", // SSO certificate config
"/Oem/Lenovo/SystemGuard", // snapshot/history service
"/Oem/Lenovo/Watchdogs", // watchdog config
"/Oem/Lenovo/ScheduledPower", // power scheduling config
"/Oem/Lenovo/BootSettings/BootOrder", // individual boot order lists
"/NetworkProtocol/Oem/Lenovo/", // DNS/LDAP/SMTP/SNMP manager config
"/PortForwardingMap/", // network port forwarding config
"/VirtualMedia/", // virtual media inventory/config, not hardware
"/Boot/Certificates", // secure boot certificate stores, not inventory
"/ThermalSubsystem/Fans/", // per-fan member docs; replay uses aggregate Thermal only
)
// Lenovo XCC BMC is typically slow (p95 latency often 3-5s even under
// normal load). Set rate thresholds that don't over-throttle on the
// first few requests, and give the ETA estimator a realistic baseline.
ensureRatePolicy(plan, AcquisitionRatePolicy{
TargetP95LatencyMS: 2000,
ThrottleP95LatencyMS: 4000,
MinSnapshotWorkers: 2,
MinPrefetchWorkers: 1,
DisablePrefetchOnErrors: true,
})
ensureETABaseline(plan, AcquisitionETABaseline{
DiscoverySeconds: 15,
SnapshotSeconds: 120,
PrefetchSeconds: 30,
CriticalPlanBSeconds: 40,
ProfilePlanBSeconds: 20,
})
addPlanNote(plan, "lenovo xcc acquisition extensions enabled: noisy sensor/oem paths excluded from snapshot")
},
refineAcquisition: func(resolved *ResolvedAcquisitionPlan, discovered DiscoveredResources, signals MatchSignals) {
allowedChassis := lenovoAllowedInventoryChassis(discovered.ChassisPaths, signals.ResourceHints)
resolved.SeedPaths = filterLenovoChassisInventoryPaths(resolved.SeedPaths, allowedChassis)
resolved.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.CriticalPaths, allowedChassis)
resolved.Plan.SeedPaths = filterLenovoChassisInventoryPaths(resolved.Plan.SeedPaths, allowedChassis)
resolved.Plan.CriticalPaths = filterLenovoChassisInventoryPaths(resolved.Plan.CriticalPaths, allowedChassis)
resolved.Plan.PlanBPaths = filterLenovoChassisInventoryPaths(resolved.Plan.PlanBPaths, allowedChassis)
},
}
}
func lenovoAllowedInventoryChassis(chassisPaths, resourceHints []string) map[string]struct{} {
allowed := make(map[string]struct{}, len(chassisPaths))
for _, chassisPath := range chassisPaths {
normalized := normalizePath(chassisPath)
if normalized == "" {
continue
}
if normalized == "/redfish/v1/Chassis/1" {
allowed[normalized] = struct{}{}
continue
}
for _, hint := range resourceHints {
hint = normalizePath(hint)
if !strings.HasPrefix(hint, normalized+"/") {
continue
}
if lenovoHintLooksLikeChassisInventory(hint) {
allowed[normalized] = struct{}{}
break
}
}
}
return allowed
}
func lenovoHintLooksLikeChassisInventory(path string) bool {
for _, suffix := range []string{
"/Power",
"/PowerSubsystem",
"/PowerSubsystem/PowerSupplies",
"/Thermal",
"/ThresholdSensors",
"/DiscreteSensors",
"/SensorsList",
"/NetworkAdapters",
"/PCIeDevices",
"/Drives",
"/Assembly",
} {
if strings.HasSuffix(path, suffix) || strings.Contains(path, suffix+"/") {
return true
}
}
return false
}
func filterLenovoChassisInventoryPaths(paths []string, allowedChassis map[string]struct{}) []string {
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
for _, path := range paths {
normalized := normalizePath(path)
chassis := lenovoPathChassisRoot(normalized)
if chassis == "" {
out = append(out, normalized)
continue
}
if normalized == chassis {
out = append(out, normalized)
continue
}
if _, ok := allowedChassis[chassis]; ok {
out = append(out, normalized)
}
}
return dedupeSorted(out)
}
func lenovoPathChassisRoot(path string) string {
const prefix = "/redfish/v1/Chassis/"
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := strings.TrimPrefix(path, prefix)
if rest == "" {
return ""
}
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
return prefix + rest[:idx]
}
return prefix + rest
}

View File

@@ -56,6 +56,7 @@ func BuiltinProfiles() []Profile {
supermicroProfile(), supermicroProfile(),
dellProfile(), dellProfile(),
hpeProfile(), hpeProfile(),
lenovoProfile(),
inspurGroupOEMPlatformsProfile(), inspurGroupOEMPlatformsProfile(),
hgxProfile(), hgxProfile(),
xfusionProfile(), xfusionProfile(),
@@ -226,6 +227,10 @@ func ensurePrefetchPolicy(plan *AcquisitionPlan, policy AcquisitionPrefetchPolic
addPlanPaths(&plan.Tuning.PrefetchPolicy.ExcludeContains, policy.ExcludeContains...) addPlanPaths(&plan.Tuning.PrefetchPolicy.ExcludeContains, policy.ExcludeContains...)
} }
func ensureSnapshotExcludeContains(plan *AcquisitionPlan, patterns ...string) {
addPlanPaths(&plan.Tuning.SnapshotExcludeContains, patterns...)
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a

View File

@@ -55,6 +55,7 @@ type AcquisitionScopedPathPolicy struct {
type AcquisitionTuning struct { type AcquisitionTuning struct {
SnapshotMaxDocuments int SnapshotMaxDocuments int
SnapshotWorkers int SnapshotWorkers int
SnapshotExcludeContains []string
PrefetchEnabled *bool PrefetchEnabled *bool
PrefetchWorkers int PrefetchWorkers int
NVMePostProbeEnabled *bool NVMePostProbeEnabled *bool

View File

@@ -15,9 +15,8 @@ type Request struct {
Password string Password string
Token string Token string
TLSMode string TLSMode string
PowerOnIfHostOff bool
StopHostAfterCollect bool
DebugPayloads bool DebugPayloads bool
SkipHungCh <-chan struct{}
} }
type Progress struct { type Progress struct {
@@ -67,7 +66,6 @@ type ProbeResult struct {
Protocol string Protocol string
HostPowerState string HostPowerState string
HostPoweredOn bool HostPoweredOn bool
PowerControlAvailable bool
SystemPath string SystemPath string
} }

View File

@@ -21,7 +21,11 @@ func New(result *models.AnalysisResult) *Exporter {
// ExportCSV exports serial numbers to CSV format // ExportCSV exports serial numbers to CSV format
func (e *Exporter) ExportCSV(w io.Writer) error { func (e *Exporter) ExportCSV(w io.Writer) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
}
writer := csv.NewWriter(w) writer := csv.NewWriter(w)
writer.Comma = ';'
defer writer.Flush() defer writer.Flush()
// Header // Header
@@ -170,3 +174,42 @@ func firstNonEmptyString(values ...string) string {
} }
return "" return ""
} }
// ExportLogsCSV writes all recognized events as a semicolon-delimited UTF-8 CSV readable in Excel.
func ExportLogsCSV(w io.Writer, result *models.AnalysisResult) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
}
writer := csv.NewWriter(w)
writer.Comma = ';'
defer writer.Flush()
if err := writer.Write([]string{"timestamp", "source", "severity", "sensor_type", "sensor_name", "event_type", "id", "description", "raw_data"}); err != nil {
return err
}
if result == nil {
return nil
}
for _, e := range result.Events {
ts := ""
if !e.Timestamp.IsZero() {
ts = e.Timestamp.UTC().Format("2006-01-02T15:04:05Z")
}
if err := writer.Write([]string{
ts,
e.Source,
string(e.Severity),
e.SensorType,
e.SensorName,
e.EventType,
e.ID,
e.Description,
e.RawData,
}); err != nil {
return err
}
}
return nil
}

View File

@@ -52,7 +52,13 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) {
t.Fatalf("ExportCSV failed: %v", err) t.Fatalf("ExportCSV failed: %v", err)
} }
rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() b := buf.Bytes()
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
b = b[3:] // strip UTF-8 BOM
}
r := csv.NewReader(bytes.NewReader(b))
r.Comma = ';'
rows, err := r.ReadAll()
if err != nil { if err != nil {
t.Fatalf("read csv: %v", err) t.Fatalf("read csv: %v", err)
} }

View File

@@ -51,6 +51,7 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)), PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)), PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors), Sensors: convertSensors(result.Sensors),
BMCEventSummary: buildBMCEventSummary(result.Events, collectedAt),
EventLogs: convertEventLogs(result.Events, collectedAt), EventLogs: convertEventLogs(result.Events, collectedAt),
}, },
} }
@@ -159,6 +160,16 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
} }
for _, stor := range hw.Storage { for _, stor := range hw.Storage {
present := stor.Present present := stor.Present
storDetails := mergeDetailMaps(nil, stor.Details)
if stor.LogicalBlockSizeBytes != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"logical_block_size_bytes": stor.LogicalBlockSizeBytes})
}
if stor.PhysicalBlockSizeBytes != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"physical_block_size_bytes": stor.PhysicalBlockSizeBytes})
}
if stor.MetadataBytesPerBlock != 0 {
storDetails = mergeDetailMaps(storDetails, map[string]any{"metadata_bytes_per_block": stor.MetadataBytesPerBlock})
}
appendDevice(models.HardwareDevice{ appendDevice(models.HardwareDevice{
Kind: models.DeviceKindStorage, Kind: models.DeviceKindStorage,
Slot: stor.Slot, Slot: stor.Slot,
@@ -177,27 +188,41 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
StatusAtCollect: stor.StatusAtCollect, StatusAtCollect: stor.StatusAtCollect,
StatusHistory: stor.StatusHistory, StatusHistory: stor.StatusHistory,
ErrorDescription: stor.ErrorDescription, ErrorDescription: stor.ErrorDescription,
Details: mergeDetailMaps(nil, stor.Details), Details: storDetails,
}) })
} }
for _, pcie := range hw.PCIeDevices { for _, pcie := range hw.PCIeDevices {
// Use PartNumber as model when available; fall back to chip description. // Priority: PartNumber (vendor P/N) > Model (product name) > Description (chip label).
// Description contains the chip/product name (e.g. "BCM57414 NetXtreme-E …") pcieModel := firstNonEmptyString(pcie.PartNumber, pcie.Model, pcie.Description)
// while PartNumber is a part/product code. Prefer PartNumber when set.
pcieModel := pcie.PartNumber
if pcieModel == "" {
pcieModel = pcie.Description
}
details := mergeDetailMaps(nil, pcie.Details) details := mergeDetailMaps(nil, pcie.Details)
pcieFirmware := stringFromDetailMap(details, "firmware") // Firmware: prefer direct field, fall back to details, then NVSwitch lookup.
pcieFirmware := firstNonEmptyString(pcie.Firmware, stringFromDetailMap(details, "firmware"))
if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) { if pcieFirmware == "" && isNVSwitchPCIeDevice(pcie) {
pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)] pcieFirmware = nvswitchFirmwareBySlot[normalizeNVSwitchSlotForLookup(pcie.Slot)]
}
if pcieFirmware != "" { if pcieFirmware != "" {
details = mergeDetailMaps(details, map[string]any{ details = mergeDetailMaps(details, map[string]any{"firmware": pcieFirmware})
"firmware": pcieFirmware,
})
} }
// Telemetry fields: put into details so convertPCIeFromDevices can pick them up.
if pcie.TemperatureC != nil {
details = mergeDetailMaps(details, map[string]any{"temperature_c": *pcie.TemperatureC})
} }
if pcie.PowerW != nil {
details = mergeDetailMaps(details, map[string]any{"power_w": *pcie.PowerW})
}
if pcie.ECCCorrectedTotal != nil {
details = mergeDetailMaps(details, map[string]any{"ecc_corrected_total": *pcie.ECCCorrectedTotal})
}
if pcie.ECCUncorrectedTotal != nil {
details = mergeDetailMaps(details, map[string]any{"ecc_uncorrected_total": *pcie.ECCUncorrectedTotal})
}
if pcie.HWSlowdown != nil {
details = mergeDetailMaps(details, map[string]any{"hw_slowdown": *pcie.HWSlowdown})
}
if pcie.IOMMUGroup != nil {
details = mergeDetailMaps(details, map[string]any{"iommu_group": *pcie.IOMMUGroup})
}
present := pcie.Present
appendDevice(models.HardwareDevice{ appendDevice(models.HardwareDevice{
Kind: models.DeviceKindPCIe, Kind: models.DeviceKindPCIe,
Slot: pcie.Slot, Slot: pcie.Slot,
@@ -209,11 +234,13 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
PartNumber: pcie.PartNumber, PartNumber: pcie.PartNumber,
Manufacturer: pcie.Manufacturer, Manufacturer: pcie.Manufacturer,
SerialNumber: pcie.SerialNumber, SerialNumber: pcie.SerialNumber,
MACAddresses: append([]string(nil), pcie.MACAddresses...),
LinkWidth: pcie.LinkWidth, LinkWidth: pcie.LinkWidth,
LinkSpeed: pcie.LinkSpeed, LinkSpeed: pcie.LinkSpeed,
MaxLinkWidth: pcie.MaxLinkWidth, MaxLinkWidth: pcie.MaxLinkWidth,
MaxLinkSpeed: pcie.MaxLinkSpeed, MaxLinkSpeed: pcie.MaxLinkSpeed,
NUMANode: pcie.NUMANode, NUMANode: pcie.NUMANode,
Present: present,
Status: pcie.Status, Status: pcie.Status,
StatusCheckedAt: pcie.StatusCheckedAt, StatusCheckedAt: pcie.StatusCheckedAt,
StatusChangedAt: pcie.StatusChangedAt, StatusChangedAt: pcie.StatusChangedAt,
@@ -747,6 +774,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
Firmware: d.Firmware, Firmware: d.Firmware,
Interface: d.Interface, Interface: d.Interface,
Present: &presentValue, Present: &presentValue,
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
MetadataBytesPerBlock: int64FromDetailMap(d.Details, "metadata_bytes_per_block"),
TemperatureC: floatFromDetailMap(d.Details, "temperature_c"), TemperatureC: floatFromDetailMap(d.Details, "temperature_c"),
PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"), PowerOnHours: int64FromDetailMap(d.Details, "power_on_hours"),
PowerCycles: int64FromDetailMap(d.Details, "power_cycles"), PowerCycles: int64FromDetailMap(d.Details, "power_cycles"),
@@ -818,6 +848,7 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string)
VendorID: d.VendorID, VendorID: d.VendorID,
DeviceID: d.DeviceID, DeviceID: d.DeviceID,
NUMANode: d.NUMANode, NUMANode: d.NUMANode,
IOMMUGroup: intPtrFromDetailMap(d.Details, "iommu_group"),
TemperatureC: temperatureC, TemperatureC: temperatureC,
PowerW: powerW, PowerW: powerW,
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"), LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
@@ -1204,7 +1235,7 @@ func normalizeEventLogSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) { switch strings.ToLower(strings.TrimSpace(source)) {
case "redfish": case "redfish":
return "redfish" return "redfish"
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller": case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
return "bmc" return "bmc"
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host": case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
return "host" return "host"
@@ -1961,7 +1992,10 @@ func pcieDedupKey(item ReanimatorPCIe) string {
slot := strings.ToLower(strings.TrimSpace(item.Slot)) slot := strings.ToLower(strings.TrimSpace(item.Slot))
serial := strings.ToLower(strings.TrimSpace(item.SerialNumber)) serial := strings.ToLower(strings.TrimSpace(item.SerialNumber))
bdf := strings.ToLower(strings.TrimSpace(item.BDF)) bdf := strings.ToLower(strings.TrimSpace(item.BDF))
if slot != "" { // Generic slot names (e.g. "PCIe Device" from HGX BMC) are not unique
// hardware positions — multiple distinct devices share the same name.
// Fall through to serial/BDF so they are not incorrectly collapsed.
if slot != "" && !isGenericPCIeSlotName(slot) {
return "slot:" + slot return "slot:" + slot
} }
if serial != "" { if serial != "" {
@@ -1970,9 +2004,22 @@ func pcieDedupKey(item ReanimatorPCIe) string {
if bdf != "" { if bdf != "" {
return "bdf:" + bdf return "bdf:" + bdf
} }
if slot != "" {
return "slot:" + slot
}
return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model)) return strings.ToLower(strings.TrimSpace(item.DeviceClass)) + "|" + strings.ToLower(strings.TrimSpace(item.Model))
} }
// isGenericPCIeSlotName reports whether slot is a generic device-type label
// rather than a unique hardware position identifier.
func isGenericPCIeSlotName(slot string) bool {
switch slot {
case "pcie device", "pcie slot", "pcie":
return true
}
return false
}
func pcieQualityScore(item ReanimatorPCIe) int { func pcieQualityScore(item ReanimatorPCIe) int {
score := 0 score := 0
if strings.TrimSpace(item.SerialNumber) != "" { if strings.TrimSpace(item.SerialNumber) != "" {
@@ -2077,6 +2124,17 @@ func parseSocketFromSlot(slot string) int {
return v return v
} }
func intPtrFromDetailMap(details map[string]any, key string) *int {
if details == nil {
return nil
}
if _, ok := details[key]; !ok {
return nil
}
v := intFromDetailMap(details, key)
return &v
}
func intFromDetailMap(details map[string]any, key string) int { func intFromDetailMap(details map[string]any, key string) int {
if details == nil { if details == nil {
return 0 return 0
@@ -2246,10 +2304,8 @@ func normalizePCIeDeviceClass(d models.HardwareDevice) string {
func normalizeLegacyPCIeDeviceClass(deviceClass string) string { func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
switch strings.ToLower(strings.TrimSpace(deviceClass)) { switch strings.ToLower(strings.TrimSpace(deviceClass)) {
case "", "network", "network controller", "networkcontroller": case "", "network", "network controller", "networkcontroller", "ethernet", "ethernet controller", "ethernetcontroller":
return "NetworkController" return "NetworkController"
case "ethernet", "ethernet controller", "ethernetcontroller":
return "EthernetController"
case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc": case "fibre channel", "fibre channel controller", "fibrechannelcontroller", "fc":
return "FibreChannelController" return "FibreChannelController"
case "display", "displaycontroller", "display controller", "vga": case "display", "displaycontroller", "display controller", "vga":
@@ -2270,8 +2326,6 @@ func normalizeLegacyPCIeDeviceClass(deviceClass string) string {
func normalizeNetworkDeviceClass(portType, model, description string) string { func normalizeNetworkDeviceClass(portType, model, description string) string {
joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " "))) joined := strings.ToLower(strings.TrimSpace(strings.Join([]string{portType, model, description}, " ")))
switch { switch {
case strings.Contains(joined, "ethernet"):
return "EthernetController"
case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "): case strings.Contains(joined, "fibre channel") || strings.Contains(joined, " fibrechannel") || strings.Contains(joined, "fc "):
return "FibreChannelController" return "FibreChannelController"
default: default:
@@ -2404,3 +2458,76 @@ func inferTargetHost(targetHost, filename string) string {
return "" return ""
} }
// buildBMCEventSummary produces a summary table of Critical/Warning BMC events
// with their resolution status derived from Assert/Deassert pairs.
func buildBMCEventSummary(events []models.Event, collectedAt string) []ReanimatorBMCEventRow {
type assertKey struct {
id string
desc string
}
type eventPair struct {
assertEvent *models.Event
deassertEvent *models.Event
}
pairs := make(map[assertKey]*eventPair)
order := make([]assertKey, 0)
for i := range events {
e := &events[i]
if e.Severity != models.SeverityCritical && e.Severity != models.SeverityWarning {
continue
}
key := assertKey{id: e.ID, desc: e.Description}
p, exists := pairs[key]
if !exists {
p = &eventPair{}
pairs[key] = p
order = append(order, key)
}
switch strings.ToLower(e.EventType) {
case "deassert":
if p.deassertEvent == nil || e.Timestamp.After(p.deassertEvent.Timestamp) {
p.deassertEvent = e
}
default:
if p.assertEvent == nil || e.Timestamp.Before(p.assertEvent.Timestamp) {
p.assertEvent = e
}
}
}
rows := make([]ReanimatorBMCEventRow, 0, len(order))
for _, key := range order {
p := pairs[key]
ref := p.assertEvent
if ref == nil {
ref = p.deassertEvent
}
if ref == nil {
continue
}
status := "Active"
resolvedAt := ""
if p.deassertEvent != nil {
status = "Resolved"
resolvedAt = formatEventLogTime(p.deassertEvent.Timestamp, collectedAt)
}
rows = append(rows, ReanimatorBMCEventRow{
Severity: normalizeEventLogSeverity(ref.Severity),
Component: strings.ToUpper(strings.TrimSpace(ref.SensorType)),
MessageID: strings.TrimSpace(ref.ID),
Timestamp: formatEventLogTime(ref.Timestamp, collectedAt),
Description: strings.TrimSpace(ref.Description),
Status: status,
ResolvedAt: resolvedAt,
})
}
if len(rows) == 0 {
return nil
}
return rows
}

View File

@@ -733,6 +733,42 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
} }
} }
func TestConvertPCIeDevices_PreservesAllGPUsWithGenericSlot(t *testing.T) {
// Supermicro HGX BMC reports all GPU PCIe devices with Name "PCIe Device" —
// a generic label that is not a unique hardware position. All 8 GPUs must
// be preserved; dedup by generic slot name must not collapse them into one.
gpus := make([]models.GPU, 8)
serials := []string{
"1654925165720", "1654925166160", "1654925165942", "1654925165271",
"1654925165719", "1654925165252", "1654925165304", "1654925165587",
}
for i, sn := range serials {
gpus[i] = models.GPU{
Slot: "PCIe Device",
Model: "B200 180GB HBM3e",
Manufacturer: "NVIDIA",
SerialNumber: sn,
PartNumber: "2901-886-A1",
Status: "OK",
}
}
hw := &models.HardwareConfig{GPUs: gpus}
result := convertPCIeDevices(hw, "2026-04-13T10:00:00Z")
if len(result) != 8 {
t.Fatalf("expected 8 GPU entries (one per serial), got %d", len(result))
}
seen := make(map[string]bool)
for _, r := range result {
if seen[r.SerialNumber] {
t.Fatalf("duplicate serial %q in PCIe result", r.SerialNumber)
}
seen[r.SerialNumber] = true
if r.DeviceClass != "VideoController" {
t.Fatalf("expected VideoController device class, got %q", r.DeviceClass)
}
}
}
func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) { func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
hw := &models.HardwareConfig{ hw := &models.HardwareConfig{
GPUs: []models.GPU{ GPUs: []models.GPU{
@@ -1733,6 +1769,43 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
} }
} }
func TestConvertToReanimator_UnifiesEthernetAndNetworkControllers(t *testing.T) {
input := &models.AnalysisResult{
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-123"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindPCIe,
Slot: "PCIe1",
DeviceClass: "EthernetController",
Present: boolPtr(true),
SerialNumber: "ETH-001",
},
{
Kind: models.DeviceKindNetwork,
Slot: "NIC1",
Model: "Ethernet Adapter",
Present: boolPtr(true),
SerialNumber: "NIC-001",
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected two pcie-class exports, got %d", len(out.Hardware.PCIeDevices))
}
for _, dev := range out.Hardware.PCIeDevices {
if dev.DeviceClass != "NetworkController" {
t.Fatalf("expected unified NetworkController class, got %+v", dev)
}
}
}
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) { func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
input := &models.AnalysisResult{ input := &models.AnalysisResult{
Filename: "legacy-details.json", Filename: "legacy-details.json",

View File

@@ -20,7 +20,20 @@ type ReanimatorHardware struct {
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"` PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"` PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"` Sensors *ReanimatorSensors `json:"sensors,omitempty"`
BMCEventSummary []ReanimatorBMCEventRow `json:"bmc_event_summary,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"` EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
PlatformConfig map[string]any `json:"platform_config,omitempty"`
}
// ReanimatorBMCEventRow is one row in the BMC critical/warning event summary table.
type ReanimatorBMCEventRow struct {
Severity string `json:"severity"`
Component string `json:"component"`
MessageID string `json:"message_id"`
Timestamp string `json:"timestamp"`
Description string `json:"description"`
Status string `json:"status"`
ResolvedAt string `json:"resolved_at,omitempty"`
} }
// ReanimatorBoard represents motherboard/server information // ReanimatorBoard represents motherboard/server information
@@ -110,6 +123,9 @@ type ReanimatorStorage struct {
Firmware string `json:"firmware,omitempty"` Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"` Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"` Present *bool `json:"present,omitempty"`
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"` TemperatureC float64 `json:"temperature_c,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"` PowerOnHours int64 `json:"power_on_hours,omitempty"`
PowerCycles int64 `json:"power_cycles,omitempty"` PowerCycles int64 `json:"power_cycles,omitempty"`
@@ -139,6 +155,7 @@ type ReanimatorPCIe struct {
VendorID int `json:"vendor_id,omitempty"` VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"` DeviceID int `json:"device_id,omitempty"`
NUMANode int `json:"numa_node,omitempty"` NUMANode int `json:"numa_node,omitempty"`
IOMMUGroup *int `json:"iommu_group,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"` TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"` PowerW float64 `json:"power_w,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"` LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`

View File

@@ -17,12 +17,22 @@ type AnalysisResult struct {
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status) InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status)
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree) RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
CollectionErrors []CollectionError `json:"collection_errors,omitempty"` // BMC-reported failures to collect specific sections
Events []Event `json:"events"` Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"` FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"` Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"` Hardware *HardwareConfig `json:"hardware"`
} }
// CollectionError represents a BMC-reported failure to collect a specific data section.
// Populated by vendor parsers when the source explicitly returns an error response
// instead of structured data (e.g. {"error":"...","code":1458} in Inspur component.log).
type CollectionError struct {
Section string `json:"section"`
Message string `json:"message"`
Code int `json:"code,omitempty"`
}
// Event represents a single log event // Event represents a single log event
type Event struct { type Event struct {
ID string `json:"id"` ID string `json:"id"`
@@ -245,6 +255,9 @@ type Storage struct {
Location string `json:"location,omitempty"` // Front/Rear Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"` BackplaneID int `json:"backplane_id,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
MetadataBytesPerBlock int64 `json:"metadata_bytes_per_block,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Details map[string]any `json:"details,omitempty"` Details map[string]any `json:"details,omitempty"`
@@ -266,6 +279,7 @@ type StorageVolume struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"` Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"` Encrypted bool `json:"encrypted,omitempty"`
Drives []string `json:"drives,omitempty"` // member drive names/labels
} }
// PCIeDevice represents a PCIe device // PCIeDevice represents a PCIe device
@@ -277,6 +291,8 @@ type PCIeDevice struct {
BDF string `json:"bdf"` BDF string `json:"bdf"`
DeviceClass string `json:"device_class"` DeviceClass string `json:"device_class"`
Manufacturer string `json:"manufacturer,omitempty"` Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
Firmware string `json:"firmware,omitempty"`
LinkWidth int `json:"link_width"` LinkWidth int `json:"link_width"`
LinkSpeed string `json:"link_speed"` LinkSpeed string `json:"link_speed"`
MaxLinkWidth int `json:"max_link_width"` MaxLinkWidth int `json:"max_link_width"`
@@ -285,8 +301,17 @@ type PCIeDevice struct {
SerialNumber string `json:"serial_number,omitempty"` SerialNumber string `json:"serial_number,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"` MACAddresses []string `json:"mac_addresses,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Present *bool `json:"present,omitempty"`
IOMMUGroup *int `json:"iommu_group,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
// GPU telemetry fields (populated by bee audit for GPU devices)
TemperatureC *float64 `json:"temperature_c,omitempty"`
PowerW *float64 `json:"power_w,omitempty"`
ECCCorrectedTotal *int64 `json:"ecc_corrected_total,omitempty"`
ECCUncorrectedTotal *int64 `json:"ecc_uncorrected_total,omitempty"`
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`

View File

@@ -15,9 +15,11 @@ import (
) )
const maxSingleFileSize = 10 * 1024 * 1024 const maxSingleFileSize = 10 * 1024 * 1024
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
const maxZipArchiveSize = 50 * 1024 * 1024 const maxZipArchiveSize = 50 * 1024 * 1024
const maxGzipDecompressedSize = 50 * 1024 * 1024 const maxGzipDecompressedSize = 50 * 1024 * 1024
var supportedArchiveExt = map[string]struct{}{ var supportedArchiveExt = map[string]struct{}{
".ahs": {}, ".ahs": {},
".gz": {}, ".gz": {},
@@ -47,7 +49,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
switch ext { switch ext {
case ".ahs": case ".ahs":
return extractSingleFile(archivePath) return extractSingleFileWithLimit(archivePath, maxSingleFileSizeLarge)
case ".gz", ".tgz": case ".gz", ".tgz":
return extractTarGz(archivePath) return extractTarGz(archivePath)
case ".tar", ".sds": case ".tar", ".sds":
@@ -55,7 +57,7 @@ func ExtractArchive(archivePath string) ([]ExtractedFile, error) {
case ".zip": case ".zip":
return extractZip(archivePath) return extractZip(archivePath)
case ".txt", ".log": case ".txt", ".log":
return extractSingleFile(archivePath) return extractSingleFileWithLimit(archivePath, maxSingleFileSize)
default: default:
return nil, fmt.Errorf("unsupported archive format: %s", ext) return nil, fmt.Errorf("unsupported archive format: %s", ext)
} }
@@ -70,7 +72,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
switch ext { switch ext {
case ".ahs": case ".ahs":
return extractSingleFileFromReader(r, filename) return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSizeLarge)
case ".gz", ".tgz": case ".gz", ".tgz":
return extractTarGzFromReader(r, filename) return extractTarGzFromReader(r, filename)
case ".tar", ".sds": case ".tar", ".sds":
@@ -78,7 +80,7 @@ func ExtractArchiveFromReader(r io.Reader, filename string) ([]ExtractedFile, er
case ".zip": case ".zip":
return extractZipFromReader(r) return extractZipFromReader(r)
case ".txt", ".log": case ".txt", ".log":
return extractSingleFileFromReader(r, filename) return extractSingleFileFromReaderWithLimit(r, filename, maxSingleFileSize)
default: default:
return nil, fmt.Errorf("unsupported archive format: %s", ext) return nil, fmt.Errorf("unsupported archive format: %s", ext)
} }
@@ -337,7 +339,7 @@ func extractZipFromReader(r io.Reader) ([]ExtractedFile, error) {
return files, nil return files, nil
} }
func extractSingleFile(path string) ([]ExtractedFile, error) { func extractSingleFileWithLimit(path string, limit int64) ([]ExtractedFile, error) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("stat file: %w", err) return nil, fmt.Errorf("stat file: %w", err)
@@ -348,7 +350,7 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
} }
defer f.Close() defer f.Close()
files, err := extractSingleFileFromReader(f, filepath.Base(path)) files, err := extractSingleFileFromReaderWithLimit(f, filepath.Base(path), limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -358,14 +360,14 @@ func extractSingleFile(path string) ([]ExtractedFile, error) {
return files, nil return files, nil
} }
func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile, error) { func extractSingleFileFromReaderWithLimit(r io.Reader, filename string, limit int64) ([]ExtractedFile, error) {
content, err := io.ReadAll(io.LimitReader(r, maxSingleFileSize+1)) content, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("read file content: %w", err) return nil, fmt.Errorf("read file content: %w", err)
} }
truncated := len(content) > maxSingleFileSize truncated := int64(len(content)) > limit
if truncated { if truncated {
content = content[:maxSingleFileSize] content = content[:limit]
} }
file := ExtractedFile{ file := ExtractedFile{
@@ -376,7 +378,7 @@ func extractSingleFileFromReader(r io.Reader, filename string) ([]ExtractedFile,
file.Truncated = true file.Truncated = true
file.TruncatedMessage = fmt.Sprintf( file.TruncatedMessage = fmt.Sprintf(
"file exceeded %d bytes and was truncated", "file exceeded %d bytes and was truncated",
maxSingleFileSize, limit,
) )
} }

View File

@@ -2867,9 +2867,9 @@ func parseKeyValueBlocks(content string) []map[string]string {
func findCPUIndex(items []models.CPU, target models.CPU) int { func findCPUIndex(items []models.CPU, target models.CPU) int {
targetSocket := target.Socket targetSocket := target.Socket
targetPPIN := strings.ToLower(strings.TrimSpace(target.PPIN)) targetPPIN := strings.TrimSpace(target.PPIN)
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) targetSerial := strings.TrimSpace(target.SerialNumber)
targetModel := strings.ToLower(strings.TrimSpace(target.Model)) targetModel := strings.TrimSpace(target.Model)
for i := range items { for i := range items {
cpu := items[i] cpu := items[i]
@@ -2880,18 +2880,18 @@ func findCPUIndex(items []models.CPU, target models.CPU) int {
continue continue
} }
ppin := strings.ToLower(strings.TrimSpace(cpu.PPIN)) ppin := strings.TrimSpace(cpu.PPIN)
if targetPPIN != "" && ppin != "" && targetPPIN == ppin { if targetPPIN != "" && ppin != "" && strings.EqualFold(targetPPIN, ppin) {
return i return i
} }
serial := strings.ToLower(strings.TrimSpace(cpu.SerialNumber)) serial := strings.TrimSpace(cpu.SerialNumber)
if targetSerial != "" && serial != "" && targetSerial == serial { if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
return i return i
} }
model := strings.ToLower(strings.TrimSpace(cpu.Model)) model := strings.TrimSpace(cpu.Model)
if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && model == targetModel { if targetSocket == 0 && cpu.Socket == 0 && targetModel != "" && strings.EqualFold(model, targetModel) {
return i return i
} }
} }
@@ -2931,15 +2931,15 @@ func mergeCPU(dst *models.CPU, src models.CPU) {
} }
func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int { func findMemoryIndex(items []models.MemoryDIMM, target models.MemoryDIMM) int {
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) targetSerial := strings.TrimSpace(target.SerialNumber)
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) targetSlot := strings.TrimSpace(target.Slot)
for i := range items { for i := range items {
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) serial := strings.TrimSpace(items[i].SerialNumber)
slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) slot := strings.TrimSpace(items[i].Slot)
if targetSerial != "" && serial != "" && targetSerial == serial { if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
return i return i
} }
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
return i return i
} }
} }
@@ -2993,15 +2993,15 @@ func dedupeStorage(items []models.Storage) []models.Storage {
} }
func findStorageIndex(items []models.Storage, target models.Storage) int { func findStorageIndex(items []models.Storage, target models.Storage) int {
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) targetSerial := strings.TrimSpace(target.SerialNumber)
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) targetSlot := strings.TrimSpace(target.Slot)
for i := range items { for i := range items {
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) serial := strings.TrimSpace(items[i].SerialNumber)
slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) slot := strings.TrimSpace(items[i].Slot)
if targetSerial != "" && serial != "" && targetSerial == serial { if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
return i return i
} }
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
return i return i
} }
} }
@@ -3248,15 +3248,15 @@ func isPSUEmpty(p models.PSU) bool {
} }
func findPSUIndex(items []models.PSU, target models.PSU) int { func findPSUIndex(items []models.PSU, target models.PSU) int {
targetSerial := strings.ToLower(strings.TrimSpace(target.SerialNumber)) targetSerial := strings.TrimSpace(target.SerialNumber)
targetSlot := strings.ToLower(strings.TrimSpace(target.Slot)) targetSlot := strings.TrimSpace(target.Slot)
for i := range items { for i := range items {
serial := strings.ToLower(strings.TrimSpace(items[i].SerialNumber)) serial := strings.TrimSpace(items[i].SerialNumber)
slot := strings.ToLower(strings.TrimSpace(items[i].Slot)) slot := strings.TrimSpace(items[i].Slot)
if targetSerial != "" && serial != "" && targetSerial == serial { if targetSerial != "" && serial != "" && strings.EqualFold(targetSerial, serial) {
return i return i
} }
if targetSerial == "" && targetSlot != "" && slot != "" && targetSlot == slot { if targetSerial == "" && targetSlot != "" && slot != "" && strings.EqualFold(targetSlot, slot) {
return i return i
} }
} }

View File

@@ -214,8 +214,10 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00") name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
start := offset + ahsHeaderSize start := offset + ahsHeaderSize
end := start + size end := start + size
truncated := false
if size < 0 || end > len(data) { if size < 0 || end > len(data) {
return nil, fmt.Errorf("invalid payload size for %q", name) end = len(data)
truncated = true
} }
payload := append([]byte(nil), data[start:end]...) payload := append([]byte(nil), data[start:end]...)
@@ -235,6 +237,9 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
Content: content, Content: content,
Compressed: compressed, Compressed: compressed,
}) })
if truncated {
break
}
offset = end offset = end
} }
@@ -992,7 +997,7 @@ func parseEvents(tokens []string) []models.Event {
break break
} }
if looksLikeEventMessage(tokens[j]) { if looksLikeEventMessage(tokens[j]) {
message = tokens[j] message = trimEventJunk(tokens[j])
break break
} }
} }
@@ -1173,7 +1178,7 @@ func looksLikeServerModel(v string) bool {
return false return false
} }
lower := strings.ToLower(v) lower := strings.ToLower(v)
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") || strings.Contains(lower, "alletra")
} }
func looksLikeCPUVendor(v string) bool { func looksLikeCPUVendor(v string) bool {
@@ -1464,7 +1469,19 @@ func fabricIDFromPath(path string) string {
func inferSeverity(message string) models.Severity { func inferSeverity(message string) models.Severity {
lower := strings.ToLower(message) lower := strings.ToLower(message)
switch { switch {
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"): case strings.Contains(lower, "critical"):
return models.SeverityCritical
case strings.Contains(lower, " down"),
strings.Contains(lower, "warning"),
strings.Contains(lower, "fail"),
strings.Contains(lower, "error"),
strings.Contains(lower, "server reset"),
strings.Contains(lower, "server power"),
strings.Contains(lower, "power restored"),
strings.Contains(lower, "ilo reset"),
strings.Contains(lower, "ilo restarted"),
strings.Contains(lower, "pcr measurements"),
strings.Contains(lower, "hardware data received from uefi"):
return models.SeverityWarning return models.SeverityWarning
default: default:
return models.SeverityInfo return models.SeverityInfo
@@ -1478,21 +1495,73 @@ func inferEventType(message string) string {
return "Login" return "Login"
case strings.Contains(lower, "logout"): case strings.Contains(lower, "logout"):
return "Logout" return "Logout"
case strings.Contains(lower, "network"): case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
return "Network" return "Network"
case strings.Contains(lower, "license"): case strings.Contains(lower, "license"):
return "License" return "License"
case strings.Contains(lower, "backup operation"), strings.Contains(lower, "remote console"):
return "Management"
case strings.Contains(lower, "server power"), strings.Contains(lower, "power restored"), strings.Contains(lower, "power off"), strings.Contains(lower, "server reset"), strings.Contains(lower, "ilo reset"), strings.Contains(lower, "ilo restarted"):
return "Power"
case strings.Contains(lower, "storage"), strings.Contains(lower, "volume"), strings.Contains(lower, "drive"), strings.Contains(lower, "firmware"):
return "Hardware"
case strings.Contains(lower, "certificate"), strings.Contains(lower, "pcr measurements"), strings.Contains(lower, "hardware data"), strings.Contains(lower, "security"):
return "Security"
default: default:
return "Event" return "Event"
} }
} }
// trimEventJunk strips trailing single-byte frame markers written by iLO into
// binary .zbb log records. These markers are printable ASCII (letters, *, +, ')
// that appear immediately after the sentence-ending punctuation or a digit.
func trimEventJunk(s string) string {
if len(s) < 3 {
return s
}
last := s[len(s)-1]
prev := s[len(s)-2]
isJunk := (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') ||
last == '*' || last == '+' || last == '\''
prevIsBoundary := prev == '.' || prev == '!' || prev == '"' || prev == ')' ||
(prev >= '0' && prev <= '9')
if isJunk && prevIsBoundary {
return s[:len(s)-1]
}
return s
}
func looksLikeEventMessage(v string) bool { func looksLikeEventMessage(v string) bool {
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") { if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
return false return false
} }
// JSON document accidentally extracted — skip
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
return false
}
// Numbered list items (e.g. "2.Perform the iLO reset.") are instructions, not events
if len(v) > 2 && v[0] >= '1' && v[0] <= '9' && v[1] == '.' {
return false
}
lower := strings.ToLower(v) lower := strings.ToLower(v)
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state") return strings.Contains(lower, "login") ||
strings.Contains(lower, "logout") ||
strings.Contains(lower, "link") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "security state") ||
strings.Contains(lower, "server power") ||
strings.Contains(lower, "server reset") ||
strings.Contains(lower, "power restored") ||
strings.Contains(lower, "power off") ||
strings.Contains(lower, "storage") ||
strings.Contains(lower, "firmware") ||
strings.Contains(lower, "certificate") ||
strings.Contains(lower, "backup operation") ||
strings.Contains(lower, "pcr measurements") ||
strings.Contains(lower, "hardware data") ||
strings.Contains(lower, "ilo reset") ||
strings.Contains(lower, "ilo restarted") ||
strings.Contains(lower, "remote console")
} }
func sanitizeModel(v string) string { func sanitizeModel(v string) string {

View File

@@ -153,6 +153,29 @@ func TestParseAHSInventory(t *testing.T) {
} }
} }
func TestParseAHSTruncatedEntry(t *testing.T) {
p := &Parser{}
// Build archive where the last entry's declared size exceeds available data.
archive := makeAHSArchive(t, []ahsTestEntry{
{Name: "CUST_INFO.DAT", Payload: []byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421")},
{Name: "0000150-2025-11-27.zbb", Payload: []byte("some content")},
})
// Corrupt the size field of the second entry to exceed len(archive).
secondHeaderOffset := ahsHeaderSize + len([]byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421"))
binary.LittleEndian.PutUint32(archive[secondHeaderOffset+8:secondHeaderOffset+12], 0xFFFFFFFF)
result, err := p.Parse([]parser.ExtractedFile{{
Path: "HPE_CZ2D1X0GS3_20251127.ahs",
Content: archive,
}})
if err != nil {
t.Fatalf("expected graceful handling of truncated entry, got error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestParseExampleAHS(t *testing.T) { func TestParseExampleAHS(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs") path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
content, err := os.ReadFile(path) content, err := os.ReadFile(path)

View File

@@ -117,7 +117,6 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
} }
// Parse CPU info // Parse CPU info
seenMicrocode := make(map[string]bool)
for i, cpu := range asset.CpuInfo { for i, cpu := range asset.CpuInfo {
config.CPUs = append(config.CPUs, models.CPU{ config.CPUs = append(config.CPUs, models.CPU{
Socket: i, Socket: i,
@@ -133,13 +132,11 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
PPIN: cpu.PPIN, PPIN: cpu.PPIN,
}) })
// Add CPU microcode to firmware list (deduplicated) if cpu.MicroCodeVer != "" {
if cpu.MicroCodeVer != "" && !seenMicrocode[cpu.MicroCodeVer] {
config.Firmware = append(config.Firmware, models.FirmwareInfo{ config.Firmware = append(config.Firmware, models.FirmwareInfo{
DeviceName: fmt.Sprintf("CPU%d Microcode", i), DeviceName: fmt.Sprintf("CPU%d Microcode", i),
Version: cpu.MicroCodeVer, Version: cpu.MicroCodeVer,
}) })
seenMicrocode[cpu.MicroCodeVer] = true
} }
} }

View File

@@ -19,6 +19,11 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) {
text := string(content) text := string(content)
// Parse RESTful CPU info — fallback when asset.json is absent
if len(hw.CPUs) == 0 {
parseCPUInfo(text, hw)
}
// Parse RESTful Memory info (detailed memory data) // Parse RESTful Memory info (detailed memory data)
parseMemoryInfo(text, hw) parseMemoryInfo(text, hw)
@@ -51,6 +56,52 @@ func ParseComponentLogEvents(content []byte) []models.Event {
return events return events
} }
// ParseComponentLogCollectionErrors detects BMC-reported collection failures in component.log.
// When a RESTful section returns {"error":"...","code":N} instead of structured data,
// the BMC itself failed to collect that subsystem — the parser emits a CollectionError
// so the UI can surface it explicitly rather than showing an empty section.
func ParseComponentLogCollectionErrors(content []byte) []models.CollectionError {
type bmcErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
}
// Map of section name (for display) → regex that captures its JSON payload.
// Sections that return arrays use \[ ... \]; object sections use \{ ... \}.
// We only probe sections that are expected to have structured hardware data.
sections := []struct {
name string
re *regexp.Regexp
}{
{"HDD", regexp.MustCompile(`RESTful HDD info:\s*(\{[^\n]*\})`)},
{"PCIe Devices", regexp.MustCompile(`RESTful PCIE Device info:\s*(\{[^\n]*\})`)},
{"Network Adapters", regexp.MustCompile(`RESTful Network Adapter info:\s*(\{[^\n]*\})`)},
{"Disk Backplane", regexp.MustCompile(`RESTful diskbackplane info:\s*(\{[^\n]*\})`)},
}
text := string(content)
var out []models.CollectionError
for _, s := range sections {
m := s.re.FindStringSubmatch(text)
if m == nil {
continue
}
var errResp bmcErrorResponse
if err := json.Unmarshal([]byte(m[1]), &errResp); err != nil {
continue
}
if strings.TrimSpace(errResp.Error) == "" {
continue
}
out = append(out, models.CollectionError{
Section: s.name,
Message: errResp.Error,
Code: errResp.Code,
})
}
return out
}
// ParseComponentLogSensors extracts sensor readings from component.log JSON sections. // ParseComponentLogSensors extracts sensor readings from component.log JSON sections.
func ParseComponentLogSensors(content []byte) []models.SensorReading { func ParseComponentLogSensors(content []byte) []models.SensorReading {
text := string(content) text := string(content)
@@ -61,6 +112,68 @@ func ParseComponentLogSensors(content []byte) []models.SensorReading {
return out return out
} }
// CPURESTInfo represents the RESTful CPU info structure in component.log
type CPURESTInfo struct {
Processors []struct {
ProcID int `json:"proc_id"`
CPUID string `json:"PROC_ID"` // uppercase key — prevents case-insensitive collision with proc_id
Manufacturer string `json:"Manufacturer"`
MaxSpeedMHz int `json:"MaxSpeedMHz"`
ConfigStatus int `json:"configStatus"`
ProcName string `json:"proc_name"`
ProcStatus int `json:"proc_status"`
ProcSpeed int `json:"proc_speed"`
CoreCount int `json:"proc_core_count"`
ThreadCount int `json:"proc_thread_count"`
TDP int `json:"proc_tdp"`
L1Cache int `json:"proc_l1cache_size"`
L2Cache int `json:"proc_l2cache_size"`
L3Cache int `json:"proc_l3cache_size"`
MicroCode string `json:"micro_code"`
PPIN string `json:"ppin"`
Status string `json:"status"`
} `json:"processors"`
}
func parseCPUInfo(text string, hw *models.HardwareConfig) {
re := regexp.MustCompile(`RESTful CPU info:\s*(\{[\s\S]*?\})\s*RESTful Memory`)
match := re.FindStringSubmatch(text)
if match == nil {
return
}
jsonStr := strings.ReplaceAll(match[1], "\n", "")
var cpuInfo CPURESTInfo
if err := json.Unmarshal([]byte(jsonStr), &cpuInfo); err != nil {
return
}
for _, proc := range cpuInfo.Processors {
if proc.ProcStatus != 1 && proc.ConfigStatus != 1 {
continue
}
hw.CPUs = append(hw.CPUs, models.CPU{
Socket: proc.ProcID,
Model: strings.TrimSpace(proc.ProcName),
Cores: proc.CoreCount,
Threads: proc.ThreadCount,
FrequencyMHz: proc.ProcSpeed,
MaxFreqMHz: proc.MaxSpeedMHz,
L1CacheKB: proc.L1Cache,
L2CacheKB: proc.L2Cache,
L3CacheKB: proc.L3Cache,
TDP: proc.TDP,
PPIN: proc.PPIN,
})
if proc.MicroCode != "" {
hw.Firmware = append(hw.Firmware, models.FirmwareInfo{
DeviceName: fmt.Sprintf("CPU%d Microcode", proc.ProcID),
Version: proc.MicroCode,
})
}
}
}
// MemoryRESTInfo represents the RESTful Memory info structure // MemoryRESTInfo represents the RESTful Memory info structure
type MemoryRESTInfo struct { type MemoryRESTInfo struct {
MemModules []struct { MemModules []struct {
@@ -114,7 +227,8 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
item := models.MemoryDIMM{ item := models.MemoryDIMM{
Slot: mem.MemModSlot, Slot: mem.MemModSlot,
Location: mem.MemModSlot, Location: mem.MemModSlot,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0, // status=1 with a known serial/part is definitely present even if BMC reports size=0
Present: mem.MemModStatus == 1 && (mem.MemModSize > 0 || strings.TrimSpace(mem.MemModSerial) != "" || strings.TrimSpace(mem.MemModPartNum) != ""),
SizeMB: mem.MemModSize * 1024, // Convert GB to MB SizeMB: mem.MemModSize * 1024, // Convert GB to MB
Type: mem.MemModType, Type: mem.MemModType,
Technology: strings.TrimSpace(mem.MemModTechnology), Technology: strings.TrimSpace(mem.MemModTechnology),
@@ -136,6 +250,25 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
} }
merged = append(merged, item) merged = append(merged, item)
} }
// If a present DIMM has size=0 (BMC firmware glitch), infer size from
// another present DIMM with the same part number in the same batch.
partSize := make(map[string]int)
for _, m := range merged {
if m.Present && m.SizeMB > 0 && strings.TrimSpace(m.PartNumber) != "" {
partSize[strings.TrimSpace(m.PartNumber)] = m.SizeMB
}
}
for i := range merged {
if merged[i].Present && merged[i].SizeMB == 0 {
if pn := strings.TrimSpace(merged[i].PartNumber); pn != "" {
if sz, ok := partSize[pn]; ok {
merged[i].SizeMB = sz
}
}
}
}
hw.Memory = merged hw.Memory = merged
} }
@@ -163,7 +296,7 @@ type PSURESTInfo struct {
func parsePSUInfo(text string, hw *models.HardwareConfig) { func parsePSUInfo(text string, hw *models.HardwareConfig) {
// Find RESTful PSU info section // Find RESTful PSU info section
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`) re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
match := re.FindStringSubmatch(text) match := re.FindStringSubmatch(text)
if match == nil { if match == nil {
return return
@@ -793,7 +926,7 @@ func parseDiskBackplaneSensors(text string) []models.SensorReading {
} }
func parsePSUSummarySensors(text string) []models.SensorReading { func parsePSUSummarySensors(text string) []models.SensorReading {
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`) re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
match := re.FindStringSubmatch(text) match := re.FindStringSubmatch(text)
if match == nil { if match == nil {
return nil return nil
@@ -941,7 +1074,7 @@ func extractComponentFirmware(text string, hw *models.HardwareConfig) {
// Skip extracting from component.log to avoid duplicates // Skip extracting from component.log to avoid duplicates
// Extract PSU firmware from RESTful PSU info // Extract PSU firmware from RESTful PSU info
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`) rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
if match := rePSU.FindStringSubmatch(text); match != nil { if match := rePSU.FindStringSubmatch(text); match != nil {
jsonStr := strings.ReplaceAll(match[1], "\n", "") jsonStr := strings.ReplaceAll(match[1], "\n", "")
var psuInfo PSURESTInfo var psuInfo PSURESTInfo

View File

@@ -0,0 +1,83 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
const cpuMemComponentLog = `RESTful version info:
[]
RESTful CPU info:
{ "processors": [ { "proc_id": 0, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_used_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "47149E2253E81688", "status": "OK" }, { "proc_id": 1, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "475AC1221D41F557", "status": "OK" } ] }
RESTful Memory info:
{ "mem_modules": [ { "mem_mod_id": 0, "config_status": 1, "mem_mod_slot": "CPU0_C0D0", "mem_mod_status": 1, "mem_mod_size": 32, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "S1440202433526FC12", "mem_mod_ranks": 2, "status": "OK" }, { "mem_mod_id": 16, "config_status": 1, "mem_mod_slot": "CPU1_C0D0", "mem_mod_status": 1, "mem_mod_size": 0, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "K0UX000401205D2037", "mem_mod_ranks": 2, "status": "OK" } ], "total_memory_count": 2, "present_memory_count": 2, "mem_total_mem_size": 32 }
RESTful HDD info:
[]
RESTful PSU info:
{ "power_supplies": [] }
RESTful Network Adapter info:
{ "sys_adapters": [] }
RESTful fan info:
{ "fans": [] }
RESTful diskbackplane info:
[]
BMC done
`
func TestParseCPUInfo_FromComponentLog(t *testing.T) {
hw := &models.HardwareConfig{}
ParseComponentLog([]byte(cpuMemComponentLog), hw)
if len(hw.CPUs) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(hw.CPUs))
}
if !strings.Contains(hw.CPUs[0].Model, "Gold 6330") {
t.Errorf("unexpected CPU model: %s", hw.CPUs[0].Model)
}
if hw.CPUs[0].Cores != 28 {
t.Errorf("expected 28 cores, got %d", hw.CPUs[0].Cores)
}
if hw.CPUs[0].PPIN != "47149E2253E81688" {
t.Errorf("unexpected PPIN: %s", hw.CPUs[0].PPIN)
}
if hw.CPUs[1].PPIN != "475AC1221D41F557" {
t.Errorf("unexpected CPU1 PPIN: %s", hw.CPUs[1].PPIN)
}
}
func TestParseMemoryInfo_PresentWithZeroSize(t *testing.T) {
hw := &models.HardwareConfig{}
ParseComponentLog([]byte(cpuMemComponentLog), hw)
presentCount := 0
for _, m := range hw.Memory {
if m.Present {
presentCount++
}
}
if presentCount != 2 {
t.Errorf("expected 2 present DIMMs, got %d", presentCount)
}
// Find CPU1_C0D0 (size=0 but serial present — size should be inferred from same part number)
found := false
for _, m := range hw.Memory {
if m.Slot == "CPU1_C0D0" {
found = true
if !m.Present {
t.Error("CPU1_C0D0 should be Present=true despite size=0")
}
if m.SerialNumber != "K0UX000401205D2037" {
t.Errorf("wrong serial: %s", m.SerialNumber)
}
if m.SizeMB != 32768 {
t.Errorf("expected SizeMB=32768 inferred from part number, got %d", m.SizeMB)
}
}
}
if !found {
t.Error("CPU1_C0D0 not found in memory list")
}
}

View File

@@ -56,10 +56,12 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event)
} }
for _, e := range relevantEvents { for _, e := range relevantEvents {
// Deassert means the alarm was cleared: all GPUs return to OK.
isDeassert := strings.EqualFold(strings.TrimSpace(e.EventType), "Deassert")
faultySet := extractFaultyGPUSet(e.Description) faultySet := extractFaultyGPUSet(e.Description)
for idx, gpu := range gpuByIndex { for idx, gpu := range gpuByIndex {
newStatus := "OK" newStatus := "OK"
if faultySet[idx] { if !isDeassert && faultySet[idx] {
newStatus = "Critical" newStatus = "Critical"
lastCriticalDetails[idx] = strings.TrimSpace(e.Description) lastCriticalDetails[idx] = strings.TrimSpace(e.Description)
} }

View File

@@ -155,6 +155,40 @@ func TestApplyGPUStatusFromEvents_UsesLatestEventAsCurrentStatusAndKeepsHistory(
} }
} }
func TestApplyGPUStatusFromEvents_DeassertClearsAllGPUs(t *testing.T) {
hw := &models.HardwareConfig{
GPUs: []models.GPU{
{Slot: "#GPU1"},
{Slot: "#GPU3"},
{Slot: "#GPU5"},
{Slot: "#GPU6"},
},
}
events := []models.Event{
{
ID: "17FFB002",
EventType: "Assert",
Timestamp: time.Date(2026, 5, 27, 13, 6, 56, 0, time.FixedZone("UTC+8", 8*3600)),
Description: "PCIe Present mismatch BIOS Scan, BIOS miss F_GPU1 F_GPU3 F_GPU5 F_GPU6",
},
{
ID: "17FFB002",
EventType: "Deassert",
Timestamp: time.Date(2026, 5, 27, 13, 15, 56, 0, time.FixedZone("UTC+8", 8*3600)),
Description: "PCIe Present mismatch BIOS Scan, BIOS miss F_GPU1 F_GPU3 F_GPU5 F_GPU6",
},
}
applyGPUStatusFromEvents(hw, events)
for _, gpu := range hw.GPUs {
if gpu.Status != "OK" {
t.Fatalf("expected %s to recover to OK after Deassert, got %q", gpu.Slot, gpu.Status)
}
}
}
func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) { func TestParseIDLLog_ParsesStructuredJSONLine(t *testing.T) {
content := []byte(`{ "MESSAGE": "|2026-01-12T23:05:18+08:00|PCIE|Assert|Critical|17FFB002|PCIe Present mismatch BIOS miss F_GPU6 - Assert|" }`) content := []byte(`{ "MESSAGE": "|2026-01-12T23:05:18+08:00|PCIE|Assert|Critical|17FFB002|PCIe Present mismatch BIOS miss F_GPU6 - Assert|" }`)

View File

@@ -48,7 +48,7 @@ func ParseIDLLog(content []byte) []models.Event {
description = cleanDescription(description) description = cleanDescription(description)
// Create unique key for deduplication // Create unique key for deduplication
eventKey := eventID + "|" + description eventKey := eventID + "|" + eventType + "|" + description
if seenEvents[eventKey] { if seenEvents[eventKey] {
continue continue
} }

View File

@@ -16,7 +16,7 @@ import (
// parserVersion - version of this parser module // parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic! // IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.8" const parserVersion = "2.1"
func init() { func init() {
parser.Register(&Parser{}) parser.Register(&Parser{})
@@ -163,6 +163,26 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
// (fan RPM, backplane temperature, PSU summary power, etc.). // (fan RPM, backplane temperature, PSU summary power, etc.).
componentSensors := ParseComponentLogSensors(f.Content) componentSensors := ParseComponentLogSensors(f.Content)
result.Sensors = mergeSensorReadings(result.Sensors, componentSensors) result.Sensors = mergeSensorReadings(result.Sensors, componentSensors)
// Record sections where BMC itself returned an error instead of data,
// and mirror each one into the Events stream so they appear in the log viewer.
// Source is set to "BMC/<section>" so the viewer can show the specific module.
for _, ce := range ParseComponentLogCollectionErrors(f.Content) {
result.CollectionErrors = append(result.CollectionErrors, ce)
desc := ce.Message
if ce.Code != 0 {
desc = fmt.Sprintf("%s (code %d)", ce.Message, ce.Code)
}
result.Events = append(result.Events, models.Event{
ID: fmt.Sprintf("bmc_collection_error_%s", strings.ToLower(strings.ReplaceAll(ce.Section, " ", "_"))),
Timestamp: time.Time{}, // no timestamp available
Source: fmt.Sprintf("BMC/%s", ce.Section),
SensorType: "bmc_collection_error",
EventType: "Collection Error",
Severity: models.SeverityWarning,
Description: desc,
})
}
} }
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry), // Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
@@ -214,6 +234,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if result.Hardware != nil { if result.Hardware != nil {
applyGPUStatusFromEvents(result.Hardware, result.Events) applyGPUStatusFromEvents(result.Hardware, result.Events)
enrichStorageFromSerialFallbackFiles(files, result.Hardware) enrichStorageFromSerialFallbackFiles(files, result.Hardware)
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
enrichStorageFromSOLSmartd(files, result.Hardware)
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change). // Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
// These override redis/component.log serials which may be stale after disk replacement. // These override redis/component.log serials which may be stale after disk replacement.
applyRAIDSlotSerials(result.Hardware, raidSlotSerials) applyRAIDSlotSerials(result.Hardware, raidSlotSerials)

View File

@@ -0,0 +1,247 @@
package inspur
import (
"regexp"
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log.
// Example:
//
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB
var solSmartdDeviceRe = regexp.MustCompile(
`Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`,
)
type solSmartdDevice struct {
Model string
Serial string
Firmware string
SizeGB int
}
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
// Deduplicates by serial number (case-insensitive); preserves first-seen order.
func parseSOLSmartdDevices(content []byte) []solSmartdDevice {
seen := make(map[string]struct{})
var out []solSmartdDevice
for _, line := range strings.Split(string(content), "\n") {
m := solSmartdDeviceRe.FindStringSubmatch(line)
if m == nil {
continue
}
serial := strings.TrimSpace(m[2])
if serial == "" {
continue
}
key := strings.ToLower(serial)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sizeGB := parseSolSizeGB(m[4], m[5])
out = append(out, solSmartdDevice{
Model: strings.TrimSpace(m[1]),
Serial: serial,
Firmware: strings.TrimSpace(m[3]),
SizeGB: sizeGB,
})
}
return out
}
// parseSolSizeGB converts smartd size string ("480", "3.84") + unit ("GB", "TB") to integer GB.
// Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions.
func parseSolSizeGB(value, unit string) int {
f, err := strconv.ParseFloat(value, 64)
if err != nil || f <= 0 {
return 0
}
if strings.EqualFold(unit, "TB") {
f *= 1000
}
return int(f + 0.5)
}
// enrichStorageFromSOLSmartd enriches the storage inventory using disk info from
// SOLHostCapture.log (smartd startup messages). Both the log/ and runningdata/ copies
// are processed; serials are deduplicated across both files.
//
// Enrichment priority:
// 1. Exact model match to existing entries that are missing a serial.
// 2. Positional assignment to present placeholder slots (no model, no serial).
// 3. New entries added for any remaining devices.
func enrichStorageFromSOLSmartd(files []parser.ExtractedFile, hw *models.HardwareConfig) {
if hw == nil {
return
}
solFiles := parser.FindFileByPattern(files, "SOLHostCapture.log")
if len(solFiles) == 0 {
return
}
// Collect unique devices from all SOL log copies.
seenSerial := make(map[string]struct{})
var devices []solSmartdDevice
for _, f := range solFiles {
for _, d := range parseSOLSmartdDevices(f.Content) {
key := strings.ToLower(d.Serial)
if _, ok := seenSerial[key]; ok {
continue
}
seenSerial[key] = struct{}{}
devices = append(devices, d)
}
}
if len(devices) == 0 {
return
}
// Skip devices whose serial already appears in the storage inventory.
existingSerials := make(map[string]struct{}, len(hw.Storage))
for _, dev := range hw.Storage {
sn := strings.ToLower(strings.TrimSpace(dev.SerialNumber))
if sn != "" {
existingSerials[sn] = struct{}{}
}
}
var newDevices []solSmartdDevice
for _, d := range devices {
if _, ok := existingSerials[strings.ToLower(d.Serial)]; !ok {
newDevices = append(newDevices, d)
}
}
if len(newDevices) == 0 {
return
}
// Pass 1: enrich existing entries that match by model (first-match wins per device).
remaining := solEnrichByModel(hw, newDevices)
if len(remaining) == 0 {
return
}
// Pass 2: assign to present placeholder slots (present=true, no model, no serial).
remaining = solEnrichByPlaceholder(hw, remaining)
if len(remaining) == 0 {
return
}
// Pass 3: add as new storage entries without a slot assignment.
for _, d := range remaining {
hw.Storage = append(hw.Storage, solMakeStorage(d))
}
}
// solEnrichByModel fills SerialNumber (and optionally Firmware/SizeGB) on existing storage
// entries whose model matches the smartd model exactly. Returns unmatched devices.
func solEnrichByModel(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
var unmatched []solSmartdDevice
for _, d := range devices {
matched := false
for i := range hw.Storage {
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
continue
}
if !strings.EqualFold(strings.TrimSpace(hw.Storage[i].Model), d.Model) {
continue
}
hw.Storage[i].SerialNumber = d.Serial
if strings.TrimSpace(hw.Storage[i].Firmware) == "" {
hw.Storage[i].Firmware = d.Firmware
}
if hw.Storage[i].SizeGB == 0 {
hw.Storage[i].SizeGB = d.SizeGB
}
matched = true
break
}
if !matched {
unmatched = append(unmatched, d)
}
}
return unmatched
}
// solEnrichByPlaceholder assigns smartd devices to present storage entries that have
// neither a model nor a serial number, sorted by slot name. Returns unmatched devices.
func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
type slot struct {
index int
name string
}
var placeholders []slot
for i := range hw.Storage {
if !hw.Storage[i].Present {
continue
}
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
continue
}
if strings.TrimSpace(hw.Storage[i].Model) != "" {
continue
}
placeholders = append(placeholders, slot{index: i, name: hw.Storage[i].Slot})
}
sort.Slice(placeholders, func(i, j int) bool {
return placeholders[i].name < placeholders[j].name
})
pi := 0
var unmatched []solSmartdDevice
for _, d := range devices {
if pi >= len(placeholders) {
unmatched = append(unmatched, d)
continue
}
idx := placeholders[pi].index
pi++
hw.Storage[idx].SerialNumber = d.Serial
hw.Storage[idx].Model = d.Model
hw.Storage[idx].Firmware = d.Firmware
if hw.Storage[idx].SizeGB == 0 {
hw.Storage[idx].SizeGB = d.SizeGB
}
hw.Storage[idx].Type = solStorageType(d.Model)
if hw.Storage[idx].Manufacturer == "" {
hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model)
}
if hw.Storage[idx].Interface == "" {
hw.Storage[idx].Interface = "SATA"
}
}
return unmatched
}
func solMakeStorage(d solSmartdDevice) models.Storage {
return models.Storage{
Model: d.Model,
SerialNumber: d.Serial,
Firmware: d.Firmware,
SizeGB: d.SizeGB,
Type: solStorageType(d.Model),
Manufacturer: extractStorageManufacturer(d.Model),
Interface: "SATA",
Present: true,
}
}
// solStorageType infers SSD vs HDD from the model string.
// Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD".
func solStorageType(model string) string {
upper := strings.ToUpper(model)
if strings.Contains(upper, "SSD") ||
strings.HasPrefix(upper, "MTFDD") ||
strings.HasPrefix(upper, "MICRON_5") {
return "SSD"
}
return "HDD"
}

View File

@@ -0,0 +1,191 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const solSmartdSample = `
[ 17.219818] smartd[3321]: Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
[ 17.553024] smartd[3321]: Device: /dev/sdc [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F172DB3, WWN:5-00a075-14f172db3, FW:D4DK403, 3.84 TB
[ 17.553331] smartd[3321]: Device: /dev/sde [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC80F, WWN:5-00a075-1400dc80f, FW:D4CM003, 480 GB
[ 17.553709] smartd[3321]: Device: /dev/sdh [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F57DAB8, WWN:5-00a075-14f57dab8, FW:D4DK403, 3.84 TB
[ 17.886180] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.Micron-2310400DC7E3.ata.state
`
func TestParseSOLSmartdDevices_Dedup(t *testing.T) {
devices := parseSOLSmartdDevices([]byte(solSmartdSample))
if len(devices) != 4 {
t.Fatalf("expected 4 unique devices, got %d: %v", len(devices), devices)
}
// order matches first-seen
if devices[0].Serial != "2310400DC7E3" {
t.Errorf("first device serial: got %q, want 2310400DC7E3", devices[0].Serial)
}
if devices[0].SizeGB != 480 {
t.Errorf("first device size: got %d, want 480", devices[0].SizeGB)
}
if devices[1].SizeGB != 3840 {
t.Errorf("TB device size: got %d, want 3840", devices[1].SizeGB)
}
if devices[1].Firmware != "D4DK403" {
t.Errorf("firmware: got %q, want D4DK403", devices[1].Firmware)
}
}
func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) {
content := `
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state
[ 17.040843] smartd[3321]: Device: /dev/sda [SAT], not found in smartd database 7.3/5319.
[ 17.040865] smartd[3321]: Device: /dev/sda [SAT], is SMART capable. Adding to "monitor" list.
`
devices := parseSOLSmartdDevices([]byte(content))
if len(devices) != 0 {
t.Errorf("expected 0 devices, got %d", len(devices))
}
}
func TestParseSolSizeGB(t *testing.T) {
cases := []struct {
value, unit string
want int
}{
{"480", "GB", 480},
{"1.92", "TB", 1920},
{"3.84", "TB", 3840},
{"1", "TB", 1000},
{"0", "GB", 0},
}
for _, c := range cases {
got := parseSolSizeGB(c.value, c.unit)
if got != c.want {
t.Errorf("parseSolSizeGB(%q, %q) = %d, want %d", c.value, c.unit, got, c.want)
}
}
}
func TestSolStorageType(t *testing.T) {
cases := []struct {
model string
want string
}{
{"MTFDDAK3T8TGA-1BC1ZABDA", "SSD"},
{"Micron_5400_MTFDDAK480TGA", "SSD"},
{"INTEL SSDSC2KB019TZ", "SSD"},
{"SEAGATE ST4000NM0115", "HDD"},
}
for _, c := range cases {
got := solStorageType(c.model)
if got != c.want {
t.Errorf("solStorageType(%q) = %q, want %q", c.model, got, c.want)
}
}
}
func TestEnrichStorageFromSOLSmartd_ModelMatch(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
{Slot: "BP0:1", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
},
}
enrichStorageFromSOLSmartd(files, hw)
// The two existing slots must have received serials via model match.
for _, s := range hw.Storage[:2] {
if s.SerialNumber == "" {
t.Errorf("slot %q: expected serial to be assigned via model match", s.Slot)
}
if s.SizeGB != 3576 {
t.Errorf("slot %q: size should be preserved, got %d", s.Slot, s.SizeGB)
}
}
// The two unmatched Micron entries should be added as new storage entries.
if len(hw.Storage) != 4 {
t.Errorf("expected 4 total storage entries (2 existing + 2 new Micron), got %d", len(hw.Storage))
}
}
func TestEnrichStorageFromSOLSmartd_PlaceholderSlots(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", Present: true},
{Slot: "BP0:1", Present: true},
},
}
enrichStorageFromSOLSmartd(files, hw)
for _, s := range hw.Storage {
if s.SerialNumber == "" {
t.Errorf("slot %q: expected serial to be assigned", s.Slot)
}
if s.Model == "" {
t.Errorf("slot %q: expected model to be assigned", s.Slot)
}
}
}
func TestEnrichStorageFromSOLSmartd_SkipsExistingSerial(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", SerialNumber: "2310400DC7E3", Present: true},
},
}
before := len(hw.Storage)
enrichStorageFromSOLSmartd(files, hw)
// BP0:0 should still have original serial unchanged
if hw.Storage[0].SerialNumber != "2310400DC7E3" {
t.Errorf("existing serial was changed: got %q", hw.Storage[0].SerialNumber)
}
// Remaining 3 devices should be added as new entries
if len(hw.Storage) <= before {
t.Errorf("expected new entries to be added, got %d (same as before)", len(hw.Storage))
}
}
func TestEnrichStorageFromSOLSmartd_MergesTwoFiles(t *testing.T) {
// Two SOL files with partial overlap; combined unique serials = 3
file1 := `[ 17.0] smartd[1]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`
file2 := strings.Join([]string{
`[ 17.0] smartd[2]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`,
`[ 17.1] smartd[2]: Device: /dev/sdb [SAT], ModelB, S/N:SN002, WWN:w, FW:fw2, 480 GB`,
`[ 17.2] smartd[2]: Device: /dev/sdc [SAT], ModelC, S/N:SN003, WWN:w, FW:fw3, 480 GB`,
}, "\n")
files := []parser.ExtractedFile{
{Path: "log/sollog/SOLHostCapture.log", Content: []byte(file1)},
{Path: "runningdata/var/sollog/SOLHostCapture.log", Content: []byte(file2)},
}
hw := &models.HardwareConfig{}
enrichStorageFromSOLSmartd(files, hw)
if len(hw.Storage) != 3 {
t.Fatalf("expected 3 unique storage entries, got %d", len(hw.Storage))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
package lenovo_xcc
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const exampleArchive = "/Users/mchusavitin/Documents/git/logpile/example/7D76CTO1WW_JF0002KT_xcc_mini-log_20260413-122150.zip"
func TestDetect_LenovoXCCMiniLog(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
score := p.Detect(files)
if score < 80 {
t.Errorf("expected Detect score >= 80 for XCC mini-log archive, got %d", score)
}
}
func TestParse_LenovoXCCMiniLog_BasicSysInfo(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, err := p.Parse(files)
if err != nil {
t.Fatalf("Parse returned error: %v", err)
}
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil result or hardware")
}
hw := result.Hardware
if hw.BoardInfo.SerialNumber == "" {
t.Error("BoardInfo.SerialNumber is empty")
}
if hw.BoardInfo.ProductName == "" {
t.Error("BoardInfo.ProductName is empty")
}
t.Logf("BoardInfo: serial=%s model=%s uuid=%s", hw.BoardInfo.SerialNumber, hw.BoardInfo.ProductName, hw.BoardInfo.UUID)
}
func TestParse_LenovoXCCMiniLog_CPUs(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.CPUs) == 0 {
t.Error("expected at least one CPU, got none")
}
for i, cpu := range result.Hardware.CPUs {
t.Logf("CPU[%d]: socket=%d model=%q cores=%d threads=%d freq=%dMHz", i, cpu.Socket, cpu.Model, cpu.Cores, cpu.Threads, cpu.FrequencyMHz)
}
}
func TestParse_LenovoXCCMiniLog_Memory(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.Memory) == 0 {
t.Error("expected memory DIMMs, got none")
}
t.Logf("Memory: %d DIMMs", len(result.Hardware.Memory))
for i, m := range result.Hardware.Memory {
t.Logf("DIMM[%d]: slot=%s present=%v size=%dMB sn=%s", i, m.Slot, m.Present, m.SizeMB, m.SerialNumber)
}
}
func TestParse_LenovoXCCMiniLog_Storage(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
t.Logf("Storage: %d disks", len(result.Hardware.Storage))
for i, s := range result.Hardware.Storage {
t.Logf("Disk[%d]: slot=%s model=%q size=%dGB sn=%s", i, s.Slot, s.Model, s.SizeGB, s.SerialNumber)
}
}
func TestParse_LenovoXCCMiniLog_PCIeCards(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
t.Logf("PCIe cards: %d", len(result.Hardware.PCIeDevices))
for i, c := range result.Hardware.PCIeDevices {
t.Logf("Card[%d]: slot=%s desc=%q bdf=%s", i, c.Slot, c.Description, c.BDF)
}
}
func TestParse_LenovoXCCMiniLog_PSUs(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.PowerSupply) == 0 {
t.Error("expected PSUs, got none")
}
for i, p := range result.Hardware.PowerSupply {
t.Logf("PSU[%d]: slot=%s wattage=%dW status=%s sn=%s", i, p.Slot, p.WattageW, p.Status, p.SerialNumber)
}
}
func TestParse_LenovoXCCMiniLog_Sensors(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
}
if len(result.Sensors) == 0 {
t.Error("expected sensors, got none")
}
t.Logf("Sensors: %d", len(result.Sensors))
}
func TestParse_LenovoXCCMiniLog_Events(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
}
if len(result.Events) == 0 {
t.Error("expected events, got none")
}
t.Logf("Events: %d", len(result.Events))
for i, e := range result.Events {
if i >= 5 {
break
}
t.Logf("Event[%d]: severity=%s ts=%s desc=%q", i, e.Severity, e.Timestamp.Format("2006-01-02T15:04:05"), e.Description)
}
}
func TestParse_LenovoXCCMiniLog_FRU(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil {
t.Fatal("Parse returned nil")
}
t.Logf("FRU: %d entries", len(result.FRU))
for i, f := range result.FRU {
t.Logf("FRU[%d]: desc=%q product=%q serial=%q", i, f.Description, f.ProductName, f.SerialNumber)
}
}
func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.Firmware) == 0 {
t.Error("expected firmware entries, got none")
}
for i, f := range result.Hardware.Firmware {
t.Logf("FW[%d]: name=%q version=%q buildtime=%q", i, f.DeviceName, f.Version, f.BuildTime)
}
}
func TestParse_LenovoXCCMiniLog_VROCVolumes(t *testing.T) {
files, err := parser.ExtractArchive(exampleArchive)
if err != nil {
t.Skipf("example archive not available: %v", err)
}
p := &Parser{}
result, _ := p.Parse(files)
if result == nil || result.Hardware == nil {
t.Fatal("Parse returned nil")
}
if len(result.Hardware.Volumes) == 0 {
t.Error("expected at least one VROC volume, got none")
}
for i, v := range result.Hardware.Volumes {
t.Logf("Volume[%d]: id=%s controller=%q raid=%s size=%dGB status=%s drives=%v",
i, v.ID, v.Controller, v.RAIDLevel, v.SizeGB, v.Status, v.Drives)
if v.RAIDLevel == "" {
t.Errorf("Volume[%d]: RAIDLevel is empty", i)
}
if v.Status == "" {
t.Errorf("Volume[%d]: Status is empty", i)
}
}
}
func TestParseVolumes_IntelVROC(t *testing.T) {
content := []byte(`{
"identifier": "storage.id",
"items": [{
"volumes": [{
"id": 1,
"name": "",
"drives": "M.2 Drive 0, M.2 Drive 1",
"rdlvlstr": "RAID 1",
"capacityStr": "893.750 GiB",
"status": 3,
"statusStr": "Optimal"
}],
"totalCapacityStr": "893.750 GiB"
}]
}`)
vols := parseVolumes(content)
if len(vols) != 1 {
t.Fatalf("expected 1 volume, got %d", len(vols))
}
v := vols[0]
if v.ID != "1" {
t.Errorf("expected ID=1, got %q", v.ID)
}
if v.RAIDLevel != "RAID 1" {
t.Errorf("expected RAIDLevel=RAID 1, got %q", v.RAIDLevel)
}
if v.Status != "Optimal" {
t.Errorf("expected Status=Optimal, got %q", v.Status)
}
if v.Controller != "Intel VROC" {
t.Errorf("expected Controller=Intel VROC, got %q", v.Controller)
}
if len(v.Drives) != 2 {
t.Errorf("expected 2 drives, got %d: %v", len(v.Drives), v.Drives)
}
if v.SizeGB < 900 || v.SizeGB > 1000 {
t.Errorf("expected SizeGB ~960, got %d", v.SizeGB)
}
}
func TestParseDIMMs_UnqualifiedDIMMAddsWarningEvent(t *testing.T) {
content := []byte(`{
"items": [{
"memory": [{
"memory_name": "DIMM A1",
"memory_status": "Unqualified DIMM",
"memory_type": "DDR5",
"memory_capacity": 32
}]
}]
}`)
memory, events := parseDIMMs(content)
if len(memory) != 1 {
t.Fatalf("expected 1 DIMM, got %d", len(memory))
}
if len(events) != 1 {
t.Fatalf("expected 1 warning event, got %d", len(events))
}
if events[0].Severity != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", events[0].Severity)
}
if events[0].SensorName != "DIMM A1" {
t.Fatalf("unexpected sensor name: %q", events[0].SensorName)
}
}
func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
if got := xccSeverity("I", "System found Unqualified DIMM in slot DIMM A1"); got != models.SeverityWarning {
t.Fatalf("expected warning severity, got %q", got)
}
}
func TestApplyDIMMWarningsFromEvents_UpdatesDIMMStatusForExport(t *testing.T) {
result := &models.AnalysisResult{
Events: []models.Event{
{
Timestamp: time.Date(2026, 4, 13, 11, 37, 38, 0, time.UTC),
Severity: models.SeverityWarning,
Description: "Unqualified DIMM 3 has been detected, the DIMM serial number is 80CE042328460C5D88-V20.",
},
},
Hardware: &models.HardwareConfig{
Memory: []models.MemoryDIMM{
{
Slot: "DIMM 3",
Present: true,
SerialNumber: "80CE042328460C5D88",
Status: "Normal",
},
},
},
}
applyDIMMWarningsFromEvents(result)
dimm := result.Hardware.Memory[0]
if dimm.Status != "Warning" {
t.Fatalf("expected DIMM status Warning, got %q", dimm.Status)
}
if dimm.ErrorDescription == "" || dimm.ErrorDescription != result.Events[0].Description {
t.Fatalf("expected DIMM error description to be populated, got %q", dimm.ErrorDescription)
}
if dimm.StatusChangedAt == nil || !dimm.StatusChangedAt.Equal(result.Events[0].Timestamp) {
t.Fatalf("expected status_changed_at from event timestamp, got %#v", dimm.StatusChangedAt)
}
if len(dimm.StatusHistory) != 1 || dimm.StatusHistory[0].Status != "Warning" {
t.Fatalf("expected warning status history entry, got %#v", dimm.StatusHistory)
}
}
func TestParseBasicSysInfo_CleansPlaceholderValuesAndSetsTargetHost(t *testing.T) {
result := &models.AnalysisResult{Hardware: &models.HardwareConfig{}}
content := []byte(`{
"items": [{
"machine_name": " sr650v3-node01 ",
"machine_typemodel": " 7D76CTO1WW ",
"serial_number": " Not Specified ",
"uuid": "N/A"
}]
}`)
parseBasicSysInfo(content, result)
if result.TargetHost != "sr650v3-node01" {
t.Fatalf("unexpected target host: %q", result.TargetHost)
}
if result.Hardware.BoardInfo.ProductName != "7D76CTO1WW" {
t.Fatalf("unexpected product name: %q", result.Hardware.BoardInfo.ProductName)
}
if result.Hardware.BoardInfo.SerialNumber != "" {
t.Fatalf("expected serial number to be cleaned, got %q", result.Hardware.BoardInfo.SerialNumber)
}
if result.Hardware.BoardInfo.UUID != "" {
t.Fatalf("expected UUID to be cleaned, got %q", result.Hardware.BoardInfo.UUID)
}
}
func TestEnrichBoardFromFRU_SystemBoardManufacturerOnly(t *testing.T) {
result := &models.AnalysisResult{
Hardware: &models.HardwareConfig{},
FRU: []models.FRUInfo{
{Description: "Power Supply 1", Manufacturer: "Ignore Me"},
{Description: "System Board", Manufacturer: " Lenovo "},
},
}
enrichBoardFromFRU(result)
if result.Hardware.BoardInfo.Manufacturer != "Lenovo" {
t.Fatalf("unexpected manufacturer: %q", result.Hardware.BoardInfo.Manufacturer)
}
}
func TestEnrichPSUsFromSensors_AssignsTelemetryBySlot(t *testing.T) {
psus := []models.PSU{
{Slot: "1"},
{Slot: "2"},
}
sensors := []models.SensorReading{
{Name: "PSU1 Input Power", Value: 430},
{Name: "Power Supply 1 Output Power", Value: 390},
{Name: "PWS1 AC Voltage", Value: 230.5},
{Name: "PSU2 Input Power", Value: 0},
{Name: "PSU3 Input Power", Value: 999},
{Name: "Fan 1", Value: 12000},
}
got := enrichPSUsFromSensors(psus, sensors)
if got[0].InputPowerW != 430 {
t.Fatalf("unexpected PSU1 input power: %d", got[0].InputPowerW)
}
if got[0].OutputPowerW != 390 {
t.Fatalf("unexpected PSU1 output power: %d", got[0].OutputPowerW)
}
if got[0].InputVoltage != 230.5 {
t.Fatalf("unexpected PSU1 input voltage: %v", got[0].InputVoltage)
}
if got[1].InputPowerW != 0 || got[1].OutputPowerW != 0 || got[1].InputVoltage != 0 {
t.Fatalf("unexpected telemetry assigned to PSU2: %+v", got[1])
}
}
func TestMapDiskHealthStatus(t *testing.T) {
tests := []struct {
name string
code int
stateStr string
want string
}{
{name: "normal", code: 2, stateStr: "Online", want: "OK"},
{name: "warning", code: 1, stateStr: "Online", want: "Warning"},
{name: "predictive failure", code: 4, stateStr: "Online", want: "Warning"},
{name: "critical", code: 3, stateStr: "Failed", want: "Critical"},
{name: "fallback state", code: 0, stateStr: "Rebuilding", want: "Rebuilding"},
{name: "unknown", code: 0, stateStr: "", want: "Unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := mapDiskHealthStatus(tt.code, tt.stateStr); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestClassifySensorType(t *testing.T) {
tests := []struct {
name string
in string
unit string
want string
}{
{name: "unit rpm", in: "Fan 1", unit: "RPM", want: "fan"},
{name: "unit celsius", in: "CPU Temp", unit: "C", want: "temperature"},
{name: "unit watts", in: "PSU1 Input Power", unit: "W", want: "power"},
{name: "unit volts", in: "PWS1 AC Voltage", unit: "V", want: "voltage"},
{name: "unit amps", in: "PSU1 Current", unit: "A", want: "current"},
{name: "name fallback", in: "GPU Temp", unit: "", want: "temperature"},
{name: "other", in: "Presence", unit: "", want: "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifySensorType(tt.in, tt.unit); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestCleanXCCValue(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: " Lenovo ", want: "Lenovo"},
{in: "N/A", want: ""},
{in: " not specified ", want: ""},
{in: "-", want: ""},
}
for _, tt := range tests {
if got := cleanXCCValue(tt.in); got != tt.want {
t.Fatalf("cleanXCCValue(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,10 @@ func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) {
t.Fatalf("expected chart title in body, got %q", body) t.Fatalf("expected chart title in body, got %q", body)
} }
if !strings.Contains(body, `/chart/static/view.css`) { if !strings.Contains(body, `/chart/static/view.css`) {
t.Fatalf("expected rewritten chart static path, got %q", body) t.Fatalf("expected rewritten chart css path, got %q", body)
}
if !strings.Contains(body, `/chart/static/view.js`) {
t.Fatalf("expected rewritten chart js path, got %q", body)
} }
if !strings.Contains(body, "Snapshot Metadata") { if !strings.Contains(body, "Snapshot Metadata") {
t.Fatalf("expected rendered chart output, got %q", body) t.Fatalf("expected rendered chart output, got %q", body)

View File

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

View File

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

View File

@@ -19,8 +19,6 @@ type CollectRequest struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"` TLSMode string `json:"tls_mode"`
PowerOnIfHostOff bool `json:"power_on_if_host_off,omitempty"`
StopHostAfterCollect bool `json:"stop_host_after_collect,omitempty"`
DebugPayloads bool `json:"debug_payloads,omitempty"` DebugPayloads bool `json:"debug_payloads,omitempty"`
} }
@@ -29,7 +27,6 @@ type CollectProbeResponse struct {
Protocol string `json:"protocol,omitempty"` Protocol string `json:"protocol,omitempty"`
HostPowerState string `json:"host_power_state,omitempty"` HostPowerState string `json:"host_power_state,omitempty"`
HostPoweredOn bool `json:"host_powered_on"` HostPoweredOn bool `json:"host_powered_on"`
PowerControlAvailable bool `json:"power_control_available"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
@@ -42,12 +39,15 @@ type CollectJobResponse struct {
type CollectJobStatusResponse struct { type CollectJobStatusResponse struct {
JobID string `json:"job_id"` JobID string `json:"job_id"`
Type string `json:"type,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Progress *int `json:"progress,omitempty"` Progress *int `json:"progress,omitempty"`
Message string `json:"message,omitempty"`
CurrentPhase string `json:"current_phase,omitempty"` CurrentPhase string `json:"current_phase,omitempty"`
ETASeconds *int `json:"eta_seconds,omitempty"` ETASeconds *int `json:"eta_seconds,omitempty"`
Logs []string `json:"logs,omitempty"` Logs []string `json:"logs,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Result map[string]interface{} `json:"result,omitempty"`
ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"` ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"`
ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"` ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"`
DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"` DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"`
@@ -66,12 +66,15 @@ type CollectRequestMeta struct {
type Job struct { type Job struct {
ID string ID string
Type string
Status string Status string
Progress int Progress int
Message string
CurrentPhase string CurrentPhase string
ETASeconds int ETASeconds int
Logs []string Logs []string
Error string Error string
Result map[string]interface{}
ActiveModules []CollectModuleStatus ActiveModules []CollectModuleStatus
ModuleScores []CollectModuleStatus ModuleScores []CollectModuleStatus
DebugInfo *CollectDebugInfo DebugInfo *CollectDebugInfo
@@ -79,6 +82,7 @@ type Job struct {
UpdatedAt time.Time UpdatedAt time.Time
RequestMeta CollectRequestMeta RequestMeta CollectRequestMeta
cancel func() cancel func()
skipFn func()
} }
type CollectModuleStatus struct { type CollectModuleStatus struct {
@@ -109,11 +113,14 @@ func (j *Job) toStatusResponse() CollectJobStatusResponse {
progress := j.Progress progress := j.Progress
resp := CollectJobStatusResponse{ resp := CollectJobStatusResponse{
JobID: j.ID, JobID: j.ID,
Type: j.Type,
Status: j.Status, Status: j.Status,
Progress: &progress, Progress: &progress,
Message: j.Message,
CurrentPhase: j.CurrentPhase, CurrentPhase: j.CurrentPhase,
Logs: append([]string(nil), j.Logs...), Logs: append([]string(nil), j.Logs...),
Error: j.Error, Error: j.Error,
Result: j.Result,
ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...), ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...),
ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...), ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...),
DebugInfo: cloneCollectDebugInfo(j.DebugInfo), DebugInfo: cloneCollectDebugInfo(j.DebugInfo),

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -37,30 +38,39 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
tmplContent, err := WebFS.ReadFile("templates/index.html") tmplContent, err := WebFS.ReadFile("templates/index.html")
if err != nil { if err != nil {
http.Error(w, "Template not found", http.StatusInternalServerError) s.htmlError(w, "Template not found", http.StatusInternalServerError)
return return
} }
tmpl, err := template.New("index").Parse(string(tmplContent)) tmpl, err := template.New("index").Parse(string(tmplContent))
if err != nil { if err != nil {
http.Error(w, "Template parse error", http.StatusInternalServerError) s.htmlError(w, "Template parse error", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, map[string]string{ tmpl.Execute(w, map[string]string{
"AppVersion": s.config.AppVersion, "AppVersion": normalizeDisplayVersion(s.config.AppVersion),
"AppCommit": s.config.AppCommit, "AppCommit": s.config.AppCommit,
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
}) })
} }
func normalizeDisplayVersion(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
return strings.TrimPrefix(v, "v")
}
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) { func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
title := chartTitle(result) title := chartTitle(result)
if result == nil || result.Hardware == nil { if result == nil || result.Hardware == nil {
html, err := chartviewer.RenderHTML(nil, title) html, err := chartviewer.RenderHTML(nil, title)
if err != nil { if err != nil {
http.Error(w, "failed to render viewer", http.StatusInternalServerError) s.htmlError(w, "failed to render viewer", http.StatusInternalServerError)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -70,13 +80,13 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
snapshotBytes, err := currentReanimatorSnapshotBytes(result) snapshotBytes, err := currentReanimatorSnapshotBytes(result)
if err != nil { if err != nil {
http.Error(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError) s.htmlError(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
return return
} }
html, err := chartviewer.RenderHTML(snapshotBytes, title) html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
if err != nil { if err != nil {
http.Error(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError) s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
return return
} }
@@ -127,7 +137,9 @@ func chartTitle(result *models.AnalysisResult) string {
} }
func rewriteChartStaticPaths(html []byte) []byte { func rewriteChartStaticPaths(html []byte) []byte {
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`)) html = bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
html = bytes.ReplaceAll(html, []byte(`src="/static/view.js"`), []byte(`src="/chart/static/view.js"`))
return html
} }
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
@@ -382,7 +394,7 @@ func uniqueSortedExtensions(exts []string) []string {
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
if result == nil { if result == nil {
jsonResponse(w, []interface{}{}) jsonList(w, []interface{}{}, 0)
return return
} }
@@ -395,18 +407,18 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
return events[i].Timestamp.After(events[j].Timestamp) return events[i].Timestamp.After(events[j].Timestamp)
}) })
jsonResponse(w, events) jsonList(w, events, len(events))
} }
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
if result == nil { if result == nil {
jsonResponse(w, []interface{}{}) jsonList(w, []interface{}{}, 0)
return return
} }
sensors := append([]models.SensorReading{}, result.Sensors...) sensors := append([]models.SensorReading{}, result.Sensors...)
sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...) sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...)
jsonResponse(w, sensors) jsonList(w, sensors, len(sensors))
} }
func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading { func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading {
@@ -520,7 +532,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
float64(cpu.FrequencyMHz)/1000, float64(cpu.FrequencyMHz)/1000,
cpu.Cores, cpu.Cores,
intFromDetails(cpu.Details, "tdp_w")) intFromDetails(cpu.Details, "tdp_w"))
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count}) spec = append(spec, SpecLine{Category: "CPU", Name: name, Quantity: count})
} }
// Memory - group by size, type and frequency (only installed modules) // Memory - group by size, type and frequency (only installed modules)
@@ -555,7 +567,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
memGroups[key]++ memGroups[key]++
} }
for key, count := range memGroups { for key, count := range memGroups {
spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count}) spec = append(spec, SpecLine{Category: "Memory", Name: key, Quantity: count})
} }
// Storage - group by type and capacity // Storage - group by type and capacity
@@ -573,7 +585,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
storGroups[key]++ storGroups[key]++
} }
for key, count := range storGroups { for key, count := range storGroups {
spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count}) spec = append(spec, SpecLine{Category: "Storage", Name: key, Quantity: count})
} }
// PCIe devices - group by device class/name and manufacturer // PCIe devices - group by device class/name and manufacturer
@@ -596,7 +608,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
} }
for key, count := range pcieGroups { for key, count := range pcieGroups {
pcie := pcieDetails[key] pcie := pcieDetails[key]
category := "PCIe устройство" category := "PCIe Device"
name := key name := key
// Determine category based on device class or known GPU names // Determine category based on device class or known GPU names
@@ -605,11 +617,11 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX") isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
if isGPU { if isGPU {
category = "Графический процессор" category = "GPU"
} else if isNetwork { } else if isNetwork {
category = "Сетевой адаптер" category = "Network Adapter"
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" { } else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
category = "Контроллер" category = "Controller"
} }
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count}) spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
@@ -630,7 +642,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
} }
} }
for key, count := range psuGroups { for key, count := range psuGroups {
spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count}) spec = append(spec, SpecLine{Category: "Power Supply", Name: key, Quantity: count})
} }
return spec return spec
@@ -651,7 +663,7 @@ func nonEmptyStrings(values ...string) []string {
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
if result == nil { if result == nil {
jsonResponse(w, []interface{}{}) jsonList(w, []interface{}{}, 0)
return return
} }
@@ -701,7 +713,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
} }
} }
jsonResponse(w, serials) jsonList(w, serials, len(serials))
} }
func normalizePCIeSerialComponentName(p models.PCIeDevice) string { func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
@@ -755,11 +767,12 @@ func hasUsableFirmwareVersion(version string) bool {
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
if result == nil || result.Hardware == nil { if result == nil || result.Hardware == nil {
jsonResponse(w, []interface{}{}) jsonList(w, []interface{}{}, 0)
return return
} }
jsonResponse(w, buildFirmwareEntries(result.Hardware)) entries := buildFirmwareEntries(result.Hardware)
jsonList(w, entries, len(entries))
} }
type parseErrorEntry struct { type parseErrorEntry struct {
@@ -844,6 +857,28 @@ func (s *Server) handleGetParseErrors(w http.ResponseWriter, r *http.Request) {
} }
} }
// BMC-reported collection failures surfaced by vendor parsers.
if result != nil {
for _, ce := range result.CollectionErrors {
msg := strings.TrimSpace(ce.Message)
if msg == "" {
continue
}
detail := ""
if ce.Code != 0 {
detail = fmt.Sprintf("code %d", ce.Code)
}
add(parseErrorEntry{
Source: "bmc",
Category: "bmc_collection_error",
Severity: "warning",
Path: ce.Section,
Message: msg,
Detail: detail,
})
}
}
sort.Slice(items, func(i, j int) bool { sort.Slice(items, func(i, j int) bool {
if items[i].Severity != items[j].Severity { if items[i].Severity != items[j].Severity {
// error > warning > info // error > warning > info
@@ -906,8 +941,7 @@ func looksLikeErrorLogLine(line string) bool {
if s == "" { if s == "" {
return false return false
} }
return strings.Contains(s, "ошибка") || return strings.Contains(s, "error") ||
strings.Contains(s, "error") ||
strings.Contains(s, "failed") || strings.Contains(s, "failed") ||
strings.Contains(s, "timeout") || strings.Contains(s, "timeout") ||
strings.Contains(s, "deadline exceeded") strings.Contains(s, "deadline exceeded")
@@ -942,7 +976,7 @@ func parseErrorSeverityFromMessage(msg string) string {
if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") { if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") {
return "info" return "info"
} }
if strings.Contains(s, "ошибка") || strings.Contains(s, "error") || strings.Contains(s, "failed") { if strings.Contains(s, "error") || strings.Contains(s, "failed") {
return "warning" return "warning"
} }
return "info" return "info"
@@ -1200,6 +1234,13 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
exp.ExportCSV(w) exp.ExportCSV(w)
} }
func (s *Server) handleExportLogsCSV(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "logs.csv")))
exporter.ExportLogsCSV(w, result)
}
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()
@@ -1281,7 +1322,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
tempDir, err := os.MkdirTemp("", "logpile-convert-input-*") tempDir, err := os.MkdirTemp("", "logpile-convert-input-*")
if err != nil { if err != nil {
jsonError(w, "Не удалось создать временную директорию", http.StatusInternalServerError) jsonError(w, "Failed to create temp directory", http.StatusInternalServerError)
return return
} }
@@ -1328,7 +1369,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
if len(inputFiles) == 0 { if len(inputFiles) == 0 {
_ = os.RemoveAll(tempDir) _ = os.RemoveAll(tempDir)
jsonError(w, "Нет файлов поддерживаемого типа для конвертации", http.StatusBadRequest) jsonError(w, "No supported files to convert", http.StatusBadRequest)
return return
} }
@@ -1341,9 +1382,9 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
TLSMode: "insecure", TLSMode: "insecure",
}) })
s.markConvertJob(job.ID) s.markConvertJob(job.ID)
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Запущена пакетная конвертация: %d файлов", len(inputFiles))) s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Batch conversion started: %d files", len(inputFiles)))
if skipped > 0 { if skipped > 0 {
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Пропущено неподдерживаемых файлов: %d", skipped)) s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Skipped unsupported files: %d", skipped))
} }
s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "") s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "")
@@ -1371,7 +1412,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip") resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip")
if err != nil { if err != nil {
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "не удалось создать zip") s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "failed to create zip")
return return
} }
resultPath := resultFile.Name() resultPath := resultFile.Name()
@@ -1383,7 +1424,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
totalProcess := len(inputFiles) totalProcess := len(inputFiles)
for i, in := range inputFiles { for i, in := range inputFiles {
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Обработка %s", in.Name)) s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Processing %s", in.Name))
payload, err := os.ReadFile(in.Path) payload, err := os.ReadFile(in.Path)
if err != nil { if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err)) failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
@@ -1436,13 +1477,13 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
if success == 0 { if success == 0 {
_ = zw.Close() _ = zw.Close()
_ = os.Remove(resultPath) _ = os.Remove(resultPath)
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось конвертировать ни один файл") s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to convert any file")
return return
} }
summaryLines := []string{fmt.Sprintf("Конвертировано %d из %d файлов", success, total)} summaryLines := []string{fmt.Sprintf("Converted %d of %d files", success, total)}
if skipped > 0 { if skipped > 0 {
summaryLines = append(summaryLines, fmt.Sprintf("Пропущено неподдерживаемых: %d", skipped)) summaryLines = append(summaryLines, fmt.Sprintf("Skipped unsupported: %d", skipped))
} }
summaryLines = append(summaryLines, failures...) summaryLines = append(summaryLines, failures...)
if entry, err := zw.Create("convert-summary.txt"); err == nil { if entry, err := zw.Create("convert-summary.txt"); err == nil {
@@ -1450,7 +1491,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
} }
if err := zw.Close(); err != nil { if err := zw.Close(); err != nil {
_ = os.Remove(resultPath) _ = os.Remove(resultPath)
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось упаковать результаты") s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to pack results")
return return
} }
@@ -1603,7 +1644,7 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
} }
job := s.jobManager.CreateJob(req) job := s.jobManager.CreateJob(req)
s.jobManager.AppendJobLog(job.ID, "Клиент: "+s.ClientVersionString()) s.jobManager.AppendJobLog(job.ID, "Client: "+s.ClientVersionString())
s.startCollectionJob(job.ID, req) s.startCollectionJob(job.ID, req)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1632,7 +1673,7 @@ func pingHost(host string, port int, total, need int) (bool, string) {
} }
n := int(successes.Load()) n := int(successes.Load())
if n < need { if n < need {
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need) return false, fmt.Sprintf("Host unreachable: only %d of %d connection attempts to %s succeeded (minimum required: %d)", n, total, addr, need)
} }
return true, "" return true, ""
} }
@@ -1649,12 +1690,12 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
} }
connector, ok := s.getCollector(req.Protocol) connector, ok := s.getCollector(req.Protocol)
if !ok { if !ok {
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest) jsonError(w, "Protocol connector not registered", http.StatusBadRequest)
return return
} }
prober, ok := connector.(collector.Prober) prober, ok := connector.(collector.Prober)
if !ok { if !ok {
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest) jsonError(w, "Connection probe not supported for this protocol", http.StatusBadRequest)
return return
} }
@@ -1668,31 +1709,26 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
result, err := prober.Probe(ctx, toCollectorRequest(req)) result, err := prober.Probe(ctx, toCollectorRequest(req))
if err != nil { if err != nil {
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest) jsonError(w, "Connection probe failed: "+err.Error(), http.StatusBadRequest)
return return
} }
message := "Связь с BMC установлена" message := "BMC connection established"
if result != nil { if result != nil {
switch { if result.HostPoweredOn {
case !result.HostPoweredOn && result.PowerControlAvailable: message = "BMC connection established, host is powered on."
message = "Связь с BMC установлена, host выключен. Можно включить перед сбором." } else {
case !result.HostPoweredOn: message = "BMC connection established, host is powered off. Inventory data may be incomplete."
message = "Связь с BMC установлена, host выключен."
default:
message = "Связь с BMC установлена, host включен."
} }
} }
hostPowerState := "" hostPowerState := ""
hostPoweredOn := false hostPoweredOn := false
powerControlAvailable := false
reachable := false reachable := false
if result != nil { if result != nil {
reachable = result.Reachable reachable = result.Reachable
hostPowerState = strings.TrimSpace(result.HostPowerState) hostPowerState = strings.TrimSpace(result.HostPowerState)
hostPoweredOn = result.HostPoweredOn hostPoweredOn = result.HostPoweredOn
powerControlAvailable = result.PowerControlAvailable
} }
jsonResponse(w, CollectProbeResponse{ jsonResponse(w, CollectProbeResponse{
@@ -1700,7 +1736,6 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
Protocol: req.Protocol, Protocol: req.Protocol,
HostPowerState: hostPowerState, HostPowerState: hostPowerState,
HostPoweredOn: hostPoweredOn, HostPoweredOn: hostPoweredOn,
PowerControlAvailable: powerControlAvailable,
Message: message, Message: message,
}) })
} }
@@ -1737,6 +1772,22 @@ func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, job.toStatusResponse()) jsonResponse(w, job.toStatusResponse())
} }
func (s *Server) handleCollectSkip(w http.ResponseWriter, r *http.Request) {
jobID := strings.TrimSpace(r.PathValue("id"))
if !isValidCollectJobID(jobID) {
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
return
}
job, ok := s.jobManager.SkipJob(jobID)
if !ok {
jsonError(w, "Collect job not found", http.StatusNotFound)
return
}
jsonResponse(w, job.toStatusResponse())
}
func (s *Server) startCollectionJob(jobID string, req CollectRequest) { func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached { if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
@@ -1744,11 +1795,16 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
return return
} }
skipCh := make(chan struct{})
var skipOnce sync.Once
skipFn := func() { skipOnce.Do(func() { close(skipCh) }) }
s.jobManager.AttachJobSkip(jobID, skipFn)
go func() { go func() {
connector, ok := s.getCollector(req.Protocol) connector, ok := s.getCollector(req.Protocol)
if !ok { if !ok {
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован") s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Protocol connector not registered")
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") s.jobManager.AppendJobLog(jobID, "Collection completed with error")
return return
} }
@@ -1811,7 +1867,9 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
} }
} }
result, err := connector.Collect(ctx, toCollectorRequest(req), emitProgress) collectorReq := toCollectorRequest(req)
collectorReq.SkipHungCh = skipCh
result, err := connector.Collect(ctx, collectorReq, emitProgress)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return return
@@ -1820,7 +1878,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
return return
} }
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error()) s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой") s.jobManager.AppendJobLog(jobID, "Collection completed with error")
return return
} }
@@ -1830,7 +1888,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
applyCollectSourceMetadata(result, req) applyCollectSourceMetadata(result, req)
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "") s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
s.jobManager.AppendJobLog(jobID, "Сбор завершен") s.jobManager.AppendJobLog(jobID, "Collection completed")
s.SetResult(result) s.SetResult(result)
s.SetDetectedVendor(req.Protocol) s.SetDetectedVendor(req.Protocol)
if job, ok := s.jobManager.GetJob(jobID); ok { if job, ok := s.jobManager.GetJob(jobID); ok {
@@ -2035,8 +2093,6 @@ func toCollectorRequest(req CollectRequest) collector.Request {
Password: req.Password, Password: req.Password,
Token: req.Token, Token: req.Token,
TLSMode: req.TLSMode, TLSMode: req.TLSMode,
PowerOnIfHostOff: req.PowerOnIfHostOff,
StopHostAfterCollect: req.StopHostAfterCollect,
DebugPayloads: req.DebugPayloads, DebugPayloads: req.DebugPayloads,
} }
} }
@@ -2092,6 +2148,27 @@ func jsonError(w http.ResponseWriter, message string, code int) {
json.NewEncoder(w).Encode(map[string]string{"error": message}) json.NewEncoder(w).Encode(map[string]string{"error": message})
} }
func (s *Server) htmlError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
version := normalizeDisplayVersion(s.config.AppVersion)
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error %d</title></head>`+
`<body><h1>Error %d</h1><p>%s</p>`+
`<footer style="margin-top:2em;color:#999;font-size:12px">LOGPile %s</footer></body></html>`,
code, code, template.HTMLEscapeString(message), template.HTMLEscapeString(version))
}
func jsonList(w http.ResponseWriter, items interface{}, totalCount int) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"items": items,
"total_count": totalCount,
"page": 1,
"per_page": totalCount,
"total_pages": 1,
})
}
// isGPUDevice checks if device class indicates a GPU // isGPUDevice checks if device class indicates a GPU
func isGPUDevice(deviceClass string) bool { func isGPUDevice(deviceClass string) bool {
// Standard PCI class names // Standard PCI class names

View File

@@ -51,17 +51,20 @@ func TestHandleGetSerials_WithGPUs(t *testing.T) {
} }
// Parse response // Parse response
var serials []struct { var resp struct {
Items []struct {
Component string `json:"component"` Component string `json:"component"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
SerialNumber string `json:"serial_number"` SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"` Manufacturer string `json:"manufacturer,omitempty"`
Category string `json:"category"` Category string `json:"category"`
} `json:"items"`
} }
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil { if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err) t.Fatalf("Failed to decode response: %v", err)
} }
serials := resp.Items
// Check that we have GPU entries // Check that we have GPU entries
gpuCount := 0 gpuCount := 0
@@ -115,13 +118,16 @@ func TestHandleGetSerials_WithoutGPUSerials(t *testing.T) {
srv.handleGetSerials(w, req) srv.handleGetSerials(w, req)
// Parse response // Parse response
var serials []struct { var resp struct {
Items []struct {
Category string `json:"category"` Category string `json:"category"`
} `json:"items"`
} }
if err := json.NewDecoder(w.Body).Decode(&serials); err != nil { if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err) t.Fatalf("Failed to decode response: %v", err)
} }
serials := resp.Items
// Check that GPUs without serial numbers are not included // Check that GPUs without serial numbers are not included
for _, s := range serials { for _, s := range serials {

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"maps"
"sync" "sync"
"time" "time"
) )
@@ -22,9 +23,11 @@ func (m *JobManager) CreateJob(req CollectRequest) *Job {
now := time.Now().UTC() now := time.Now().UTC()
job := &Job{ job := &Job{
ID: generateJobID(), ID: generateJobID(),
Type: req.Protocol,
Status: CollectStatusQueued, Status: CollectStatusQueued,
Progress: 0, Progress: 0,
Logs: []string{formatCollectLogLine(now, "Задача поставлена в очередь")}, Message: "Job queued",
Logs: []string{formatCollectLogLine(now, "Job queued")},
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
RequestMeta: CollectRequestMeta{ RequestMeta: CollectRequestMeta{
@@ -66,7 +69,7 @@ func (m *JobManager) CancelJob(id string) (*Job, bool) {
job.Status = CollectStatusCanceled job.Status = CollectStatusCanceled
job.Error = "" job.Error = ""
job.UpdatedAt = time.Now().UTC() job.UpdatedAt = time.Now().UTC()
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Сбор отменен пользователем")) job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Collection canceled by user"))
} }
cancelFn := job.cancel cancelFn := job.cancel
@@ -122,6 +125,7 @@ func (m *JobManager) AppendJobLog(id, message string) (*Job, bool) {
job.Logs = append(job.Logs, message) job.Logs = append(job.Logs, message)
job.UpdatedAt = time.Now().UTC() job.UpdatedAt = time.Now().UTC()
job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message) job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message)
job.Message = message
cloned := cloneJob(job) cloned := cloneJob(job)
m.mu.Unlock() m.mu.Unlock()
@@ -175,6 +179,55 @@ func (m *JobManager) UpdateJobDebugInfo(id string, info *CollectDebugInfo) (*Job
return cloned, true return cloned, true
} }
func (m *JobManager) AttachJobSkip(id string, skipFn func()) bool {
m.mu.Lock()
defer m.mu.Unlock()
job, ok := m.jobs[id]
if !ok || job == nil || isTerminalCollectStatus(job.Status) {
return false
}
job.skipFn = skipFn
return true
}
func (m *JobManager) SkipJob(id string) (*Job, bool) {
m.mu.Lock()
job, ok := m.jobs[id]
if !ok || job == nil {
m.mu.Unlock()
return nil, false
}
if isTerminalCollectStatus(job.Status) {
cloned := cloneJob(job)
m.mu.Unlock()
return cloned, true
}
skipFn := job.skipFn
job.skipFn = nil
job.UpdatedAt = time.Now().UTC()
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Skipping stalled requests on user request"))
cloned := cloneJob(job)
m.mu.Unlock()
if skipFn != nil {
skipFn()
}
return cloned, true
}
func (m *JobManager) SetJobResult(id string, result map[string]interface{}) bool {
m.mu.Lock()
defer m.mu.Unlock()
job, ok := m.jobs[id]
if !ok || job == nil {
return false
}
job.Result = result
job.UpdatedAt = time.Now().UTC()
return true
}
func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool { func (m *JobManager) AttachJobCancel(id string, cancelFn context.CancelFunc) bool {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -228,6 +281,10 @@ func cloneJob(job *Job) *Job {
cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo) cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo)
cloned.CurrentPhase = job.CurrentPhase cloned.CurrentPhase = job.CurrentPhase
cloned.ETASeconds = job.ETASeconds cloned.ETASeconds = job.ETASeconds
if job.Result != nil {
cloned.Result = maps.Clone(job.Result)
}
cloned.cancel = nil cloned.cancel = nil
cloned.skipFn = nil
return &cloned return &cloned
} }

View File

@@ -23,6 +23,7 @@ type Config struct {
PreloadFile string PreloadFile string
AppVersion string AppVersion string
AppCommit string AppCommit string
ChartVersion string
} }
type Server struct { type Server struct {
@@ -90,6 +91,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV) s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON) s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator) s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
s.mux.HandleFunc("GET /api/export/logs-csv", s.handleExportLogsCSV)
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch) s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus) s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload) s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)
@@ -99,6 +101,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe) s.mux.HandleFunc("POST /api/collect/probe", s.handleCollectProbe)
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus) s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel) s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
s.mux.HandleFunc("POST /api/collect/{id}/skip", s.handleCollectSkip)
} }
func (s *Server) Run() error { func (s *Server) Run() error {

View File

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

BIN
logpile

Binary file not shown.

View File

@@ -0,0 +1,62 @@
# logpile v1.21
Дата релиза: 2026-06-15
Тег: `v1.21`
## Что нового
### Inspur/Kaytus (onekeylog) — серийные номера дисков из SOLHostCapture.log
Когда RAID-контроллер (например, Microchip PM8204-2GB) подключён напрямую через PCIe,
BMC возвращает пустой массив в секции `RESTful HDD info`. Серийники дисков теперь
восстанавливаются из вывода smartd в `SOLHostCapture.log`:
- Обрабатываются оба экземпляра файла (`log/sollog/` и `runningdata/var/sollog/`),
серийники дедуплицируются по обоим источникам.
- Три прохода обогащения: совпадение по модели → позиционное заполнение пустых
backplane-слотов → добавление новых записей.
- Определяется тип (SSD/HDD), производитель, прошивка и ёмкость.
### Inspur/Kaytus — корректное определение live-сбора на NF-серверах
NF-серверы хранения (например, NF5280M6) не имеют GPU-топологии, из-за чего
Redfish-коллектор раньше не мог идентифицировать их как Inspur и переходил в
режим fallback с AMI-профилем, пробуя несуществующие пути `/Oem/Ami`.
Добавлено определение по `SystemManufacturer` / `ChassisManufacturer`: значение
`"Inspur"` теперь даёт 60 очков — достаточно для входа в matched-режим без
GPU-сигналов.
### Inspur/Kaytus — исправление IDL-событий GPU (Assert/Deassert)
- Deassert-события больше не отбрасываются как дубликаты Assert — в ключ дедупликации
добавлен `EventType`.
- Deassert корректно снимает критический статус GPU: раньше GPUы оставались в Critical
даже после сброса аварии.
- В экспорт Reanimator добавлена секция `bmc_event_summary` — дедуплицированная таблица
критических и предупреждающих событий со статусом Active/Resolved на основе пар
Assert/Deassert.
### UI — кнопка PDF
Добавлена кнопка «PDF» в шапку отчёта. При нажатии отчёт открывается в новой
вкладке, откуда можно сохранить в PDF через системный диалог печати браузера.
### Внутренние изменения (bible-контракты)
- Идентификаторы нормализованы через `strings.EqualFold` (H3C-парсер).
- CSV-экспорт: UTF-8 BOM + разделитель `;`.
- Все русскоязычные строки в исходниках переведены на английский (ADL-007).
- `Job` расширен полями `Type`, `Message`, `Result`.
- List-эндпоинты обёрнуты в конверт `{items, total_count, page, per_page, total_pages}`.
- Страницы ошибок рендерят footer с версией.
- Логирование переведено на `log/slog` со структурированными атрибутами.
### pci.ids обновлён
База идентификаторов PCI-устройств обновлена до актуальной версии от 2026-06-15.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,60 @@
# logpile v1.22
Дата релиза: 2026-06-19
Тег: `v1.22`
## Что нового
### HPE iLO AHS — новый парсер
Добавлена поддержка файлов `*.ahs` (Active Health System), экспортируемых
из веб-интерфейса iLO. Парсер извлекает:
- **Инвентарь оборудования**: плата, процессоры, память, диски, сетевые
адаптеры, блоки питания, backplane, RAID-контроллеры.
- **Прошивки**: iLO, System ROM, SPS, TPM, SPLD, контроллеры, NIC, backplane —
из основного бинарного контейнера и XML-сертификата `bcert.pkg`.
- **События**: разбор `.zbb`-файлов с журналом iLO; определение типа и
серьёзности по тексту сообщения; очистка однобайтовых frame-сепараторов
из концов строк.
- **Устойчивость к битым файлам**: если последняя запись в AHS-контейнере
обрезана (объявленный размер выходит за границу файла), парсер обрабатывает
данные частично вместо возврата ошибки.
- Добавлено распознавание модельного ряда **Alletra Storage Server** (ранее
`ProductName` оставался пустым).
### Экспорт логов в CSV («Logs Export»)
Новая кнопка «**Logs Export**» в шапке интерфейса выгружает все
распознанные события (без какой-либо фильтрации) в CSV-файл:
- Разделитель — точка с запятой (`;`), кодировка — UTF-8 с BOM.
- Файл открывается в Excel без дополнительных настроек импорта.
- Колонки: `timestamp`, `source`, `severity`, `sensor_type`, `sensor_name`,
`event_type`, `id`, `description`, `raw_data`.
Кнопка «PDF» удалена.
### Исправления в Reanimator-экспорте
- `event_logs` в JSON-экспорте Reanimator больше не оказывается пустым для
HPE iLO AHS: источник `"HPE iLO"` теперь корректно нормализуется в `"bmc"`.
### Исправления chart viewer
- JavaScript `view.js` не загружался в LOGPile из-за отсутствия перезаписи
пути `/static/view.js``/chart/static/view.js`. Исправлено; фильтры
по колонкам в таблицах теперь работают.
- Субмодуль chart обновлён до **v2.7**: фильтры вынесены в отдельную строку
под заголовком, исправлена минимальная ширина колонок.
### Обновления зависимостей
- **pci.ids** (база PCI-устройств) обновлена. Коллектор скорректирован под
переименование `0x8086:0x28c0`: `"Volume Management Device NVMe RAID
Controller"``"Volume Management Device (VMD)"`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,23 @@
# logpile v1.23
Дата релиза: 2026-06-19
Тег: `v1.23`
## Что нового
### Исправление: HPE iLO AHS файлы больше 10 МБ не обрезаются
AHS-файлы могут весить сотни мегабайт (типичный пример — 104 МБ). Универсальный
лимит в 10 МБ молча обрезал их, из-за чего парсер видел лишь начало файла и
извлекал неполный список событий.
Теперь лимит зависит от расширения: `.ahs` — до **1 ГБ**, прочие
одиночные файлы (`.txt`, `.log`) — прежние 10 МБ.
Для AHS-файла размером 104 МБ количество распознанных событий увеличивается
с ~529 до ~12 600.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/logpile-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,57 +7,64 @@
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css">
</head> </head>
<body> <body>
<header> <header class="page-header">
<div class="app-header-row"> <div class="page-header-brand">
<div class="app-header-brand"> <p class="page-eyebrow">Diagnostic Workbench</p>
<h1>LOGPile <span class="header-domain">mchus.pro</span></h1> <h1>LOGPile</h1>
<p>Анализатор диагностических данных BMC/IPMI</p> <p class="page-subtitle">BMC diagnostic data analyzer</p>
</div> </div>
<div id="header-log-meta" class="header-log-meta hidden"> <div id="header-log-meta" class="header-log-meta hidden">
<div class="header-actions"> <div class="header-actions">
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button> <button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
<button id="header-raw-btn" class="hidden" onclick="exportData('json')">Export Raw Data</button> <button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
<button id="header-reanimator-btn" class="hidden" onclick="exportData('reanimator')">Экспорт Reanimator</button> <button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
<button id="restart-btn" onclick="restartApp()">Перезапуск</button> <button id="header-logs-csv-btn" class="header-action hidden" onclick="exportData('logs-csv')">Logs Export</button>
<button id="exit-btn" onclick="exitApp()">Выход</button> <button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
</div> <button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
</div> </div>
</div> </div>
</header> </header>
<main> <main class="page-main">
<section id="upload-section"> <section id="upload-section" class="control-deck">
<div class="source-switch" role="tablist" aria-label="Источник данных"> <div class="source-switch" role="tablist" aria-label="Data source">
<button type="button" class="source-switch-btn active" data-source-type="archive">Архив</button> <button type="button" class="source-switch-btn active" data-source-type="archive">Archive</button>
<button type="button" class="source-switch-btn" data-source-type="api">API</button> <button type="button" class="source-switch-btn" data-source-type="api">API</button>
<button type="button" class="source-switch-btn" data-source-type="convert">Convert</button> <button type="button" class="source-switch-btn" data-source-type="convert">Convert</button>
</div> </div>
<div id="archive-source-content"> <div id="archive-source-content" class="surface-panel upload-panel">
<div class="upload-area" id="drop-zone"> <h2>Open Archive</h2>
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p> <p>Upload a support archive, plain log, or raw JSON snapshot to open the hardware report.</p>
<div class="upload-area upload-dropzone" id="drop-zone">
<input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden> <input type="file" id="file-input" accept="application/gzip,application/x-gzip,application/x-tar,application/zip,application/json,text/plain,.ahs,.json,.tar,.tar.gz,.tgz,.sds,.zip,.txt,.log" hidden>
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button> <span class="upload-kicker">Archive Import</span>
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p> <strong>Drop a file here</strong>
<span class="upload-copy">LOGPile will parse it and open the report immediately.</span>
<div class="upload-actions">
<button type="button" onclick="document.getElementById('file-input').click()">Select File</button>
</div>
<p class="hint">Supported formats: `.ahs`, `.tar.gz`, `.tar`, `.tgz`, `.sds`, `.zip`, `.json`, `.txt`, `.log`</p>
</div> </div>
<div id="upload-status"></div> <div id="upload-status"></div>
<div id="parsers-info" class="parsers-info"></div> <div id="parsers-info" class="parsers-info"></div>
</div> </div>
<div id="api-source-content" class="api-placeholder hidden"> <div id="api-source-content" class="surface-panel upload-panel hidden">
<h2>BMC API</h2>
<p>Validate access and start live collection through the production Redfish pipeline.</p>
<form id="api-connect-form" novalidate> <form id="api-connect-form" novalidate>
<h3>Подключение к BMC API</h3>
<div id="api-form-errors" class="form-errors hidden"></div> <div id="api-form-errors" class="form-errors hidden"></div>
<div class="api-form-grid"> <div class="api-form-grid">
<label class="api-form-field" for="api-host"> <label class="api-form-field" for="api-host">
<span>Host</span> <span>Host</span>
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 или bmc.example.local"> <input id="api-host" name="host" type="text" placeholder="10.0.0.10 or bmc.example.local">
<span class="field-error" data-error-for="host"></span> <span class="field-error" data-error-for="host"></span>
</label> </label>
<label class="api-form-field" for="api-port"> <label class="api-form-field" for="api-port">
<span>Порт</span> <span>Port</span>
<input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443"> <input id="api-port" name="port" type="number" min="1" max="65535" value="443" placeholder="443">
<span class="field-error" data-error-for="port"></span> <span class="field-error" data-error-for="port"></span>
</label> </label>
@@ -69,55 +76,52 @@
</label> </label>
<label class="api-form-field" id="api-password-field" for="api-password"> <label class="api-form-field" id="api-password-field" for="api-password">
<span>Пароль</span> <span>Password</span>
<input id="api-password" name="password" type="password" autocomplete="current-password"> <input id="api-password" name="password" type="password" autocomplete="current-password">
<span class="field-error" data-error-for="password"></span> <span class="field-error" data-error-for="password"></span>
</label> </label>
</div> </div>
<div class="api-form-actions"> <div class="api-form-actions">
<button id="api-connect-btn" type="button">Подключиться</button> <button id="api-connect-btn" type="button">Connect</button>
</div> </div>
<div id="api-connect-status" class="api-connect-status"></div> <div id="api-connect-status" class="api-connect-status"></div>
<div id="api-probe-options" class="api-probe-options hidden"> <div id="api-probe-options" class="api-probe-options hidden">
<label class="api-form-checkbox" for="api-power-on"> <div id="api-host-off-warning" class="api-host-off-warning hidden">
<input id="api-power-on" name="power_on_if_host_off" type="checkbox"> &#9888; Host is powered off. Inventory data may be incomplete.
<span>Включить перед сбором</span> </div>
</label>
<label class="api-form-checkbox" for="api-power-off">
<input id="api-power-off" name="stop_host_after_collect" type="checkbox">
<span>Выключить после сбора</span>
</label>
<div class="api-probe-options-separator"></div>
<label class="api-form-checkbox" for="api-debug-payloads"> <label class="api-form-checkbox" for="api-debug-payloads">
<input id="api-debug-payloads" name="debug_payloads" type="checkbox"> <input id="api-debug-payloads" name="debug_payloads" type="checkbox">
<span>Сбор расширенных метрик для отладки</span> <span>Collect extended diagnostics</span>
</label> </label>
<div class="api-form-actions"> <div class="api-form-actions">
<button id="api-collect-btn" type="submit">Собрать</button> <button id="api-collect-btn" type="submit">Collect</button>
</div> </div>
</div> </div>
</form> </form>
<section id="api-job-status" class="job-status hidden" aria-live="polite"> <section id="api-job-status" class="job-status hidden" aria-live="polite">
<div class="job-status-header"> <div class="job-status-header">
<h4>Статус задачи сбора</h4> <h4>Collection Job Status</h4>
<button id="cancel-job-btn" type="button">Отменить</button> <div class="job-status-actions">
<button id="skip-hung-btn" type="button" class="hidden" title="Abort hung requests and continue with analysis of collected data">Skip Hung Requests</button>
<button id="cancel-job-btn" type="button">Cancel</button>
</div>
</div> </div>
<div class="job-status-meta"> <div class="job-status-meta">
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div> <div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
<div> <div>
<span class="meta-label">Статус:</span> <span class="meta-label">Status:</span>
<span id="job-status-value" class="job-status-badge">Queued</span> <span id="job-status-value" class="job-status-badge">Queued</span>
</div> </div>
<div><span class="meta-label">Этап:</span> <span id="job-progress-value">Сбор данных...</span></div> <div><span class="meta-label">Stage:</span> <span id="job-progress-value">Collecting data...</span></div>
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div> <div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
</div> </div>
<div class="job-progress" aria-label="Прогресс задачи"> <div class="job-progress" aria-label="Job progress">
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div> <div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
</div> </div>
<div id="job-active-modules" class="job-active-modules hidden"> <div id="job-active-modules" class="job-active-modules hidden">
<p class="meta-label">Активные модули:</p> <p class="meta-label">Active modules:</p>
<div id="job-active-modules-list" class="job-module-chips"></div> <div id="job-active-modules-list" class="job-module-chips"></div>
</div> </div>
<div id="job-debug-info" class="job-debug-info hidden"> <div id="job-debug-info" class="job-debug-info hidden">
@@ -126,23 +130,23 @@
<div id="job-phase-telemetry" class="job-phase-telemetry"></div> <div id="job-phase-telemetry" class="job-phase-telemetry"></div>
</div> </div>
<div class="job-status-logs"> <div class="job-status-logs">
<p class="meta-label">Журнал шагов:</p> <p class="meta-label">Step log:</p>
<ul id="job-logs-list"></ul> <ul id="job-logs-list"></ul>
</div> </div>
</section> </section>
</div> </div>
<div id="convert-source-content" class="api-placeholder hidden"> <div id="convert-source-content" class="surface-panel upload-panel hidden">
<h3>Пакетная выгрузка Reanimator</h3> <h2>Batch Convert</h2>
<p>Выберите папку с файлами поддерживаемого типа. Для каждого файла будет создан отдельный экспорт Reanimator.</p> <p>Select a folder with supported files. A separate Reanimator export will be produced for each file.</p>
<div class="api-form-actions"> <div class="api-form-actions">
<input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden> <input type="file" id="convert-folder-input" webkitdirectory directory multiple hidden>
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Выбрать папку</button> <button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Choose Folder</button>
<button id="convert-run-btn" type="button">Конвертировать в Reanimator</button> <button id="convert-run-btn" type="button">Convert to Reanimator</button>
</div> </div>
<div id="convert-progress" class="convert-progress hidden" aria-live="polite"> <div id="convert-progress" class="convert-progress hidden" aria-live="polite">
<div class="convert-progress-meta"> <div class="convert-progress-meta">
<span id="convert-progress-label">Подготовка...</span> <span id="convert-progress-label">Preparing...</span>
<span id="convert-progress-value">0%</span> <span id="convert-progress-value">0%</span>
</div> </div>
<div class="convert-progress-track"> <div class="convert-progress-track">
@@ -155,26 +159,43 @@
</section> </section>
<section id="data-section" class="hidden"> <section id="data-section" class="hidden">
<section class="result-panel"> <section class="viewer-panel">
<div class="audit-viewer-shell"> <div class="audit-viewer-shell">
<iframe <iframe
id="audit-viewer-frame" id="audit-viewer-frame"
class="audit-viewer-frame" class="audit-viewer-frame"
title="Reanimator chart viewer" title="Hardware report"
loading="eager" loading="eager"
scrolling="no" scrolling="no"
referrerpolicy="same-origin"> referrerpolicy="same-origin">
</iframe> </iframe>
</div> </div>
</section> </section>
<section id="parse-errors-section" class="parse-errors-section hidden">
<div class="parse-errors-header" onclick="toggleParseErrors()">
<span id="parse-errors-title">Collection warnings</span>
<span id="parse-errors-toggle" class="parse-errors-toggle"></span>
</div>
<div id="parse-errors-body" class="parse-errors-body">
<table class="parse-errors-table">
<thead>
<tr>
<th>Source</th>
<th>Section</th>
<th>Message</th>
<th>Detail</th>
</tr>
</thead>
<tbody id="parse-errors-rows"></tbody>
</table>
</div>
</section>
</section> </section>
</main> </main>
<footer> <footer class="page-footer">
<div class="footer-buttons">
</div>
<div class="footer-info"> <div class="footer-info">
<p>Автор: <a href="https://mchus.pro" target="_blank">mchus.pro</a> | <a href="https://git.mchus.pro/mchus/logpile" target="_blank">Git Repository</a>{{if .AppVersion}} | v{{.AppVersion}}{{end}}</p> <p>{{if .AppVersion}}LOGPile {{.AppVersion}}{{end}}{{if and .AppVersion .ChartVersion}} · {{end}}{{if .ChartVersion}}Chart {{.ChartVersion}}{{end}}</p>
</div> </div>
</footer> </footer>