Files
core/internal/api/failures.go

265 lines
8.1 KiB
Go

package api
import (
"crypto/sha1"
"encoding/hex"
"errors"
"net/http"
"strings"
"time"
"reanimator/internal/domain"
"reanimator/internal/history"
"reanimator/internal/repository/failures"
"reanimator/internal/repository/registry"
)
type FailureDependencies struct {
Failures *failures.FailureRepository
Assets *registry.AssetRepository
Components *registry.ComponentRepository
Installations *registry.InstallationRepository
History *history.Service
}
type failureHandlers struct {
deps FailureDependencies
}
func RegisterFailureRoutes(mux *http.ServeMux, deps FailureDependencies) {
h := failureHandlers{deps: deps}
mux.HandleFunc("/failures", h.handleFailures)
}
func (h failureHandlers) handleFailures(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.handleFailuresGet(w, r)
case http.MethodPost:
h.handleFailuresPost(w, r)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h failureHandlers) handleFailuresGet(w http.ResponseWriter, r *http.Request) {
if h.deps.Failures == nil {
writeError(w, http.StatusInternalServerError, "failures unavailable")
return
}
limit := 200
if raw := r.URL.Query().Get("limit"); raw != "" {
parsed, err := parseIntParam(raw)
if err != nil || parsed <= 0 {
writeError(w, http.StatusBadRequest, "limit must be a positive integer")
return
}
limit = parsed
}
items, err := h.deps.Failures.ListAll(r.Context(), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "list failures failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (h failureHandlers) handleFailuresPost(w http.ResponseWriter, r *http.Request) {
if h.deps.Failures == nil || h.deps.Components == nil || h.deps.History == nil {
writeError(w, http.StatusInternalServerError, "failures unavailable")
return
}
var req struct {
ComponentSerial string `json:"component_serial"`
ServerSerial *string `json:"server_serial"`
FailureDate string `json:"failure_date"`
Description *string `json:"description"`
}
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
componentSerial := strings.TrimSpace(req.ComponentSerial)
if componentSerial == "" {
writeError(w, http.StatusBadRequest, "component_serial is required")
return
}
failureDateRaw := strings.TrimSpace(req.FailureDate)
failureDate, err := time.Parse("2006-01-02", failureDateRaw)
if err != nil {
writeError(w, http.StatusBadRequest, "failure_date must be YYYY-MM-DD")
return
}
failureAt := time.Date(failureDate.Year(), failureDate.Month(), failureDate.Day(), 0, 0, 0, 0, time.UTC)
components, err := h.deps.Components.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "list components failed")
return
}
component, ok := findComponentByVendorSerial(components, componentSerial)
if !ok {
writeError(w, http.StatusNotFound, "component serial not found")
return
}
var machineByID = map[string]registryMachine{}
var allAssets []domain.Asset
if h.deps.Assets != nil {
list, listErr := h.deps.Assets.List(r.Context())
if listErr != nil {
writeError(w, http.StatusInternalServerError, "list assets failed")
return
}
allAssets = list
for _, item := range list {
machineByID[item.ID] = registryMachine{ID: item.ID, Name: item.Name, VendorSerial: item.VendorSerial}
}
}
var currentAssetID *string
var currentSlot *string
if h.deps.Installations != nil {
refs, refsErr := h.deps.Installations.ListCurrentInstallationsByComponentIDs(r.Context(), []string{component.ID})
if refsErr != nil {
writeError(w, http.StatusInternalServerError, "lookup component installation failed")
return
}
if ref, ok := refs[component.ID]; ok {
currentAssetID = &ref.AssetID
currentSlot = ref.SlotName
}
}
var machineID *string
if currentAssetID != nil {
machineID = currentAssetID
}
if reqServer := normalizeOptionalString(req.ServerSerial); reqServer != nil {
if h.deps.Assets == nil {
writeError(w, http.StatusInternalServerError, "assets unavailable")
return
}
if len(allAssets) == 0 {
var err error
allAssets, err = h.deps.Assets.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "list assets failed")
return
}
}
asset, found := findAssetByVendorSerial(allAssets, *reqServer)
if !found {
writeError(w, http.StatusNotFound, "server serial not found")
return
}
if currentAssetID != nil && *currentAssetID != asset.ID {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "server serial does not match current component installation",
"code": "server_mismatch",
})
return
}
machineID = &asset.ID
machineByID[asset.ID] = registryMachine{ID: asset.ID, Name: asset.Name, VendorSerial: asset.VendorSerial}
}
desc := normalizeOptionalString(req.Description)
externalID := buildManualFailureExternalIDForAPI(component.ID, failureAt, desc)
sourceRef := "ui_failures_manual:" + component.ID + ":" + failureAt.Format("2006-01-02")
var idemPtr *string
if idem := strings.TrimSpace(r.Header.Get("Idempotency-Key")); idem != "" {
idemPtr = &idem
}
res, err := h.deps.History.RegisterManualComponentFailure(r.Context(), history.RegisterManualComponentFailureCommand{
ComponentID: component.ID,
MachineID: machineID,
FailureTime: failureAt,
Description: desc,
FailureType: "component_failed_manual",
FailureSource: "manual_ui",
FailureExternal: externalID,
SourceRef: &sourceRef,
IdempotencyKey: idemPtr,
})
if err != nil {
switch {
case errors.Is(err, history.ErrInvalidPatch):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, history.ErrNotFound):
writeError(w, http.StatusNotFound, "component not found")
case errors.Is(err, history.ErrConflict):
writeError(w, http.StatusConflict, "history conflict")
default:
writeError(w, http.StatusInternalServerError, "register failure failed")
}
return
}
var resolvedServer any
if machineID != nil {
if info, ok := machineByID[*machineID]; ok {
resolvedServer = map[string]any{
"id": info.ID,
"vendor_serial": info.VendorSerial,
"label": strings.TrimSpace(info.Name),
}
} else {
resolvedServer = map[string]any{"id": *machineID}
}
}
componentLabel := component.VendorSerial
if component.Model != nil && strings.TrimSpace(*component.Model) != "" {
componentLabel += " · " + strings.TrimSpace(*component.Model)
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "registered",
"failure_event_id": res.FailureEventID,
"history_event_id": res.HistoryApply.EventID,
"resolved": map[string]any{
"component": map[string]any{
"id": component.ID,
"vendor_serial": component.VendorSerial,
"label": componentLabel,
},
"server": resolvedServer,
"slot": currentSlot,
},
})
}
type registryMachine struct {
ID string
Name string
VendorSerial string
}
func findComponentByVendorSerial(items []domain.Component, serial string) (domain.Component, bool) {
needle := strings.ToLower(strings.TrimSpace(serial))
for _, item := range items {
if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle {
return item, true
}
}
return domain.Component{}, false
}
func findAssetByVendorSerial(items []domain.Asset, serial string) (domain.Asset, bool) {
needle := strings.ToLower(strings.TrimSpace(serial))
for _, item := range items {
if strings.ToLower(strings.TrimSpace(item.VendorSerial)) == needle {
return item, true
}
}
return domain.Asset{}, false
}
func buildManualFailureExternalIDForAPI(componentID string, failureAt time.Time, description *string) string {
normDesc := ""
if description != nil {
normDesc = strings.TrimSpace(*description)
}
sum := sha1.Sum([]byte(strings.ToLower(strings.TrimSpace(componentID)) + "|" + failureAt.UTC().Format(time.RFC3339Nano) + "|" + strings.ToLower(normDesc)))
return "manual_ui:" + strings.TrimSpace(componentID) + ":" + failureAt.UTC().Format(time.RFC3339Nano) + ":" + hex.EncodeToString(sum[:4])
}