diff --git a/docker-compose.yml b/docker-compose.yml index 68c8b8df..a0735e1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,24 @@ services: + polyweather_redis: + image: redis:7-alpine + container_name: polyweather_redis + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy noeviction + restart: unless-stopped + healthcheck: + interval: 10s + retries: 5 + test: + - CMD + - redis-cli + - ping + timeout: 5s + volumes: + - polyweather_redis_data:/data polyweather: container_name: polyweather_bot + depends_on: + polyweather_redis: + condition: service_healthy logging: driver: "json-file" options: @@ -52,10 +70,8 @@ services: interval: 30s retries: 3 test: - - CMD - - wget - - -qO- - - http://localhost:3000 + - CMD-SHELL + - wget -qO- http://$(hostname):3000 timeout: 5s image: ghcr.io/yangyuan-zhen/polyweather-frontend:${IMAGE_TAG:-latest} ports: @@ -63,6 +79,9 @@ services: restart: unless-stopped polyweather_web: command: python web/app.py + depends_on: + polyweather_redis: + condition: service_healthy logging: driver: "json-file" options: @@ -90,3 +109,5 @@ services: x-polyweather-base: env_file: *id001 image: ghcr.io/yangyuan-zhen/polyweather-backend:${IMAGE_TAG:-latest} +volumes: + polyweather_redis_data: diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index c5f6e402..831ddb39 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -1109,6 +1109,39 @@ export function runTests() { "Full-day chart should start at local 00:00 when the DEB hourly path has a midnight point", ); + const qingdaoPartialDetailFullDay = __buildTemperatureChartDataForTest( + { + city: "qingdao", + local_date: "2026-05-26", + local_time: "11:11", + tz_offset_seconds: 8 * 60 * 60, + deb_prediction: 22, + runway_plate_history: { + "16/34": [ + { time: "2026-05-26T00:05:00+08:00", temp: 23.5 }, + { time: "2026-05-26T09:28:00+08:00", temp: 19.8 }, + { time: "2026-05-26T11:11:00+08:00", temp: 19.8 }, + ], + }, + } as any, + { + localDate: "2026-05-26", + localTime: "11:11", + times: ["00:00", "06:00", "09:00", "10:00"], + temps: [22, 18.5, 19.1, 19.6], + debPrediction: 22, + } as any, + "1D", + ); + assert( + qingdaoPartialDetailFullDay.data[0]?.ts === qingdaoDayStart, + "All-day view should keep the local-day start even when Qingdao detail data is partial", + ); + assert( + qingdaoPartialDetailFullDay.data.some((point) => point.ts === qingdaoDayEnd - 60 * 60 * 1000), + "All-day view should keep an end-of-day axis slot even when Qingdao detail data only reaches the morning", + ); + const chongqingRolledToNextDay = __buildTemperatureChartDataForTest( { city: "chongqing", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 60c56482..0ae33667 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -1320,6 +1320,13 @@ function sortedTimeline(timestamps: Iterable) { return Array.from(new Set(Array.from(timestamps).filter((ts) => Number.isFinite(ts)))).sort((a, b) => a - b); } +function addLocalDayAxisSlots(timeline: Set, bounds: LocalDayBounds | null) { + if (!bounds) return; + for (let ts = bounds.start; ts < bounds.end; ts += 60 * 60 * 1000) { + timeline.add(ts); + } +} + function resolveFullDayFallbackAnchor( row: ScanOpportunityRow | null, hourly: HourlyForecast, @@ -1582,6 +1589,7 @@ function buildFullDayChartData( finalSettlementObs.forEach((point) => timelineSet.add(point.ts)); if (!isRunwaySensorAggregateSource) finalMadisObs.forEach((point) => timelineSet.add(point.ts)); if (shouldRenderMetar) metarObs.forEach((point) => timelineSet.add(point.ts)); + addLocalDayAxisSlots(timelineSet, localDayBounds); let debPath: ReturnType | null = null; if (hourly?.times?.length && hourly?.temps?.length) { diff --git a/web/redis_realtime_event_store.py b/web/redis_realtime_event_store.py index d64baa4e..59e81847 100644 --- a/web/redis_realtime_event_store.py +++ b/web/redis_realtime_event_store.py @@ -18,6 +18,8 @@ from web.realtime_patch_schema import EVENT_TYPE DEFAULT_STREAM_KEY = "stream:city_observation" DEFAULT_COUNTER_KEY = "counter:city_observation_revision" DEFAULT_MAXLEN = 50000 +DEFAULT_SOCKET_TIMEOUT_SECONDS = 15.0 +DEFAULT_SOCKET_CONNECT_TIMEOUT_SECONDS = 5.0 APPEND_EVENT_SCRIPT = """ local revision = redis.call('INCR', KEYS[2]) @@ -60,6 +62,13 @@ def _int_or_zero(value: Any) -> int: return 0 +def _float_env(name: str, default: float) -> float: + try: + return float(os.getenv(name) or default) + except (TypeError, ValueError): + return default + + class RedisRealtimeEventStore: """Persist replayable observation patch events in a Redis Stream.""" @@ -94,8 +103,11 @@ class RedisRealtimeEventStore: url = redis_url or os.getenv("POLYWEATHER_REDIS_URL") or "redis://127.0.0.1:6379/0" client = redis.Redis.from_url( url, - socket_timeout=5, - socket_connect_timeout=5, + socket_timeout=_float_env("POLYWEATHER_REDIS_SOCKET_TIMEOUT_SECONDS", DEFAULT_SOCKET_TIMEOUT_SECONDS), + socket_connect_timeout=_float_env( + "POLYWEATHER_REDIS_SOCKET_CONNECT_TIMEOUT_SECONDS", + DEFAULT_SOCKET_CONNECT_TIMEOUT_SECONDS, + ), health_check_interval=30, ) client.ping()