feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration
This commit is contained in:
@@ -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
@@ -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,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 = {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user