3 Commits
main ... v1.14

Author SHA1 Message Date
Mikhail Chusavitin
63ed8a384e 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:53:47 +03:00
Mikhail Chusavitin
1162ccd22e Trim noisy Lenovo Redfish collection paths 2026-04-29 17:02:40 +03:00
Mikhail Chusavitin
3887df6547 Improve Lenovo XCC inventory enrichment 2026-04-29 16:38:30 +03:00
51 changed files with 3273 additions and 5041 deletions

2
bible

Submodule bible updated: 1977730d93...d2600f1279

View File

@@ -180,10 +180,3 @@ When changing collection logic:
Status: mock scaffold only.
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

@@ -7,7 +7,6 @@
| `GET /api/export/csv` | CSV | Serial-number export |
| `GET /api/export/json` | raw-export ZIP bundle | Reopen and re-analyze later |
| `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 |
## Raw export

View File

@@ -1154,47 +1154,3 @@ continue to inherit the exact git tag string from `git describe --tags`.
- 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.

View File

@@ -1,21 +0,0 @@
# 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
version: "2.11"
updated: "2026-06-19"
version: "2.7"
updated: "2026-03-15"
maintainer: Reanimator Core
audience: external-integrators, ai-agents
language: ru
@@ -9,7 +9,7 @@ language: ru
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
Версия: **2.11** · Дата: **2026-06-19**
Версия: **2.7** · Дата: **2026-03-15**
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
@@ -22,10 +22,6 @@ 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.6 | 2026-03-15 | Добавлена необязательная секция `event_logs` для dedup/upsert логов `host` / `bmc` / `redfish` вне history timeline |
| 2.5 | 2026-03-15 | Добавлено общее необязательное поле `manufactured_year_week` для компонентных секций (`YYYY-Www`) |
@@ -135,9 +131,8 @@ GET /ingest/hardware/jobs/{job_id}
"storage": [ ... ],
"pcie_devices": [ ... ],
"power_supplies": [ ... ],
"sensors": { ... },
"event_logs": [ ... ],
"platform_config": { ... }
"sensors": { ... },
"event_logs": [ ... ]
}
}
```
@@ -348,9 +343,6 @@ GET /ingest/hardware/jobs/{job_id}
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
| `interface` | string | нет | Интерфейс: `NVMe`, `SATA`, `SAS` |
| `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) |
| `power_on_hours` | int64 | нет | Время работы, часы |
| `power_cycles` | int64 | нет | Количество циклов питания |
@@ -371,11 +363,6 @@ GET /ingest/hardware/jobs/{job_id}
Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`.
Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
- `logical_block_size_bytes = 512`
- `metadata_bytes_per_block = 8`
- `physical_block_size_bytes = 512` или `4096`, если известен физический размер блока
```json
"storage": [
{
@@ -383,9 +370,6 @@ GET /ingest/hardware/jobs/{job_id}
"type": "NVMe",
"model": "INTEL SSDPF2KX076T1",
"size_gb": 7680,
"logical_block_size_bytes": 512,
"physical_block_size_bytes": 4096,
"metadata_bytes_per_block": 8,
"temperature_c": 38.5,
"power_on_hours": 12450,
"unsafe_shutdowns": 3,
@@ -423,12 +407,11 @@ GET /ingest/hardware/jobs/{job_id}
| `battery_temperature_c` | float | нет | Температура батареи / supercap, °C |
| `battery_voltage_v` | float | нет | Напряжение батареи / supercap, В |
| `battery_replace_required` | bool | нет | Требуется замена батареи / supercap |
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C *(deprecated since 2.11)* |
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm *(deprecated since 2.11)* |
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm *(deprecated since 2.11)* |
| `sfp_voltage_v` | float | нет | Напряжение SFP, В *(deprecated since 2.11)* |
| `sfp_bias_ma` | float | нет | Bias current SFP, мА *(deprecated since 2.11)* |
| `sfp_modules` | array | нет | Установленные SFP/QSFP-модули по портам (см. sfp_modules[]) |
| `sfp_temperature_c` | float | нет | Температура SFP/optic, °C |
| `sfp_tx_power_dbm` | float | нет | TX optical power, dBm |
| `sfp_rx_power_dbm` | float | нет | RX optical power, dBm |
| `sfp_voltage_v` | float | нет | Напряжение SFP, В |
| `sfp_bias_ma` | float | нет | Bias current SFP, мА |
| `bdf` | string | нет | Deprecated alias для `slot`; при наличии ingest нормализует его в `slot` |
| `device_class` | string | нет | Класс устройства (см. список ниже) |
| `manufacturer` | string | нет | Производитель |
@@ -446,43 +429,10 @@ GET /ingest/hardware/jobs/{job_id}
`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. Они не влияют на идентификацию компонента.
**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.
`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`:**
| Значение | Назначение |
@@ -507,47 +457,16 @@ GET /ingest/hardware/jobs/{job_id}
"numa_node": 0,
"temperature_c": 48.5,
"power_w": 18.2,
"sfp_temperature_c": 36.2,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
"device_class": "EthernetController",
"manufacturer": "Mellanox",
"model": "ConnectX-6 Dx",
"serial_number": "MT2012X12345",
"firmware": "22.35.2010",
"manufacturer": "Intel",
"model": "X710 10GbE",
"serial_number": "K65472-003",
"firmware": "9.20 0x8000d4ae",
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
"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
}
]
"status": "OK"
}
]
```
@@ -673,6 +592,7 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора в рамках секции |
| `location` | string | нет | Физическое расположение |
| `rpm` | int | нет | Обороты, RPM |
| `status` | string | нет | Статус: `OK`, `Warning`, `Critical`, `Unknown` |
@@ -681,6 +601,7 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `voltage_v` | float | нет | Напряжение, В |
| `current_a` | float | нет | Ток, А |
| `power_w` | float | нет | Мощность, Вт |
@@ -691,6 +612,7 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `celsius` | float | нет | Температура, °C |
| `threshold_warning_celsius` | float | нет | Порог Warning, °C |
| `threshold_critical_celsius` | float | нет | Порог Critical, °C |
@@ -701,63 +623,38 @@ PSU без `serial_number` игнорируется.
| Поле | Тип | Обязательно | Описание |
|------|-----|-------------|----------|
| `name` | string | **да** | Уникальное имя сенсора |
| `location` | string | нет | Физическое расположение |
| `value` | float | нет | Значение |
| `unit` | string | нет | Единица измерения |
| `status` | string | нет | Статус |
**Правила sensors:**
- Идентификатор сенсора: пара `(sensor_type, name)`. Дубли в одном payload — берётся первое вхождение.
- `location` для сенсоров передавать не нужно и не следует: в Reanimator location/slot используется только для проверки перемещения и установки компонентов, а не для last-known-value sensor ingest.
- Сенсоры без `name` игнорируются.
- При каждом импорте значения перезаписываются (upsert по ключу).
```json
"sensors": {
"fans": [
{ "name": "FAN1", "rpm": 4200, "status": "OK" },
{ "name": "FAN_CPU0", "rpm": 5600, "status": "OK" }
{ "name": "FAN1", "location": "Front", "rpm": 4200, "status": "OK" },
{ "name": "FAN_CPU0", "location": "CPU0", "rpm": 5600, "status": "OK" }
],
"power": [
{ "name": "12V Rail", "voltage_v": 12.06, "status": "OK" },
{ "name": "PSU0 Input", "voltage_v": 215.25, "current_a": 0.64, "power_w": 137.0, "status": "OK" }
{ "name": "12V Rail", "location": "Mainboard", "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" }
],
"temperatures": [
{ "name": "CPU0 Temp", "celsius": 46.0, "threshold_warning_celsius": 80.0, "threshold_critical_celsius": 95.0, "status": "OK" },
{ "name": "Inlet Temp", "celsius": 22.0, "threshold_warning_celsius": 40.0, "threshold_critical_celsius": 50.0, "status": "OK" }
{ "name": "CPU0 Temp", "location": "CPU0", "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" }
],
"other": [
{ "name": "System Humidity", "value": 38.5, "unit": "%", "status": "OK" }
{ "name": "System Humidity", "value": 38.5, "unit": "%" , "status": "OK" }
]
}
```
---
## Секция 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"
}
```
---
## Обработка статусов компонентов
| Статус | Поведение |
@@ -859,24 +756,7 @@ PSU без `serial_number` игнорируется.
"model": "X710 10GbE",
"serial_number": "K65472-003",
"mac_addresses": ["3c:fd:fe:aa:bb:cc", "3c:fd:fe:aa:bb:cd"],
"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
}
]
"status": "OK"
}
],
"power_supplies": [
@@ -907,12 +787,6 @@ PSU без `serial_number` игнорируется.
"other": [
{ "name": "System Humidity", "value": 38.5, "unit": "%" }
]
},
"platform_config": {
"SecureBoot": "Enabled",
"BiosVersion": "06.08.05",
"TpmEnabled": true,
"HyperThreading": "Enabled"
}
}
}

View File

@@ -4,11 +4,10 @@ import (
"bufio"
"flag"
"fmt"
"log/slog"
"log"
"os"
"os/exec"
"runtime"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/parser"
@@ -39,18 +38,17 @@ func main() {
server.WebFS = web.FS
cfg := server.Config{
Port: *port,
PreloadFile: *file,
AppVersion: version,
AppCommit: commit,
ChartVersion: detectChartVersion(),
Port: *port,
PreloadFile: *file,
AppVersion: version,
AppCommit: commit,
}
srv := server.New(cfg)
url := fmt.Sprintf("http://localhost:%d", *port)
slog.Info("LOGPile starting", "url", url)
slog.Info("registered parsers", "parsers", parser.ListParsers())
log.Printf("LOGPile starting on %s", url)
log.Printf("Registered parsers: %v", parser.ListParsers())
// Open browser automatically
if !*noBrowser {
@@ -61,7 +59,7 @@ func main() {
}
if err := runServer(srv); err != nil {
slog.Error("fatal error", "err", err)
log.Printf("FATAL: %v", err)
maybeWaitForCrashInput(*holdOnCrash)
os.Exit(1)
}
@@ -90,19 +88,10 @@ func openBrowser(url string) {
}
if err := cmd.Start(); err != nil {
slog.Warn("failed to open browser", "err", err)
log.Printf("Failed to open browser: %v", 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) {
if !enabled || !isInteractiveConsole() {
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) {
steps := []Progress{
{Status: "running", Progress: 20, Message: "IPMI: connecting to BMC..."},
{Status: "running", Progress: 55, Message: "IPMI: reading inventory..."},
{Status: "running", Progress: 85, Message: "IPMI: normalizing data..."},
{Status: "running", Progress: 20, Message: "IPMI: подключение к BMC..."},
{Status: "running", Progress: 55, Message: "IPMI: чтение инвентаря..."},
{Status: "running", Progress: 85, Message: "IPMI: нормализация данных..."},
}
for _, step := range steps {

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"log"
"net/http"
"net/url"
"os"
@@ -124,14 +124,14 @@ func (c *RedfishConnector) debugf(format string, args ...interface{}) {
if !c.debug {
return
}
slog.Debug("redfish-debug: " + fmt.Sprintf(format, args...))
log.Printf("redfish-debug: "+format, args...)
}
func (c *RedfishConnector) debugSnapshotf(format string, args ...interface{}) {
if !c.debugSnapshot {
return
}
slog.Debug("redfish-snapshot-debug: " + fmt.Sprintf(format, args...))
log.Printf("redfish-snapshot-debug: "+format, args...)
}
func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit ProgressFn) (*models.AnalysisResult, error) {
@@ -149,7 +149,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
hintClient := c.httpClientWithTimeout(req, 4*time.Second)
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: connecting to BMC..."})
emit(Progress{Status: "running", Progress: 10, Message: "Redfish: подключение к BMC..."})
}
discoveryCtx := withRedfishTelemetryPhase(ctx, "discovery")
serviceRootDoc, err := c.getJSON(discoveryCtx, snapshotClient, req, baseURL, "/redfish/v1")
@@ -192,7 +192,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
emit(Progress{
Status: "running",
Progress: 25,
Message: fmt.Sprintf("Redfish: profiles mode=%s active=%s", acquisitionPlan.Mode, formatActiveModuleLog(activeModules)),
Message: fmt.Sprintf("Redfish: профили mode=%s active=%s", acquisitionPlan.Mode, formatActiveModuleLog(activeModules)),
ActiveModules: activeModules,
ModuleScores: moduleScores,
DebugInfo: &CollectDebugInfo{
@@ -229,32 +229,33 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
seedPaths := resolvedPlan.SeedPaths
criticalPaths := resolvedPlan.CriticalPaths
if len(acquisitionPlan.Profiles) > 0 {
slog.Info("redfish-profile-plan",
"mode", acquisitionPlan.Mode,
"profiles", strings.Join(acquisitionPlan.Profiles, ","),
"notes", strings.Join(acquisitionPlan.Notes, "; "),
"scores", formatModuleScoreLog(moduleScores),
"req", telemetrySummary.Requests,
"err", telemetrySummary.Errors,
"p95_ms", telemetrySummary.P95.Milliseconds(),
"avg_ms", telemetrySummary.Avg.Milliseconds(),
"throttled", throttled,
log.Printf(
"redfish-profile-plan: mode=%s profiles=%s notes=%s scores=%s req=%d err=%d p95=%dms avg=%dms throttled=%t",
acquisitionPlan.Mode,
strings.Join(acquisitionPlan.Profiles, ","),
strings.Join(acquisitionPlan.Notes, "; "),
formatModuleScoreLog(moduleScores),
telemetrySummary.Requests,
telemetrySummary.Errors,
telemetrySummary.P95.Milliseconds(),
telemetrySummary.Avg.Milliseconds(),
throttled,
)
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 30,
Message: "Redfish: reading Redfish structure...",
Message: "Redfish: чтение структуры Redfish...",
CurrentPhase: "snapshot",
ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds,
})
}
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: preparing snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: preparing extended snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: collecting extended snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 55, Message: "Redfish: подготовка snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 80, Message: "Redfish: подготовка расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot...", CurrentPhase: "snapshot", ETASeconds: acquisitionPlan.Tuning.ETABaseline.SnapshotSeconds})
}
// collectCtx covers all data-fetching phases (snapshot, prefetch, plan-B).
// Cancelling it via the skip signal aborts only the collection phases while
@@ -269,10 +270,10 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
emit(Progress{
Status: "running",
Progress: 97,
Message: "Redfish: skipping stalled requests, analyzing collected data...",
Message: "Redfish: пропуск зависших запросов, анализ уже собранных данных...",
})
}
slog.Info("redfish: skip-hung triggered, cancelling collection phases")
log.Printf("redfish: skip-hung triggered, cancelling collection phases")
cancelCollect()
case <-ctx.Done():
}
@@ -295,14 +296,15 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
for p := range prefetchedCritical {
delete(fetchErrMap, p)
}
slog.Info("redfish-prefetch-metrics",
"enabled", prefetchMetrics.Enabled,
"candidates", prefetchMetrics.Candidates,
"targets", prefetchMetrics.Targets,
"docs", prefetchMetrics.Docs,
"added", prefetchMetrics.Added,
"dur", prefetchMetrics.Duration.Round(time.Millisecond),
"skip", firstNonEmpty(prefetchMetrics.SkipReason, "-"),
log.Printf(
"redfish-prefetch-metrics: enabled=%t candidates=%d targets=%d docs=%d added=%d dur=%s skip=%s",
prefetchMetrics.Enabled,
prefetchMetrics.Candidates,
prefetchMetrics.Targets,
prefetchMetrics.Docs,
prefetchMetrics.Added,
prefetchMetrics.Duration.Round(time.Millisecond),
firstNonEmpty(prefetchMetrics.SkipReason, "-"),
)
if recoveredN := c.recoverCriticalRedfishDocsPlanB(withRedfishTelemetryPhase(collectCtx, "critical_plan_b"), criticalClient, req, baseURL, criticalPaths, rawTree, fetchErrMap, acquisitionPlan.Tuning, emit); recoveredN > 0 {
c.debugSnapshotf("critical plan-b recovered docs=%d", recoveredN)
@@ -317,7 +319,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
}
if emit != nil {
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: analyzing raw snapshot..."})
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."})
}
// Collect hardware event logs separately (not part of tree-walk to avoid bloat).
rawLogEntries := c.collectRedfishLogEntries(withRedfishTelemetryPhase(ctx, "log_entries"), snapshotClient, req, baseURL, systemPaths, managerPaths)
@@ -441,36 +443,38 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
}
totalElapsed := time.Since(collectStart).Round(time.Second)
if !result.InventoryLastModifiedAt.IsZero() {
slog.Info("redfish-collect: inventory last modified",
"at", result.InventoryLastModifiedAt.Format(time.RFC3339),
"age", time.Since(result.InventoryLastModifiedAt).Round(time.Minute),
log.Printf("redfish-collect: inventory last modified at %s (age: %s)",
result.InventoryLastModifiedAt.Format(time.RFC3339),
time.Since(result.InventoryLastModifiedAt).Round(time.Minute),
)
}
slog.Info("redfish-postprobe-metrics",
"nvme_candidates", postProbeMetrics.NVMECandidates,
"nvme_selected", postProbeMetrics.NVMESelected,
"nvme_added", postProbeMetrics.NVMEAdded,
"candidates", postProbeMetrics.CollectionCandidates,
"selected", postProbeMetrics.CollectionSelected,
"skipped_explicit", postProbeMetrics.SkippedExplicit,
"added", postProbeMetrics.Added,
"dur", postProbeMetrics.Duration.Round(time.Millisecond),
log.Printf(
"redfish-postprobe-metrics: nvme_candidates=%d nvme_selected=%d nvme_added=%d candidates=%d selected=%d skipped_explicit=%d added=%d dur=%s",
postProbeMetrics.NVMECandidates,
postProbeMetrics.NVMESelected,
postProbeMetrics.NVMEAdded,
postProbeMetrics.CollectionCandidates,
postProbeMetrics.CollectionSelected,
postProbeMetrics.SkippedExplicit,
postProbeMetrics.Added,
postProbeMetrics.Duration.Round(time.Millisecond),
)
slog.Info("redfish-telemetry",
"req", telemetrySummary.Requests,
"err", telemetrySummary.Errors,
"err_rate", telemetrySummary.ErrorRate,
"avg_ms", telemetrySummary.Avg.Milliseconds(),
"p95_ms", telemetrySummary.P95.Milliseconds(),
"throttled", throttled,
"snapshot_workers", acquisitionPlan.Tuning.SnapshotWorkers,
"prefetch_workers", acquisitionPlan.Tuning.PrefetchWorkers,
"timing_top", firstNonEmpty(snapshotTimingSummary, "-"),
log.Printf(
"redfish-telemetry: req=%d err=%d err_rate=%.2f avg=%dms p95=%dms throttled=%t snapshot_workers=%d prefetch_workers=%d timing_top=%s",
telemetrySummary.Requests,
telemetrySummary.Errors,
telemetrySummary.ErrorRate,
telemetrySummary.Avg.Milliseconds(),
telemetrySummary.P95.Milliseconds(),
throttled,
acquisitionPlan.Tuning.SnapshotWorkers,
acquisitionPlan.Tuning.PrefetchWorkers,
firstNonEmpty(snapshotTimingSummary, "-"),
)
for _, line := range redfishPhaseTelemetryLogLines(phaseTelemetry) {
slog.Info("redfish-telemetry-phase", "line", line)
log.Printf("redfish-telemetry-phase: %s", line)
}
slog.Info("redfish-collect: completed", "elapsed", totalElapsed, "docs", len(rawTree), "fetch_errors", len(fetchErrMap))
log.Printf("redfish-collect: completed in %s (docs=%d, fetch_errors=%d)", totalElapsed, len(rawTree), len(fetchErrMap))
if emit != nil {
emit(Progress{
Status: "running",
@@ -487,7 +491,7 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
emit(Progress{
Status: "running",
Progress: 100,
Message: fmt.Sprintf("Redfish: collection completed in %s", totalElapsed),
Message: fmt.Sprintf("Redfish: сбор завершен за %s", totalElapsed),
})
}
return result, nil
@@ -607,7 +611,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs(
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch skipped (adaptive, candidates=%d)", metrics.Candidates),
Message: fmt.Sprintf("Redfish: prefetch пропущен (адаптивно, кандидатов=%d)", metrics.Candidates),
})
}
return nil, metrics
@@ -616,7 +620,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs(
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch critical endpoints (adaptive %d/%d)...", len(targets), len(candidates)),
Message: fmt.Sprintf("Redfish: prefetch критичных endpoint (адаптивно %d/%d)...", len(targets), len(candidates)),
CurrentPhase: "prefetch",
ETASeconds: int(estimateProgressETA(time.Now(), 0, len(targets), 2*time.Second).Seconds()),
})
@@ -702,7 +706,7 @@ func (c *RedfishConnector) prefetchCriticalRedfishDocs(
emit(Progress{
Status: "running",
Progress: 96,
Message: fmt.Sprintf("Redfish: prefetch completed (adaptive targets=%d, docs=%d)", len(targets), len(out)),
Message: fmt.Sprintf("Redfish: prefetch завершен (адаптивно targets=%d, docs=%d)", len(targets), len(out)),
CurrentPhase: "prefetch",
})
}
@@ -1393,7 +1397,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: heartbeat docs=%d (ok=%d, seen=%d), ETA≈%s, roots=%s, last=%s", n, outN, seenN, eta, strings.Join(roots, ", "), compactProgressPath(last)),
Message: fmt.Sprintf("Redfish snapshot: heartbeat документов=%d (ok=%d, seen=%d), ETA≈%s, корни=%s, последний=%s", n, outN, seenN, eta, strings.Join(roots, ", "), compactProgressPath(last)),
CurrentPhase: "snapshot",
ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()),
})
@@ -1430,7 +1434,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: error on %s", compactProgressPath(current)),
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
})
}
wg.Done()
@@ -1485,7 +1489,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: error on %s", compactProgressPath(current)),
Message: fmt.Sprintf("Redfish snapshot: ошибка на %s", compactProgressPath(current)),
})
}
}
@@ -1508,7 +1512,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 92 + int(minInt32(n/200, 6)),
Message: fmt.Sprintf("Redfish snapshot: docs=%d, ETA≈%s, roots=%s, last=%s", n, eta, strings.Join(roots, ", "), compactProgressPath(last)),
Message: fmt.Sprintf("Redfish snapshot: документов=%d, ETA≈%s, корни=%s, последний=%s", n, eta, strings.Join(roots, ", "), compactProgressPath(last)),
CurrentPhase: "snapshot",
ETASeconds: int(estimateSnapshotETA(crawlStart, int(n), seenN, len(jobs), workers, client.Timeout).Seconds()),
})
@@ -1570,7 +1574,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish snapshot: post-probe NVMe (%d/%d, ETA≈%s), collection=%s", i+1, len(driveCollections), formatETA(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second)), compactProgressPath(path)),
Message: fmt.Sprintf("Redfish snapshot: post-probe NVMe (%d/%d, ETA≈%s), коллекция=%s", i+1, len(driveCollections), formatETA(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second)), compactProgressPath(path)),
CurrentPhase: "snapshot_postprobe_nvme",
ETASeconds: int(estimateProgressETA(nvmeProbeStart, i, len(driveCollections), 2*time.Second).Seconds()),
})
@@ -1618,7 +1622,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe collections (%d/%d, ETA≈%s), current=%s", i+1, len(postProbeCollections), formatETA(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second)), compactProgressPath(path)),
Message: fmt.Sprintf("Redfish snapshot: post-probe коллекций (%d/%d, ETA≈%s), текущая=%s", i+1, len(postProbeCollections), formatETA(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second)), compactProgressPath(path)),
CurrentPhase: "snapshot_postprobe_collections",
ETASeconds: int(estimateProgressETA(postProbeStart, i, len(postProbeCollections), 3*time.Second).Seconds()),
})
@@ -1637,14 +1641,14 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe added %d docs", addedPostProbe),
Message: fmt.Sprintf("Redfish snapshot: post-probe добавлено %d документов", addedPostProbe),
})
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: post-probe metrics candidates=%d selected=%d skipped_explicit=%d added=%d", postProbeMetrics.CollectionCandidates, postProbeMetrics.CollectionSelected, postProbeMetrics.SkippedExplicit, postProbeMetrics.Added),
Message: fmt.Sprintf("Redfish snapshot: post-probe метрики candidates=%d selected=%d skipped_explicit=%d added=%d", postProbeMetrics.CollectionCandidates, postProbeMetrics.CollectionSelected, postProbeMetrics.SkippedExplicit, postProbeMetrics.Added),
})
}
@@ -1652,7 +1656,7 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: collected %d docs", len(out)),
Message: fmt.Sprintf("Redfish snapshot: собрано %d документов", len(out)),
})
}
@@ -1667,14 +1671,14 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
return asString(errorList[i]["path"]) < asString(errorList[j]["path"])
})
if summary := timings.Summary(12); summary != "" {
slog.Info("redfish-snapshot-timing", "summary", summary)
log.Printf("redfish-snapshot-timing: %s", summary)
}
if emit != nil {
if summary := timings.Summary(3); summary != "" {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish snapshot: top branches by time: %s", summary),
Message: fmt.Sprintf("Redfish snapshot: топ веток по времени: %s", summary),
})
}
}
@@ -2975,14 +2979,14 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: extended diagnostics disabled, skipped %d heavy diagnostic endpoints", skippedDiagnosticTargets),
Message: fmt.Sprintf("Redfish: расширенная диагностика выключена, пропущено %d тяжелых diagnostic endpoint", skippedDiagnosticTargets),
})
}
totalETA := redfishCriticalCooldown() + estimatePlanBETA(len(targets))
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: cooldown before retrying critical endpoints... ETA≈%s", formatETA(totalETA)),
Message: fmt.Sprintf("Redfish: cooldown перед повторным добором критичных endpoint... ETA≈%s", formatETA(totalETA)),
CurrentPhase: "critical_plan_b",
ETASeconds: int(totalETA.Seconds()),
})
@@ -3068,17 +3072,17 @@ func (c *RedfishConnector) recoverCriticalRedfishDocsPlanB(ctx context.Context,
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B top branches by time: %s", summary),
Message: fmt.Sprintf("Redfish: plan-B топ веток по времени: %s", summary),
})
}
emit(Progress{
Status: "running",
Progress: 97,
Message: fmt.Sprintf("Redfish: plan-B completed in %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
Message: fmt.Sprintf("Redfish: plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
})
}
if summary := timings.Summary(12); summary != "" {
slog.Info("redfish-planb-timing", "summary", summary)
log.Printf("redfish-planb-timing: %s", summary)
}
return recovered
}
@@ -3139,7 +3143,7 @@ func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client *
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish: profile plan-B fetching %d endpoints...", len(targets)),
Message: fmt.Sprintf("Redfish: profile plan-B добирает %d endpoint...", len(targets)),
CurrentPhase: "profile_plan_b",
ETASeconds: int(estimateProgressETA(planBStart, 0, len(targets), 2*time.Second).Seconds()),
})
@@ -3163,18 +3167,19 @@ func (c *RedfishConnector) recoverProfilePlanBDocs(ctx context.Context, client *
recovered++
}
if recovered > 0 {
slog.Info("redfish-profile-planb",
"mode", plan.Mode,
"profiles", strings.Join(plan.Profiles, ","),
"targets", len(targets),
"recovered", recovered,
log.Printf(
"redfish-profile-planb: mode=%s profiles=%s targets=%d recovered=%d",
plan.Mode,
strings.Join(plan.Profiles, ","),
len(targets),
recovered,
)
}
if emit != nil {
emit(Progress{
Status: "running",
Progress: 98,
Message: fmt.Sprintf("Redfish: profile plan-B completed in %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
Message: fmt.Sprintf("Redfish: profile plan-B завершен за %s (targets=%d, recovered=%d)", time.Since(planBStart).Round(time.Second), len(targets), recovered),
CurrentPhase: "profile_plan_b",
})
}

View File

@@ -2,7 +2,7 @@ package collector
import (
"context"
"log/slog"
"log"
"net/http"
"strings"
"time"
@@ -62,7 +62,7 @@ func (c *RedfishConnector) collectRedfishLogEntries(ctx context.Context, client
}
if len(out) > 0 {
slog.Info("redfish: collected hardware log entries", "count", len(out), "window", "7d")
log.Printf("redfish: collected %d hardware log entries (Systems+Managers SEL, window=7d)", len(out))
}
return out
}

View File

@@ -3,7 +3,7 @@ package collector
import (
"encoding/json"
"fmt"
"log/slog"
"log"
"sort"
"strings"
"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..."})
}
if _, err := r.getJSON("/redfish/v1"); err != nil {
slog.Warn("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults", "err", err)
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
}
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} {
if ts, err := time.Parse(layout, raw); err == nil {
t := ts.UTC()
slog.Info("redfish replay: inventory last modified", "at", t.Format(time.RFC3339), "source", "InventoryData/Status.LastModifiedTime")
log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339))
return t
}
}

View File

@@ -244,7 +244,7 @@ func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeD
if strings.Contains(name, "pcie switch management endpoint") {
return true
}
if strings.Contains(name, "volume management device") {
if strings.Contains(name, "volume management device nvme raid controller") {
return true
}
return false

View File

@@ -29,7 +29,6 @@ func inspurGroupOEMPlatformsProfile() Profile {
matchFn: func(s MatchSignals) int {
topologyScore := 0
boardScore := 0
manufacturerScore := 0
chassisOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Chassis/", outboardCardHintRe)
systemOutboard := matchedPathTokens(s.ResourceHints, "/redfish/v1/Systems/", outboardCardHintRe)
obDrives := matchedPathTokens(s.ResourceHints, "", obDriveHintRe)
@@ -63,17 +62,10 @@ func inspurGroupOEMPlatformsProfile() Profile {
if anySignalContains(s, "GetServerAllUSBStatus") {
boardScore += 8
}
// 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 {
if topologyScore == 0 || boardScore == 0 {
return 0
}
return min(total, 100)
return min(topologyScore+boardScore, 100)
},
extendAcquisition: func(plan *AcquisitionPlan, _ MatchSignals) {
addPlanNote(plan, "Inspur Group OEM platform fingerprint matched")

View File

@@ -118,52 +118,6 @@ func TestCollectSignalsFromTree_InspurGroupOEMPlatformsSelectsMatchedMode(t *tes
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) {
examples := []string{
"2026-03-18 (G5500 V7) - 210619KUGGXGS2000015.zip",

View File

@@ -21,11 +21,7 @@ func New(result *models.AnalysisResult) *Exporter {
// ExportCSV exports serial numbers to CSV format
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.Comma = ';'
defer writer.Flush()
// Header
@@ -174,42 +170,3 @@ func firstNonEmptyString(values ...string) string {
}
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,13 +52,7 @@ func TestExportCSV_IncludesAllComponentTypesWithUsableSerials(t *testing.T) {
t.Fatalf("ExportCSV failed: %v", err)
}
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()
rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll()
if err != nil {
t.Fatalf("read csv: %v", err)
}

View File

@@ -49,10 +49,9 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro
Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)),
Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)),
PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors),
BMCEventSummary: buildBMCEventSummary(result.Events, collectedAt),
EventLogs: convertEventLogs(result.Events, collectedAt),
PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)),
Sensors: convertSensors(result.Sensors),
EventLogs: convertEventLogs(result.Events, collectedAt),
},
}
@@ -219,9 +218,6 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
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{
Kind: models.DeviceKindPCIe,
@@ -848,7 +844,6 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string)
VendorID: d.VendorID,
DeviceID: d.DeviceID,
NUMANode: d.NUMANode,
IOMMUGroup: intPtrFromDetailMap(d.Details, "iommu_group"),
TemperatureC: temperatureC,
PowerW: powerW,
LifeRemainingPct: floatFromDetailMap(d.Details, "life_remaining_pct"),
@@ -1235,7 +1230,7 @@ func normalizeEventLogSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case "redfish":
return "redfish"
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
return "bmc"
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
return "host"
@@ -2124,17 +2119,6 @@ func parseSocketFromSlot(slot string) int {
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 {
if details == nil {
return 0
@@ -2458,76 +2442,3 @@ func inferTargetHost(targetHost, filename string) string {
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

@@ -12,28 +12,16 @@ type ReanimatorExport struct {
// ReanimatorHardware contains all hardware components
type ReanimatorHardware struct {
Board ReanimatorBoard `json:"board"`
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
Memory []ReanimatorMemory `json:"memory,omitempty"`
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
BMCEventSummary []ReanimatorBMCEventRow `json:"bmc_event_summary,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"`
Board ReanimatorBoard `json:"board"`
Firmware []ReanimatorFirmware `json:"firmware,omitempty"`
CPUs []ReanimatorCPU `json:"cpus,omitempty"`
Memory []ReanimatorMemory `json:"memory,omitempty"`
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
EventLogs []ReanimatorEventLog `json:"event_logs,omitempty"`
PlatformConfig map[string]any `json:"platform_config,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -155,7 +143,6 @@ type ReanimatorPCIe struct {
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
NUMANode int `json:"numa_node,omitempty"`
IOMMUGroup *int `json:"iommu_group,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`

View File

@@ -16,21 +16,11 @@ type AnalysisResult struct {
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
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)
CollectionErrors []CollectionError `json:"collection_errors,omitempty"` // BMC-reported failures to collect specific sections
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
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"`
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"`
}
// Event represents a single log event
@@ -270,16 +260,15 @@ type Storage struct {
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
type StorageVolume struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
Drives []string `json:"drives,omitempty"` // member drive names/labels
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
}
// PCIeDevice represents a PCIe device

View File

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

View File

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

View File

@@ -214,10 +214,8 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
start := offset + ahsHeaderSize
end := start + size
truncated := false
if size < 0 || end > len(data) {
end = len(data)
truncated = true
return nil, fmt.Errorf("invalid payload size for %q", name)
}
payload := append([]byte(nil), data[start:end]...)
@@ -237,9 +235,6 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
Content: content,
Compressed: compressed,
})
if truncated {
break
}
offset = end
}
@@ -997,7 +992,7 @@ func parseEvents(tokens []string) []models.Event {
break
}
if looksLikeEventMessage(tokens[j]) {
message = trimEventJunk(tokens[j])
message = tokens[j]
break
}
}
@@ -1178,7 +1173,7 @@ func looksLikeServerModel(v string) bool {
return false
}
lower := strings.ToLower(v)
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") || strings.Contains(lower, "alletra")
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline")
}
func looksLikeCPUVendor(v string) bool {
@@ -1469,19 +1464,7 @@ func fabricIDFromPath(path string) string {
func inferSeverity(message string) models.Severity {
lower := strings.ToLower(message)
switch {
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"):
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"):
return models.SeverityWarning
default:
return models.SeverityInfo
@@ -1495,73 +1478,21 @@ func inferEventType(message string) string {
return "Login"
case strings.Contains(lower, "logout"):
return "Logout"
case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
case strings.Contains(lower, "network"):
return "Network"
case strings.Contains(lower, "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:
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 {
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
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)
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")
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state")
}
func sanitizeModel(v string) string {

View File

@@ -153,29 +153,6 @@ 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) {
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
content, err := os.ReadFile(path)

View File

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

View File

@@ -19,11 +19,6 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) {
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)
parseMemoryInfo(text, hw)
@@ -56,52 +51,6 @@ func ParseComponentLogEvents(content []byte) []models.Event {
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.
func ParseComponentLogSensors(content []byte) []models.SensorReading {
text := string(content)
@@ -112,68 +61,6 @@ func ParseComponentLogSensors(content []byte) []models.SensorReading {
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
type MemoryRESTInfo struct {
MemModules []struct {
@@ -225,10 +112,9 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
}
for _, mem := range memInfo.MemModules {
item := models.MemoryDIMM{
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
// 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) != ""),
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
Type: mem.MemModType,
Technology: strings.TrimSpace(mem.MemModTechnology),
@@ -250,25 +136,6 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
}
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
}
@@ -296,7 +163,7 @@ type PSURESTInfo struct {
func parsePSUInfo(text string, hw *models.HardwareConfig) {
// Find RESTful PSU info section
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
match := re.FindStringSubmatch(text)
if match == nil {
return
@@ -926,7 +793,7 @@ func parseDiskBackplaneSensors(text string) []models.SensorReading {
}
func parsePSUSummarySensors(text string) []models.SensorReading {
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
match := re.FindStringSubmatch(text)
if match == nil {
return nil
@@ -1074,7 +941,7 @@ func extractComponentFirmware(text string, hw *models.HardwareConfig) {
// Skip extracting from component.log to avoid duplicates
// Extract PSU firmware from RESTful PSU info
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
if match := rePSU.FindStringSubmatch(text); match != nil {
jsonStr := strings.ReplaceAll(match[1], "\n", "")
var psuInfo PSURESTInfo

View File

@@ -1,83 +0,0 @@
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,12 +56,10 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event)
}
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)
for idx, gpu := range gpuByIndex {
newStatus := "OK"
if !isDeassert && faultySet[idx] {
if faultySet[idx] {
newStatus = "Critical"
lastCriticalDetails[idx] = strings.TrimSpace(e.Description)
}

View File

@@ -155,40 +155,6 @@ 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) {
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)
// Create unique key for deduplication
eventKey := eventID + "|" + eventType + "|" + description
eventKey := eventID + "|" + description
if seenEvents[eventKey] {
continue
}

View File

@@ -16,7 +16,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "2.1"
const parserVersion = "1.8"
func init() {
parser.Register(&Parser{})
@@ -163,26 +163,6 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
// (fan RPM, backplane temperature, PSU summary power, etc.).
componentSensors := ParseComponentLogSensors(f.Content)
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),
@@ -234,9 +214,6 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if result.Hardware != nil {
applyGPUStatusFromEvents(result.Hardware, result.Events)
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).
// These override redis/component.log serials which may be stale after disk replacement.
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)

View File

@@ -1,247 +0,0 @@
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

@@ -1,191 +0,0 @@
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))
}
}

View File

@@ -89,9 +89,6 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if f := findByPath(files, "tmp/inventory_disk.log"); f != nil {
result.Hardware.Storage = parseDisks(f.Content)
}
if f := findByPath(files, "tmp/inventory_volume.log"); f != nil {
result.Hardware.Volumes = parseVolumes(f.Content)
}
if f := findByPath(files, "tmp/inventory_card.log"); f != nil {
result.Hardware.PCIeDevices = parseCards(f.Content)
}
@@ -109,7 +106,6 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
for _, f := range findEventFiles(files) {
result.Events = append(result.Events, parseEvents(f.Content)...)
}
applyDIMMWarningsFromEvents(result)
result.Protocol = "ipmi"
result.SourceType = models.SourceTypeArchive
@@ -304,25 +300,6 @@ type xccEventDoc struct {
Items []xccEvent `json:"items"`
}
type xccVolumeDoc struct {
Items []xccVolumeItem `json:"items"`
}
type xccVolumeItem struct {
Volumes []xccVolume `json:"volumes"`
TotalCapacityStr string `json:"totalCapacityStr"`
}
type xccVolume struct {
ID int `json:"id"`
Name string `json:"name"`
Drives string `json:"drives"` // e.g. "M.2 Drive 0, M.2 Drive 1"
RDLvlStr string `json:"rdlvlstr"` // e.g. "RAID 1"
CapacityStr string `json:"capacityStr"` // e.g. "893.750 GiB"
Status int `json:"status"`
StatusStr string `json:"statusStr"` // e.g. "Optimal"
}
type xccEvent struct {
Severity string `json:"severity"` // "I", "W", "E", "C"
Source string `json:"source"`
@@ -496,37 +473,6 @@ func parseDisks(content []byte) []models.Storage {
return out
}
func parseVolumes(content []byte) []models.StorageVolume {
var doc xccVolumeDoc
if err := json.Unmarshal(content, &doc); err != nil || len(doc.Items) == 0 {
return nil
}
var out []models.StorageVolume
for _, item := range doc.Items {
for _, v := range item.Volumes {
vol := models.StorageVolume{
ID: fmt.Sprintf("%d", v.ID),
Name: strings.TrimSpace(v.Name),
RAIDLevel: strings.TrimSpace(v.RDLvlStr),
SizeGB: parseCapacityToGB(v.CapacityStr),
Status: strings.TrimSpace(v.StatusStr),
}
drives := strings.TrimSpace(v.Drives)
if drives != "" {
for _, d := range strings.Split(drives, ",") {
vol.Drives = append(vol.Drives, strings.TrimSpace(d))
}
// M.2 NVMe volumes are managed by Intel VROC (VMD)
if strings.Contains(strings.ToLower(drives), "m.2") {
vol.Controller = "Intel VROC"
}
}
out = append(out, vol)
}
}
return out
}
func parseCards(content []byte) []models.PCIeDevice {
var doc xccCardDoc
if err := json.Unmarshal(content, &doc); err != nil {
@@ -830,96 +776,6 @@ func isUnqualifiedDIMM(value string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(value)), "unqualified dimm")
}
var (
unqualifiedDIMMSlotRE = regexp.MustCompile(`(?i)\bunqualified dimm\s+(\d+)\b`)
unqualifiedDIMMSerialRE = regexp.MustCompile(`(?i)\bserial number is\s+([A-Z0-9-]+)`)
)
func applyDIMMWarningsFromEvents(result *models.AnalysisResult) {
if result == nil || result.Hardware == nil || len(result.Hardware.Memory) == 0 || len(result.Events) == 0 {
return
}
for _, ev := range result.Events {
if !isUnqualifiedDIMM(ev.Description) {
continue
}
idx := findDIMMIndexForUnqualifiedEvent(result.Hardware.Memory, ev.Description)
if idx < 0 {
continue
}
dimm := &result.Hardware.Memory[idx]
dimm.Status = "Warning"
dimm.ErrorDescription = ev.Description
if !ev.Timestamp.IsZero() {
ts := ev.Timestamp.UTC()
dimm.StatusChangedAt = &ts
dimm.StatusCheckedAt = &ts
}
appendDIMMStatusHistory(dimm, ev)
}
}
func findDIMMIndexForUnqualifiedEvent(memory []models.MemoryDIMM, description string) int {
slot := extractUnqualifiedDIMMSlot(description)
serial := normalizeUnqualifiedDIMMSerial(extractUnqualifiedDIMMSerial(description))
for i := range memory {
if slot != "" && strings.EqualFold(strings.TrimSpace(memory[i].Slot), slot) {
return i
}
}
for i := range memory {
if serial != "" && normalizeUnqualifiedDIMMSerial(memory[i].SerialNumber) == serial {
return i
}
}
return -1
}
func extractUnqualifiedDIMMSlot(description string) string {
m := unqualifiedDIMMSlotRE.FindStringSubmatch(description)
if len(m) < 2 {
return ""
}
return "DIMM " + strings.TrimSpace(m[1])
}
func extractUnqualifiedDIMMSerial(description string) string {
m := unqualifiedDIMMSerialRE.FindStringSubmatch(description)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
func normalizeUnqualifiedDIMMSerial(serial string) string {
serial = strings.ToUpper(strings.TrimSpace(serial))
if idx := strings.Index(serial, "-"); idx >= 0 {
serial = serial[:idx]
}
return serial
}
func appendDIMMStatusHistory(dimm *models.MemoryDIMM, ev models.Event) {
if dimm == nil || ev.Timestamp.IsZero() {
return
}
for _, item := range dimm.StatusHistory {
if strings.EqualFold(strings.TrimSpace(item.Status), "Warning") &&
item.ChangedAt.Equal(ev.Timestamp.UTC()) &&
strings.TrimSpace(item.Details) == strings.TrimSpace(ev.Description) {
return
}
}
dimm.StatusHistory = append(dimm.StatusHistory, models.StatusHistoryEntry{
Status: "Warning",
ChangedAt: ev.Timestamp.UTC(),
Details: ev.Description,
})
}
func parseXCCTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
formats := []string{
@@ -981,12 +837,8 @@ func parseCapacityToGB(s string) int {
switch strings.ToUpper(parts[1]) {
case "TB":
return int(v * 1000)
case "TIB":
return int(v * 1099.511627776) // 1 TiB = 1099.511... GB
case "GB":
return int(v)
case "GIB":
return int(v * 1.073741824) // 1 GiB = 1.073741824 GB
case "MB":
return int(v / 1024)
}

View File

@@ -2,7 +2,6 @@ package lenovo_xcc
import (
"testing"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
@@ -225,75 +224,6 @@ func TestParse_LenovoXCCMiniLog_Firmware(t *testing.T) {
}
}
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": [{
@@ -327,44 +257,6 @@ func TestSeverity_UnqualifiedDIMMMessageBecomesWarning(t *testing.T) {
}
}
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(`{

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -38,21 +38,18 @@ type CollectJobResponse struct {
}
type CollectJobStatusResponse struct {
JobID string `json:"job_id"`
Type string `json:"type,omitempty"`
Status string `json:"status"`
Progress *int `json:"progress,omitempty"`
Message string `json:"message,omitempty"`
CurrentPhase string `json:"current_phase,omitempty"`
ETASeconds *int `json:"eta_seconds,omitempty"`
Logs []string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
Result map[string]interface{} `json:"result,omitempty"`
ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"`
ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"`
DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
JobID string `json:"job_id"`
Status string `json:"status"`
Progress *int `json:"progress,omitempty"`
CurrentPhase string `json:"current_phase,omitempty"`
ETASeconds *int `json:"eta_seconds,omitempty"`
Logs []string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
ActiveModules []CollectModuleStatus `json:"active_modules,omitempty"`
ModuleScores []CollectModuleStatus `json:"module_scores,omitempty"`
DebugInfo *CollectDebugInfo `json:"debug_info,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CollectRequestMeta struct {
@@ -66,15 +63,12 @@ type CollectRequestMeta struct {
type Job struct {
ID string
Type string
Status string
Progress int
Message string
CurrentPhase string
ETASeconds int
Logs []string
Error string
Result map[string]interface{}
ActiveModules []CollectModuleStatus
ModuleScores []CollectModuleStatus
DebugInfo *CollectDebugInfo
@@ -113,14 +107,11 @@ func (j *Job) toStatusResponse() CollectJobStatusResponse {
progress := j.Progress
resp := CollectJobStatusResponse{
JobID: j.ID,
Type: j.Type,
Status: j.Status,
Progress: &progress,
Message: j.Message,
CurrentPhase: j.CurrentPhase,
Logs: append([]string(nil), j.Logs...),
Error: j.Error,
Result: j.Result,
ActiveModules: append([]CollectModuleStatus(nil), j.ActiveModules...),
ModuleScores: append([]CollectModuleStatus(nil), j.ModuleScores...),
DebugInfo: cloneCollectDebugInfo(j.DebugInfo),

View File

@@ -174,7 +174,7 @@ func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
spec := buildSpecification(hw)
for _, line := range spec {
if line.Category == "Memory" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 {
return
}
}

View File

@@ -38,39 +38,30 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
tmplContent, err := WebFS.ReadFile("templates/index.html")
if err != nil {
s.htmlError(w, "Template not found", http.StatusInternalServerError)
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
tmpl, err := template.New("index").Parse(string(tmplContent))
if err != nil {
s.htmlError(w, "Template parse error", http.StatusInternalServerError)
http.Error(w, "Template parse error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, map[string]string{
"AppVersion": normalizeDisplayVersion(s.config.AppVersion),
"AppCommit": s.config.AppCommit,
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
"AppVersion": s.config.AppVersion,
"AppCommit": s.config.AppCommit,
})
}
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) {
result := s.GetResult()
title := chartTitle(result)
if result == nil || result.Hardware == nil {
html, err := chartviewer.RenderHTML(nil, title)
if err != nil {
s.htmlError(w, "failed to render viewer", http.StatusInternalServerError)
http.Error(w, "failed to render viewer", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -80,13 +71,13 @@ func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
snapshotBytes, err := currentReanimatorSnapshotBytes(result)
if err != nil {
s.htmlError(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
return
}
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
html, err := chartviewer.RenderHTML(snapshotBytes, title)
if err != nil {
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
http.Error(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
return
}
@@ -137,9 +128,7 @@ func chartTitle(result *models.AnalysisResult) string {
}
func rewriteChartStaticPaths(html []byte) []byte {
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
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
}
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
@@ -394,7 +383,7 @@ func uniqueSortedExtensions(exts []string) []string {
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil {
jsonList(w, []interface{}{}, 0)
jsonResponse(w, []interface{}{})
return
}
@@ -407,18 +396,18 @@ func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
return events[i].Timestamp.After(events[j].Timestamp)
})
jsonList(w, events, len(events))
jsonResponse(w, events)
}
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil {
jsonList(w, []interface{}{}, 0)
jsonResponse(w, []interface{}{})
return
}
sensors := append([]models.SensorReading{}, result.Sensors...)
sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...)
jsonList(w, sensors, len(sensors))
jsonResponse(w, sensors)
}
func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading {
@@ -532,7 +521,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
float64(cpu.FrequencyMHz)/1000,
cpu.Cores,
intFromDetails(cpu.Details, "tdp_w"))
spec = append(spec, SpecLine{Category: "CPU", Name: name, Quantity: count})
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
}
// Memory - group by size, type and frequency (only installed modules)
@@ -567,7 +556,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
memGroups[key]++
}
for key, count := range memGroups {
spec = append(spec, SpecLine{Category: "Memory", Name: key, Quantity: count})
spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count})
}
// Storage - group by type and capacity
@@ -585,7 +574,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
storGroups[key]++
}
for key, count := range storGroups {
spec = append(spec, SpecLine{Category: "Storage", Name: key, Quantity: count})
spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count})
}
// PCIe devices - group by device class/name and manufacturer
@@ -608,7 +597,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
}
for key, count := range pcieGroups {
pcie := pcieDetails[key]
category := "PCIe Device"
category := "PCIe устройство"
name := key
// Determine category based on device class or known GPU names
@@ -617,11 +606,11 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
if isGPU {
category = "GPU"
category = "Графический процессор"
} else if isNetwork {
category = "Network Adapter"
category = "Сетевой адаптер"
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
category = "Controller"
category = "Контроллер"
}
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
@@ -642,7 +631,7 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine {
}
}
for key, count := range psuGroups {
spec = append(spec, SpecLine{Category: "Power Supply", Name: key, Quantity: count})
spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count})
}
return spec
@@ -663,7 +652,7 @@ func nonEmptyStrings(values ...string) []string {
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil {
jsonList(w, []interface{}{}, 0)
jsonResponse(w, []interface{}{})
return
}
@@ -713,7 +702,7 @@ func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
}
}
jsonList(w, serials, len(serials))
jsonResponse(w, serials)
}
func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
@@ -767,12 +756,11 @@ func hasUsableFirmwareVersion(version string) bool {
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
if result == nil || result.Hardware == nil {
jsonList(w, []interface{}{}, 0)
jsonResponse(w, []interface{}{})
return
}
entries := buildFirmwareEntries(result.Hardware)
jsonList(w, entries, len(entries))
jsonResponse(w, buildFirmwareEntries(result.Hardware))
}
type parseErrorEntry struct {
@@ -857,28 +845,6 @@ 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 {
if items[i].Severity != items[j].Severity {
// error > warning > info
@@ -941,7 +907,8 @@ func looksLikeErrorLogLine(line string) bool {
if s == "" {
return false
}
return strings.Contains(s, "error") ||
return strings.Contains(s, "ошибка") ||
strings.Contains(s, "error") ||
strings.Contains(s, "failed") ||
strings.Contains(s, "timeout") ||
strings.Contains(s, "deadline exceeded")
@@ -976,7 +943,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 ") {
return "info"
}
if strings.Contains(s, "error") || strings.Contains(s, "failed") {
if strings.Contains(s, "ошибка") || strings.Contains(s, "error") || strings.Contains(s, "failed") {
return "warning"
}
return "info"
@@ -1234,13 +1201,6 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
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) {
result := s.GetResult()
@@ -1322,7 +1282,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
tempDir, err := os.MkdirTemp("", "logpile-convert-input-*")
if err != nil {
jsonError(w, "Failed to create temp directory", http.StatusInternalServerError)
jsonError(w, "Не удалось создать временную директорию", http.StatusInternalServerError)
return
}
@@ -1369,7 +1329,7 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
if len(inputFiles) == 0 {
_ = os.RemoveAll(tempDir)
jsonError(w, "No supported files to convert", http.StatusBadRequest)
jsonError(w, "Нет файлов поддерживаемого типа для конвертации", http.StatusBadRequest)
return
}
@@ -1382,9 +1342,9 @@ func (s *Server) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Req
TLSMode: "insecure",
})
s.markConvertJob(job.ID)
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Batch conversion started: %d files", len(inputFiles)))
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Запущена пакетная конвертация: %d файлов", len(inputFiles)))
if skipped > 0 {
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Skipped unsupported files: %d", skipped))
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Пропущено неподдерживаемых файлов: %d", skipped))
}
s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "")
@@ -1412,7 +1372,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip")
if err != nil {
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "failed to create zip")
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "не удалось создать zip")
return
}
resultPath := resultFile.Name()
@@ -1424,7 +1384,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
totalProcess := len(inputFiles)
for i, in := range inputFiles {
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Processing %s", in.Name))
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Обработка %s", in.Name))
payload, err := os.ReadFile(in.Path)
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
@@ -1477,13 +1437,13 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
if success == 0 {
_ = zw.Close()
_ = os.Remove(resultPath)
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to convert any file")
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось конвертировать ни один файл")
return
}
summaryLines := []string{fmt.Sprintf("Converted %d of %d files", success, total)}
summaryLines := []string{fmt.Sprintf("Конвертировано %d из %d файлов", success, total)}
if skipped > 0 {
summaryLines = append(summaryLines, fmt.Sprintf("Skipped unsupported: %d", skipped))
summaryLines = append(summaryLines, fmt.Sprintf("Пропущено неподдерживаемых: %d", skipped))
}
summaryLines = append(summaryLines, failures...)
if entry, err := zw.Create("convert-summary.txt"); err == nil {
@@ -1491,7 +1451,7 @@ func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputF
}
if err := zw.Close(); err != nil {
_ = os.Remove(resultPath)
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to pack results")
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Не удалось упаковать результаты")
return
}
@@ -1644,7 +1604,7 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
}
job := s.jobManager.CreateJob(req)
s.jobManager.AppendJobLog(job.ID, "Client: "+s.ClientVersionString())
s.jobManager.AppendJobLog(job.ID, "Клиент: "+s.ClientVersionString())
s.startCollectionJob(job.ID, req)
w.Header().Set("Content-Type", "application/json")
@@ -1673,7 +1633,7 @@ func pingHost(host string, port int, total, need int) (bool, string) {
}
n := int(successes.Load())
if n < need {
return false, fmt.Sprintf("Host unreachable: only %d of %d connection attempts to %s succeeded (minimum required: %d)", n, total, addr, need)
return false, fmt.Sprintf("Хост недоступен: только %d из %d попыток подключения к %s прошли успешно (требуется минимум %d)", n, total, addr, need)
}
return true, ""
}
@@ -1690,12 +1650,12 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
}
connector, ok := s.getCollector(req.Protocol)
if !ok {
jsonError(w, "Protocol connector not registered", http.StatusBadRequest)
jsonError(w, "Коннектор для протокола не зарегистрирован", http.StatusBadRequest)
return
}
prober, ok := connector.(collector.Prober)
if !ok {
jsonError(w, "Connection probe not supported for this protocol", http.StatusBadRequest)
jsonError(w, "Проверка подключения для протокола не поддерживается", http.StatusBadRequest)
return
}
@@ -1709,16 +1669,16 @@ func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
result, err := prober.Probe(ctx, toCollectorRequest(req))
if err != nil {
jsonError(w, "Connection probe failed: "+err.Error(), http.StatusBadRequest)
jsonError(w, "Проверка подключения не удалась: "+err.Error(), http.StatusBadRequest)
return
}
message := "BMC connection established"
message := "Связь с BMC установлена"
if result != nil {
if result.HostPoweredOn {
message = "BMC connection established, host is powered on."
message = "Связь с BMC установлена, host включён."
} else {
message = "BMC connection established, host is powered off. Inventory data may be incomplete."
message = "Связь с BMC установлена, host выключен. Данные инвентаря могут быть неполными."
}
}
@@ -1803,8 +1763,8 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
go func() {
connector, ok := s.getCollector(req.Protocol)
if !ok {
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Protocol connector not registered")
s.jobManager.AppendJobLog(jobID, "Collection completed with error")
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован")
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
return
}
@@ -1878,7 +1838,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
return
}
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
s.jobManager.AppendJobLog(jobID, "Collection completed with error")
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
return
}
@@ -1888,7 +1848,7 @@ func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
applyCollectSourceMetadata(result, req)
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
s.jobManager.AppendJobLog(jobID, "Collection completed")
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
s.SetResult(result)
s.SetDetectedVendor(req.Protocol)
if job, ok := s.jobManager.GetJob(jobID); ok {
@@ -2085,14 +2045,14 @@ func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectReques
func toCollectorRequest(req CollectRequest) collector.Request {
return collector.Request{
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
Host: req.Host,
Protocol: req.Protocol,
Port: req.Port,
Username: req.Username,
AuthType: req.AuthType,
Password: req.Password,
Token: req.Token,
TLSMode: req.TLSMode,
DebugPayloads: req.DebugPayloads,
}
}
@@ -2148,27 +2108,6 @@ func jsonError(w http.ResponseWriter, message string, code int) {
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
func isGPUDevice(deviceClass string) bool {
// Standard PCI class names

View File

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

View File

@@ -3,7 +3,6 @@ package server
import (
"context"
"fmt"
"maps"
"sync"
"time"
)
@@ -23,11 +22,9 @@ func (m *JobManager) CreateJob(req CollectRequest) *Job {
now := time.Now().UTC()
job := &Job{
ID: generateJobID(),
Type: req.Protocol,
Status: CollectStatusQueued,
Progress: 0,
Message: "Job queued",
Logs: []string{formatCollectLogLine(now, "Job queued")},
Logs: []string{formatCollectLogLine(now, "Задача поставлена в очередь")},
CreatedAt: now,
UpdatedAt: now,
RequestMeta: CollectRequestMeta{
@@ -69,7 +66,7 @@ func (m *JobManager) CancelJob(id string) (*Job, bool) {
job.Status = CollectStatusCanceled
job.Error = ""
job.UpdatedAt = time.Now().UTC()
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Collection canceled by user"))
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Сбор отменен пользователем"))
}
cancelFn := job.cancel
@@ -125,7 +122,6 @@ func (m *JobManager) AppendJobLog(id, message string) (*Job, bool) {
job.Logs = append(job.Logs, message)
job.UpdatedAt = time.Now().UTC()
job.Logs[len(job.Logs)-1] = formatCollectLogLine(job.UpdatedAt, message)
job.Message = message
cloned := cloneJob(job)
m.mu.Unlock()
@@ -206,7 +202,7 @@ func (m *JobManager) SkipJob(id string) (*Job, bool) {
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"))
job.Logs = append(job.Logs, formatCollectLogLine(job.UpdatedAt, "Пропуск зависших запросов по команде пользователя"))
cloned := cloneJob(job)
m.mu.Unlock()
@@ -216,18 +212,6 @@ func (m *JobManager) SkipJob(id string) (*Job, bool) {
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 {
m.mu.Lock()
defer m.mu.Unlock()
@@ -281,9 +265,6 @@ func cloneJob(job *Job) *Job {
cloned.DebugInfo = cloneCollectDebugInfo(job.DebugInfo)
cloned.CurrentPhase = job.CurrentPhase
cloned.ETASeconds = job.ETASeconds
if job.Result != nil {
cloned.Result = maps.Clone(job.Result)
}
cloned.cancel = nil
cloned.skipFn = nil
return &cloned

View File

@@ -19,11 +19,10 @@ import (
var WebFS embed.FS
type Config struct {
Port int
PreloadFile string
AppVersion string
AppCommit string
ChartVersion string
Port int
PreloadFile string
AppVersion string
AppCommit string
}
type Server struct {
@@ -91,7 +90,6 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
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("GET /api/convert/{id}", s.handleConvertStatus)
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)

View File

@@ -1,62 +0,0 @@
# 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

@@ -1,60 +0,0 @@
# 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

@@ -1,23 +0,0 @@
# 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.

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>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,64 +7,57 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="page-header">
<div class="page-header-brand">
<p class="page-eyebrow">Diagnostic Workbench</p>
<h1>LOGPile</h1>
<p class="page-subtitle">BMC diagnostic data analyzer</p>
</div>
<div id="header-log-meta" class="header-log-meta hidden">
<div class="header-actions">
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
<button id="header-logs-csv-btn" class="header-action hidden" onclick="exportData('logs-csv')">Logs Export</button>
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
<header>
<div class="app-header-row">
<div class="app-header-brand">
<h1>LOGPile <span class="header-domain">mchus.pro</span></h1>
<p>Анализатор диагностических данных BMC/IPMI</p>
</div>
<div id="header-log-meta" class="header-log-meta hidden">
<div class="header-actions">
<button id="clear-btn" class="hidden" onclick="clearData()">Очистить данные</button>
<button id="header-raw-btn" class="hidden" onclick="exportData('json')">Export Raw Data</button>
<button id="header-reanimator-btn" class="hidden" onclick="exportData('reanimator')">Экспорт Reanimator</button>
<button id="restart-btn" onclick="restartApp()">Перезапуск</button>
<button id="exit-btn" onclick="exitApp()">Выход</button>
</div>
</div>
</div>
</header>
<main class="page-main">
<section id="upload-section" class="control-deck">
<div class="source-switch" role="tablist" aria-label="Data source">
<button type="button" class="source-switch-btn active" data-source-type="archive">Archive</button>
<main>
<section id="upload-section">
<div class="source-switch" role="tablist" aria-label="Источник данных">
<button type="button" class="source-switch-btn active" data-source-type="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="convert">Convert</button>
</div>
<div id="archive-source-content" class="surface-panel upload-panel">
<h2>Open Archive</h2>
<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">
<div id="archive-source-content">
<div class="upload-area" id="drop-zone">
<p>Перетащите архив, TXT/LOG или JSON snapshot сюда</p>
<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>
<span class="upload-kicker">Archive Import</span>
<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>
<button type="button" onclick="document.getElementById('file-input').click()">Выберите файл</button>
<p class="hint">Поддерживаемые форматы: ahs, tar.gz, tar, tgz, sds, zip, json, txt, log</p>
</div>
<div id="upload-status"></div>
<div id="parsers-info" class="parsers-info"></div>
</div>
<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>
<div id="api-source-content" class="api-placeholder hidden">
<form id="api-connect-form" novalidate>
<h3>Подключение к BMC API</h3>
<div id="api-form-errors" class="form-errors hidden"></div>
<div class="api-form-grid">
<label class="api-form-field" for="api-host">
<span>Host</span>
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 or bmc.example.local">
<input id="api-host" name="host" type="text" placeholder="10.0.0.10 или bmc.example.local">
<span class="field-error" data-error-for="host"></span>
</label>
<label class="api-form-field" for="api-port">
<span>Port</span>
<span>Порт</span>
<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>
</label>
@@ -76,52 +69,52 @@
</label>
<label class="api-form-field" id="api-password-field" for="api-password">
<span>Password</span>
<span>Пароль</span>
<input id="api-password" name="password" type="password" autocomplete="current-password">
<span class="field-error" data-error-for="password"></span>
</label>
</div>
<div class="api-form-actions">
<button id="api-connect-btn" type="button">Connect</button>
<button id="api-connect-btn" type="button">Подключиться</button>
</div>
<div id="api-connect-status" class="api-connect-status"></div>
<div id="api-probe-options" class="api-probe-options hidden">
<div id="api-host-off-warning" class="api-host-off-warning hidden">
&#9888; Host is powered off. Inventory data may be incomplete.
&#9888; Host выключен — данные инвентаря могут быть неполными
</div>
<label class="api-form-checkbox" for="api-debug-payloads">
<input id="api-debug-payloads" name="debug_payloads" type="checkbox">
<span>Collect extended diagnostics</span>
<span>Сбор расширенных данных для диагностики</span>
</label>
<div class="api-form-actions">
<button id="api-collect-btn" type="submit">Collect</button>
<button id="api-collect-btn" type="submit">Собрать</button>
</div>
</div>
</form>
<section id="api-job-status" class="job-status hidden" aria-live="polite">
<div class="job-status-header">
<h4>Collection Job Status</h4>
<h4>Статус задачи сбора</h4>
<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>
<button id="skip-hung-btn" type="button" class="hidden" title="Прервать зависшие запросы и перейти к анализу собранных данных">Пропустить зависшие</button>
<button id="cancel-job-btn" type="button">Отменить</button>
</div>
</div>
<div class="job-status-meta">
<div><span class="meta-label">jobId:</span> <code id="job-id-value">-</code></div>
<div>
<span class="meta-label">Status:</span>
<span class="meta-label">Статус:</span>
<span id="job-status-value" class="job-status-badge">Queued</span>
</div>
<div><span class="meta-label">Stage:</span> <span id="job-progress-value">Collecting data...</span></div>
<div><span class="meta-label">Этап:</span> <span id="job-progress-value">Сбор данных...</span></div>
<div><span class="meta-label">ETA:</span> <span id="job-eta-value">-</span></div>
</div>
<div class="job-progress" aria-label="Job progress">
<div class="job-progress" aria-label="Прогресс задачи">
<div id="job-progress-bar" class="job-progress-bar" style="width: 0%">0%</div>
</div>
<div id="job-active-modules" class="job-active-modules hidden">
<p class="meta-label">Active modules:</p>
<p class="meta-label">Активные модули:</p>
<div id="job-active-modules-list" class="job-module-chips"></div>
</div>
<div id="job-debug-info" class="job-debug-info hidden">
@@ -130,23 +123,23 @@
<div id="job-phase-telemetry" class="job-phase-telemetry"></div>
</div>
<div class="job-status-logs">
<p class="meta-label">Step log:</p>
<p class="meta-label">Журнал шагов:</p>
<ul id="job-logs-list"></ul>
</div>
</section>
</div>
<div id="convert-source-content" class="surface-panel upload-panel hidden">
<h2>Batch Convert</h2>
<p>Select a folder with supported files. A separate Reanimator export will be produced for each file.</p>
<div id="convert-source-content" class="api-placeholder hidden">
<h3>Пакетная выгрузка Reanimator</h3>
<p>Выберите папку с файлами поддерживаемого типа. Для каждого файла будет создан отдельный экспорт Reanimator.</p>
<div class="api-form-actions">
<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()">Choose Folder</button>
<button id="convert-run-btn" type="button">Convert to Reanimator</button>
<button id="convert-folder-btn" type="button" onclick="document.getElementById('convert-folder-input').click()">Выбрать папку</button>
<button id="convert-run-btn" type="button">Конвертировать в Reanimator</button>
</div>
<div id="convert-progress" class="convert-progress hidden" aria-live="polite">
<div class="convert-progress-meta">
<span id="convert-progress-label">Preparing...</span>
<span id="convert-progress-label">Подготовка...</span>
<span id="convert-progress-value">0%</span>
</div>
<div class="convert-progress-track">
@@ -159,43 +152,26 @@
</section>
<section id="data-section" class="hidden">
<section class="viewer-panel">
<section class="result-panel">
<div class="audit-viewer-shell">
<iframe
id="audit-viewer-frame"
class="audit-viewer-frame"
title="Hardware report"
title="Reanimator chart viewer"
loading="eager"
scrolling="no"
referrerpolicy="same-origin">
</iframe>
</div>
</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>
</main>
<footer class="page-footer">
<footer>
<div class="footer-buttons">
</div>
<div class="footer-info">
<p>{{if .AppVersion}}LOGPile {{.AppVersion}}{{end}}{{if and .AppVersion .ChartVersion}} · {{end}}{{if .ChartVersion}}Chart {{.ChartVersion}}{{end}}</p>
<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>
</div>
</footer>