feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration

This commit is contained in:
2569718930@qq.com
2026-05-26 05:20:36 +08:00
parent 1ac41dde33
commit 8ca9ce4223
9 changed files with 265 additions and 28 deletions
+40
View File
@@ -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;
}
}
+20 -5
View File
@@ -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_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY 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 ENV NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS=$NEXT_PUBLIC_POLYWEATHER_LOCAL_FULL_ACCESS
WORKDIR /app/frontend WORKDIR /app/frontend
COPY package.json package-lock.json ./ COPY --from=deps /app/frontend/node_modules ./node_modules
RUN npm ci
COPY . ./ 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 EXPOSE 3000
CMD ["npm", "run", "start"] CMD ["node", "server.js"]
@@ -14,7 +14,7 @@ import {
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "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 { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access"; import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
import { sortRowsByUserTime } from "@/components/dashboard/scan-terminal/decision-utils"; 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 { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils";
import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown"; import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown";
import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector"; import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector";
import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows";
function createEmptyAccess(loading = true): ProAccessState { function createEmptyAccess(loading = true): ProAccessState {
return { return {
@@ -1024,9 +1025,32 @@ function ScanTerminalScreen() {
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null, timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
tradingRegion: selectedRegionKey, tradingRegion: selectedRegionKey,
}); });
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]);
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( 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 [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
@@ -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",
);
}
@@ -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,
};
});
}
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
reactStrictMode: true, reactStrictMode: true,
async headers() { async headers() {
const cacheHeader = { const cacheHeader = {
+8 -19
View File
@@ -1,22 +1,11 @@
FROM node:20-alpine FROM node:20-alpine AS runner
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
ENV NODE_ENV=production
WORKDIR /app/frontend WORKDIR /app/frontend
COPY package.json package-lock.json ./
RUN npm ci COPY public ./public
COPY .next .next/ COPY .next/standalone ./
COPY public public/ COPY .next/static ./.next/static
COPY next.config.mjs ./
EXPOSE 3000 EXPOSE 3000
CMD ["npm", "run", "start"] CMD ["node", "server.js"]
+38
View File
@@ -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()",
"",
)
+10 -1
View File
@@ -5,6 +5,8 @@ This module centralizes router registration while preserving the existing
more modular backend structure. more modular backend structure.
""" """
import os
from fastapi import FastAPI from fastapi import FastAPI
from web.core import app as core_app 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" _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: def create_app() -> FastAPI:
"""Return the configured FastAPI app with routers registered once.""" """Return the configured FastAPI app with routers registered once."""
if not bool(getattr(core_app.state, _ROUTES_REGISTERED_FLAG, False)): 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(ops_router)
core_app.include_router(legacy_router) core_app.include_router(legacy_router)
setattr(core_app.state, _ROUTES_REGISTERED_FLAG, True) setattr(core_app.state, _ROUTES_REGISTERED_FLAG, True)
start_scan_terminal_prewarm() if _scan_terminal_prewarm_enabled():
start_scan_terminal_prewarm()
return core_app return core_app