Add chart freshness diagnostics
This commit is contained in:
@@ -33,6 +33,15 @@ function parseRequestedCities(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
||||||
|
const city_status = Object.fromEntries(
|
||||||
|
requestedCities.map((city) => [
|
||||||
|
city,
|
||||||
|
{
|
||||||
|
status: "proxy_timeout",
|
||||||
|
duration_ms: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
cities: requestedCities,
|
cities: requestedCities,
|
||||||
details: {},
|
details: {},
|
||||||
@@ -40,6 +49,18 @@ function buildCityDetailBatchTimeoutPayload(requestedCities: string[]) {
|
|||||||
missing: requestedCities,
|
missing: requestedCities,
|
||||||
partial: true,
|
partial: true,
|
||||||
timeout: true,
|
timeout: true,
|
||||||
|
diagnostics: {
|
||||||
|
version: 1,
|
||||||
|
response_source: "next_proxy_timeout",
|
||||||
|
partial: true,
|
||||||
|
partial_reason: "proxy_timeout",
|
||||||
|
requested_count: requestedCities.length,
|
||||||
|
completed_count: 0,
|
||||||
|
missing_count: requestedCities.length,
|
||||||
|
error_count: 0,
|
||||||
|
proxy_timeout_ms: DETAIL_BATCH_PROXY_TIMEOUT_MS,
|
||||||
|
city_status,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
mergeRowObservationIntoHourly,
|
mergeRowObservationIntoHourly,
|
||||||
normObs,
|
normObs,
|
||||||
prefersHighFrequencyRunwayResolution,
|
prefersHighFrequencyRunwayResolution,
|
||||||
|
readCityDetailBatchDiagnostics,
|
||||||
readSessionCache,
|
readSessionCache,
|
||||||
selectCompactSecondaryTemp,
|
selectCompactSecondaryTemp,
|
||||||
selectDisplayRunwayTemp,
|
selectDisplayRunwayTemp,
|
||||||
@@ -134,6 +135,115 @@ function rowObservationSignature(row: ScanOpportunityRow | null) {
|
|||||||
return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : "";
|
return hasObservation ? pieces.map((piece) => String(piece ?? "")).join("|") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChartDetailStatus = "idle" | "loading" | "fresh" | "stale_cache" | "degraded";
|
||||||
|
type ChartDetailSource =
|
||||||
|
| "none"
|
||||||
|
| "memory_cache"
|
||||||
|
| "session_cache"
|
||||||
|
| "network"
|
||||||
|
| "force_refresh"
|
||||||
|
| "partial_or_timeout";
|
||||||
|
|
||||||
|
type ChartFreshnessState = {
|
||||||
|
rowAppliedAtMs: number | null;
|
||||||
|
rowObservationTime: string | null;
|
||||||
|
ssePatchAppliedAtMs: number | null;
|
||||||
|
sseObservedAt: string | null;
|
||||||
|
sseRevision: number | null;
|
||||||
|
sseEmittedAtMs: number | null;
|
||||||
|
sseServerToClientLatencySec: number | null;
|
||||||
|
sseCollectorToClientLatencySec: number | null;
|
||||||
|
sseSourceToCollectorLatencySec: number | null;
|
||||||
|
detailRequestedAtMs: number | null;
|
||||||
|
detailResolvedAtMs: number | null;
|
||||||
|
detailErrorAtMs: number | null;
|
||||||
|
detailStatus: ChartDetailStatus;
|
||||||
|
detailSource: ChartDetailSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createChartFreshnessState(
|
||||||
|
overrides: Partial<ChartFreshnessState> = {},
|
||||||
|
): ChartFreshnessState {
|
||||||
|
return {
|
||||||
|
rowAppliedAtMs: null,
|
||||||
|
rowObservationTime: null,
|
||||||
|
ssePatchAppliedAtMs: null,
|
||||||
|
sseObservedAt: null,
|
||||||
|
sseRevision: null,
|
||||||
|
sseEmittedAtMs: null,
|
||||||
|
sseServerToClientLatencySec: null,
|
||||||
|
sseCollectorToClientLatencySec: null,
|
||||||
|
sseSourceToCollectorLatencySec: null,
|
||||||
|
detailRequestedAtMs: null,
|
||||||
|
detailResolvedAtMs: null,
|
||||||
|
detailErrorAtMs: null,
|
||||||
|
detailStatus: "idle",
|
||||||
|
detailSource: "none",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ageSeconds(fromMs: number | null | undefined, nowMs: number) {
|
||||||
|
if (!Number.isFinite(fromMs)) return null;
|
||||||
|
return Math.max(0, Math.round((nowMs - Number(fromMs)) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartFreshnessContext(state: ChartFreshnessState, nowMs = Date.now()) {
|
||||||
|
const rowAgeSec = ageSeconds(state.rowAppliedAtMs, nowMs);
|
||||||
|
const sseAgeSec = ageSeconds(state.ssePatchAppliedAtMs, nowMs);
|
||||||
|
let livePath: "none" | "row" | "sse" = "none";
|
||||||
|
let liveAgeSec: number | null = null;
|
||||||
|
if (rowAgeSec !== null) {
|
||||||
|
livePath = "row";
|
||||||
|
liveAgeSec = rowAgeSec;
|
||||||
|
}
|
||||||
|
if (sseAgeSec !== null && (liveAgeSec === null || sseAgeSec <= liveAgeSec)) {
|
||||||
|
livePath = "sse";
|
||||||
|
liveAgeSec = sseAgeSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
live_path: livePath,
|
||||||
|
live_age_sec: liveAgeSec,
|
||||||
|
row_applied_age_sec: rowAgeSec,
|
||||||
|
row_observation_time: state.rowObservationTime,
|
||||||
|
sse_patch_age_sec: sseAgeSec,
|
||||||
|
sse_observed_at: state.sseObservedAt,
|
||||||
|
sse_revision: state.sseRevision,
|
||||||
|
sse_emitted_at_ms: state.sseEmittedAtMs,
|
||||||
|
sse_server_to_client_latency_sec: state.sseServerToClientLatencySec,
|
||||||
|
sse_collector_to_client_latency_sec: state.sseCollectorToClientLatencySec,
|
||||||
|
sse_source_to_collector_latency_sec: state.sseSourceToCollectorLatencySec,
|
||||||
|
detail_status: state.detailStatus,
|
||||||
|
detail_source: state.detailSource,
|
||||||
|
detail_request_age_sec: ageSeconds(state.detailRequestedAtMs, nowMs),
|
||||||
|
detail_age_sec: ageSeconds(state.detailResolvedAtMs, nowMs),
|
||||||
|
detail_error_age_sec: ageSeconds(state.detailErrorAtMs, nowMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowObservationTimeForFreshness(row: ScanOpportunityRow | null) {
|
||||||
|
if (!row) return null;
|
||||||
|
const metarContext = row.metar_context || null;
|
||||||
|
return String(
|
||||||
|
metarContext?.airport_obs_time ||
|
||||||
|
metarContext?.last_observation_time ||
|
||||||
|
metarContext?.last_time ||
|
||||||
|
row.local_time ||
|
||||||
|
"",
|
||||||
|
).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchObservationTimeForFreshness(patch: { changes?: Record<string, unknown> } | null | undefined) {
|
||||||
|
const changes = patch?.changes || {};
|
||||||
|
return String(
|
||||||
|
changes.observed_at_utc ||
|
||||||
|
changes.observed_at_local ||
|
||||||
|
changes.obs_time ||
|
||||||
|
"",
|
||||||
|
).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
function getWundergroundDailyHigh(hourly: HourlyForecast) {
|
function getWundergroundDailyHigh(hourly: HourlyForecast) {
|
||||||
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
|
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
|
||||||
}
|
}
|
||||||
@@ -458,6 +568,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const [detailError, setDetailError] = useState<string | null>(null);
|
const [detailError, setDetailError] = useState<string | null>(null);
|
||||||
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
|
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
|
||||||
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
|
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
|
||||||
|
const [chartFreshness, setChartFreshness] = useState<ChartFreshnessState>(() =>
|
||||||
|
createChartFreshnessState(),
|
||||||
|
);
|
||||||
const hasLoadedHourlyDetailRef = useRef(false);
|
const hasLoadedHourlyDetailRef = useRef(false);
|
||||||
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
||||||
const lastPatchAtRef = useRef<number>(Date.now());
|
const lastPatchAtRef = useRef<number>(Date.now());
|
||||||
@@ -486,8 +599,28 @@ export function LiveTemperatureThresholdChart({
|
|||||||
formatCityLocalDate(row?.tz_offset_seconds),
|
formatCityLocalDate(row?.tz_offset_seconds),
|
||||||
);
|
);
|
||||||
const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]);
|
const currentRowObservationSignature = useMemo(() => rowObservationSignature(row), [row]);
|
||||||
|
const markDetailRequest = useCallback((source: ChartDetailSource) => {
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
detailRequestedAtMs: Date.now(),
|
||||||
|
detailStatus: "loading",
|
||||||
|
detailSource: source,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
const markDetailDegraded = useCallback((options?: { showUserError?: boolean }) => {
|
||||||
|
if (options?.showUserError) {
|
||||||
|
setDetailError(isEn ? "Detail temporarily unavailable." : "详情暂不可用");
|
||||||
|
}
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
detailErrorAtMs: Date.now(),
|
||||||
|
detailStatus: "degraded",
|
||||||
|
detailSource: "partial_or_timeout",
|
||||||
|
}));
|
||||||
|
}, [isEn]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const now = Date.now();
|
||||||
setUserToggledKeys({});
|
setUserToggledKeys({});
|
||||||
setZoomRange(null);
|
setZoomRange(null);
|
||||||
setViewMode("full");
|
setViewMode("full");
|
||||||
@@ -499,8 +632,15 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setDetailRetryNonce(0);
|
setDetailRetryNonce(0);
|
||||||
setShowingStaleDetail(false);
|
setShowingStaleDetail(false);
|
||||||
|
setChartFreshness(createChartFreshnessState({
|
||||||
|
rowAppliedAtMs: currentRowObservationSignature ? now : null,
|
||||||
|
rowObservationTime: rowObservationTimeForFreshness(row),
|
||||||
|
detailRequestedAtMs: city && detailLoadDelayMs === 0 ? now : null,
|
||||||
|
detailStatus: city && detailLoadDelayMs === 0 ? "loading" : "idle",
|
||||||
|
detailSource: city && detailLoadDelayMs === 0 ? "network" : "none",
|
||||||
|
}));
|
||||||
hasLoadedHourlyDetailRef.current = false;
|
hasLoadedHourlyDetailRef.current = false;
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = now;
|
||||||
lastAppliedPatchRevisionRef.current = 0;
|
lastAppliedPatchRevisionRef.current = 0;
|
||||||
lastProbabilityRefreshAtRef.current = 0;
|
lastProbabilityRefreshAtRef.current = 0;
|
||||||
lastForegroundRefreshAtRef.current = 0;
|
lastForegroundRefreshAtRef.current = 0;
|
||||||
@@ -551,6 +691,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
|
const applySuccessfulHourlyDetail = useCallback((data: HourlyForecast, options?: { updateLiveTemp?: boolean }) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
const loadedAtMs = Date.now();
|
||||||
const rowSeed = seedHourlyForecastFromRow(row);
|
const rowSeed = seedHourlyForecastFromRow(row);
|
||||||
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row);
|
const dataWithCurrentRow = mergeHourlyWithLiveObservations(data, rowSeed, row);
|
||||||
hasLoadedHourlyDetailRef.current = true;
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
@@ -561,16 +702,32 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row));
|
setHourly((prev) => mergeHourlyWithLiveObservations(dataWithCurrentRow, prev, row));
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setShowingStaleDetail(false);
|
setShowingStaleDetail(false);
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
detailResolvedAtMs: loadedAtMs,
|
||||||
|
detailErrorAtMs: null,
|
||||||
|
detailStatus: "fresh",
|
||||||
|
detailSource:
|
||||||
|
prev.detailSource === "none" || prev.detailSource === "partial_or_timeout"
|
||||||
|
? "network"
|
||||||
|
: prev.detailSource,
|
||||||
|
}));
|
||||||
}, [row]);
|
}, [row]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city || !currentRowObservationSignature) return;
|
if (!city || !currentRowObservationSignature) return;
|
||||||
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
|
if (lastRowObservationSignatureRef.current === currentRowObservationSignature) return;
|
||||||
|
const now = Date.now();
|
||||||
lastRowObservationSignatureRef.current = currentRowObservationSignature;
|
lastRowObservationSignatureRef.current = currentRowObservationSignature;
|
||||||
const rowSeed = seedHourlyForecastFromRow(row);
|
const rowSeed = seedHourlyForecastFromRow(row);
|
||||||
const temp = getLiveTempFromHourly(rowSeed);
|
const temp = getLiveTempFromHourly(rowSeed);
|
||||||
if (temp !== null) setLiveTemp(temp);
|
if (temp !== null) setLiveTemp(temp);
|
||||||
setHourly((prev) => mergeRowObservationIntoHourly(prev ?? rowSeed, row));
|
setHourly((prev) => mergeRowObservationIntoHourly(prev ?? rowSeed, row));
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
rowAppliedAtMs: now,
|
||||||
|
rowObservationTime: rowObservationTimeForFreshness(row),
|
||||||
|
}));
|
||||||
}, [city, currentRowObservationSignature, row]);
|
}, [city, currentRowObservationSignature, row]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -581,10 +738,12 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const cacheKey = `${city}:${targetResolution}`;
|
const cacheKey = `${city}:${targetResolution}`;
|
||||||
let cached = _hourlyCache.get(cacheKey);
|
let cached = _hourlyCache.get(cacheKey);
|
||||||
|
let cachedSource: ChartDetailSource = cached ? "memory_cache" : "none";
|
||||||
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
||||||
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
|
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
|
||||||
if (sessionEntry) {
|
if (sessionEntry) {
|
||||||
cached = sessionEntry;
|
cached = sessionEntry;
|
||||||
|
cachedSource = "session_cache";
|
||||||
_hourlyCache.set(cacheKey, sessionEntry);
|
_hourlyCache.set(cacheKey, sessionEntry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,6 +754,12 @@ export function LiveTemperatureThresholdChart({
|
|||||||
hasLoadedHourlyDetailRef.current = true;
|
hasLoadedHourlyDetailRef.current = true;
|
||||||
setHourly(cached.data);
|
setHourly(cached.data);
|
||||||
setShowingStaleDetail(!hasFreshCache);
|
setShowingStaleDetail(!hasFreshCache);
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
detailResolvedAtMs: Number(cached.ts || Date.now()),
|
||||||
|
detailStatus: hasFreshCache ? "fresh" : "stale_cache",
|
||||||
|
detailSource: cachedSource,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFreshCache) {
|
if (hasFreshCache) {
|
||||||
@@ -625,6 +790,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
setShowingStaleDetail(false);
|
setShowingStaleDetail(false);
|
||||||
}
|
}
|
||||||
setIsHourlyLoading(true);
|
setIsHourlyLoading(true);
|
||||||
|
markDetailRequest("network");
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -632,13 +798,15 @@ export function LiveTemperatureThresholdChart({
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
markDetailDegraded({ showUserError: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applySuccessfulHourlyDetail(data);
|
applySuccessfulHourlyDetail(data);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
if (!cancelled) {
|
||||||
|
markDetailDegraded({ showUserError: true });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsHourlyLoading(false);
|
if (!cancelled) setIsHourlyLoading(false);
|
||||||
@@ -660,17 +828,29 @@ export function LiveTemperatureThresholdChart({
|
|||||||
slotIndex,
|
slotIndex,
|
||||||
detailLoadReady,
|
detailLoadReady,
|
||||||
detailRetryNonce,
|
detailRetryNonce,
|
||||||
isEn,
|
markDetailDegraded,
|
||||||
|
markDetailRequest,
|
||||||
applySuccessfulHourlyDetail,
|
applySuccessfulHourlyDetail,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||||
|
const patchAppliedAtMs = Date.now();
|
||||||
lastAppliedPatchRevisionRef.current = latestPatch.revision;
|
lastAppliedPatchRevisionRef.current = latestPatch.revision;
|
||||||
lastPatchAtRef.current = Date.now();
|
lastPatchAtRef.current = patchAppliedAtMs;
|
||||||
const tempValue = validNumber(latestPatch.changes.temp);
|
const tempValue = validNumber(latestPatch.changes.temp);
|
||||||
if (tempValue !== null) setLiveTemp(tempValue);
|
if (tempValue !== null) setLiveTemp(tempValue);
|
||||||
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
|
setHourly((prev) => mergePatchIntoHourly(prev ?? seedHourlyForecastFromRow(row), latestPatch));
|
||||||
|
setChartFreshness((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ssePatchAppliedAtMs: patchAppliedAtMs,
|
||||||
|
sseObservedAt: patchObservationTimeForFreshness(latestPatch),
|
||||||
|
sseRevision: latestPatch.revision,
|
||||||
|
sseEmittedAtMs: latestPatch.delivery?.sse_emitted_at_ms ?? null,
|
||||||
|
sseServerToClientLatencySec: latestPatch.delivery?.server_to_client_latency_sec ?? null,
|
||||||
|
sseCollectorToClientLatencySec: latestPatch.delivery?.collector_to_client_latency_sec ?? null,
|
||||||
|
sseSourceToCollectorLatencySec: latestPatch.delivery?.source_to_collector_latency_sec ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
const hasObservationChange =
|
const hasObservationChange =
|
||||||
tempValue !== null ||
|
tempValue !== null ||
|
||||||
@@ -684,36 +864,54 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refreshProbabilityOverlayAfterPatch = () => {
|
const refreshProbabilityOverlayAfterPatch = () => {
|
||||||
|
markDetailRequest("force_refresh");
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled) return;
|
||||||
|
if (!data) {
|
||||||
|
markDetailDegraded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applySuccessfulHourlyDetail(data);
|
applySuccessfulHourlyDetail(data);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
markDetailDegraded();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
refreshProbabilityOverlayAfterPatch();
|
refreshProbabilityOverlayAfterPatch();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized, applySuccessfulHourlyDetail]);
|
}, [latestPatch, row, city, targetResolution, compact, isActive, isMaximized, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resyncVersion || !city) return;
|
if (!resyncVersion || !city) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
markDetailRequest("force_refresh");
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled) return;
|
||||||
|
if (!data) {
|
||||||
|
markDetailDegraded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applySuccessfulHourlyDetail(data);
|
applySuccessfulHourlyDetail(data);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
markDetailDegraded();
|
||||||
|
}
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsHourlyLoading(false);
|
if (!cancelled) setIsHourlyLoading(false);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [resyncVersion, city, targetResolution, applySuccessfulHourlyDetail]);
|
}, [resyncVersion, city, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
|
||||||
|
|
||||||
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ──
|
// ── SSE fallback: only full-fetch if a visible chart has seen no patch for one METAR cadence ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -721,14 +919,24 @@ export function LiveTemperatureThresholdChart({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const refreshFullDetail = () => {
|
const refreshFullDetail = () => {
|
||||||
lastPatchAtRef.current = Date.now();
|
const now = Date.now();
|
||||||
|
lastPatchAtRef.current = now;
|
||||||
|
markDetailRequest("force_refresh");
|
||||||
|
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled) return;
|
||||||
|
if (!data) {
|
||||||
|
markDetailDegraded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applySuccessfulHourlyDetail(data, { updateLiveTemp: true });
|
applySuccessfulHourlyDetail(data, { updateLiveTemp: true });
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
markDetailDegraded();
|
||||||
|
}
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsHourlyLoading(false);
|
if (!cancelled) setIsHourlyLoading(false);
|
||||||
});
|
});
|
||||||
@@ -746,7 +954,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [city, compact, isActive, isMaximized, targetResolution, applySuccessfulHourlyDetail]);
|
}, [city, compact, isActive, isMaximized, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
if (!shouldPollLiveChart({ city, compact, isActive, isMaximized })) return;
|
||||||
@@ -766,13 +974,22 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
lastForegroundRefreshAtRef.current = now;
|
lastForegroundRefreshAtRef.current = now;
|
||||||
lastPatchAtRef.current = now;
|
lastPatchAtRef.current = now;
|
||||||
|
markDetailRequest("force_refresh");
|
||||||
|
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled) return;
|
||||||
|
if (!data) {
|
||||||
|
markDetailDegraded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applySuccessfulHourlyDetail(data, { updateLiveTemp: true });
|
applySuccessfulHourlyDetail(data, { updateLiveTemp: true });
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
markDetailDegraded();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
@@ -787,7 +1004,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
window.removeEventListener("focus", refreshForegroundFullDetail);
|
window.removeEventListener("focus", refreshForegroundFullDetail);
|
||||||
};
|
};
|
||||||
}, [city, compact, isActive, isMaximized, targetResolution, applySuccessfulHourlyDetail]);
|
}, [city, compact, isActive, isMaximized, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!city || !currentCityLocalDate) return;
|
if (!city || !currentCityLocalDate) return;
|
||||||
@@ -797,19 +1014,27 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
localDayRolloverFetchDateRef.current = currentCityLocalDate;
|
localDayRolloverFetchDateRef.current = currentCityLocalDate;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
markDetailRequest("force_refresh");
|
||||||
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
fetchHourlyForecastForCity(city, { ignoreCache: true, resolution: targetResolution })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled || !data) return;
|
if (cancelled) return;
|
||||||
|
if (!data) {
|
||||||
|
markDetailDegraded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
applySuccessfulHourlyDetail(data);
|
applySuccessfulHourlyDetail(data);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) localDayRolloverFetchDateRef.current = "";
|
if (!cancelled) {
|
||||||
|
localDayRolloverFetchDateRef.current = "";
|
||||||
|
markDetailDegraded();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, applySuccessfulHourlyDetail]);
|
}, [city, currentCityLocalDate, hourly?.localDate, row?.local_date, targetResolution, markDetailDegraded, markDetailRequest, applySuccessfulHourlyDetail]);
|
||||||
|
|
||||||
const chartHourly = useMemo<HourlyForecast>(() => {
|
const chartHourly = useMemo<HourlyForecast>(() => {
|
||||||
if (!hourly) return hourly;
|
if (!hourly) return hourly;
|
||||||
@@ -1012,6 +1237,14 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
|
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
|
||||||
const showDetailErrorBadge = !compact || isActive || isMaximized;
|
const showDetailErrorBadge = !compact || isActive || isMaximized;
|
||||||
|
const chartFreshnessContext = useMemo(
|
||||||
|
() => buildChartFreshnessContext(chartFreshness),
|
||||||
|
[chartFreshness],
|
||||||
|
);
|
||||||
|
const detailBatchDiagnostics = useMemo(
|
||||||
|
() => readCityDetailBatchDiagnostics(city, targetResolution),
|
||||||
|
[city, targetResolution, chartFreshness.detailRequestedAtMs, chartFreshness.detailErrorAtMs, chartFreshness.detailResolvedAtMs],
|
||||||
|
);
|
||||||
|
|
||||||
const handleZoomReset = useCallback(() => {
|
const handleZoomReset = useCallback(() => {
|
||||||
setZoomRange(null);
|
setZoomRange(null);
|
||||||
@@ -1072,8 +1305,9 @@ export function LiveTemperatureThresholdChart({
|
|||||||
const handleRetryDetail = useCallback(() => {
|
const handleRetryDetail = useCallback(() => {
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setIsHourlyLoading(true);
|
setIsHourlyLoading(true);
|
||||||
|
markDetailRequest("network");
|
||||||
setDetailRetryNonce((value) => value + 1);
|
setDetailRetryNonce((value) => value + 1);
|
||||||
}, []);
|
}, [markDetailRequest]);
|
||||||
|
|
||||||
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1087,6 +1321,12 @@ export function LiveTemperatureThresholdChart({
|
|||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
is_maximized: isMaximized,
|
is_maximized: isMaximized,
|
||||||
detail_error: detailError,
|
detail_error: detailError,
|
||||||
|
freshness: chartFreshnessContext,
|
||||||
|
detail_batch_diagnostics: detailBatchDiagnostics,
|
||||||
|
live_path: chartFreshnessContext.live_path,
|
||||||
|
live_age_sec: chartFreshnessContext.live_age_sec,
|
||||||
|
detail_status: chartFreshnessContext.detail_status,
|
||||||
|
detail_source: chartFreshnessContext.detail_source,
|
||||||
is_hourly_loading: isHourlyLoading,
|
is_hourly_loading: isHourlyLoading,
|
||||||
showing_stale_detail: showingStaleDetail,
|
showing_stale_detail: showingStaleDetail,
|
||||||
target_resolution: targetResolution,
|
target_resolution: targetResolution,
|
||||||
@@ -1104,11 +1344,13 @@ export function LiveTemperatureThresholdChart({
|
|||||||
}, [
|
}, [
|
||||||
activeSeries,
|
activeSeries,
|
||||||
chartLocalDate,
|
chartLocalDate,
|
||||||
|
chartFreshnessContext,
|
||||||
chartSeries,
|
chartSeries,
|
||||||
city,
|
city,
|
||||||
compact,
|
compact,
|
||||||
currentMetarTemp,
|
currentMetarTemp,
|
||||||
currentRunwayTemp,
|
currentRunwayTemp,
|
||||||
|
detailBatchDiagnostics,
|
||||||
detailError,
|
detailError,
|
||||||
hasRunwayData,
|
hasRunwayData,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -1304,6 +1546,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
showRunwayDetails={showRunwayDetails}
|
showRunwayDetails={showRunwayDetails}
|
||||||
isHourlyLoading={isHourlyLoading}
|
isHourlyLoading={isHourlyLoading}
|
||||||
detailError={detailError}
|
detailError={detailError}
|
||||||
|
detailStatus={chartFreshness.detailStatus}
|
||||||
showingStaleDetail={showingStaleDetail}
|
showingStaleDetail={showingStaleDetail}
|
||||||
showDetailErrorBadge={showDetailErrorBadge}
|
showDetailErrorBadge={showDetailErrorBadge}
|
||||||
refAreaLeft={refAreaLeft}
|
refAreaLeft={refAreaLeft}
|
||||||
@@ -1342,6 +1585,7 @@ export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
|||||||
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
export const __getInitialDetailLoadDelayMsForTest = getInitialDetailLoadDelayMs;
|
||||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||||
|
export const __buildChartFreshnessContextForTest = buildChartFreshnessContext;
|
||||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||||
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
|
export const __selectCompactSecondaryTempForTest = selectCompactSecondaryTemp;
|
||||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ function TemperatureChartCanvasComponent({
|
|||||||
showRunwayDetails,
|
showRunwayDetails,
|
||||||
isHourlyLoading,
|
isHourlyLoading,
|
||||||
detailError,
|
detailError,
|
||||||
|
detailStatus,
|
||||||
showingStaleDetail,
|
showingStaleDetail,
|
||||||
showDetailErrorBadge = true,
|
showDetailErrorBadge = true,
|
||||||
refAreaLeft,
|
refAreaLeft,
|
||||||
@@ -139,6 +140,7 @@ function TemperatureChartCanvasComponent({
|
|||||||
showRunwayDetails: boolean;
|
showRunwayDetails: boolean;
|
||||||
isHourlyLoading: boolean;
|
isHourlyLoading: boolean;
|
||||||
detailError?: string | null;
|
detailError?: string | null;
|
||||||
|
detailStatus?: string | null;
|
||||||
showingStaleDetail?: boolean;
|
showingStaleDetail?: boolean;
|
||||||
showDetailErrorBadge?: boolean;
|
showDetailErrorBadge?: boolean;
|
||||||
refAreaLeft: number | null;
|
refAreaLeft: number | null;
|
||||||
@@ -226,6 +228,10 @@ function TemperatureChartCanvasComponent({
|
|||||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||||
const shouldShowBackgroundError =
|
const shouldShowBackgroundError =
|
||||||
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
showDetailErrorBadge && Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||||
|
const backgroundErrorLabel =
|
||||||
|
showingStaleDetail || detailStatus === "stale_cache"
|
||||||
|
? (isEn ? "Detail cache" : "详情缓存")
|
||||||
|
: (isEn ? "Detail degraded" : "详情降级");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||||
@@ -389,7 +395,7 @@ function TemperatureChartCanvasComponent({
|
|||||||
{shouldShowUnavailableState && (
|
{shouldShowUnavailableState && (
|
||||||
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
|
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
|
||||||
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
|
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
|
||||||
<div>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
|
<div>{isEn ? "Detail temporarily unavailable" : "详情暂不可用"}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRetryDetail}
|
onClick={onRetryDetail}
|
||||||
@@ -410,7 +416,7 @@ function TemperatureChartCanvasComponent({
|
|||||||
</div>
|
</div>
|
||||||
{shouldShowBackgroundError && (
|
{shouldShowBackgroundError && (
|
||||||
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
|
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
|
||||||
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
|
<span>{backgroundErrorLabel}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRetryDetail}
|
onClick={onRetryDetail}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} 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 {
|
import {
|
||||||
|
__buildChartFreshnessContextForTest,
|
||||||
__getInitialDetailLoadDelayMsForTest,
|
__getInitialDetailLoadDelayMsForTest,
|
||||||
__shouldFetchCityDetailForChartForTest,
|
__shouldFetchCityDetailForChartForTest,
|
||||||
__shouldPollLiveChartForTest,
|
__shouldPollLiveChartForTest,
|
||||||
@@ -15,9 +16,11 @@ import {
|
|||||||
HOURLY_CACHE_TTL_MS,
|
HOURLY_CACHE_TTL_MS,
|
||||||
__resolveCityDetailFromBatchForTest,
|
__resolveCityDetailFromBatchForTest,
|
||||||
__readHourlyCacheEntryForTest,
|
__readHourlyCacheEntryForTest,
|
||||||
|
__rememberCityDetailBatchDiagnosticsForTest,
|
||||||
__resetHourlyDetailRequestQueueForTest,
|
__resetHourlyDetailRequestQueueForTest,
|
||||||
__runQueuedHourlyDetailRequestForTest,
|
__runQueuedHourlyDetailRequestForTest,
|
||||||
clearCityDetailCache,
|
clearCityDetailCache,
|
||||||
|
readCityDetailBatchDiagnostics,
|
||||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
function assert(condition: unknown, message: string) {
|
||||||
@@ -119,6 +122,90 @@ export async function runTests() {
|
|||||||
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: true, isActive: false, isMaximized: false }) === true,
|
||||||
"compact grid slots are visible charts and should run the no-patch fallback guard",
|
"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(
|
assert(
|
||||||
__shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false,
|
__shouldPollLiveChartForTest({ city: "shanghai", compact: false, isActive: false, isMaximized: false }) === false,
|
||||||
"inactive non-compact charts should not run live polling",
|
"inactive non-compact charts should not run live polling",
|
||||||
@@ -192,7 +279,7 @@ export async function runTests() {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
chartSource.includes("allowStale: true") &&
|
chartSource.includes("allowStale: true") &&
|
||||||
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
|
chartCanvasSourceIncludes(chartSource, "详情暂不可用") &&
|
||||||
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
|
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
|
||||||
"city detail charts should show stale cache first and expose a retryable unavailable state",
|
"city detail charts should show stale cache first and expose a retryable unavailable state",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
__applySsePatchForTest,
|
||||||
|
getLatestPatchesSnapshot,
|
||||||
|
} from "@/hooks/use-sse-patches";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
@@ -127,6 +131,36 @@ export function runTests() {
|
|||||||
!subscriptionBlock.includes("ensureSsePatchConnection();"),
|
!subscriptionBlock.includes("ensureSsePatchConnection();"),
|
||||||
"city subscription mount/unmount should schedule one coalesced SSE reconnect instead of reconnecting per chart",
|
"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");
|
const bffEventsRoute = readFrontendFile("app", "api", "events", "route.ts");
|
||||||
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
|
assert(bffEventsRoute.includes("searchParams"), "Next.js SSE proxy must forward query parameters to FastAPI");
|
||||||
@@ -249,7 +283,9 @@ export function runTests() {
|
|||||||
chart.includes("refreshProbabilityOverlayAfterPatch"),
|
chart.includes("refreshProbabilityOverlayAfterPatch"),
|
||||||
"temperature chart must trigger a throttled background probability refresh after live observation patches",
|
"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(
|
assert(
|
||||||
patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") &&
|
patchEffectBlock.includes("refreshProbabilityOverlayAfterPatch") &&
|
||||||
patchEffectBlock.includes("ignoreCache: true") &&
|
patchEffectBlock.includes("ignoreCache: true") &&
|
||||||
|
|||||||
@@ -372,6 +372,43 @@ const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
|||||||
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||||
|
|
||||||
type HourlyCacheEntry = { ts: number; data: HourlyForecast };
|
type HourlyCacheEntry = { ts: number; data: HourlyForecast };
|
||||||
|
type CityDetailBatchDiagnostics = Record<string, any>;
|
||||||
|
type CityDetailBatchDiagnosticsEntry = { ts: number; data: CityDetailBatchDiagnostics };
|
||||||
|
|
||||||
|
const _cityDetailBatchDiagnosticsCache = new Map<string, CityDetailBatchDiagnosticsEntry>();
|
||||||
|
|
||||||
|
function cityDetailBatchDiagnosticsKey(city: string, resolution: string) {
|
||||||
|
return `${normalizeCityKey(city)}:${resolution || "10m"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberCityDetailBatchDiagnostics(
|
||||||
|
city: string,
|
||||||
|
resolution: string,
|
||||||
|
diagnostics: unknown,
|
||||||
|
) {
|
||||||
|
if (!diagnostics || typeof diagnostics !== "object") return;
|
||||||
|
const key = cityDetailBatchDiagnosticsKey(city, resolution);
|
||||||
|
if (!key.startsWith(":")) {
|
||||||
|
_cityDetailBatchDiagnosticsCache.set(key, {
|
||||||
|
ts: Date.now(),
|
||||||
|
data: diagnostics as CityDetailBatchDiagnostics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCityDetailBatchDiagnostics(
|
||||||
|
city: string,
|
||||||
|
resolution: string,
|
||||||
|
): CityDetailBatchDiagnostics | null {
|
||||||
|
const key = cityDetailBatchDiagnosticsKey(city, resolution);
|
||||||
|
const entry = _cityDetailBatchDiagnosticsCache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() - Number(entry.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
||||||
|
_cityDetailBatchDiagnosticsCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
function isFreshHourlyCacheEntry(
|
function isFreshHourlyCacheEntry(
|
||||||
entry: HourlyCacheEntry | null | undefined,
|
entry: HourlyCacheEntry | null | undefined,
|
||||||
@@ -459,6 +496,7 @@ function runQueuedHourlyDetailRequest<T>(task: () => Promise<T>): Promise<T> {
|
|||||||
export function clearCityDetailCache() {
|
export function clearCityDetailCache() {
|
||||||
_hourlyCache.clear();
|
_hourlyCache.clear();
|
||||||
_hourlyRequestCache.clear();
|
_hourlyRequestCache.clear();
|
||||||
|
_cityDetailBatchDiagnosticsCache.clear();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
try {
|
try {
|
||||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||||
@@ -1288,6 +1326,7 @@ type HourlyForecastFetchOptions = {
|
|||||||
type CityDetailBatchPayload = {
|
type CityDetailBatchPayload = {
|
||||||
cities?: string[];
|
cities?: string[];
|
||||||
details?: Record<string, CityDetail | null | undefined>;
|
details?: Record<string, CityDetail | null | undefined>;
|
||||||
|
diagnostics?: CityDetailBatchDiagnostics;
|
||||||
errors?: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
missing?: string[];
|
missing?: string[];
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
@@ -1449,6 +1488,7 @@ async function flushCityDetailBatch(queueKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const details = payload?.details || {};
|
const details = payload?.details || {};
|
||||||
|
const diagnostics = payload?.diagnostics || null;
|
||||||
const partialMissingCities =
|
const partialMissingCities =
|
||||||
payload?.partial === true
|
payload?.partial === true
|
||||||
? new Set((payload.missing || []).map((city) => normalizeCityKey(city)))
|
? new Set((payload.missing || []).map((city) => normalizeCityKey(city)))
|
||||||
@@ -1456,6 +1496,7 @@ async function flushCityDetailBatch(queueKey: string) {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
cities.map(async (city) => {
|
cities.map(async (city) => {
|
||||||
const waiters = queue.waiters.get(city);
|
const waiters = queue.waiters.get(city);
|
||||||
|
rememberCityDetailBatchDiagnostics(city, queue.resolution, diagnostics);
|
||||||
const detail = resolveCityDetailFromBatch(details, city);
|
const detail = resolveCityDetailFromBatch(details, city);
|
||||||
const data = primeCityDetailCache(city, queue.resolution, detail);
|
const data = primeCityDetailCache(city, queue.resolution, detail);
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -2817,6 +2858,7 @@ export {
|
|||||||
normObs,
|
normObs,
|
||||||
normalizeCityKey,
|
normalizeCityKey,
|
||||||
prefersHighFrequencyRunwayResolution,
|
prefersHighFrequencyRunwayResolution,
|
||||||
|
readCityDetailBatchDiagnostics,
|
||||||
readSessionCache,
|
readSessionCache,
|
||||||
selectCompactSecondaryTemp,
|
selectCompactSecondaryTemp,
|
||||||
selectDisplayRunwayTemp,
|
selectDisplayRunwayTemp,
|
||||||
@@ -2824,6 +2866,7 @@ export {
|
|||||||
seriesStats,
|
seriesStats,
|
||||||
shouldPollLiveChart,
|
shouldPollLiveChart,
|
||||||
validNumber,
|
validNumber,
|
||||||
|
rememberCityDetailBatchDiagnostics as __rememberCityDetailBatchDiagnosticsForTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };
|
export type { EvidenceSeries, HourlyForecast, PeakGlowMeta, PeakGlowState, ProbabilityOverlay };
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ export function runTests() {
|
|||||||
/partial:\s*true/,
|
/partial:\s*true/,
|
||||||
"city detail batch proxy timeout fallback should preserve partial response semantics",
|
"city detail batch proxy timeout fallback should preserve partial response semantics",
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
detailBatchProxy,
|
||||||
|
/diagnostics:\s*{/,
|
||||||
|
"city detail batch proxy timeout fallback should include structured diagnostics",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
detailBatchProxy,
|
||||||
|
/response_source:\s*"next_proxy_timeout"/,
|
||||||
|
"city detail batch proxy timeout diagnostics should identify the proxy timeout layer",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
detailBatchProxy,
|
||||||
|
/partial_reason:\s*"proxy_timeout"/,
|
||||||
|
"city detail batch proxy timeout diagnostics should distinguish proxy timeout from backend partial timeout",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
detailBatchProxy,
|
||||||
|
/city_status/,
|
||||||
|
"city detail batch proxy timeout diagnostics should include per-city status",
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
detailBatchProxy,
|
detailBatchProxy,
|
||||||
/status:\s*200/,
|
/status:\s*200/,
|
||||||
|
|||||||
@@ -39,4 +39,15 @@ export function runTests() {
|
|||||||
source.includes("1500"),
|
source.includes("1500"),
|
||||||
"ops feedback page must document fixed reward point guidelines",
|
"ops feedback page must document fixed reward point guidelines",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("feedbackFreshnessBadges") &&
|
||||||
|
source.includes("freshness?.live_path") &&
|
||||||
|
source.includes("freshness?.live_age_sec") &&
|
||||||
|
source.includes("sse_server_to_client_latency_sec") &&
|
||||||
|
source.includes("sse_collector_to_client_latency_sec") &&
|
||||||
|
source.includes("sse_source_to_collector_latency_sec") &&
|
||||||
|
source.includes("detail_batch_diagnostics") &&
|
||||||
|
source.includes("partial_reason"),
|
||||||
|
"ops feedback page must summarize live freshness, SSE latency and detail-batch diagnostics",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,111 @@ function contextSummary(context?: Record<string, unknown>) {
|
|||||||
return pieces.length ? pieces.join(" · ") : "terminal";
|
return pieces.length ? pieces.join(" · ") : "terminal";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function objectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(value: unknown) {
|
||||||
|
const number = finiteNumber(value);
|
||||||
|
return number === null ? null : `${number}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencyTone(value: unknown) {
|
||||||
|
const number = finiteNumber(value);
|
||||||
|
if (number === null) return "border-slate-200 bg-slate-50 text-slate-600";
|
||||||
|
if (number >= 300) return "border-red-200 bg-red-50 text-red-700";
|
||||||
|
if (number >= 60) return "border-amber-200 bg-amber-50 text-amber-700";
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedbackFreshnessBadges(context?: Record<string, unknown>) {
|
||||||
|
const freshness = objectRecord(context?.freshness);
|
||||||
|
const detailBatchDiagnostics = objectRecord(context?.detail_batch_diagnostics);
|
||||||
|
const badges: Array<{ key: string; label: string; tone: string }> = [];
|
||||||
|
|
||||||
|
const livePath = String(freshness?.live_path || context?.live_path || "").trim();
|
||||||
|
const liveAge = formatSeconds(freshness?.live_age_sec ?? context?.live_age_sec);
|
||||||
|
if (livePath) {
|
||||||
|
badges.push({
|
||||||
|
key: "live",
|
||||||
|
label: liveAge ? `实时 ${livePath} ${liveAge}` : `实时 ${livePath}`,
|
||||||
|
tone: latencyTone(freshness?.live_age_sec ?? context?.live_age_sec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailStatus = String(freshness?.detail_status || context?.detail_status || "").trim();
|
||||||
|
const detailSource = String(freshness?.detail_source || context?.detail_source || "").trim();
|
||||||
|
if (detailStatus) {
|
||||||
|
badges.push({
|
||||||
|
key: "detail",
|
||||||
|
label: detailSource ? `详情 ${detailStatus}/${detailSource}` : `详情 ${detailStatus}`,
|
||||||
|
tone: ["degraded", "stale_cache"].includes(detailStatus)
|
||||||
|
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-600",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sseServerToClientLatency = formatSeconds(freshness?.sse_server_to_client_latency_sec);
|
||||||
|
if (sseServerToClientLatency) {
|
||||||
|
badges.push({
|
||||||
|
key: "sse-server",
|
||||||
|
label: `SSE ${sseServerToClientLatency}`,
|
||||||
|
tone: latencyTone(freshness?.sse_server_to_client_latency_sec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sseCollectorToClientLatency = formatSeconds(freshness?.sse_collector_to_client_latency_sec);
|
||||||
|
if (sseCollectorToClientLatency) {
|
||||||
|
badges.push({
|
||||||
|
key: "sse-collector",
|
||||||
|
label: `采集到前端 ${sseCollectorToClientLatency}`,
|
||||||
|
tone: latencyTone(freshness?.sse_collector_to_client_latency_sec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sseSourceToCollectorLatency = formatSeconds(freshness?.sse_source_to_collector_latency_sec);
|
||||||
|
if (sseSourceToCollectorLatency) {
|
||||||
|
badges.push({
|
||||||
|
key: "sse-source",
|
||||||
|
label: `源到采集 ${sseSourceToCollectorLatency}`,
|
||||||
|
tone: latencyTone(freshness?.sse_source_to_collector_latency_sec),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const partialReason = String(detailBatchDiagnostics?.partial_reason || "").trim();
|
||||||
|
const responseSource = String(detailBatchDiagnostics?.response_source || "").trim();
|
||||||
|
if (partialReason || responseSource) {
|
||||||
|
badges.push({
|
||||||
|
key: "batch",
|
||||||
|
label: responseSource
|
||||||
|
? `批量 ${partialReason || "ok"} · ${responseSource}`
|
||||||
|
: `批量 ${partialReason}`,
|
||||||
|
tone: partialReason
|
||||||
|
? "border-amber-200 bg-amber-50 text-amber-700"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-600",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingCount = finiteNumber(detailBatchDiagnostics?.missing_count);
|
||||||
|
const errorCount = finiteNumber(detailBatchDiagnostics?.error_count);
|
||||||
|
if (missingCount || errorCount) {
|
||||||
|
badges.push({
|
||||||
|
key: "batch-counts",
|
||||||
|
label: `缺失 ${missingCount || 0} · 错误 ${errorCount || 0}`,
|
||||||
|
tone: "border-red-200 bg-red-50 text-red-700",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
export function FeedbackPageClient() {
|
export function FeedbackPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -299,6 +404,7 @@ export function FeedbackPageClient() {
|
|||||||
const rewardPoints = Number(row.reward_points || 0);
|
const rewardPoints = Number(row.reward_points || 0);
|
||||||
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
const rewardStatus = String(row.reward_status || "").toLowerCase();
|
||||||
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
const hasReward = rewardStatus === "granted" && rewardPoints > 0;
|
||||||
|
const freshnessBadges = feedbackFreshnessBadges(row.context);
|
||||||
return (
|
return (
|
||||||
<tr key={row.id} className="border-b border-slate-100 align-top">
|
<tr key={row.id} className="border-b border-slate-100 align-top">
|
||||||
<td className="py-3 pr-4">
|
<td className="py-3 pr-4">
|
||||||
@@ -318,6 +424,18 @@ export function FeedbackPageClient() {
|
|||||||
{String(row.context?.detail_error || "").slice(0, 120)}
|
{String(row.context?.detail_error || "").slice(0, 120)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{freshnessBadges.length > 0 && (
|
||||||
|
<div className="mt-2 flex max-w-xs flex-wrap gap-1.5">
|
||||||
|
{freshnessBadges.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.key}
|
||||||
|
className={`rounded border px-1.5 py-0.5 text-[10px] font-bold ${item.tone}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
||||||
{row.user_email || row.user_id || "—"}
|
{row.user_email || row.user_id || "—"}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export type CityPatch = {
|
|||||||
changes: Record<string, unknown>;
|
changes: Record<string, unknown>;
|
||||||
revision: number;
|
revision: number;
|
||||||
ts?: number;
|
ts?: number;
|
||||||
|
delivery?: {
|
||||||
|
client_received_at_ms: number;
|
||||||
|
sse_emitted_at_ms?: number | null;
|
||||||
|
server_to_client_latency_sec?: number | null;
|
||||||
|
collector_to_client_latency_sec?: number | null;
|
||||||
|
source_to_collector_latency_sec?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type ObservationPatchV1 = {
|
type ObservationPatchV1 = {
|
||||||
@@ -30,6 +37,7 @@ type ObservationPatchV1 = {
|
|||||||
source_cadence_sec?: number | null;
|
source_cadence_sec?: number | null;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
ts?: number;
|
ts?: number;
|
||||||
|
sse_emitted_at_ms?: number | null;
|
||||||
payload?: Record<string, unknown>;
|
payload?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +113,34 @@ function resolveSseReplayLimit(cityCount: number) {
|
|||||||
return Math.max(SSE_REPLAY_BASE_LIMIT, Math.min(SSE_REPLAY_MAX_LIMIT, requested));
|
return Math.max(SSE_REPLAY_BASE_LIMIT, Math.min(SSE_REPLAY_MAX_LIMIT, requested));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value: unknown): number | null {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latencySeconds(fromMs: number | null, toMs: number) {
|
||||||
|
if (fromMs === null) return null;
|
||||||
|
return Math.max(0, Math.round((toMs - fromMs) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPatchDelivery(
|
||||||
|
patch: Partial<CityPatch> & ObservationPatchV1,
|
||||||
|
clientReceivedAtMs: number,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const sseEmittedAtMs = finiteNumber(patch.sse_emitted_at_ms);
|
||||||
|
const collectorReceivedAtMs = finiteNumber(patch.ts);
|
||||||
|
const sourceToCollectorLatencySec =
|
||||||
|
finiteNumber(payload?.latency_sec) ?? finiteNumber((patch.changes as Record<string, unknown> | undefined)?.latency_sec);
|
||||||
|
return {
|
||||||
|
client_received_at_ms: clientReceivedAtMs,
|
||||||
|
sse_emitted_at_ms: sseEmittedAtMs,
|
||||||
|
server_to_client_latency_sec: latencySeconds(sseEmittedAtMs, clientReceivedAtMs),
|
||||||
|
collector_to_client_latency_sec: latencySeconds(collectorReceivedAtMs, clientReceivedAtMs),
|
||||||
|
source_to_collector_latency_sec: sourceToCollectorLatencySec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function currentConnectionKey() {
|
function currentConnectionKey() {
|
||||||
return `${useFallbackUrl ? "fallback" : "direct"}:${subscribedCityList().join("|")}:${lastRevision}`;
|
return `${useFallbackUrl ? "fallback" : "direct"}:${subscribedCityList().join("|")}:${lastRevision}`;
|
||||||
}
|
}
|
||||||
@@ -219,7 +255,10 @@ function registerCitySubscription(city: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLegacyPatch(patch: Partial<CityPatch>): CityPatch | null {
|
function normalizeLegacyPatch(
|
||||||
|
patch: Partial<CityPatch> & ObservationPatchV1,
|
||||||
|
clientReceivedAtMs: number,
|
||||||
|
): CityPatch | null {
|
||||||
const city = normalizeCityKey(patch.city);
|
const city = normalizeCityKey(patch.city);
|
||||||
const changes = patch.changes;
|
const changes = patch.changes;
|
||||||
const revision = Number(patch.revision);
|
const revision = Number(patch.revision);
|
||||||
@@ -232,10 +271,14 @@ function normalizeLegacyPatch(patch: Partial<CityPatch>): CityPatch | null {
|
|||||||
changes: changes as Record<string, unknown>,
|
changes: changes as Record<string, unknown>,
|
||||||
revision,
|
revision,
|
||||||
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
||||||
|
delivery: buildPatchDelivery(patch, clientReceivedAtMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeV1Patch(patch: ObservationPatchV1): CityPatch | null {
|
function normalizeV1Patch(
|
||||||
|
patch: ObservationPatchV1,
|
||||||
|
clientReceivedAtMs: number,
|
||||||
|
): CityPatch | null {
|
||||||
const city = normalizeCityKey(patch.city);
|
const city = normalizeCityKey(patch.city);
|
||||||
const revision = Number(patch.revision);
|
const revision = Number(patch.revision);
|
||||||
const payload = patch.payload;
|
const payload = patch.payload;
|
||||||
@@ -262,17 +305,19 @@ function normalizeV1Patch(patch: ObservationPatchV1): CityPatch | null {
|
|||||||
changes,
|
changes,
|
||||||
revision,
|
revision,
|
||||||
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
ts: typeof patch.ts === "number" ? patch.ts : Date.now(),
|
||||||
|
delivery: buildPatchDelivery(patch, clientReceivedAtMs, payload),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeIncomingPatch(payload: unknown): CityPatch | null {
|
function normalizeIncomingPatch(payload: unknown): CityPatch | null {
|
||||||
if (!payload || typeof payload !== "object") return null;
|
if (!payload || typeof payload !== "object") return null;
|
||||||
const patch = payload as Partial<CityPatch> & ObservationPatchV1;
|
const patch = payload as Partial<CityPatch> & ObservationPatchV1;
|
||||||
|
const clientReceivedAtMs = Date.now();
|
||||||
if (patch.type === "city_patch" || !patch.type) {
|
if (patch.type === "city_patch" || !patch.type) {
|
||||||
return normalizeLegacyPatch(patch);
|
return normalizeLegacyPatch(patch, clientReceivedAtMs);
|
||||||
}
|
}
|
||||||
if (patch.type === V1_EVENT_TYPE) {
|
if (patch.type === V1_EVENT_TYPE) {
|
||||||
return normalizeV1Patch(patch);
|
return normalizeV1Patch(patch, clientReceivedAtMs);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi.testclient import TestClient
|
|||||||
|
|
||||||
from web.app import app
|
from web.app import app
|
||||||
from web.routers import sse_router
|
from web.routers import sse_router
|
||||||
|
from web.sse_manager import SseManager
|
||||||
|
|
||||||
|
|
||||||
def _decode_sse_events(text: str):
|
def _decode_sse_events(text: str):
|
||||||
@@ -15,6 +16,25 @@ def _decode_sse_events(text: str):
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_format_event_stamps_emit_time_for_latency_diagnostics(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.sse_manager.time.time", lambda: 1780750904.5)
|
||||||
|
|
||||||
|
frame = SseManager._format_event(
|
||||||
|
{
|
||||||
|
"type": "city_observation_patch.v1",
|
||||||
|
"revision": 123,
|
||||||
|
"city": "busan",
|
||||||
|
"source": "amos",
|
||||||
|
"ts": 1780750864062,
|
||||||
|
"payload": {"temp": 23.0},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
event = _decode_sse_events(frame)[0]
|
||||||
|
assert event["sse_emitted_at_ms"] == 1780750904500
|
||||||
|
assert event["payload"]["temp"] == 23.0
|
||||||
|
|
||||||
|
|
||||||
def test_events_endpoint_replays_only_requested_cities(monkeypatch):
|
def test_events_endpoint_replays_only_requested_cities(monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1012,6 +1012,13 @@ def test_city_detail_batch_returns_busy_when_global_builder_slot_is_full(monkeyp
|
|||||||
assert payload["busy"] is True
|
assert payload["busy"] is True
|
||||||
assert payload["details"] == {}
|
assert payload["details"] == {}
|
||||||
assert payload["missing"] == ["paris", "shanghai"]
|
assert payload["missing"] == ["paris", "shanghai"]
|
||||||
|
assert payload["diagnostics"]["partial_reason"] == "busy"
|
||||||
|
assert payload["diagnostics"]["response_source"] == "busy"
|
||||||
|
assert payload["diagnostics"]["requested_count"] == 2
|
||||||
|
assert payload["diagnostics"]["completed_count"] == 0
|
||||||
|
assert payload["diagnostics"]["missing_count"] == 2
|
||||||
|
assert payload["diagnostics"]["city_status"]["paris"]["status"] == "busy"
|
||||||
|
assert payload["diagnostics"]["city_status"]["shanghai"]["status"] == "busy"
|
||||||
assert build_calls == 0
|
assert build_calls == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1102,6 +1109,17 @@ def test_city_detail_batch_returns_completed_details_when_one_city_is_slow(monke
|
|||||||
assert payload["partial"] is True
|
assert payload["partial"] is True
|
||||||
assert payload["missing"] == ["slow"]
|
assert payload["missing"] == ["slow"]
|
||||||
assert payload["errors"] == {}
|
assert payload["errors"] == {}
|
||||||
|
assert payload["diagnostics"]["partial_reason"] == "timeout"
|
||||||
|
assert payload["diagnostics"]["requested_count"] == 3
|
||||||
|
assert payload["diagnostics"]["completed_count"] == 2
|
||||||
|
assert payload["diagnostics"]["missing_count"] == 1
|
||||||
|
assert payload["diagnostics"]["error_count"] == 0
|
||||||
|
assert payload["diagnostics"]["batch_concurrency"] == 2
|
||||||
|
assert payload["diagnostics"]["partial_timeout_ms"] == 20
|
||||||
|
assert payload["diagnostics"]["city_status"]["fast"]["status"] == "ok"
|
||||||
|
assert payload["diagnostics"]["city_status"]["other"]["status"] == "ok"
|
||||||
|
assert payload["diagnostics"]["city_status"]["slow"]["status"] == "timeout"
|
||||||
|
assert isinstance(payload["diagnostics"]["city_status"]["fast"]["duration_ms"], (int, float))
|
||||||
assert "slow" not in completed
|
assert "slow" not in completed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+129
-12
@@ -973,6 +973,90 @@ def _city_detail_batch_partial_timeout_seconds() -> Optional[float]:
|
|||||||
return max(0.001, min(60.0, timeout_ms / 1000.0))
|
return max(0.001, min(60.0, timeout_ms / 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _city_detail_batch_partial_timeout_ms() -> Optional[int]:
|
||||||
|
timeout_sec = _city_detail_batch_partial_timeout_seconds()
|
||||||
|
if timeout_sec is None:
|
||||||
|
return None
|
||||||
|
return int(round(timeout_sec * 1000.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _city_detail_batch_partial_reason(
|
||||||
|
*,
|
||||||
|
busy: bool,
|
||||||
|
missing: List[str],
|
||||||
|
errors: Dict[str, str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
if busy:
|
||||||
|
return "busy"
|
||||||
|
if missing and errors:
|
||||||
|
return "timeout_error"
|
||||||
|
if missing:
|
||||||
|
return "timeout"
|
||||||
|
if errors:
|
||||||
|
return "error"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_city_detail_batch_diagnostics(
|
||||||
|
*,
|
||||||
|
city_names: List[str],
|
||||||
|
details: Dict[str, Any],
|
||||||
|
errors: Dict[str, str],
|
||||||
|
missing: List[str],
|
||||||
|
resolution: Optional[str],
|
||||||
|
detail_scope: str,
|
||||||
|
force_refresh: bool,
|
||||||
|
response_source: str,
|
||||||
|
busy: bool = False,
|
||||||
|
city_durations_ms: Optional[Dict[str, float]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
city_durations_ms = city_durations_ms or {}
|
||||||
|
missing_set = set(missing)
|
||||||
|
error_set = set(errors)
|
||||||
|
detail_set = set(details)
|
||||||
|
partial_reason = _city_detail_batch_partial_reason(
|
||||||
|
busy=busy,
|
||||||
|
missing=missing,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
city_status: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for city in city_names:
|
||||||
|
if busy:
|
||||||
|
status = "busy"
|
||||||
|
elif city in missing_set:
|
||||||
|
status = "timeout"
|
||||||
|
elif city in error_set:
|
||||||
|
status = "error"
|
||||||
|
elif city in detail_set:
|
||||||
|
status = "ok"
|
||||||
|
else:
|
||||||
|
status = "missing"
|
||||||
|
city_status[city] = {
|
||||||
|
"status": status,
|
||||||
|
"duration_ms": city_durations_ms.get(city),
|
||||||
|
}
|
||||||
|
if city in errors:
|
||||||
|
city_status[city]["error"] = errors[city]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"response_source": response_source,
|
||||||
|
"partial": bool(partial_reason),
|
||||||
|
"partial_reason": partial_reason,
|
||||||
|
"requested_count": len(city_names),
|
||||||
|
"completed_count": len(details),
|
||||||
|
"missing_count": len(missing),
|
||||||
|
"error_count": len(errors),
|
||||||
|
"batch_concurrency": _city_detail_batch_concurrency(),
|
||||||
|
"global_concurrency": _city_detail_batch_global_concurrency(),
|
||||||
|
"partial_timeout_ms": _city_detail_batch_partial_timeout_ms(),
|
||||||
|
"force_refresh": force_refresh,
|
||||||
|
"resolution": resolution,
|
||||||
|
"scope": detail_scope,
|
||||||
|
"city_status": city_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_city_detail_batch_payload(
|
async def get_city_detail_batch_payload(
|
||||||
request: Request,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
@@ -1014,30 +1098,52 @@ async def get_city_detail_batch_payload(
|
|||||||
async def _build_uncached_payload() -> Dict[str, Any]:
|
async def _build_uncached_payload() -> Dict[str, Any]:
|
||||||
build_semaphore = _city_detail_batch_build_semaphore()
|
build_semaphore = _city_detail_batch_build_semaphore()
|
||||||
if not build_semaphore.acquire(blocking=False):
|
if not build_semaphore.acquire(blocking=False):
|
||||||
|
missing = list(city_names)
|
||||||
|
errors: Dict[str, str] = {}
|
||||||
|
details: Dict[str, Any] = {}
|
||||||
return {
|
return {
|
||||||
"cities": city_names,
|
"cities": city_names,
|
||||||
"details": {},
|
"details": details,
|
||||||
"errors": {},
|
"errors": errors,
|
||||||
"missing": list(city_names),
|
"missing": missing,
|
||||||
"partial": True,
|
"partial": True,
|
||||||
"busy": True,
|
"busy": True,
|
||||||
"stale_reason": "city detail batch builder is busy",
|
"stale_reason": "city detail batch builder is busy",
|
||||||
|
"diagnostics": _build_city_detail_batch_diagnostics(
|
||||||
|
city_names=city_names,
|
||||||
|
details=details,
|
||||||
|
errors=errors,
|
||||||
|
missing=missing,
|
||||||
|
resolution=resolution,
|
||||||
|
detail_scope=detail_scope,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
response_source="busy",
|
||||||
|
busy=True,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
|
semaphore = asyncio.Semaphore(_city_detail_batch_concurrency())
|
||||||
|
city_durations_ms: Dict[str, float] = {}
|
||||||
|
|
||||||
async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]:
|
async def _build_with_limit(city: str) -> Tuple[str, Dict[str, Any]]:
|
||||||
async with semaphore:
|
async with semaphore:
|
||||||
return await _build_city_detail_batch_item_async(
|
started = time.perf_counter()
|
||||||
city,
|
try:
|
||||||
force_refresh=force_refresh,
|
return await _build_city_detail_batch_item_async(
|
||||||
market_slug=market_slug,
|
city,
|
||||||
target_date=target_date,
|
force_refresh=force_refresh,
|
||||||
resolution=resolution,
|
market_slug=market_slug,
|
||||||
detail_scope=detail_scope,
|
target_date=target_date,
|
||||||
timing_recorder=timer,
|
resolution=resolution,
|
||||||
)
|
detail_scope=detail_scope,
|
||||||
|
timing_recorder=timer,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
city_durations_ms[city] = round(
|
||||||
|
(time.perf_counter() - started) * 1000.0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
task_by_city = {
|
task_by_city = {
|
||||||
city: asyncio.create_task(_build_with_limit(city))
|
city: asyncio.create_task(_build_with_limit(city))
|
||||||
@@ -1076,6 +1182,17 @@ async def get_city_detail_batch_payload(
|
|||||||
"errors": errors,
|
"errors": errors,
|
||||||
"missing": missing,
|
"missing": missing,
|
||||||
"partial": bool(missing or errors),
|
"partial": bool(missing or errors),
|
||||||
|
"diagnostics": _build_city_detail_batch_diagnostics(
|
||||||
|
city_names=city_names,
|
||||||
|
details=details,
|
||||||
|
errors=errors,
|
||||||
|
missing=missing,
|
||||||
|
resolution=resolution,
|
||||||
|
detail_scope=detail_scope,
|
||||||
|
force_refresh=force_refresh,
|
||||||
|
response_source="fresh_build",
|
||||||
|
city_durations_ms=city_durations_ms,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
build_semaphore.release()
|
build_semaphore.release()
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ class SseManager:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_event(event: dict[str, Any]) -> str:
|
def _format_event(event: dict[str, Any]) -> str:
|
||||||
|
if str(event.get("type") or "").startswith("city_observation_patch"):
|
||||||
|
event = {
|
||||||
|
**event,
|
||||||
|
"sse_emitted_at_ms": int(time.time() * 1000),
|
||||||
|
}
|
||||||
return f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
return f"data: {json.dumps(event, ensure_ascii=False, separators=(',', ':'))}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user