feat(api): add live collection contract endpoints

This commit is contained in:
Mikhail Chusavitin
2026-02-04 09:54:48 +03:00
parent 5a982d7ca8
commit aa3c82d9ba
4 changed files with 227 additions and 2 deletions

View File

@@ -0,0 +1,30 @@
package server
import "time"
type CollectRequest struct {
Host string `json:"host"`
Protocol string `json:"protocol"`
Port int `json:"port"`
Username string `json:"username"`
AuthType string `json:"auth_type"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
TLSMode string `json:"tls_mode"`
}
type CollectJobResponse struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CollectJobStatusResponse struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Progress *int `json:"progress,omitempty"`
Logs []string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -1,11 +1,13 @@
package server
import (
"crypto/rand"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"regexp"
"sort"
"strings"
"time"
@@ -556,6 +558,142 @@ func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
}()
}
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)

View File

@@ -28,12 +28,16 @@ type Server struct {
mu sync.RWMutex
result *models.AnalysisResult
detectedVendor string
collectMu sync.RWMutex
collectJobs map[string]*CollectJobStatusResponse
}
func New(cfg Config) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
config: cfg,
mux: http.NewServeMux(),
collectJobs: make(map[string]*CollectJobStatusResponse),
}
s.setupRoutes()
return s
@@ -64,6 +68,9 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("GET /api/export/txt", s.handleExportTXT)
s.mux.HandleFunc("DELETE /api/clear", s.handleClear)
s.mux.HandleFunc("POST /api/shutdown", s.handleShutdown)
s.mux.HandleFunc("POST /api/collect", s.handleCollectStart)
s.mux.HandleFunc("GET /api/collect/{id}", s.handleCollectStatus)
s.mux.HandleFunc("POST /api/collect/{id}/cancel", s.handleCollectCancel)
}
func (s *Server) Run() error {