Compare commits
3 Commits
v1.2.1
...
ae588ae75a
| Author | SHA1 | Date | |
|---|---|---|---|
| ae588ae75a | |||
| b64a8d8709 | |||
|
|
f9230e12f3 |
199
CLAUDE.md
199
CLAUDE.md
@@ -1,130 +1,95 @@
|
||||
# LOGPile - Инструкции для Claude Code
|
||||
# LOGPile - Engineering Notes (for Claude/Codex)
|
||||
|
||||
## Что это за проект
|
||||
## Project summary
|
||||
|
||||
LOGPile - standalone Go-приложение для анализа BMC/IPMI диагностических архивов с веб-интерфейсом.
|
||||
Приложение запускает локальный HTTP-сервер, парсит архив, автоматически выбирает подходящий parser по vendor и показывает результат в UI + экспортирует данные.
|
||||
LOGPile is a standalone Go app for BMC diagnostics analysis with embedded web UI.
|
||||
|
||||
## Актуальная архитектура
|
||||
Current product modes:
|
||||
1. Upload and parse vendor archives / JSON snapshots.
|
||||
2. Collect live data via Redfish and analyze/export it.
|
||||
|
||||
- Язык: Go 1.22+
|
||||
- HTTP: стандартный `net/http` + `http.ServeMux`
|
||||
- UI: embedded (`//go:embed`) HTML/CSS/Vanilla JS
|
||||
- Бинарник: один executable, без внешних зависимостей на runtime
|
||||
- Порт по умолчанию: `8082` (а не 8080)
|
||||
## Runtime architecture
|
||||
|
||||
## Реальная структура репозитория
|
||||
- Go + `net/http` (`http.ServeMux`)
|
||||
- Embedded UI (`web/embed.go`, `//go:embed templates static`)
|
||||
- In-memory state (`Server.result`, `Server.detectedVendor`)
|
||||
- Job manager for live collect status/logs
|
||||
|
||||
```
|
||||
logpile/
|
||||
├── cmd/logpile/main.go
|
||||
├── internal/
|
||||
│ ├── analyzer/
|
||||
│ ├── exporter/
|
||||
│ ├── models/
|
||||
│ ├── parser/
|
||||
│ │ └── vendors/
|
||||
│ │ ├── generic/
|
||||
│ │ ├── inspur/
|
||||
│ │ ├── nvidia/
|
||||
│ │ ├── nvidia_bug_report/
|
||||
│ │ └── supermicro/
|
||||
│ └── server/
|
||||
├── web/
|
||||
│ ├── static/
|
||||
│ └── templates/
|
||||
├── Makefile
|
||||
└── go.mod
|
||||
```
|
||||
Default port: `8082`.
|
||||
|
||||
## CLI и запуск (актуально)
|
||||
## Key flows
|
||||
|
||||
### Upload flow (`POST /api/upload`)
|
||||
- Accepts multipart file field `archive`.
|
||||
- If file looks like JSON, parsed as `models.AnalysisResult` snapshot.
|
||||
- Otherwise passed to archive parser (`parser.NewBMCParser().ParseFromReader(...)`).
|
||||
- Result stored in memory and exposed by API/UI.
|
||||
|
||||
### Live flow (`POST /api/collect`)
|
||||
- Validates request (`host/protocol/port/username/auth_type/tls_mode`).
|
||||
- Runs collector asynchronously with progress callback.
|
||||
- On success:
|
||||
- source metadata set (`source_type=api`, protocol/host/date),
|
||||
- result becomes current in-memory dataset.
|
||||
- On failed/canceled previous dataset stays unchanged.
|
||||
|
||||
## Collectors
|
||||
|
||||
Registry: `internal/collector/registry.go`
|
||||
|
||||
- `redfish` (real collector):
|
||||
- dynamic discovery of Systems/Chassis/Managers,
|
||||
- CPU/RAM/Storage/GPU/PSU/NIC/PCIe/Firmware mapping,
|
||||
- raw Redfish snapshot (`result.RawPayloads["redfish_tree"]`) for offline future analysis,
|
||||
- progress logs include active collection stage and snapshot progress.
|
||||
- `ipmi` is currently a mock collector scaffold.
|
||||
|
||||
## Export behavior
|
||||
|
||||
Endpoints:
|
||||
- `/api/export/csv`
|
||||
- `/api/export/json`
|
||||
- `/api/export/txt`
|
||||
|
||||
Filename pattern for all exports:
|
||||
`YYYY-MM-DD (SERVER MODEL) - SERVER SN.<ext>`
|
||||
|
||||
Notes:
|
||||
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
|
||||
- TXT export is tabular and mirrors UI sections (no raw JSON section).
|
||||
|
||||
## CLI flags (`cmd/logpile/main.go`)
|
||||
|
||||
- `--port`
|
||||
- `--file` (reserved/preload, not active workflow)
|
||||
- `--version`
|
||||
- `--no-browser`
|
||||
- `--hold-on-crash` (default true on Windows) — keeps console open on fatal crash for debugging.
|
||||
|
||||
## Build / release
|
||||
|
||||
- `make build` -> single local binary (`CGO_ENABLED=0`).
|
||||
- `make build-all` -> cross-platform binaries.
|
||||
- Tags/releases are published with `tea`.
|
||||
- Release notes live in `docs/releases/<tag>.md`.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
Before merge:
|
||||
|
||||
```bash
|
||||
# Сборка
|
||||
make build
|
||||
|
||||
# Запуск (авто-открытие браузера включено)
|
||||
./bin/logpile
|
||||
|
||||
# Явный порт
|
||||
./bin/logpile --port 8082
|
||||
|
||||
# Не открывать браузер автоматически
|
||||
./bin/logpile --no-browser
|
||||
|
||||
# Версия
|
||||
./bin/logpile --version
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Важно: сейчас **нет** subcommand `serve`, запуск идёт напрямую через флаги.
|
||||
If touching collectors/handlers, prefer adding or updating tests in:
|
||||
- `internal/collector/*_test.go`
|
||||
- `internal/server/*_test.go`
|
||||
|
||||
## Основной runtime-flow
|
||||
## Practical coding guidance
|
||||
|
||||
1. `main.go` регистрирует embedded web FS и запускает сервер.
|
||||
2. `POST /api/upload` принимает архив и передаёт его в `parser.BMCParser`.
|
||||
3. `DetectFormat()` выбирает parser с максимальным confidence.
|
||||
4. Результат сохраняется в памяти (`Server.result`) и отдаётся через API.
|
||||
5. `POST /api/collect` запускает in-memory mock lifecycle live-сбора (`queued -> running -> success|failed`, поддерживается `cancel`).
|
||||
6. Для live-flow `AnalysisResult` обновляется только на `success` (при `failed/canceled` остаётся предыдущий результат).
|
||||
7. UI строит вкладки: конфигурация, прошивки, сенсоры, серийники, события.
|
||||
|
||||
## Поддерживаемые parser modules
|
||||
|
||||
- `supermicro` - Supermicro parser
|
||||
- `inspur` - Inspur/Kaytus parser
|
||||
- `nvidia` - NVIDIA Field Diagnostics parser
|
||||
- `nvidia_bug_report` - parser для `nvidia-bug-report.sh`
|
||||
- `generic` - fallback parser
|
||||
|
||||
Реестр parser-ов: `internal/parser/registry.go`, подключение модулей: `internal/parser/vendors/vendors.go`.
|
||||
|
||||
## API (фактически в коде)
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
POST /api/collect
|
||||
GET /api/collect/{id}
|
||||
POST /api/collect/{id}/cancel
|
||||
GET /api/status
|
||||
GET /api/parsers
|
||||
GET /api/events
|
||||
GET /api/sensors
|
||||
GET /api/config
|
||||
GET /api/serials
|
||||
GET /api/firmware
|
||||
GET /api/export/csv
|
||||
GET /api/export/json
|
||||
GET /api/export/txt
|
||||
DELETE /api/clear
|
||||
POST /api/shutdown
|
||||
```
|
||||
|
||||
## Форматы данных и экспорт
|
||||
|
||||
- `AnalysisResult` агрегирует: events, sensors, FRU, hardware.
|
||||
- Экспорт реализован в `internal/exporter/exporter.go`:
|
||||
- CSV: серийные номера компонентов
|
||||
- JSON: полный `AnalysisResult`
|
||||
- TXT: человекочитаемый отчёт
|
||||
|
||||
## Важные текущие ограничения (чтобы не ошибаться в задачах)
|
||||
|
||||
- Upload через `/api/upload` использует `ParseFromReader()`, где сейчас поддержаны `.tar`, `.tar.gz`, `.tgz`.
|
||||
- Код распаковки `.zip` есть, но в текущем upload-пути `zip` не обрабатывается.
|
||||
- Флаг `--file` присутствует в CLI-конфиге, но preload в `Server.Run()` сейчас не выполняется.
|
||||
- Данные хранятся только в памяти процесса; перезапуск очищает состояние.
|
||||
- Live-сбор пока mock-only (реальные Redfish/IPMI коннекторы ещё не подключены).
|
||||
|
||||
## Практические рекомендации для доработок
|
||||
|
||||
- Если меняется parser-логика, обновляй `Version()` соответствующего parser-модуля.
|
||||
- Новые vendor-парсеры регистрируй через import в `internal/parser/vendors/vendors.go`.
|
||||
- Для API/контрактов проверяй согласованность `handlers.go` и `web/static/js/app.js`.
|
||||
- Для UI-изменений не забывай, что ассеты embedded через `web/embed.go`.
|
||||
|
||||
## Приоритетные следующие шаги
|
||||
|
||||
1. Довести поддержку `zip` в upload path (`ParseFromReader`).
|
||||
2. Реализовать preload из `--file`.
|
||||
3. Добавить/актуализировать автотесты для parser и HTTP handlers.
|
||||
4. Расширить vendor coverage (Dell/HPE/Lenovo) по реальным дампам.
|
||||
- Keep API contracts stable with frontend (`web/static/js/app.js`).
|
||||
- When adding Redfish mappings, prefer tolerant/fallback parsing:
|
||||
- alternate collection paths,
|
||||
- `@odata.id` references and embedded members,
|
||||
- deduping by serial/BDF/slot+model.
|
||||
- Avoid breaking snapshot backward compatibility (`AnalysisResult` JSON shape).
|
||||
|
||||
250
README.md
250
README.md
@@ -1,123 +1,79 @@
|
||||
# LOGPile
|
||||
|
||||
**LOGPile** - это инструмент для анализа диагностической информации с BMC серверов (IPMI).
|
||||
Представляет собой standalone Go-бинарник со встроенным веб-интерфейсом, который позволяет анализировать и визуализировать данные из архивов BMC.
|
||||
LOGPile — standalone Go-приложение для анализа диагностических данных BMC.
|
||||
|
||||
## Особенности
|
||||
Поддерживает два сценария:
|
||||
1. Загрузка архивов/снапшотов и оффлайн-анализ в веб-интерфейсе.
|
||||
2. Live-сбор через Redfish API с последующим экспортом и повторной загрузкой оффлайн.
|
||||
|
||||
- **Поддержка различных производителей**: Supermicro, Inspur/Kaytus, Nvidia и другие
|
||||
- **Анализ различных типов данных**:
|
||||
- System Event Log (SEL) - журнал событий IPMI
|
||||
- Field Replaceable Unit (FRU) - серийные номера компонентов
|
||||
- Конфигурация сервера (CPU, RAM, диски, и т.д.)
|
||||
- **Встроенный веб-интерфейс**: Человекочитаемая визуализация данных
|
||||
- **Экспорт данных**: Поддержка экспорта в CSV, JSON и TXT форматы
|
||||
- **Автоматическое определение формата**: Для upload path сейчас поддержаны tar.gz/tgz/tar (zip в плане доработки upload)
|
||||
## Что умеет
|
||||
|
||||
- Standalone бинарник с embedded UI (без внешних статических файлов).
|
||||
- Парсинг vendor-архивов (Supermicro, Inspur/Kaytus, NVIDIA, fallback generic).
|
||||
- Live-сбор по Redfish (`/api/collect`) с прогрессом и журналом шагов.
|
||||
- Расширенный Redfish snapshot:
|
||||
- нормализованные данные (CPU/RAM/Storage/GPU/PSU/NIC/PCIe/Firmware),
|
||||
- сырой `redfish_tree` для будущего анализа.
|
||||
- Загрузка JSON snapshot обратно через `/api/upload` для оффлайн-работы.
|
||||
- Экспорт в CSV / JSON / TXT.
|
||||
|
||||
## Требования
|
||||
|
||||
- Go 1.22+
|
||||
|
||||
## Установка
|
||||
|
||||
### Сборка из исходников
|
||||
## Сборка
|
||||
|
||||
```bash
|
||||
# Клонирование репозитория
|
||||
git clone https://git.mchus.pro/mchus/logpile.git
|
||||
cd logpile
|
||||
|
||||
# Сборка
|
||||
make build
|
||||
```
|
||||
|
||||
### Быстрая установка (Linux)
|
||||
Бинарник будет в `bin/logpile`.
|
||||
|
||||
Для кросс-сборки:
|
||||
|
||||
```bash
|
||||
# Скачать последнюю версию
|
||||
curl -L https://git.mchus.pro/mchus/logpile/-/releases/latest/download/logpile-linux-amd64.tar.gz | tar xz
|
||||
|
||||
# Запуск
|
||||
./logpile
|
||||
make build-all
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
Артефакты:
|
||||
- `bin/logpile-linux-amd64`
|
||||
- `bin/logpile-linux-arm64`
|
||||
- `bin/logpile-darwin-amd64`
|
||||
- `bin/logpile-darwin-arm64`
|
||||
- `bin/logpile-windows-amd64.exe`
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# Запуск веб-сервера
|
||||
./bin/logpile
|
||||
|
||||
# Открыть в браузере
|
||||
open http://localhost:8082
|
||||
|
||||
# С указанием порта
|
||||
./bin/logpile --port 9000
|
||||
|
||||
# Не открывать браузер автоматически
|
||||
./bin/logpile --port 8082
|
||||
./bin/logpile --no-browser
|
||||
./bin/logpile --version
|
||||
```
|
||||
|
||||
## Функционал
|
||||
Отладка падений (чтобы консоль не закрывалась):
|
||||
|
||||
### 1. Анализ архивов
|
||||
- Поддержка upload path: tar.gz/tgz/tar
|
||||
- Автоматическое определение производителя
|
||||
- Извлечение всех доступных данных из архива
|
||||
|
||||
### 2. Парсинг данных IPMI
|
||||
- **System Event Log (SEL)**: Анализ событий системы
|
||||
- **Field Replaceable Unit (FRU)**: Сбор серийных номеров компонентов
|
||||
- **Конфигурация сервера**: CPU, RAM, диски, PCIe устройства, сетевые карты и т.д.
|
||||
|
||||
### 3. Визуализация данных
|
||||
- Интерактивный веб-интерфейс
|
||||
- Отображение событий в хронологическом порядке
|
||||
- Таблицы с конфигурацией сервера
|
||||
- Списки серийных номеров
|
||||
- Показания сенсоров
|
||||
|
||||
### 4. Экспорт данных
|
||||
- **CSV**: Экспорт серийных номеров в формате CSV
|
||||
- **JSON**: Экспорт конфигурации и событий в формате JSON
|
||||
- **TXT**: Экспорт текстового отчета
|
||||
|
||||
## Поддерживаемые производители
|
||||
|
||||
- **Supermicro**
|
||||
- **Inspur/Kaytus**
|
||||
- **Nvidia**
|
||||
- **Generic** (fallback для неизвестных форматов)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```
|
||||
POST /api/upload # Загрузить архив
|
||||
POST /api/collect # Создать задачу live-сбора
|
||||
GET /api/collect/{id} # Получить статус задачи live-сбора
|
||||
POST /api/collect/{id}/cancel # Отменить задачу live-сбора
|
||||
GET /api/status # Получить статус парсинга
|
||||
GET /api/parsers # Получить список доступных парсеров
|
||||
GET /api/events # Получить список событий
|
||||
GET /api/sensors # Получить показания сенсоров
|
||||
GET /api/config # Получить конфигурацию
|
||||
GET /api/serials # Получить серийные номера
|
||||
GET /api/firmware # Получить версии прошивок
|
||||
GET /api/export/csv # Экспорт в CSV
|
||||
GET /api/export/json # Экспорт в JSON
|
||||
GET /api/export/txt # Экспорт текстового отчета
|
||||
DELETE /api/clear # Очистить загруженные данные
|
||||
POST /api/shutdown # Завершить работу приложения
|
||||
```bash
|
||||
./bin/logpile --hold-on-crash
|
||||
```
|
||||
|
||||
`/api/status` и `/api/config` теперь возвращают унифицированные метаданные источника:
|
||||
- `source_type`: `archive` или `api`
|
||||
- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым)
|
||||
- `target_host`: BMC host для live-сбора
|
||||
- `collected_at`: timestamp времени получения данных
|
||||
> На Windows `--hold-on-crash` включён по умолчанию.
|
||||
|
||||
### Контракты live-сбора (`/api/collect`)
|
||||
## Форматы загрузки
|
||||
|
||||
`POST /api/collect` принимает JSON:
|
||||
`POST /api/upload` принимает:
|
||||
- архивы: `.tar`, `.tar.gz`, `.tgz`
|
||||
- JSON snapshot (`AnalysisResult`)
|
||||
|
||||
## Live Redfish
|
||||
|
||||
Запуск live-сбора:
|
||||
|
||||
```http
|
||||
POST /api/collect
|
||||
```
|
||||
|
||||
Пример body:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -127,75 +83,69 @@ POST /api/shutdown # Завершить работу приложени
|
||||
"username": "admin",
|
||||
"auth_type": "password",
|
||||
"password": "secret",
|
||||
"tls_mode": "strict"
|
||||
"tls_mode": "insecure"
|
||||
}
|
||||
```
|
||||
|
||||
- Обязательные поля: `host`, `protocol`, `port`, `username`, `auth_type`, `tls_mode`
|
||||
- `protocol`: `redfish` или `ipmi`
|
||||
- `auth_type`: `password` или `token`
|
||||
- `tls_mode`: `strict` или `insecure`
|
||||
- При `auth_type=password` обязателен `password`, при `auth_type=token` — `token`
|
||||
Жизненный цикл задачи:
|
||||
`queued -> running -> success|failed|canceled`
|
||||
|
||||
Ответ `202 Accepted`:
|
||||
Статус и прогресс:
|
||||
- `GET /api/collect/{id}`
|
||||
- `POST /api/collect/{id}/cancel`
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "job_a1b2c3d4e5f6g7h8",
|
||||
"status": "queued",
|
||||
"message": "Collection job accepted",
|
||||
"created_at": "2026-02-04T10:15:20Z"
|
||||
}
|
||||
## Экспорт
|
||||
|
||||
- `GET /api/export/csv` — серийные номера
|
||||
- `GET /api/export/json` — полный `AnalysisResult` (включая `raw_payloads`)
|
||||
- `GET /api/export/txt` — табличный отчёт по разделам UI
|
||||
|
||||
Имена экспортируемых файлов:
|
||||
|
||||
`YYYY-MM-DD (SERVER MODEL) - SERVER SN.<ext>`
|
||||
|
||||
Пример:
|
||||
`2026-02-04 (SYS-421GE-TNHR2) - C8X123456789.json`
|
||||
|
||||
## API
|
||||
|
||||
```text
|
||||
POST /api/upload
|
||||
POST /api/collect
|
||||
GET /api/collect/{id}
|
||||
POST /api/collect/{id}/cancel
|
||||
GET /api/status
|
||||
GET /api/parsers
|
||||
GET /api/events
|
||||
GET /api/sensors
|
||||
GET /api/config
|
||||
GET /api/serials
|
||||
GET /api/firmware
|
||||
GET /api/export/csv
|
||||
GET /api/export/json
|
||||
GET /api/export/txt
|
||||
DELETE /api/clear
|
||||
POST /api/shutdown
|
||||
```
|
||||
|
||||
`GET /api/collect/{id}` возвращает `200 OK` со статусом задачи:
|
||||
`/api/status` и `/api/config` содержат метаданные источника:
|
||||
- `source_type`: `archive` | `api`
|
||||
- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым)
|
||||
- `target_host`
|
||||
- `collected_at`
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "job_a1b2c3d4e5f6g7h8",
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"logs": ["Задача поставлена в очередь"],
|
||||
"created_at": "2026-02-04T10:15:20Z",
|
||||
"updated_at": "2026-02-04T10:15:20Z"
|
||||
}
|
||||
```
|
||||
## Структура
|
||||
|
||||
`POST /api/collect/{id}/cancel` возвращает `200 OK` и переводит задачу в `canceled`.
|
||||
Жизненный цикл задачи: `queued -> running -> success|failed|canceled`.
|
||||
|
||||
### Подключаемые коннекторы live-сбора
|
||||
|
||||
- `redfish`: реальный сбор конфигурации с BMC по REST API (`/redfish/v1/...`)
|
||||
- `ipmi`: временный mock-коннектор (каркас для последующей замены на реальный IPMI)
|
||||
|
||||
`host` можно передавать как обычный hostname (например, `bmc01.example.local`) или как полный URL (`https://10.0.0.10:8443`).
|
||||
`AnalysisResult` для API-сценария обновляется на `success`; при `failed/canceled` предыдущие загруженные данные сохраняются.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
logpile/
|
||||
├── cmd/logpile/main.go # Точка входа
|
||||
├── internal/
|
||||
│ ├── parser/ # Парсинг архивов и IPMI данных
|
||||
│ ├── models/ # Модели данных
|
||||
│ ├── analyzer/ # Логика анализа
|
||||
│ ├── exporter/ # Экспорт данных
|
||||
│ └── server/ # HTTP сервер и handlers
|
||||
├── web/ # Embedded веб-интерфейс
|
||||
│ ├── static/ # CSS, JS, изображения
|
||||
│ └── templates/ # HTML шаблоны
|
||||
├── testdata/ # Примеры архивов для тестов
|
||||
├── go.mod
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```text
|
||||
cmd/logpile/main.go # entrypoint
|
||||
internal/collector/ # live collectors (redfish, ipmi mock)
|
||||
internal/parser/ # archive parsers
|
||||
internal/server/ # HTTP handlers
|
||||
internal/exporter/ # CSV/JSON/TXT export
|
||||
internal/models/ # data contracts
|
||||
web/ # embedded templates/static
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Этот проект лицензирован под MIT License - смотрите файл [LICENSE](LICENSE) для получения подробной информации.
|
||||
|
||||
## Автор
|
||||
|
||||
[https://mchus.pro](https://mchus.pro)
|
||||
MIT — см. `LICENSE`.
|
||||
|
||||
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -8,6 +8,7 @@ import (
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/supermicro"
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
||||
|
||||
// Generic fallback parser (must be last for lowest priority)
|
||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/generic"
|
||||
|
||||
46
internal/parser/vendors/xigmanas/README.md
vendored
Normal file
46
internal/parser/vendors/xigmanas/README.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Xigmanas Parser
|
||||
|
||||
Parser for Xigmanas (FreeBSD-based NAS) system logs.
|
||||
|
||||
## Supported Files
|
||||
|
||||
- `xigmanas` - Main system log file with configuration and status information
|
||||
- `dmesg` - Kernel messages and hardware initialization information
|
||||
- SMART data from disk monitoring
|
||||
|
||||
## Features
|
||||
|
||||
This parser extracts the following information from Xigmanas logs:
|
||||
|
||||
### System Information
|
||||
- Firmware version
|
||||
- System uptime
|
||||
- CPU model and specifications
|
||||
- Memory configuration
|
||||
- Hardware platform information
|
||||
|
||||
### Storage Information
|
||||
- Disk models and serial numbers
|
||||
- Disk capacity and health status
|
||||
- SMART temperature readings
|
||||
|
||||
### Hardware Configuration
|
||||
- CPU information
|
||||
- Memory modules
|
||||
- Storage devices
|
||||
|
||||
## Detection Logic
|
||||
|
||||
The parser detects Xigmanas format by looking for:
|
||||
- Files with "xigmanas", "system", or "dmesg" in their names
|
||||
- Content containing "XigmaNAS" or "FreeBSD" strings
|
||||
- SMART-related information in log content
|
||||
|
||||
## Example Output
|
||||
|
||||
The parser populates the following fields in AnalysisResult:
|
||||
- `Hardware.Firmware` - Firmware versions
|
||||
- `Hardware.CPUs` - CPU information
|
||||
- `Hardware.Memory` - Memory configuration
|
||||
- `Hardware.Storage` - Storage devices with SMART data
|
||||
- `Sensors` - Temperature readings from SMART data
|
||||
392
internal/parser/vendors/xigmanas/parser.go
vendored
Normal file
392
internal/parser/vendors/xigmanas/parser.go
vendored
Normal file
@@ -0,0 +1,392 @@
|
||||
// Package xigmanas provides parser for XigmaNAS diagnostic dumps.
|
||||
package xigmanas
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
// parserVersion - increment when parsing logic changes.
|
||||
const parserVersion = "2.0.0"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
}
|
||||
|
||||
// Parser implements VendorParser for XigmaNAS logs.
|
||||
type Parser struct{}
|
||||
|
||||
func (p *Parser) Name() string { return "XigmaNAS Parser" }
|
||||
func (p *Parser) Vendor() string { return "xigmanas" }
|
||||
func (p *Parser) Version() string {
|
||||
return parserVersion
|
||||
}
|
||||
|
||||
// Detect checks if files contain typical XigmaNAS markers.
|
||||
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
||||
confidence := 0
|
||||
|
||||
for _, f := range files {
|
||||
path := strings.ToLower(f.Path)
|
||||
content := strings.ToLower(string(f.Content))
|
||||
|
||||
if strings.Contains(path, "xigmanas") || strings.HasSuffix(path, "dmesg") {
|
||||
confidence += 20
|
||||
}
|
||||
if strings.Contains(content, `loader_brand="xigmanas"`) {
|
||||
confidence += 70
|
||||
}
|
||||
if strings.Contains(content, "xigmanas kernel build") {
|
||||
confidence += 35
|
||||
}
|
||||
if strings.Contains(content, "system uptime:") && strings.Contains(content, "routing tables:") {
|
||||
confidence += 20
|
||||
}
|
||||
if strings.Contains(content, "s.m.a.r.t. [/dev/") {
|
||||
confidence += 10
|
||||
}
|
||||
if confidence >= 100 {
|
||||
return 100
|
||||
}
|
||||
}
|
||||
|
||||
if confidence > 100 {
|
||||
return 100
|
||||
}
|
||||
return confidence
|
||||
}
|
||||
|
||||
// Parse parses XigmaNAS logs and returns normalized data.
|
||||
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
||||
result := &models.AnalysisResult{
|
||||
Events: make([]models.Event, 0),
|
||||
FRU: make([]models.FRUInfo, 0),
|
||||
Sensors: make([]models.SensorReading, 0),
|
||||
Hardware: &models.HardwareConfig{
|
||||
Firmware: make([]models.FirmwareInfo, 0),
|
||||
CPUs: make([]models.CPU, 0),
|
||||
Memory: make([]models.MemoryDIMM, 0),
|
||||
Storage: make([]models.Storage, 0),
|
||||
},
|
||||
}
|
||||
|
||||
content := joinFileContents(files)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
parseSystemInfo(content, result)
|
||||
parseCPU(content, result)
|
||||
parseMemory(content, result)
|
||||
parseUptime(content, result)
|
||||
parseZFSState(content, result)
|
||||
parseStorageAndSMART(content, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func joinFileContents(files []parser.ExtractedFile) string {
|
||||
var b strings.Builder
|
||||
for _, f := range files {
|
||||
b.Write(f.Content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func parseSystemInfo(content string, result *models.AnalysisResult) {
|
||||
if m := regexp.MustCompile(`(?m)^Version:\s*\n-+\s*\n([^\n]+)`).FindStringSubmatch(content); len(m) == 2 {
|
||||
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
||||
DeviceName: "XigmaNAS",
|
||||
Version: strings.TrimSpace(m[1]),
|
||||
})
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)^smbios\.bios\.version="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
|
||||
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{
|
||||
DeviceName: "System BIOS",
|
||||
Version: strings.TrimSpace(m[1]),
|
||||
})
|
||||
}
|
||||
|
||||
board := models.BoardInfo{}
|
||||
if m := regexp.MustCompile(`(?m)^smbios\.system\.maker="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
|
||||
board.Manufacturer = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)^smbios\.system\.product="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
|
||||
board.ProductName = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)^smbios\.system\.serial="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
|
||||
board.SerialNumber = strings.TrimSpace(m[1])
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)^smbios\.system\.uuid="([^"]+)"`).FindStringSubmatch(content); len(m) == 2 {
|
||||
board.UUID = strings.TrimSpace(m[1])
|
||||
}
|
||||
result.Hardware.BoardInfo = board
|
||||
}
|
||||
|
||||
func parseCPU(content string, result *models.AnalysisResult) {
|
||||
var cores, threads int
|
||||
if m := regexp.MustCompile(`(?m)^FreeBSD/SMP:\s+\d+\s+package\(s\)\s+x\s+(\d+)\s+core\(s\)`).FindStringSubmatch(content); len(m) == 2 {
|
||||
cores = parseInt(m[1])
|
||||
threads = cores
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
cpuRe := regexp.MustCompile(`(?m)^CPU:\s+(.+?)\s+\(([\d.]+)-MHz`)
|
||||
for _, m := range cpuRe.FindAllStringSubmatch(content, -1) {
|
||||
model := strings.TrimSpace(m[1])
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
|
||||
result.Hardware.CPUs = append(result.Hardware.CPUs, models.CPU{
|
||||
Socket: len(result.Hardware.CPUs),
|
||||
Model: model,
|
||||
Cores: cores,
|
||||
Threads: threads,
|
||||
FrequencyMHz: int(parseFloat(m[2])),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseMemory(content string, result *models.AnalysisResult) {
|
||||
if m := regexp.MustCompile(`(?m)^real memory\s*=\s*\d+\s+\((\d+)\s+MB\)`).FindStringSubmatch(content); len(m) == 2 {
|
||||
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
|
||||
Slot: "system",
|
||||
Present: true,
|
||||
SizeMB: parseInt(m[1]),
|
||||
Type: "DRAM",
|
||||
Status: "ok",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for logs that only have active/inactive breakdown.
|
||||
if m := regexp.MustCompile(`(?m)^Mem:\s+(.+)$`).FindStringSubmatch(content); len(m) == 2 {
|
||||
totalMB := 0
|
||||
tokenRe := regexp.MustCompile(`(\d+)M`)
|
||||
for _, t := range tokenRe.FindAllStringSubmatch(m[1], -1) {
|
||||
totalMB += parseInt(t[1])
|
||||
}
|
||||
if totalMB > 0 {
|
||||
result.Hardware.Memory = append(result.Hardware.Memory, models.MemoryDIMM{
|
||||
Slot: "system",
|
||||
Present: true,
|
||||
SizeMB: totalMB,
|
||||
Type: "DRAM",
|
||||
Status: "estimated",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseUptime(content string, result *models.AnalysisResult) {
|
||||
upRe := regexp.MustCompile(`(?m)^(\d+:\d+(?:AM|PM))\s+up\s+(.+?),\s+(\d+)\s+users?,\s+load averages?:\s+([\d.]+),\s+([\d.]+),\s+([\d.]+)$`)
|
||||
m := upRe.FindStringSubmatch(content)
|
||||
if len(m) != 7 {
|
||||
return
|
||||
}
|
||||
|
||||
result.Events = append(result.Events, models.Event{
|
||||
Timestamp: time.Now(),
|
||||
Source: "System",
|
||||
EventType: "Uptime",
|
||||
Severity: models.SeverityInfo,
|
||||
Description: "System uptime and load averages parsed",
|
||||
RawData: "time=" + m[1] + "; uptime=" + m[2] + "; users=" + m[3] + "; load=" + m[4] + "," + m[5] + "," + m[6],
|
||||
})
|
||||
}
|
||||
|
||||
func parseZFSState(content string, result *models.AnalysisResult) {
|
||||
m := regexp.MustCompile(`(?m)^state:\s+([A-Z]+)$`).FindStringSubmatch(content)
|
||||
if len(m) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
state := m[1]
|
||||
severity := models.SeverityInfo
|
||||
if state != "ONLINE" {
|
||||
severity = models.SeverityWarning
|
||||
}
|
||||
result.Events = append(result.Events, models.Event{
|
||||
Timestamp: time.Now(),
|
||||
Source: "ZFS",
|
||||
EventType: "Pool State",
|
||||
Severity: severity,
|
||||
Description: "ZFS pool state: " + state,
|
||||
RawData: state,
|
||||
})
|
||||
}
|
||||
|
||||
func parseStorageAndSMART(content string, result *models.AnalysisResult) {
|
||||
type smartInfo struct {
|
||||
model string
|
||||
serial string
|
||||
firmware string
|
||||
health string
|
||||
tempC int
|
||||
capacityB int64
|
||||
}
|
||||
|
||||
storageBySlot := make(map[string]*models.Storage)
|
||||
scsiRe := regexp.MustCompile(`(?m)^<([^>]+)>\s+at\s+scbus\d+\s+target\s+\d+\s+lun\s+\d+\s+\(([^,]+),([^)]+)\)$`)
|
||||
for _, m := range scsiRe.FindAllStringSubmatch(content, -1) {
|
||||
slot := strings.TrimSpace(m[3])
|
||||
model, fw := splitModelAndFirmware(strings.TrimSpace(m[1]))
|
||||
entry := &models.Storage{
|
||||
Slot: slot,
|
||||
Type: guessStorageType(slot),
|
||||
Model: model,
|
||||
Firmware: fw,
|
||||
Present: true,
|
||||
Interface: "SCSI/SATA",
|
||||
}
|
||||
storageBySlot[slot] = entry
|
||||
}
|
||||
|
||||
smartBySlot := make(map[string]smartInfo)
|
||||
sectionRe := regexp.MustCompile(`(?m)^S\.M\.A\.R\.T\.\s+\[(/dev/[^\]]+)\]:\s*\n-+\n`)
|
||||
sections := sectionRe.FindAllStringSubmatchIndex(content, -1)
|
||||
for i, sec := range sections {
|
||||
// sec indexes:
|
||||
// [0]=full start, [1]=full end, [2]=capture 1 start, [3]=capture 1 end
|
||||
if len(sec) < 4 {
|
||||
continue
|
||||
}
|
||||
slot := strings.TrimPrefix(strings.TrimSpace(content[sec[2]:sec[3]]), "/dev/")
|
||||
bodyStart := sec[1]
|
||||
bodyEnd := len(content)
|
||||
if i+1 < len(sections) {
|
||||
bodyEnd = sections[i+1][0]
|
||||
}
|
||||
body := content[bodyStart:bodyEnd]
|
||||
|
||||
info := smartInfo{
|
||||
model: findFirst(body, `(?m)^Device Model:\s+(.+)$`),
|
||||
serial: findFirst(body, `(?m)^Serial Number:\s+(.+)$`),
|
||||
firmware: findFirst(body, `(?m)^Firmware Version:\s+(.+)$`),
|
||||
health: findFirst(body, `(?m)^SMART overall-health self-assessment test result:\s+(.+)$`),
|
||||
}
|
||||
info.capacityB = parseCapacityBytes(findFirst(body, `(?m)^User Capacity:\s+([\d,]+)\s+bytes`))
|
||||
if t := findFirst(body, `(?m)^\s*194\s+Temperature_Celsius.*?-\s+(\d+)(?:\s|\()`); t != "" {
|
||||
info.tempC = parseInt(t)
|
||||
}
|
||||
smartBySlot[slot] = info
|
||||
|
||||
if info.tempC > 0 {
|
||||
status := "ok"
|
||||
if info.health != "" && !strings.EqualFold(info.health, "PASSED") {
|
||||
status = "warning"
|
||||
}
|
||||
result.Sensors = append(result.Sensors, models.SensorReading{
|
||||
Name: "disk_temp_" + slot,
|
||||
Type: "temperature",
|
||||
Value: float64(info.tempC),
|
||||
Unit: "C",
|
||||
Status: status,
|
||||
RawValue: strconv.Itoa(info.tempC),
|
||||
})
|
||||
}
|
||||
if info.health != "" && !strings.EqualFold(info.health, "PASSED") {
|
||||
result.Events = append(result.Events, models.Event{
|
||||
Timestamp: time.Now(),
|
||||
Source: "SMART",
|
||||
EventType: "Disk Health",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: "SMART health is not PASSED for " + slot,
|
||||
RawData: info.health,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Merge SMART data into storage entries and add missing entries.
|
||||
for slot, info := range smartBySlot {
|
||||
s := storageBySlot[slot]
|
||||
if s == nil {
|
||||
s = &models.Storage{
|
||||
Slot: slot,
|
||||
Type: guessStorageType(slot),
|
||||
Present: true,
|
||||
Interface: "SATA",
|
||||
}
|
||||
storageBySlot[slot] = s
|
||||
}
|
||||
|
||||
if s.Model == "" && info.model != "" {
|
||||
s.Model = info.model
|
||||
}
|
||||
if info.serial != "" {
|
||||
s.SerialNumber = info.serial
|
||||
}
|
||||
if s.Firmware == "" && info.firmware != "" {
|
||||
s.Firmware = info.firmware
|
||||
}
|
||||
if info.capacityB > 0 {
|
||||
s.SizeGB = int(info.capacityB / 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range storageBySlot {
|
||||
result.Hardware.Storage = append(result.Hardware.Storage, *s)
|
||||
}
|
||||
}
|
||||
|
||||
func splitModelAndFirmware(raw string) (string, string) {
|
||||
fields := strings.Fields(raw)
|
||||
if len(fields) < 2 {
|
||||
return raw, ""
|
||||
}
|
||||
last := fields[len(fields)-1]
|
||||
// Firmware token is usually compact (e.g. GKAOAB0A, 1.00).
|
||||
if regexp.MustCompile(`^[A-Za-z0-9._-]{2,12}$`).MatchString(last) {
|
||||
return strings.TrimSpace(strings.Join(fields[:len(fields)-1], " ")), last
|
||||
}
|
||||
return raw, ""
|
||||
}
|
||||
|
||||
func guessStorageType(slot string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(slot, "cd"):
|
||||
return "optical"
|
||||
case strings.HasPrefix(slot, "da"), strings.HasPrefix(slot, "ada"):
|
||||
return "hdd"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func findFirst(content, expr string) string {
|
||||
m := regexp.MustCompile(expr).FindStringSubmatch(content)
|
||||
if len(m) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
|
||||
func parseCapacityBytes(s string) int64 {
|
||||
clean := strings.ReplaceAll(strings.TrimSpace(s), ",", "")
|
||||
if clean == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseInt(clean, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
v, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return v
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
return v
|
||||
}
|
||||
94
internal/parser/vendors/xigmanas/parser_test.go
vendored
Normal file
94
internal/parser/vendors/xigmanas/parser_test.go
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
package xigmanas
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
func TestParserDetect(t *testing.T) {
|
||||
p := &Parser{}
|
||||
|
||||
files := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "xigmanas",
|
||||
Content: []byte(`Version:
|
||||
--------
|
||||
14.3.0.5
|
||||
loader_brand="XigmaNAS"`),
|
||||
},
|
||||
}
|
||||
|
||||
if got := p.Detect(files); got < 70 {
|
||||
t.Fatalf("expected high confidence, got %d", got)
|
||||
}
|
||||
|
||||
files2 := []parser.ExtractedFile{
|
||||
{
|
||||
Path: "random_file.txt",
|
||||
Content: []byte("Some random content"),
|
||||
},
|
||||
}
|
||||
|
||||
if got := p.Detect(files2); got != 0 {
|
||||
t.Fatalf("expected zero confidence, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParserParseExample(t *testing.T) {
|
||||
p := &Parser{}
|
||||
|
||||
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
|
||||
raw, err := os.ReadFile(examplePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read example file: %v", err)
|
||||
}
|
||||
|
||||
files := []parser.ExtractedFile{
|
||||
{Path: "xigmanas", Content: raw},
|
||||
}
|
||||
|
||||
result, err := p.Parse(files)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if result == nil || result.Hardware == nil {
|
||||
t.Fatal("expected non-nil result with hardware")
|
||||
}
|
||||
|
||||
if len(result.Hardware.Firmware) == 0 {
|
||||
t.Fatal("expected firmware data")
|
||||
}
|
||||
foundXigmaVersion := false
|
||||
for _, fw := range result.Hardware.Firmware {
|
||||
if fw.DeviceName == "XigmaNAS" && fw.Version == "14.3.0.5" {
|
||||
foundXigmaVersion = true
|
||||
}
|
||||
}
|
||||
if !foundXigmaVersion {
|
||||
t.Fatalf("expected XigmaNAS firmware version 14.3.0.5, got %+v", result.Hardware.Firmware)
|
||||
}
|
||||
|
||||
if result.Hardware.BoardInfo.Manufacturer != "HP" {
|
||||
t.Fatalf("expected board manufacturer HP, got %q", result.Hardware.BoardInfo.Manufacturer)
|
||||
}
|
||||
if len(result.Hardware.CPUs) == 0 {
|
||||
t.Fatal("expected at least one CPU")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(result.Hardware.CPUs[0].Model), "athlon") {
|
||||
t.Fatalf("expected CPU model to contain athlon, got %q", result.Hardware.CPUs[0].Model)
|
||||
}
|
||||
|
||||
if len(result.Hardware.Storage) < 4 {
|
||||
t.Fatalf("expected at least 4 storage devices, got %d", len(result.Hardware.Storage))
|
||||
}
|
||||
if len(result.Sensors) == 0 {
|
||||
t.Fatal("expected SMART temperature sensors")
|
||||
}
|
||||
if len(result.Events) == 0 {
|
||||
t.Fatal("expected events from uptime/zfs sections")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user