Improve ops monitoring and chart loading

This commit is contained in:
2569718930@qq.com
2026-05-31 03:21:58 +08:00
parent ca72072da0
commit 2a6d8748f4
38 changed files with 3110 additions and 637 deletions
@@ -105,6 +105,18 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null;
}
function shouldFetchCityDetailForChart({
city,
documentHidden,
isChartVisible,
}: {
city: string;
documentHidden: boolean;
isChartVisible: boolean;
}) {
return Boolean(city) && isChartVisible && !documentHidden;
}
// ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({
@@ -141,11 +153,18 @@ export function LiveTemperatureThresholdChart({
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
const hasLoadedHourlyDetailRef = useRef(false);
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined",
);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
@@ -167,6 +186,9 @@ export function LiveTemperatureThresholdChart({
setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null);
setIsHourlyLoading(Boolean(city));
setDetailError(null);
setDetailRetryNonce(0);
setShowingStaleDetail(false);
hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0;
@@ -175,6 +197,23 @@ export function LiveTemperatureThresholdChart({
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]);
useEffect(() => {
const node = chartVisibilityRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
setIsChartVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsChartVisible(entry.isIntersecting || entry.intersectionRatio > 0);
},
{ root: null, rootMargin: "160px 0px", threshold: 0.01 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
useEffect(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
const id = setInterval(() => {
@@ -188,30 +227,47 @@ export function LiveTemperatureThresholdChart({
setIsHourlyLoading(false);
return;
}
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
})
) {
return;
}
const cacheKey = `${city}:${targetResolution}`;
// Check in-memory cache first
let cached = _hourlyCache.get(cacheKey);
if (!cached) {
// Fallback to session cache
const sessionEntry = readSessionCache(cacheKey);
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
if (sessionEntry) {
cached = sessionEntry;
_hourlyCache.set(cacheKey, sessionEntry);
}
}
const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
if (cached) {
hasLoadedHourlyDetailRef.current = true;
setHourly(cached.data);
setShowingStaleDetail(!hasFreshCache);
}
if (hasFreshCache) {
setIsHourlyLoading(false);
setDetailError(null);
return;
}
if (!hasLoadedHourlyDetailRef.current) {
if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(row));
setShowingStaleDetail(false);
}
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current);
setIsHourlyLoading(true);
let cancelled = false;
// Prioritize active slots, stagger/delay background slots to optimize load performance
@@ -220,11 +276,19 @@ export function LiveTemperatureThresholdChart({
const timer = setTimeout(() => {
fetchHourlyForecastForCity(city, { resolution: targetResolution })
.then((data) => {
if (cancelled || !data) return;
if (cancelled) return;
if (!data) {
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
return;
}
hasLoadedHourlyDetailRef.current = true;
setHourly(data);
setDetailError(null);
setShowingStaleDetail(false);
})
.catch(() => {
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
})
.catch(() => {})
.finally(() => {
if (!cancelled) setIsHourlyLoading(false);
});
@@ -234,7 +298,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true;
clearTimeout(timer);
};
}, [city, row, isActive, slotIndex, targetResolution]);
}, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
@@ -636,6 +700,12 @@ export function LiveTemperatureThresholdChart({
}));
}, [isSeriesVisible]);
const handleRetryDetail = useCallback(() => {
setDetailError(null);
setIsHourlyLoading(true);
setDetailRetryNonce((value) => value + 1);
}, []);
const panelTitle = row ? (
<div className="flex items-center gap-1">
<button
@@ -743,7 +813,7 @@ export function LiveTemperatureThresholdChart({
actions={timeframeActions}
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
>
<div className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
<div ref={chartVisibilityRef} className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
<TemperatureStatsBars
isEn={isEn}
compact={compact}
@@ -790,6 +860,8 @@ export function LiveTemperatureThresholdChart({
hasRunwayData={hasRunwayData}
showRunwayDetails={showRunwayDetails}
isHourlyLoading={isHourlyLoading}
detailError={detailError}
showingStaleDetail={showingStaleDetail}
refAreaLeft={refAreaLeft}
refAreaRight={refAreaRight}
onMouseDown={handleMouseDown}
@@ -799,6 +871,7 @@ export function LiveTemperatureThresholdChart({
isSeriesVisible={isSeriesVisible}
onSeriesToggle={handleSeriesToggle}
onShowRunwayDetailsChange={setShowRunwayDetails}
onRetryDetail={handleRetryDetail}
/>
</div>
</Panel>
@@ -822,6 +895,7 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __getPeakGlowStateForTest = getPeakGlowState;
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
@@ -110,6 +110,8 @@ function TemperatureChartCanvasComponent({
hasRunwayData,
showRunwayDetails,
isHourlyLoading,
detailError,
showingStaleDetail,
refAreaLeft,
refAreaRight,
onMouseDown,
@@ -119,6 +121,7 @@ function TemperatureChartCanvasComponent({
isSeriesVisible,
onSeriesToggle,
onShowRunwayDetailsChange,
onRetryDetail,
}: {
isEn: boolean;
compact: boolean;
@@ -134,6 +137,8 @@ function TemperatureChartCanvasComponent({
hasRunwayData: boolean;
showRunwayDetails: boolean;
isHourlyLoading: boolean;
detailError?: string | null;
showingStaleDetail?: boolean;
refAreaLeft: number | null;
refAreaRight: number | null;
onMouseDown: (event: any) => void;
@@ -143,6 +148,7 @@ function TemperatureChartCanvasComponent({
isSeriesVisible: (seriesKey: string) => boolean;
onSeriesToggle: (seriesKey: string) => void;
onShowRunwayDetailsChange: (value: boolean) => void;
onRetryDetail?: () => void;
}) {
const chartHostRef = useRef<HTMLDivElement | null>(null);
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
@@ -215,6 +221,9 @@ function TemperatureChartCanvasComponent({
const shouldRenderChart = canRenderChart && hasDrawableChartContent;
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundError =
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
return (
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
@@ -420,7 +429,21 @@ function TemperatureChartCanvasComponent({
))}
</ReComposedChart>
)}
{shouldShowEmptyState && (
{shouldShowUnavailableState && (
<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>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
<button
type="button"
onClick={onRetryDetail}
className="mt-2 rounded border border-amber-300 bg-white px-2 py-1 text-[10px] font-bold text-amber-700 shadow-sm transition-colors hover:bg-amber-100"
>
{isEn ? "Retry" : "重试"}
</button>
</div>
</div>
)}
{shouldShowEmptyState && !shouldShowUnavailableState && (
<div className="pointer-events-none absolute inset-0 grid place-items-center px-4 text-center">
<div className="rounded border border-slate-200 bg-white/90 px-3 py-2 text-[11px] font-semibold text-slate-500 shadow-sm">
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
@@ -428,6 +451,18 @@ function TemperatureChartCanvasComponent({
</div>
)}
</div>
{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">
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
<button
type="button"
onClick={onRetryDetail}
className="rounded border border-amber-300 bg-white px-1.5 py-0.5 font-bold transition-colors hover:bg-amber-100"
>
{isEn ? "Retry" : "重试"}
</button>
</div>
)}
{shouldShowBackgroundRefresh && (
<div className="pointer-events-none absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-slate-200 bg-white/80 px-2 py-1 text-[10px] font-semibold text-slate-500 shadow-sm backdrop-blur-[1px]">
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500" />
@@ -5,11 +5,17 @@ import {
DASHBOARD_REFRESH_POLICY_SEC,
} from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) {
@@ -79,6 +85,30 @@ export async function runTests() {
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
);
assert(
chartSource.includes("IntersectionObserver") &&
chartSource.includes("shouldFetchCityDetailForChart") &&
chartSource.includes("isChartVisible"),
"city detail prefetch should be gated to visible chart cards instead of every mounted slot",
);
assert(
chartSource.includes("allowStale: true") &&
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
"city detail charts should show stale cache first and expose a retryable unavailable state",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
"visible chart cards should fetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false,
"offscreen chart cards should not prefetch city detail",
);
assert(
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false,
"hidden browser tabs should not prefetch city detail",
);
__resetHourlyDetailRequestQueueForTest();
let activeRequests = 0;
@@ -130,4 +160,63 @@ export async function runTests() {
"city detail queue should resolve every queued request in order",
);
__resetHourlyDetailRequestQueueForTest();
const originalWindow = (globalThis as any).window;
const originalSessionStorage = (globalThis as any).sessionStorage;
const store = new Map<string, string>();
(globalThis as any).window = {};
(globalThis as any).sessionStorage = {
get length() {
return store.size;
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
try {
clearCityDetailCache();
const cacheKey = "paris:10m";
store.set(
`polyweather_city_detail_v1:${cacheKey}`,
JSON.stringify({
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
data: {
forecastDaily: [],
localDate: "2026-05-31",
multiModelDaily: {},
probabilities: null,
temps: [21],
times: ["00:00"],
},
}),
);
assert(
__readHourlyCacheEntryForTest(cacheKey) === null,
"stale city detail cache should not suppress a fresh revalidation",
);
assert(
__readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21,
"stale city detail cache should still be available for immediate chart rendering",
);
} finally {
clearCityDetailCache();
(globalThis as any).window = originalWindow;
(globalThis as any).sessionStorage = originalSessionStorage;
}
}
function chartCanvasSourceIncludes(source: string, pattern: string) {
return source.includes(pattern) || fs.readFileSync(
path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
"utf8",
).includes(pattern);
}
@@ -364,19 +364,50 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null {
type HourlyCacheEntry = { ts: number; data: HourlyForecast };
function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) {
return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS);
}
function readSessionCache(
city: string,
options: { allowStale?: boolean } = {},
): HourlyCacheEntry | null {
if (typeof window === "undefined") return null;
try {
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null;
const item = JSON.parse(raw);
if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) {
if (
item &&
item.ts &&
(options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS)
) {
return item;
}
} catch {}
return null;
}
function readHourlyCacheEntry(
cacheKey: string,
options: { allowStale?: boolean } = {},
): HourlyCacheEntry | null {
const cached = _hourlyCache.get(cacheKey);
if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) {
return cached;
}
const sessionEntry = readSessionCache(cacheKey, options);
if (sessionEntry) {
_hourlyCache.set(cacheKey, sessionEntry);
return sessionEntry;
}
return null;
}
function writeSessionCache(city: string, data: HourlyForecast) {
if (typeof window === "undefined" || !data) return;
try {
@@ -436,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() {
}
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
const __readHourlyCacheEntryForTest = readHourlyCacheEntry;
function validNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -969,16 +1001,10 @@ async function fetchHourlyForecastForCity(
const cacheKey = `${city}:${resParam}`;
if (!options.ignoreCache) {
const cached = _hourlyCache.get(cacheKey);
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
const cached = readHourlyCacheEntry(cacheKey);
if (cached) {
return cached.data;
}
const sessionCached = readSessionCache(cacheKey);
if (sessionCached) {
_hourlyCache.set(cacheKey, sessionCached);
return sessionCached.data;
}
}
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
@@ -2259,6 +2285,7 @@ export {
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS,
_hourlyCache,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest,
buildChartDomain,