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>
973 lines
26 KiB
Go
973 lines
26 KiB
Go
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html/template"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.mchus.pro/mchus/logpile/internal/collector"
|
||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||
"git.mchus.pro/mchus/logpile/internal/models"
|
||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||
)
|
||
|
||
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
tmplContent, err := WebFS.ReadFile("templates/index.html")
|
||
if err != nil {
|
||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
tmpl, err := template.New("index").Parse(string(tmplContent))
|
||
if err != nil {
|
||
http.Error(w, "Template parse error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
tmpl.Execute(w, nil)
|
||
}
|
||
|
||
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||
// Max 100MB file
|
||
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||
jsonError(w, "File too large", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
file, header, err := r.FormFile("archive")
|
||
if err != nil {
|
||
jsonError(w, "Failed to read file", http.StatusBadRequest)
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
payload, err := io.ReadAll(file)
|
||
if err != nil {
|
||
jsonError(w, "Failed to read file", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var (
|
||
result *models.AnalysisResult
|
||
vendor string
|
||
)
|
||
|
||
if looksLikeJSONSnapshot(header.Filename, payload) {
|
||
snapshotResult, snapshotErr := parseUploadedSnapshot(payload)
|
||
if snapshotErr != nil {
|
||
jsonError(w, "Failed to parse snapshot: "+snapshotErr.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
result = snapshotResult
|
||
vendor = strings.TrimSpace(snapshotResult.Protocol)
|
||
if vendor == "" {
|
||
vendor = "snapshot"
|
||
}
|
||
} else {
|
||
// Parse archive
|
||
p := parser.NewBMCParser()
|
||
if err := p.ParseFromReader(bytes.NewReader(payload), header.Filename); err != nil {
|
||
jsonError(w, "Failed to parse archive: "+err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
result = p.Result()
|
||
applyArchiveSourceMetadata(result)
|
||
vendor = p.DetectedVendor()
|
||
}
|
||
|
||
s.SetResult(result)
|
||
s.SetDetectedVendor(vendor)
|
||
|
||
jsonResponse(w, map[string]interface{}{
|
||
"status": "ok",
|
||
"message": "File uploaded and parsed successfully",
|
||
"filename": header.Filename,
|
||
"vendor": vendor,
|
||
"stats": map[string]int{
|
||
"events": len(result.Events),
|
||
"sensors": len(result.Sensors),
|
||
"fru": len(result.FRU),
|
||
},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleGetParsers(w http.ResponseWriter, r *http.Request) {
|
||
jsonResponse(w, map[string]interface{}{
|
||
"parsers": parser.ListParsersInfo(),
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil {
|
||
jsonResponse(w, []interface{}{})
|
||
return
|
||
}
|
||
|
||
// Sort events by timestamp (newest first)
|
||
events := make([]models.Event, len(result.Events))
|
||
copy(events, result.Events)
|
||
|
||
// Sort in descending order using sort.Slice (newest first)
|
||
sort.Slice(events, func(i, j int) bool {
|
||
return events[i].Timestamp.After(events[j].Timestamp)
|
||
})
|
||
|
||
jsonResponse(w, events)
|
||
}
|
||
|
||
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil {
|
||
jsonResponse(w, []interface{}{})
|
||
return
|
||
}
|
||
jsonResponse(w, result.Sensors)
|
||
}
|
||
|
||
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil {
|
||
jsonResponse(w, map[string]interface{}{})
|
||
return
|
||
}
|
||
|
||
response := map[string]interface{}{
|
||
"source_type": result.SourceType,
|
||
"protocol": result.Protocol,
|
||
"target_host": result.TargetHost,
|
||
"collected_at": result.CollectedAt,
|
||
}
|
||
|
||
if result.Hardware == nil {
|
||
response["hardware"] = map[string]interface{}{}
|
||
response["specification"] = []SpecLine{}
|
||
jsonResponse(w, response)
|
||
return
|
||
}
|
||
|
||
// Build specification summary
|
||
spec := buildSpecification(result)
|
||
|
||
response["hardware"] = result.Hardware
|
||
response["specification"] = spec
|
||
jsonResponse(w, response)
|
||
}
|
||
|
||
// SpecLine represents a single line in specification
|
||
type SpecLine struct {
|
||
Category string `json:"category"`
|
||
Name string `json:"name"`
|
||
Quantity int `json:"quantity"`
|
||
}
|
||
|
||
func buildSpecification(result *models.AnalysisResult) []SpecLine {
|
||
var spec []SpecLine
|
||
hw := result.Hardware
|
||
if hw == nil {
|
||
return spec
|
||
}
|
||
|
||
// CPUs - group by model
|
||
cpuGroups := make(map[string]int)
|
||
cpuDetails := make(map[string]models.CPU)
|
||
for _, cpu := range hw.CPUs {
|
||
cpuGroups[cpu.Model]++
|
||
cpuDetails[cpu.Model] = cpu
|
||
}
|
||
for model, count := range cpuGroups {
|
||
cpu := cpuDetails[model]
|
||
name := fmt.Sprintf("Intel %s (%.1fGHz %dC %dW)",
|
||
model,
|
||
float64(cpu.FrequencyMHz)/1000,
|
||
cpu.Cores,
|
||
cpu.TDP)
|
||
spec = append(spec, SpecLine{Category: "Процессор", Name: name, Quantity: count})
|
||
}
|
||
|
||
// Memory - group by size, type and frequency (only installed modules)
|
||
memGroups := make(map[string]int)
|
||
for _, mem := range hw.Memory {
|
||
// Skip empty slots (not present or 0 size)
|
||
if !mem.Present || mem.SizeMB == 0 {
|
||
continue
|
||
}
|
||
// Include frequency if available
|
||
key := ""
|
||
if mem.CurrentSpeedMHz > 0 {
|
||
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, mem.CurrentSpeedMHz)
|
||
} else {
|
||
key = fmt.Sprintf("%s %dGB", mem.Type, mem.SizeMB/1024)
|
||
}
|
||
memGroups[key]++
|
||
}
|
||
for key, count := range memGroups {
|
||
spec = append(spec, SpecLine{Category: "Память", Name: key, Quantity: count})
|
||
}
|
||
|
||
// Storage - group by type and capacity
|
||
storGroups := make(map[string]int)
|
||
for _, stor := range hw.Storage {
|
||
var key string
|
||
if stor.SizeGB >= 1000 {
|
||
key = fmt.Sprintf("%s %s %.2fTB", stor.Type, stor.Interface, float64(stor.SizeGB)/1000)
|
||
} else {
|
||
key = fmt.Sprintf("%s %s %dGB", stor.Type, stor.Interface, stor.SizeGB)
|
||
}
|
||
storGroups[key]++
|
||
}
|
||
for key, count := range storGroups {
|
||
spec = append(spec, SpecLine{Category: "Накопитель", Name: key, Quantity: count})
|
||
}
|
||
|
||
// PCIe devices - group by device class/name and manufacturer
|
||
pcieGroups := make(map[string]int)
|
||
pcieDetails := make(map[string]models.PCIeDevice)
|
||
for _, pcie := range hw.PCIeDevices {
|
||
// Create unique key from manufacturer + device class/name
|
||
key := pcie.DeviceClass
|
||
if pcie.Manufacturer != "" {
|
||
key = pcie.Manufacturer + " " + pcie.DeviceClass
|
||
}
|
||
if pcie.PartNumber != "" && pcie.PartNumber != pcie.DeviceClass {
|
||
key = key + " (" + pcie.PartNumber + ")"
|
||
}
|
||
pcieGroups[key]++
|
||
pcieDetails[key] = pcie
|
||
}
|
||
for key, count := range pcieGroups {
|
||
pcie := pcieDetails[key]
|
||
category := "PCIe устройство"
|
||
name := key
|
||
|
||
// Determine category based on device class or known GPU names
|
||
deviceClass := pcie.DeviceClass
|
||
isGPU := isGPUDevice(deviceClass)
|
||
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
||
|
||
if isGPU {
|
||
category = "Графический процессор"
|
||
} else if isNetwork {
|
||
category = "Сетевой адаптер"
|
||
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
|
||
category = "Контроллер"
|
||
}
|
||
|
||
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
|
||
}
|
||
|
||
// Power supplies - group by model/wattage
|
||
psuGroups := make(map[string]int)
|
||
for _, psu := range hw.PowerSupply {
|
||
key := psu.Model
|
||
if key == "" && psu.WattageW > 0 {
|
||
key = fmt.Sprintf("%dW", psu.WattageW)
|
||
}
|
||
if key != "" {
|
||
psuGroups[key]++
|
||
}
|
||
}
|
||
for key, count := range psuGroups {
|
||
spec = append(spec, SpecLine{Category: "Блок питания", Name: key, Quantity: count})
|
||
}
|
||
|
||
return spec
|
||
}
|
||
|
||
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil {
|
||
jsonResponse(w, []interface{}{})
|
||
return
|
||
}
|
||
|
||
// Collect all serial numbers from various sources
|
||
type SerialEntry struct {
|
||
Component string `json:"component"`
|
||
Location string `json:"location,omitempty"`
|
||
SerialNumber string `json:"serial_number"`
|
||
Manufacturer string `json:"manufacturer,omitempty"`
|
||
PartNumber string `json:"part_number,omitempty"`
|
||
Category string `json:"category"`
|
||
}
|
||
|
||
var serials []SerialEntry
|
||
|
||
// From FRU
|
||
for _, fru := range result.FRU {
|
||
if fru.SerialNumber == "" {
|
||
continue
|
||
}
|
||
name := fru.ProductName
|
||
if name == "" {
|
||
name = fru.Description
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: name,
|
||
SerialNumber: fru.SerialNumber,
|
||
Manufacturer: fru.Manufacturer,
|
||
PartNumber: fru.PartNumber,
|
||
Category: "FRU",
|
||
})
|
||
}
|
||
|
||
// From Hardware
|
||
if result.Hardware != nil {
|
||
// Board
|
||
if result.Hardware.BoardInfo.SerialNumber != "" {
|
||
serials = append(serials, SerialEntry{
|
||
Component: result.Hardware.BoardInfo.ProductName,
|
||
SerialNumber: result.Hardware.BoardInfo.SerialNumber,
|
||
Manufacturer: result.Hardware.BoardInfo.Manufacturer,
|
||
PartNumber: result.Hardware.BoardInfo.PartNumber,
|
||
Category: "Board",
|
||
})
|
||
}
|
||
|
||
// CPUs
|
||
for _, cpu := range result.Hardware.CPUs {
|
||
sn := cpu.SerialNumber
|
||
if sn == "" {
|
||
sn = cpu.PPIN // Use PPIN as fallback identifier
|
||
}
|
||
if sn == "" {
|
||
continue
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: cpu.Model,
|
||
Location: fmt.Sprintf("CPU%d", cpu.Socket),
|
||
SerialNumber: sn,
|
||
Category: "CPU",
|
||
})
|
||
}
|
||
|
||
// Memory DIMMs
|
||
for _, mem := range result.Hardware.Memory {
|
||
if mem.SerialNumber == "" {
|
||
continue
|
||
}
|
||
location := mem.Location
|
||
if location == "" {
|
||
location = mem.Slot
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: mem.PartNumber,
|
||
Location: location,
|
||
SerialNumber: mem.SerialNumber,
|
||
Manufacturer: mem.Manufacturer,
|
||
PartNumber: mem.PartNumber,
|
||
Category: "Memory",
|
||
})
|
||
}
|
||
|
||
// Storage
|
||
for _, stor := range result.Hardware.Storage {
|
||
if stor.SerialNumber == "" {
|
||
continue
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: stor.Model,
|
||
Location: stor.Slot,
|
||
SerialNumber: stor.SerialNumber,
|
||
Manufacturer: stor.Manufacturer,
|
||
Category: "Storage",
|
||
})
|
||
}
|
||
|
||
// GPUs
|
||
for _, gpu := range result.Hardware.GPUs {
|
||
if gpu.SerialNumber == "" {
|
||
continue
|
||
}
|
||
model := gpu.Model
|
||
if model == "" {
|
||
model = "GPU"
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: model,
|
||
Location: gpu.Slot,
|
||
SerialNumber: gpu.SerialNumber,
|
||
Manufacturer: gpu.Manufacturer,
|
||
Category: "GPU",
|
||
})
|
||
}
|
||
|
||
// PCIe devices
|
||
for _, pcie := range result.Hardware.PCIeDevices {
|
||
if pcie.SerialNumber == "" {
|
||
continue
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: pcie.DeviceClass,
|
||
Location: pcie.Slot,
|
||
SerialNumber: pcie.SerialNumber,
|
||
Manufacturer: pcie.Manufacturer,
|
||
PartNumber: pcie.PartNumber,
|
||
Category: "PCIe",
|
||
})
|
||
}
|
||
|
||
// Network cards
|
||
for _, nic := range result.Hardware.NetworkCards {
|
||
if nic.SerialNumber == "" {
|
||
continue
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: nic.Model,
|
||
SerialNumber: nic.SerialNumber,
|
||
Category: "Network",
|
||
})
|
||
}
|
||
|
||
// Power supplies
|
||
for _, psu := range result.Hardware.PowerSupply {
|
||
if psu.SerialNumber == "" {
|
||
continue
|
||
}
|
||
serials = append(serials, SerialEntry{
|
||
Component: psu.Model,
|
||
Location: psu.Slot,
|
||
SerialNumber: psu.SerialNumber,
|
||
Manufacturer: psu.Vendor,
|
||
Category: "PSU",
|
||
})
|
||
}
|
||
|
||
// Firmware (using version as "serial number" for display)
|
||
for _, fw := range result.Hardware.Firmware {
|
||
serials = append(serials, SerialEntry{
|
||
Component: fw.DeviceName,
|
||
SerialNumber: fw.Version,
|
||
Category: "Firmware",
|
||
})
|
||
}
|
||
}
|
||
|
||
jsonResponse(w, serials)
|
||
}
|
||
|
||
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil || result.Hardware == nil {
|
||
jsonResponse(w, []interface{}{})
|
||
return
|
||
}
|
||
|
||
// Deduplicate firmware by extracting model name and version
|
||
// E.g., "PSU0 (AP-CR3000F12BY)" and "PSU1 (AP-CR3000F12BY)" with same version -> one entry
|
||
type FirmwareEntry struct {
|
||
Component string `json:"component"`
|
||
Model string `json:"model"`
|
||
Version string `json:"version"`
|
||
}
|
||
|
||
seen := make(map[string]bool)
|
||
var deduplicated []FirmwareEntry
|
||
|
||
for _, fw := range result.Hardware.Firmware {
|
||
// Extract component type and model from device name
|
||
component, model := extractFirmwareComponentAndModel(fw.DeviceName)
|
||
key := component + "|" + model + "|" + fw.Version
|
||
|
||
if !seen[key] {
|
||
seen[key] = true
|
||
deduplicated = append(deduplicated, FirmwareEntry{
|
||
Component: component,
|
||
Model: model,
|
||
Version: fw.Version,
|
||
})
|
||
}
|
||
}
|
||
|
||
jsonResponse(w, deduplicated)
|
||
}
|
||
|
||
// extractFirmwareComponentAndModel extracts the component type and model from firmware device name
|
||
func extractFirmwareComponentAndModel(deviceName string) (component, model string) {
|
||
// Parse different firmware name formats and extract component + model
|
||
|
||
// For "PSU0 (AP-CR3000F12BY)" -> component: "PSU", model: "AP-CR3000F12BY"
|
||
if strings.HasPrefix(deviceName, "PSU") {
|
||
if idx := strings.Index(deviceName, "("); idx != -1 {
|
||
model = strings.Trim(deviceName[idx:], "()")
|
||
return "PSU", model
|
||
}
|
||
return "PSU", "-"
|
||
}
|
||
|
||
// For "CPU0 Microcode" -> component: "CPU Microcode", model: "-"
|
||
if strings.HasPrefix(deviceName, "CPU") && strings.Contains(deviceName, "Microcode") {
|
||
return "CPU Microcode", "-"
|
||
}
|
||
|
||
// For "NIC #CPU1_PCIE9 (MCX512A-ACAT)" -> component: "NIC", model: "MCX512A-ACAT"
|
||
if strings.HasPrefix(deviceName, "NIC ") {
|
||
if idx := strings.Index(deviceName, "("); idx != -1 {
|
||
model = strings.Trim(deviceName[idx:], "()")
|
||
return "NIC", model
|
||
}
|
||
return "NIC", "-"
|
||
}
|
||
|
||
// For "HDD Samsung MZ7L33T8HBNA-00A07" -> component: "HDD", model: "Samsung MZ7L33T8HBNA-00A07"
|
||
if strings.HasPrefix(deviceName, "HDD ") {
|
||
return "HDD", strings.TrimPrefix(deviceName, "HDD ")
|
||
}
|
||
|
||
// For "SSD Samsung MZ7..." -> component: "SSD", model: "Samsung MZ7..."
|
||
if strings.HasPrefix(deviceName, "SSD ") {
|
||
return "SSD", strings.TrimPrefix(deviceName, "SSD ")
|
||
}
|
||
|
||
// For "NVMe KIOXIA..." -> component: "NVMe", model: "KIOXIA..."
|
||
if strings.HasPrefix(deviceName, "NVMe ") {
|
||
return "NVMe", strings.TrimPrefix(deviceName, "NVMe ")
|
||
}
|
||
|
||
// For simple names like "BIOS", "ME", "BKC", "Virtual MicroCo"
|
||
// component = name, model = "-"
|
||
return deviceName, "-"
|
||
}
|
||
|
||
func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
if result == nil {
|
||
jsonResponse(w, map[string]interface{}{
|
||
"loaded": false,
|
||
})
|
||
return
|
||
}
|
||
|
||
jsonResponse(w, map[string]interface{}{
|
||
"loaded": true,
|
||
"filename": result.Filename,
|
||
"vendor": s.GetDetectedVendor(),
|
||
"source_type": result.SourceType,
|
||
"protocol": result.Protocol,
|
||
"target_host": result.TargetHost,
|
||
"collected_at": result.CollectedAt,
|
||
"stats": map[string]int{
|
||
"events": len(result.Events),
|
||
"sensors": len(result.Sensors),
|
||
"fru": len(result.FRU),
|
||
},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
|
||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "csv")))
|
||
|
||
exp := exporter.New(result)
|
||
exp.ExportCSV(w)
|
||
}
|
||
|
||
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "json")))
|
||
|
||
exp := exporter.New(result)
|
||
exp.ExportJSON(w)
|
||
}
|
||
|
||
func (s *Server) handleExportTXT(w http.ResponseWriter, r *http.Request) {
|
||
result := s.GetResult()
|
||
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "txt")))
|
||
|
||
exp := exporter.New(result)
|
||
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) {
|
||
s.SetResult(nil)
|
||
s.SetDetectedVendor("")
|
||
jsonResponse(w, map[string]string{
|
||
"status": "ok",
|
||
"message": "Data cleared",
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
||
jsonResponse(w, map[string]string{
|
||
"status": "ok",
|
||
"message": "Server shutting down",
|
||
})
|
||
|
||
// Shutdown in a goroutine so the response can be sent
|
||
go func() {
|
||
time.Sleep(100 * time.Millisecond)
|
||
s.Shutdown()
|
||
os.Exit(0)
|
||
}()
|
||
}
|
||
|
||
func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
||
var req CollectRequest
|
||
decoder := json.NewDecoder(r.Body)
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(&req); err != nil {
|
||
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if err := validateCollectRequest(req); err != nil {
|
||
jsonError(w, err.Error(), http.StatusUnprocessableEntity)
|
||
return
|
||
}
|
||
|
||
job := s.jobManager.CreateJob(req)
|
||
s.startCollectionJob(job.ID, req)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusAccepted)
|
||
_ = json.NewEncoder(w).Encode(job.toJobResponse("Collection job accepted"))
|
||
}
|
||
|
||
func (s *Server) handleCollectStatus(w http.ResponseWriter, r *http.Request) {
|
||
jobID := strings.TrimSpace(r.PathValue("id"))
|
||
if !isValidCollectJobID(jobID) {
|
||
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
job, ok := s.jobManager.GetJob(jobID)
|
||
if !ok {
|
||
jsonError(w, "Collect job not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
jsonResponse(w, job.toStatusResponse())
|
||
}
|
||
|
||
func (s *Server) handleCollectCancel(w http.ResponseWriter, r *http.Request) {
|
||
jobID := strings.TrimSpace(r.PathValue("id"))
|
||
if !isValidCollectJobID(jobID) {
|
||
jsonError(w, "Invalid collect job id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
job, ok := s.jobManager.CancelJob(jobID)
|
||
if !ok {
|
||
jsonError(w, "Collect job not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
jsonResponse(w, job.toStatusResponse())
|
||
}
|
||
|
||
func (s *Server) startCollectionJob(jobID string, req CollectRequest) {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
if attached := s.jobManager.AttachJobCancel(jobID, cancel); !attached {
|
||
cancel()
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
connector, ok := s.getCollector(req.Protocol)
|
||
if !ok {
|
||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Коннектор для протокола не зарегистрирован")
|
||
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
||
return
|
||
}
|
||
|
||
emitProgress := func(update collector.Progress) {
|
||
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
||
return
|
||
}
|
||
status := update.Status
|
||
if status == "" {
|
||
status = CollectStatusRunning
|
||
}
|
||
s.jobManager.UpdateJobStatus(jobID, status, update.Progress, "")
|
||
if update.Message != "" {
|
||
s.jobManager.AppendJobLog(jobID, update.Message)
|
||
}
|
||
}
|
||
|
||
result, err := connector.Collect(ctx, toCollectorRequest(req), emitProgress)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
||
return
|
||
}
|
||
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, err.Error())
|
||
s.jobManager.AppendJobLog(jobID, "Сбор завершен с ошибкой")
|
||
return
|
||
}
|
||
|
||
if job, ok := s.jobManager.GetJob(jobID); !ok || isTerminalCollectStatus(job.Status) {
|
||
return
|
||
}
|
||
|
||
applyCollectSourceMetadata(result, req)
|
||
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
||
s.jobManager.AppendJobLog(jobID, "Сбор завершен")
|
||
s.SetResult(result)
|
||
s.SetDetectedVendor(req.Protocol)
|
||
}()
|
||
}
|
||
|
||
func validateCollectRequest(req CollectRequest) error {
|
||
if strings.TrimSpace(req.Host) == "" {
|
||
return fmt.Errorf("field 'host' is required")
|
||
}
|
||
switch req.Protocol {
|
||
case "redfish", "ipmi":
|
||
default:
|
||
return fmt.Errorf("field 'protocol' must be one of: redfish, ipmi")
|
||
}
|
||
if req.Port < 1 || req.Port > 65535 {
|
||
return fmt.Errorf("field 'port' must be in range 1..65535")
|
||
}
|
||
if strings.TrimSpace(req.Username) == "" {
|
||
return fmt.Errorf("field 'username' is required")
|
||
}
|
||
switch req.AuthType {
|
||
case "password":
|
||
if strings.TrimSpace(req.Password) == "" {
|
||
return fmt.Errorf("field 'password' is required when auth_type=password")
|
||
}
|
||
case "token":
|
||
if strings.TrimSpace(req.Token) == "" {
|
||
return fmt.Errorf("field 'token' is required when auth_type=token")
|
||
}
|
||
default:
|
||
return fmt.Errorf("field 'auth_type' must be one of: password, token")
|
||
}
|
||
switch req.TLSMode {
|
||
case "strict", "insecure":
|
||
default:
|
||
return fmt.Errorf("field 'tls_mode' must be one of: strict, insecure")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
var collectJobIDPattern = regexp.MustCompile(`^job_[a-zA-Z0-9_-]{8,}$`)
|
||
|
||
func isValidCollectJobID(id string) bool {
|
||
return collectJobIDPattern.MatchString(id)
|
||
}
|
||
|
||
func generateJobID() string {
|
||
buf := make([]byte, 8)
|
||
if _, err := rand.Read(buf); err != nil {
|
||
return fmt.Sprintf("job_%d", time.Now().UnixNano())
|
||
}
|
||
return fmt.Sprintf("job_%x", buf)
|
||
}
|
||
|
||
func applyArchiveSourceMetadata(result *models.AnalysisResult) {
|
||
if result == nil {
|
||
return
|
||
}
|
||
result.SourceType = models.SourceTypeArchive
|
||
result.Protocol = ""
|
||
result.TargetHost = ""
|
||
result.CollectedAt = time.Now().UTC()
|
||
}
|
||
|
||
func applyCollectSourceMetadata(result *models.AnalysisResult, req CollectRequest) {
|
||
if result == nil {
|
||
return
|
||
}
|
||
result.SourceType = models.SourceTypeAPI
|
||
result.Protocol = req.Protocol
|
||
result.TargetHost = req.Host
|
||
result.CollectedAt = time.Now().UTC()
|
||
if strings.TrimSpace(result.Filename) == "" {
|
||
result.Filename = fmt.Sprintf("%s://%s", req.Protocol, req.Host)
|
||
}
|
||
}
|
||
|
||
func toCollectorRequest(req CollectRequest) collector.Request {
|
||
return collector.Request{
|
||
Host: req.Host,
|
||
Protocol: req.Protocol,
|
||
Port: req.Port,
|
||
Username: req.Username,
|
||
AuthType: req.AuthType,
|
||
Password: req.Password,
|
||
Token: req.Token,
|
||
TLSMode: req.TLSMode,
|
||
}
|
||
}
|
||
|
||
func looksLikeJSONSnapshot(filename string, payload []byte) bool {
|
||
ext := strings.ToLower(filepath.Ext(filename))
|
||
if ext == ".json" {
|
||
return true
|
||
}
|
||
trimmed := bytes.TrimSpace(payload)
|
||
return len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '[')
|
||
}
|
||
|
||
func parseUploadedSnapshot(payload []byte) (*models.AnalysisResult, error) {
|
||
var result models.AnalysisResult
|
||
if err := json.Unmarshal(payload, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
if result.Hardware == nil && len(result.Events) == 0 && len(result.Sensors) == 0 && len(result.FRU) == 0 {
|
||
return nil, fmt.Errorf("unsupported snapshot format")
|
||
}
|
||
if strings.TrimSpace(result.SourceType) == "" {
|
||
if result.Protocol != "" {
|
||
result.SourceType = models.SourceTypeAPI
|
||
} else {
|
||
result.SourceType = models.SourceTypeArchive
|
||
}
|
||
}
|
||
if result.CollectedAt.IsZero() {
|
||
result.CollectedAt = time.Now().UTC()
|
||
}
|
||
if strings.TrimSpace(result.Filename) == "" {
|
||
result.Filename = "uploaded_snapshot.json"
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
func (s *Server) getCollector(protocol string) (collector.Connector, bool) {
|
||
if s.collectors == nil {
|
||
s.collectors = collector.NewDefaultRegistry()
|
||
}
|
||
return s.collectors.Get(protocol)
|
||
}
|
||
|
||
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(data)
|
||
}
|
||
|
||
func jsonError(w http.ResponseWriter, message string, code int) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||
}
|
||
|
||
// isGPUDevice checks if device class indicates a GPU
|
||
func isGPUDevice(deviceClass string) bool {
|
||
// Standard PCI class names
|
||
if deviceClass == "VGA" || deviceClass == "3D Controller" || deviceClass == "Display" {
|
||
return true
|
||
}
|
||
// Known GPU model patterns
|
||
gpuPatterns := []string{
|
||
"L40", "A100", "A10", "A16", "A30", "H100", "H200", "V100",
|
||
"RTX", "GTX", "Quadro", "Tesla",
|
||
"Instinct", "Radeon",
|
||
"AST2500", "AST2600", // ASPEED BMC VGA
|
||
}
|
||
upperClass := strings.ToUpper(deviceClass)
|
||
for _, pattern := range gpuPatterns {
|
||
if strings.Contains(upperClass, strings.ToUpper(pattern)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func exportFilename(result *models.AnalysisResult, ext string) string {
|
||
date := time.Now().UTC().Format("2006-01-02")
|
||
model := "SERVER MODEL"
|
||
sn := "SERVER SN"
|
||
|
||
if result != nil {
|
||
if !result.CollectedAt.IsZero() {
|
||
date = result.CollectedAt.UTC().Format("2006-01-02")
|
||
}
|
||
if result.Hardware != nil {
|
||
if m := strings.TrimSpace(result.Hardware.BoardInfo.ProductName); m != "" {
|
||
model = m
|
||
}
|
||
if serial := strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber); serial != "" {
|
||
sn = serial
|
||
}
|
||
}
|
||
}
|
||
|
||
model = sanitizeFilenamePart(model)
|
||
sn = sanitizeFilenamePart(sn)
|
||
ext = strings.TrimPrefix(strings.TrimSpace(ext), ".")
|
||
if ext == "" {
|
||
ext = "txt"
|
||
}
|
||
return fmt.Sprintf("%s (%s) - %s.%s", date, model, sn, ext)
|
||
}
|
||
|
||
func sanitizeFilenamePart(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return "-"
|
||
}
|
||
|
||
replacer := strings.NewReplacer(
|
||
"/", "_",
|
||
"\\", "_",
|
||
":", "_",
|
||
"*", "_",
|
||
"?", "_",
|
||
"\"", "_",
|
||
"<", "_",
|
||
">", "_",
|
||
"|", "_",
|
||
"\n", " ",
|
||
"\r", " ",
|
||
"\t", " ",
|
||
)
|
||
v = replacer.Replace(v)
|
||
v = strings.Join(strings.Fields(v), " ")
|
||
if v == "" {
|
||
return "-"
|
||
}
|
||
return v
|
||
}
|