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 "—" }