Optimize terminal startup and deployment smoke checks
This commit is contained in:
@@ -146,11 +146,11 @@ fi
|
||||
|
||||
warm_public_route "terminal" "https://polyweather.top/terminal" 20 4 3
|
||||
warm_public_route "auth snapshot" "https://polyweather.top/api/auth/me?prefer_snapshot=1" 10 3 2
|
||||
warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1" 15 2 2
|
||||
warm_public_route "cities" "https://polyweather.top/api/cities" 20 3 2
|
||||
|
||||
FAILED=0
|
||||
smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5 || FAILED=1
|
||||
smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3 || FAILED=1
|
||||
smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5 || FAILED=1
|
||||
smoke_check "frontend" "https://www.polyweather.top/" 15 3 5 || FAILED=1
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
@@ -136,6 +135,27 @@ function subscriptionRequiredAuthProfileResponse({
|
||||
return applyAuthResponseCookies(inactive, response);
|
||||
}
|
||||
|
||||
function unauthenticatedAuthProfileResponse({
|
||||
reason,
|
||||
response,
|
||||
}: {
|
||||
reason: string;
|
||||
response: NextResponse | null;
|
||||
}) {
|
||||
const anonymous = NextResponse.json(
|
||||
{
|
||||
authenticated: false,
|
||||
subscription_active: false,
|
||||
points: 0,
|
||||
degraded_auth_profile: true,
|
||||
degraded_reason: reason,
|
||||
},
|
||||
{ headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
clearEntitlementSnapshotCookie(anonymous);
|
||||
return applyAuthResponseCookies(anonymous, response);
|
||||
}
|
||||
|
||||
function snapshotAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
@@ -377,8 +397,19 @@ export async function GET(req: NextRequest) {
|
||||
userId: identity.userId,
|
||||
});
|
||||
}
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch auth profile",
|
||||
const snapshotPayload = entitlementSnapshotToAuthPayload(
|
||||
readEntitlementSnapshot(req),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
entitlement_snapshot_reason: "exception_snapshot",
|
||||
});
|
||||
return applyAuthResponseCookies(snapshotResponse, auth?.response || null);
|
||||
}
|
||||
return unauthenticatedAuthProfileResponse({
|
||||
reason: String(error),
|
||||
response: auth?.response || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export const metadata: Metadata = {
|
||||
template: "%s | PolyWeather",
|
||||
},
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards. Real-time observations for 51 global cities.",
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context. Real-time observations for 51 global cities.",
|
||||
manifest: "/site.webmanifest",
|
||||
metadataBase: new URL("https://polyweather.top"),
|
||||
alternates: {
|
||||
@@ -34,7 +34,7 @@ export const metadata: Metadata = {
|
||||
siteName: "PolyWeather",
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, AI decision cards. 51 cities, real-time.",
|
||||
"Paid professional weather-signal intelligence terminal. METAR evidence, DEB forecast blending, structured decision context. 51 cities, real-time.",
|
||||
url: "https://polyweather.top",
|
||||
images: [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { InstitutionalLandingPage } from "@/components/landing/InstitutionalLand
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather | Institutional Weather Signal Intelligence",
|
||||
description:
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
"PolyWeather is a paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
|
||||
other: {
|
||||
preconnect: "https://api.polyweather.top",
|
||||
},
|
||||
@@ -37,7 +37,7 @@ export default async function HomePage({
|
||||
"@type": "WebApplication",
|
||||
name: "PolyWeather",
|
||||
description:
|
||||
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and AI decision cards.",
|
||||
"Paid professional weather-signal intelligence terminal with METAR evidence, DEB forecast blending, and structured decision context.",
|
||||
url: "https://polyweather.top",
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardShellSkeleton } from "@/components/dashboard/DashboardShellSkeleton";
|
||||
|
||||
const ScanTerminalDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/ScanTerminalDashboard").then(
|
||||
(mod) => mod.ScanTerminalDashboard,
|
||||
),
|
||||
{
|
||||
loading: () => <DashboardShellSkeleton />,
|
||||
},
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PolyWeather Terminal | Paid Product",
|
||||
|
||||
@@ -149,10 +149,16 @@ export function runTests() {
|
||||
"/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.indexOf("const bearerIdentity = await getVerifiedBearerIdentity(req)") <
|
||||
authMeRouteSource.indexOf("return buildProxyExceptionResponse(error"),
|
||||
authMeRouteSource.includes("const identity = await getBearerIdentityOnce();") &&
|
||||
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
|
||||
"/api/auth/me must try bearer identity fallback before returning a proxy exception",
|
||||
);
|
||||
assert(
|
||||
authMeRouteSource.includes("exception_snapshot") &&
|
||||
authMeRouteSource.includes("unauthenticatedAuthProfileResponse") &&
|
||||
!authMeRouteSource.includes("return buildProxyExceptionResponse(error"),
|
||||
"/api/auth/me must serve a snapshot or anonymous auth profile before surfacing proxy exceptions to long-lived clients",
|
||||
);
|
||||
for (const route of [
|
||||
"app/api/ops/analytics/funnel/route.ts",
|
||||
"app/api/ops/config/route.ts",
|
||||
|
||||
@@ -372,4 +372,11 @@ export function runTests() {
|
||||
.length >= 3,
|
||||
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON",
|
||||
);
|
||||
assert(
|
||||
paymentFlowSource.includes("verifyPaymentAuthReady") &&
|
||||
paymentFlowSource.indexOf("await verifyPaymentAuthReady()") <
|
||||
paymentFlowSource.indexOf("resolvePaymentProvider(") &&
|
||||
paymentFlowSource.includes("auth_confirmed_at"),
|
||||
"wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,6 +183,35 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
trackAppEvent("payment_success", payload);
|
||||
}, []);
|
||||
|
||||
const verifyPaymentAuthReady = useCallback(async () => {
|
||||
const accessToken = await getValidAccessToken();
|
||||
const authHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
const authRes = await fetch("/api/auth/me", {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
|
||||
});
|
||||
if (!authRes.ok) {
|
||||
const raw = (await authRes.text().catch(() => "")).slice(0, 240);
|
||||
throw new Error(
|
||||
isEn
|
||||
? `Authentication check failed before payment (${authRes.status}). ${raw}`
|
||||
: `支付前登录态校验失败 (${authRes.status})。${raw}`,
|
||||
);
|
||||
}
|
||||
const profile = (await authRes.json()) as AuthMeResponse;
|
||||
if (profile.authenticated !== true) {
|
||||
throw new Error(copy.loginBeforePay);
|
||||
}
|
||||
return {
|
||||
authHeaders,
|
||||
auth_confirmed_at: new Date().toISOString(),
|
||||
profile,
|
||||
};
|
||||
}, [copy.loginBeforePay, getValidAccessToken, isEn]);
|
||||
|
||||
const availableChainList: PaymentChainOption[] = useMemo(() => {
|
||||
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
|
||||
const clean = configured
|
||||
@@ -401,6 +430,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setPaymentBusy(true);
|
||||
let approvedInThisRun = false;
|
||||
try {
|
||||
const authReady = await verifyPaymentAuthReady();
|
||||
const authHeaders = authReady.authHeaders;
|
||||
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
|
||||
const eth = providerSelection.provider;
|
||||
const activeAccounts = await requestWalletWithTimeout<string[]>(
|
||||
@@ -418,18 +449,6 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
setSelectedWallet(payingWallet);
|
||||
setProviderMode(providerSelection.mode);
|
||||
|
||||
let accessToken: string;
|
||||
try { accessToken = await getValidAccessToken(); }
|
||||
catch (tokenErr) {
|
||||
setPaymentError(normalizePaymentError(tokenErr).message);
|
||||
setPaymentBusy(false);
|
||||
return;
|
||||
}
|
||||
const authHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
|
||||
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
|
||||
const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
|
||||
@@ -480,6 +499,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
||||
source: "account_center",
|
||||
frontend_host: currentPaymentHost || null,
|
||||
account_email: backend?.email || null,
|
||||
auth_confirmed_at: authReady.auth_confirmed_at,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -108,8 +108,8 @@ export function LoginClient({ nextPath, initialMode }: LoginClientProps) {
|
||||
? "By proceeding, you agree to the Privacy Policy and Terms & Conditions."
|
||||
: "继续操作即代表您同意隐私政策与服务条款。",
|
||||
desc: isEn
|
||||
? "Access robust METAR observations, advanced DEB forecast blends, and real-time AI decision cards that bring clarity to your weather risk analyses."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和实时 AI 决策卡片,助您理清气象风险脉络。",
|
||||
? "Access robust METAR observations, advanced DEB forecast blends, and structured decision context for weather risk analysis."
|
||||
: "提供精准的机场 METAR 实况、先进的 DEB 智能融合预测和结构化决策背景,助您理清气象风险脉络。",
|
||||
trusted: isEn ? "Trusted by industry professionals" : "深受行业决策人员信赖",
|
||||
} as const;
|
||||
const submittingLabel = isLogin ? copy.loginSubmitting : copy.signupSubmitting;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
@@ -44,7 +45,6 @@ import { ScanTerminalLoadingScreen } from "@/components/dashboard/scan-terminal/
|
||||
import { scanRootClass } from "@/components/dashboard/scan-root-styles";
|
||||
import { useRelativeTime } from "@/hooks/useRelativeTime";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";
|
||||
import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard";
|
||||
import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable";
|
||||
@@ -62,6 +62,20 @@ import {
|
||||
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
|
||||
const TrainingDashboard = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/scan-terminal/TrainingDashboard").then(
|
||||
(mod) => mod.TrainingDashboard,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs font-semibold text-slate-400">
|
||||
Loading analytics...
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
function createEmptyAccess(loading = true): ProAccessState {
|
||||
return {
|
||||
loading,
|
||||
@@ -1037,6 +1051,9 @@ function ScanTerminalScreen() {
|
||||
hydrated && (proAccess.authenticated || canUseLocalFullAccess);
|
||||
const isPro =
|
||||
hydrated && (proAccess.subscriptionActive || canUseLocalFullAccess);
|
||||
const accessDecisionPending =
|
||||
!hydrated || (proAccess.loading && !canUseLocalFullAccess);
|
||||
const shouldShowPaywall = !accessDecisionPending && (!isAuthenticated || !isPro);
|
||||
const userLocalTime = useUserLocalClock();
|
||||
const { themeMode } = useScanTerminalTheme();
|
||||
const [selectedRegionKey, setSelectedRegionKey] = useState<string>("all");
|
||||
@@ -1272,7 +1289,7 @@ function ScanTerminalScreen() {
|
||||
}, []);
|
||||
const generatedText = useRelativeTime(terminalData?.generated_at ?? null);
|
||||
|
||||
if (!hydrated || (proAccess.loading && !canUseLocalFullAccess)) {
|
||||
if (accessDecisionPending) {
|
||||
return (
|
||||
<ScanTerminalLoadingScreen
|
||||
isEn={isEn}
|
||||
@@ -1283,7 +1300,7 @@ function ScanTerminalScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !isPro) {
|
||||
if (shouldShowPaywall) {
|
||||
return (
|
||||
<ProductAccessRequired
|
||||
isAuthenticated={isAuthenticated}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { REGIONS, getCityRegion } from "./continent-grouping";
|
||||
@@ -126,6 +126,7 @@ export function CitySelectorDropdown({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const deferredSearchQuery = useDeferredValue(searchQuery);
|
||||
const [activeTab, setActiveTab] = useState<string>("all");
|
||||
const [viewportNudgeY, setViewportNudgeY] = useState(0);
|
||||
|
||||
@@ -170,7 +171,7 @@ export function CitySelectorDropdown({
|
||||
|
||||
// Filter rows
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const q = deferredSearchQuery.toLowerCase().trim();
|
||||
return rows.filter((row) => {
|
||||
// 1. Region filter
|
||||
if (activeTab !== "all") {
|
||||
@@ -195,7 +196,7 @@ export function CitySelectorDropdown({
|
||||
.map((s) => s!.toLowerCase());
|
||||
return haystack.some((s) => s.includes(q));
|
||||
});
|
||||
}, [rows, searchQuery, activeTab]);
|
||||
}, [rows, deferredSearchQuery, activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
|
||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||
import { ModelCurvesSummary } from "@/components/dashboard/scan-terminal/ModelCurvesSummary";
|
||||
import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";
|
||||
import { TemperatureChartCanvasFallback } from "@/components/dashboard/scan-terminal/TemperatureChartCanvasFallback";
|
||||
import { TemperatureRunwayDetails } from "@/components/dashboard/scan-terminal/TemperatureRunwayDetails";
|
||||
import { TemperatureStatsBars } from "@/components/dashboard/scan-terminal/TemperatureStatsBars";
|
||||
import { rowName } from "@/components/dashboard/scan-terminal/utils";
|
||||
@@ -56,6 +57,17 @@ const PEAK_GLOW_BADGE_CLASS = {
|
||||
|
||||
const PROBABILITY_REFRESH_AFTER_PATCH_MS = 60_000;
|
||||
|
||||
const TemperatureChartCanvas = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/scan-terminal/TemperatureChartCanvas").then(
|
||||
(mod) => mod.TemperatureChartCanvas,
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <TemperatureChartCanvasFallback />,
|
||||
},
|
||||
);
|
||||
|
||||
function peakGlowLabel(state: keyof typeof PEAK_GLOW_PANEL_CLASS, isEn: boolean) {
|
||||
if (state === "watch") return isEn ? "Watch" : "关注";
|
||||
if (state === "near_peak") return isEn ? "Near peak" : "接近峰值";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export function TemperatureChartCanvasFallback({ compact }: { compact?: boolean }) {
|
||||
const horizontalLines = compact ? 5 : 7;
|
||||
const verticalLines = compact ? 5 : 8;
|
||||
const minChartHeight = compact === false ? 220 : 120;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex-1 overflow-hidden rounded-sm border border-slate-100 bg-white"
|
||||
style={{ minHeight: minChartHeight }}
|
||||
>
|
||||
<div className="absolute inset-x-3 bottom-7 top-4 rounded-sm border border-slate-100">
|
||||
{Array.from({ length: horizontalLines }).map((_, index) => (
|
||||
<span
|
||||
key={`h-${index}`}
|
||||
className="absolute left-0 right-0 border-t border-dashed border-sky-100"
|
||||
style={{ top: `${(index / Math.max(1, horizontalLines - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: verticalLines }).map((_, index) => (
|
||||
<span
|
||||
key={`v-${index}`}
|
||||
className="absolute bottom-0 top-0 border-l border-dashed border-sky-100"
|
||||
style={{ left: `${(index / Math.max(1, verticalLines - 1)) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<div className="flex items-center gap-2 rounded border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-500 shadow-sm">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-blue-200 border-t-blue-500" />
|
||||
加载图表
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const terminalPageSource = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "terminal", "page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const chartCanvasSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
||||
"utf8",
|
||||
@@ -28,6 +32,10 @@ export function runTests() {
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "CitySelectorDropdown.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const scanQuerySource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "dashboard", "scan-terminal", "use-scan-terminal-query.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("MAX_TERMINAL_CHARTS = 9"),
|
||||
@@ -75,6 +83,26 @@ export function runTests() {
|
||||
citySelectorSource.includes("getBoundingClientRect()"),
|
||||
"city selector dropdown must nudge itself inside the viewport when opened from top-row chart cards",
|
||||
);
|
||||
assert(
|
||||
terminalPageSource.includes("dynamic(") &&
|
||||
terminalPageSource.includes('import("@/components/dashboard/ScanTerminalDashboard")') &&
|
||||
terminalPageSource.includes("DashboardShellSkeleton") &&
|
||||
!terminalPageSource.includes('import { ScanTerminalDashboard } from "@/components/dashboard/ScanTerminalDashboard";'),
|
||||
"terminal route must dynamically load the heavy dashboard behind the shell skeleton",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes('from "next/dynamic"') &&
|
||||
dashboardSource.includes('import("@/components/dashboard/scan-terminal/TrainingDashboard")') &&
|
||||
!dashboardSource.includes('import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard";'),
|
||||
"terminal dashboard must lazy-load the training analytics tab so Recharts stays out of the default terminal path",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes('from "next/dynamic"') &&
|
||||
chartSource.includes("TemperatureChartCanvasFallback") &&
|
||||
chartSource.includes("import(\"@/components/dashboard/scan-terminal/TemperatureChartCanvas\")") &&
|
||||
!chartSource.includes('import { TemperatureChartCanvas } from "@/components/dashboard/scan-terminal/TemperatureChartCanvas";'),
|
||||
"terminal temperature charts must lazy-load the Recharts canvas behind a lightweight fallback",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("setLiveTemp(null);") &&
|
||||
chartSource.includes("lastAppliedPatchRevisionRef.current = 0;"),
|
||||
@@ -109,6 +137,25 @@ export function runTests() {
|
||||
dashboardSource.includes("[rows, deferredSearchQuery]"),
|
||||
"terminal search must defer expensive row filtering so typing stays responsive",
|
||||
);
|
||||
assert(
|
||||
scanQuerySource.includes("MAX_STALE_SCAN_CACHE_MS") &&
|
||||
scanQuerySource.includes("allowStale") &&
|
||||
scanQuerySource.includes("setCachedRows(readScanCache(tradingRegion || \"\", { allowStale: true }))"),
|
||||
"terminal data hook must render stale scan rows immediately while revalidating the first-screen API",
|
||||
);
|
||||
assert(
|
||||
citySelectorSource.includes("useDeferredValue") &&
|
||||
citySelectorSource.includes("deferredSearchQuery") &&
|
||||
citySelectorSource.includes("[rows, deferredSearchQuery, activeTab]"),
|
||||
"city selector search must defer expensive dropdown filtering so top-row selection stays responsive",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes("accessDecisionPending") &&
|
||||
dashboardSource.includes("shouldShowPaywall") &&
|
||||
dashboardSource.indexOf("if (accessDecisionPending)") <
|
||||
dashboardSource.indexOf("if (shouldShowPaywall)"),
|
||||
"terminal must keep showing verification while access is undecided instead of flashing the paywall",
|
||||
);
|
||||
assert(
|
||||
dashboardSource.includes('trackAppEvent("enter_terminal"') &&
|
||||
dashboardSource.includes('entry: "terminal"'),
|
||||
|
||||
@@ -17,17 +17,23 @@ import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
|
||||
const SCAN_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.scanRows;
|
||||
const MAX_STALE_SCAN_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
function scanCacheKey(tradingRegion: string): string {
|
||||
return `${SCAN_CACHE_PREFIX}:${tradingRegion || "all"}`;
|
||||
}
|
||||
|
||||
function readScanCache(tradingRegion: string): ScanTerminalResponse | null {
|
||||
function readScanCache(
|
||||
tradingRegion: string,
|
||||
options?: { allowStale?: boolean },
|
||||
): ScanTerminalResponse | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(scanCacheKey(tradingRegion));
|
||||
if (!raw) return null;
|
||||
const cached = JSON.parse(raw);
|
||||
if (cached.ts && Date.now() - cached.ts < SCAN_CACHE_TTL_MS && cached.data?.rows) {
|
||||
const age = Date.now() - Number(cached.ts || 0);
|
||||
const maxAge = options?.allowStale ? MAX_STALE_SCAN_CACHE_MS : SCAN_CACHE_TTL_MS;
|
||||
if (cached.ts && age >= 0 && age < maxAge && cached.data?.rows) {
|
||||
return cached.data;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -99,10 +105,17 @@ export function useScanTerminalQuery({
|
||||
const lastForcedScanRefreshAtRef = useRef(0);
|
||||
const patchVersion = useSsePatchVersion();
|
||||
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
|
||||
if (typeof window !== "undefined") return readScanCache(tradingRegion || "");
|
||||
if (typeof window !== "undefined") {
|
||||
return readScanCache(tradingRegion || "", { allowStale: true });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setCachedRows(readScanCache(tradingRegion || "", { allowStale: true }));
|
||||
}, [tradingRegion]);
|
||||
|
||||
const fetchScanTerminal = useCallback(
|
||||
async ({
|
||||
forceRefresh = false,
|
||||
|
||||
@@ -81,6 +81,11 @@ export function runTests() {
|
||||
assert(appPageSource.includes('price: "79.90"'), "JSON-LD must expose quarterly Pro pricing");
|
||||
assert(!appPageSource.includes('price: "10.00"'), "legacy JSON-LD pricing must be removed");
|
||||
assert(!appPageSource.includes("PreloadTerminalData"), "landing route must not add a fourth client island");
|
||||
assert(
|
||||
!appPageSource.includes("AI decision cards") &&
|
||||
!appPageSource.includes("AI 气象证据链"),
|
||||
"landing metadata and JSON-LD must not advertise the removed AI decision-card positioning",
|
||||
);
|
||||
}
|
||||
|
||||
function projectRoot() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const source = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "config", "ConfigPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
source.includes("data-testid=\"sensitive-session-input\"") &&
|
||||
source.includes("spellCheck={false}") &&
|
||||
source.includes("autoCapitalize=\"none\""),
|
||||
"ops sensitive session input must be optimized for exact sessionId entry",
|
||||
);
|
||||
assert(
|
||||
source.includes("Docker .env") &&
|
||||
source.includes("不需要把 $$ 写成 $$$$") &&
|
||||
source.includes("真实的 $$"),
|
||||
"ops config page must explain that UI-entered sessionIds keep literal $$ and do not need Docker escaping",
|
||||
);
|
||||
assert(
|
||||
source.includes("最近检查") &&
|
||||
source.includes("sensitiveCheckedAt") &&
|
||||
source.includes("setSensitiveCheckedAt"),
|
||||
"ops config page must show when the post-rotation sensitive health check was performed",
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export function ConfigPageClient() {
|
||||
const [result, setResult] = useState("");
|
||||
const [sensitiveResult, setSensitiveResult] = useState("");
|
||||
const [sensitiveHealth, setSensitiveHealth] = useState<SensitiveHealth | null>(null);
|
||||
const [sensitiveCheckedAt, setSensitiveCheckedAt] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -90,6 +91,7 @@ export function ConfigPageClient() {
|
||||
setSensitiveSaving(true);
|
||||
setSensitiveResult("");
|
||||
setSensitiveHealth(null);
|
||||
setSensitiveCheckedAt("");
|
||||
try {
|
||||
const res = await fetch("/api/ops/sensitive-config", {
|
||||
method: "PUT",
|
||||
@@ -106,6 +108,7 @@ export function ConfigPageClient() {
|
||||
}
|
||||
setSensitiveEditing((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setSensitiveHealth(data.health ?? null);
|
||||
setSensitiveCheckedAt(new Date().toLocaleString("zh-CN", { hour12: false }));
|
||||
setSensitiveResult(`${key} 已轮换`);
|
||||
} else {
|
||||
setSensitiveResult(`轮换失败: ${await res.text().catch(() => "")}`);
|
||||
@@ -214,8 +217,11 @@ export function ConfigPageClient() {
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row xl:w-[520px]">
|
||||
<input
|
||||
data-testid="sensitive-session-input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="输入新的 sessionId,不会回显"
|
||||
value={sensitiveEditing[cfg.key] ?? ""}
|
||||
onChange={(e) => setSensitiveEditing((prev) => ({ ...prev, [cfg.key]: e.target.value }))}
|
||||
@@ -232,6 +238,9 @@ export function ConfigPageClient() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-5 text-slate-500">
|
||||
在 ops 页面粘贴真实的 $$ sessionId 即可;这里不是 Docker .env,不需要把 $$ 写成 $$$$。
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -244,6 +253,7 @@ export function ConfigPageClient() {
|
||||
{sensitiveHealth && (
|
||||
<div className={`mt-3 rounded-lg border px-3 py-2 text-xs ${sensitiveHealth.ok ? "border-emerald-400/20 bg-emerald-400/10 text-emerald-200" : "border-amber-400/20 bg-amber-400/10 text-amber-200"}`}>
|
||||
AMSC 健康检查:{sensitiveHealth.ok ? "通过" : "失败"}
|
||||
{sensitiveCheckedAt ? ` · 最近检查 ${sensitiveCheckedAt}` : ""}
|
||||
{typeof sensitiveHealth.points === "number" ? ` · 跑道点 ${sensitiveHealth.points}` : ""}
|
||||
{sensitiveHealth.observation_time_local ? ` · 观测 ${sensitiveHealth.observation_time_local}` : ""}
|
||||
{sensitiveHealth.error ? ` · ${sensitiveHealth.error}` : ""}
|
||||
|
||||
@@ -74,7 +74,8 @@ def test_deploy_script_retries_startup_smoke_checks():
|
||||
|
||||
assert "smoke_check()" in script
|
||||
assert 'smoke_check "healthz" "https://api.polyweather.top/healthz" 15 3 5' in script
|
||||
assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' in script
|
||||
assert 'warm_public_route "local cities recent stats" "http://127.0.0.1:8000/api/cities?refresh_deb_recent=1"' in script
|
||||
assert 'smoke_check "local cities" "http://127.0.0.1:8000/api/cities" 10 6 3' not in script
|
||||
assert 'smoke_check "frontend cities" "https://polyweather.top/api/cities" 20 5 5' in script
|
||||
assert 'smoke_check "frontend" "https://www.polyweather.top/" 15 3 5' in script
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import web.routes as routes
|
||||
import web.services.ops_api as ops_api
|
||||
import web.scan_terminal_cache as scan_terminal_cache
|
||||
import web.scan_terminal_service as scan_terminal_service
|
||||
import web.services.city_api as city_api
|
||||
import web.services.city_runtime as city_runtime
|
||||
from web.scan_terminal_cache import scan_terminal_cache_key
|
||||
from src.database.runtime_state import TruthRecordRepository
|
||||
@@ -132,6 +133,30 @@ def test_cities_endpoint_includes_new_wunderground_cities():
|
||||
}.issubset(names)
|
||||
|
||||
|
||||
def test_cities_endpoint_does_not_block_on_recent_deb_index(monkeypatch):
|
||||
monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE", None, raising=False)
|
||||
monkeypatch.setattr(city_api, "_RECENT_DEB_CACHE_TS", 0.0, raising=False)
|
||||
monkeypatch.setattr(city_api, "_RECENT_DEB_REFRESHING", False, raising=False)
|
||||
monkeypatch.setattr(city_api, "_get_recent_deb_cache", lambda: None, raising=False)
|
||||
monkeypatch.setattr(city_api, "_start_recent_deb_refresh", lambda: None, raising=False)
|
||||
|
||||
def fail_recent_index():
|
||||
raise AssertionError("recent DEB stats must not run in the default city-list request")
|
||||
|
||||
monkeypatch.setattr(
|
||||
city_api.legacy_routes,
|
||||
"_build_recent_deb_performance_index",
|
||||
fail_recent_index,
|
||||
)
|
||||
|
||||
response = client.get("/api/cities")
|
||||
|
||||
assert response.status_code == 200
|
||||
denver = next(item for item in response.json()["cities"] if item["name"] == "denver")
|
||||
assert denver["deb_recent_tier"] == "other"
|
||||
assert denver["deb_recent_sample_count"] == 0
|
||||
|
||||
|
||||
def test_payment_runtime_endpoint_returns_shape():
|
||||
response = client.get('/api/payments/runtime')
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
@@ -10,6 +13,15 @@ from loguru import logger
|
||||
|
||||
import web.routes as legacy_routes
|
||||
|
||||
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
|
||||
_RECENT_DEB_CACHE_TS = 0.0
|
||||
_RECENT_DEB_REFRESHING = False
|
||||
_RECENT_DEB_LOCK = threading.Lock()
|
||||
_RECENT_DEB_CACHE_TTL_SEC = max(
|
||||
60,
|
||||
int(os.getenv("POLYWEATHER_CITIES_DEB_RECENT_CACHE_TTL_SEC", "300") or "300"),
|
||||
)
|
||||
|
||||
|
||||
async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return await run_in_threadpool(
|
||||
@@ -19,13 +31,69 @@ async def _overlay_cached_wunderground(city: str, payload: Dict[str, Any]) -> Di
|
||||
)
|
||||
|
||||
|
||||
def _build_cities_payload() -> Dict[str, Any]:
|
||||
def _default_deb_recent() -> Dict[str, object]:
|
||||
return {
|
||||
"tier": "other",
|
||||
"hit_rate": None,
|
||||
"sample_count": 0,
|
||||
"mae": None,
|
||||
"last_date": None,
|
||||
}
|
||||
|
||||
|
||||
def _refresh_recent_deb_cache() -> Dict[str, Dict[str, object]]:
|
||||
global _RECENT_DEB_CACHE, _RECENT_DEB_CACHE_TS, _RECENT_DEB_REFRESHING
|
||||
|
||||
try:
|
||||
index = legacy_routes._build_recent_deb_performance_index()
|
||||
with _RECENT_DEB_LOCK:
|
||||
_RECENT_DEB_CACHE = index
|
||||
_RECENT_DEB_CACHE_TS = time.time()
|
||||
return index
|
||||
except Exception as exc:
|
||||
logger.warning(f"Recent DEB performance cache refresh failed: {exc}")
|
||||
with _RECENT_DEB_LOCK:
|
||||
return _RECENT_DEB_CACHE or {}
|
||||
finally:
|
||||
with _RECENT_DEB_LOCK:
|
||||
_RECENT_DEB_REFRESHING = False
|
||||
|
||||
|
||||
def _get_recent_deb_cache() -> Optional[Dict[str, Dict[str, object]]]:
|
||||
with _RECENT_DEB_LOCK:
|
||||
if (
|
||||
_RECENT_DEB_CACHE is not None
|
||||
and time.time() - _RECENT_DEB_CACHE_TS < _RECENT_DEB_CACHE_TTL_SEC
|
||||
):
|
||||
return _RECENT_DEB_CACHE
|
||||
return None
|
||||
|
||||
|
||||
def _start_recent_deb_refresh() -> None:
|
||||
global _RECENT_DEB_REFRESHING
|
||||
|
||||
with _RECENT_DEB_LOCK:
|
||||
if _RECENT_DEB_REFRESHING:
|
||||
return
|
||||
_RECENT_DEB_REFRESHING = True
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_refresh_recent_deb_cache,
|
||||
name="cities-recent-deb-refresh",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
|
||||
def _build_cities_payload(
|
||||
deb_recent_index: Optional[Dict[str, Dict[str, object]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
out = []
|
||||
deb_recent_index = legacy_routes._build_recent_deb_performance_index()
|
||||
deb_recent_index = deb_recent_index or {}
|
||||
for name, info in legacy_routes.CITIES.items():
|
||||
risk = legacy_routes.CITY_RISK_PROFILES.get(name, {})
|
||||
city_meta = legacy_routes.CITY_REGISTRY.get(name, {}) or {}
|
||||
deb_recent = deb_recent_index.get(name, {})
|
||||
deb_recent = deb_recent_index.get(name, _default_deb_recent())
|
||||
settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar"
|
||||
provider = legacy_routes.get_country_network_provider(name)
|
||||
out.append(
|
||||
@@ -60,9 +128,19 @@ def _build_cities_payload() -> Dict[str, Any]:
|
||||
return {"cities": out}
|
||||
|
||||
|
||||
async def list_cities_payload(_request: Request) -> Dict[str, Any]:
|
||||
async def list_cities_payload(request: Request) -> Dict[str, Any]:
|
||||
try:
|
||||
return await run_in_threadpool(_build_cities_payload)
|
||||
refresh_recent = str(
|
||||
request.query_params.get("refresh_deb_recent") or "",
|
||||
).strip().lower() in {"1", "true", "yes"}
|
||||
if refresh_recent:
|
||||
deb_recent_index = await run_in_threadpool(_refresh_recent_deb_cache)
|
||||
else:
|
||||
deb_recent_index = _get_recent_deb_cache()
|
||||
if deb_recent_index is None:
|
||||
_start_recent_deb_refresh()
|
||||
deb_recent_index = {}
|
||||
return await run_in_threadpool(_build_cities_payload, deb_recent_index)
|
||||
except Exception as exc:
|
||||
logger.error(f"Error in list_cities: {exc}")
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
Reference in New Issue
Block a user