feat: implement SSE-based real-time event distribution system with replay support and heartbeat functionality
This commit is contained in:
@@ -21,15 +21,35 @@ export function runTests() {
|
||||
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("broadcast_event"), "SSE manager must broadcast stored replayable events");
|
||||
assert(sseManager.includes("event_stream("), "SSE manager must expose an async event_stream(user_id)");
|
||||
assert(sseManager.includes("_queue_cities"), "SSE manager must track per-connection city subscriptions");
|
||||
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 schemaPath = path.join(repoRoot, "web", "realtime_patch_schema.py");
|
||||
assert(fs.existsSync(schemaPath), "backend must define a versioned realtime patch schema module");
|
||||
const schema = fs.readFileSync(schemaPath, "utf8");
|
||||
assert(schema.includes("city_observation_patch.v1"), "patch schema must expose city_observation_patch.v1");
|
||||
assert(schema.includes("normalize_observation_patch"), "patch schema must normalize collector payloads");
|
||||
assert(schema.includes("runway_points"), "patch schema must preserve runway point observations");
|
||||
|
||||
const storePath = path.join(repoRoot, "web", "realtime_event_store.py");
|
||||
assert(fs.existsSync(storePath), "backend must define a realtime event replay store");
|
||||
const store = fs.readFileSync(storePath, "utf8");
|
||||
assert(store.includes("observation_patch_events"), "event store must use the SQLite observation_patch_events table");
|
||||
assert(store.includes("replay_events"), "event store must expose replay_events");
|
||||
assert(store.includes("replay_requires_resync"), "event store must detect incomplete replay windows");
|
||||
|
||||
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("cities"), "SSE route must accept a cities query parameter");
|
||||
assert(sseRouter.includes("since_revision"), "SSE route must accept since_revision for replay");
|
||||
assert(sseRouter.includes("replay_limit"), "SSE route must bound replay batches");
|
||||
assert(sseRouter.includes("resync_required"), "SSE route must emit resync_required when replay is incomplete");
|
||||
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");
|
||||
@@ -53,14 +73,24 @@ export function runTests() {
|
||||
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("city_observation_patch.v1"), "frontend patch hook must accept v1 observation patch events");
|
||||
assert(hook.includes("subscribedCities"), "frontend patch hook must track the visible city subscription set");
|
||||
assert(hook.includes("since_revision"), "frontend patch hook must reconnect with since_revision");
|
||||
assert(hook.includes("resync_required"), "frontend patch hook must react to server resync_required events");
|
||||
assert(hook.includes("lastRevision"), "frontend patch hook must track the global last processed revision");
|
||||
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 bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts");
|
||||
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
|
||||
|
||||
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("useSseResyncVersion"), "temperature chart must resync full detail when SSE replay is incomplete");
|
||||
assert(chart.includes("runway_points"), "temperature chart must merge v1 runway_points into runway history");
|
||||
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)"),
|
||||
|
||||
+92
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
__buildTemperatureChartDataForTest,
|
||||
__getActiveTemperatureSeriesForTest,
|
||||
__getObservationDisplayMetricsForTest,
|
||||
__getVisibleTemperatureSeriesForTest,
|
||||
__isTemperatureSeriesVisibleByDefaultForTest,
|
||||
__mergePatchIntoHourlyForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
@@ -57,6 +59,7 @@ export function runTests() {
|
||||
|
||||
const { series } = __buildTemperatureChartDataForTest(guangzhou, hourly, "1D");
|
||||
const defaultVisibleSeries = __getVisibleTemperatureSeriesForTest("guangzhou", series, {});
|
||||
const activeDefaultSeries = __getActiveTemperatureSeriesForTest("guangzhou", series, {}, true);
|
||||
|
||||
const settlementRunway = seriesByKey(series, "runway_02L_20R") as any;
|
||||
assert(settlementRunway, "settlement runway should use a stable runway-pair key");
|
||||
@@ -77,6 +80,14 @@ export function runTests() {
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "runway_02L_20R"),
|
||||
"runway series should be visible by default",
|
||||
);
|
||||
assert(
|
||||
activeDefaultSeries.some((item) => item.key === "runway_02L_20R"),
|
||||
"settlement runway should remain in the active chart series by default",
|
||||
);
|
||||
assert(
|
||||
activeDefaultSeries.some((item) => item.key === "runway_01L_19R"),
|
||||
"auxiliary runway should remain in the active chart series by default",
|
||||
);
|
||||
assert(
|
||||
__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "settlement"),
|
||||
"settlement/HKO observations should be visible by default",
|
||||
@@ -284,6 +295,87 @@ export function runTests() {
|
||||
assert(madisSeries.label.includes("MADIS"), "US MADIS series should be labeled as NOAA MADIS instead of plain METAR");
|
||||
assert(madisSeries.values.filter((value: number | null) => value !== null).length >= 2, "MADIS series should keep sub-hourly observations");
|
||||
|
||||
const newYorkMinuteStream = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "new york",
|
||||
local_date: "2026-05-25",
|
||||
local_time: "10:04",
|
||||
tz_offset_seconds: -4 * 60 * 60,
|
||||
airport: "KLGA",
|
||||
} as any,
|
||||
{
|
||||
localTime: "10:04",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [55, 57, 65, 72],
|
||||
airportPrimary: {
|
||||
source_code: "madis_hfmetar",
|
||||
source_label: "NOAA MADIS",
|
||||
},
|
||||
airportPrimaryTodayObs: [
|
||||
["2026-05-25T14:01:00Z", 73.1],
|
||||
["2026-05-25T14:02:00Z", 73.4],
|
||||
["2026-05-25T14:03:00Z", 73.8],
|
||||
],
|
||||
} as any,
|
||||
"1D",
|
||||
);
|
||||
const minuteLabels = newYorkMinuteStream.data
|
||||
.filter((point) => point.madis !== null)
|
||||
.map((point) => point.label);
|
||||
assert(
|
||||
minuteLabels.includes("10:01") &&
|
||||
minuteLabels.includes("10:02") &&
|
||||
minuteLabels.includes("10:03"),
|
||||
"live observation chart should preserve minute-level SSE patch points instead of collapsing them into a 30-minute bucket",
|
||||
);
|
||||
|
||||
const chengduMergedHourly = __mergePatchIntoHourlyForTest(
|
||||
{
|
||||
localTime: "05:25",
|
||||
times: ["00:00", "06:00", "12:00", "18:00"],
|
||||
temps: [24, 28, 31, 27],
|
||||
runwayPlateHistory: {
|
||||
"02L/20R": [{ time: "05:20", temp: 24.2 }],
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
type: "city_observation_patch.v1",
|
||||
city: "chengdu",
|
||||
revision: 12,
|
||||
changes: {
|
||||
temp: 24.8,
|
||||
obs_time: "2026-05-26 05:26:00",
|
||||
source: "amsc_awos",
|
||||
runway_points: [
|
||||
{
|
||||
runway: "02L/20R",
|
||||
temp: 25.1,
|
||||
tdz_temp: 24.7,
|
||||
mid_temp: 24.9,
|
||||
end_temp: 25.1,
|
||||
target_runway_max: 25.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
);
|
||||
const chengduMergedChart = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "chengdu",
|
||||
local_date: "2026-05-26",
|
||||
local_time: "05:26",
|
||||
tz_offset_seconds: 8 * 60 * 60,
|
||||
} as any,
|
||||
chengduMergedHourly as any,
|
||||
"1D",
|
||||
);
|
||||
const chengduMergedRunway = seriesByKey(chengduMergedChart.series, "runway_02L_20R") as any;
|
||||
assert(chengduMergedRunway, "v1 runway_points patch should update the runway series");
|
||||
assert(
|
||||
chengduMergedRunway.values.some((value: number | null) => value === 25.1),
|
||||
"v1 runway_points patch should append the latest runway max point to the chart",
|
||||
);
|
||||
|
||||
const shanghaiDebFromDetail = __buildTemperatureChartDataForTest(
|
||||
{
|
||||
city: "shanghai",
|
||||
|
||||
Reference in New Issue
Block a user