Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/capacity-check/main.go
2026-07-16 06:48:47 +08:00

1861 lines
98 KiB
Go

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.HistoryLocationErrorRecentSec, "history-location-error-recent-seconds", thresholds.HistoryLocationErrorRecentSec, "only degrade on history location write errors observed within this many seconds; <=0 evaluates the lifetime counter")
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
}