2240 lines
60 KiB
Go
2240 lines
60 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/collector"
|
|
"git.mchus.pro/mchus/logpile/internal/exporter"
|
|
"git.mchus.pro/mchus/logpile/internal/ingest"
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
chartviewer "reanimator/chart/viewer"
|
|
)
|
|
|
|
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 {
|
|
s.htmlError(w, "Template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
tmpl, err := template.New("index").Parse(string(tmplContent))
|
|
if err != nil {
|
|
s.htmlError(w, "Template parse error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
tmpl.Execute(w, map[string]string{
|
|
"AppVersion": normalizeDisplayVersion(s.config.AppVersion),
|
|
"AppCommit": s.config.AppCommit,
|
|
"ChartVersion": normalizeDisplayVersion(s.config.ChartVersion),
|
|
})
|
|
}
|
|
|
|
func normalizeDisplayVersion(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimPrefix(v, "v")
|
|
}
|
|
|
|
func (s *Server) handleChartCurrent(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
title := chartTitle(result)
|
|
if result == nil || result.Hardware == nil {
|
|
html, err := chartviewer.RenderHTML(nil, title)
|
|
if err != nil {
|
|
s.htmlError(w, "failed to render viewer", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(rewriteChartStaticPaths(html))
|
|
return
|
|
}
|
|
|
|
snapshotBytes, err := currentReanimatorSnapshotBytes(result)
|
|
if err != nil {
|
|
s.htmlError(w, "failed to build reanimator snapshot: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
html, err := chartviewer.RenderHTMLWithOptions(snapshotBytes, title, chartviewer.RenderOptions{})
|
|
if err != nil {
|
|
s.htmlError(w, "failed to render chart: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = w.Write(rewriteChartStaticPaths(html))
|
|
}
|
|
|
|
func currentReanimatorSnapshotBytes(result *models.AnalysisResult) ([]byte, error) {
|
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
encoder := json.NewEncoder(&buf)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(reanimatorData); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func chartTitle(result *models.AnalysisResult) string {
|
|
const fallback = "LOGPile Reanimator Viewer"
|
|
if result == nil {
|
|
return fallback
|
|
}
|
|
if result.Hardware != nil {
|
|
board := result.Hardware.BoardInfo
|
|
product := strings.TrimSpace(board.ProductName)
|
|
serial := strings.TrimSpace(board.SerialNumber)
|
|
switch {
|
|
case product != "" && serial != "":
|
|
return product + " - " + serial
|
|
case product != "":
|
|
return product
|
|
case serial != "":
|
|
return serial
|
|
}
|
|
}
|
|
if host := strings.TrimSpace(result.TargetHost); host != "" {
|
|
return host
|
|
}
|
|
if filename := strings.TrimSpace(result.Filename); filename != "" {
|
|
return filename
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func rewriteChartStaticPaths(html []byte) []byte {
|
|
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
|
|
}
|
|
|
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, uploadMultipartMaxBytes())
|
|
if err := r.ParseMultipartForm(uploadMultipartFormMemoryBytes()); 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
|
|
)
|
|
|
|
result, vendor, rawPkg, err := s.analyzeUploadedFile(header.Filename, header.Header.Get("Content-Type"), payload)
|
|
if err != nil {
|
|
jsonError(w, "Failed to parse uploaded file: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if strings.TrimSpace(vendor) == "" {
|
|
vendor = "snapshot"
|
|
}
|
|
if rawPkg != nil {
|
|
s.SetRawExport(rawPkg)
|
|
}
|
|
|
|
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) analyzeUploadedFile(filename, mimeType string, payload []byte) (*models.AnalysisResult, string, *RawExportPackage, error) {
|
|
if rawPkg, ok, err := parseRawExportBundle(payload); err != nil {
|
|
return nil, "", nil, err
|
|
} else if ok {
|
|
result, vendor, err := s.reanalyzeRawExportPackage(rawPkg)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
if strings.TrimSpace(vendor) == "" {
|
|
vendor = "snapshot"
|
|
}
|
|
return result, vendor, rawPkg, nil
|
|
}
|
|
|
|
if looksLikeJSONSnapshot(filename, payload) {
|
|
if rawPkg, ok, err := parseRawExportPackage(payload); err != nil {
|
|
return nil, "", nil, err
|
|
} else if ok {
|
|
result, vendor, err := s.reanalyzeRawExportPackage(rawPkg)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
if strings.TrimSpace(vendor) == "" {
|
|
vendor = "snapshot"
|
|
}
|
|
return result, vendor, rawPkg, nil
|
|
}
|
|
|
|
snapshotResult, err := parseUploadedSnapshot(payload)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
vendor := strings.TrimSpace(snapshotResult.Protocol)
|
|
if vendor == "" {
|
|
vendor = "snapshot"
|
|
}
|
|
return snapshotResult, vendor, newRawExportFromUploadedFile(filename, mimeType, payload, snapshotResult), nil
|
|
}
|
|
if !parser.IsSupportedArchiveFilename(filename) {
|
|
return nil, "", nil, fmt.Errorf("unsupported archive format: %s", strings.ToLower(filepath.Ext(filename)))
|
|
}
|
|
|
|
result, vendor, err := s.ingestService().AnalyzeArchivePayload(filename, payload)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
applyArchiveSourceMetadata(result)
|
|
return result, vendor, newRawExportFromUploadedFile(filename, mimeType, payload, result), nil
|
|
}
|
|
|
|
func uploadMultipartMaxBytes() int64 {
|
|
// Limit for incoming multipart request body.
|
|
const (
|
|
defMB = 2048
|
|
minMB = 100
|
|
maxMB = 8192
|
|
)
|
|
mb := defMB
|
|
if v := strings.TrimSpace(os.Getenv("LOGPILE_UPLOAD_MAX_MB")); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
if n < minMB {
|
|
n = minMB
|
|
}
|
|
if n > maxMB {
|
|
n = maxMB
|
|
}
|
|
mb = n
|
|
}
|
|
}
|
|
return int64(mb) << 20
|
|
}
|
|
|
|
func convertMultipartMaxBytes() int64 {
|
|
// Convert mode typically uploads a folder with many files,
|
|
// so it has a larger independent limit.
|
|
const (
|
|
defMB = 16384
|
|
minMB = 512
|
|
maxMB = 65536
|
|
)
|
|
mb := defMB
|
|
if v := strings.TrimSpace(os.Getenv("LOGPILE_CONVERT_MAX_MB")); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
if n < minMB {
|
|
n = minMB
|
|
}
|
|
if n > maxMB {
|
|
n = maxMB
|
|
}
|
|
mb = n
|
|
}
|
|
}
|
|
return int64(mb) << 20
|
|
}
|
|
|
|
func uploadMultipartFormMemoryBytes() int64 {
|
|
// Keep a small in-memory threshold; file parts spill to temp files.
|
|
const formMemoryMB = 32
|
|
return int64(formMemoryMB) << 20
|
|
}
|
|
|
|
func (s *Server) reanalyzeRawExportPackage(pkg *RawExportPackage) (*models.AnalysisResult, string, error) {
|
|
if pkg == nil {
|
|
return nil, "", fmt.Errorf("empty package")
|
|
}
|
|
switch pkg.Source.Kind {
|
|
case "file_bytes":
|
|
if strings.TrimSpace(pkg.Source.Encoding) != "base64" {
|
|
return nil, "", fmt.Errorf("unsupported file_bytes encoding: %s", pkg.Source.Encoding)
|
|
}
|
|
data, err := base64.StdEncoding.DecodeString(pkg.Source.Data)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("decode source.data: %w", err)
|
|
}
|
|
return s.parseUploadedPayload(pkg.Source.Filename, data)
|
|
case "live_redfish":
|
|
if !strings.EqualFold(strings.TrimSpace(pkg.Source.Protocol), "redfish") {
|
|
return nil, "", fmt.Errorf("unsupported live protocol: %s", pkg.Source.Protocol)
|
|
}
|
|
result, vendor, err := s.ingestService().AnalyzeRedfishRawPayloads(pkg.Source.RawPayloads, ingest.RedfishSourceMetadata{
|
|
TargetHost: pkg.Source.TargetHost,
|
|
SourceTimezone: pkg.Source.SourceTimezone,
|
|
Filename: pkg.Source.Filename,
|
|
})
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if result != nil {
|
|
result.CollectedAt = inferRawExportCollectedAt(result, pkg)
|
|
}
|
|
return result, vendor, nil
|
|
default:
|
|
return nil, "", fmt.Errorf("unsupported raw export source kind: %s", pkg.Source.Kind)
|
|
}
|
|
}
|
|
|
|
func (s *Server) parseUploadedPayload(filename string, payload []byte) (*models.AnalysisResult, string, error) {
|
|
if looksLikeJSONSnapshot(filename, payload) {
|
|
snapshotResult, err := parseUploadedSnapshot(payload)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
vendor := strings.TrimSpace(snapshotResult.Protocol)
|
|
if vendor == "" {
|
|
vendor = "snapshot"
|
|
}
|
|
return snapshotResult, vendor, nil
|
|
}
|
|
|
|
result, vendor, err := s.ingestService().AnalyzeArchivePayload(filename, payload)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
applyArchiveSourceMetadata(result)
|
|
return result, vendor, nil
|
|
}
|
|
|
|
func (s *Server) handleGetParsers(w http.ResponseWriter, r *http.Request) {
|
|
jsonResponse(w, map[string]interface{}{
|
|
"parsers": parser.ListParsersInfo(),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetFileTypes(w http.ResponseWriter, r *http.Request) {
|
|
archiveExt := parser.SupportedArchiveExtensions()
|
|
uploadExt := append([]string{}, archiveExt...)
|
|
uploadExt = append(uploadExt, ".json")
|
|
|
|
jsonResponse(w, map[string]any{
|
|
"archive_extensions": archiveExt,
|
|
"upload_extensions": uniqueSortedExtensions(uploadExt),
|
|
"convert_extensions": uniqueSortedExtensions(uploadExt),
|
|
})
|
|
}
|
|
|
|
func uniqueSortedExtensions(exts []string) []string {
|
|
if len(exts) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(exts))
|
|
out := make([]string, 0, len(exts))
|
|
for _, e := range exts {
|
|
e = strings.ToLower(strings.TrimSpace(e))
|
|
if e == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[e]; ok {
|
|
continue
|
|
}
|
|
seen[e] = struct{}{}
|
|
out = append(out, e)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleGetEvents(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
if result == nil {
|
|
jsonList(w, []interface{}{}, 0)
|
|
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)
|
|
})
|
|
|
|
jsonList(w, events, len(events))
|
|
}
|
|
|
|
func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
if result == nil {
|
|
jsonList(w, []interface{}{}, 0)
|
|
return
|
|
}
|
|
sensors := append([]models.SensorReading{}, result.Sensors...)
|
|
sensors = append(sensors, synthesizePSUVoltageSensors(result.Hardware)...)
|
|
jsonList(w, sensors, len(sensors))
|
|
}
|
|
|
|
func synthesizePSUVoltageSensors(hw *models.HardwareConfig) []models.SensorReading {
|
|
if hw == nil || len(hw.PowerSupply) == 0 {
|
|
return nil
|
|
}
|
|
const (
|
|
nominalV = 230.0
|
|
minV = nominalV * 0.9 // 207V
|
|
maxV = nominalV * 1.1 // 253V
|
|
)
|
|
out := make([]models.SensorReading, 0, len(hw.PowerSupply))
|
|
for _, psu := range hw.PowerSupply {
|
|
if psu.InputVoltage <= 0 {
|
|
continue
|
|
}
|
|
name := "PSU " + strings.TrimSpace(psu.Slot) + " input voltage"
|
|
if strings.TrimSpace(psu.Slot) == "" {
|
|
name = "PSU input voltage"
|
|
}
|
|
status := "ok"
|
|
if psu.InputVoltage < minV || psu.InputVoltage > maxV {
|
|
status = "warn"
|
|
}
|
|
out = append(out, models.SensorReading{
|
|
Name: name,
|
|
Type: "voltage",
|
|
Value: psu.InputVoltage,
|
|
Unit: "V",
|
|
Status: status,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
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,
|
|
"source_timezone": result.SourceTimezone,
|
|
"collected_at": result.CollectedAt,
|
|
}
|
|
if result.RawPayloads != nil {
|
|
if fetchErrors, ok := result.RawPayloads["redfish_fetch_errors"]; ok {
|
|
response["redfish_fetch_errors"] = fetchErrors
|
|
}
|
|
}
|
|
|
|
if result.Hardware == nil {
|
|
response["hardware"] = map[string]interface{}{}
|
|
response["specification"] = []SpecLine{}
|
|
jsonResponse(w, response)
|
|
return
|
|
}
|
|
|
|
devices := canonicalDevices(result.Hardware)
|
|
spec := buildSpecification(result.Hardware)
|
|
|
|
response["hardware"] = map[string]any{
|
|
"board": result.Hardware.BoardInfo,
|
|
"firmware": result.Hardware.Firmware,
|
|
"devices": devices,
|
|
}
|
|
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 canonicalDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
|
if hw == nil {
|
|
return nil
|
|
}
|
|
hw.Devices = BuildHardwareDevices(hw)
|
|
return hw.Devices
|
|
}
|
|
|
|
func buildSpecification(hw *models.HardwareConfig) []SpecLine {
|
|
var spec []SpecLine
|
|
if hw == nil {
|
|
return spec
|
|
}
|
|
devices := canonicalDevices(hw)
|
|
|
|
// CPUs - group by model
|
|
cpuGroups := make(map[string]int)
|
|
cpuDetails := make(map[string]models.HardwareDevice)
|
|
for _, cpu := range devices {
|
|
if cpu.Kind != models.DeviceKindCPU {
|
|
continue
|
|
}
|
|
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,
|
|
intFromDetails(cpu.Details, "tdp_w"))
|
|
spec = append(spec, SpecLine{Category: "CPU", Name: name, Quantity: count})
|
|
}
|
|
|
|
// Memory - group by size, type and frequency (only installed modules)
|
|
memGroups := make(map[string]int)
|
|
for _, mem := range devices {
|
|
if mem.Kind != models.DeviceKindMemory {
|
|
continue
|
|
}
|
|
present := mem.Present != nil && *mem.Present
|
|
if !present {
|
|
continue
|
|
}
|
|
|
|
if mem.SizeMB == 0 {
|
|
name := strings.TrimSpace(strings.Join(nonEmptyStrings(mem.Manufacturer, mem.PartNumber, mem.Type), " "))
|
|
if name == "" {
|
|
name = "Installed DIMM (size unknown)"
|
|
} else {
|
|
name += " (size unknown)"
|
|
}
|
|
memGroups[name]++
|
|
continue
|
|
}
|
|
|
|
key := ""
|
|
currentSpeed := intFromDetails(mem.Details, "current_speed_mhz")
|
|
if currentSpeed > 0 {
|
|
key = fmt.Sprintf("%s %dGB %dMHz", mem.Type, mem.SizeMB/1024, currentSpeed)
|
|
} else {
|
|
key = fmt.Sprintf("%s %dGB", mem.Type, mem.SizeMB/1024)
|
|
}
|
|
memGroups[key]++
|
|
}
|
|
for key, count := range memGroups {
|
|
spec = append(spec, SpecLine{Category: "Memory", Name: key, Quantity: count})
|
|
}
|
|
|
|
// Storage - group by type and capacity
|
|
storGroups := make(map[string]int)
|
|
for _, stor := range devices {
|
|
if stor.Kind != models.DeviceKindStorage {
|
|
continue
|
|
}
|
|
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: "Storage", Name: key, Quantity: count})
|
|
}
|
|
|
|
// PCIe devices - group by device class/name and manufacturer
|
|
pcieGroups := make(map[string]int)
|
|
pcieDetails := make(map[string]models.HardwareDevice)
|
|
for _, pcie := range devices {
|
|
if pcie.Kind != models.DeviceKindPCIe && pcie.Kind != models.DeviceKindGPU && pcie.Kind != models.DeviceKindNetwork {
|
|
continue
|
|
}
|
|
// 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 Device"
|
|
name := key
|
|
|
|
// Determine category based on device class or known GPU names
|
|
deviceClass := pcie.DeviceClass
|
|
isGPU := pcie.Kind == models.DeviceKindGPU || isGPUDevice(deviceClass)
|
|
isNetwork := deviceClass == "Network" || strings.Contains(deviceClass, "ConnectX")
|
|
|
|
if isGPU {
|
|
category = "GPU"
|
|
} else if isNetwork {
|
|
category = "Network Adapter"
|
|
} else if deviceClass == "NVMe" || deviceClass == "RAID" || deviceClass == "SAS" || deviceClass == "SATA" || deviceClass == "Storage" {
|
|
category = "Controller"
|
|
}
|
|
|
|
spec = append(spec, SpecLine{Category: category, Name: name, Quantity: count})
|
|
}
|
|
|
|
// Power supplies - group by model/wattage
|
|
psuGroups := make(map[string]int)
|
|
for _, psu := range devices {
|
|
if psu.Kind != models.DeviceKindPSU {
|
|
continue
|
|
}
|
|
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: "Power Supply", Name: key, Quantity: count})
|
|
}
|
|
|
|
return spec
|
|
}
|
|
|
|
func nonEmptyStrings(values ...string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
if result == nil {
|
|
jsonList(w, []interface{}{}, 0)
|
|
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 !hasUsableSerial(fru.SerialNumber) {
|
|
continue
|
|
}
|
|
name := fru.ProductName
|
|
if name == "" {
|
|
name = fru.Description
|
|
}
|
|
serials = append(serials, SerialEntry{
|
|
Component: name,
|
|
SerialNumber: strings.TrimSpace(fru.SerialNumber),
|
|
Manufacturer: fru.Manufacturer,
|
|
PartNumber: fru.PartNumber,
|
|
Category: "FRU",
|
|
})
|
|
}
|
|
|
|
if result.Hardware != nil {
|
|
for _, d := range canonicalDevices(result.Hardware) {
|
|
if !hasUsableSerial(d.SerialNumber) {
|
|
continue
|
|
}
|
|
serials = append(serials, SerialEntry{
|
|
Component: serialComponent(d),
|
|
Location: strings.TrimSpace(coalesce(d.Location, d.Slot)),
|
|
SerialNumber: strings.TrimSpace(d.SerialNumber),
|
|
Manufacturer: strings.TrimSpace(d.Manufacturer),
|
|
PartNumber: strings.TrimSpace(d.PartNumber),
|
|
Category: serialCategory(d.Kind),
|
|
})
|
|
}
|
|
}
|
|
|
|
jsonList(w, serials, len(serials))
|
|
}
|
|
|
|
func normalizePCIeSerialComponentName(p models.PCIeDevice) string {
|
|
className := strings.TrimSpace(p.DeviceClass)
|
|
part := strings.TrimSpace(p.PartNumber)
|
|
if part != "" && !strings.EqualFold(part, className) {
|
|
return part
|
|
}
|
|
lowerClass := strings.ToLower(className)
|
|
switch lowerClass {
|
|
case "display", "display controller", "3d controller", "vga", "network", "network controller", "pcie device", "other", "unknown", "":
|
|
if part != "" {
|
|
return part
|
|
}
|
|
}
|
|
if className != "" {
|
|
return className
|
|
}
|
|
if part != "" {
|
|
return part
|
|
}
|
|
return "PCIe device"
|
|
}
|
|
|
|
func hasUsableSerial(serial string) bool {
|
|
s := strings.TrimSpace(serial)
|
|
if s == "" {
|
|
return false
|
|
}
|
|
switch strings.ToUpper(s) {
|
|
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func hasUsableFirmwareVersion(version string) bool {
|
|
v := strings.TrimSpace(version)
|
|
if v == "" {
|
|
return false
|
|
}
|
|
switch strings.ToUpper(v) {
|
|
case "N/A", "NA", "NONE", "NULL", "UNKNOWN", "-":
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleGetFirmware(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
if result == nil || result.Hardware == nil {
|
|
jsonList(w, []interface{}{}, 0)
|
|
return
|
|
}
|
|
|
|
entries := buildFirmwareEntries(result.Hardware)
|
|
jsonList(w, entries, len(entries))
|
|
}
|
|
|
|
type parseErrorEntry struct {
|
|
Source string `json:"source"` // redfish | parser | file | collect_log
|
|
Category string `json:"category"` // fetch | partial_inventory | collect_log
|
|
Severity string `json:"severity,omitempty"` // error | warning | info
|
|
Path string `json:"path,omitempty"`
|
|
Message string `json:"message"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleGetParseErrors(w http.ResponseWriter, r *http.Request) {
|
|
result := s.GetResult()
|
|
rawPkg := s.GetRawExport()
|
|
|
|
items := make([]parseErrorEntry, 0)
|
|
seen := make(map[string]struct{})
|
|
add := func(e parseErrorEntry) {
|
|
key := strings.TrimSpace(e.Source) + "|" + strings.TrimSpace(e.Category) + "|" + strings.TrimSpace(e.Path) + "|" + strings.TrimSpace(e.Message)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
items = append(items, e)
|
|
}
|
|
|
|
var fetchErrMap map[string]string
|
|
if result != nil && result.RawPayloads != nil {
|
|
fetchErrMap = extractRedfishFetchErrors(result.RawPayloads["redfish_fetch_errors"])
|
|
for path, msg := range fetchErrMap {
|
|
add(parseErrorEntry{
|
|
Source: "redfish",
|
|
Category: "fetch",
|
|
Severity: parseErrorSeverityFromMessage(msg),
|
|
Path: path,
|
|
Message: msg,
|
|
})
|
|
}
|
|
}
|
|
|
|
if rawPkg != nil && len(rawPkg.Source.CollectLogs) > 0 {
|
|
for _, line := range rawPkg.Source.CollectLogs {
|
|
if !looksLikeErrorLogLine(line) {
|
|
continue
|
|
}
|
|
add(parseErrorEntry{
|
|
Source: "collect_log",
|
|
Category: "collect_log",
|
|
Severity: parseErrorSeverityFromMessage(line),
|
|
Message: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
|
|
if result != nil && result.Protocol == "redfish" && result.Hardware != nil {
|
|
hw := result.Hardware
|
|
if len(hw.Memory) == 0 && hasFetchErrorSuffix(fetchErrMap, "/Memory") {
|
|
add(parseErrorEntry{
|
|
Source: "parser",
|
|
Category: "partial_inventory",
|
|
Severity: "warning",
|
|
Path: "/redfish/v1/Systems/*/Memory",
|
|
Message: "Memory inventory is empty because Redfish Memory endpoint failed during collection",
|
|
})
|
|
}
|
|
if len(hw.CPUs) == 0 && hasFetchErrorSuffix(fetchErrMap, "/Processors") {
|
|
add(parseErrorEntry{
|
|
Source: "parser",
|
|
Category: "partial_inventory",
|
|
Severity: "warning",
|
|
Path: "/redfish/v1/Systems/*/Processors",
|
|
Message: "CPU inventory is empty because Redfish Processors endpoint failed during collection",
|
|
})
|
|
}
|
|
if len(hw.Firmware) == 0 && (hasFetchErrorSuffix(fetchErrMap, "/Managers/1") || hasFetchErrorSuffix(fetchErrMap, "/UpdateService")) {
|
|
add(parseErrorEntry{
|
|
Source: "parser",
|
|
Category: "partial_inventory",
|
|
Severity: "warning",
|
|
Message: "Firmware inventory may be incomplete due to Redfish Manager/UpdateService endpoint failures",
|
|
})
|
|
}
|
|
}
|
|
|
|
// BMC-reported collection failures surfaced by vendor parsers.
|
|
if result != nil {
|
|
for _, ce := range result.CollectionErrors {
|
|
msg := strings.TrimSpace(ce.Message)
|
|
if msg == "" {
|
|
continue
|
|
}
|
|
detail := ""
|
|
if ce.Code != 0 {
|
|
detail = fmt.Sprintf("code %d", ce.Code)
|
|
}
|
|
add(parseErrorEntry{
|
|
Source: "bmc",
|
|
Category: "bmc_collection_error",
|
|
Severity: "warning",
|
|
Path: ce.Section,
|
|
Message: msg,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].Severity != items[j].Severity {
|
|
// error > warning > info
|
|
return parseErrorSeverityRank(items[i].Severity) < parseErrorSeverityRank(items[j].Severity)
|
|
}
|
|
if items[i].Source != items[j].Source {
|
|
return items[i].Source < items[j].Source
|
|
}
|
|
if items[i].Category != items[j].Category {
|
|
return items[i].Category < items[j].Category
|
|
}
|
|
return items[i].Path < items[j].Path
|
|
})
|
|
|
|
jsonResponse(w, map[string]any{
|
|
"items": items,
|
|
"summary": map[string]any{
|
|
"total": len(items),
|
|
"source_kind": func() string {
|
|
if rawPkg != nil {
|
|
return rawPkg.Source.Kind
|
|
}
|
|
return ""
|
|
}(),
|
|
},
|
|
})
|
|
}
|
|
|
|
func extractRedfishFetchErrors(raw any) map[string]string {
|
|
out := make(map[string]string)
|
|
list, ok := raw.([]map[string]interface{})
|
|
if ok {
|
|
for _, item := range list {
|
|
p := strings.TrimSpace(fmt.Sprintf("%v", item["path"]))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out[p] = strings.TrimSpace(fmt.Sprintf("%v", item["error"]))
|
|
}
|
|
return out
|
|
}
|
|
if generic, ok := raw.([]interface{}); ok {
|
|
for _, itemAny := range generic {
|
|
item, ok := itemAny.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(fmt.Sprintf("%v", item["path"]))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out[p] = strings.TrimSpace(fmt.Sprintf("%v", item["error"]))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func looksLikeErrorLogLine(line string) bool {
|
|
s := strings.ToLower(strings.TrimSpace(line))
|
|
if s == "" {
|
|
return false
|
|
}
|
|
return strings.Contains(s, "error") ||
|
|
strings.Contains(s, "failed") ||
|
|
strings.Contains(s, "timeout") ||
|
|
strings.Contains(s, "deadline exceeded")
|
|
}
|
|
|
|
func hasFetchErrorSuffix(fetchErrs map[string]string, suffix string) bool {
|
|
if len(fetchErrs) == 0 {
|
|
return false
|
|
}
|
|
for p := range fetchErrs {
|
|
if strings.HasSuffix(p, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseErrorSeverityFromMessage(msg string) string {
|
|
s := strings.ToLower(strings.TrimSpace(msg))
|
|
if s == "" {
|
|
return "info"
|
|
}
|
|
if strings.Contains(s, "timeout") || strings.Contains(s, "deadline exceeded") {
|
|
return "error"
|
|
}
|
|
if strings.HasPrefix(s, "status 500 ") || strings.HasPrefix(s, "status 502 ") || strings.HasPrefix(s, "status 503 ") || strings.HasPrefix(s, "status 504 ") {
|
|
return "error"
|
|
}
|
|
if strings.HasPrefix(s, "status 401 ") || strings.HasPrefix(s, "status 403 ") {
|
|
return "error"
|
|
}
|
|
if strings.HasPrefix(s, "status 404 ") || strings.HasPrefix(s, "status 405 ") || strings.HasPrefix(s, "status 410 ") || strings.HasPrefix(s, "status 501 ") {
|
|
return "info"
|
|
}
|
|
if strings.Contains(s, "error") || strings.Contains(s, "failed") {
|
|
return "warning"
|
|
}
|
|
return "info"
|
|
}
|
|
|
|
func parseErrorSeverityRank(severity string) int {
|
|
switch strings.ToLower(strings.TrimSpace(severity)) {
|
|
case "error":
|
|
return 0
|
|
case "warning":
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
type firmwareEntry struct {
|
|
Component string `json:"component"`
|
|
Model string `json:"model"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
func buildFirmwareEntries(hw *models.HardwareConfig) []firmwareEntry {
|
|
if hw == nil {
|
|
return nil
|
|
}
|
|
|
|
// Deduplicate firmware by extracting model name and version
|
|
// E.g., "PSU0 (AP-CR3000F12BY)" and "PSU1 (AP-CR3000F12BY)" with same version -> one entry
|
|
seen := make(map[string]bool)
|
|
var deduplicated []firmwareEntry
|
|
|
|
appendEntry := func(component, model, version string) {
|
|
component = strings.TrimSpace(component)
|
|
model = strings.TrimSpace(model)
|
|
version = strings.TrimSpace(version)
|
|
if component == "" || !hasUsableFirmwareVersion(version) {
|
|
return
|
|
}
|
|
if model == "" {
|
|
model = "-"
|
|
}
|
|
key := component + "|" + model + "|" + version
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
deduplicated = append(deduplicated, firmwareEntry{
|
|
Component: component,
|
|
Model: model,
|
|
Version: version,
|
|
})
|
|
}
|
|
|
|
for _, fw := range hw.Firmware {
|
|
component, model := extractFirmwareComponentAndModel(fw.DeviceName)
|
|
appendEntry(component, model, fw.Version)
|
|
}
|
|
|
|
for _, d := range canonicalDevices(hw) {
|
|
version := strings.TrimSpace(d.Firmware)
|
|
if version == "" {
|
|
continue
|
|
}
|
|
model := strings.TrimSpace(d.PartNumber)
|
|
if model == "" {
|
|
model = strings.TrimSpace(d.Model)
|
|
}
|
|
if model == "" {
|
|
model = strings.TrimSpace(d.Slot)
|
|
}
|
|
appendEntry(serialCategory(d.Kind), model, version)
|
|
}
|
|
|
|
return deduplicated
|
|
}
|
|
|
|
func serialComponent(d models.HardwareDevice) string {
|
|
if strings.TrimSpace(d.Model) != "" {
|
|
return strings.TrimSpace(d.Model)
|
|
}
|
|
if strings.TrimSpace(d.PartNumber) != "" {
|
|
return strings.TrimSpace(d.PartNumber)
|
|
}
|
|
if d.Kind == models.DeviceKindPCIe {
|
|
return normalizePCIeSerialComponentName(models.PCIeDevice{
|
|
DeviceClass: d.DeviceClass,
|
|
PartNumber: d.PartNumber,
|
|
})
|
|
}
|
|
if strings.TrimSpace(d.DeviceClass) != "" {
|
|
return strings.TrimSpace(d.DeviceClass)
|
|
}
|
|
return strings.ToUpper(d.Kind)
|
|
}
|
|
|
|
func serialCategory(kind string) string {
|
|
switch kind {
|
|
case models.DeviceKindBoard:
|
|
return "Board"
|
|
case models.DeviceKindCPU:
|
|
return "CPU"
|
|
case models.DeviceKindMemory:
|
|
return "Memory"
|
|
case models.DeviceKindStorage:
|
|
return "Storage"
|
|
case models.DeviceKindGPU:
|
|
return "GPU"
|
|
case models.DeviceKindNetwork:
|
|
return "Network"
|
|
case models.DeviceKindPSU:
|
|
return "PSU"
|
|
default:
|
|
return "PCIe"
|
|
}
|
|
}
|
|
|
|
func intFromDetails(details map[string]any, key string) int {
|
|
if details == nil {
|
|
return 0
|
|
}
|
|
v, ok := details[key]
|
|
if !ok {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case float64:
|
|
return int(n)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func coalesce(values ...string) string {
|
|
for _, v := range values {
|
|
if strings.TrimSpace(v) != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// 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 "GPU GPUSXM5 (692-2G520-0280-501)" -> component: "GPU", model: "GPUSXM5 (692-2G520-0280-501)"
|
|
if strings.HasPrefix(deviceName, "GPU ") {
|
|
if idx := strings.Index(deviceName, "("); idx != -1 {
|
|
model = strings.TrimSpace(strings.Trim(deviceName[idx:], "()"))
|
|
if model != "" {
|
|
return "GPU", model
|
|
}
|
|
}
|
|
model = strings.TrimSpace(strings.TrimPrefix(deviceName, "GPU "))
|
|
if model == "" {
|
|
return "GPU", "-"
|
|
}
|
|
return "GPU", model
|
|
}
|
|
|
|
// For "NVSwitch NVSWITCH2 (NVSWITCH2)" -> component: "NVSwitch", model: "NVSWITCH2 (NVSWITCH2)"
|
|
if strings.HasPrefix(deviceName, "NVSwitch ") {
|
|
if idx := strings.Index(deviceName, "("); idx != -1 {
|
|
model = strings.TrimSpace(strings.Trim(deviceName[idx:], "()"))
|
|
if model != "" {
|
|
return "NVSwitch", model
|
|
}
|
|
}
|
|
model = strings.TrimSpace(strings.TrimPrefix(deviceName, "NVSwitch "))
|
|
if model == "" {
|
|
return "NVSwitch", "-"
|
|
}
|
|
return "NVSwitch", model
|
|
}
|
|
|
|
// 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,
|
|
"source_timezone": result.SourceTimezone,
|
|
"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()
|
|
|
|
if rawPkg := s.GetRawExport(); rawPkg != nil {
|
|
bundle, err := buildRawExportBundle(rawPkg, result, s.ClientVersionString())
|
|
if err != nil {
|
|
jsonError(w, "Failed to build raw export bundle: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "zip")))
|
|
_, _ = w.Write(bundle)
|
|
return
|
|
}
|
|
|
|
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) 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 {
|
|
statusCode := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "required for Reanimator export") {
|
|
statusCode = http.StatusBadRequest
|
|
}
|
|
jsonError(w, fmt.Sprintf("Export failed: %v", err), statusCode)
|
|
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) handleConvertReanimatorBatch(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, convertMultipartMaxBytes())
|
|
if err := r.ParseMultipartForm(uploadMultipartFormMemoryBytes()); err != nil {
|
|
if strings.Contains(strings.ToLower(err.Error()), "too large") {
|
|
msg := fmt.Sprintf(
|
|
"File too large. Increase LOGPILE_CONVERT_MAX_MB (current limit: %d MB)",
|
|
convertMultipartMaxBytes()>>20,
|
|
)
|
|
jsonError(w, msg, http.StatusBadRequest)
|
|
return
|
|
}
|
|
jsonError(w, "Failed to parse multipart form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
form := r.MultipartForm
|
|
if form == nil {
|
|
jsonError(w, "No files provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
files := form.File["files[]"]
|
|
if len(files) == 0 {
|
|
files = form.File["files"]
|
|
}
|
|
if len(files) == 0 {
|
|
jsonError(w, "No files provided", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tempDir, err := os.MkdirTemp("", "logpile-convert-input-*")
|
|
if err != nil {
|
|
jsonError(w, "Failed to create temp directory", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
inputFiles := make([]convertInputFile, 0, len(files))
|
|
var skipped int
|
|
for _, fh := range files {
|
|
if fh == nil {
|
|
continue
|
|
}
|
|
if !isSupportedConvertFileName(fh.Filename) {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
tmpFile, err := os.CreateTemp(tempDir, "input-*")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
src, err := fh.Open()
|
|
if err != nil {
|
|
_ = tmpFile.Close()
|
|
_ = os.Remove(tmpFile.Name())
|
|
continue
|
|
}
|
|
_, err = io.Copy(tmpFile, src)
|
|
_ = src.Close()
|
|
_ = tmpFile.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpFile.Name())
|
|
continue
|
|
}
|
|
|
|
mimeType := ""
|
|
if fh.Header != nil {
|
|
mimeType = fh.Header.Get("Content-Type")
|
|
}
|
|
|
|
inputFiles = append(inputFiles, convertInputFile{
|
|
Name: fh.Filename,
|
|
Path: tmpFile.Name(),
|
|
MIMEType: mimeType,
|
|
})
|
|
}
|
|
|
|
if len(inputFiles) == 0 {
|
|
_ = os.RemoveAll(tempDir)
|
|
jsonError(w, "No supported files to convert", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
job := s.jobManager.CreateJob(CollectRequest{
|
|
Host: "convert.local",
|
|
Protocol: "convert",
|
|
Port: 0,
|
|
Username: "convert",
|
|
AuthType: "password",
|
|
TLSMode: "insecure",
|
|
})
|
|
s.markConvertJob(job.ID)
|
|
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Batch conversion started: %d files", len(inputFiles)))
|
|
if skipped > 0 {
|
|
s.jobManager.AppendJobLog(job.ID, fmt.Sprintf("Skipped unsupported files: %d", skipped))
|
|
}
|
|
s.jobManager.UpdateJobStatus(job.ID, CollectStatusRunning, 0, "")
|
|
|
|
go s.runConvertJob(job.ID, tempDir, inputFiles, skipped, len(files))
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"job_id": job.ID,
|
|
"status": CollectStatusRunning,
|
|
"accepted": len(inputFiles),
|
|
"skipped": skipped,
|
|
"total_files": len(files),
|
|
})
|
|
}
|
|
|
|
type convertInputFile struct {
|
|
Name string
|
|
Path string
|
|
MIMEType string
|
|
}
|
|
|
|
func (s *Server) runConvertJob(jobID, tempDir string, inputFiles []convertInputFile, skipped, total int) {
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
resultFile, err := os.CreateTemp("", "logpile-convert-result-*.zip")
|
|
if err != nil {
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "failed to create zip")
|
|
return
|
|
}
|
|
resultPath := resultFile.Name()
|
|
defer resultFile.Close()
|
|
|
|
zw := zip.NewWriter(resultFile)
|
|
failures := make([]string, 0)
|
|
success := 0
|
|
totalProcess := len(inputFiles)
|
|
|
|
for i, in := range inputFiles {
|
|
s.jobManager.AppendJobLog(jobID, fmt.Sprintf("Processing %s", in.Name))
|
|
payload, err := os.ReadFile(in.Path)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
|
continue
|
|
}
|
|
|
|
result, _, _, err := s.analyzeUploadedFile(in.Name, in.MIMEType, payload)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
|
progress := ((i + 1) * 100) / totalProcess
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "")
|
|
continue
|
|
}
|
|
if result == nil || result.Hardware == nil {
|
|
failures = append(failures, fmt.Sprintf("%s: no hardware data", in.Name))
|
|
progress := ((i + 1) * 100) / totalProcess
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "")
|
|
continue
|
|
}
|
|
|
|
reanimatorData, err := exporter.ConvertToReanimator(result)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
|
progress := ((i + 1) * 100) / totalProcess
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "")
|
|
continue
|
|
}
|
|
|
|
entryPath := sanitizeZipPath(in.Name)
|
|
entry, err := zw.Create(entryPath + ".reanimator.json")
|
|
if err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
|
progress := ((i + 1) * 100) / totalProcess
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "")
|
|
continue
|
|
}
|
|
|
|
encoder := json.NewEncoder(entry)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(reanimatorData); err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", in.Name, err))
|
|
} else {
|
|
success++
|
|
}
|
|
|
|
progress := ((i + 1) * 100) / totalProcess
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusRunning, progress, "")
|
|
}
|
|
|
|
if success == 0 {
|
|
_ = zw.Close()
|
|
_ = os.Remove(resultPath)
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to convert any file")
|
|
return
|
|
}
|
|
|
|
summaryLines := []string{fmt.Sprintf("Converted %d of %d files", success, total)}
|
|
if skipped > 0 {
|
|
summaryLines = append(summaryLines, fmt.Sprintf("Skipped unsupported: %d", skipped))
|
|
}
|
|
summaryLines = append(summaryLines, failures...)
|
|
if entry, err := zw.Create("convert-summary.txt"); err == nil {
|
|
_, _ = io.WriteString(entry, strings.Join(summaryLines, "\n"))
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
_ = os.Remove(resultPath)
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Failed to pack results")
|
|
return
|
|
}
|
|
|
|
s.setConvertArtifact(jobID, ConvertArtifact{
|
|
Path: resultPath,
|
|
Summary: summaryLines[0],
|
|
})
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusSuccess, 100, "")
|
|
}
|
|
|
|
func (s *Server) handleConvertStatus(w http.ResponseWriter, r *http.Request) {
|
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
|
if !isValidCollectJobID(jobID) {
|
|
jsonError(w, "Invalid convert job id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !s.isConvertJob(jobID) {
|
|
jsonError(w, "Convert job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
job, ok := s.jobManager.GetJob(jobID)
|
|
if !ok {
|
|
jsonError(w, "Convert job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
jsonResponse(w, job.toStatusResponse())
|
|
}
|
|
|
|
func (s *Server) handleConvertDownload(w http.ResponseWriter, r *http.Request) {
|
|
jobID := strings.TrimSpace(r.PathValue("id"))
|
|
if !isValidCollectJobID(jobID) {
|
|
jsonError(w, "Invalid convert job id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !s.isConvertJob(jobID) {
|
|
jsonError(w, "Convert job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
job, ok := s.jobManager.GetJob(jobID)
|
|
if !ok {
|
|
jsonError(w, "Convert job not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if job.Status != CollectStatusSuccess {
|
|
jsonError(w, "Convert job is not finished yet", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
artifact, ok := s.getConvertArtifact(jobID)
|
|
if !ok || strings.TrimSpace(artifact.Path) == "" {
|
|
jsonError(w, "Convert result not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
file, err := os.Open(artifact.Path)
|
|
if err != nil {
|
|
jsonError(w, "Convert result not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
defer func() {
|
|
_ = os.Remove(artifact.Path)
|
|
s.clearConvertArtifact(jobID)
|
|
}()
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
jsonError(w, "Convert result not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "logpile-convert.zip"))
|
|
if artifact.Summary != "" {
|
|
w.Header().Set("X-Convert-Summary", artifact.Summary)
|
|
}
|
|
http.ServeContent(w, r, "logpile-convert.zip", stat.ModTime(), file)
|
|
}
|
|
|
|
func isSupportedConvertFileName(filename string) bool {
|
|
name := strings.TrimSpace(filename)
|
|
if name == "" {
|
|
return false
|
|
}
|
|
if strings.HasSuffix(strings.ToLower(name), ".json") {
|
|
return true
|
|
}
|
|
return parser.IsSupportedArchiveFilename(name)
|
|
}
|
|
|
|
func sanitizeZipPath(filename string) string {
|
|
path := filepath.Clean(filename)
|
|
if path == "." || path == "/" {
|
|
path = filepath.Base(filename)
|
|
}
|
|
path = strings.TrimPrefix(path, string(filepath.Separator))
|
|
if strings.HasPrefix(path, "..") {
|
|
path = filepath.Base(path)
|
|
}
|
|
path = filepath.ToSlash(path)
|
|
if path == "" {
|
|
path = filepath.Base(filename)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) {
|
|
s.SetResult(nil)
|
|
s.SetDetectedVendor("")
|
|
s.SetRawExport(nil)
|
|
for _, artifact := range s.clearAllConvertArtifacts() {
|
|
if strings.TrimSpace(artifact.Path) != "" {
|
|
_ = os.Remove(artifact.Path)
|
|
}
|
|
}
|
|
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.jobManager.AppendJobLog(job.ID, "Client: "+s.ClientVersionString())
|
|
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"))
|
|
}
|
|
|
|
// pingHost dials host:port up to total times with 2s timeout each, returns true if
|
|
// at least need attempts succeeded.
|
|
func pingHost(host string, port int, total, need int) (bool, string) {
|
|
addr := fmt.Sprintf("%s:%d", host, port)
|
|
var successes atomic.Int32
|
|
done := make(chan struct{}, total)
|
|
for i := 0; i < total; i++ {
|
|
go func() {
|
|
defer func() { done <- struct{}{} }()
|
|
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err == nil {
|
|
conn.Close()
|
|
successes.Add(1)
|
|
}
|
|
}()
|
|
}
|
|
for i := 0; i < total; i++ {
|
|
<-done
|
|
}
|
|
n := int(successes.Load())
|
|
if n < need {
|
|
return false, fmt.Sprintf("Host unreachable: only %d of %d connection attempts to %s succeeded (minimum required: %d)", n, total, addr, need)
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func (s *Server) handleCollectProbe(w http.ResponseWriter, r *http.Request) {
|
|
var req CollectRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "Invalid JSON body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := validateCollectRequest(req); err != nil {
|
|
jsonError(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
connector, ok := s.getCollector(req.Protocol)
|
|
if !ok {
|
|
jsonError(w, "Protocol connector not registered", http.StatusBadRequest)
|
|
return
|
|
}
|
|
prober, ok := connector.(collector.Prober)
|
|
if !ok {
|
|
jsonError(w, "Connection probe not supported for this protocol", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if ok, msg := pingHost(req.Host, req.Port, 10, 3); !ok {
|
|
jsonError(w, msg, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := prober.Probe(ctx, toCollectorRequest(req))
|
|
if err != nil {
|
|
jsonError(w, "Connection probe failed: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
message := "BMC connection established"
|
|
if result != nil {
|
|
if result.HostPoweredOn {
|
|
message = "BMC connection established, host is powered on."
|
|
} else {
|
|
message = "BMC connection established, host is powered off. Inventory data may be incomplete."
|
|
}
|
|
}
|
|
|
|
hostPowerState := ""
|
|
hostPoweredOn := false
|
|
reachable := false
|
|
if result != nil {
|
|
reachable = result.Reachable
|
|
hostPowerState = strings.TrimSpace(result.HostPowerState)
|
|
hostPoweredOn = result.HostPoweredOn
|
|
}
|
|
|
|
jsonResponse(w, CollectProbeResponse{
|
|
Reachable: reachable,
|
|
Protocol: req.Protocol,
|
|
HostPowerState: hostPowerState,
|
|
HostPoweredOn: hostPoweredOn,
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
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) handleCollectSkip(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.SkipJob(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
|
|
}
|
|
|
|
skipCh := make(chan struct{})
|
|
var skipOnce sync.Once
|
|
skipFn := func() { skipOnce.Do(func() { close(skipCh) }) }
|
|
s.jobManager.AttachJobSkip(jobID, skipFn)
|
|
|
|
go func() {
|
|
connector, ok := s.getCollector(req.Protocol)
|
|
if !ok {
|
|
s.jobManager.UpdateJobStatus(jobID, CollectStatusFailed, 100, "Protocol connector not registered")
|
|
s.jobManager.AppendJobLog(jobID, "Collection completed with error")
|
|
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.CurrentPhase != "" || update.ETASeconds > 0 {
|
|
s.jobManager.UpdateJobETA(jobID, update.CurrentPhase, update.ETASeconds)
|
|
}
|
|
if update.DebugInfo != nil {
|
|
debugInfo := &CollectDebugInfo{
|
|
AdaptiveThrottled: update.DebugInfo.AdaptiveThrottled,
|
|
SnapshotWorkers: update.DebugInfo.SnapshotWorkers,
|
|
PrefetchWorkers: update.DebugInfo.PrefetchWorkers,
|
|
PrefetchEnabled: update.DebugInfo.PrefetchEnabled,
|
|
}
|
|
if len(update.DebugInfo.PhaseTelemetry) > 0 {
|
|
debugInfo.PhaseTelemetry = make([]CollectPhaseTelemetry, 0, len(update.DebugInfo.PhaseTelemetry))
|
|
for _, item := range update.DebugInfo.PhaseTelemetry {
|
|
debugInfo.PhaseTelemetry = append(debugInfo.PhaseTelemetry, CollectPhaseTelemetry{
|
|
Phase: item.Phase,
|
|
Requests: item.Requests,
|
|
Errors: item.Errors,
|
|
ErrorRate: item.ErrorRate,
|
|
AvgMS: item.AvgMS,
|
|
P95MS: item.P95MS,
|
|
})
|
|
}
|
|
}
|
|
s.jobManager.UpdateJobDebugInfo(jobID, debugInfo)
|
|
}
|
|
if len(update.ActiveModules) > 0 || len(update.ModuleScores) > 0 {
|
|
activeModules := make([]CollectModuleStatus, 0, len(update.ActiveModules))
|
|
for _, module := range update.ActiveModules {
|
|
activeModules = append(activeModules, CollectModuleStatus{
|
|
Name: module.Name,
|
|
Score: module.Score,
|
|
Active: true,
|
|
})
|
|
}
|
|
moduleScores := make([]CollectModuleStatus, 0, len(update.ModuleScores))
|
|
for _, module := range update.ModuleScores {
|
|
moduleScores = append(moduleScores, CollectModuleStatus{
|
|
Name: module.Name,
|
|
Score: module.Score,
|
|
Active: module.Active,
|
|
Priority: module.Priority,
|
|
})
|
|
}
|
|
s.jobManager.UpdateJobModules(jobID, activeModules, moduleScores)
|
|
}
|
|
if update.Message != "" {
|
|
s.jobManager.AppendJobLog(jobID, update.Message)
|
|
}
|
|
}
|
|
|
|
collectorReq := toCollectorRequest(req)
|
|
collectorReq.SkipHungCh = skipCh
|
|
result, err := connector.Collect(ctx, collectorReq, 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, "Collection completed with error")
|
|
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, "Collection completed")
|
|
s.SetResult(result)
|
|
s.SetDetectedVendor(req.Protocol)
|
|
if job, ok := s.jobManager.GetJob(jobID); ok {
|
|
s.SetRawExport(newRawExportFromLiveCollect(result, req, job.Logs))
|
|
}
|
|
}()
|
|
}
|
|
|
|
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 = ""
|
|
if result.CollectedAt.IsZero() {
|
|
result.CollectedAt = inferArchiveCollectedAt(result)
|
|
}
|
|
}
|
|
|
|
func inferArchiveCollectedAt(result *models.AnalysisResult) time.Time {
|
|
if result == nil {
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
var latestReliable time.Time
|
|
var latestAny time.Time
|
|
for _, event := range result.Events {
|
|
if event.Timestamp.IsZero() {
|
|
continue
|
|
}
|
|
// Drop obviously bad epochs from broken RTC logs.
|
|
if event.Timestamp.Year() < 2000 {
|
|
continue
|
|
}
|
|
if latestAny.IsZero() || event.Timestamp.After(latestAny) {
|
|
latestAny = event.Timestamp
|
|
}
|
|
if !isReliableCollectedAtEvent(event) {
|
|
continue
|
|
}
|
|
if latestReliable.IsZero() || event.Timestamp.After(latestReliable) {
|
|
latestReliable = event.Timestamp
|
|
}
|
|
}
|
|
if !latestReliable.IsZero() {
|
|
return latestReliable.UTC()
|
|
}
|
|
if !latestAny.IsZero() {
|
|
return latestAny.UTC()
|
|
}
|
|
if fromFilename, ok := inferCollectedAtFromFilename(result.Filename); ok {
|
|
return fromFilename.UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func isReliableCollectedAtEvent(event models.Event) bool {
|
|
// component.log-derived synthetic states are created "at parse time"
|
|
// and must not override real log timestamps.
|
|
src := strings.ToLower(strings.TrimSpace(event.Source))
|
|
etype := strings.ToLower(strings.TrimSpace(event.EventType))
|
|
stype := strings.ToLower(strings.TrimSpace(event.SensorType))
|
|
if etype == "fan status" && (src == "fan" || stype == "fan") {
|
|
return false
|
|
}
|
|
if etype == "memory status" && (src == "memory" || stype == "memory") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
var (
|
|
filenameDateTimeRegex = regexp.MustCompile(`(?i)(\d{8})[-_](\d{4})(\d{2})?`)
|
|
filenameDateRegex = regexp.MustCompile(`(?i)(\d{4})-(\d{2})-(\d{2})`)
|
|
)
|
|
|
|
func inferCollectedAtFromFilename(name string) (time.Time, bool) {
|
|
base := strings.TrimSpace(filepath.Base(name))
|
|
if base == "" {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
if m := filenameDateTimeRegex.FindStringSubmatch(base); len(m) == 4 {
|
|
datePart := m[1]
|
|
timePart := m[2]
|
|
if strings.TrimSpace(m[3]) != "" {
|
|
timePart += m[3]
|
|
} else {
|
|
timePart += "00"
|
|
}
|
|
if ts, err := parser.ParseInDefaultArchiveLocation("20060102 150405", datePart+" "+timePart); err == nil {
|
|
return ts, true
|
|
}
|
|
}
|
|
|
|
if m := filenameDateRegex.FindStringSubmatch(base); len(m) == 4 {
|
|
datePart := m[1] + "-" + m[2] + "-" + m[3]
|
|
if ts, err := parser.ParseInDefaultArchiveLocation("2006-01-02 15:04:05", datePart+" 00:00:00"); err == nil {
|
|
return ts, true
|
|
}
|
|
}
|
|
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func inferRawExportCollectedAt(result *models.AnalysisResult, pkg *RawExportPackage) time.Time {
|
|
if result != nil && !result.CollectedAt.IsZero() {
|
|
return result.CollectedAt.UTC()
|
|
}
|
|
if pkg != nil {
|
|
if !pkg.CollectedAtHint.IsZero() {
|
|
return pkg.CollectedAtHint.UTC()
|
|
}
|
|
if _, finishedAt, ok := collectLogTimeBounds(pkg.Source.CollectLogs); ok {
|
|
return finishedAt.UTC()
|
|
}
|
|
if !pkg.ExportedAt.IsZero() {
|
|
return pkg.ExportedAt.UTC()
|
|
}
|
|
}
|
|
return 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
|
|
if strings.TrimSpace(result.SourceTimezone) == "" && result.RawPayloads != nil {
|
|
if tz, ok := result.RawPayloads["source_timezone"].(string); ok {
|
|
result.SourceTimezone = strings.TrimSpace(tz)
|
|
}
|
|
}
|
|
if result.CollectedAt.IsZero() {
|
|
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,
|
|
DebugPayloads: req.DebugPayloads,
|
|
}
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
func (s *Server) htmlError(w http.ResponseWriter, message string, code int) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
version := normalizeDisplayVersion(s.config.AppVersion)
|
|
fmt.Fprintf(w, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Error %d</title></head>`+
|
|
`<body><h1>Error %d</h1><p>%s</p>`+
|
|
`<footer style="margin-top:2em;color:#999;font-size:12px">LOGPile %s</footer></body></html>`,
|
|
code, code, template.HTMLEscapeString(message), template.HTMLEscapeString(version))
|
|
}
|
|
|
|
func jsonList(w http.ResponseWriter, items interface{}, totalCount int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"items": items,
|
|
"total_count": totalCount,
|
|
"page": 1,
|
|
"per_page": totalCount,
|
|
"total_pages": 1,
|
|
})
|
|
}
|
|
|
|
// isGPUDevice checks if device class indicates a GPU
|
|
func isGPUDevice(deviceClass string) bool {
|
|
// Standard PCI class names
|
|
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 = "json"
|
|
}
|
|
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
|
|
}
|