SSE Patch 增量推送架构全链路实现
- web/sse_manager.py: asyncio.Queue 连接池 + broadcast + event_stream - web/routers/sse_router.py: GET /api/events + POST /api/internal/collector-patch - weather_sources.py: 采集后 POST patch 给 web - Nginx polyweather.conf: /api/events proxy_buffering off - frontend/api/events/route.ts: Next.js SSE 代理 - frontend/hooks/use-sse-patches.ts: EventSource + useLatestPatch + 重连 - LiveTemperatureThresholdChart: mergePatchIntoHourly 增量合并 - scan-terminal-query: 接入 SSE patch 更新行数据 - 新增 ssePatchArchitecture.test.ts 架构测试
This commit is contained in:
@@ -29,6 +29,19 @@ server {
|
|||||||
proxy_buffers 8 16k;
|
proxy_buffers 8 16k;
|
||||||
proxy_busy_buffers_size 32k;
|
proxy_busy_buffers_size 32k;
|
||||||
|
|
||||||
|
location /api/events {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8000;
|
proxy_pass http://127.0.0.1:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(`${API_BASE.replace(/\/+$/, "")}/api/events`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
Cookie: req.headers.get("cookie") || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `SSE upstream failed with HTTP ${upstream.status}` },
|
||||||
|
{ status: upstream.status || 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
import { buildDebBaselinePath } from "@/lib/temperature-chart-paths";
|
||||||
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||||
|
import { useLatestPatch, type CityPatch } from "@/hooks/use-sse-patches";
|
||||||
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
|
||||||
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
import { rowName, temp } from "@/components/dashboard/scan-terminal/utils";
|
||||||
|
|
||||||
@@ -484,19 +485,51 @@ function seedHourlyForecastFromRow(row: ScanOpportunityRow | null): HourlyForeca
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast> {
|
type HourlyForecastFetchOptions = {
|
||||||
const cached = _hourlyCache.get(city);
|
ignoreCache?: boolean;
|
||||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
};
|
||||||
return cached.data;
|
|
||||||
|
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
|
||||||
|
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||||
|
if (!json || !hourlySource) return null;
|
||||||
|
return {
|
||||||
|
forecastTodayHigh: json.forecast?.today_high ?? null,
|
||||||
|
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
|
||||||
|
localTime: json.local_time || null,
|
||||||
|
times: hourlySource.times || [],
|
||||||
|
temps: hourlySource.temps || [],
|
||||||
|
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
||||||
|
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
|
||||||
|
amos: json.amos || null,
|
||||||
|
airportCurrent: json.airport_current || null,
|
||||||
|
airportPrimary: json.airport_primary || null,
|
||||||
|
forecastDaily: json.forecast?.daily || [],
|
||||||
|
multiModelDaily: json.multi_model_daily || {},
|
||||||
|
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
||||||
|
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
||||||
|
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHourlyForecastForCity(
|
||||||
|
city: string,
|
||||||
|
options: HourlyForecastFetchOptions = {},
|
||||||
|
): Promise<HourlyForecast> {
|
||||||
|
if (!options.ignoreCache) {
|
||||||
|
const cached = _hourlyCache.get(city);
|
||||||
|
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCached = readSessionCache(city);
|
||||||
|
if (sessionCached) {
|
||||||
|
_hourlyCache.set(city, sessionCached);
|
||||||
|
return sessionCached.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionCached = readSessionCache(city);
|
const requestKey = options.ignoreCache ? `${city}:live` : city;
|
||||||
if (sessionCached) {
|
const pending = _hourlyRequestCache.get(requestKey);
|
||||||
_hourlyCache.set(city, sessionCached);
|
|
||||||
return sessionCached.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = _hourlyRequestCache.get(city);
|
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`, {
|
||||||
@@ -507,37 +540,96 @@ async function fetchHourlyForecastForCity(city: string): Promise<HourlyForecast>
|
|||||||
return res.json() as Promise<CityDetail>;
|
return res.json() as Promise<CityDetail>;
|
||||||
})
|
})
|
||||||
.then((json) => {
|
.then((json) => {
|
||||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
const data = parseHourlyForecastFromCityDetail(json);
|
||||||
if (!json || !hourlySource) return null;
|
if (!data) return null;
|
||||||
const data: HourlyForecast = {
|
|
||||||
forecastTodayHigh: json.forecast?.today_high ?? null,
|
|
||||||
debPrediction: json.deb?.prediction ?? (json as any)?.overview?.deb_prediction ?? null,
|
|
||||||
localTime: json.local_time || null,
|
|
||||||
times: hourlySource.times || [],
|
|
||||||
temps: hourlySource.temps || [],
|
|
||||||
modelCurves: (json.models_hourly ?? (json as any)?.timeseries?.models_hourly)?.curves || undefined,
|
|
||||||
runwayPlateHistory: (json as any)?.runway_plate_history || (json.amos as any)?.runway_plate_history || undefined,
|
|
||||||
amos: json.amos || null,
|
|
||||||
airportCurrent: json.airport_current || null,
|
|
||||||
airportPrimary: json.airport_primary || null,
|
|
||||||
forecastDaily: json.forecast?.daily || [],
|
|
||||||
multiModelDaily: json.multi_model_daily || {},
|
|
||||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs || (json as any)?.settlement_today_obs || undefined,
|
|
||||||
metarTodayObs: (json as any).timeseries?.metar_today_obs || (json as any)?.metar_today_obs || undefined,
|
|
||||||
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs || (json as any)?.airport_primary_today_obs || undefined,
|
|
||||||
};
|
|
||||||
_hourlyCache.set(city, { ts: Date.now(), data });
|
_hourlyCache.set(city, { ts: Date.now(), data });
|
||||||
writeSessionCache(city, data);
|
writeSessionCache(city, data);
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
_hourlyRequestCache.delete(city);
|
_hourlyRequestCache.delete(requestKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
_hourlyRequestCache.set(city, request);
|
_hourlyRequestCache.set(requestKey, request);
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldPollLiveChart({
|
||||||
|
city,
|
||||||
|
compact,
|
||||||
|
isActive,
|
||||||
|
isMaximized,
|
||||||
|
}: {
|
||||||
|
city: string;
|
||||||
|
compact: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
isMaximized: boolean;
|
||||||
|
}) {
|
||||||
|
return Boolean(city) && (compact || isActive || isMaximized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePatchIntoHourly(
|
||||||
|
prev: HourlyForecast,
|
||||||
|
patch: CityPatch,
|
||||||
|
): HourlyForecast {
|
||||||
|
const changes = patch.changes || {};
|
||||||
|
const tempValue = validNumber(changes.temp);
|
||||||
|
const obsTime = typeof changes.obs_time === "string" ? changes.obs_time : null;
|
||||||
|
const source = typeof changes.source === "string" ? changes.source : "";
|
||||||
|
const explicitHourlyPatch = changes.hourly && typeof changes.hourly === "object"
|
||||||
|
? changes.hourly as Partial<NonNullable<HourlyForecast>>
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const next: NonNullable<HourlyForecast> = {
|
||||||
|
...(prev || {
|
||||||
|
forecastTodayHigh: null,
|
||||||
|
debPrediction: null,
|
||||||
|
localTime: null,
|
||||||
|
times: [],
|
||||||
|
temps: [],
|
||||||
|
forecastDaily: [],
|
||||||
|
multiModelDaily: {},
|
||||||
|
}),
|
||||||
|
...explicitHourlyPatch,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (changes.amos && typeof changes.amos === "object") {
|
||||||
|
next.amos = changes.amos as AmosData;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempValue !== null) {
|
||||||
|
next.airportCurrent = {
|
||||||
|
...(next.airportCurrent || {}),
|
||||||
|
obs_time: next.airportCurrent?.obs_time ?? null,
|
||||||
|
temp: tempValue,
|
||||||
|
max_so_far: Math.max(
|
||||||
|
tempValue,
|
||||||
|
validNumber(next.airportCurrent?.max_so_far) ?? tempValue,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
next.airportPrimary = {
|
||||||
|
...(next.airportPrimary || {}),
|
||||||
|
obs_time: next.airportPrimary?.obs_time ?? null,
|
||||||
|
temp: tempValue,
|
||||||
|
max_so_far: Math.max(
|
||||||
|
tempValue,
|
||||||
|
validNumber(next.airportPrimary?.max_so_far) ?? tempValue,
|
||||||
|
),
|
||||||
|
source_label: next.airportPrimary?.source_label || source || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tempValue !== null && obsTime) {
|
||||||
|
const obsPoint: RawObsPoint = [obsTime, tempValue];
|
||||||
|
const currentObs = Array.isArray(next.airportPrimaryTodayObs)
|
||||||
|
? next.airportPrimaryTodayObs
|
||||||
|
: [];
|
||||||
|
next.airportPrimaryTodayObs = [...currentObs, obsPoint].slice(-MAX_OBS_POINTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
function parseRunwayHistoryValue(point: Record<string, unknown>) {
|
||||||
return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value);
|
return validNumber(point.max_temp_c) ?? validNumber(point.temp_c) ?? validNumber(point.temp) ?? validNumber(point.value);
|
||||||
}
|
}
|
||||||
@@ -1155,12 +1247,17 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}) {
|
}) {
|
||||||
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
const [hourly, setHourly] = useState<HourlyForecast>(null);
|
||||||
const city = String(row?.city || "").toLowerCase().trim();
|
const city = String(row?.city || "").toLowerCase().trim();
|
||||||
|
const latestPatch = useLatestPatch(city);
|
||||||
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
|
const [timeframe, setTimeframe] = useState<"1D" | "3D">("1D");
|
||||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||||
|
const lastPatchAtRef = useRef<number>(Date.now());
|
||||||
|
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserToggledKeys({});
|
setUserToggledKeys({});
|
||||||
|
lastPatchAtRef.current = Date.now();
|
||||||
|
lastAppliedPatchRevisionRef.current = 0;
|
||||||
}, [city, timeframe]);
|
}, [city, timeframe]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1203,53 +1300,53 @@ export function LiveTemperatureThresholdChart({
|
|||||||
};
|
};
|
||||||
}, [city, row, isActive, slotIndex]);
|
}, [city, row, isActive, slotIndex]);
|
||||||
|
|
||||||
// ── 60s live poll: summary (number) + detail amos/runway (curves) ──
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city) return;
|
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||||
const poll = () => {
|
lastAppliedPatchRevisionRef.current = latestPatch.revision;
|
||||||
// Lightweight: current temp number
|
lastPatchAtRef.current = Date.now();
|
||||||
|
const tempValue = validNumber(latestPatch.changes.temp);
|
||||||
|
if (tempValue !== null) setLiveTemp(tempValue);
|
||||||
|
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
|
||||||
|
}, [latestPatch, row]);
|
||||||
|
|
||||||
|
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for 2 minutes ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const refreshFullDetail = () => {
|
||||||
|
lastPatchAtRef.current = Date.now();
|
||||||
|
|
||||||
|
fetchHourlyForecastForCity(city, { ignoreCache: true })
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled || !data) return;
|
||||||
|
setHourly(data);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkFallback = () => {
|
||||||
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
|
||||||
|
if (Date.now() - lastPatchAtRef.current < 2 * 60_000) return;
|
||||||
|
|
||||||
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
|
fetch(`/api/city/${encodeURIComponent(city)}/summary`)
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!payload) return;
|
if (cancelled || !payload) return;
|
||||||
const temp = validNumber(payload?.current?.temp);
|
const temp = validNumber(payload?.current?.temp);
|
||||||
if (temp !== null) setLiveTemp(temp);
|
if (temp !== null) setLiveTemp(temp);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Refresh runway curves and station observations
|
refreshFullDetail();
|
||||||
fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false`)
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((json) => {
|
|
||||||
if (!json) return;
|
|
||||||
setHourly((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
amos: json.amos ?? prev.amos,
|
|
||||||
airportPrimary: json.airport_primary ?? prev.airportPrimary,
|
|
||||||
airportCurrent: json.airport_current ?? prev.airportCurrent,
|
|
||||||
airportPrimaryTodayObs: (json as any)?.official?.airport_primary_today_obs
|
|
||||||
|| json.airport_primary_today_obs
|
|
||||||
|| prev.airportPrimaryTodayObs,
|
|
||||||
runwayPlateHistory: (json as any)?.runway_plate_history
|
|
||||||
|| (json.amos as any)?.runway_plate_history
|
|
||||||
|| prev.runwayPlateHistory,
|
|
||||||
settlementTodayObs: (json as any).timeseries?.settlement_today_obs
|
|
||||||
|| (json as any)?.settlement_today_obs
|
|
||||||
|| prev.settlementTodayObs,
|
|
||||||
metarTodayObs: (json as any).timeseries?.metar_today_obs
|
|
||||||
|| (json as any)?.metar_today_obs
|
|
||||||
|| prev.metarTodayObs,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
};
|
};
|
||||||
poll();
|
|
||||||
const id = setInterval(poll, 60_000);
|
const id = setInterval(checkFallback, 60_000);
|
||||||
return () => clearInterval(id);
|
return () => {
|
||||||
}, [city]);
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [city, compact, isActive, isMaximized]);
|
||||||
|
|
||||||
const { data, series } = useMemo(() => {
|
const { data, series } = useMemo(() => {
|
||||||
if (timeframe === "3D") {
|
if (timeframe === "3D") {
|
||||||
@@ -1300,8 +1397,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const isTokyo = normalizedKey === 'tokyo';
|
const isTokyo = normalizedKey === 'tokyo';
|
||||||
const isSingapore = normalizedKey === 'singapore';
|
const isSingapore = normalizedKey === 'singapore';
|
||||||
const isParis = normalizedKey === 'paris';
|
const isParis = normalizedKey === 'paris';
|
||||||
|
const stationSourceCode = hourly?.airportPrimary?.source_code || (row as any)?.station_source_code || '';
|
||||||
|
const hasRealStationNetwork = /^(mgm|jma_amedas|fmi|knmi|cowin_obs|ims|ncm|aeroweb|madis_hfmetar|singapore_mss)$/.test(stationSourceCode);
|
||||||
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
const isWeatherStation = !runwaySensorCities.has(normalizedKey)
|
||||||
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis;
|
&& !isHKO && !isShenzhen && !isTokyo && !isSingapore && !isParis
|
||||||
|
&& hasRealStationNetwork;
|
||||||
|
|
||||||
const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
|
const runwayHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
|
||||||
: isHKO ? '参考站点 (1分钟)'
|
: isHKO ? '参考站点 (1分钟)'
|
||||||
@@ -1311,8 +1411,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
: isWeatherStation ? '气象站实测'
|
: isWeatherStation ? '气象站实测'
|
||||||
: '跑道实测 (1分钟)';
|
: '跑道实测 (1分钟)';
|
||||||
|
|
||||||
const metarHeaderLabel = isShenzhen ? '天文台实测 (10分钟)'
|
const metarHeaderLabel = (isShenzhen || isHKO) ? '天文台实测 (10分钟)'
|
||||||
: isHKO ? '天文台实测 (10分钟)'
|
|
||||||
: 'METAR 结算 (30分钟)';
|
: 'METAR 结算 (30分钟)';
|
||||||
|
|
||||||
const runwayHighLabel = isShenzhen ? '天文台实测'
|
const runwayHighLabel = isShenzhen ? '天文台实测'
|
||||||
@@ -1800,3 +1899,4 @@ export function __buildTemperatureChartDataForTest(
|
|||||||
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
export const __isTemperatureSeriesVisibleByDefaultForTest = isTemperatureSeriesVisibleByDefault;
|
||||||
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
|
export const __getVisibleTemperatureSeriesForTest = getVisibleTemperatureSeries;
|
||||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||||
|
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DASHBOARD_REFRESH_POLICY_SEC,
|
DASHBOARD_REFRESH_POLICY_SEC,
|
||||||
} from "@/lib/refresh-policy";
|
} from "@/lib/refresh-policy";
|
||||||
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||||
|
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
@@ -16,7 +17,7 @@ export function runTests() {
|
|||||||
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
|
assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes");
|
||||||
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
|
assert(DASHBOARD_REFRESH_POLICY_MS.model === 30 * 60_000, "DEB and multi-model data should refresh every 30 minutes");
|
||||||
assert(DASHBOARD_REFRESH_POLICY_SEC.metar === 5 * 60, "METAR polling should be 5 minutes");
|
assert(DASHBOARD_REFRESH_POLICY_SEC.metar === 5 * 60, "METAR polling should be 5 minutes");
|
||||||
assert(scanTerminalQueryPolicy.autoRefreshMs === DASHBOARD_REFRESH_POLICY_MS.scanRows, "scan terminal auto refresh should use the shared row cadence");
|
assert(scanTerminalQueryPolicy.autoRefreshMs === null, "scan terminal auto refresh should be disabled after SSE patch migration");
|
||||||
|
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const querySource = fs.readFileSync(
|
const querySource = fs.readFileSync(
|
||||||
@@ -29,8 +30,9 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
querySource.includes("DASHBOARD_REFRESH_POLICY_MS.scanRows"),
|
querySource.includes("useSsePatchVersion") &&
|
||||||
"scan list local cache should use the shared 5-minute row cadence",
|
!querySource.includes("window.setInterval"),
|
||||||
|
"scan list should subscribe to SSE patch state instead of running a 5-minute interval",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
chartSource.includes("DASHBOARD_REFRESH_POLICY_MS.metar") &&
|
||||||
@@ -38,8 +40,23 @@ export function runTests() {
|
|||||||
"selected city detail chart cache should align with 5-minute scan/metar cadence",
|
"selected city detail chart cache should align with 5-minute scan/metar cadence",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartSource.includes("setInterval(poll, 60_000)"),
|
chartSource.includes("useLatestPatch") &&
|
||||||
"selected city chart should poll live temperature and runway curves every 60 seconds",
|
chartSource.includes("latestPatch") &&
|
||||||
|
chartSource.includes("2 * 60_000"),
|
||||||
|
"selected city chart should consume SSE patches and use a 2-minute no-patch fallback",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
chartSource.includes("fetchHourlyForecastForCity(city, { ignoreCache: true })") &&
|
||||||
|
chartSource.includes("setHourly(data)"),
|
||||||
|
"visible chart fallback must refresh the full city detail payload when SSE patches stop",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
||||||
|
"compact grid slots are visible charts and should run the no-patch fallback guard",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false,
|
||||||
|
"inactive non-compact charts should not run live polling",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartSource.includes("_hourlyRequestCache") &&
|
chartSource.includes("_hourlyRequestCache") &&
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRepoFile(...parts: string[]) {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), "..", ...parts), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFrontendFile(...parts: string[]) {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), ...parts), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTests() {
|
||||||
|
const repoRoot = path.join(process.cwd(), "..");
|
||||||
|
|
||||||
|
const sseManagerPath = path.join(repoRoot, "web", "sse_manager.py");
|
||||||
|
assert(fs.existsSync(sseManagerPath), "FastAPI backend must define web/sse_manager.py");
|
||||||
|
const sseManager = fs.readFileSync(sseManagerPath, "utf8");
|
||||||
|
assert(sseManager.includes("asyncio.Queue"), "SSE manager must keep asyncio.Queue connections");
|
||||||
|
assert(sseManager.includes("broadcast("), "SSE manager must expose broadcast(city, changes)");
|
||||||
|
assert(sseManager.includes("event_stream("), "SSE manager must expose an async event_stream(user_id)");
|
||||||
|
assert(sseManager.includes("revision"), "SSE patches must carry monotonic revision numbers");
|
||||||
|
assert(sseManager.includes("30"), "SSE stream must include a 30-second heartbeat");
|
||||||
|
assert(sseManager.includes("data: "), "SSE stream must emit data: JSON frames");
|
||||||
|
|
||||||
|
const sseRouterPath = path.join(repoRoot, "web", "routers", "sse_router.py");
|
||||||
|
assert(fs.existsSync(sseRouterPath), "FastAPI backend must define web/routers/sse_router.py");
|
||||||
|
const sseRouter = fs.readFileSync(sseRouterPath, "utf8");
|
||||||
|
assert(sseRouter.includes('"/api/events"'), "SSE router must expose GET /api/events");
|
||||||
|
assert(sseRouter.includes('"/api/internal/collector-patch"'), "SSE router must expose collector patch ingest endpoint");
|
||||||
|
assert(sseRouter.includes("StreamingResponse"), "SSE route must return StreamingResponse");
|
||||||
|
assert(sseRouter.includes('"text/event-stream"'), "SSE route must use text/event-stream media type");
|
||||||
|
|
||||||
|
const appFactory = readRepoFile("web", "app_factory.py");
|
||||||
|
assert(appFactory.includes("sse_router"), "FastAPI app factory must register the SSE router");
|
||||||
|
|
||||||
|
const nginx = readRepoFile("deploy", "nginx", "polyweather.conf");
|
||||||
|
assert(nginx.includes("location /api/events"), "Nginx deploy config must route /api/events separately");
|
||||||
|
assert(nginx.includes("proxy_buffering off"), "Nginx /api/events must disable proxy buffering for SSE");
|
||||||
|
assert(nginx.includes("proxy_read_timeout 86400s"), "Nginx /api/events must keep SSE connections open");
|
||||||
|
|
||||||
|
const weatherSources = readRepoFile("src", "data_collection", "weather_sources.py");
|
||||||
|
assert(weatherSources.includes("_emit_temperature_patch_if_changed"), "collector must centralize temperature patch emission");
|
||||||
|
assert(weatherSources.includes("requests.post"), "collector must POST patches to the internal endpoint");
|
||||||
|
assert(weatherSources.includes("/api/internal/collector-patch"), "collector must POST to /api/internal/collector-patch");
|
||||||
|
assert(weatherSources.includes("threading.Thread"), "collector patch POST must run in a separate thread");
|
||||||
|
|
||||||
|
const hookPath = path.join(process.cwd(), "hooks", "use-sse-patches.ts");
|
||||||
|
assert(fs.existsSync(hookPath), "frontend must define hooks/use-sse-patches.ts");
|
||||||
|
const hook = fs.readFileSync(hookPath, "utf8");
|
||||||
|
assert(hook.includes("new EventSource"), "frontend patch hook must connect with EventSource");
|
||||||
|
assert(hook.includes("/api/events"), "frontend patch hook must subscribe to /api/events");
|
||||||
|
assert(hook.includes("Map<"), "frontend patch hook must keep latest patches in a Map");
|
||||||
|
assert(hook.includes("useLatestPatch"), "frontend patch hook must export useLatestPatch(city)");
|
||||||
|
assert(hook.includes("revision"), "frontend patch hook must track revisions and skip stale patches");
|
||||||
|
assert(hook.includes("setTimeout"), "frontend patch hook must implement explicit reconnect backoff");
|
||||||
|
|
||||||
|
const chart = readFrontendFile("components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx");
|
||||||
|
assert(chart.includes("useLatestPatch"), "temperature chart must consume useLatestPatch(city)");
|
||||||
|
assert(chart.includes("latestPatch"), "temperature chart must react to incoming SSE patches");
|
||||||
|
assert(chart.includes("2 * 60_000"), "temperature chart must wait two minutes without patches before full-fetch fallback");
|
||||||
|
assert(
|
||||||
|
!chart.includes("setInterval(poll, 60_000)"),
|
||||||
|
"temperature chart must not use unconditional 60-second full-detail polling after SSE patch migration",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export type RemoteData<T> =
|
|||||||
| { status: "error"; error: string; previous?: T };
|
| { status: "error"; error: string; previous?: T };
|
||||||
|
|
||||||
export const scanTerminalQueryPolicy = {
|
export const scanTerminalQueryPolicy = {
|
||||||
autoRefreshMs: DASHBOARD_REFRESH_POLICY_MS.scanRows,
|
autoRefreshMs: null,
|
||||||
manualForceRefreshCooldownMs: 2 * 60_000,
|
manualForceRefreshCooldownMs: 2 * 60_000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
scanTerminalQueryPolicy,
|
|
||||||
scanTerminalClient,
|
scanTerminalClient,
|
||||||
shouldRunAutoTerminalRefresh,
|
|
||||||
shouldSkipManualTerminalRefresh,
|
shouldSkipManualTerminalRefresh,
|
||||||
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||||
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
|
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
|
||||||
import { REGIONS } from "@/components/dashboard/scan-terminal/continent-grouping";
|
import { REGIONS } from "@/components/dashboard/scan-terminal/continent-grouping";
|
||||||
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
import { DASHBOARD_REFRESH_POLICY_MS } from "@/lib/refresh-policy";
|
||||||
|
import {
|
||||||
|
getLatestPatchesSnapshot,
|
||||||
|
useSsePatchVersion,
|
||||||
|
type CityPatch,
|
||||||
|
} from "@/hooks/use-sse-patches";
|
||||||
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
|
const SCAN_CACHE_PREFIX = "polyweather_scan_v2";
|
||||||
@@ -35,6 +38,44 @@ function writeScanCache(data: ScanTerminalResponse, tradingRegion: string) {
|
|||||||
try { localStorage.setItem(scanCacheKey(tradingRegion), JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ }
|
try { localStorage.setItem(scanCacheKey(tradingRegion), JSON.stringify({ ts: Date.now(), data })); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCityKey(city: string | null | undefined) {
|
||||||
|
return String(city || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPatchToScanRow(row: any, patch: CityPatch) {
|
||||||
|
const temp = typeof patch.changes.temp === "number" && Number.isFinite(patch.changes.temp)
|
||||||
|
? patch.changes.temp
|
||||||
|
: null;
|
||||||
|
if (temp === null) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
current_temp: temp,
|
||||||
|
current_max_so_far: Math.max(
|
||||||
|
temp,
|
||||||
|
typeof row.current_max_so_far === "number" && Number.isFinite(row.current_max_so_far)
|
||||||
|
? row.current_max_so_far
|
||||||
|
: temp,
|
||||||
|
),
|
||||||
|
local_time: typeof patch.changes.obs_time === "string" ? patch.changes.obs_time : row.local_time,
|
||||||
|
sse_revision: patch.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTerminalPatches(
|
||||||
|
data: ScanTerminalResponse | null | undefined,
|
||||||
|
patches: Map<string, CityPatch>,
|
||||||
|
): ScanTerminalResponse | null | undefined {
|
||||||
|
if (!data?.rows?.length || !patches.size) return data;
|
||||||
|
let changed = false;
|
||||||
|
const rows = data.rows.map((row: any) => {
|
||||||
|
const patch = patches.get(normalizeCityKey(row.city));
|
||||||
|
if (!patch) return row;
|
||||||
|
changed = true;
|
||||||
|
return applyPatchToScanRow(row, patch);
|
||||||
|
});
|
||||||
|
return changed ? { ...data, rows } : data;
|
||||||
|
}
|
||||||
|
|
||||||
export function useScanTerminalQuery({
|
export function useScanTerminalQuery({
|
||||||
isPro,
|
isPro,
|
||||||
proAccessLoading,
|
proAccessLoading,
|
||||||
@@ -49,7 +90,6 @@ export function useScanTerminalQuery({
|
|||||||
const {
|
const {
|
||||||
data: terminalData,
|
data: terminalData,
|
||||||
error: scanError,
|
error: scanError,
|
||||||
isLoading,
|
|
||||||
loading: scanLoading,
|
loading: scanLoading,
|
||||||
remote: scanRemote,
|
remote: scanRemote,
|
||||||
reset,
|
reset,
|
||||||
@@ -57,6 +97,7 @@ export function useScanTerminalQuery({
|
|||||||
} = useRemoteDataQuery<ScanTerminalResponse>();
|
} = useRemoteDataQuery<ScanTerminalResponse>();
|
||||||
|
|
||||||
const lastForcedScanRefreshAtRef = useRef(0);
|
const lastForcedScanRefreshAtRef = useRef(0);
|
||||||
|
const patchVersion = useSsePatchVersion();
|
||||||
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
|
const [cachedRows, setCachedRows] = useState<ScanTerminalResponse | null>(() => {
|
||||||
if (typeof window !== "undefined") return readScanCache(tradingRegion || "");
|
if (typeof window !== "undefined") return readScanCache(tradingRegion || "");
|
||||||
return null;
|
return null;
|
||||||
@@ -101,7 +142,10 @@ export function useScanTerminalQuery({
|
|||||||
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
||||||
}, [fetchScanTerminal, isPro, proAccessLoading, reset, timezoneOffsetSeconds, tradingRegion]);
|
}, [fetchScanTerminal, isPro, proAccessLoading, reset, timezoneOffsetSeconds, tradingRegion]);
|
||||||
|
|
||||||
const effectiveData = terminalData || cachedRows;
|
const effectiveData = useMemo(
|
||||||
|
() => applyTerminalPatches(terminalData || cachedRows, getLatestPatchesSnapshot()),
|
||||||
|
[terminalData, cachedRows, patchVersion],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshScanTerminalManually = useCallback(() => {
|
const refreshScanTerminalManually = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -115,29 +159,6 @@ export function useScanTerminalQuery({
|
|||||||
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
||||||
}, [fetchScanTerminal, terminalData]);
|
}, [fetchScanTerminal, terminalData]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (proAccessLoading || !isPro) return;
|
|
||||||
if (
|
|
||||||
typeof window === "undefined" ||
|
|
||||||
typeof window.setInterval !== "function" ||
|
|
||||||
typeof window.clearInterval !== "function"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
if (
|
|
||||||
!shouldRunAutoTerminalRefresh({
|
|
||||||
documentHidden: document.hidden,
|
|
||||||
isLoading: isLoading(),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void fetchScanTerminal({ forceRefresh: false, showLoading: false });
|
|
||||||
}, scanTerminalQueryPolicy.autoRefreshMs);
|
|
||||||
return () => window.clearInterval(intervalId);
|
|
||||||
}, [fetchScanTerminal, isLoading, isPro, proAccessLoading]);
|
|
||||||
|
|
||||||
// Preload adjacent regions in idle time for instant tab switches
|
// Preload adjacent regions in idle time for instant tab switches
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !tradingRegion || !isPro) return;
|
if (typeof window === "undefined" || !tradingRegion || !isPro) return;
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
|
import { resolveBackendApiUrl } from "@/lib/backend-api";
|
||||||
|
|
||||||
|
export type CityPatch = {
|
||||||
|
type?: string;
|
||||||
|
city: string;
|
||||||
|
changes: Record<string, unknown>;
|
||||||
|
revision: number;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestPatches = new Map<string, CityPatch>();
|
||||||
|
const latestRevisions = new Map<string, number>();
|
||||||
|
const cityListeners = new Map<string, Set<() => void>>();
|
||||||
|
const globalListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
let eventSource: EventSource | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let patchVersion = 0;
|
||||||
|
|
||||||
|
function normalizeCityKey(city: string | null | undefined) {
|
||||||
|
return String(city || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(city: string) {
|
||||||
|
patchVersion += 1;
|
||||||
|
cityListeners.get(city)?.forEach((listener) => listener());
|
||||||
|
globalListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnectTimer || typeof window === "undefined") return;
|
||||||
|
const delayMs = Math.min(30_000, 1_000 * Math.max(1, 2 ** reconnectAttempt));
|
||||||
|
reconnectAttempt += 1;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
connectSsePatches();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEventSource() {
|
||||||
|
if (!eventSource) return;
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSsePatches() {
|
||||||
|
if (typeof window === "undefined" || eventSource) return;
|
||||||
|
closeEventSource();
|
||||||
|
eventSource = new EventSource(resolveBackendApiUrl("/api/events"));
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
applySsePatch(JSON.parse(event.data));
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed frames; the stream stays alive.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
closeEventSource();
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSsePatchConnection() {
|
||||||
|
connectSsePatches();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySsePatch(payload: unknown) {
|
||||||
|
if (!payload || typeof payload !== "object") return false;
|
||||||
|
const patch = payload as Partial<CityPatch>;
|
||||||
|
if (patch.type && patch.type !== "city_patch") return false;
|
||||||
|
const city = normalizeCityKey(patch.city);
|
||||||
|
const changes = patch.changes;
|
||||||
|
const revision = Number(patch.revision);
|
||||||
|
if (!city || !changes || typeof changes !== "object" || !Number.isFinite(revision)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRevision = latestRevisions.get(city) ?? 0;
|
||||||
|
if (revision <= previousRevision) return false;
|
||||||
|
|
||||||
|
const normalizedPatch: CityPatch = {
|
||||||
|
type: "city_patch",
|
||||||
|
city,
|
||||||
|
changes: changes as Record<string, unknown>,
|
||||||
|
revision,
|
||||||
|
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
||||||
|
};
|
||||||
|
latestRevisions.set(city, revision);
|
||||||
|
latestPatches.set(city, normalizedPatch);
|
||||||
|
notify(city);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestPatchesSnapshot() {
|
||||||
|
return latestPatches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSsePatchVersion() {
|
||||||
|
useEffect(() => {
|
||||||
|
ensureSsePatchConnection();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
globalListeners.add(listener);
|
||||||
|
return () => globalListeners.delete(listener);
|
||||||
|
},
|
||||||
|
() => patchVersion,
|
||||||
|
() => 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLatestPatch(city: string | null | undefined) {
|
||||||
|
const cityKey = normalizeCityKey(city);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureSsePatchConnection();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useSyncExternalStore(
|
||||||
|
(listener) => {
|
||||||
|
if (!cityKey) return () => {};
|
||||||
|
const listeners = cityListeners.get(cityKey) ?? new Set<() => void>();
|
||||||
|
listeners.add(listener);
|
||||||
|
cityListeners.set(cityKey, listeners);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (!listeners.size) cityListeners.delete(cityKey);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
() => (cityKey ? latestPatches.get(cityKey) ?? null : null),
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __applySsePatchForTest = applySsePatch;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
import re
|
import re
|
||||||
|
import requests
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional, Dict, List
|
from typing import Any, Optional, Dict, List
|
||||||
@@ -268,6 +269,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
)
|
)
|
||||||
self._disk_cache_lock = threading.Lock()
|
self._disk_cache_lock = threading.Lock()
|
||||||
self._disk_cache_last_mtime: float = 0.0
|
self._disk_cache_last_mtime: float = 0.0
|
||||||
|
self.collector_patch_endpoint = str(
|
||||||
|
os.getenv(
|
||||||
|
"POLYWEATHER_COLLECTOR_PATCH_ENDPOINT",
|
||||||
|
"http://127.0.0.1:8000/api/internal/collector-patch",
|
||||||
|
)
|
||||||
|
).strip()
|
||||||
|
self._last_emitted_temperature_patch: Dict[str, tuple[float, str]] = {}
|
||||||
|
self._temperature_patch_lock = threading.Lock()
|
||||||
self._load_open_meteo_disk_cache()
|
self._load_open_meteo_disk_cache()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Open-Meteo 磁盘缓存路径: {self._disk_cache_path} (max_age={self._disk_cache_max_age_sec}s)"
|
f"Open-Meteo 磁盘缓存路径: {self._disk_cache_path} (max_age={self._disk_cache_max_age_sec}s)"
|
||||||
@@ -338,6 +347,62 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
with lock:
|
with lock:
|
||||||
for key in stale:
|
for key in stale:
|
||||||
cache.pop(key, None)
|
cache.pop(key, None)
|
||||||
|
|
||||||
|
def _emit_temperature_patch_if_changed(
|
||||||
|
self,
|
||||||
|
city: str,
|
||||||
|
temp: Any,
|
||||||
|
obs_time: Any = None,
|
||||||
|
*,
|
||||||
|
source: str = "",
|
||||||
|
extra: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
if temp is None or not self.collector_patch_endpoint:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
temp_value = round(float(temp), 2)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
obs_time_value = str(obs_time or datetime.now(timezone.utc).isoformat())
|
||||||
|
source_value = str(source or "weather").strip().lower() or "weather"
|
||||||
|
city_value = str(city or "").strip().lower()
|
||||||
|
if not city_value:
|
||||||
|
return
|
||||||
|
|
||||||
|
dedupe_key = f"{city_value}:{source_value}"
|
||||||
|
signature = (temp_value, obs_time_value)
|
||||||
|
with self._temperature_patch_lock:
|
||||||
|
if self._last_emitted_temperature_patch.get(dedupe_key) == signature:
|
||||||
|
return
|
||||||
|
self._last_emitted_temperature_patch[dedupe_key] = signature
|
||||||
|
|
||||||
|
changes: Dict[str, Any] = {
|
||||||
|
"temp": temp_value,
|
||||||
|
"obs_time": obs_time_value,
|
||||||
|
"source": source_value,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
changes.update(extra)
|
||||||
|
|
||||||
|
payload = {"city": city_value, "changes": changes}
|
||||||
|
|
||||||
|
def post_patch() -> None:
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
self.collector_patch_endpoint,
|
||||||
|
json=payload,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
"collector patch POST failed city={} source={}: {}",
|
||||||
|
city_value,
|
||||||
|
source_value,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.Thread(target=post_patch, daemon=True).start()
|
||||||
# MADIS cache is a single list, not a keyed dict — expire on age
|
# MADIS cache is a single list, not a keyed dict — expire on age
|
||||||
with self._madis_cache_lock:
|
with self._madis_cache_lock:
|
||||||
if self._madis_cache is not None and now - self._madis_cache_ts > self.madis_cache_ttl_sec * 2:
|
if self._madis_cache is not None and now - self._madis_cache_ts > self.madis_cache_ttl_sec * 2:
|
||||||
@@ -878,6 +943,13 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
settlement_current = self.fetch_settlement_current(city_lower)
|
settlement_current = self.fetch_settlement_current(city_lower)
|
||||||
if settlement_current:
|
if settlement_current:
|
||||||
results["settlement_current"] = settlement_current
|
results["settlement_current"] = settlement_current
|
||||||
|
current = settlement_current.get("current") or {}
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
current.get("temp"),
|
||||||
|
settlement_current.get("observation_time") or current.get("obs_time"),
|
||||||
|
source=str(settlement_current.get("source") or "settlement"),
|
||||||
|
)
|
||||||
|
|
||||||
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower()) or {}
|
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower()) or {}
|
||||||
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
||||||
@@ -905,9 +977,15 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
if not mgm_data:
|
if not mgm_data:
|
||||||
return
|
return
|
||||||
results["mgm"] = mgm_data
|
results["mgm"] = mgm_data
|
||||||
|
mgm_current = mgm_data.get("current") or {}
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
mgm_current.get("temp"),
|
||||||
|
mgm_data.get("obs_time"),
|
||||||
|
source="mgm",
|
||||||
|
)
|
||||||
# Log airport obs for high-freq monitoring (Ankara 17128)
|
# Log airport obs for high-freq monitoring (Ankara 17128)
|
||||||
try:
|
try:
|
||||||
mgm_current = mgm_data.get("current") or {}
|
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=str(istno),
|
icao=str(istno),
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
@@ -966,8 +1044,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
ims_data = self.fetch_from_ims(self.IMS_LOD_AIRPORT_STATION)
|
ims_data = self.fetch_from_ims(self.IMS_LOD_AIRPORT_STATION)
|
||||||
if ims_data:
|
if ims_data:
|
||||||
results["ims"] = ims_data
|
results["ims"] = ims_data
|
||||||
|
ims_current = ims_data.get("current") or {}
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
ims_current.get("temp"),
|
||||||
|
ims_data.get("obs_time"),
|
||||||
|
source="ims",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
ims_current = ims_data.get("current") or {}
|
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao="LLBG",
|
icao="LLBG",
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
@@ -984,8 +1068,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
ncm_data = self.fetch_from_ncm()
|
ncm_data = self.fetch_from_ncm()
|
||||||
if ncm_data:
|
if ncm_data:
|
||||||
results["ncm"] = ncm_data
|
results["ncm"] = ncm_data
|
||||||
|
ncm_current = ncm_data.get("current") or {}
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
ncm_current.get("temp"),
|
||||||
|
ncm_data.get("obs_time"),
|
||||||
|
source="ncm",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
ncm_current = ncm_data.get("current") or {}
|
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao="OEJN",
|
icao="OEJN",
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
@@ -1002,8 +1092,14 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
aw_data = self.fetch_from_aeroweb()
|
aw_data = self.fetch_from_aeroweb()
|
||||||
if aw_data:
|
if aw_data:
|
||||||
results["aeroweb"] = aw_data
|
results["aeroweb"] = aw_data
|
||||||
|
aw_current = aw_data.get("current") or {}
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
aw_current.get("temp"),
|
||||||
|
aw_data.get("obs_time"),
|
||||||
|
source="aeroweb",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
aw_current = aw_data.get("current") or {}
|
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao="LFPB",
|
icao="LFPB",
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
@@ -1031,6 +1127,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
return
|
return
|
||||||
results["jma_official_nearby"] = official_rows
|
results["jma_official_nearby"] = official_rows
|
||||||
results["jma_current"] = official_rows[0] # primary station for airport_primary
|
results["jma_current"] = official_rows[0] # primary station for airport_primary
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
official_rows[0].get("temp"),
|
||||||
|
official_rows[0].get("obs_time"),
|
||||||
|
source="jma_amedas",
|
||||||
|
)
|
||||||
if "mgm_nearby" not in results:
|
if "mgm_nearby" not in results:
|
||||||
results["mgm_nearby"] = official_rows
|
results["mgm_nearby"] = official_rows
|
||||||
results["nearby_source"] = "jma"
|
results["nearby_source"] = "jma"
|
||||||
@@ -1058,6 +1160,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
return
|
return
|
||||||
results["knmi_official_nearby"] = rows
|
results["knmi_official_nearby"] = rows
|
||||||
results["knmi_current"] = rows[0] # primary station for airport_primary
|
results["knmi_current"] = rows[0] # primary station for airport_primary
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
rows[0].get("temp"),
|
||||||
|
rows[0].get("obs_time"),
|
||||||
|
source="knmi",
|
||||||
|
)
|
||||||
if "mgm_nearby" not in results:
|
if "mgm_nearby" not in results:
|
||||||
results["mgm_nearby"] = rows
|
results["mgm_nearby"] = rows
|
||||||
results["nearby_source"] = "knmi"
|
results["nearby_source"] = "knmi"
|
||||||
@@ -1084,6 +1192,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
return
|
return
|
||||||
results["fmi_official_nearby"] = rows
|
results["fmi_official_nearby"] = rows
|
||||||
results["fmi_current"] = rows[0] # primary station for airport_primary
|
results["fmi_current"] = rows[0] # primary station for airport_primary
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
rows[0].get("temp"),
|
||||||
|
rows[0].get("obs_time"),
|
||||||
|
source="fmi",
|
||||||
|
)
|
||||||
if "mgm_nearby" not in results:
|
if "mgm_nearby" not in results:
|
||||||
results["mgm_nearby"] = rows
|
results["mgm_nearby"] = rows
|
||||||
results["nearby_source"] = "fmi"
|
results["nearby_source"] = "fmi"
|
||||||
@@ -1110,6 +1224,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
results["hko_obs_nearby"] = rows
|
results["hko_obs_nearby"] = rows
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
rows[0].get("temp"),
|
||||||
|
rows[0].get("obs_time"),
|
||||||
|
source="hko_obs",
|
||||||
|
)
|
||||||
if "mgm_nearby" not in results:
|
if "mgm_nearby" not in results:
|
||||||
results["mgm_nearby"] = rows
|
results["mgm_nearby"] = rows
|
||||||
results["nearby_source"] = "hko_obs"
|
results["nearby_source"] = "hko_obs"
|
||||||
@@ -1136,6 +1256,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
return
|
return
|
||||||
results["cowin_obs_nearby"] = rows
|
results["cowin_obs_nearby"] = rows
|
||||||
results["cowin_current"] = rows[0]
|
results["cowin_current"] = rows[0]
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
rows[0].get("temp"),
|
||||||
|
rows[0].get("obs_time"),
|
||||||
|
source="cowin_obs",
|
||||||
|
)
|
||||||
results["mgm_nearby"] = rows
|
results["mgm_nearby"] = rows
|
||||||
results["nearby_source"] = "cowin_obs"
|
results["nearby_source"] = "cowin_obs"
|
||||||
try:
|
try:
|
||||||
@@ -1192,6 +1318,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
}
|
}
|
||||||
results["mgm_nearby"] = [row]
|
results["mgm_nearby"] = [row]
|
||||||
results["nearby_source"] = "cwa"
|
results["nearby_source"] = "cwa"
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
temp,
|
||||||
|
sc.get("observation_time"),
|
||||||
|
source="cwa",
|
||||||
|
)
|
||||||
|
|
||||||
def _attach_korean_amos_data(
|
def _attach_korean_amos_data(
|
||||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
@@ -1209,6 +1341,13 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
city_lower, amos_data.get("temp_c"), amos_data.get("temp_source"),
|
city_lower, amos_data.get("temp_c"), amos_data.get("temp_source"),
|
||||||
len(amos_data.get("runway_obs", {}).get("runway_pairs", []) or []))
|
len(amos_data.get("runway_obs", {}).get("runway_pairs", []) or []))
|
||||||
results["amos"] = amos_data
|
results["amos"] = amos_data
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
amos_data.get("temp_c"),
|
||||||
|
amos_data.get("observation_time"),
|
||||||
|
source="amos",
|
||||||
|
extra={"amos": amos_data},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=amos_data.get("icao") or "",
|
icao=amos_data.get("icao") or "",
|
||||||
@@ -1255,6 +1394,13 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
)
|
)
|
||||||
# Reuse the existing `amos` detail shape consumed by dashboard runway panels.
|
# Reuse the existing `amos` detail shape consumed by dashboard runway panels.
|
||||||
results["amos"] = amsc_data
|
results["amos"] = amsc_data
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
amsc_data.get("temp_c"),
|
||||||
|
amsc_data.get("observation_time"),
|
||||||
|
source="amsc_awos",
|
||||||
|
extra={"amos": amsc_data},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
icao = amsc_data.get("icao") or ""
|
icao = amsc_data.get("icao") or ""
|
||||||
obs_time = amsc_data.get("observation_time") or datetime.now().isoformat()
|
obs_time = amsc_data.get("observation_time") or datetime.now().isoformat()
|
||||||
@@ -1332,6 +1478,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
if obs["icao"] == icao:
|
if obs["icao"] == icao:
|
||||||
# Inject into results so it flows through to airport_primary
|
# Inject into results so it flows through to airport_primary
|
||||||
results["madis_hfmetar_current"] = obs
|
results["madis_hfmetar_current"] = obs
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
obs.get("temp_c"),
|
||||||
|
obs.get("obs_time"),
|
||||||
|
source="madis_hfmetar",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=icao,
|
icao=icao,
|
||||||
@@ -1359,6 +1511,12 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
return
|
return
|
||||||
|
|
||||||
results["singapore_mss_current"] = obs
|
results["singapore_mss_current"] = obs
|
||||||
|
self._emit_temperature_patch_if_changed(
|
||||||
|
city_lower,
|
||||||
|
obs.get("temp_c"),
|
||||||
|
obs.get("obs_time"),
|
||||||
|
source="singapore_mss",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao="WSSS",
|
icao="WSSS",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from web.routers.auth import router as auth_router
|
|||||||
from web.routers.ops import router as ops_router
|
from web.routers.ops import router as ops_router
|
||||||
from web.routers.payments import router as payments_router
|
from web.routers.payments import router as payments_router
|
||||||
from web.routers.scan import router as scan_router
|
from web.routers.scan import router as scan_router
|
||||||
|
from web.routers.sse_router import router as sse_router
|
||||||
from web.routers.system import router as system_router
|
from web.routers.system import router as system_router
|
||||||
from web.routes import router as legacy_router
|
from web.routes import router as legacy_router
|
||||||
from web.scan_terminal_service import start_scan_terminal_prewarm
|
from web.scan_terminal_service import start_scan_terminal_prewarm
|
||||||
@@ -37,6 +38,7 @@ def create_app() -> FastAPI:
|
|||||||
core_app.include_router(auth_router)
|
core_app.include_router(auth_router)
|
||||||
core_app.include_router(analytics_router)
|
core_app.include_router(analytics_router)
|
||||||
core_app.include_router(scan_router)
|
core_app.include_router(scan_router)
|
||||||
|
core_app.include_router(sse_router)
|
||||||
core_app.include_router(payments_router)
|
core_app.include_router(payments_router)
|
||||||
core_app.include_router(ops_router)
|
core_app.include_router(ops_router)
|
||||||
core_app.include_router(legacy_router)
|
core_app.include_router(legacy_router)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""SSE endpoints for live terminal patch delivery."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from web.sse_manager import sse_manager
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/events")
|
||||||
|
async def sse_events(request: Request):
|
||||||
|
user_id = getattr(request.state, "auth_user_id", None) or "anon"
|
||||||
|
return StreamingResponse(
|
||||||
|
sse_manager.event_stream(user_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/internal/collector-patch")
|
||||||
|
async def ingest_patch(patch: dict[str, Any]):
|
||||||
|
city = str(patch.get("city") or "").strip().lower()
|
||||||
|
changes = patch.get("changes")
|
||||||
|
if not city:
|
||||||
|
raise HTTPException(status_code=400, detail="city is required")
|
||||||
|
if not isinstance(changes, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="changes must be an object")
|
||||||
|
event = sse_manager.broadcast(city, changes)
|
||||||
|
return {"ok": True, "revision": event["revision"]}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""In-process SSE patch broadcaster for live terminal updates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, AsyncIterator, DefaultDict
|
||||||
|
|
||||||
|
|
||||||
|
HEARTBEAT_INTERVAL_SECONDS = 30
|
||||||
|
QUEUE_MAXSIZE = 256
|
||||||
|
|
||||||
|
|
||||||
|
class SseManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._queues: DefaultDict[str, set[asyncio.Queue[dict[str, Any]]]] = defaultdict(set)
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._revision = 0
|
||||||
|
|
||||||
|
def _next_revision(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
self._revision += 1
|
||||||
|
return self._revision
|
||||||
|
|
||||||
|
def broadcast(self, city: str, changes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
event = {
|
||||||
|
"type": "city_patch",
|
||||||
|
"city": str(city or "").strip().lower(),
|
||||||
|
"changes": changes or {},
|
||||||
|
"revision": self._next_revision(),
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
if not event["city"]:
|
||||||
|
return event
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
queues = [queue for queue_set in self._queues.values() for queue in queue_set]
|
||||||
|
|
||||||
|
for queue in queues:
|
||||||
|
try:
|
||||||
|
queue.put_nowait(event)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
try:
|
||||||
|
queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
queue.put_nowait(event)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def event_stream(self, user_id: str) -> AsyncIterator[str]:
|
||||||
|
user_key = str(user_id or "anon")
|
||||||
|
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=QUEUE_MAXSIZE)
|
||||||
|
with self._lock:
|
||||||
|
self._queues[user_key].add(queue)
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield self._format_event({
|
||||||
|
"type": "connected",
|
||||||
|
"revision": self._revision,
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
})
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = await asyncio.wait_for(
|
||||||
|
queue.get(),
|
||||||
|
timeout=HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
event = {
|
||||||
|
"type": "heartbeat",
|
||||||
|
"revision": self._revision,
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
yield self._format_event(event)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._queues[user_key].discard(queue)
|
||||||
|
if not self._queues[user_key]:
|
||||||
|
self._queues.pop(user_key, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_event(event: dict[str, Any]) -> str:
|
||||||
|
return f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
sse_manager = SseManager()
|
||||||
Reference in New Issue
Block a user