Add chart freshness diagnostics

This commit is contained in:
2569718930@qq.com
2026-06-10 15:34:34 +08:00
parent ba012736c9
commit 5a7d302d11
14 changed files with 833 additions and 42 deletions
@@ -6,6 +6,7 @@ import {
} from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
__buildChartFreshnessContextForTest,
__getInitialDetailLoadDelayMsForTest,
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
@@ -15,9 +16,11 @@ import {
HOURLY_CACHE_TTL_MS,
__resolveCityDetailFromBatchForTest,
__readHourlyCacheEntryForTest,
__rememberCityDetailBatchDiagnosticsForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
readCityDetailBatchDiagnostics,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) {
@@ -119,6 +122,90 @@ export async function runTests() {
__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(
typeof __buildChartFreshnessContextForTest === "function",
"temperature charts should expose a structured freshness context for feedback diagnostics",
);
const degradedFreshness = __buildChartFreshnessContextForTest(
{
rowAppliedAtMs: 1_000,
rowObservationTime: "12:45",
ssePatchAppliedAtMs: 5_000,
sseObservedAt: "2026-06-10T04:48:00Z",
sseRevision: 42,
detailRequestedAtMs: 7_000,
detailResolvedAtMs: null,
detailErrorAtMs: 9_000,
detailStatus: "degraded",
detailSource: "partial_or_timeout",
sseEmittedAtMs: 5_500,
sseServerToClientLatencySec: 5,
sseCollectorToClientLatencySec: 60,
sseSourceToCollectorLatencySec: 64,
},
11_000,
);
assert(
degradedFreshness.live_path === "sse" &&
degradedFreshness.live_age_sec === 6 &&
degradedFreshness.row_applied_age_sec === 10 &&
degradedFreshness.sse_patch_age_sec === 6,
"freshness context should make the newest live row/SSE path observable independently of detail loading",
);
assert(
degradedFreshness.detail_status === "degraded" &&
degradedFreshness.detail_source === "partial_or_timeout" &&
degradedFreshness.detail_request_age_sec === 4 &&
degradedFreshness.detail_error_age_sec === 2 &&
degradedFreshness.detail_age_sec === null,
"freshness context should report degraded full-detail state without implying the live point is unavailable",
);
assert(
degradedFreshness.sse_emitted_at_ms === 5_500 &&
degradedFreshness.sse_server_to_client_latency_sec === 5 &&
degradedFreshness.sse_collector_to_client_latency_sec === 60 &&
degradedFreshness.sse_source_to_collector_latency_sec === 64,
"freshness context should expose SSE delivery and collector latency diagnostics",
);
__rememberCityDetailBatchDiagnosticsForTest("Paris", "10m", {
response_source: "next_proxy_timeout",
partial_reason: "proxy_timeout",
city_status: { paris: { status: "proxy_timeout" } },
});
const rememberedBatchDiagnostics = readCityDetailBatchDiagnostics("paris", "10m");
assert(
rememberedBatchDiagnostics?.response_source === "next_proxy_timeout" &&
rememberedBatchDiagnostics?.city_status?.paris?.status === "proxy_timeout",
"chart logic should retain the latest detail-batch diagnostics for feedback context",
);
assert(
chartSource.includes("readCityDetailBatchDiagnostics") &&
chartSource.includes("detail_batch_diagnostics"),
"temperature chart feedback context should include the latest detail-batch diagnostics",
);
const rowWinsFreshness = __buildChartFreshnessContextForTest(
{
rowAppliedAtMs: 10_000,
rowObservationTime: "12:50",
ssePatchAppliedAtMs: 5_000,
sseObservedAt: "2026-06-10T04:48:00Z",
sseRevision: 42,
sseEmittedAtMs: null,
sseServerToClientLatencySec: null,
sseCollectorToClientLatencySec: null,
sseSourceToCollectorLatencySec: null,
detailRequestedAtMs: null,
detailResolvedAtMs: null,
detailErrorAtMs: null,
detailStatus: "idle",
detailSource: "none",
},
11_000,
);
assert(
rowWinsFreshness.live_path === "row" && rowWinsFreshness.live_age_sec === 1,
"freshness context should report the newest live path when a scan-row update is newer than the last SSE patch",
);
assert(
__shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false,
"inactive non-compact charts should not run live polling",
@@ -192,7 +279,7 @@ export async function runTests() {
);
assert(
chartSource.includes("allowStale: true") &&
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
chartCanvasSourceIncludes(chartSource, "详情暂不可用") &&
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
"city detail charts should show stale cache first and expose a retryable unavailable state",
);
@@ -1,5 +1,9 @@
import fs from "node:fs";
import path from "node:path";
import {
__applySsePatchForTest,
getLatestPatchesSnapshot,
} from "@/hooks/use-sse-patches";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
@@ -127,6 +131,36 @@ export function runTests() {
!subscriptionBlock.includes("ensureSsePatchConnection();"),
"city subscription mount/unmount should schedule one coalesced SSE reconnect instead of reconnecting per chart",
);
const originalDateNow = Date.now;
try {
Date.now = () => 1_000_000;
__applySsePatchForTest({
type: "city_observation_patch.v1",
city: "Latency City",
source: "amsc_awos",
obs_time: "2026-06-10T04:50:00Z",
observed_at_utc: "2026-06-10T04:50:00Z",
revision: 987001,
ts: 940_000,
sse_emitted_at_ms: 995_000,
payload: {
temp: 30.5,
latency_sec: 64,
received_at_utc: "2026-06-10T04:51:04Z",
},
});
const latencyPatch = getLatestPatchesSnapshot().get("latency city") as any;
assert(
latencyPatch?.delivery?.client_received_at_ms === 1_000_000 &&
latencyPatch?.delivery?.sse_emitted_at_ms === 995_000 &&
latencyPatch?.delivery?.server_to_client_latency_sec === 5 &&
latencyPatch?.delivery?.collector_to_client_latency_sec === 60 &&
latencyPatch?.delivery?.source_to_collector_latency_sec === 64,
"frontend patch hook should retain SSE delivery latency diagnostics for feedback context",
);
} finally {
Date.now = originalDateNow;
}
const bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts");
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
@@ -249,7 +283,9 @@ export function runTests() {
chart.includes("refreshProbabilityOverlayAfterPatch"),
"temperature chart must trigger a throttled background probability refresh after live observation patches",
);
const patchEffectBlock = chart.match(/useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?\}, \[latestPatch, row, city, targetResolution, compact, isActive, isMaximized, applySuccessfulHourlyDetail\]\);/)?.[0] || "";
const patchEffectBlock = chart.match(
/useEffect\(\(\) => \{\s*if \(!latestPatch[\s\S]*?refreshProbabilityOverlayAfterPatch\(\);[\s\S]*?\}, \[[^\]]*latestPatch[^\]]*applySuccessfulHourlyDetail[^\]]*\]\);/,
)?.[0] || "";
assert(
patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") &&
patchEffectBlock.includes("ignoreCache: true") &&