711 lines
22 KiB
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 "—"
|
|
}
|