package main import ( "context" "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "sort" "strings" "time" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/capacity" ) const defaultEndpoints = "gateway=http://127.0.0.1:20211,history=http://127.0.0.1:20212,stat=http://127.0.0.1:20213,bridge=http://127.0.0.1:20214,fields-projector=http://127.0.0.1:20218,realtime=http://127.0.0.1:20216,identity=http://127.0.0.1:20217,realtime-api=http://127.0.0.1:20200,fast-writer=http://127.0.0.1:20215" const defaultRequiredServices = "gateway,history,stat,bridge,fields-projector,realtime,identity,realtime-api,fast-writer" const defaultDailyMileageDiagnosticsURL = "http://127.0.0.1:20200/api/stats/daily-metrics/diagnostics/reasons" const defaultDailyMileageDiagnosticsPath = "/api/stats/daily-metrics/diagnostics/reasons" const defaultDailyMileageFieldStatusPath = "/api/stats/daily-metrics/diagnostics/field-status" const defaultDailyMileageSourceQualityURL = "http://127.0.0.1:20200/api/stats/daily-metrics/sources/quality" const defaultDailyMileageSourceQualityPath = "/api/stats/daily-metrics/sources/quality" const defaultDailyMileageSourceSelectionURL = "http://127.0.0.1:20200/api/stats/daily-metrics/sources/selection" const defaultDailyMileageSourceSelectionPath = "/api/stats/daily-metrics/sources/selection" const defaultDailyMileageSourcesPath = "/api/stats/daily-metrics/sources" const defaultDataSourceDiagnosticsURL = "http://127.0.0.1:20200/api/stats/data-sources/diagnostics" const defaultDataSourceDiagnosticsPath = "/api/stats/data-sources/diagnostics" const defaultDataSourceKindSuggestionsURL = "http://127.0.0.1:20200/api/stats/data-sources/kind-suggestions" const defaultDataSourceKindSuggestionsPath = "/api/stats/data-sources/kind-suggestions" const defaultDataSourceKindSuggestionProtocols = "GB32960,YUTONG_MQTT,JT808" const defaultJT808IdentityGapsURL = "http://127.0.0.1:20200/api/stats/data-sources/jt808-identity-gaps" const defaultJT808IdentityGapsPath = "/api/stats/data-sources/jt808-identity-gaps" type endpoint struct { Name string URL string } func main() { var rawEndpoints string var rawRequiredServices string var timeout time.Duration thresholds := capacity.DefaultThresholds() var requiredConsumerTopics string var dailyMileageDiagnosticsURL string var dailyMileageDiagnosticsDate string var dailyMileageDiagnosticsMaxActionable int64 var dailyMileageDiagnosticsMaxPipeline int64 var dailyMileageDiagnosticsMaxSourceData int64 var dailyMileageDiagnosticsSampleLimit int var dailyMileageSourceQualityURL string var dailyMileageSourceQualityDate string var dailyMileageSourceQualityLimit int var dailyMileageSourceQualityMaxInvalidDelta int64 var dailyMileageSourceQualityMaxNoBaseline int64 var dailyMileageSourceQualityMaxFallbackAfterInvalidBaseline int64 var dailyMileageSourceQualityMaxRealtimeLocationFallback int64 var dailyMileageSourceSelectionURL string var dailyMileageSourceSelectionDate string var dailyMileageSourceSelectionLimit int var dailyMileageSourceSelectionSampleLimit int var dailyMileageSourceSelectionMinSelected int64 var dailyMileageSourceSelectionRequiredProtocols string var dailyMileageSourceSelectionMinSelectedPerProtocol int64 var dailyMileageSourceSelectionMaxUnmanaged int64 var dailyMileageSourceSelectionMaxUnknownKind int64 var dailyMileageSourceSelectionMaxNotSelectedMileageKM float64 var dataSourceDiagnosticsURL string var dataSourceDiagnosticsSampleLimit int var dataSourceDiagnosticsReasonLimit int var dataSourceDiagnosticsRecentAgeSeconds int64 var dataSourceDiagnosticsMaxMissingSourceCode int64 var dataSourceDiagnosticsMaxRecentMissingSourceCode int64 var dataSourceDiagnosticsMaxActionableCandidates int64 var dataSourceDiagnosticsMaxMappingIssues int64 var dataSourceDiagnosticsMaxRecentMappingIssues int64 var dataSourceKindSuggestionsURL string var dataSourceKindSuggestionsProtocols string var dataSourceKindSuggestionsSampleLimit int var dataSourceKindSuggestionsReasonLimit int var dataSourceKindSuggestionsRecentAgeSeconds int64 var dataSourceKindSuggestionsMaxPlatformCandidates int64 var dataSourceKindSuggestionsMaxRecentPlatformCandidates int64 var jt808IdentityGapsURL string var jt808IdentityGapsRecentSeconds int64 var jt808IdentityGapsSampleLimit int var jt808IdentityGapsMax int64 flag.StringVar(&rawEndpoints, "endpoints", defaultEndpoints, "comma separated name=url endpoints; /metrics is appended when no path is provided") flag.StringVar(&rawRequiredServices, "required-services", defaultRequiredServices, "comma separated service names that must be present and successfully scraped; empty disables") flag.DurationVar(&timeout, "timeout", 2*time.Second, "per endpoint timeout") flag.Float64Var(&thresholds.GatewayFrameP99MS, "gateway-frame-p99-ms", thresholds.GatewayFrameP99MS, "degrade when gateway frame p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityP99MS, "gateway-identity-p99-ms", thresholds.GatewayIdentityP99MS, "degrade when gateway identity resolve p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.GatewayResponseRecentP99MS, "gateway-response-recent-p99-ms", thresholds.GatewayResponseRecentP99MS, "degrade when recent frame receipt to successful protocol response p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.AsyncSinkP99MS, "async-sink-p99-ms", thresholds.AsyncSinkP99MS, "degrade when gateway async publish p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.AsyncSinkQueueWaitRecentP99MS, "async-sink-queue-wait-recent-p99-ms", thresholds.AsyncSinkQueueWaitRecentP99MS, "degrade when recent gateway async queue wait p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeBatchP99MS, "bridge-batch-p99-ms", thresholds.BridgeBatchP99MS, "degrade when NATS to Kafka bridge batch p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeKafkaWriteP99MS, "bridge-kafka-write-p99-ms", thresholds.BridgeKafkaWriteP99MS, "degrade when NATS to Kafka bridge per-topic Kafka write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeKafkaE2EP99MS, "bridge-kafka-e2e-p99-ms", thresholds.BridgeKafkaE2EP99MS, "degrade when gateway received_at to Kafka bridge write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeKafkaRecentE2EP99MS, "bridge-kafka-recent-e2e-p99-ms", thresholds.BridgeKafkaRecentE2EP99MS, "degrade when recent gateway received_at to Kafka bridge write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeFetchWaitMaxMS, "bridge-fetch-wait-max-ms", thresholds.BridgeFetchWaitMaxMS, "degrade when bridge runtime fetch wait exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.BridgeWorkersMin, "bridge-workers-min", thresholds.BridgeWorkersMin, "degrade when bridge runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.BridgeRouteErrorMax, "bridge-route-error-max", thresholds.BridgeRouteErrorMax, "degrade when bridge unrouted NATS subject count exceeds this value; <0 disables") flag.Float64Var(&thresholds.BridgeKafkaWriteErrorMax, "bridge-kafka-write-error-max", thresholds.BridgeKafkaWriteErrorMax, "degrade when bridge Kafka write errors exceed this value; <0 disables") flag.Float64Var(&thresholds.BridgeMessageMinReceived, "bridge-message-min-received", thresholds.BridgeMessageMinReceived, "minimum bridge received messages per subject before unacked ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.BridgeMessageMaxUnackedRatio, "bridge-message-max-unacked-ratio", thresholds.BridgeMessageMaxUnackedRatio, "degrade when bridge unacked messages divided by received messages exceeds this value; <=0 disables") flag.Float64Var(&thresholds.FastWriterStageP99MS, "fast-writer-stage-p99-ms", thresholds.FastWriterStageP99MS, "degrade when NATS fast-writer stage p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisE2EP99MS, "fast-writer-redis-e2e-p99-ms", thresholds.FastWriterRedisE2EP99MS, "degrade when gateway received_at to fast-writer Redis update p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisRecentE2EP99MS, "fast-writer-redis-recent-e2e-p99-ms", thresholds.FastWriterRedisRecentE2EP99MS, "degrade when recent gateway received_at to fast-writer Redis update p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.FastWriterFetchWaitMaxMS, "fast-writer-fetch-wait-max-ms", thresholds.FastWriterFetchWaitMaxMS, "degrade when fast-writer runtime fetch wait exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.FastWriterWorkersMin, "fast-writer-workers-min", thresholds.FastWriterWorkersMin, "degrade when fast-writer runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.FastWriterInvalidJSONMax, "fast-writer-invalid-json-max", thresholds.FastWriterInvalidJSONMax, "degrade when fast-writer invalid JSON count exceeds this value; <0 disables") flag.Float64Var(&thresholds.FastWriterFallbackErrorMax, "fast-writer-fallback-error-max", thresholds.FastWriterFallbackErrorMax, "degrade when fast-writer batch fallback single writes still fail; <0 disables") flag.Float64Var(&thresholds.FastWriterDecoupledUpdateErrorMax, "fast-writer-decoupled-update-error-max", thresholds.FastWriterDecoupledUpdateErrorMax, "degrade when fast-writer Redis catch-up after a decoupled storage failure also fails; <0 disables") flag.Float64Var(&thresholds.FastWriterTDengineEnabledMax, "fast-writer-tdengine-enabled-max", thresholds.FastWriterTDengineEnabledMax, "degrade when fast-writer TDengine stage enabled gauge exceeds this value; <0 disables") flag.Float64Var(&thresholds.HistoryFlushP99MS, "history-flush-p99-ms", thresholds.HistoryFlushP99MS, "degrade when history TDengine batch flush p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.HistoryInvalidJSONMax, "history-invalid-json-max", thresholds.HistoryInvalidJSONMax, "degrade when history-writer invalid JSON count exceeds this value; <0 disables") flag.Float64Var(&thresholds.HistoryWriteE2EP99MS, "history-write-e2e-p99-ms", thresholds.HistoryWriteE2EP99MS, "degrade when gateway received_at to history TDengine write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.HistoryWriteRecentE2EP99MS, "history-write-recent-e2e-p99-ms", thresholds.HistoryWriteRecentE2EP99MS, "degrade when recent gateway received_at to history TDengine write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.HistoryWorkersMin, "history-workers-min", thresholds.HistoryWorkersMin, "degrade when history-writer runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.HistoryLocationErrorMax, "history-location-error-max", thresholds.HistoryLocationErrorMax, "degrade when history-writer location derived write errors exceed this value; <0 disables") flag.Float64Var(&thresholds.HistoryLocationAccountingMaxRatio, "history-location-accounting-max-mismatch-ratio", thresholds.HistoryLocationAccountingMaxRatio, "degrade when location derived status counters do not account for successful history raw writes by more than this ratio; <=0 disables") flag.Float64Var(&thresholds.HistoryFallbackErrorMax, "history-fallback-error-max", thresholds.HistoryFallbackErrorMax, "degrade when history-writer batch fallback single writes still fail; <0 disables") flag.Float64Var(&thresholds.HistoryRetryMax, "history-retry-max", thresholds.HistoryRetryMax, "degrade when history-writer transient write retries exceed this value; <0 disables") flag.Float64Var(&thresholds.HistoryRetryExhaustedMax, "history-retry-exhausted-max", thresholds.HistoryRetryExhaustedMax, "degrade when history-writer exhausted transient write retries exceed this value; <0 disables") flag.Float64Var(&thresholds.RealtimeBatchP99MS, "realtime-batch-p99-ms", thresholds.RealtimeBatchP99MS, "degrade when realtime projector whole-batch flush p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.RealtimeStoreP99MS, "realtime-store-p99-ms", thresholds.RealtimeStoreP99MS, "degrade when realtime store update p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.RealtimeWorkersMin, "realtime-workers-min", thresholds.RealtimeWorkersMin, "degrade when realtime-writer runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.RealtimeStoreErrorMax, "realtime-store-error-max", thresholds.RealtimeStoreErrorMax, "degrade when realtime Redis/MySQL store update errors exceed this value; <0 disables") flag.Float64Var(&thresholds.RealtimeInvalidJSONMax, "realtime-invalid-json-max", thresholds.RealtimeInvalidJSONMax, "degrade when realtime projector invalid JSON count exceeds this value; <0 disables") flag.Float64Var(&thresholds.StatWriteP99MS, "stat-write-p99-ms", thresholds.StatWriteP99MS, "degrade when stat write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.StatWriteE2EP99MS, "stat-write-e2e-p99-ms", thresholds.StatWriteE2EP99MS, "degrade when gateway received_at to stat MySQL write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.StatWriteRecentE2EP99MS, "stat-write-recent-e2e-p99-ms", thresholds.StatWriteRecentE2EP99MS, "degrade when recent gateway received_at to stat MySQL write p99 exceeds this many milliseconds; <=0 disables") flag.Float64Var(&thresholds.StatWorkersMin, "stat-workers-min", thresholds.StatWorkersMin, "degrade when stat-writer runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.IdentityWorkersMin, "identity-workers-min", thresholds.IdentityWorkersMin, "degrade when identity-writer runtime worker count is below this value; <=0 disables") flag.Float64Var(&thresholds.StatInvalidJSONMax, "stat-invalid-json-max", thresholds.StatInvalidJSONMax, "degrade when stat-writer invalid JSON count exceeds this value; <0 disables") flag.Float64Var(&thresholds.StatProtocolTopicMismatchMax, "stat-protocol-topic-mismatch-max", thresholds.StatProtocolTopicMismatchMax, "degrade when stat-writer protocol/topic mismatch count exceeds this value; <0 disables") flag.Float64Var(&thresholds.MessageContractMismatchMax, "message-contract-mismatch-max", thresholds.MessageContractMismatchMax, "degrade when consumer event kind or raw protocol/topic contract mismatch count exceeds this value; <0 disables") flag.Float64Var(&thresholds.StatRetryMax, "stat-retry-max", thresholds.StatRetryMax, "degrade when stat-writer transient write retries exceed this value; <0 disables") flag.Float64Var(&thresholds.StatRetryExhaustedMax, "stat-retry-exhausted-max", thresholds.StatRetryExhaustedMax, "degrade when stat-writer exhausted transient write retries exceed this value; <0 disables") flag.Float64Var(&thresholds.ProcessUptimeMinSec, "process-uptime-min-seconds", thresholds.ProcessUptimeMinSec, "degrade when a service process has been up for fewer seconds than this value; <=0 disables") flag.Float64Var(&thresholds.ProcessHeapAllocMaxBytes, "process-heap-alloc-max-bytes", thresholds.ProcessHeapAllocMaxBytes, "degrade when a service heap allocation exceeds this many bytes; <=0 disables") flag.Float64Var(&thresholds.ProcessHeapSysMaxBytes, "process-heap-sys-max-bytes", thresholds.ProcessHeapSysMaxBytes, "degrade when a service heap reservation exceeds this many bytes; <=0 disables") flag.Float64Var(&thresholds.ProcessGoroutinesMax, "process-goroutines-max", thresholds.ProcessGoroutinesMax, "degrade when a service goroutine count exceeds this value; <=0 disables") flag.Float64Var(&thresholds.KafkaLagMax, "kafka-lag-max", thresholds.KafkaLagMax, "degrade when summed Kafka lag across history/stat/realtime exceeds this value; <=0 disables") flag.Float64Var(&thresholds.KafkaConsumerCommitErrorMax, "kafka-consumer-commit-error-max", thresholds.KafkaConsumerCommitErrorMax, "degrade when Kafka consumer commit errors per topic exceed this value; <0 disables") flag.Float64Var(&thresholds.KafkaConsumerMessageMinReceived, "kafka-consumer-message-min-received", thresholds.KafkaConsumerMessageMinReceived, "minimum Kafka consumer received messages per topic before uncommitted ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.KafkaConsumerMaxUncommittedRatio, "kafka-consumer-max-uncommitted-ratio", thresholds.KafkaConsumerMaxUncommittedRatio, "degrade when Kafka consumer uncommitted messages divided by received messages exceeds this value; <=0 disables") flag.Float64Var(&thresholds.DurableSpoolBacklogMax, "durable-spool-backlog-max", thresholds.DurableSpoolBacklogMax, "degrade when durable spool backlog files exceed this value; <=0 disables") flag.Float64Var(&thresholds.DurableSpoolOldestAgeMaxSec, "durable-spool-oldest-age-max-seconds", thresholds.DurableSpoolOldestAgeMaxSec, "degrade when the oldest durable spool file is older than this many seconds; <=0 disables") flag.Float64Var(&thresholds.DurableSpoolErrorMax, "durable-spool-error-max", thresholds.DurableSpoolErrorMax, "degrade when durable spool record write errors exceed this value; <0 disables") flag.Float64Var(&thresholds.DurableSpoolReplayErrorMax, "durable-spool-replay-error-max", thresholds.DurableSpoolReplayErrorMax, "degrade when durable spool replay errors exceed this value; <0 disables") flag.Float64Var(&thresholds.DurableOutboxInflightMax, "durable-outbox-inflight-max", thresholds.DurableOutboxInflightMax, "degrade when durable outbox asynchronous publishes in flight exceed this value; <=0 disables") flag.Float64Var(&thresholds.DurableOutboxErrorMax, "durable-outbox-error-max", thresholds.DurableOutboxErrorMax, "degrade when durable outbox validation, submit, acknowledgement, removal, close, or quarantine errors exceed this value; <0 disables") flag.Float64Var(&thresholds.AsyncSinkEnqueueErrorMax, "async-sink-enqueue-error-max", thresholds.AsyncSinkEnqueueErrorMax, "degrade when gateway async sink enqueue timeout or closed events exceed this value; <0 disables") flag.Float64Var(&thresholds.AsyncSinkPublishErrorMax, "async-sink-publish-error-max", thresholds.AsyncSinkPublishErrorMax, "degrade when gateway async sink background publish errors exceed this value; <0 disables") flag.Float64Var(&thresholds.AsyncSinkQueueDepthMax, "async-sink-queue-depth-max", thresholds.AsyncSinkQueueDepthMax, "degrade when gateway async sink queue depth exceeds this value; <=0 disables") flag.Float64Var(&thresholds.AsyncSinkQueueMaxUsageRatio, "async-sink-queue-max-usage-ratio", thresholds.AsyncSinkQueueMaxUsageRatio, "degrade when async sink queue depth divided by capacity exceeds this ratio; <=0 disables") flag.Float64Var(&thresholds.FastWriterBatchPendingMax, "fast-writer-batch-pending-max", thresholds.FastWriterBatchPendingMax, "degrade when fast-writer in-flight batch messages exceed this value; <=0 disables") flag.Float64Var(&thresholds.HistoryBatchPendingMax, "history-batch-pending-max", thresholds.HistoryBatchPendingMax, "degrade when history in-flight batch messages exceed this value; <=0 disables") flag.Float64Var(&thresholds.HistoryRowsPendingMax, "history-rows-pending-max", thresholds.HistoryRowsPendingMax, "degrade when history in-flight TDengine rows exceed this value; <=0 disables") flag.Float64Var(&thresholds.RealtimeBatchPendingMax, "realtime-batch-pending-max", thresholds.RealtimeBatchPendingMax, "degrade when realtime in-flight batch messages exceed this value; <=0 disables") flag.Float64Var(&thresholds.RealtimeAsyncQueueDepthMax, "realtime-async-queue-depth-max", thresholds.RealtimeAsyncQueueDepthMax, "degrade when realtime async MySQL queue depth exceeds this value; <=0 disables") flag.Float64Var(&thresholds.RealtimeAsyncQueueMaxUsageRatio, "realtime-async-queue-max-usage-ratio", thresholds.RealtimeAsyncQueueMaxUsageRatio, "degrade when realtime async MySQL queue depth divided by capacity exceeds this ratio; <=0 disables") flag.Float64Var(&thresholds.RealtimeAsyncQueueDroppedMax, "realtime-async-queue-dropped-max", thresholds.RealtimeAsyncQueueDroppedMax, "degrade when realtime async MySQL queue dropped or closed events exceed this value; <0 disables") flag.Float64Var(&thresholds.HistoryParsedFieldMinFrames, "history-parsed-field-min-frames", thresholds.HistoryParsedFieldMinFrames, "minimum realtime history frames per topic/protocol before parsed field missing ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.HistoryParsedFieldMaxMissingRatio, "history-parsed-field-max-missing-ratio", thresholds.HistoryParsedFieldMaxMissingRatio, "degrade when history realtime frames missing parsed_fields divided by checked frames exceeds this value; <=0 disables") flag.Float64Var(&thresholds.MinHistogramSamples, "histogram-min-samples", thresholds.MinHistogramSamples, "minimum histogram sample count before latency thresholds are enforced") flag.Float64Var(&thresholds.StatSampleMinWrites, "stat-sample-min-writes", thresholds.StatSampleMinWrites, "minimum successful stat append count per topic before requiring extracted mileage samples; <=0 disables") flag.Float64Var(&thresholds.StatSampleMaxActionableSkipRatio, "stat-sample-max-actionable-skip-ratio", thresholds.StatSampleMaxActionableSkipRatio, "degrade when actionable stat sample skips divided by successful stat appends exceeds this value; <=0 disables") flag.Float64Var(&thresholds.StatSampleFutureAdjustmentMax, "stat-sample-future-adjustment-max", thresholds.StatSampleFutureAdjustmentMax, "degrade when stat-writer future event-time corrections exceed this value; <0 disables") flag.Float64Var(&thresholds.StatSourceMinSamples, "stat-source-min-samples", thresholds.StatSourceMinSamples, "minimum stat samples found per topic before source tracking checks are enforced; <=0 disables") flag.Float64Var(&thresholds.StatSourceMaxMissingRatio, "stat-source-max-missing-ratio", thresholds.StatSourceMaxMissingRatio, "degrade when source missing endpoint count divided by samples found exceeds this value; <=0 disables") flag.Float64Var(&thresholds.StatProjectionMinSampleWrites, "stat-projection-min-sample-writes", thresholds.StatProjectionMinSampleWrites, "minimum written stat samples per topic before requiring at least one final daily mileage projection; <=0 disables") flag.Float64Var(&thresholds.StatBatchPendingMax, "stat-batch-pending-max", thresholds.StatBatchPendingMax, "degrade when stat-writer in-flight batch messages exceed this value; <=0 disables") flag.Float64Var(&thresholds.ConsumerRetryPendingMax, "consumer-retry-pending-max", thresholds.ConsumerRetryPendingMax, "degrade when history/realtime/stat/identity messages waiting for failure-closed retry exceed this value; <0 disables") flag.Float64Var(&thresholds.StatCacheEntriesMax, "stat-cache-entries-max", thresholds.StatCacheEntriesMax, "degrade when any stat-writer in-memory cache entry gauge exceeds this value; <=0 disables") flag.Float64Var(&thresholds.RealtimeThrottleMinSamples, "realtime-throttle-min-samples", thresholds.RealtimeThrottleMinSamples, "minimum realtime throttle events per store/protocol before requiring some passed MySQL projection writes; <=0 disables") flag.Float64Var(&thresholds.RealtimeThrottleCacheEntriesMax, "realtime-throttle-cache-entries-max", thresholds.RealtimeThrottleCacheEntriesMax, "degrade when realtime MySQL throttle cache entry gauge exceeds this value; <=0 disables") flag.Float64Var(&thresholds.RealtimePlateCacheEntriesMax, "realtime-plate-cache-entries-max", thresholds.RealtimePlateCacheEntriesMax, "degrade when realtime VIN-to-plate cache entry gauge exceeds this value; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisEnvelopeMinSeen, "fast-writer-redis-envelope-min-seen", thresholds.FastWriterRedisEnvelopeMinSeen, "minimum Redis realtime envelopes seen per raw subject before update/skip checks are enforced; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisEnvelopeMaxBadRatio, "fast-writer-redis-envelope-max-bad-ratio", thresholds.FastWriterRedisEnvelopeMaxBadRatio, "degrade when missing-vin or missing-vehicle-key Redis envelopes divided by seen envelopes exceeds this value; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisFieldMinSeen, "fast-writer-redis-field-min-seen", thresholds.FastWriterRedisFieldMinSeen, "minimum Redis realtime fields seen per raw subject before stale field ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.FastWriterRedisFieldMaxStaleRatio, "fast-writer-redis-field-max-stale-ratio", thresholds.FastWriterRedisFieldMaxStaleRatio, "degrade when Redis stale/skipped realtime fields divided by seen fields exceeds this value; <=0 disables") flag.Float64Var(&thresholds.FastWriterMessageMinReceived, "fast-writer-message-min-received", thresholds.FastWriterMessageMinReceived, "minimum fast-writer received messages per raw subject before unacked ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.FastWriterMessageMaxUnackedRatio, "fast-writer-message-max-unacked-ratio", thresholds.FastWriterMessageMaxUnackedRatio, "degrade when fast-writer unacked messages divided by received messages exceeds this value; <=0 disables") flag.Float64Var(&thresholds.GatewayFieldMinRaw, "gateway-field-min-raw", thresholds.GatewayFieldMinRaw, "minimum gateway raw publishes per protocol before requiring at least one fields publish; <=0 disables") flag.Float64Var(&thresholds.GatewayFieldMaxMissingRatio, "gateway-field-max-missing-ratio", thresholds.GatewayFieldMaxMissingRatio, "degrade when gateway fields missing/publish-error events divided by realtime candidate raw publishes exceeds this value; <=0 disables") flag.Float64Var(&thresholds.GatewayFieldMinCount, "gateway-field-min-count", thresholds.GatewayFieldMinCount, "degrade when a protocol has enough published fields events but the latest fields payload has fewer flattened fields than this; <=0 disables") flag.Float64Var(&thresholds.GatewayBridgeMinPublish, "gateway-bridge-min-publishes", thresholds.GatewayBridgeMinPublish, "minimum gateway publishes per protocol/kind before requiring bridge Kafka writes to the expected topic; <=0 disables") flag.Float64Var(&thresholds.GatewayFastWriterMinRawPublish, "gateway-fast-writer-min-raw-publishes", thresholds.GatewayFastWriterMinRawPublish, "minimum gateway raw publishes per protocol before requiring fast-writer messages on the expected raw subject; <=0 disables") flag.Float64Var(&thresholds.GatewayParseMinFrames, "gateway-parse-min-frames", thresholds.GatewayParseMinFrames, "minimum gateway frames per protocol before non-OK parse ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.GatewayParseMaxBadRatio, "gateway-parse-max-bad-ratio", thresholds.GatewayParseMaxBadRatio, "degrade when gateway non-OK parse ratio exceeds this value after enough frames; <=0 disables") flag.Float64Var(&thresholds.GatewayResponseMinSamples, "gateway-response-min-samples", thresholds.GatewayResponseMinSamples, "minimum gateway response attempts per protocol before response error ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.GatewayResponseMaxErrorRatio, "gateway-response-max-error-ratio", thresholds.GatewayResponseMaxErrorRatio, "degrade when gateway protocol response build/write error ratio exceeds this value after enough samples; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityMinSamples, "gateway-identity-min-samples", thresholds.GatewayIdentityMinSamples, "minimum gateway identity samples per protocol before unresolved ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityMaxUnresolvedRatio, "gateway-identity-max-unresolved-ratio", thresholds.GatewayIdentityMaxUnresolvedRatio, "degrade when gateway non-resolved identity ratio exceeds this value after enough samples; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityCacheEntriesMax, "gateway-identity-cache-entries-max", thresholds.GatewayIdentityCacheEntriesMax, "degrade when any gateway identity cache entry gauge exceeds this value; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentitySnapshotRequired, "gateway-identity-snapshot-required", thresholds.GatewayIdentitySnapshotRequired, "degrade when the gateway identity snapshot is not ready; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentitySnapshotMaxAgeSec, "gateway-identity-snapshot-max-age-seconds", thresholds.GatewayIdentitySnapshotMaxAgeSec, "degrade when the last successful identity snapshot refresh is older than this many seconds; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityLocationTouchFailureMax, "gateway-identity-location-touch-failure-max", thresholds.GatewayIdentityLocationTouchFailureMax, "degrade when jt808 registration location-touch failure backoff cache exceeds this value; <0 disables") flag.Float64Var(&thresholds.GatewayIdentityRegistrationWriteQueueDepthMax, "gateway-identity-registration-write-queue-depth-max", thresholds.GatewayIdentityRegistrationWriteQueueDepthMax, "degrade when async JT808 registration write queue depth exceeds this value; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityRegistrationWriteQueueMaxUsageRatio, "gateway-identity-registration-write-queue-max-usage-ratio", thresholds.GatewayIdentityRegistrationWriteQueueMaxUsageRatio, "degrade when async JT808 registration write queue depth divided by capacity exceeds this ratio; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityRegistrationWriteErrorMax, "gateway-identity-registration-write-error-max", thresholds.GatewayIdentityRegistrationWriteErrorMax, "degrade when async/sync JT808 registration write errors exceed this value; <0 disables") flag.Float64Var(&thresholds.GatewayIdentityStaleMinSamples, "gateway-identity-stale-min-samples", thresholds.GatewayIdentityStaleMinSamples, "minimum gateway identity samples per protocol before stale cache ratio is enforced; <=0 disables") flag.Float64Var(&thresholds.GatewayIdentityMaxStaleCacheRatio, "gateway-identity-max-stale-cache-ratio", thresholds.GatewayIdentityMaxStaleCacheRatio, "degrade when stale identity cache resolutions divided by identity samples exceeds this value; <=0 disables") flag.Float64Var(&thresholds.RawFanoutMinBridge, "raw-fanout-min-bridge-writes", thresholds.RawFanoutMinBridge, "minimum bridge writes per raw topic before requiring history writes and realtime updates; <=0 disables") flag.Float64Var(&thresholds.StatTopicMinBridge, "stat-topic-min-bridge-writes", thresholds.StatTopicMinBridge, "minimum bridge writes per fields topic before requiring stat consumer topic info and received messages; <=0 disables") flag.Float64Var(&thresholds.LastActivityStaleSec, "last-activity-stale-seconds", thresholds.LastActivityStaleSec, "degrade when an observed successful protocol/topic activity timestamp is older than this many seconds; <=0 disables") flag.StringVar(&requiredConsumerTopics, "required-consumer-topics", formatRequiredConsumerTopics(capacity.DefaultRequiredConsumerTopics()), "semicolon separated service=topic|topic contract; empty disables required topic checks") flag.StringVar(&dailyMileageDiagnosticsURL, "daily-mileage-diagnostics-url", defaultDailyMileageDiagnosticsURL, "daily mileage diagnosis reason summary URL; empty disables this business-health probe") flag.StringVar(&dailyMileageDiagnosticsDate, "daily-mileage-diagnostics-date", "", "YYYY-MM-DD stat date for daily mileage diagnostics; empty lets the API use local current date") flag.Int64Var(&dailyMileageDiagnosticsMaxActionable, "daily-mileage-diagnostics-max-actionable", -1, "degrade when daily mileage actionable issues exceed this value; <0 reports only") flag.Int64Var(&dailyMileageDiagnosticsMaxPipeline, "daily-mileage-diagnostics-max-pipeline", -1, "degrade when daily mileage pipeline issues exceed this value; <0 reports only") flag.Int64Var(&dailyMileageDiagnosticsMaxSourceData, "daily-mileage-diagnostics-max-source-data", -1, "degrade when daily mileage source-data issues exceed this value; <0 reports only") flag.IntVar(&dailyMileageDiagnosticsSampleLimit, "daily-mileage-diagnostics-sample-limit", 5, "maximum daily mileage diagnosis detail samples to attach to the report; <=0 disables samples") flag.StringVar(&dailyMileageSourceQualityURL, "daily-mileage-source-quality-url", defaultDailyMileageSourceQualityURL, "daily mileage source quality summary URL; empty disables this business-health probe") flag.StringVar(&dailyMileageSourceQualityDate, "daily-mileage-source-quality-date", "", "YYYY-MM-DD stat date for daily mileage source quality; empty uses Asia/Shanghai current date") flag.IntVar(&dailyMileageSourceQualityLimit, "daily-mileage-source-quality-limit", 1000, "maximum daily mileage source quality summary rows to fetch; <=0 fetches 1 row") flag.Int64Var(&dailyMileageSourceQualityMaxInvalidDelta, "daily-mileage-source-quality-max-invalid-delta", -1, "degrade when INVALID_DELTA source count exceeds this value; <0 reports only") flag.Int64Var(&dailyMileageSourceQualityMaxNoBaseline, "daily-mileage-source-quality-max-no-baseline", -1, "degrade when NO_PREVIOUS_BASELINE source count exceeds this value; <0 reports only") flag.Int64Var(&dailyMileageSourceQualityMaxFallbackAfterInvalidBaseline, "daily-mileage-source-quality-max-fallback-after-invalid-baseline", -1, "degrade when OK sources using current-day fallback after invalid baseline exceed this value; <0 reports only") flag.Int64Var(&dailyMileageSourceQualityMaxRealtimeLocationFallback, "daily-mileage-source-quality-max-realtime-location-fallback", -1, "degrade when OK sources using realtime-location historical fallback exceed this value; <0 reports only") flag.StringVar(&dailyMileageSourceSelectionURL, "daily-mileage-source-selection-url", defaultDailyMileageSourceSelectionURL, "daily mileage source selection summary URL; empty disables this business-health probe") flag.StringVar(&dailyMileageSourceSelectionDate, "daily-mileage-source-selection-date", "", "YYYY-MM-DD stat date for daily mileage source selection; empty uses Asia/Shanghai current date") flag.IntVar(&dailyMileageSourceSelectionLimit, "daily-mileage-source-selection-limit", 1000, "maximum daily mileage source selection summary rows to fetch; <=0 fetches 1 row") flag.IntVar(&dailyMileageSourceSelectionSampleLimit, "daily-mileage-source-selection-sample-limit", 5, "maximum problematic daily mileage source selection detail samples to attach; <=0 disables samples") flag.Int64Var(&dailyMileageSourceSelectionMinSelected, "daily-mileage-source-selection-min-selected", -1, "degrade when selected source count is below this value; <0 reports only") flag.StringVar(&dailyMileageSourceSelectionRequiredProtocols, "daily-mileage-source-selection-required-protocols", defaultDataSourceKindSuggestionProtocols, "comma separated protocols that must each have selected mileage sources when per-protocol selection gating is enabled") flag.Int64Var(&dailyMileageSourceSelectionMinSelectedPerProtocol, "daily-mileage-source-selection-min-selected-per-protocol", -1, "degrade when any required protocol has fewer selected source rows than this value; <0 reports only") flag.Int64Var(&dailyMileageSourceSelectionMaxUnmanaged, "daily-mileage-source-selection-max-unmanaged", -1, "degrade when unmanaged selected-candidate source count exceeds this value; <0 reports only") flag.Int64Var(&dailyMileageSourceSelectionMaxUnknownKind, "daily-mileage-source-selection-max-unknown-kind", -1, "degrade when UNKNOWN source_kind selected-candidate source count exceeds this value; <0 reports only") flag.Float64Var(&dailyMileageSourceSelectionMaxNotSelectedMileageKM, "daily-mileage-source-selection-max-not-selected-mileage-km", -1, "degrade when not-selected source mileage sum exceeds this value; <0 reports only") flag.StringVar(&dataSourceDiagnosticsURL, "data-source-diagnostics-url", defaultDataSourceDiagnosticsURL, "data source diagnostics URL for missing source_code; empty disables this business-health probe") flag.IntVar(&dataSourceDiagnosticsSampleLimit, "data-source-diagnostics-sample-limit", 5, "maximum data source diagnosis samples to attach to the report; <=0 keeps total only") flag.IntVar(&dataSourceDiagnosticsReasonLimit, "data-source-diagnostics-reason-limit", 1000, "maximum data source diagnosis rows to fetch for reason/actionable summary; <=0 disables reason summary") flag.Int64Var(&dataSourceDiagnosticsRecentAgeSeconds, "data-source-diagnostics-recent-age-seconds", 300, "classify missing source_code data sources as recent when latest_seen_age_seconds is at or below this value; <=0 disables recent summary") flag.Int64Var(&dataSourceDiagnosticsMaxMissingSourceCode, "data-source-diagnostics-max-missing-source-code", -1, "degrade when missing source_code data sources exceed this value; <0 reports only") flag.Int64Var(&dataSourceDiagnosticsMaxRecentMissingSourceCode, "data-source-diagnostics-max-recent-missing-source-code", -1, "degrade when recent missing source_code data sources exceed this value; <0 reports only") flag.Int64Var(&dataSourceDiagnosticsMaxActionableCandidates, "data-source-diagnostics-max-actionable-candidates", -1, "degrade when actionable data source candidates exceed this value; <0 reports only") flag.Int64Var(&dataSourceDiagnosticsMaxMappingIssues, "data-source-diagnostics-max-mapping-issues", -1, "degrade when configured data source mapping issues exceed this value; <0 reports only") flag.Int64Var(&dataSourceDiagnosticsMaxRecentMappingIssues, "data-source-diagnostics-max-recent-mapping-issues", -1, "degrade when recent configured data source mapping issues exceed this value; <0 reports only") flag.StringVar(&dataSourceKindSuggestionsURL, "data-source-kind-suggestions-url", defaultDataSourceKindSuggestionsURL, "data source kind suggestion URL; empty disables this business-health probe") flag.StringVar(&dataSourceKindSuggestionsProtocols, "data-source-kind-suggestions-protocols", defaultDataSourceKindSuggestionProtocols, "comma separated protocols to check for UNKNOWN source_kind suggestions") flag.IntVar(&dataSourceKindSuggestionsSampleLimit, "data-source-kind-suggestions-sample-limit", 5, "maximum source_kind diagnosis samples to attach to the report; <=0 keeps total only") flag.IntVar(&dataSourceKindSuggestionsReasonLimit, "data-source-kind-suggestions-reason-limit", 1000, "maximum source_kind diagnosis rows to fetch for suggestion summary; <=0 disables reason summary") flag.Int64Var(&dataSourceKindSuggestionsRecentAgeSeconds, "data-source-kind-suggestions-recent-age-seconds", 300, "classify UNKNOWN source_kind rows as recent when latest_seen_age_seconds is at or below this value; <=0 disables recent summary") flag.Int64Var(&dataSourceKindSuggestionsMaxPlatformCandidates, "data-source-kind-suggestions-max-platform-candidates", -1, "degrade when UNKNOWN source_kind rows that should be PLATFORM exceed this value; <0 reports only") flag.Int64Var(&dataSourceKindSuggestionsMaxRecentPlatformCandidates, "data-source-kind-suggestions-max-recent-platform-candidates", 0, "degrade when recent UNKNOWN source_kind rows that should be PLATFORM exceed this value; <0 reports only") flag.StringVar(&jt808IdentityGapsURL, "jt808-identity-gaps-url", defaultJT808IdentityGapsURL, "JT808 identity gap diagnostics URL; empty disables this business-health probe") flag.Int64Var(&jt808IdentityGapsRecentSeconds, "jt808-identity-gaps-recent-seconds", 86400, "only count JT808 unresolved identity registrations seen within this many seconds; <=0 checks all rows") flag.IntVar(&jt808IdentityGapsSampleLimit, "jt808-identity-gaps-sample-limit", 5, "maximum JT808 identity gap samples to attach to the report; <=0 keeps total only") flag.Int64Var(&jt808IdentityGapsMax, "jt808-identity-gaps-max", -1, "degrade when JT808 unresolved identity gaps exceed this value; <0 reports only") flag.Parse() thresholds.RequiredConsumerTopics = parseRequiredConsumerTopics(requiredConsumerTopics) requiredServices := parseRequiredServices(rawRequiredServices) endpoints := parseEndpoints(rawEndpoints) metricsCtx, metricsCancel := context.WithTimeout(context.Background(), timeout) metricsByService, findings := collectMetrics(metricsCtx, endpoints, timeout) metricsCancel() findings = append(findings, requiredServiceFindings(endpoints, metricsByService, requiredServices)...) report := capacity.EvaluateWithThresholds(metricsByService, thresholds) report.Findings = append(report.Findings, findings...) if strings.TrimSpace(dailyMileageDiagnosticsURL) != "" { diagnosticsCtx, diagnosticsCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectDailyMileageDiagnostics(diagnosticsCtx, dailyMileageDiagnosticsURL, dailyMileageDiagnosticsDate, timeout, dailyMileageDiagnosticsSampleLimit) diagnosticsCancel() if diagnostics != nil { report.DailyMileageDiagnostics = diagnostics report.Findings = append(report.Findings, dailyMileageDiagnosticFindings( diagnostics, dailyMileageDiagnosticsMaxActionable, dailyMileageDiagnosticsMaxPipeline, dailyMileageDiagnosticsMaxSourceData, )...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if strings.TrimSpace(dailyMileageSourceQualityURL) != "" { sourceQualityCtx, sourceQualityCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectDailyMileageSourceQuality( sourceQualityCtx, dailyMileageSourceQualityURL, dailyMileageSourceQualityDate, timeout, dailyMileageSourceQualityLimit, ) sourceQualityCancel() if diagnostics != nil { report.DailyMileageSourceQuality = diagnostics report.Findings = append(report.Findings, dailyMileageSourceQualityFindings( diagnostics, dailyMileageSourceQualityMaxInvalidDelta, dailyMileageSourceQualityMaxNoBaseline, dailyMileageSourceQualityMaxFallbackAfterInvalidBaseline, dailyMileageSourceQualityMaxRealtimeLocationFallback, )...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if strings.TrimSpace(dailyMileageSourceSelectionURL) != "" { sourceSelectionCtx, sourceSelectionCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectDailyMileageSourceSelection( sourceSelectionCtx, dailyMileageSourceSelectionURL, dailyMileageSourceSelectionDate, timeout, dailyMileageSourceSelectionLimit, dailyMileageSourceSelectionSampleLimit, ) sourceSelectionCancel() if diagnostics != nil { report.DailyMileageSourceSelection = diagnostics report.Findings = append(report.Findings, dailyMileageSourceSelectionFindings( diagnostics, dailyMileageSourceSelectionMinSelected, parseDataSourceKindSuggestionProtocols(dailyMileageSourceSelectionRequiredProtocols), dailyMileageSourceSelectionMinSelectedPerProtocol, dailyMileageSourceSelectionMaxUnmanaged, dailyMileageSourceSelectionMaxUnknownKind, dailyMileageSourceSelectionMaxNotSelectedMileageKM, )...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if strings.TrimSpace(dataSourceDiagnosticsURL) != "" { dataSourceCtx, dataSourceCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectDataSourceDiagnostics( dataSourceCtx, dataSourceDiagnosticsURL, timeout, dataSourceDiagnosticsSampleLimit, dataSourceDiagnosticsReasonLimit, dataSourceDiagnosticsRecentAgeSeconds, ) dataSourceCancel() if diagnostics != nil { report.DataSourceDiagnostics = diagnostics report.Findings = append(report.Findings, dataSourceDiagnosticFindings( diagnostics, dataSourceDiagnosticsMaxMissingSourceCode, dataSourceDiagnosticsMaxRecentMissingSourceCode, dataSourceDiagnosticsMaxActionableCandidates, dataSourceDiagnosticsMaxMappingIssues, dataSourceDiagnosticsMaxRecentMappingIssues, )...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if strings.TrimSpace(dataSourceKindSuggestionsURL) != "" { dataSourceKindCtx, dataSourceKindCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectDataSourceKindSuggestions( dataSourceKindCtx, dataSourceKindSuggestionsURL, timeout, parseDataSourceKindSuggestionProtocols(dataSourceKindSuggestionsProtocols), dataSourceKindSuggestionsSampleLimit, dataSourceKindSuggestionsReasonLimit, dataSourceKindSuggestionsRecentAgeSeconds, ) dataSourceKindCancel() if diagnostics != nil { report.DataSourceKindDiagnostics = diagnostics report.Findings = append(report.Findings, dataSourceKindDiagnosticFindings( diagnostics, dataSourceKindSuggestionsMaxPlatformCandidates, dataSourceKindSuggestionsMaxRecentPlatformCandidates, )...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if strings.TrimSpace(jt808IdentityGapsURL) != "" { identityGapCtx, identityGapCancel := context.WithTimeout(context.Background(), timeout) diagnostics, diagnosticsFindings := collectJT808IdentityGaps( identityGapCtx, jt808IdentityGapsURL, timeout, jt808IdentityGapsRecentSeconds, jt808IdentityGapsSampleLimit, ) identityGapCancel() if diagnostics != nil { report.JT808IdentityGaps = diagnostics report.Findings = append(report.Findings, jt808IdentityGapFindings(diagnostics, jt808IdentityGapsMax)...) } report.Findings = append(report.Findings, diagnosticsFindings...) } if len(report.Findings) > 0 { report.Status = capacity.StatusDegraded } encoder := json.NewEncoder(os.Stdout) encoder.SetIndent("", " ") _ = encoder.Encode(report) if report.Status != capacity.StatusOK { os.Exit(2) } } func dailyMileageDiagnosticFindings(diagnostics *capacity.DailyMileageDiagnostics, maxActionable, maxPipeline, maxSourceData int64) []string { if diagnostics == nil { return nil } var findings []string if maxActionable >= 0 && diagnostics.ActionableIssueTotal > maxActionable { findings = append(findings, fmt.Sprintf( "daily mileage actionable issues %d exceeds %d (pipeline=%d, source_data=%d)", diagnostics.ActionableIssueTotal, maxActionable, diagnostics.PipelineIssueTotal, diagnostics.SourceDataIssueTotal, )) } if maxPipeline >= 0 && diagnostics.PipelineIssueTotal > maxPipeline { findings = append(findings, fmt.Sprintf( "daily mileage pipeline issues %d exceeds %d (source_data=%d)", diagnostics.PipelineIssueTotal, maxPipeline, diagnostics.SourceDataIssueTotal, )) } if maxSourceData >= 0 && diagnostics.SourceDataIssueTotal > maxSourceData { findings = append(findings, fmt.Sprintf( "daily mileage source-data issues %d exceeds %d (pipeline=%d)", diagnostics.SourceDataIssueTotal, maxSourceData, diagnostics.PipelineIssueTotal, )) } return findings } func dailyMileageSourceQualityFindings(diagnostics *capacity.DailyMileageSourceQualityDiagnostics, maxInvalidDelta, maxNoBaseline, maxFallbackAfterInvalidBaseline, maxRealtimeLocationFallback int64) []string { if diagnostics == nil { return nil } var findings []string if maxInvalidDelta >= 0 && diagnostics.InvalidDeltaSourceCount > maxInvalidDelta { findings = append(findings, fmt.Sprintf( "daily mileage source INVALID_DELTA count %d exceeds %d", diagnostics.InvalidDeltaSourceCount, maxInvalidDelta, )) } if maxNoBaseline >= 0 && diagnostics.NoBaselineSourceCount > maxNoBaseline { findings = append(findings, fmt.Sprintf( "daily mileage source NO_PREVIOUS_BASELINE count %d exceeds %d", diagnostics.NoBaselineSourceCount, maxNoBaseline, )) } if maxFallbackAfterInvalidBaseline >= 0 && diagnostics.FallbackAfterInvalidBaselineSourceCount > maxFallbackAfterInvalidBaseline { findings = append(findings, fmt.Sprintf( "daily mileage source current-day fallback after invalid baseline count %d exceeds %d", diagnostics.FallbackAfterInvalidBaselineSourceCount, maxFallbackAfterInvalidBaseline, )) } if maxRealtimeLocationFallback >= 0 && diagnostics.RealtimeLocationFallbackSourceCount > maxRealtimeLocationFallback { findings = append(findings, fmt.Sprintf( "daily mileage source realtime-location fallback count %d exceeds %d", diagnostics.RealtimeLocationFallbackSourceCount, maxRealtimeLocationFallback, )) } return findings } func dailyMileageSourceSelectionFindings(diagnostics *capacity.DailyMileageSourceSelectionDiagnostics, minSelected int64, requiredProtocols []string, minSelectedPerProtocol int64, maxUnmanaged, maxUnknownKind int64, maxNotSelectedMileageKM float64) []string { if diagnostics == nil { return nil } var findings []string if minSelected >= 0 && diagnostics.SelectedSourceCount < minSelected { findings = append(findings, fmt.Sprintf( "daily mileage selected source count %d is below %d", diagnostics.SelectedSourceCount, minSelected, )) } if minSelectedPerProtocol >= 0 { byProtocol := dailyMileageSourceSelectionProtocolsByName(diagnostics.Protocols) for _, protocol := range requiredProtocols { protocol = strings.ToUpper(strings.TrimSpace(protocol)) if protocol == "" { continue } selected := byProtocol[protocol].SelectedSourceCount if selected >= minSelectedPerProtocol { continue } findings = append(findings, fmt.Sprintf( "daily mileage selected source count for protocol %s is %d below %d", protocol, selected, minSelectedPerProtocol, )) } } if maxUnmanaged >= 0 && diagnostics.UnmanagedSourceCount > maxUnmanaged { findings = append(findings, fmt.Sprintf( "daily mileage unmanaged source count %d exceeds %d", diagnostics.UnmanagedSourceCount, maxUnmanaged, )) } if maxUnknownKind >= 0 && diagnostics.UnknownKindSourceCount > maxUnknownKind { findings = append(findings, fmt.Sprintf( "daily mileage UNKNOWN source_kind count %d exceeds %d", diagnostics.UnknownKindSourceCount, maxUnknownKind, )) } if maxNotSelectedMileageKM >= 0 && diagnostics.NotSelectedMileageKM > maxNotSelectedMileageKM { findings = append(findings, fmt.Sprintf( "daily mileage not-selected source mileage %.1fkm exceeds %.1fkm", diagnostics.NotSelectedMileageKM, maxNotSelectedMileageKM, )) } return findings } func dailyMileageSourceSelectionProtocolsByName(items []capacity.DailyMileageSourceSelectionProtocol) map[string]capacity.DailyMileageSourceSelectionProtocol { out := make(map[string]capacity.DailyMileageSourceSelectionProtocol, len(items)) for _, item := range items { protocol := strings.ToUpper(strings.TrimSpace(item.Protocol)) if protocol == "" { continue } out[protocol] = item } return out } func dataSourceDiagnosticFindings(diagnostics *capacity.DataSourceDiagnostics, maxMissingSourceCode, maxRecentMissingSourceCode, maxActionableCandidates, maxMappingIssues, maxRecentMappingIssues int64) []string { if diagnostics == nil { return nil } var findings []string if maxMissingSourceCode >= 0 && diagnostics.MissingSourceCodeTotal > maxMissingSourceCode { findings = append(findings, fmt.Sprintf( "data source missing source_code %d exceeds %d", diagnostics.MissingSourceCodeTotal, maxMissingSourceCode, )) } if maxRecentMissingSourceCode >= 0 && diagnostics.RecentMissingSourceCodeTotal > maxRecentMissingSourceCode { findings = append(findings, fmt.Sprintf( "data source recent missing source_code %d exceeds %d (recent_age_seconds=%d)", diagnostics.RecentMissingSourceCodeTotal, maxRecentMissingSourceCode, diagnostics.RecentAgeThresholdSeconds, )) } if maxActionableCandidates >= 0 && diagnostics.ActionableCandidateTotal > maxActionableCandidates { findings = append(findings, fmt.Sprintf( "data source actionable candidates %d exceeds %d", diagnostics.ActionableCandidateTotal, maxActionableCandidates, )) } if maxMappingIssues >= 0 && diagnostics.MappingIssueTotal > maxMappingIssues { findings = append(findings, fmt.Sprintf( "data source mapping issues %d exceeds %d", diagnostics.MappingIssueTotal, maxMappingIssues, )) } if maxRecentMappingIssues >= 0 && diagnostics.RecentMappingIssueTotal > maxRecentMappingIssues { findings = append(findings, fmt.Sprintf( "data source recent mapping issues %d exceeds %d (recent_age_seconds=%d)", diagnostics.RecentMappingIssueTotal, maxRecentMappingIssues, diagnostics.RecentAgeThresholdSeconds, )) } return findings } func dataSourceKindDiagnosticFindings(diagnostics *capacity.DataSourceDiagnostics, maxPlatformCandidates, maxRecentPlatformCandidates int64) []string { if diagnostics == nil { return nil } var findings []string if maxPlatformCandidates >= 0 && diagnostics.PlatformKindCandidateTotal > maxPlatformCandidates { findings = append(findings, fmt.Sprintf( "data source UNKNOWN source_kind platform candidates %d exceeds %d", diagnostics.PlatformKindCandidateTotal, maxPlatformCandidates, )) } if maxRecentPlatformCandidates >= 0 && diagnostics.RecentPlatformKindCandidateTotal > maxRecentPlatformCandidates { findings = append(findings, fmt.Sprintf( "data source recent UNKNOWN source_kind platform candidates %d exceeds %d (recent_age_seconds=%d)", diagnostics.RecentPlatformKindCandidateTotal, maxRecentPlatformCandidates, diagnostics.RecentAgeThresholdSeconds, )) } return findings } func jt808IdentityGapFindings(diagnostics *capacity.JT808IdentityGapDiagnostics, maxGaps int64) []string { if diagnostics == nil { return nil } if maxGaps < 0 || diagnostics.Total <= maxGaps { return nil } return []string{fmt.Sprintf( "JT808 unresolved identity gaps %d exceeds %d (recent_seconds=%d)", diagnostics.Total, maxGaps, diagnostics.RecentSeconds, )} } func formatRequiredConsumerTopics(values map[string][]string) string { if len(values) == 0 { return "" } var services []string for service := range values { service = strings.TrimSpace(service) if service == "" { continue } services = append(services, service) } sort.Strings(services) parts := make([]string, 0, len(services)) for _, service := range services { var topics []string for _, topic := range values[service] { topic = strings.TrimSpace(topic) if topic == "" { continue } topics = append(topics, topic) } if len(topics) == 0 { continue } sort.Strings(topics) parts = append(parts, service+"="+strings.Join(topics, "|")) } return strings.Join(parts, ";") } func parseRequiredConsumerTopics(value string) map[string][]string { value = strings.TrimSpace(value) if value == "" { return nil } out := map[string][]string{} for _, serviceSpec := range strings.Split(value, ";") { serviceSpec = strings.TrimSpace(serviceSpec) if serviceSpec == "" { continue } service, rawTopics, ok := strings.Cut(serviceSpec, "=") if !ok { continue } service = strings.TrimSpace(service) if service == "" { continue } seen := map[string]struct{}{} for _, topic := range strings.Split(rawTopics, "|") { topic = strings.TrimSpace(topic) if topic == "" { continue } if _, exists := seen[topic]; exists { continue } seen[topic] = struct{}{} out[service] = append(out[service], topic) } } if len(out) == 0 { return nil } return out } func parseRequiredServices(value string) []string { value = strings.TrimSpace(value) if value == "" { return nil } seen := map[string]struct{}{} var services []string for _, item := range strings.Split(value, ",") { item = strings.TrimSpace(item) if item == "" { continue } if _, exists := seen[item]; exists { continue } seen[item] = struct{}{} services = append(services, item) } return services } func parseDataSourceKindSuggestionProtocols(value string) []string { protocols := parseRequiredServices(value) if len(protocols) == 0 { protocols = parseRequiredServices(defaultDataSourceKindSuggestionProtocols) } for i, protocol := range protocols { protocols[i] = strings.ToUpper(strings.TrimSpace(protocol)) } return protocols } func requiredServiceFindings(endpoints []endpoint, metricsByService map[string]string, requiredServices []string) []string { if len(requiredServices) == 0 { return nil } configured := map[string]struct{}{} for _, ep := range endpoints { if ep.Name == "" { continue } configured[ep.Name] = struct{}{} } var findings []string for _, service := range requiredServices { if _, ok := configured[service]; !ok { findings = append(findings, fmt.Sprintf("required service endpoint missing: %s", service)) continue } if _, ok := metricsByService[service]; !ok { findings = append(findings, fmt.Sprintf("required service metrics missing: %s", service)) } } return findings } func parseEndpoints(value string) []endpoint { var endpoints []endpoint for _, item := range strings.Split(value, ",") { item = strings.TrimSpace(item) if item == "" { continue } name, url, ok := strings.Cut(item, "=") if !ok { continue } name = strings.TrimSpace(name) url = withMetricsPath(strings.TrimSpace(url)) if name == "" || url == "" { continue } endpoints = append(endpoints, endpoint{Name: name, URL: url}) } return endpoints } func withMetricsPath(value string) string { parsed, err := url.Parse(value) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return value } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = "/metrics" } return parsed.String() } func collectMetrics(ctx context.Context, endpoints []endpoint, timeout time.Duration) (map[string]string, []string) { client := &http.Client{Timeout: timeout} metricsByService := make(map[string]string, len(endpoints)) var findings []string for _, ep := range endpoints { request, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.URL, nil) if err != nil { findings = append(findings, fmt.Sprintf("%s metrics request invalid: %v", ep.Name, err)) continue } response, err := client.Do(request) if err != nil { findings = append(findings, fmt.Sprintf("%s metrics fetch failed: %v", ep.Name, err)) continue } body, readErr := io.ReadAll(response.Body) _ = response.Body.Close() if readErr != nil { findings = append(findings, fmt.Sprintf("%s metrics read failed: %v", ep.Name, readErr)) continue } if response.StatusCode < 200 || response.StatusCode >= 300 { findings = append(findings, fmt.Sprintf("%s metrics status %d", ep.Name, response.StatusCode)) continue } metricsByService[ep.Name] = string(body) } return metricsByService, findings } func collectDailyMileageDiagnostics(ctx context.Context, rawURL string, date string, timeout time.Duration, sampleLimit int) (*capacity.DailyMileageDiagnostics, []string) { requestURL, err := dailyMileageDiagnosticsRequestURL(rawURL, date) if err != nil { return nil, []string{fmt.Sprintf("daily mileage diagnostics request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("daily mileage diagnostics request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("daily mileage diagnostics fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("daily mileage diagnostics read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("daily mileage diagnostics status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.DailyMileageDiagnosisReasonItem `json:"items"` VehicleTotal int64 `json:"vehicle_total"` ActionableIssueTotal int64 `json:"actionable_issue_total"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("daily mileage diagnostics decode failed: %v", err)} } items, pipelineIssues, sourceDataIssues := capacity.AnnotateDailyMileageDiagnostics(payload.Items) samples, sampleFindings := collectDailyMileageDiagnosisSamples(ctx, rawURL, date, timeout, items, sampleLimit) fieldStatus, fieldStatusFindings := collectDailyMileageFieldStatus(ctx, rawURL, date, timeout) findings := append(sampleFindings, fieldStatusFindings...) return &capacity.DailyMileageDiagnostics{ URL: requestURL, VehicleTotal: payload.VehicleTotal, ActionableIssueTotal: payload.ActionableIssueTotal, PipelineIssueTotal: pipelineIssues, SourceDataIssueTotal: sourceDataIssues, Items: items, Samples: samples, FieldStatus: fieldStatus, }, findings } func collectDailyMileageFieldStatus(ctx context.Context, rawURL string, date string, timeout time.Duration) (*capacity.DailyMileageFieldStatusDiagnostics, []string) { requestURL, err := dailyMileageFieldStatusRequestURL(rawURL, date) if err != nil { return nil, []string{fmt.Sprintf("daily mileage field status request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("daily mileage field status request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("daily mileage field status fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("daily mileage field status read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("daily mileage field status status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.DailyMileageFieldStatusSummaryItem `json:"items"` Total int64 `json:"total"` VehicleTotal int64 `json:"vehicle_total"` ActionableIssueTotal int64 `json:"actionable_issue_total"` PipelineIssueTotal int64 `json:"pipeline_issue_total"` SourceDataIssueTotal int64 `json:"source_data_issue_total"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("daily mileage field status decode failed: %v", err)} } actionable, pipeline, sourceData := summarizeDailyMileageFieldStatus(payload.Items) if payload.ActionableIssueTotal == 0 && actionable > 0 { payload.ActionableIssueTotal = actionable } if payload.PipelineIssueTotal == 0 && pipeline > 0 { payload.PipelineIssueTotal = pipeline } if payload.SourceDataIssueTotal == 0 && sourceData > 0 { payload.SourceDataIssueTotal = sourceData } if payload.VehicleTotal == 0 { for _, item := range payload.Items { payload.VehicleTotal += item.Count } } return &capacity.DailyMileageFieldStatusDiagnostics{ URL: requestURL, SummaryTotal: payload.Total, VehicleTotal: payload.VehicleTotal, ActionableIssueTotal: payload.ActionableIssueTotal, PipelineIssueTotal: payload.PipelineIssueTotal, SourceDataIssueTotal: payload.SourceDataIssueTotal, Items: payload.Items, }, nil } func summarizeDailyMileageFieldStatus(items []capacity.DailyMileageFieldStatusSummaryItem) (actionable, pipeline, sourceData int64) { for _, item := range items { severity := strings.ToLower(strings.TrimSpace(item.Severity)) if severity == "" { severity = strings.ToLower(strings.TrimSpace(item.FieldStatusSeverity)) } switch severity { case capacity.DailyMileageSeverityPipeline: actionable += item.Count pipeline += item.Count case capacity.DailyMileageSeveritySourceData: actionable += item.Count sourceData += item.Count default: if !strings.EqualFold(strings.TrimSpace(item.Diagnosis), "OK") { actionable += item.Count } } } return actionable, pipeline, sourceData } func collectDailyMileageSourceQuality(ctx context.Context, rawURL string, date string, timeout time.Duration, limit int) (*capacity.DailyMileageSourceQualityDiagnostics, []string) { requestURL, err := dailyMileageSourceQualityRequestURL(rawURL, date, limit) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source quality request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source quality request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source quality fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source quality read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("daily mileage source quality status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.DailyMileageSourceQualityItem `json:"items"` Total int64 `json:"total"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("daily mileage source quality decode failed: %v", err)} } diagnostics := &capacity.DailyMileageSourceQualityDiagnostics{ URL: requestURL, SummaryTotal: payload.Total, Items: payload.Items, ItemsTruncated: payload.Total > int64(len(payload.Items)), } for _, item := range payload.Items { diagnostics.SourceCount += item.SourceCount diagnostics.VehicleCount += item.VehicleCount diagnostics.SampleCount += item.SampleCount if strings.EqualFold(strings.TrimSpace(item.QualityReason), "current_day_fallback_after_invalid_baseline") { diagnostics.FallbackAfterInvalidBaselineSourceCount += item.SourceCount } if strings.EqualFold(strings.TrimSpace(item.QualityReason), "realtime_location_fallback_historical_baseline") { diagnostics.RealtimeLocationFallbackSourceCount += item.SourceCount } switch strings.ToUpper(strings.TrimSpace(item.QualityStatus)) { case "OK": diagnostics.OKSourceCount += item.SourceCount case "INVALID_DELTA": diagnostics.InvalidDeltaSourceCount += item.SourceCount case "NO_PREVIOUS_BASELINE": diagnostics.NoBaselineSourceCount += item.SourceCount } } if limit <= 0 || len(diagnostics.Items) == 0 { diagnostics.Items = nil } return diagnostics, nil } func collectDailyMileageSourceSelection(ctx context.Context, rawURL string, date string, timeout time.Duration, limit int, sampleLimit int) (*capacity.DailyMileageSourceSelectionDiagnostics, []string) { requestURL, err := dailyMileageSourceSelectionRequestURL(rawURL, date, limit) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("daily mileage source selection status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.DailyMileageSourceSelectionItem `json:"items"` Total int64 `json:"total"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection decode failed: %v", err)} } diagnostics := &capacity.DailyMileageSourceSelectionDiagnostics{ URL: requestURL, SummaryTotal: payload.Total, Items: payload.Items, ItemsTruncated: payload.Total > int64(len(payload.Items)), } protocols := map[string]capacity.DailyMileageSourceSelectionProtocol{} for _, item := range payload.Items { diagnostics.SourceCount += item.SourceCount diagnostics.VehicleCount += item.VehicleCount diagnostics.SampleCount += item.SampleCount protocolName := strings.ToUpper(strings.TrimSpace(item.Protocol)) protocolSummary := protocols[protocolName] protocolSummary.Protocol = protocolName protocolSummary.SourceCount += item.SourceCount protocolSummary.VehicleCount += item.VehicleCount protocolSummary.SampleCount += item.SampleCount switch strings.ToLower(strings.TrimSpace(item.SelectionStatus)) { case "selected": diagnostics.SelectedSourceCount += item.SourceCount protocolSummary.SelectedSourceCount += item.SourceCount case "excluded": diagnostics.ExcludedSourceCount += item.SourceCount protocolSummary.ExcludedSourceCount += item.SourceCount default: diagnostics.NotSelectedSourceCount += item.SourceCount diagnostics.NotSelectedMileageKM += item.DailyMileageKM protocolSummary.NotSelectedSourceCount += item.SourceCount protocolSummary.NotSelectedMileageKM += item.DailyMileageKM } reason := strings.ToLower(strings.TrimSpace(item.SelectionReason)) if strings.Contains(reason, "unmanaged") { diagnostics.UnmanagedSourceCount += item.SourceCount protocolSummary.UnmanagedSourceCount += item.SourceCount } if strings.Contains(reason, "unknown_source_kind") { diagnostics.UnknownKindSourceCount += item.SourceCount protocolSummary.UnknownKindSourceCount += item.SourceCount } if protocolName != "" { protocols[protocolName] = protocolSummary } } diagnostics.Protocols = dailyMileageSourceSelectionProtocolSummaries(protocols) if limit <= 0 || len(diagnostics.Items) == 0 { diagnostics.Items = nil } var findings []string if sampleLimit > 0 && (diagnostics.UnmanagedSourceCount > 0 || diagnostics.UnknownKindSourceCount > 0 || diagnostics.NotSelectedMileageKM > 0) { samples, sampleFindings := collectDailyMileageSourceSelectionSamples(ctx, rawURL, date, timeout, sampleLimit) diagnostics.Samples = samples findings = append(findings, sampleFindings...) } return diagnostics, findings } func dailyMileageSourceSelectionProtocolSummaries(values map[string]capacity.DailyMileageSourceSelectionProtocol) []capacity.DailyMileageSourceSelectionProtocol { if len(values) == 0 { return nil } protocols := make([]string, 0, len(values)) for protocol := range values { protocols = append(protocols, protocol) } sort.Strings(protocols) out := make([]capacity.DailyMileageSourceSelectionProtocol, 0, len(protocols)) for _, protocol := range protocols { out = append(out, values[protocol]) } return out } func collectDailyMileageSourceSelectionSamples(ctx context.Context, rawURL string, date string, timeout time.Duration, sampleLimit int) ([]capacity.DailyMileageSourceSelectionSample, []string) { fetchLimit := dailyMileageSourceSelectionSampleFetchLimit(sampleLimit) requestURL, err := dailyMileageSourceSelectionSampleRequestURL(rawURL, date, fetchLimit) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection sample request invalid: %v", err)} } samples, err := fetchDailyMileageSourceSelectionSamples(ctx, requestURL, timeout) if err != nil { return nil, []string{fmt.Sprintf("daily mileage source selection sample fetch failed: %v", err)} } out := make([]capacity.DailyMileageSourceSelectionSample, 0, sampleLimit) for _, sample := range samples { if !isProblematicDailyMileageSourceSelectionSample(sample) { continue } out = append(out, sample) if len(out) >= sampleLimit { break } } findings := enrichDailyMileageSourceSelectionSamples(ctx, rawURL, date, timeout, out) return out, findings } func enrichDailyMileageSourceSelectionSamples(ctx context.Context, rawURL string, date string, timeout time.Duration, samples []capacity.DailyMileageSourceSelectionSample) []string { var findings []string for index := range samples { requestURL, err := dailyMileageSelectedSourceSampleRequestURL(rawURL, date, samples[index], 1) if err != nil { findings = append(findings, fmt.Sprintf("daily mileage selected source sample request invalid for vin %s: %v", samples[index].VIN, err)) continue } samples[index].SelectedSourceQueryPath = requestPathWithQuery(requestURL) selected, err := fetchDailyMileageSourceSelectionSamples(ctx, requestURL, timeout) if err != nil { findings = append(findings, fmt.Sprintf("daily mileage selected source sample fetch failed for vin %s: %v", samples[index].VIN, err)) continue } if len(selected) == 0 { continue } samples[index].SelectedSource = &selected[0] } return findings } func fetchDailyMileageSourceSelectionSamples(ctx context.Context, requestURL string, timeout time.Duration) ([]capacity.DailyMileageSourceSelectionSample, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, err } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("status %d: %s", response.StatusCode, strings.TrimSpace(string(body))) } var payload struct { Items []capacity.DailyMileageSourceSelectionSample `json:"items"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, err } return payload.Items, nil } func isProblematicDailyMileageSourceSelectionSample(sample capacity.DailyMileageSourceSelectionSample) bool { reason := strings.ToLower(strings.TrimSpace(sample.SelectionReason)) status := strings.ToLower(strings.TrimSpace(sample.SelectionStatus)) return strings.Contains(reason, "unmanaged") || strings.Contains(reason, "unknown_source_kind") || (status == "not_selected" && sample.DailyMileageKM > 0) } func dailyMileageSourceSelectionSampleFetchLimit(sampleLimit int) int { if sampleLimit <= 0 { return 0 } fetchLimit := sampleLimit * 20 if fetchLimit < sampleLimit { fetchLimit = sampleLimit } if fetchLimit > 1000 { return 1000 } return fetchLimit } func collectDataSourceDiagnostics(ctx context.Context, rawURL string, timeout time.Duration, sampleLimit int, reasonLimit int, recentAgeSeconds int64) (*capacity.DataSourceDiagnostics, []string) { fetchLimit := dataSourceDiagnosticsFetchLimit(sampleLimit, reasonLimit) requestURL, err := dataSourceDiagnosticsRequestURL(rawURL, fetchLimit) if err != nil { return nil, []string{fmt.Sprintf("data source diagnostics request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("data source diagnostics request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("data source diagnostics fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("data source diagnostics read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("data source diagnostics status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.DataSourceDiagnosticSample `json:"items"` Total int64 `json:"total"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("data source diagnostics decode failed: %v", err)} } reasonCounts, actionableCandidates := summarizeDataSourceDiagnosticReasons(payload.Items) recentMissing := countRecentDataSourceDiagnostics(payload.Items, recentAgeSeconds) samples := payload.Items if sampleLimit <= 0 || len(samples) == 0 { samples = nil } else if len(samples) > sampleLimit { samples = samples[:sampleLimit] } if reasonLimit <= 0 { reasonCounts = nil actionableCandidates = 0 } diagnostics := &capacity.DataSourceDiagnostics{ URL: requestURL, MissingSourceCodeTotal: payload.Total, RecentMissingSourceCodeTotal: recentMissing, RecentAgeThresholdSeconds: normalizedRecentDataSourceAgeThreshold(recentAgeSeconds), ActionableCandidateTotal: actionableCandidates, ReasonCounts: reasonCounts, ReasonCountsTruncated: reasonLimit > 0 && payload.Total > int64(len(payload.Items)), Samples: samples, } mappingDiagnostics, mappingFindings := collectDataSourceMappingIssueDiagnostics(ctx, rawURL, timeout, sampleLimit, reasonLimit, recentAgeSeconds) if mappingDiagnostics != nil { diagnostics.MappingIssueTotal = mappingDiagnostics.MappingIssueTotal diagnostics.RecentMappingIssueTotal = mappingDiagnostics.RecentMappingIssueTotal diagnostics.MappingIssueReasonCounts = mappingDiagnostics.MappingIssueReasonCounts diagnostics.MappingIssueReasonCountsTruncated = mappingDiagnostics.MappingIssueReasonCountsTruncated diagnostics.MappingIssueSamples = mappingDiagnostics.MappingIssueSamples } return diagnostics, mappingFindings } func collectDataSourceMappingIssueDiagnostics(ctx context.Context, rawURL string, timeout time.Duration, sampleLimit int, reasonLimit int, recentAgeSeconds int64) (*capacity.DataSourceDiagnostics, []string) { fetchLimit := dataSourceDiagnosticsFetchLimit(sampleLimit, reasonLimit) requestURL, err := dataSourceMappingIssuesRequestURL(rawURL, fetchLimit) if err != nil { return nil, []string{fmt.Sprintf("data source mapping diagnostics request invalid: %v", err)} } payload, findings := fetchDataSourceDiagnosticPage(ctx, requestURL, timeout, "data source mapping diagnostics") if len(findings) > 0 { return nil, findings } reasonCounts, _ := summarizeDataSourceDiagnosticReasons(payload.Items) recentMappingIssues := countRecentDataSourceDiagnostics(payload.Items, recentAgeSeconds) samples := payload.Items if sampleLimit <= 0 || len(samples) == 0 { samples = nil } else if len(samples) > sampleLimit { samples = samples[:sampleLimit] } if reasonLimit <= 0 { reasonCounts = nil } return &capacity.DataSourceDiagnostics{ MappingIssueTotal: payload.Total, RecentMappingIssueTotal: recentMappingIssues, RecentAgeThresholdSeconds: normalizedRecentDataSourceAgeThreshold(recentAgeSeconds), MappingIssueReasonCounts: reasonCounts, MappingIssueReasonCountsTruncated: reasonLimit > 0 && payload.Total > int64(len(payload.Items)), MappingIssueSamples: samples, }, nil } func collectDataSourceKindSuggestions(ctx context.Context, rawURL string, timeout time.Duration, protocols []string, sampleLimit int, reasonLimit int, recentAgeSeconds int64) (*capacity.DataSourceDiagnostics, []string) { fetchLimit := dataSourceDiagnosticsFetchLimit(sampleLimit, reasonLimit) if len(protocols) == 0 { protocols = parseDataSourceKindSuggestionProtocols(defaultDataSourceKindSuggestionProtocols) } var findings []string var samples []capacity.DataSourceDiagnosticSample var unknownTotal int64 var recentUnknown int64 var platformCandidates int64 var recentPlatformCandidates int64 reasonCounts := map[string]int64{} reasonSummaryEnabled := reasonLimit > 0 reasonCountsTruncated := false var firstURL string for _, protocol := range protocols { requestURL, err := dataSourceKindSuggestionsRequestURL(rawURL, protocol, fetchLimit) if err != nil { findings = append(findings, fmt.Sprintf("data source kind suggestions request invalid for %s: %v", protocol, err)) continue } if firstURL == "" { firstURL = requestURL } payload, fetchFindings := fetchDataSourceDiagnosticPage(ctx, requestURL, timeout, "data source kind suggestions") if len(fetchFindings) > 0 { findings = append(findings, fetchFindings...) continue } unknownTotal += payload.Total protocolRecentUnknown := countRecentDataSourceDiagnostics(payload.Items, recentAgeSeconds) recentUnknown += protocolRecentUnknown protocolReasonCounts, protocolPlatformCandidates, protocolRecentPlatformCandidates := summarizeDataSourceKindSuggestionReasons(payload.Items, recentAgeSeconds) platformCandidates += protocolPlatformCandidates recentPlatformCandidates += protocolRecentPlatformCandidates if reasonSummaryEnabled { mergeInt64Counts(reasonCounts, protocolReasonCounts) reasonCountsTruncated = reasonCountsTruncated || payload.Total > int64(len(payload.Items)) } if sampleLimit > 0 && len(samples) < sampleLimit { for _, item := range payload.Items { if !isPlatformKindCandidate(item) { continue } samples = append(samples, item) if len(samples) >= sampleLimit { break } } } } if !reasonSummaryEnabled || len(reasonCounts) == 0 { reasonCounts = nil } if sampleLimit <= 0 || len(samples) == 0 { samples = nil } return &capacity.DataSourceDiagnostics{ URL: firstURL, UnknownSourceKindTotal: unknownTotal, RecentUnknownSourceKindTotal: recentUnknown, RecentAgeThresholdSeconds: normalizedRecentDataSourceAgeThreshold(recentAgeSeconds), PlatformKindCandidateTotal: platformCandidates, RecentPlatformKindCandidateTotal: recentPlatformCandidates, ReasonCounts: reasonCounts, ReasonCountsTruncated: reasonCountsTruncated, Samples: samples, }, findings } func collectJT808IdentityGaps(ctx context.Context, rawURL string, timeout time.Duration, recentSeconds int64, sampleLimit int) (*capacity.JT808IdentityGapDiagnostics, []string) { requestURL, err := jt808IdentityGapsRequestURL(rawURL, recentSeconds, sampleLimit) if err != nil { return nil, []string{fmt.Sprintf("JT808 identity gaps request invalid: %v", err)} } request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, []string{fmt.Sprintf("JT808 identity gaps request invalid: %v", err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, []string{fmt.Sprintf("JT808 identity gaps fetch failed: %v", err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, []string{fmt.Sprintf("JT808 identity gaps read failed: %v", err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, []string{fmt.Sprintf("JT808 identity gaps status %d: %s", response.StatusCode, strings.TrimSpace(string(body)))} } var payload struct { Items []capacity.JT808IdentityGapSample `json:"items"` Total int64 `json:"total"` RecentSeconds int64 `json:"recentSeconds"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, []string{fmt.Sprintf("JT808 identity gaps decode failed: %v", err)} } samples := payload.Items if sampleLimit <= 0 || len(samples) == 0 { samples = nil } else if len(samples) > sampleLimit { samples = samples[:sampleLimit] } return &capacity.JT808IdentityGapDiagnostics{ URL: requestURL, Total: payload.Total, RecentSeconds: payload.RecentSeconds, Samples: samples, }, nil } type dataSourceDiagnosticPayload struct { Items []capacity.DataSourceDiagnosticSample `json:"items"` Total int64 `json:"total"` } func fetchDataSourceDiagnosticPage(ctx context.Context, requestURL string, timeout time.Duration, label string) (dataSourceDiagnosticPayload, []string) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return dataSourceDiagnosticPayload{}, []string{fmt.Sprintf("%s request invalid: %v", label, err)} } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return dataSourceDiagnosticPayload{}, []string{fmt.Sprintf("%s fetch failed: %v", label, err)} } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return dataSourceDiagnosticPayload{}, []string{fmt.Sprintf("%s read failed: %v", label, err)} } if response.StatusCode < 200 || response.StatusCode >= 300 { return dataSourceDiagnosticPayload{}, []string{fmt.Sprintf("%s status %d: %s", label, response.StatusCode, strings.TrimSpace(string(body)))} } var payload dataSourceDiagnosticPayload if err := json.Unmarshal(body, &payload); err != nil { return dataSourceDiagnosticPayload{}, []string{fmt.Sprintf("%s decode failed: %v", label, err)} } return payload, nil } func dataSourceDiagnosticsFetchLimit(sampleLimit int, reasonLimit int) int { limit := sampleLimit if reasonLimit > limit { limit = reasonLimit } if limit < 0 { return 0 } return limit } func summarizeDataSourceDiagnosticReasons(items []capacity.DataSourceDiagnosticSample) (map[string]int64, int64) { if len(items) == 0 { return nil, 0 } counts := make(map[string]int64) var actionable int64 for _, item := range items { reason := strings.TrimSpace(item.Reason) if reason == "" { reason = "unknown" } counts[reason]++ if reason == "candidate_available" { actionable++ } } return counts, actionable } func countRecentDataSourceDiagnostics(items []capacity.DataSourceDiagnosticSample, recentAgeSeconds int64) int64 { if recentAgeSeconds <= 0 { return 0 } var count int64 for _, item := range items { if item.LatestSeenAgeSeconds <= recentAgeSeconds { count++ } } return count } func summarizeDataSourceKindSuggestionReasons(items []capacity.DataSourceDiagnosticSample, recentAgeSeconds int64) (map[string]int64, int64, int64) { if len(items) == 0 { return nil, 0, 0 } counts := make(map[string]int64) var platformCandidates int64 var recentPlatformCandidates int64 for _, item := range items { reason := strings.TrimSpace(item.SuggestionReason) if reason == "" { reason = strings.TrimSpace(item.Reason) } if reason == "" { reason = "unknown" } counts[reason]++ if !isPlatformKindCandidate(item) { continue } platformCandidates++ if recentAgeSeconds > 0 && item.LatestSeenAgeSeconds <= recentAgeSeconds { recentPlatformCandidates++ } } return counts, platformCandidates, recentPlatformCandidates } func isPlatformKindCandidate(item capacity.DataSourceDiagnosticSample) bool { if !strings.EqualFold(strings.TrimSpace(item.SuggestedSourceKind), "PLATFORM") { return false } return !strings.EqualFold(strings.TrimSpace(item.SuggestionConfidence), "LOW") } func mergeInt64Counts(dst map[string]int64, src map[string]int64) { for key, value := range src { if key == "" || value == 0 { continue } dst[key] += value } } func normalizedRecentDataSourceAgeThreshold(recentAgeSeconds int64) int64 { if recentAgeSeconds <= 0 { return 0 } return recentAgeSeconds } func collectDailyMileageDiagnosisSamples(ctx context.Context, rawURL string, date string, timeout time.Duration, items []capacity.DailyMileageDiagnosisReasonItem, sampleLimit int) ([]capacity.DailyMileageDiagnosisSample, []string) { if sampleLimit <= 0 { return nil, nil } var samples []capacity.DailyMileageDiagnosisSample var findings []string seen := make(map[string]struct{}) for _, item := range items { if len(samples) >= sampleLimit { break } if capacity.DailyMileageIssueSeverity(item) == capacity.DailyMileageSeverityOK || item.Count <= 0 { continue } key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "|" + strings.ToUpper(strings.TrimSpace(item.Diagnosis)) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} requestURL, err := dailyMileageDiagnosticsSampleRequestURL(rawURL, date, item, sampleLimit-len(samples)) if err != nil { findings = append(findings, fmt.Sprintf("daily mileage diagnostics sample request invalid: %v", err)) continue } fetched, err := fetchDailyMileageDiagnosisSamples(ctx, requestURL, timeout) if err != nil { findings = append(findings, fmt.Sprintf("daily mileage diagnostics sample fetch failed: %v", err)) continue } for _, sample := range fetched { if len(samples) >= sampleLimit { break } sample.Severity = capacity.DailyMileageIssueSeverity(capacity.DailyMileageDiagnosisReasonItem{ Diagnosis: sample.Diagnosis, Reason: sample.Reason, Count: 1, }) if strings.TrimSpace(sample.RecommendedOperatorAction) == "" { sample.RecommendedOperatorAction = capacity.DailyMileageRecommendedOperatorAction(sample.Diagnosis, sample.Reason) } samples = append(samples, sample) } } return samples, findings } func fetchDailyMileageDiagnosisSamples(ctx context.Context, requestURL string, timeout time.Duration) ([]capacity.DailyMileageDiagnosisSample, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, err } response, err := (&http.Client{Timeout: timeout}).Do(request) if err != nil { return nil, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("status %d: %s", response.StatusCode, strings.TrimSpace(string(body))) } var payload struct { Items []capacity.DailyMileageDiagnosisSample `json:"items"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, err } return payload.Items, nil } func dailyMileageDiagnosticsRequestURL(rawURL string, date string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDailyMileageDiagnosticsPath } query := parsed.Query() if date = strings.TrimSpace(date); date != "" { query.Set("date", date) } parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageFieldStatusRequestURL(rawURL string, date string) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } switch { case parsed.Path == "" || parsed.Path == "/": parsed.Path = defaultDailyMileageFieldStatusPath case strings.HasSuffix(parsed.Path, "/reasons"): parsed.Path = strings.TrimSuffix(parsed.Path, "/reasons") + "/field-status" case strings.HasSuffix(parsed.Path, "/summary"): parsed.Path = strings.TrimSuffix(parsed.Path, "/summary") + "/field-status" case strings.HasSuffix(parsed.Path, "/diagnostics"): parsed.Path = parsed.Path + "/field-status" case strings.HasSuffix(parsed.Path, "/field-status"): default: parsed.Path = defaultDailyMileageFieldStatusPath } query := parsed.Query() if date = strings.TrimSpace(date); date != "" { query.Set("date", date) } parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageSourceQualityRequestURL(rawURL string, date string, limit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDailyMileageSourceQualityPath } limit = normalizedPositiveLimit(limit) statDate := dailyMileageSourceQualityDate(date) query := parsed.Query() query.Set("dateFrom", statDate) query.Set("dateTo", statDate) query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageSourceSelectionRequestURL(rawURL string, date string, limit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDailyMileageSourceSelectionPath } limit = normalizedPositiveLimit(limit) statDate := dailyMileageSourceQualityDate(date) query := parsed.Query() query.Set("dateFrom", statDate) query.Set("dateTo", statDate) query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageSourceSelectionSampleRequestURL(rawURL string, date string, limit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } path := strings.TrimRight(parsed.Path, "/") switch { case path == "": parsed.Path = defaultDailyMileageSourcesPath case strings.HasSuffix(path, "/selection"): parsed.Path = strings.TrimSuffix(path, "/selection") case strings.HasSuffix(path, "/quality"): parsed.Path = strings.TrimSuffix(path, "/quality") case path == defaultDailyMileageSourcesPath: parsed.Path = defaultDailyMileageSourcesPath default: parsed.Path = defaultDailyMileageSourcesPath } limit = normalizedPositiveLimit(limit) statDate := dailyMileageSourceQualityDate(date) query := parsed.Query() query.Set("dateFrom", statDate) query.Set("dateTo", statDate) query.Set("selected", "false") query.Set("includeTotal", "false") query.Set("orderBy", "dailyMileageDesc") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageSelectedSourceSampleRequestURL(rawURL string, date string, sample capacity.DailyMileageSourceSelectionSample, limit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } parsed.Path = defaultDailyMileageSourcesPath limit = normalizedPositiveLimit(limit) statDate := dailyMileageSourceQualityDate(date) query := parsed.Query() query.Set("dateFrom", statDate) query.Set("dateTo", statDate) query.Set("selected", "true") query.Set("includeTotal", "false") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") if vin := strings.TrimSpace(sample.VIN); vin != "" { query.Set("vin", vin) } if protocol := strings.TrimSpace(sample.Protocol); protocol != "" { query.Set("protocol", protocol) } parsed.RawQuery = query.Encode() return parsed.String(), nil } func requestPathWithQuery(requestURL string) string { parsed, err := url.Parse(requestURL) if err != nil { return requestURL } if parsed.RawQuery == "" { return parsed.EscapedPath() } return parsed.EscapedPath() + "?" + parsed.RawQuery } func dailyMileageSourceQualityDate(date string) string { if date = strings.TrimSpace(date); date != "" { return date } return time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02") } func normalizedPositiveLimit(limit int) int { if limit <= 0 { return 1 } return limit } func dataSourceDiagnosticsRequestURL(rawURL string, sampleLimit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDataSourceDiagnosticsPath } limit := normalizedPositiveLimit(sampleLimit) query := parsed.Query() query.Set("sourceCodeMissing", "true") query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func dataSourceMappingIssuesRequestURL(rawURL string, sampleLimit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDataSourceDiagnosticsPath } limit := normalizedPositiveLimit(sampleLimit) query := parsed.Query() query.Set("sourceCodeMissing", "false") query.Set("mappingIssueOnly", "true") query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func dataSourceKindSuggestionsRequestURL(rawURL string, protocol string, sampleLimit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultDataSourceKindSuggestionsPath } limit := normalizedPositiveLimit(sampleLimit) query := parsed.Query() query.Set("protocol", strings.ToUpper(strings.TrimSpace(protocol))) query.Set("sourceKind", "UNKNOWN") query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil } func jt808IdentityGapsRequestURL(rawURL string, recentSeconds int64, sampleLimit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } if parsed.Path == "" || parsed.Path == "/" { parsed.Path = defaultJT808IdentityGapsPath } limit := normalizedPositiveLimit(sampleLimit) query := parsed.Query() query.Set("includeTotal", "true") query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") if recentSeconds >= 0 { query.Set("recentSeconds", fmt.Sprintf("%d", recentSeconds)) } parsed.RawQuery = query.Encode() return parsed.String(), nil } func dailyMileageDiagnosticsSampleRequestURL(rawURL string, date string, item capacity.DailyMileageDiagnosisReasonItem, limit int) (string, error) { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid URL %q", rawURL) } switch { case parsed.Path == "" || parsed.Path == "/": parsed.Path = "/api/stats/daily-metrics/diagnostics" case strings.HasSuffix(parsed.Path, "/reasons"): parsed.Path = strings.TrimSuffix(parsed.Path, "/reasons") case strings.HasSuffix(parsed.Path, "/summary"): parsed.Path = strings.TrimSuffix(parsed.Path, "/summary") } if !strings.HasSuffix(parsed.Path, "/diagnostics") { parsed.Path = "/api/stats/daily-metrics/diagnostics" } query := parsed.Query() query.Del("reason") if date == "" { date = item.StatDate } if date != "" { query.Set("date", date) } query.Set("protocol", strings.ToUpper(strings.TrimSpace(item.Protocol))) query.Set("diagnosis", strings.ToUpper(strings.TrimSpace(item.Diagnosis))) query.Set("limit", fmt.Sprintf("%d", limit)) query.Set("offset", "0") parsed.RawQuery = query.Encode() return parsed.String(), nil }