Files
core/internal/api/ui_failures_page.go

711 lines
22 KiB
Go

package api
import (
"context"
"encoding/json"
"html/template"
"sort"
"strconv"
"strings"
"time"
"reanimator/internal/domain"
"reanimator/internal/repository/registry"
)
type failureActiveItem struct {
FailureID string
FailureTime time.Time
FailureType string
Description string
ComponentID string
ComponentLabel string
ComponentSerial string
ComponentURL string
ComponentStatus string
AssetID string
AssetLabel string
AssetSerial string
AssetURL string
AssetStatus string
Slot string
HasReplacement bool
ReplacementTime *time.Time
ReplacementPartID *string
}
type failureChronologyEntry struct {
Kind string
EventTime time.Time
Day string
FailureID string
FailureType string
Description string
ComponentID string
ComponentLabel string
ComponentSerial string
ComponentURL string
AssetID string
AssetLabel string
AssetSerial string
AssetURL string
Slot string
ReplacementComponentID string
ReplacementComponentLabel string
ReplacementComponentSerial string
ReplacementComponentURL string
}
type failureChronologyDayGroup struct {
Day string
Items []failureChronologyEntry
}
type failureManualLookupEntry struct {
ComponentID string `json:"component_id"`
ComponentSerial string `json:"component_serial"`
ComponentModel *string `json:"component_model,omitempty"`
ComponentLabel string `json:"component_label"`
ServerID *string `json:"server_id,omitempty"`
ServerSerial *string `json:"server_serial,omitempty"`
ServerLabel *string `json:"server_label,omitempty"`
Slot *string `json:"slot,omitempty"`
}
type failureManualComponentOption struct {
Serial string
Model string
}
type failureManualFormData struct {
Today string
ComponentOptions []failureManualComponentOption
ServerSerials []string
LocationSuggestions []string
DateSuggestions []string
DescriptionHints []string
LookupByComponent template.JS
}
type failurePageComputed struct {
Active []failureActiveItem
Chronology []failureChronologyEntry
ManualForm failureManualFormData
}
type failureDetailComputed struct {
Failure domain.FailureEvent
ComponentLabel string
ComponentSerial string
ComponentModel string
ComponentURL string
AssetLabel string
AssetSerial string
AssetURL string
Slot string
InstalledAt *time.Time
RepairAt *time.Time
RepairComponentID string
RepairComponentLabel string
RepairComponentSerial string
RepairComponentModel string
RepairComponentURL string
OpenDurationText string
IsOpen bool
ModelChanged bool
OldSerial string
NewSerial string
OldModel string
NewModel string
}
type failureRepairCandidate struct {
Time time.Time
ComponentID string
}
func buildFailurePageComputed(
ctx context.Context,
items []domain.FailureEvent,
assets []domain.Asset,
components []domain.Component,
installations *registry.InstallationRepository,
assetLabelByID map[string]string,
componentLabelByID map[string]string,
assetURLByID map[string]string,
componentURLByID map[string]string,
assetStatusByID map[string]string,
componentStatusByID map[string]string,
) (failurePageComputed, error) {
componentByID := make(map[string]domain.Component, len(components))
componentIDs := make([]string, 0, len(components))
for _, c := range components {
componentByID[c.ID] = c
componentIDs = append(componentIDs, c.ID)
}
assetByID := make(map[string]domain.Asset, len(assets))
assetIDs := make([]string, 0, len(assets))
for _, a := range assets {
assetByID[a.ID] = a
assetIDs = append(assetIDs, a.ID)
}
historyByComponent := map[string][]registry.InstallationHistorySpan{}
currentInstByComponent := map[string]registry.CurrentInstallationRef{}
assetInstallsSince := map[string][]registry.AssetInstallationRecord{}
minFailure := time.Time{}
if len(items) > 0 {
minFailure = items[len(items)-1].FailureTime
for _, it := range items {
if minFailure.IsZero() || it.FailureTime.Before(minFailure) {
minFailure = it.FailureTime
}
}
}
if installations != nil {
var err error
historyByComponent, err = installations.ListInstallationHistoryByComponentIDs(ctx, uniqueFailurePartIDs(items))
if err != nil {
return failurePageComputed{}, err
}
currentInstByComponent, err = installations.ListCurrentInstallationsByComponentIDs(ctx, componentIDs)
if err != nil {
return failurePageComputed{}, err
}
assetInstallsSince, err = installations.ListInstallationsByAssetIDsSince(ctx, uniqueFailureAssetIDs(items), minFailure)
if err != nil {
return failurePageComputed{}, err
}
}
manual := buildFailureManualFormData(items, assets, components, currentInstByComponent, assetLabelByID, componentLabelByID)
type derived struct {
slot string
repair *failureRepairCandidate
}
derivedByFailureID := make(map[string]derived, len(items))
for _, it := range items {
d := derived{}
if spans := historyByComponent[it.PartID]; len(spans) > 0 {
if slot := findFailureSlot(spans, it); slot != nil {
d.slot = strings.TrimSpace(*slot)
}
}
// Fallback for active/current UI: use current installation slot from server/component projection
// when historical span matching the normalized failure timestamp (date-only -> 00:00 UTC) is not found.
if d.slot == "" {
if cur, ok := currentInstByComponent[it.PartID]; ok && cur.SlotName != nil && strings.TrimSpace(*cur.SlotName) != "" {
if it.MachineID == nil || strings.TrimSpace(*it.MachineID) == "" || strings.TrimSpace(*it.MachineID) == strings.TrimSpace(cur.AssetID) {
d.slot = strings.TrimSpace(*cur.SlotName)
}
}
}
if it.MachineID != nil && *it.MachineID != "" && d.slot != "" {
if repair := findReplacementRepair(it, d.slot, assetInstallsSince[*it.MachineID]); repair != nil {
d.repair = repair
}
}
derivedByFailureID[it.ID] = d
}
activeByComponent := map[string]failureActiveItem{}
chronology := make([]failureChronologyEntry, 0, len(items)*2)
for _, it := range items {
comp := componentByID[it.PartID]
compSerial := strings.TrimSpace(comp.VendorSerial)
compLabel := componentLabelByID[it.PartID]
if compLabel == "" {
compLabel = compSerial
if compLabel == "" {
compLabel = "Unknown component"
}
}
assetID := ""
if it.MachineID != nil {
assetID = strings.TrimSpace(*it.MachineID)
}
asset := assetByID[assetID]
assetLabel := assetLabelByID[assetID]
if assetLabel == "" && strings.TrimSpace(asset.Name) != "" {
assetLabel = strings.TrimSpace(asset.Name)
}
if assetLabel == "" {
assetLabel = "—"
}
assetSerial := strings.TrimSpace(asset.VendorSerial)
desc := ""
if it.Details != nil {
desc = strings.TrimSpace(*it.Details)
}
drv := derivedByFailureID[it.ID]
chronology = append(chronology, failureChronologyEntry{
Kind: "failure",
EventTime: it.FailureTime,
Day: it.FailureTime.UTC().Format("2006-01-02"),
FailureID: it.ID,
FailureType: it.FailureType,
Description: desc,
ComponentID: it.PartID,
ComponentLabel: compLabel,
ComponentSerial: compSerial,
ComponentURL: componentURLByID[it.PartID],
AssetID: assetID,
AssetLabel: assetLabel,
AssetSerial: assetSerial,
AssetURL: assetURLByID[assetID],
Slot: drv.slot,
})
if drv.repair != nil {
repComp := componentByID[drv.repair.ComponentID]
repLabel := componentLabelByID[drv.repair.ComponentID]
if repLabel == "" {
repLabel = strings.TrimSpace(repComp.VendorSerial)
if repLabel == "" {
repLabel = "Unknown component"
}
}
chronology = append(chronology, failureChronologyEntry{
Kind: "replacement_repair",
EventTime: drv.repair.Time,
Day: drv.repair.Time.UTC().Format("2006-01-02"),
FailureID: it.ID,
ComponentID: it.PartID,
ComponentLabel: compLabel,
ComponentSerial: compSerial,
ComponentURL: componentURLByID[it.PartID],
AssetID: assetID,
AssetLabel: assetLabel,
AssetSerial: assetSerial,
AssetURL: assetURLByID[assetID],
Slot: drv.slot,
ReplacementComponentID: drv.repair.ComponentID,
ReplacementComponentLabel: repLabel,
ReplacementComponentSerial: strings.TrimSpace(repComp.VendorSerial),
ReplacementComponentURL: componentURLByID[drv.repair.ComponentID],
})
}
if drv.repair == nil {
current, ok := activeByComponent[it.PartID]
candidate := failureActiveItem{
FailureID: it.ID,
FailureTime: it.FailureTime,
FailureType: it.FailureType,
Description: desc,
ComponentID: it.PartID,
ComponentLabel: compLabel,
ComponentSerial: compSerial,
ComponentURL: componentURLByID[it.PartID],
ComponentStatus: componentStatusByID[it.PartID],
AssetID: assetID,
AssetLabel: assetLabel,
AssetSerial: assetSerial,
AssetURL: assetURLByID[assetID],
AssetStatus: assetStatusByID[assetID],
Slot: drv.slot,
}
if !ok || candidate.FailureTime.After(current.FailureTime) {
activeByComponent[it.PartID] = candidate
}
}
}
active := make([]failureActiveItem, 0, len(activeByComponent))
for _, item := range activeByComponent {
active = append(active, item)
}
sort.Slice(active, func(i, j int) bool {
if active[i].FailureTime.Equal(active[j].FailureTime) {
return active[i].ComponentSerial < active[j].ComponentSerial
}
return active[i].FailureTime.After(active[j].FailureTime)
})
sort.Slice(chronology, func(i, j int) bool {
if chronology[i].EventTime.Equal(chronology[j].EventTime) {
if chronology[i].Kind != chronology[j].Kind {
return chronology[i].Kind < chronology[j].Kind
}
return chronology[i].FailureID < chronology[j].FailureID
}
return chronology[i].EventTime.After(chronology[j].EventTime)
})
chronology = dedupeFailureChronology(chronology)
return failurePageComputed{
Active: active,
Chronology: chronology,
ManualForm: manual,
}, nil
}
func dedupeFailureChronology(items []failureChronologyEntry) []failureChronologyEntry {
if len(items) <= 1 {
return items
}
out := make([]failureChronologyEntry, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
key := strings.ToLower(strings.Join([]string{
strings.TrimSpace(item.Kind),
item.EventTime.UTC().Format(time.RFC3339Nano),
strings.TrimSpace(item.FailureType),
strings.TrimSpace(item.ComponentID),
strings.TrimSpace(item.AssetID),
strings.TrimSpace(item.Slot),
strings.TrimSpace(item.ReplacementComponentID),
strings.TrimSpace(item.Description),
}, "\x1f"))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
func buildFailureChronologyDayGroups(items []failureChronologyEntry) []failureChronologyDayGroup {
if len(items) == 0 {
return nil
}
byDay := make(map[string][]failureChronologyEntry)
order := make([]string, 0)
for _, item := range items {
day := item.Day
if day == "" {
day = item.EventTime.UTC().Format("2006-01-02")
}
if _, ok := byDay[day]; !ok {
order = append(order, day)
}
byDay[day] = append(byDay[day], item)
}
sort.Slice(order, func(i, j int) bool { return order[i] > order[j] })
out := make([]failureChronologyDayGroup, 0, len(order))
for _, day := range order {
out = append(out, failureChronologyDayGroup{Day: day, Items: byDay[day]})
}
return out
}
func buildFailureDetailComputed(
ctx context.Context,
failure domain.FailureEvent,
assets []domain.Asset,
components []domain.Component,
installations *registry.InstallationRepository,
assetLabelByID map[string]string,
componentLabelByID map[string]string,
assetURLByID map[string]string,
componentURLByID map[string]string,
) (failureDetailComputed, error) {
componentByID := make(map[string]domain.Component, len(components))
for _, c := range components {
componentByID[c.ID] = c
}
assetByID := make(map[string]domain.Asset, len(assets))
for _, a := range assets {
assetByID[a.ID] = a
}
comp := componentByID[failure.PartID]
detail := failureDetailComputed{
Failure: failure,
ComponentLabel: fallbackLabel(componentLabelByID[failure.PartID], strings.TrimSpace(comp.VendorSerial), "Unknown component"),
ComponentSerial: strings.TrimSpace(comp.VendorSerial),
ComponentModel: trimPtr(comp.Model),
ComponentURL: componentURLByID[failure.PartID],
OldSerial: strings.TrimSpace(comp.VendorSerial),
OldModel: trimPtr(comp.Model),
IsOpen: true,
}
if failure.MachineID != nil && strings.TrimSpace(*failure.MachineID) != "" {
assetID := strings.TrimSpace(*failure.MachineID)
asset := assetByID[assetID]
detail.AssetLabel = fallbackLabel(assetLabelByID[assetID], strings.TrimSpace(asset.Name), "—")
detail.AssetSerial = strings.TrimSpace(asset.VendorSerial)
detail.AssetURL = assetURLByID[assetID]
}
if installations == nil {
detail.OpenDurationText = humanizeDatePrecisionDuration(failure.FailureTime, time.Now())
return detail, nil
}
historyByComp, err := installations.ListInstallationHistoryByComponentIDs(ctx, []string{failure.PartID})
if err != nil {
return failureDetailComputed{}, err
}
assetIDs := uniqueFailureAssetIDs([]domain.FailureEvent{failure})
installsByAsset, err := installations.ListInstallationsByAssetIDsSince(ctx, assetIDs, failure.FailureTime)
if err != nil {
return failureDetailComputed{}, err
}
var slot string
var installedAt *time.Time
spans := historyByComp[failure.PartID]
if sp := findFailureInstallationSpan(spans, failure); sp != nil {
slot = trimPtr(sp.SlotName)
installedAt = &sp.InstalledAt
}
if slot == "" {
cur, _ := installations.ListCurrentInstallationsByComponentIDs(ctx, []string{failure.PartID})
if ref, ok := cur[failure.PartID]; ok {
if failure.MachineID == nil || *failure.MachineID == ref.AssetID {
slot = trimPtr(ref.SlotName)
}
}
}
detail.Slot = slot
detail.InstalledAt = installedAt
if failure.MachineID != nil && *failure.MachineID != "" && slot != "" {
if repair := findReplacementRepair(failure, slot, installsByAsset[*failure.MachineID]); repair != nil {
detail.IsOpen = false
detail.RepairAt = &repair.Time
detail.RepairComponentID = repair.ComponentID
rep := componentByID[repair.ComponentID]
detail.RepairComponentLabel = fallbackLabel(componentLabelByID[repair.ComponentID], strings.TrimSpace(rep.VendorSerial), "Unknown component")
detail.RepairComponentSerial = strings.TrimSpace(rep.VendorSerial)
detail.RepairComponentModel = trimPtr(rep.Model)
detail.RepairComponentURL = componentURLByID[repair.ComponentID]
detail.NewSerial = detail.RepairComponentSerial
detail.NewModel = detail.RepairComponentModel
detail.ModelChanged = strings.TrimSpace(strings.ToLower(detail.OldModel)) != strings.TrimSpace(strings.ToLower(detail.NewModel))
detail.OpenDurationText = humanizeDatePrecisionDuration(failure.FailureTime, repair.Time)
}
}
if detail.IsOpen {
detail.OpenDurationText = humanizeDatePrecisionDuration(failure.FailureTime, time.Now())
detail.NewSerial = "—"
detail.NewModel = "—"
}
if detail.OldSerial == "" {
detail.OldSerial = "—"
}
if detail.OldModel == "" {
detail.OldModel = "—"
}
return detail, nil
}
func buildFailureManualFormData(
failures []domain.FailureEvent,
assets []domain.Asset,
components []domain.Component,
currentInstByComponent map[string]registry.CurrentInstallationRef,
assetLabelByID map[string]string,
componentLabelByID map[string]string,
) failureManualFormData {
componentOptions := make([]failureManualComponentOption, 0, len(components))
serverSerials := make([]string, 0, len(assets))
locationHints := []string{}
lookup := make(map[string]failureManualLookupEntry, len(components))
assetByID := make(map[string]domain.Asset, len(assets))
for _, a := range assets {
assetByID[a.ID] = a
if s := strings.TrimSpace(a.VendorSerial); s != "" {
serverSerials = append(serverSerials, s)
}
}
for _, c := range components {
serial := strings.TrimSpace(c.VendorSerial)
if serial == "" {
continue
}
label := componentLabelByID[c.ID]
if label == "" {
label = serial
}
modelText := ""
if c.Model != nil && strings.TrimSpace(*c.Model) != "" {
modelText = strings.TrimSpace(*c.Model)
}
componentOptions = append(componentOptions, failureManualComponentOption{Serial: serial, Model: modelText})
entry := failureManualLookupEntry{
ComponentID: c.ID,
ComponentSerial: serial,
ComponentLabel: label,
}
if modelText != "" {
tmp := modelText
entry.ComponentModel = &tmp
}
if inst, ok := currentInstByComponent[c.ID]; ok {
assetID := inst.AssetID
entry.ServerID = &assetID
if a, ok := assetByID[assetID]; ok {
if s := strings.TrimSpace(a.VendorSerial); s != "" {
tmp := s
entry.ServerSerial = &tmp
}
lbl := strings.TrimSpace(assetLabelByID[assetID])
if lbl == "" {
lbl = strings.TrimSpace(a.Name)
}
if lbl != "" {
tmp := lbl
entry.ServerLabel = &tmp
}
}
if inst.SlotName != nil && strings.TrimSpace(*inst.SlotName) != "" {
tmp := strings.TrimSpace(*inst.SlotName)
entry.Slot = &tmp
locationHints = append(locationHints, tmp)
}
}
lookup[strings.ToLower(serial)] = entry
}
dateHints := []string{time.Now().UTC().Format("2006-01-02")}
descHints := []string{}
for _, f := range failures {
dateHints = append(dateHints, f.FailureTime.UTC().Format("2006-01-02"))
if f.Details != nil && strings.TrimSpace(*f.Details) != "" {
descHints = append(descHints, strings.TrimSpace(*f.Details))
}
if strings.TrimSpace(f.FailureType) != "" {
descHints = append(descHints, strings.TrimSpace(f.FailureType))
}
}
lookupJSON := "{}"
if raw, err := json.Marshal(lookup); err == nil {
lookupJSON = string(raw)
}
sort.Slice(componentOptions, func(i, j int) bool {
return strings.ToLower(componentOptions[i].Serial) < strings.ToLower(componentOptions[j].Serial)
})
return failureManualFormData{
Today: time.Now().UTC().Format("2006-01-02"),
ComponentOptions: componentOptions,
ServerSerials: uniqueSorted(serverSerials),
LocationSuggestions: uniqueSorted(locationHints),
DateSuggestions: uniqueSorted(dateHints),
DescriptionHints: uniqueSorted(descHints),
LookupByComponent: template.JS(lookupJSON),
}
}
func uniqueFailurePartIDs(items []domain.FailureEvent) []string {
ids := make([]string, 0, len(items))
for _, it := range items {
if s := strings.TrimSpace(it.PartID); s != "" {
ids = append(ids, s)
}
}
return uniqueSorted(ids)
}
func uniqueFailureAssetIDs(items []domain.FailureEvent) []string {
ids := make([]string, 0, len(items))
for _, it := range items {
if it.MachineID == nil {
continue
}
if s := strings.TrimSpace(*it.MachineID); s != "" {
ids = append(ids, s)
}
}
return uniqueSorted(ids)
}
func findFailureSlot(spans []registry.InstallationHistorySpan, f domain.FailureEvent) *string {
if sp := findFailureInstallationSpan(spans, f); sp != nil && sp.SlotName != nil {
tmp := strings.TrimSpace(*sp.SlotName)
if tmp != "" {
return &tmp
}
}
return nil
}
func findFailureInstallationSpan(spans []registry.InstallationHistorySpan, f domain.FailureEvent) *registry.InstallationHistorySpan {
for _, sp := range spans {
if sp.AssetID == "" {
continue
}
if f.MachineID != nil && strings.TrimSpace(*f.MachineID) != "" && sp.AssetID != strings.TrimSpace(*f.MachineID) {
continue
}
if sp.InstalledAt.After(f.FailureTime) {
continue
}
if sp.RemovedAt != nil && !sp.RemovedAt.After(f.FailureTime) {
continue
}
cp := sp
return &cp
}
return nil
}
func findReplacementRepair(f domain.FailureEvent, slot string, rows []registry.AssetInstallationRecord) *failureRepairCandidate {
slotNeedle := strings.ToLower(strings.TrimSpace(slot))
var best *failureRepairCandidate
for _, row := range rows {
if row.ComponentID == "" || row.ComponentID == f.PartID {
continue
}
if row.SlotName == nil || strings.TrimSpace(*row.SlotName) == "" {
continue
}
if strings.ToLower(strings.TrimSpace(*row.SlotName)) != slotNeedle {
continue
}
if !row.InstalledAt.After(f.FailureTime) {
continue
}
if best == nil || row.InstalledAt.Before(best.Time) {
best = &failureRepairCandidate{Time: row.InstalledAt, ComponentID: row.ComponentID}
}
}
return best
}
func humanizeDuration(d time.Duration) string {
if d < 0 {
d = 0
}
totalMinutes := int(d.Round(time.Minute).Minutes())
days := totalMinutes / (24 * 60)
hours := (totalMinutes % (24 * 60)) / 60
mins := totalMinutes % 60
parts := make([]string, 0, 3)
if days > 0 {
parts = append(parts, strconv.Itoa(days)+"d")
}
if hours > 0 {
parts = append(parts, strconv.Itoa(hours)+"h")
}
if mins > 0 || len(parts) == 0 {
parts = append(parts, strconv.Itoa(mins)+"m")
}
return strings.Join(parts, " ")
}
func humanizeDatePrecisionDuration(start, end time.Time) string {
startDay := time.Date(start.UTC().Year(), start.UTC().Month(), start.UTC().Day(), 0, 0, 0, 0, time.UTC)
endDay := time.Date(end.UTC().Year(), end.UTC().Month(), end.UTC().Day(), 0, 0, 0, 0, time.UTC)
if endDay.Before(startDay) {
endDay = startDay
}
days := int(endDay.Sub(startDay).Hours() / 24)
if days == 1 {
return "1 day"
}
return strconv.Itoa(days) + " days"
}
func trimPtr(v *string) string {
if v == nil {
return ""
}
return strings.TrimSpace(*v)
}
func fallbackLabel(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return "—"
}