@@ -109,6 +109,12 @@ DELETE /api/clear # Очистить загруженные данны
|
|||||||
POST /api/shutdown # Завершить работу приложения
|
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`)
|
### Контракты live-сбора (`/api/collect`)
|
||||||
|
|
||||||
`POST /api/collect` принимает JSON:
|
`POST /api/collect` принимает JSON:
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceTypeArchive = "archive"
|
||||||
|
SourceTypeAPI = "api"
|
||||||
|
)
|
||||||
|
|
||||||
// AnalysisResult contains all parsed data from an archive
|
// AnalysisResult contains all parsed data from an archive
|
||||||
type AnalysisResult struct {
|
type AnalysisResult struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Events []Event `json:"events"`
|
SourceType string `json:"source_type,omitempty"` // archive | api
|
||||||
FRU []FRUInfo `json:"fru"`
|
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
|
||||||
Sensors []SensorReading `json:"sensors"`
|
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
|
||||||
Hardware *HardwareConfig `json:"hardware"`
|
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
|
// Event represents a single log event
|
||||||
@@ -164,21 +173,21 @@ type NIC struct {
|
|||||||
|
|
||||||
// PSU represents a power supply unit
|
// PSU represents a power supply unit
|
||||||
type PSU struct {
|
type PSU struct {
|
||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Present bool `json:"present"`
|
Present bool `json:"present"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Vendor string `json:"vendor,omitempty"`
|
Vendor string `json:"vendor,omitempty"`
|
||||||
WattageW int `json:"wattage_w,omitempty"`
|
WattageW int `json:"wattage_w,omitempty"`
|
||||||
SerialNumber string `json:"serial_number,omitempty"`
|
SerialNumber string `json:"serial_number,omitempty"`
|
||||||
PartNumber string `json:"part_number,omitempty"`
|
PartNumber string `json:"part_number,omitempty"`
|
||||||
Firmware string `json:"firmware,omitempty"`
|
Firmware string `json:"firmware,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
InputType string `json:"input_type,omitempty"`
|
InputType string `json:"input_type,omitempty"`
|
||||||
InputPowerW int `json:"input_power_w,omitempty"`
|
InputPowerW int `json:"input_power_w,omitempty"`
|
||||||
OutputPowerW int `json:"output_power_w,omitempty"`
|
OutputPowerW int `json:"output_power_w,omitempty"`
|
||||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||||
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
OutputVoltage float64 `json:"output_voltage,omitempty"`
|
||||||
TemperatureC int `json:"temperature_c,omitempty"`
|
TemperatureC int `json:"temperature_c,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPU represents a graphics processing unit
|
// GPU represents a graphics processing unit
|
||||||
@@ -200,11 +209,11 @@ type GPU struct {
|
|||||||
DMASize string `json:"dma_size,omitempty"`
|
DMASize string `json:"dma_size,omitempty"`
|
||||||
DMAMask string `json:"dma_mask,omitempty"`
|
DMAMask string `json:"dma_mask,omitempty"`
|
||||||
DeviceMinor int `json:"device_minor,omitempty"`
|
DeviceMinor int `json:"device_minor,omitempty"`
|
||||||
Temperature int `json:"temperature,omitempty"` // GPU core temp
|
Temperature int `json:"temperature,omitempty"` // GPU core temp
|
||||||
MemTemperature int `json:"mem_temperature,omitempty"` // GPU memory temp
|
MemTemperature int `json:"mem_temperature,omitempty"` // GPU memory temp
|
||||||
Power int `json:"power,omitempty"` // Current power draw (W)
|
Power int `json:"power,omitempty"` // Current power draw (W)
|
||||||
MaxPower int `json:"max_power,omitempty"` // TDP (W)
|
MaxPower int `json:"max_power,omitempty"` // TDP (W)
|
||||||
ClockSpeed int `json:"clock_speed,omitempty"` // Operating speed MHz
|
ClockSpeed int `json:"clock_speed,omitempty"` // Operating speed MHz
|
||||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||||
CurrentLinkWidth int `json:"current_link_width,omitempty"`
|
CurrentLinkWidth int `json:"current_link_width,omitempty"`
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := p.Result()
|
result := p.Result()
|
||||||
|
applyArchiveSourceMetadata(result)
|
||||||
s.SetResult(result)
|
s.SetResult(result)
|
||||||
s.SetDetectedVendor(p.DetectedVendor())
|
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) {
|
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
result := s.GetResult()
|
result := s.GetResult()
|
||||||
if result == nil || result.Hardware == nil {
|
if result == nil {
|
||||||
jsonResponse(w, map[string]interface{}{})
|
jsonResponse(w, map[string]interface{}{})
|
||||||
return
|
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
|
// Build specification summary
|
||||||
spec := buildSpecification(result)
|
spec := buildSpecification(result)
|
||||||
|
|
||||||
jsonResponse(w, map[string]interface{}{
|
response["hardware"] = result.Hardware
|
||||||
"hardware": result.Hardware,
|
response["specification"] = spec
|
||||||
"specification": spec,
|
jsonResponse(w, response)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpecLine represents a single line in specification
|
// 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{}{
|
jsonResponse(w, map[string]interface{}{
|
||||||
"loaded": true,
|
"loaded": true,
|
||||||
"filename": result.Filename,
|
"filename": result.Filename,
|
||||||
"vendor": s.GetDetectedVendor(),
|
"vendor": s.GetDetectedVendor(),
|
||||||
|
"source_type": result.SourceType,
|
||||||
|
"protocol": result.Protocol,
|
||||||
|
"target_host": result.TargetHost,
|
||||||
|
"collected_at": result.CollectedAt,
|
||||||
"stats": map[string]int{
|
"stats": map[string]int{
|
||||||
"events": len(result.Events),
|
"events": len(result.Events),
|
||||||
"sensors": len(result.Sensors),
|
"sensors": len(result.Sensors),
|
||||||
@@ -574,6 +592,8 @@ func (s *Server) handleCollectStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
job := s.jobManager.CreateJob(req)
|
job := s.jobManager.CreateJob(req)
|
||||||
|
s.SetResult(newAPIResult(req))
|
||||||
|
s.SetDetectedVendor("")
|
||||||
s.startMockCollectionJob(job.ID, req)
|
s.startMockCollectionJob(job.ID, req)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -726,6 +746,28 @@ func generateJobID() string {
|
|||||||
return fmt.Sprintf("job_%x", buf)
|
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{}) {
|
func jsonResponse(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
131
internal/server/source_metadata_test.go
Normal file
131
internal/server/source_metadata_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user