package history import ( "context" "database/sql" "fmt" "strings" "time" ) func diffComponentStates(before, after ComponentStateV1) ([]PatchOp, error) { var ops []PatchOp ops = appendPatchStringPtr(ops, "/identity/vendor_serial", strPtr(before.Identity.VendorSerial), strPtr(after.Identity.VendorSerial), true) ops = appendPatchStringPtr(ops, "/identity/vendor", before.Identity.Vendor, after.Identity.Vendor, false) ops = appendPatchStringPtr(ops, "/identity/model", before.Identity.Model, after.Identity.Model, false) ops = appendPatchStringPtr(ops, "/runtime/firmware_version", before.Runtime.FirmwareVersion, after.Runtime.FirmwareVersion, false) ops = appendPatchStringPtr(ops, "/runtime/health_status", before.Runtime.HealthStatus, after.Runtime.HealthStatus, false) ops = appendPatchStringPtr(ops, "/runtime/health_status_at", before.Runtime.HealthStatusAt, after.Runtime.HealthStatusAt, false) ops = appendPatchStringPtr(ops, "/installation/current_machine_id", before.Installation.CurrentMachineID, after.Installation.CurrentMachineID, false) ops = appendPatchStringPtr(ops, "/installation/installed_at", before.Installation.InstalledAt, after.Installation.InstalledAt, false) ops = appendPatchStringPtr(ops, "/metadata/first_seen_at", before.Metadata.FirstSeenAt, after.Metadata.FirstSeenAt, false) return ops, nil } func diffAssetStates(before, after AssetStateV1) ([]PatchOp, error) { var ops []PatchOp ops = appendPatchStringPtr(ops, "/identity/name", strPtr(before.Identity.Name), strPtr(after.Identity.Name), true) ops = appendPatchStringPtr(ops, "/identity/vendor_serial", strPtr(before.Identity.VendorSerial), strPtr(after.Identity.VendorSerial), true) ops = appendPatchStringPtr(ops, "/identity/vendor", before.Identity.Vendor, after.Identity.Vendor, false) ops = appendPatchStringPtr(ops, "/identity/model", before.Identity.Model, after.Identity.Model, false) ops = appendPatchStringPtr(ops, "/identity/machine_tag", before.Identity.MachineTag, after.Identity.MachineTag, false) for key, beforeValue := range before.Firmware.Devices { afterValue, ok := after.Firmware.Devices[key] if !ok { ops = append(ops, PatchOp{Op: "remove", Path: "/firmware/devices/" + escapeJSONPointer(key)}) continue } if beforeValue != afterValue { ops = append(ops, PatchOp{Op: "replace", Path: "/firmware/devices/" + escapeJSONPointer(key), Value: afterValue}) } } for key, afterValue := range after.Firmware.Devices { if _, ok := before.Firmware.Devices[key]; ok { continue } ops = append(ops, PatchOp{Op: "add", Path: "/firmware/devices/" + escapeJSONPointer(key), Value: afterValue}) } if !equalStringSlices(before.Inventory.InstalledComponentIDs, after.Inventory.InstalledComponentIDs) { ops = append(ops, PatchOp{Op: "replace", Path: "/inventory/installed_component_ids", Value: after.Inventory.InstalledComponentIDs}) } return ops, nil } func appendPatchStringPtr(ops []PatchOp, path string, before, after *string, required bool) []PatchOp { b := "" a := "" bok := false aok := false if before != nil { b = strings.TrimSpace(*before) if b != "" || required { bok = true } } if after != nil { a = strings.TrimSpace(*after) if a != "" || required { aok = true } } switch { case bok && aok && b == a: return ops case !bok && !aok: return ops case !aok: return append(ops, PatchOp{Op: "remove", Path: path}) case !bok: return append(ops, PatchOp{Op: "add", Path: path, Value: a}) default: return append(ops, PatchOp{Op: "replace", Path: path, Value: a}) } } func strPtr(v string) *string { s := strings.TrimSpace(v) return &s } func escapeJSONPointer(value string) string { value = strings.ReplaceAll(value, "~", "~0") value = strings.ReplaceAll(value, "/", "~1") return value } func (s *Service) insertComponentTimelineFromStates(ctx context.Context, tx *sql.Tx, componentID, changeType, sourceType string, eventTime time.Time, logicalEntityType, logicalEventID string, correlationID *string, before, after *ComponentStateV1) error { eventType, firmware := componentTimelineProjection(changeType, before, after) if eventType == "" { return nil } var machineID *string if after != nil && after.Installation.CurrentMachineID != nil { machineID = after.Installation.CurrentMachineID } else if before != nil && before.Installation.CurrentMachineID != nil { machineID = before.Installation.CurrentMachineID } if isPairedComponentTimelineChange(changeType) && machineID != nil { if err := s.insertTimelineProjectionRaw(ctx, tx, "asset", *machineID, eventType, sourceType, eventTime, logicalEntityType, logicalEventID, correlationID, machineID, &componentID, firmware); err != nil { return err } } return s.insertTimelineProjectionRaw(ctx, tx, "component", componentID, eventType, sourceType, eventTime, logicalEntityType, logicalEventID, correlationID, machineID, &componentID, firmware) } func (s *Service) insertAssetTimelineFromStates(ctx context.Context, tx *sql.Tx, assetID, changeType, sourceType string, eventTime time.Time, logicalEntityType, logicalEventID string, correlationID *string, before, after *AssetStateV1) error { eventType, firmware := assetTimelineProjection(changeType, before, after) if eventType == "" { return nil } return s.insertTimelineProjectionRaw(ctx, tx, "asset", assetID, eventType, sourceType, eventTime, logicalEntityType, logicalEventID, correlationID, &assetID, nil, firmware) } func componentTimelineProjection(changeType string, before, after *ComponentStateV1) (string, *string) { switch strings.TrimSpace(changeType) { case "COMPONENT_STATUS_SET": if after == nil || after.Runtime.HealthStatus == nil { return "", nil } switch *after.Runtime.HealthStatus { case "OK": return "COMPONENT_OK", nil case "WARNING": return "COMPONENT_WARNING", nil case "FAILED": return "COMPONENT_FAILED", nil default: return "", nil } case "COMPONENT_FIRMWARE_SET": if after == nil || after.Runtime.FirmwareVersion == nil { return "", nil } current := strings.TrimSpace(*after.Runtime.FirmwareVersion) if current == "" { return "", nil } if before == nil || before.Runtime.FirmwareVersion == nil || strings.TrimSpace(*before.Runtime.FirmwareVersion) == "" { v := fmt.Sprintf("- -> %s", current) return "FIRMWARE_INSTALLED", &v } if strings.TrimSpace(*before.Runtime.FirmwareVersion) == current { return "", nil } v := current return "FIRMWARE_CHANGED", &v case "COMPONENT_INSTALLED": return "INSTALLED", nil case "COMPONENT_REMOVED": return "REMOVED", nil case "COMPONENT_REGISTRY_UPDATED": return "COMPONENT_UPDATED", nil case "COMPONENT_ROLLBACK_APPLIED": return "ROLLBACK_APPLIED", nil case "COMPONENT_FIRST_SEEN_CORRECTED": return "", nil default: return "CHANGE_APPLIED", nil } } func assetTimelineProjection(changeType string, before, after *AssetStateV1) (string, *string) { switch strings.TrimSpace(changeType) { case "ASSET_FIRMWARE_DEVICE_SET": if after == nil { return "", nil } // If exactly one device changed, capture its version for the timeline payload. changed := 0 var last string beforeDevices := map[string]string{} if before != nil { beforeDevices = before.Firmware.Devices } for name, version := range after.Firmware.Devices { if beforeDevices[name] != version { changed++ last = version } } for name := range beforeDevices { if _, ok := after.Firmware.Devices[name]; !ok { changed++ } } if changed == 0 { return "", nil } if strings.TrimSpace(last) != "" { return "FIRMWARE_CHANGED", &last } return "FIRMWARE_CHANGED", nil case "ASSET_REGISTRY_UPDATED": return "ASSET_UPDATED", nil case "ASSET_LOG_COLLECTED": return "LOG_COLLECTED", nil case "ASSET_COMPONENT_SET_CHANGED": return "", nil case "ASSET_ROLLBACK_APPLIED": return "ROLLBACK_APPLIED", nil default: return "CHANGE_APPLIED", nil } } func isPairedComponentTimelineChange(changeType string) bool { switch strings.TrimSpace(changeType) { case "COMPONENT_STATUS_SET", "COMPONENT_FIRMWARE_SET", "COMPONENT_INSTALLED", "COMPONENT_REMOVED": return true default: return false } } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }