From 8ca9ce4223dc28287d470d370488a9cf0dad00e0 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 26 May 2026 05:20:36 +0800 Subject: [PATCH] feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration --- deploy/nginx/polyweather.conf | 40 ++++++++++++ frontend/Dockerfile | 25 ++++++-- .../dashboard/ScanTerminalDashboard.tsx | 30 ++++++++- .../__tests__/cityFallbackRows.test.ts | 58 +++++++++++++++++ .../scan-terminal/city-fallback-rows.ts | 63 +++++++++++++++++++ frontend/next.config.mjs | 1 + frontend/prod.Dockerfile | 27 +++----- tests/test_deployment_runtime_config.py | 38 +++++++++++ web/app_factory.py | 11 +++- 9 files changed, 265 insertions(+), 28 deletions(-) create mode 100644 deploy/nginx/polyweather.conf create mode 100644 frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts create mode 100644 frontend/components/dashboard/scan-terminal/city-fallback-rows.ts create mode 100644 tests/test_deployment_runtime_config.py diff --git a/deploy/nginx/polyweather.conf b/deploy/nginx/polyweather.conf new file mode 100644 index 00000000..a5b38b5e --- /dev/null +++ b/deploy/nginx/polyweather.conf @@ -0,0 +1,40 @@ +server { + listen 443 ssl http2; + server_name polyweather.top www.polyweather.top; + + # Supabase auth can set multiple chunked session cookies during OAuth + # callback. Nginx defaults are too small and can raise: + # "upstream sent too big header while reading response header from upstream". + proxy_buffer_size 16k; + proxy_buffers 8 16k; + proxy_busy_buffers_size 32k; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +server { + listen 443 ssl http2; + server_name api.polyweather.top; + + proxy_buffer_size 16k; + proxy_buffers 8 16k; + proxy_busy_buffers_size 32k; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 949d756b..01263524 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,10 @@ -FROM node:20-alpine +FROM node:20-alpine AS deps + +WORKDIR /app/frontend +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:20-alpine AS builder ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY @@ -13,9 +19,18 @@ ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS WORKDIR /app/frontend -COPY package.json package-lock.json ./ -RUN npm ci +COPY --from=deps /app/frontend/node_modules ./node_modules COPY . ./ -RUN npm run build +RUN npm run build && npm prune --omit=dev + +FROM node:20-alpine AS runner + +ENV NODE_ENV=production +WORKDIR /app/frontend + +COPY --from=builder /app/frontend/public ./public +COPY --from=builder /app/frontend/.next/standalone ./ +COPY --from=builder /app/frontend/.next/static ./.next/static + EXPOSE 3000 -CMD ["npm", "run", "start"] +CMD ["node", "server.js"] diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 64446168..2bc43569 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -14,7 +14,7 @@ import { UserRound, } from "lucide-react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; +import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; import { getInitialLocaleFromNavigator } from "@/lib/i18n"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; @@ -48,6 +48,7 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown"; import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector"; +import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows"; function createEmptyAccess(loading = true): ProAccessState { return { @@ -1024,9 +1025,32 @@ function ScanTerminalScreen() { timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null, tradingRegion: selectedRegionKey, }); + const [cityFallbackRows, setCityFallbackRows] = useState([]); + useEffect(() => { + if (!isPro || typeof fetch !== "function") return; + const controller = new AbortController(); + fetch("/api/cities", { + cache: "no-store", + headers: { Accept: "application/json" }, + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) return null; + return response.json() as Promise<{ cities?: CityListItem[] }>; + }) + .then((payload) => { + if (!payload || !Array.isArray(payload.cities)) return; + setCityFallbackRows(cityListItemsToScanRows(payload.cities)); + }) + .catch(() => {}); + return () => controller.abort(); + }, [isPro]); const rows = useMemo( - () => sortRowsByUserTime(terminalData?.rows || []), - [terminalData?.rows], + () => { + const scanRows = terminalData?.rows || []; + return sortRowsByUserTime(scanRows.length ? scanRows : cityFallbackRows); + }, + [cityFallbackRows, terminalData?.rows], ); const [searchQuery, setSearchQuery] = useState(""); const searchInputRef = useRef(null); diff --git a/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts new file mode 100644 index 00000000..43aad277 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/__tests__/cityFallbackRows.test.ts @@ -0,0 +1,58 @@ +import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows"; +import type { CityListItem } from "@/lib/dashboard-types"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +export function runTests() { + const fs = require("node:fs") as typeof import("node:fs"); + const path = require("node:path") as typeof import("node:path"); + const dashboardSource = fs.readFileSync( + path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"), + "utf8", + ); + + const cities: CityListItem[] = [ + { + airport: "Taipei Songshan", + display_name: "Taipei", + icao: "RCSS", + lat: 25.0697, + lon: 121.5525, + name: "taipei", + risk_level: "medium", + temp_unit: "celsius", + utc_offset_seconds: 28800, + }, + { + airport: "LaGuardia", + display_name: "New York", + icao: "KLGA", + lat: 40.7769, + lon: -73.874, + name: "new york", + risk_level: "low", + temp_unit: "fahrenheit", + utc_offset_seconds: -14400, + }, + ]; + + const rows = cityListItemsToScanRows(cities); + + assert(rows.length === 2, "fallback rows should preserve every city"); + assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable"); + assert(rows[0].city === "taipei", "fallback row should keep canonical city key"); + assert(rows[0].city_display_name === "Taipei", "fallback row should keep display name"); + assert(rows[0].airport === "Taipei Songshan", "fallback row should expose airport for selector display"); + assert(rows[0].trading_region === "east_asia", "fallback row should derive region from timezone"); + assert(rows[0].temp_symbol === "°C", "celsius cities should use °C"); + assert(rows[1].trading_region === "north_america", "known cities should keep their configured product region"); + assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F"); + assert( + dashboardSource.includes("cityListItemsToScanRows") && + dashboardSource.includes("/api/cities") && + dashboardSource.includes("cityFallbackRows"), + "terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready", + ); +} diff --git a/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts b/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts new file mode 100644 index 00000000..474f78c1 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/city-fallback-rows.ts @@ -0,0 +1,63 @@ +import type { CityListItem, ScanOpportunityRow } from "@/lib/dashboard-types"; +import { + REGIONS, + getCityRegion, + type RegionKey, +} from "@/components/dashboard/scan-terminal/continent-grouping"; + +function normalizeCityKey(value: string) { + return String(value || "").trim().toLowerCase(); +} + +function tempSymbolForCity(city: CityListItem) { + return city.temp_unit === "fahrenheit" ? "°F" : "°C"; +} + +function regionFromTzOffset(tzOffsetSeconds: number): (typeof REGIONS)[number] { + const hours = tzOffsetSeconds / 3600; + if (hours >= 8) return REGIONS[0]; + if (hours >= 7) return REGIONS[1]; + if (hours >= 4.5) return REGIONS[2]; + if (hours >= 2) return REGIONS[3]; + if (hours >= -2) return REGIONS[4]; + if (hours >= -4) return REGIONS[5]; + return REGIONS[6]; +} + +function resolveRegion(cityKey: string, tzOffsetSeconds: number) { + const configuredRegion = getCityRegion({ city: cityKey, id: `region:${cityKey}` } as ScanOpportunityRow); + if (configuredRegion) { + return REGIONS.find((region) => region.key === configuredRegion) || regionFromTzOffset(tzOffsetSeconds); + } + return regionFromTzOffset(tzOffsetSeconds); +} + +export function cityListItemsToScanRows(cities: CityListItem[]): ScanOpportunityRow[] { + return cities + .filter((city) => normalizeCityKey(city.name)) + .map((city) => { + const cityKey = normalizeCityKey(city.name); + const region = resolveRegion(cityKey, city.utc_offset_seconds ?? 0); + return { + active: true, + airport: city.airport || city.icao || null, + city: cityKey, + city_display_name: city.display_name || city.name, + closed: false, + current_temp: null, + deb_prediction: null, + display_name: city.display_name || city.name, + id: `city-fallback:${cityKey}`, + is_primary_signal: true, + local_time: null, + risk_level: city.risk_level || null, + temp_symbol: tempSymbolForCity(city), + tradable: false, + trading_region: region.key as RegionKey, + trading_region_label: region.labelEn, + trading_region_label_zh: region.labelZh, + trading_region_sort: region.sort, + tz_offset_seconds: city.utc_offset_seconds ?? 0, + }; + }); +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index b8329483..6b7bc6ba 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + output: "standalone", reactStrictMode: true, async headers() { const cacheHeader = { diff --git a/frontend/prod.Dockerfile b/frontend/prod.Dockerfile index ba07e183..04980097 100644 --- a/frontend/prod.Dockerfile +++ b/frontend/prod.Dockerfile @@ -1,22 +1,11 @@ -FROM node:20-alpine - -ARG NEXT_PUBLIC_SUPABASE_URL -ARG NEXT_PUBLIC_SUPABASE_ANON_KEY -ARG NEXT_PUBLIC_SITE_URL -ARG NEXT_PUBLIC_POLYWEATHER_API_BASE_URL -ARG NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS - -ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL -ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY -ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL -ENV NEXT_PUBLIC_POLYWEATHER_API_BASE_URL=$NEXT_PUBLIC_POLYWEATHER_API_BASE_URL -ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS +FROM node:20-alpine AS runner +ENV NODE_ENV=production WORKDIR /app/frontend -COPY package.json package-lock.json ./ -RUN npm ci -COPY .next .next/ -COPY public public/ -COPY next.config.mjs ./ + +COPY public ./public +COPY .next/standalone ./ +COPY .next/static ./.next/static + EXPOSE 3000 -CMD ["npm", "run", "start"] +CMD ["node", "server.js"] diff --git a/tests/test_deployment_runtime_config.py b/tests/test_deployment_runtime_config.py new file mode 100644 index 00000000..a2f9bca9 --- /dev/null +++ b/tests/test_deployment_runtime_config.py @@ -0,0 +1,38 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_frontend_dockerfile_uses_standalone_multistage_runtime(): + dockerfile = (ROOT / "frontend" / "Dockerfile").read_text(encoding="utf-8") + next_config = (ROOT / "frontend" / "next.config.mjs").read_text(encoding="utf-8") + + assert 'output: "standalone"' in next_config + assert "AS deps" in dockerfile + assert "AS builder" in dockerfile + assert "AS runner" in dockerfile + assert "npm ci" in dockerfile + assert "npm prune --omit=dev" in dockerfile or "npm ci --omit=dev" in dockerfile + assert ".next/standalone" in dockerfile + assert "CMD [\"node\", \"server.js\"]" in dockerfile + + +def test_nginx_proxy_buffers_cover_supabase_auth_cookies(): + nginx_conf = (ROOT / "deploy" / "nginx" / "polyweather.conf").read_text( + encoding="utf-8" + ) + + assert "proxy_buffer_size 16k;" in nginx_conf + assert "proxy_buffers 8 16k;" in nginx_conf + assert "proxy_busy_buffers_size 32k;" in nginx_conf + + +def test_scan_terminal_prewarm_is_lazy_by_default(): + app_factory = (ROOT / "web" / "app_factory.py").read_text(encoding="utf-8") + + assert "POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED" in app_factory + assert "start_scan_terminal_prewarm()" not in app_factory.replace( + "if _scan_terminal_prewarm_enabled():\n start_scan_terminal_prewarm()", + "", + ) diff --git a/web/app_factory.py b/web/app_factory.py index ba87a0cf..a493c046 100644 --- a/web/app_factory.py +++ b/web/app_factory.py @@ -5,6 +5,8 @@ This module centralizes router registration while preserving the existing more modular backend structure. """ +import os + from fastapi import FastAPI from web.core import app as core_app @@ -21,6 +23,12 @@ from web.scan_terminal_service import start_scan_terminal_prewarm _ROUTES_REGISTERED_FLAG = "_polyweather_routes_registered" +def _scan_terminal_prewarm_enabled() -> bool: + return str( + os.getenv("POLYWEATHER_SCAN_TERMINAL_PREWARM_ENABLED") or "false" + ).strip().lower() in {"1", "true", "yes", "on"} + + def create_app() -> FastAPI: """Return the configured FastAPI app with routers registered once.""" if not bool(getattr(core_app.state, _ROUTES_REGISTERED_FLAG, False)): @@ -33,5 +41,6 @@ def create_app() -> FastAPI: core_app.include_router(ops_router) core_app.include_router(legacy_router) setattr(core_app.state, _ROUTES_REGISTERED_FLAG, True) - start_scan_terminal_prewarm() + if _scan_terminal_prewarm_enabled(): + start_scan_terminal_prewarm() return core_app