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>
396 lines
10 KiB
Go
396 lines
10 KiB
Go
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
|
|
}
|