Add Reanimator format export support
Implement export to Reanimator format for asset tracking integration. Features: - New API endpoint: GET /api/export/reanimator - Web UI button "Экспорт Reanimator" in Configuration tab - Auto-detect CPU manufacturer (Intel/AMD/ARM/Ampere) - Generate PCIe serial numbers if missing - Merge GPUs and NetworkAdapters into pcie_devices - Filter components without serial numbers - RFC3339 timestamp format - Full compliance with Reanimator specification Changes: - Add reanimator_models.go: data models for Reanimator format - Add reanimator_converter.go: conversion functions - Add reanimator_converter_test.go: unit tests - Add reanimator_integration_test.go: integration tests - Update handlers.go: add handleExportReanimator - Update server.go: register /api/export/reanimator route - Update index.html: add export button - Update CLAUDE.md: document export behavior - Add REANIMATOR_EXPORT.md: implementation summary Tests: All tests passing (15+ new tests) Format spec: example/docs/INTEGRATION_GUIDE.md Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -50,6 +50,7 @@ Endpoints:
|
|||||||
- `/api/export/csv`
|
- `/api/export/csv`
|
||||||
- `/api/export/json`
|
- `/api/export/json`
|
||||||
- `/api/export/txt`
|
- `/api/export/txt`
|
||||||
|
- `/api/export/reanimator`
|
||||||
|
|
||||||
Filename pattern for all exports:
|
Filename pattern for all exports:
|
||||||
`YYYY-MM-DD (SERVER MODEL) - SERVER SN.<ext>`
|
`YYYY-MM-DD (SERVER MODEL) - SERVER SN.<ext>`
|
||||||
@@ -57,6 +58,17 @@ Filename pattern for all exports:
|
|||||||
Notes:
|
Notes:
|
||||||
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
|
- JSON export contains full `AnalysisResult`, including `raw_payloads`.
|
||||||
- TXT export is tabular and mirrors UI sections (no raw JSON section).
|
- TXT export is tabular and mirrors UI sections (no raw JSON section).
|
||||||
|
- **Reanimator export** (`/api/export/reanimator`):
|
||||||
|
- Exports hardware data in Reanimator format for integration with asset tracking systems.
|
||||||
|
- Format specification: `example/docs/INTEGRATION_GUIDE.md`
|
||||||
|
- Requires `hardware.board.serial_number` to be present.
|
||||||
|
- Key features:
|
||||||
|
- Infers CPU manufacturer from model name (Intel/AMD/ARM/Ampere).
|
||||||
|
- Generates PCIe serial numbers if missing: `{board_serial}-PCIE-{slot}`.
|
||||||
|
- Adds status fields (defaults to "OK").
|
||||||
|
- RFC3339 timestamp format.
|
||||||
|
- Includes GPUs and NetworkAdapters as PCIe devices.
|
||||||
|
- Filters out storage devices and PSUs without serial numbers.
|
||||||
|
|
||||||
## CLI flags (`cmd/logpile/main.go`)
|
## CLI flags (`cmd/logpile/main.go`)
|
||||||
|
|
||||||
|
|||||||
224
REANIMATOR_EXPORT.md
Normal file
224
REANIMATOR_EXPORT.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Reanimator Export - Implementation Summary
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Реализован новый формат экспорта данных LOGPile в формат Reanimator для интеграции с системами отслеживания серверных компонентов (asset tracking).
|
||||||
|
|
||||||
|
## Реализованные компоненты
|
||||||
|
|
||||||
|
### 1. Модели данных (`internal/exporter/reanimator_models.go`)
|
||||||
|
|
||||||
|
Определены структуры для формата Reanimator:
|
||||||
|
- `ReanimatorExport` - корневая структура экспорта
|
||||||
|
- `ReanimatorHardware` - контейнер для всех аппаратных компонентов
|
||||||
|
- `ReanimatorBoard` - материнская плата/сервер
|
||||||
|
- `ReanimatorCPU` - процессоры
|
||||||
|
- `ReanimatorMemory` - модули памяти (DIMM)
|
||||||
|
- `ReanimatorStorage` - накопители
|
||||||
|
- `ReanimatorPCIe` - PCIe устройства
|
||||||
|
- `ReanimatorPSU` - блоки питания
|
||||||
|
- `ReanimatorFirmware` - прошивки
|
||||||
|
|
||||||
|
### 2. Функции конвертации (`internal/exporter/reanimator_converter.go`)
|
||||||
|
|
||||||
|
Главная функция: `ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, error)`
|
||||||
|
|
||||||
|
Вспомогательные функции:
|
||||||
|
- `inferCPUManufacturer()` - определение производителя CPU по модели (Intel/AMD/ARM/Ampere)
|
||||||
|
- `generatePCIeSerialNumber()` - генерация серийных номеров для PCIe устройств
|
||||||
|
- `inferStorageStatus()` - определение статуса накопителей
|
||||||
|
- `convertBoard()`, `convertCPUs()`, `convertMemory()`, и т.д. - конвертация отдельных секций
|
||||||
|
|
||||||
|
**Ключевые особенности конвертации:**
|
||||||
|
- Автоматическое определение производителя CPU из модели
|
||||||
|
- Генерация серийных номеров для PCIe устройств: `{board_serial}-PCIE-{slot}`
|
||||||
|
- Объединение GPUs и NetworkAdapters в секцию pcie_devices
|
||||||
|
- Фильтрация компонентов без серийных номеров (storage, PSU)
|
||||||
|
- Установка статуса по умолчанию "OK" для обнаруженных компонентов
|
||||||
|
- RFC3339 формат для collected_at
|
||||||
|
- Вывод target_host из filename если отсутствует
|
||||||
|
|
||||||
|
### 3. HTTP эндпоинт
|
||||||
|
|
||||||
|
**Маршрут:** `GET /api/export/reanimator`
|
||||||
|
|
||||||
|
**Обработчик:** `handleExportReanimator()` в `internal/server/handlers.go`
|
||||||
|
|
||||||
|
**Функциональность:**
|
||||||
|
- Проверка наличия данных hardware
|
||||||
|
- Конвертация в формат Reanimator
|
||||||
|
- Возврат JSON с отступами для читаемости
|
||||||
|
- Установка заголовка Content-Disposition для скачивания
|
||||||
|
|
||||||
|
### 4. Frontend интеграция
|
||||||
|
|
||||||
|
Добавлена кнопка "Экспорт Reanimator" в веб-интерфейсе:
|
||||||
|
- Расположение: вкладка "Конфигурация"
|
||||||
|
- Использует существующую функцию `exportData('reanimator')`
|
||||||
|
|
||||||
|
### 5. Тесты
|
||||||
|
|
||||||
|
**Unit-тесты** (`reanimator_converter_test.go`):
|
||||||
|
- `TestConvertToReanimator` - основная функция конвертации
|
||||||
|
- `TestInferCPUManufacturer` - определение производителя CPU
|
||||||
|
- `TestGeneratePCIeSerialNumber` - генерация серийных номеров
|
||||||
|
- `TestInferStorageStatus` - определение статуса накопителей
|
||||||
|
- `TestConvertCPUs`, `TestConvertMemory`, и т.д. - тесты для каждого типа компонентов
|
||||||
|
|
||||||
|
**Интеграционные тесты** (`reanimator_integration_test.go`):
|
||||||
|
- `TestFullReanimatorExport` - полный экспорт с реалистичными данными
|
||||||
|
- `TestReanimatorExportWithoutTargetHost` - тест вывода target_host
|
||||||
|
|
||||||
|
**Результаты:** Все тесты проходят успешно ✓
|
||||||
|
|
||||||
|
### 6. Документация
|
||||||
|
|
||||||
|
Обновлен `CLAUDE.md`:
|
||||||
|
- Добавлен эндпоинт `/api/export/reanimator` в секцию "Export behavior"
|
||||||
|
- Описаны ключевые особенности экспорта
|
||||||
|
- Добавлена ссылка на спецификацию формата
|
||||||
|
|
||||||
|
### 7. Примеры
|
||||||
|
|
||||||
|
Создан пример экспорта: `example/docs/export-example-logpile.json`
|
||||||
|
|
||||||
|
## Формат экспорта
|
||||||
|
|
||||||
|
### Обязательные поля:
|
||||||
|
- `collected_at` (RFC3339)
|
||||||
|
- `target_host`
|
||||||
|
- `hardware.board.serial_number`
|
||||||
|
|
||||||
|
### Структура экспорта:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "redfish://10.10.10.103",
|
||||||
|
"source_type": "api",
|
||||||
|
"protocol": "redfish",
|
||||||
|
"target_host": "10.10.10.103",
|
||||||
|
"collected_at": "2026-02-10T15:30:00Z",
|
||||||
|
"hardware": {
|
||||||
|
"board": {...},
|
||||||
|
"firmware": [...],
|
||||||
|
"cpus": [...],
|
||||||
|
"memory": [...],
|
||||||
|
"storage": [...],
|
||||||
|
"pcie_devices": [...],
|
||||||
|
"power_supplies": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Соответствие спецификации Reanimator
|
||||||
|
|
||||||
|
Формат полностью соответствует спецификации из `example/docs/INTEGRATION_GUIDE.md`:
|
||||||
|
|
||||||
|
✓ Все обязательные поля присутствуют
|
||||||
|
✓ Правильные типы данных
|
||||||
|
✓ RFC3339 формат времени
|
||||||
|
✓ Генерация серийных номеров для PCIe
|
||||||
|
✓ Определение производителя CPU
|
||||||
|
✓ Статусы компонентов
|
||||||
|
✓ Включение пустых слотов памяти (present=false)
|
||||||
|
|
||||||
|
## Особенности реализации
|
||||||
|
|
||||||
|
### Маппинг моделей LOGPile → Reanimator
|
||||||
|
|
||||||
|
| LOGPile | Reanimator | Примечания |
|
||||||
|
|---------|------------|------------|
|
||||||
|
| `BoardInfo` | `board` | Прямой маппинг |
|
||||||
|
| `CPU` | `cpus` | + manufacturer (выводится) + status |
|
||||||
|
| `MemoryDIMM` | `memory` | Прямой маппинг |
|
||||||
|
| `Storage` | `storage` | + status (OK/Empty) |
|
||||||
|
| `PCIeDevice` | `pcie_devices` | + model + status |
|
||||||
|
| `GPU` | `pcie_devices` | Объединены как DisplayController |
|
||||||
|
| `NetworkAdapter` | `pcie_devices` | Объединены как NetworkController |
|
||||||
|
| `PSU` | `power_supplies` | Прямой маппинг |
|
||||||
|
| `FirmwareInfo` | `firmware` | Прямой маппинг |
|
||||||
|
|
||||||
|
### Фильтрация данных
|
||||||
|
|
||||||
|
**Исключаются из экспорта:**
|
||||||
|
- Storage без serial_number
|
||||||
|
- PSU без serial_number или present=false
|
||||||
|
- NetworkAdapters с present=false
|
||||||
|
|
||||||
|
**Включаются в экспорт:**
|
||||||
|
- Memory с present=false (как Empty slots)
|
||||||
|
- PCIe устройства без serial_number (генерируется)
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Через Web UI:
|
||||||
|
1. Загрузить архив или собрать данные через API
|
||||||
|
2. Перейти на вкладку "Конфигурация"
|
||||||
|
3. Нажать "Экспорт Reanimator"
|
||||||
|
|
||||||
|
### Через API:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8082/api/export/reanimator > reanimator.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Программно:
|
||||||
|
```go
|
||||||
|
import "git.mchus.pro/mchus/logpile/internal/exporter"
|
||||||
|
|
||||||
|
result := &models.AnalysisResult{...}
|
||||||
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
||||||
|
if err != nil {
|
||||||
|
// handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, _ := json.MarshalIndent(reanimatorData, "", " ")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Запуск тестов:
|
||||||
|
```bash
|
||||||
|
# Все тесты
|
||||||
|
go test ./internal/exporter/...
|
||||||
|
|
||||||
|
# Только тесты Reanimator
|
||||||
|
go test ./internal/exporter/... -v -run Reanimator
|
||||||
|
|
||||||
|
# С покрытием
|
||||||
|
go test ./internal/exporter/... -cover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Файлы изменений
|
||||||
|
|
||||||
|
**Новые файлы:**
|
||||||
|
- `internal/exporter/reanimator_models.go` (4.6 KB)
|
||||||
|
- `internal/exporter/reanimator_converter.go` (10 KB)
|
||||||
|
- `internal/exporter/reanimator_converter_test.go` (8.0 KB)
|
||||||
|
- `internal/exporter/reanimator_integration_test.go` (7.4 KB)
|
||||||
|
- `internal/exporter/generate_example_test.go` (4.3 KB)
|
||||||
|
- `example/docs/export-example-logpile.json` (2.3 KB)
|
||||||
|
|
||||||
|
**Измененные файлы:**
|
||||||
|
- `internal/server/handlers.go` - добавлен handleExportReanimator
|
||||||
|
- `internal/server/server.go` - добавлен маршрут
|
||||||
|
- `web/templates/index.html` - добавлена кнопка экспорта
|
||||||
|
- `CLAUDE.md` - обновлена документация
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- ✓ Обратная совместимость: существующие экспорты (JSON/CSV/TXT) не затронуты
|
||||||
|
- ✓ Формат данных: `AnalysisResult` не изменен
|
||||||
|
- ✓ API контракты: новый эндпоинт не влияет на существующие
|
||||||
|
|
||||||
|
## Будущие улучшения
|
||||||
|
|
||||||
|
1. Поддержка статусов из реальных данных (Warning/Critical) для Storage
|
||||||
|
2. Расширенная телеметрия для компонентов
|
||||||
|
3. Валидация экспорта против JSON схемы Reanimator
|
||||||
|
4. Поддержка инкрементальных обновлений
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Статус:** ✅ Реализация завершена и протестирована
|
||||||
|
**Версия:** LOGPile v1.2.1+
|
||||||
|
**Дата:** 2026-02-12
|
||||||
395
internal/exporter/reanimator_converter.go
Normal file
395
internal/exporter/reanimator_converter.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertToReanimator converts AnalysisResult to Reanimator export format
|
||||||
|
func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, error) {
|
||||||
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("no data available for export")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware == nil {
|
||||||
|
return nil, fmt.Errorf("no hardware data available for export")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Hardware.BoardInfo.SerialNumber == "" {
|
||||||
|
return nil, fmt.Errorf("board serial_number is required for Reanimator export")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target host (required field)
|
||||||
|
targetHost := result.TargetHost
|
||||||
|
if targetHost == "" {
|
||||||
|
// Try to extract from filename (e.g., "redfish://10.10.10.103")
|
||||||
|
if strings.HasPrefix(result.Filename, "redfish://") {
|
||||||
|
targetHost = strings.TrimPrefix(result.Filename, "redfish://")
|
||||||
|
} else if strings.HasPrefix(result.Filename, "ipmi://") {
|
||||||
|
targetHost = strings.TrimPrefix(result.Filename, "ipmi://")
|
||||||
|
} else {
|
||||||
|
targetHost = "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boardSerial := result.Hardware.BoardInfo.SerialNumber
|
||||||
|
|
||||||
|
export := &ReanimatorExport{
|
||||||
|
Filename: result.Filename,
|
||||||
|
SourceType: result.SourceType,
|
||||||
|
Protocol: result.Protocol,
|
||||||
|
TargetHost: targetHost,
|
||||||
|
CollectedAt: formatRFC3339(result.CollectedAt),
|
||||||
|
Hardware: ReanimatorHardware{
|
||||||
|
Board: convertBoard(result.Hardware.BoardInfo),
|
||||||
|
Firmware: convertFirmware(result.Hardware.Firmware),
|
||||||
|
CPUs: convertCPUs(result.Hardware.CPUs),
|
||||||
|
Memory: convertMemory(result.Hardware.Memory),
|
||||||
|
Storage: convertStorage(result.Hardware.Storage),
|
||||||
|
PCIeDevices: convertPCIeDevices(result.Hardware, boardSerial),
|
||||||
|
PowerSupplies: convertPowerSupplies(result.Hardware.PowerSupply),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return export, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRFC3339 formats time in RFC3339 format, returns current time if zero
|
||||||
|
func formatRFC3339(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertBoard converts BoardInfo to Reanimator format
|
||||||
|
func convertBoard(board models.BoardInfo) ReanimatorBoard {
|
||||||
|
return ReanimatorBoard{
|
||||||
|
Manufacturer: board.Manufacturer,
|
||||||
|
ProductName: board.ProductName,
|
||||||
|
SerialNumber: board.SerialNumber,
|
||||||
|
PartNumber: board.PartNumber,
|
||||||
|
UUID: board.UUID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertFirmware converts firmware information to Reanimator format
|
||||||
|
func convertFirmware(firmware []models.FirmwareInfo) []ReanimatorFirmware {
|
||||||
|
if len(firmware) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ReanimatorFirmware, 0, len(firmware))
|
||||||
|
for _, fw := range firmware {
|
||||||
|
result = append(result, ReanimatorFirmware{
|
||||||
|
DeviceName: fw.DeviceName,
|
||||||
|
Version: fw.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertCPUs converts CPU information to Reanimator format
|
||||||
|
func convertCPUs(cpus []models.CPU) []ReanimatorCPU {
|
||||||
|
if len(cpus) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ReanimatorCPU, 0, len(cpus))
|
||||||
|
for _, cpu := range cpus {
|
||||||
|
manufacturer := inferCPUManufacturer(cpu.Model)
|
||||||
|
|
||||||
|
result = append(result, ReanimatorCPU{
|
||||||
|
Socket: cpu.Socket,
|
||||||
|
Model: cpu.Model,
|
||||||
|
Cores: cpu.Cores,
|
||||||
|
Threads: cpu.Threads,
|
||||||
|
FrequencyMHz: cpu.FrequencyMHz,
|
||||||
|
MaxFrequencyMHz: cpu.MaxFreqMHz,
|
||||||
|
Manufacturer: manufacturer,
|
||||||
|
Status: "OK", // CPUs are typically OK if detected
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMemory converts memory modules to Reanimator format
|
||||||
|
func convertMemory(memory []models.MemoryDIMM) []ReanimatorMemory {
|
||||||
|
if len(memory) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ReanimatorMemory, 0, len(memory))
|
||||||
|
for _, mem := range memory {
|
||||||
|
status := mem.Status
|
||||||
|
if status == "" {
|
||||||
|
if mem.Present {
|
||||||
|
status = "OK"
|
||||||
|
} else {
|
||||||
|
status = "Empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ReanimatorMemory{
|
||||||
|
Slot: mem.Slot,
|
||||||
|
Location: mem.Location,
|
||||||
|
Present: mem.Present,
|
||||||
|
SizeMB: mem.SizeMB,
|
||||||
|
Type: mem.Type,
|
||||||
|
MaxSpeedMHz: mem.MaxSpeedMHz,
|
||||||
|
CurrentSpeedMHz: mem.CurrentSpeedMHz,
|
||||||
|
Manufacturer: mem.Manufacturer,
|
||||||
|
SerialNumber: mem.SerialNumber,
|
||||||
|
PartNumber: mem.PartNumber,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertStorage converts storage devices to Reanimator format
|
||||||
|
func convertStorage(storage []models.Storage) []ReanimatorStorage {
|
||||||
|
if len(storage) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ReanimatorStorage, 0, len(storage))
|
||||||
|
for _, stor := range storage {
|
||||||
|
// Skip storage without serial number
|
||||||
|
if stor.SerialNumber == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status := inferStorageStatus(stor)
|
||||||
|
|
||||||
|
result = append(result, ReanimatorStorage{
|
||||||
|
Slot: stor.Slot,
|
||||||
|
Type: stor.Type,
|
||||||
|
Model: stor.Model,
|
||||||
|
SizeGB: stor.SizeGB,
|
||||||
|
SerialNumber: stor.SerialNumber,
|
||||||
|
Manufacturer: stor.Manufacturer,
|
||||||
|
Firmware: stor.Firmware,
|
||||||
|
Interface: stor.Interface,
|
||||||
|
Present: stor.Present,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPCIeDevices converts PCIe devices, GPUs, and network adapters to Reanimator format
|
||||||
|
func convertPCIeDevices(hw *models.HardwareConfig, boardSerial string) []ReanimatorPCIe {
|
||||||
|
result := make([]ReanimatorPCIe, 0)
|
||||||
|
|
||||||
|
// Convert regular PCIe devices
|
||||||
|
for _, pcie := range hw.PCIeDevices {
|
||||||
|
serialNumber := pcie.SerialNumber
|
||||||
|
if serialNumber == "" || serialNumber == "N/A" {
|
||||||
|
// Generate serial number
|
||||||
|
serialNumber = generatePCIeSerialNumber(boardSerial, pcie.Slot, pcie.BDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine model (prefer PartNumber, fallback to DeviceClass)
|
||||||
|
model := pcie.PartNumber
|
||||||
|
if model == "" {
|
||||||
|
model = pcie.DeviceClass
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ReanimatorPCIe{
|
||||||
|
Slot: pcie.Slot,
|
||||||
|
VendorID: pcie.VendorID,
|
||||||
|
DeviceID: pcie.DeviceID,
|
||||||
|
BDF: pcie.BDF,
|
||||||
|
DeviceClass: pcie.DeviceClass,
|
||||||
|
Manufacturer: pcie.Manufacturer,
|
||||||
|
Model: model,
|
||||||
|
LinkWidth: pcie.LinkWidth,
|
||||||
|
LinkSpeed: pcie.LinkSpeed,
|
||||||
|
MaxLinkWidth: pcie.MaxLinkWidth,
|
||||||
|
MaxLinkSpeed: pcie.MaxLinkSpeed,
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Firmware: "", // PCIeDevice doesn't have firmware in models
|
||||||
|
Status: "OK",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert GPUs as PCIe devices
|
||||||
|
for _, gpu := range hw.GPUs {
|
||||||
|
serialNumber := gpu.SerialNumber
|
||||||
|
if serialNumber == "" {
|
||||||
|
// Generate serial number
|
||||||
|
serialNumber = generatePCIeSerialNumber(boardSerial, gpu.Slot, gpu.BDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine device class
|
||||||
|
deviceClass := "DisplayController"
|
||||||
|
if gpu.Model != "" {
|
||||||
|
deviceClass = gpu.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ReanimatorPCIe{
|
||||||
|
Slot: gpu.Slot,
|
||||||
|
VendorID: gpu.VendorID,
|
||||||
|
DeviceID: gpu.DeviceID,
|
||||||
|
BDF: gpu.BDF,
|
||||||
|
DeviceClass: deviceClass,
|
||||||
|
Manufacturer: gpu.Manufacturer,
|
||||||
|
Model: gpu.Model,
|
||||||
|
LinkWidth: gpu.CurrentLinkWidth,
|
||||||
|
LinkSpeed: gpu.CurrentLinkSpeed,
|
||||||
|
MaxLinkWidth: gpu.MaxLinkWidth,
|
||||||
|
MaxLinkSpeed: gpu.MaxLinkSpeed,
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Firmware: gpu.Firmware,
|
||||||
|
Status: inferGPUStatus(gpu.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert network adapters as PCIe devices
|
||||||
|
for _, nic := range hw.NetworkAdapters {
|
||||||
|
if !nic.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber := nic.SerialNumber
|
||||||
|
if serialNumber == "" {
|
||||||
|
// Generate serial number
|
||||||
|
serialNumber = generatePCIeSerialNumber(boardSerial, nic.Slot, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ReanimatorPCIe{
|
||||||
|
Slot: nic.Slot,
|
||||||
|
VendorID: nic.VendorID,
|
||||||
|
DeviceID: nic.DeviceID,
|
||||||
|
BDF: "",
|
||||||
|
DeviceClass: "NetworkController",
|
||||||
|
Manufacturer: nic.Vendor,
|
||||||
|
Model: nic.Model,
|
||||||
|
LinkWidth: 0,
|
||||||
|
LinkSpeed: "",
|
||||||
|
MaxLinkWidth: 0,
|
||||||
|
MaxLinkSpeed: "",
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Firmware: nic.Firmware,
|
||||||
|
Status: inferNetworkStatus(nic.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertPowerSupplies converts power supplies to Reanimator format
|
||||||
|
func convertPowerSupplies(psus []models.PSU) []ReanimatorPSU {
|
||||||
|
if len(psus) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]ReanimatorPSU, 0, len(psus))
|
||||||
|
for _, psu := range psus {
|
||||||
|
// Skip PSUs without serial number (if not present)
|
||||||
|
if !psu.Present || psu.SerialNumber == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status := psu.Status
|
||||||
|
if status == "" {
|
||||||
|
if psu.Present {
|
||||||
|
status = "OK"
|
||||||
|
} else {
|
||||||
|
status = "Empty"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, ReanimatorPSU{
|
||||||
|
Slot: psu.Slot,
|
||||||
|
Present: psu.Present,
|
||||||
|
Model: psu.Model,
|
||||||
|
Vendor: psu.Vendor,
|
||||||
|
WattageW: psu.WattageW,
|
||||||
|
SerialNumber: psu.SerialNumber,
|
||||||
|
PartNumber: psu.PartNumber,
|
||||||
|
Firmware: psu.Firmware,
|
||||||
|
Status: status,
|
||||||
|
InputType: psu.InputType,
|
||||||
|
InputPowerW: psu.InputPowerW,
|
||||||
|
OutputPowerW: psu.OutputPowerW,
|
||||||
|
InputVoltage: psu.InputVoltage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferCPUManufacturer determines CPU manufacturer from model string
|
||||||
|
func inferCPUManufacturer(model string) string {
|
||||||
|
upper := strings.ToUpper(model)
|
||||||
|
|
||||||
|
// Intel patterns
|
||||||
|
if strings.Contains(upper, "INTEL") ||
|
||||||
|
strings.Contains(upper, "XEON") ||
|
||||||
|
strings.Contains(upper, "CORE I") {
|
||||||
|
return "Intel"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AMD patterns
|
||||||
|
if strings.Contains(upper, "AMD") ||
|
||||||
|
strings.Contains(upper, "EPYC") ||
|
||||||
|
strings.Contains(upper, "RYZEN") ||
|
||||||
|
strings.Contains(upper, "THREADRIPPER") {
|
||||||
|
return "AMD"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ARM patterns
|
||||||
|
if strings.Contains(upper, "ARM") ||
|
||||||
|
strings.Contains(upper, "CORTEX") {
|
||||||
|
return "ARM"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ampere patterns
|
||||||
|
if strings.Contains(upper, "AMPERE") ||
|
||||||
|
strings.Contains(upper, "ALTRA") {
|
||||||
|
return "Ampere"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePCIeSerialNumber generates a serial number for PCIe device
|
||||||
|
func generatePCIeSerialNumber(boardSerial, slot, bdf string) string {
|
||||||
|
if slot != "" {
|
||||||
|
return fmt.Sprintf("%s-PCIE-%s", boardSerial, slot)
|
||||||
|
}
|
||||||
|
if bdf != "" {
|
||||||
|
// Use BDF as identifier (e.g., "0000:18:00.0" -> "0000-18-00-0")
|
||||||
|
safeBDF := strings.ReplaceAll(strings.ReplaceAll(bdf, ":", "-"), ".", "-")
|
||||||
|
return fmt.Sprintf("%s-PCIE-%s", boardSerial, safeBDF)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-PCIE-UNKNOWN", boardSerial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferStorageStatus determines storage device status
|
||||||
|
func inferStorageStatus(stor models.Storage) string {
|
||||||
|
if !stor.Present {
|
||||||
|
return "Empty"
|
||||||
|
}
|
||||||
|
return "OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferGPUStatus converts GPU status to Reanimator status
|
||||||
|
func inferGPUStatus(status string) string {
|
||||||
|
if status == "" {
|
||||||
|
return "OK"
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferNetworkStatus converts network adapter status to Reanimator status
|
||||||
|
func inferNetworkStatus(status string) string {
|
||||||
|
if status == "" {
|
||||||
|
return "OK"
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
377
internal/exporter/reanimator_converter_test.go
Normal file
377
internal/exporter/reanimator_converter_test.go
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertToReanimator(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input *models.AnalysisResult
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil result",
|
||||||
|
input: nil,
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "no data available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no hardware",
|
||||||
|
input: &models.AnalysisResult{
|
||||||
|
Filename: "test.json",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "no hardware data available",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no board serial",
|
||||||
|
input: &models.AnalysisResult{
|
||||||
|
Filename: "test.json",
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "board serial_number is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid minimal data",
|
||||||
|
input: &models.AnalysisResult{
|
||||||
|
Filename: "test.json",
|
||||||
|
SourceType: "api",
|
||||||
|
Protocol: "redfish",
|
||||||
|
TargetHost: "10.10.10.10",
|
||||||
|
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{
|
||||||
|
Manufacturer: "Supermicro",
|
||||||
|
ProductName: "X12DPG-QT6",
|
||||||
|
SerialNumber: "TEST123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ConvertToReanimator(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error containing %q, got nil", tt.errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Error("expected non-nil result")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Hardware.Board.SerialNumber != tt.input.Hardware.BoardInfo.SerialNumber {
|
||||||
|
t.Errorf("board serial mismatch: got %q, want %q",
|
||||||
|
result.Hardware.Board.SerialNumber,
|
||||||
|
tt.input.Hardware.BoardInfo.SerialNumber)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferCPUManufacturer(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
model string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"INTEL(R) XEON(R) GOLD 6530", "Intel"},
|
||||||
|
{"Intel Core i9-12900K", "Intel"},
|
||||||
|
{"AMD EPYC 7763", "AMD"},
|
||||||
|
{"AMD Ryzen 9 5950X", "AMD"},
|
||||||
|
{"ARM Cortex-A78", "ARM"},
|
||||||
|
{"Ampere Altra Max", "Ampere"},
|
||||||
|
{"Unknown CPU Model", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.model, func(t *testing.T) {
|
||||||
|
got := inferCPUManufacturer(tt.model)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("inferCPUManufacturer(%q) = %q, want %q", tt.model, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratePCIeSerialNumber(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
boardSerial string
|
||||||
|
slot string
|
||||||
|
bdf string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with slot",
|
||||||
|
boardSerial: "TEST123",
|
||||||
|
slot: "PCIeCard1",
|
||||||
|
bdf: "0000:18:00.0",
|
||||||
|
want: "TEST123-PCIE-PCIeCard1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without slot, with bdf",
|
||||||
|
boardSerial: "TEST123",
|
||||||
|
slot: "",
|
||||||
|
bdf: "0000:18:00.0",
|
||||||
|
want: "TEST123-PCIE-0000-18-00-0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without slot and bdf",
|
||||||
|
boardSerial: "TEST123",
|
||||||
|
slot: "",
|
||||||
|
bdf: "",
|
||||||
|
want: "TEST123-PCIE-UNKNOWN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := generatePCIeSerialNumber(tt.boardSerial, tt.slot, tt.bdf)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("generatePCIeSerialNumber() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferStorageStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
stor models.Storage
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "present",
|
||||||
|
stor: models.Storage{
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
want: "OK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not present",
|
||||||
|
stor: models.Storage{
|
||||||
|
Present: false,
|
||||||
|
},
|
||||||
|
want: "Empty",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := inferStorageStatus(tt.stor)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("inferStorageStatus() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertCPUs(t *testing.T) {
|
||||||
|
cpus := []models.CPU{
|
||||||
|
{
|
||||||
|
Socket: 0,
|
||||||
|
Model: "INTEL(R) XEON(R) GOLD 6530",
|
||||||
|
Cores: 32,
|
||||||
|
Threads: 64,
|
||||||
|
FrequencyMHz: 2100,
|
||||||
|
MaxFreqMHz: 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Socket: 1,
|
||||||
|
Model: "AMD EPYC 7763",
|
||||||
|
Cores: 64,
|
||||||
|
Threads: 128,
|
||||||
|
FrequencyMHz: 2450,
|
||||||
|
MaxFreqMHz: 3500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertCPUs(cpus)
|
||||||
|
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 CPUs, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Manufacturer != "Intel" {
|
||||||
|
t.Errorf("expected Intel manufacturer for first CPU, got %q", result[0].Manufacturer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[1].Manufacturer != "AMD" {
|
||||||
|
t.Errorf("expected AMD manufacturer for second CPU, got %q", result[1].Manufacturer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Status != "OK" {
|
||||||
|
t.Errorf("expected OK status, got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertMemory(t *testing.T) {
|
||||||
|
memory := []models.MemoryDIMM{
|
||||||
|
{
|
||||||
|
Slot: "CPU0_C0D0",
|
||||||
|
Present: true,
|
||||||
|
SizeMB: 32768,
|
||||||
|
Type: "DDR5",
|
||||||
|
SerialNumber: "TEST-MEM-001",
|
||||||
|
Status: "OK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "CPU0_C1D0",
|
||||||
|
Present: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertMemory(memory)
|
||||||
|
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 memory modules, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Status != "OK" {
|
||||||
|
t.Errorf("expected OK status for first module, got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[1].Status != "Empty" {
|
||||||
|
t.Errorf("expected Empty status for second module, got %q", result[1].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStorage(t *testing.T) {
|
||||||
|
storage := []models.Storage{
|
||||||
|
{
|
||||||
|
Slot: "OB01",
|
||||||
|
Type: "NVMe",
|
||||||
|
Model: "INTEL SSDPF2KX076T1",
|
||||||
|
SerialNumber: "BTAX41900GF87P6DGN",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "OB02",
|
||||||
|
Type: "NVMe",
|
||||||
|
Model: "INTEL SSDPF2KX076T1",
|
||||||
|
SerialNumber: "", // No serial - should be skipped
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertStorage(storage)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 storage device (skipped one without serial), got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Status != "OK" {
|
||||||
|
t.Errorf("expected OK status, got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPCIeDevices(t *testing.T) {
|
||||||
|
hw := &models.HardwareConfig{
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "PCIeCard1",
|
||||||
|
VendorID: 32902,
|
||||||
|
DeviceID: 2912,
|
||||||
|
BDF: "0000:18:00.0",
|
||||||
|
DeviceClass: "MassStorageController",
|
||||||
|
Manufacturer: "Intel",
|
||||||
|
PartNumber: "RSP3DD080F",
|
||||||
|
SerialNumber: "RAID-001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "PCIeCard2",
|
||||||
|
DeviceClass: "NetworkController",
|
||||||
|
Manufacturer: "Mellanox",
|
||||||
|
SerialNumber: "", // Should be generated
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GPUs: []models.GPU{
|
||||||
|
{
|
||||||
|
Slot: "GPU1",
|
||||||
|
Model: "NVIDIA A100",
|
||||||
|
Manufacturer: "NVIDIA",
|
||||||
|
SerialNumber: "GPU-001",
|
||||||
|
Status: "OK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NetworkAdapters: []models.NetworkAdapter{
|
||||||
|
{
|
||||||
|
Slot: "NIC1",
|
||||||
|
Model: "ConnectX-6",
|
||||||
|
Vendor: "Mellanox",
|
||||||
|
Present: true,
|
||||||
|
SerialNumber: "NIC-001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
boardSerial := "TEST123"
|
||||||
|
result := convertPCIeDevices(hw, boardSerial)
|
||||||
|
|
||||||
|
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
|
||||||
|
if len(result) != 4 {
|
||||||
|
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that serial was generated for second PCIe device
|
||||||
|
if result[1].SerialNumber != "TEST123-PCIE-PCIeCard2" {
|
||||||
|
t.Errorf("expected generated serial TEST123-PCIE-PCIeCard2, got %q", result[1].SerialNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check GPU was included
|
||||||
|
foundGPU := false
|
||||||
|
for _, dev := range result {
|
||||||
|
if dev.SerialNumber == "GPU-001" {
|
||||||
|
foundGPU = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundGPU {
|
||||||
|
t.Error("expected GPU to be included in PCIe devices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPowerSupplies(t *testing.T) {
|
||||||
|
psus := []models.PSU{
|
||||||
|
{
|
||||||
|
Slot: "0",
|
||||||
|
Present: true,
|
||||||
|
Model: "GW-CRPS3000LW",
|
||||||
|
Vendor: "Great Wall",
|
||||||
|
WattageW: 3000,
|
||||||
|
SerialNumber: "PSU-001",
|
||||||
|
Status: "OK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "1",
|
||||||
|
Present: false,
|
||||||
|
SerialNumber: "", // Not present, should be skipped
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := convertPowerSupplies(psus)
|
||||||
|
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 PSU (skipped empty), got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result[0].Status != "OK" {
|
||||||
|
t.Errorf("expected OK status, got %q", result[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
293
internal/exporter/reanimator_integration_test.go
Normal file
293
internal/exporter/reanimator_integration_test.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFullReanimatorExport tests complete export with realistic data
|
||||||
|
func TestFullReanimatorExport(t *testing.T) {
|
||||||
|
// Create a realistic AnalysisResult similar to import-example-full.json
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Filename: "redfish://10.10.10.103",
|
||||||
|
SourceType: "api",
|
||||||
|
Protocol: "redfish",
|
||||||
|
TargetHost: "10.10.10.103",
|
||||||
|
CollectedAt: time.Date(2026, 2, 10, 15, 30, 0, 0, time.UTC),
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{
|
||||||
|
Manufacturer: "Supermicro",
|
||||||
|
ProductName: "X12DPG-QT6",
|
||||||
|
SerialNumber: "21D634101",
|
||||||
|
PartNumber: "X12DPG-QT6-REV1.01",
|
||||||
|
UUID: "d7ef2fe5-2fd0-11f0-910a-346f11040868",
|
||||||
|
},
|
||||||
|
Firmware: []models.FirmwareInfo{
|
||||||
|
{DeviceName: "BIOS", Version: "06.08.05"},
|
||||||
|
{DeviceName: "BMC", Version: "5.17.00"},
|
||||||
|
{DeviceName: "CPLD", Version: "01.02.03"},
|
||||||
|
},
|
||||||
|
CPUs: []models.CPU{
|
||||||
|
{
|
||||||
|
Socket: 0,
|
||||||
|
Model: "INTEL(R) XEON(R) GOLD 6530",
|
||||||
|
Cores: 32,
|
||||||
|
Threads: 64,
|
||||||
|
FrequencyMHz: 2100,
|
||||||
|
MaxFreqMHz: 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Socket: 1,
|
||||||
|
Model: "INTEL(R) XEON(R) GOLD 6530",
|
||||||
|
Cores: 32,
|
||||||
|
Threads: 64,
|
||||||
|
FrequencyMHz: 2100,
|
||||||
|
MaxFreqMHz: 4000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Memory: []models.MemoryDIMM{
|
||||||
|
{
|
||||||
|
Slot: "CPU0_C0D0",
|
||||||
|
Location: "CPU0_C0D0",
|
||||||
|
Present: true,
|
||||||
|
SizeMB: 32768,
|
||||||
|
Type: "DDR5",
|
||||||
|
MaxSpeedMHz: 4800,
|
||||||
|
CurrentSpeedMHz: 4800,
|
||||||
|
Manufacturer: "Hynix",
|
||||||
|
SerialNumber: "80AD032419E17CEEC1",
|
||||||
|
PartNumber: "HMCG88AGBRA191N",
|
||||||
|
Status: "OK",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "CPU0_C1D0",
|
||||||
|
Location: "CPU0_C1D0",
|
||||||
|
Present: false,
|
||||||
|
SizeMB: 0,
|
||||||
|
Type: "",
|
||||||
|
MaxSpeedMHz: 0,
|
||||||
|
CurrentSpeedMHz: 0,
|
||||||
|
Status: "Empty",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Storage: []models.Storage{
|
||||||
|
{
|
||||||
|
Slot: "OB01",
|
||||||
|
Type: "NVMe",
|
||||||
|
Model: "INTEL SSDPF2KX076T1",
|
||||||
|
SizeGB: 7680,
|
||||||
|
SerialNumber: "BTAX41900GF87P6DGN",
|
||||||
|
Manufacturer: "Intel",
|
||||||
|
Firmware: "9CV10510",
|
||||||
|
Interface: "NVMe",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "FP00HDD00",
|
||||||
|
Type: "HDD",
|
||||||
|
Model: "ST12000NM0008",
|
||||||
|
SizeGB: 12000,
|
||||||
|
SerialNumber: "ZJV01234ABC",
|
||||||
|
Manufacturer: "Seagate",
|
||||||
|
Firmware: "SN03",
|
||||||
|
Interface: "SATA",
|
||||||
|
Present: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PCIeDevices: []models.PCIeDevice{
|
||||||
|
{
|
||||||
|
Slot: "PCIeCard1",
|
||||||
|
VendorID: 32902,
|
||||||
|
DeviceID: 2912,
|
||||||
|
BDF: "0000:18:00.0",
|
||||||
|
DeviceClass: "MassStorageController",
|
||||||
|
Manufacturer: "Intel",
|
||||||
|
PartNumber: "RAID Controller RSP3DD080F",
|
||||||
|
LinkWidth: 8,
|
||||||
|
LinkSpeed: "Gen3",
|
||||||
|
MaxLinkWidth: 8,
|
||||||
|
MaxLinkSpeed: "Gen3",
|
||||||
|
SerialNumber: "RAID-001-12345",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slot: "PCIeCard2",
|
||||||
|
VendorID: 5555,
|
||||||
|
DeviceID: 4401,
|
||||||
|
BDF: "0000:3b:00.0",
|
||||||
|
DeviceClass: "NetworkController",
|
||||||
|
Manufacturer: "Mellanox",
|
||||||
|
PartNumber: "ConnectX-5",
|
||||||
|
LinkWidth: 16,
|
||||||
|
LinkSpeed: "Gen3",
|
||||||
|
MaxLinkWidth: 16,
|
||||||
|
MaxLinkSpeed: "Gen3",
|
||||||
|
SerialNumber: "MT2892012345",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PowerSupply: []models.PSU{
|
||||||
|
{
|
||||||
|
Slot: "0",
|
||||||
|
Present: true,
|
||||||
|
Model: "GW-CRPS3000LW",
|
||||||
|
Vendor: "Great Wall",
|
||||||
|
WattageW: 3000,
|
||||||
|
SerialNumber: "2P06C102610",
|
||||||
|
PartNumber: "V0310C9000000000",
|
||||||
|
Firmware: "00.03.05",
|
||||||
|
Status: "OK",
|
||||||
|
InputType: "ACWideRange",
|
||||||
|
InputPowerW: 137,
|
||||||
|
OutputPowerW: 104,
|
||||||
|
InputVoltage: 215.25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Reanimator format
|
||||||
|
reanimator, err := ConvertToReanimator(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify top-level fields
|
||||||
|
if reanimator.Filename != "redfish://10.10.10.103" {
|
||||||
|
t.Errorf("Filename mismatch: got %q", reanimator.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reanimator.SourceType != "api" {
|
||||||
|
t.Errorf("SourceType mismatch: got %q", reanimator.SourceType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reanimator.Protocol != "redfish" {
|
||||||
|
t.Errorf("Protocol mismatch: got %q", reanimator.Protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reanimator.TargetHost != "10.10.10.103" {
|
||||||
|
t.Errorf("TargetHost mismatch: got %q", reanimator.TargetHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reanimator.CollectedAt != "2026-02-10T15:30:00Z" {
|
||||||
|
t.Errorf("CollectedAt mismatch: got %q", reanimator.CollectedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hardware sections
|
||||||
|
hw := reanimator.Hardware
|
||||||
|
|
||||||
|
// Board
|
||||||
|
if hw.Board.SerialNumber != "21D634101" {
|
||||||
|
t.Errorf("Board serial mismatch: got %q", hw.Board.SerialNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firmware
|
||||||
|
if len(hw.Firmware) != 3 {
|
||||||
|
t.Errorf("Expected 3 firmware entries, got %d", len(hw.Firmware))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CPUs
|
||||||
|
if len(hw.CPUs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 CPUs, got %d", len(hw.CPUs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if hw.CPUs[0].Manufacturer != "Intel" {
|
||||||
|
t.Errorf("CPU manufacturer not inferred: got %q", hw.CPUs[0].Manufacturer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hw.CPUs[0].Status != "OK" {
|
||||||
|
t.Errorf("CPU status mismatch: got %q", hw.CPUs[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory (should include empty slots)
|
||||||
|
if len(hw.Memory) != 2 {
|
||||||
|
t.Errorf("Expected 2 memory entries (including empty), got %d", len(hw.Memory))
|
||||||
|
}
|
||||||
|
|
||||||
|
if hw.Memory[1].Status != "Empty" {
|
||||||
|
t.Errorf("Empty memory slot status mismatch: got %q", hw.Memory[1].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
if len(hw.Storage) != 2 {
|
||||||
|
t.Errorf("Expected 2 storage devices, got %d", len(hw.Storage))
|
||||||
|
}
|
||||||
|
|
||||||
|
if hw.Storage[0].Status != "OK" {
|
||||||
|
t.Errorf("Storage status mismatch: got %q", hw.Storage[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCIe devices
|
||||||
|
if len(hw.PCIeDevices) != 2 {
|
||||||
|
t.Errorf("Expected 2 PCIe devices, got %d", len(hw.PCIeDevices))
|
||||||
|
}
|
||||||
|
|
||||||
|
if hw.PCIeDevices[0].Model == "" {
|
||||||
|
t.Error("PCIe model should be populated from PartNumber")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power supplies
|
||||||
|
if len(hw.PowerSupplies) != 1 {
|
||||||
|
t.Errorf("Expected 1 PSU, got %d", len(hw.PowerSupplies))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JSON marshaling works
|
||||||
|
jsonData, err := json.MarshalIndent(reanimator, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to marshal to JSON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that JSON contains expected fields
|
||||||
|
jsonStr := string(jsonData)
|
||||||
|
expectedFields := []string{
|
||||||
|
`"filename"`,
|
||||||
|
`"source_type"`,
|
||||||
|
`"protocol"`,
|
||||||
|
`"target_host"`,
|
||||||
|
`"collected_at"`,
|
||||||
|
`"hardware"`,
|
||||||
|
`"board"`,
|
||||||
|
`"cpus"`,
|
||||||
|
`"memory"`,
|
||||||
|
`"storage"`,
|
||||||
|
`"pcie_devices"`,
|
||||||
|
`"power_supplies"`,
|
||||||
|
`"firmware"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, field := range expectedFields {
|
||||||
|
if !strings.Contains(jsonStr, field) {
|
||||||
|
t.Errorf("JSON missing expected field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: print JSON for manual inspection (commented out for normal test runs)
|
||||||
|
// t.Logf("Generated Reanimator JSON:\n%s", string(jsonData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReanimatorExportWithoutTargetHost tests that target_host is inferred from filename
|
||||||
|
func TestReanimatorExportWithoutTargetHost(t *testing.T) {
|
||||||
|
result := &models.AnalysisResult{
|
||||||
|
Filename: "redfish://192.168.1.100",
|
||||||
|
SourceType: "api",
|
||||||
|
Protocol: "redfish",
|
||||||
|
TargetHost: "", // Empty - should be inferred
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
Hardware: &models.HardwareConfig{
|
||||||
|
BoardInfo: models.BoardInfo{
|
||||||
|
SerialNumber: "TEST123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
reanimator, err := ConvertToReanimator(result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertToReanimator failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reanimator.TargetHost != "192.168.1.100" {
|
||||||
|
t.Errorf("Expected target_host to be inferred from filename, got %q", reanimator.TargetHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
internal/exporter/reanimator_models.go
Normal file
113
internal/exporter/reanimator_models.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package exporter
|
||||||
|
|
||||||
|
// ReanimatorExport represents the top-level structure for Reanimator format export
|
||||||
|
type ReanimatorExport struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
SourceType string `json:"source_type"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
TargetHost string `json:"target_host"`
|
||||||
|
CollectedAt string `json:"collected_at"` // RFC3339 format
|
||||||
|
Hardware ReanimatorHardware `json:"hardware"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorBoard represents motherboard/server information
|
||||||
|
type ReanimatorBoard struct {
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
ProductName string `json:"product_name,omitempty"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
|
UUID string `json:"uuid,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorFirmware represents firmware version information
|
||||||
|
type ReanimatorFirmware struct {
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorCPU represents processor information
|
||||||
|
type ReanimatorCPU struct {
|
||||||
|
Socket int `json:"socket"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Cores int `json:"cores,omitempty"`
|
||||||
|
Threads int `json:"threads,omitempty"`
|
||||||
|
FrequencyMHz int `json:"frequency_mhz,omitempty"`
|
||||||
|
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorMemory represents a memory module (DIMM)
|
||||||
|
type ReanimatorMemory struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
Location string `json:"location,omitempty"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
SizeMB int `json:"size_mb,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
|
||||||
|
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorStorage represents a storage device
|
||||||
|
type ReanimatorStorage struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
SizeGB int `json:"size_gb,omitempty"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Interface string `json:"interface,omitempty"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorPCIe represents a PCIe device
|
||||||
|
type ReanimatorPCIe struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
VendorID int `json:"vendor_id,omitempty"`
|
||||||
|
DeviceID int `json:"device_id,omitempty"`
|
||||||
|
BDF string `json:"bdf,omitempty"`
|
||||||
|
DeviceClass string `json:"device_class,omitempty"`
|
||||||
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
LinkWidth int `json:"link_width,omitempty"`
|
||||||
|
LinkSpeed string `json:"link_speed,omitempty"`
|
||||||
|
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||||
|
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||||
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReanimatorPSU represents a power supply unit
|
||||||
|
type ReanimatorPSU struct {
|
||||||
|
Slot string `json:"slot"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Vendor string `json:"vendor,omitempty"`
|
||||||
|
WattageW int `json:"wattage_w,omitempty"`
|
||||||
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
|
Firmware string `json:"firmware,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
InputType string `json:"input_type,omitempty"`
|
||||||
|
InputPowerW int `json:"input_power_w,omitempty"`
|
||||||
|
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||||
|
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||||
|
}
|
||||||
@@ -601,6 +601,30 @@ func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) {
|
|||||||
exp.ExportTXT(w)
|
exp.ExportTXT(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleExportReanimator(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := s.GetResult()
|
||||||
|
if result == nil || result.Hardware == nil {
|
||||||
|
jsonError(w, "No hardware data available for export", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, fmt.Sprintf("Export failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "reanimator.json")))
|
||||||
|
|
||||||
|
encoder := json.NewEncoder(w)
|
||||||
|
encoder.SetIndent("", " ")
|
||||||
|
if err := encoder.Encode(reanimatorData); err != nil {
|
||||||
|
// Log error, but likely too late to send error response
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
|
||||||
s.SetResult(nil)
|
s.SetResult(nil)
|
||||||
s.SetDetectedVendor("")
|
s.SetDetectedVendor("")
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
|
||||||
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
|
||||||
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
|
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
|
||||||
|
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
|
||||||
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
|
||||||
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
|
||||||
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button onclick="exportData('json')">Экспорт JSON</button>
|
<button onclick="exportData('json')">Экспорт JSON</button>
|
||||||
<button onclick="exportData('txt')">Экспорт TXT</button>
|
<button onclick="exportData('txt')">Экспорт TXT</button>
|
||||||
|
<button onclick="exportData('reanimator')">Экспорт Reanimator</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="config-content"></div>
|
<div id="config-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user