Files
logpile/internal/server/handlers.go
2026-02-04 09:54:48 +03:00

729 lines
19 KiB
Go

package server
import (
"crypto/rand"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"regexp"
"sort"
"strings"
"time"
"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()
// Parse archive
p := parser.NewBMCParser()
if err := p.ParseFromReader(file, header.Filename); err != nil {
jsonError(w, "Failed to parse archive: "+err.Error(), http.StatusBadRequest)
return
}
result := p.Result()
s.SetResult(result)
s.SetDetectedVendor(p.DetectedVendor())
jsonResponse(w, map[string]interface{}{
"status": "ok",
"message": "File uploaded and parsed successfully",
"filename": header.Filename,
"vendor": p.DetectedVendor(),
"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 || result.Hardware == nil {
jsonResponse(w, map[string]interface{}{})
return
}
// Build specification summary
spec := buildSpecification(result)
jsonResponse(w, map[string]interface{}{
"hardware": result.Hardware,
"specification": spec,
})
}
// 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",
})
}
// 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(),
"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", "attachment; filename=serials.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", "attachment; filename=report.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", "attachment; filename=report.txt")
exp := exporter.New(result)
exp.ExportTXT(w)
}
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
}
jobID := generateJobID()
now := time.Now().UTC()
progress := 0
s.collectMu.Lock()
s.collectJobs[jobID] = &CollectJobStatusResponse{
JobID: jobID,
Status: "queued",
Progress: &progress,
Logs: []string{"Job queued"},
UpdatedAt: now,
}
s.collectMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(CollectJobResponse{
JobID: jobID,
Status: "queued",
Message: "Collection job accepted",
CreatedAt: now,
})
}
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
}
s.collectMu.RLock()
job, ok := s.collectJobs[jobID]
if !ok || job == nil {
s.collectMu.RUnlock()
jsonError(w, "Collect job not found", http.StatusNotFound)
return
}
resp := *job
s.collectMu.RUnlock()
jsonResponse(w, resp)
}
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
}
s.collectMu.Lock()
job, ok := s.collectJobs[jobID]
if !ok || job == nil {
s.collectMu.Unlock()
jsonError(w, "Collect job not found", http.StatusNotFound)
return
}
now := time.Now().UTC()
progress := 0
job.Status = "canceled"
job.Progress = &progress
job.Logs = append(job.Logs, "Job canceled by user")
job.Error = ""
job.UpdatedAt = now
resp := *job
s.collectMu.Unlock()
jsonResponse(w, resp)
}
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 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
}