diff --git a/README.md b/README.md index 77194f3..e1c360c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ DELETE /api/clear # Очистить загруженные данны POST /api/shutdown # Завершить работу приложения ``` +`/api/status` и `/api/config` теперь возвращают унифицированные метаданные источника: +- `source_type`: `archive` или `api` +- `protocol`: `redfish` | `ipmi` (для архивов может быть пустым) +- `target_host`: BMC host для live-сбора +- `collected_at`: timestamp времени получения данных + ### Контракты live-сбора (`/api/collect`) `POST /api/collect` принимает JSON: diff --git a/internal/models/models.go b/internal/models/models.go index 81df62e..e4600ff 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -2,13 +2,22 @@ package models import "time" +const ( + SourceTypeArchive = "archive" + SourceTypeAPI = "api" +) + // AnalysisResult contains all parsed data from an archive type AnalysisResult struct { - Filename string `json:"filename"` - Events []Event `json:"events"` - FRU []FRUInfo `json:"fru"` - Sensors []SensorReading `json:"sensors"` - Hardware *HardwareConfig `json:"hardware"` + Filename string `json:"filename"` + SourceType string `json:"source_type,omitempty"` // archive | api + Protocol string `json:"protocol,omitempty"` // redfish | ipmi + TargetHost string `json:"target_host,omitempty"` // BMC host for live collect + CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp + Events []Event `json:"events"` + FRU []FRUInfo `json:"fru"` + Sensors []SensorReading `json:"sensors"` + Hardware *HardwareConfig `json:"hardware"` } // Event represents a single log event @@ -164,21 +173,21 @@ type NIC struct { // PSU represents a power supply unit type PSU struct { - Slot string `json:"slot"` - Present bool `json:"present"` - Model string `json:"model"` - Vendor string `json:"vendor,omitempty"` - WattageW int `json:"wattage_w,omitempty"` - SerialNumber string `json:"serial_number,omitempty"` - PartNumber string `json:"part_number,omitempty"` - Firmware string `json:"firmware,omitempty"` - Status string `json:"status,omitempty"` - InputType string `json:"input_type,omitempty"` - InputPowerW int `json:"input_power_w,omitempty"` - OutputPowerW int `json:"output_power_w,omitempty"` - InputVoltage float64 `json:"input_voltage,omitempty"` - OutputVoltage float64 `json:"output_voltage,omitempty"` - TemperatureC int `json:"temperature_c,omitempty"` + Slot string `json:"slot"` + Present bool `json:"present"` + Model string `json:"model"` + Vendor string `json:"vendor,omitempty"` + WattageW int `json:"wattage_w,omitempty"` + SerialNumber string `json:"serial_number,omitempty"` + PartNumber string `json:"part_number,omitempty"` + Firmware string `json:"firmware,omitempty"` + Status string `json:"status,omitempty"` + InputType string `json:"input_type,omitempty"` + InputPowerW int `json:"input_power_w,omitempty"` + OutputPowerW int `json:"output_power_w,omitempty"` + InputVoltage float64 `json:"input_voltage,omitempty"` + OutputVoltage float64 `json:"output_voltage,omitempty"` + TemperatureC int `json:"temperature_c,omitempty"` } // GPU represents a graphics processing unit @@ -200,11 +209,11 @@ type GPU struct { DMASize string `json:"dma_size,omitempty"` DMAMask string `json:"dma_mask,omitempty"` DeviceMinor int `json:"device_minor,omitempty"` - Temperature int `json:"temperature,omitempty"` // GPU core temp - MemTemperature int `json:"mem_temperature,omitempty"` // GPU memory temp - Power int `json:"power,omitempty"` // Current power draw (W) - MaxPower int `json:"max_power,omitempty"` // TDP (W) - ClockSpeed int `json:"clock_speed,omitempty"` // Operating speed MHz + Temperature int `json:"temperature,omitempty"` // GPU core temp + MemTemperature int `json:"mem_temperature,omitempty"` // GPU memory temp + Power int `json:"power,omitempty"` // Current power draw (W) + MaxPower int `json:"max_power,omitempty"` // TDP (W) + ClockSpeed int `json:"clock_speed,omitempty"` // Operating speed MHz MaxLinkWidth int `json:"max_link_width,omitempty"` MaxLinkSpeed string `json:"max_link_speed,omitempty"` CurrentLinkWidth int `json:"current_link_width,omitempty"` diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 79cabea..e706a81 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -62,6 +62,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { } result := p.Result() + applyArchiveSourceMetadata(result) s.SetResult(result) s.SetDetectedVendor(p.DetectedVendor()) @@ -114,18 +115,31 @@ func (s *Server) handleGetSensors(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { result := s.GetResult() - if result == nil || result.Hardware == nil { + 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) - jsonResponse(w, map[string]interface{}{ - "hardware": result.Hardware, - "specification": spec, - }) + response["hardware"] = result.Hardware + response["specification"] = spec + jsonResponse(w, response) } // SpecLine represents a single line in specification @@ -495,9 +509,13 @@ func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { } jsonResponse(w, map[string]interface{}{ - "loaded": true, - "filename": result.Filename, - "vendor": s.GetDetectedVendor(), + "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), @@ -574,6 +592,8 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) { } job := s.jobManager.CreateJob(req) + s.SetResult(newAPIResult(req)) + s.SetDetectedVendor("") s.startMockCollectionJob(job.ID, req) w.Header().Set("Content-Type", "application/json") @@ -726,6 +746,28 @@ func generateJobID() string { 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 newAPIResult(req CollectRequest) *models.AnalysisResult { + return &models.AnalysisResult{ + SourceType: models.SourceTypeAPI, + Protocol: req.Protocol, + TargetHost: req.Host, + CollectedAt: time.Now().UTC(), + Events: make([]models.Event, 0), + FRU: make([]models.FRUInfo, 0), + Sensors: make([]models.SensorReading, 0), + } +} + func jsonResponse(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) diff --git a/internal/server/source_metadata_test.go b/internal/server/source_metadata_test.go new file mode 100644 index 0000000..d0ebd4c --- /dev/null +++ b/internal/server/source_metadata_test.go @@ -0,0 +1,131 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +func TestApplyArchiveSourceMetadata(t *testing.T) { + result := &models.AnalysisResult{} + + applyArchiveSourceMetadata(result) + + if result.SourceType != models.SourceTypeArchive { + t.Fatalf("expected source type %q, got %q", models.SourceTypeArchive, result.SourceType) + } + if result.Protocol != "" { + t.Fatalf("expected empty protocol for archive, got %q", result.Protocol) + } + if result.TargetHost != "" { + t.Fatalf("expected empty target host for archive, got %q", result.TargetHost) + } + if result.CollectedAt.IsZero() { + t.Fatalf("expected collected_at to be set") + } +} + +func TestNewAPIResultMetadata(t *testing.T) { + req := CollectRequest{ + Host: "bmc-api.local", + Protocol: "redfish", + Port: 443, + Username: "admin", + AuthType: "password", + Password: "super-secret", + TLSMode: "strict", + } + + result := newAPIResult(req) + + if result.SourceType != models.SourceTypeAPI { + t.Fatalf("expected source type %q, got %q", models.SourceTypeAPI, result.SourceType) + } + if result.Protocol != req.Protocol { + t.Fatalf("expected protocol %q, got %q", req.Protocol, result.Protocol) + } + if result.TargetHost != req.Host { + t.Fatalf("expected target host %q, got %q", req.Host, result.TargetHost) + } + if result.CollectedAt.IsZero() { + t.Fatalf("expected collected_at to be set") + } + if len(result.Events) != 0 || len(result.FRU) != 0 || len(result.Sensors) != 0 { + t.Fatalf("expected empty slices for api result") + } + + raw, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal result: %v", err) + } + if string(raw) == "" { + t.Fatalf("expected non-empty json") + } + if strings.Contains(string(raw), req.Password) || (req.Token != "" && strings.Contains(string(raw), req.Token)) { + t.Fatalf("secrets should not be present in api result") + } +} + +func TestStatusAndConfigExposeSourceMetadata(t *testing.T) { + s := &Server{} + s.SetDetectedVendor("nvidia") + s.SetResult(&models.AnalysisResult{ + Filename: "archive.tar.gz", + SourceType: models.SourceTypeArchive, + Protocol: "", + TargetHost: "", + CollectedAt: time.Now().UTC(), + Events: []models.Event{{ID: "1"}}, + Sensors: []models.SensorReading{{Name: "Temp1"}}, + FRU: []models.FRUInfo{{Description: "Board"}}, + }) + + statusReq := httptest.NewRequest(http.MethodGet, "/api/status", nil) + statusRec := httptest.NewRecorder() + s.handleGetStatus(statusRec, statusReq) + if statusRec.Code != http.StatusOK { + t.Fatalf("expected 200 from /api/status, got %d", statusRec.Code) + } + + var statusPayload map[string]interface{} + if err := json.NewDecoder(statusRec.Body).Decode(&statusPayload); err != nil { + t.Fatalf("decode status payload: %v", err) + } + + if loaded, _ := statusPayload["loaded"].(bool); !loaded { + t.Fatalf("expected loaded=true") + } + if statusPayload["source_type"] != models.SourceTypeArchive { + t.Fatalf("expected source_type in status payload") + } + if _, ok := statusPayload["stats"]; !ok { + t.Fatalf("expected legacy stats field to remain") + } + + configReq := httptest.NewRequest(http.MethodGet, "/api/config", nil) + configRec := httptest.NewRecorder() + s.handleGetConfig(configRec, configReq) + if configRec.Code != http.StatusOK { + t.Fatalf("expected 200 from /api/config, got %d", configRec.Code) + } + + var configPayload map[string]interface{} + if err := json.NewDecoder(configRec.Body).Decode(&configPayload); err != nil { + t.Fatalf("decode config payload: %v", err) + } + + if configPayload["source_type"] != models.SourceTypeArchive { + t.Fatalf("expected source_type in config payload") + } + if _, ok := configPayload["hardware"]; !ok { + t.Fatalf("expected legacy hardware field in config payload") + } + if _, ok := configPayload["specification"]; !ok { + t.Fatalf("expected legacy specification field in config payload") + } +}