Optimize terminal detail loading and Redis replay
This commit is contained in:
@@ -61,6 +61,7 @@ import {
|
||||
mergeScanRowsWithCityFallbackRows,
|
||||
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
|
||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||
import { STATIC_CITY_LIST } from "@/lib/static-cities";
|
||||
|
||||
const TrainingDashboard = dynamic(
|
||||
() =>
|
||||
@@ -1218,7 +1219,9 @@ function ScanTerminalScreen() {
|
||||
refreshScanTerminalManually();
|
||||
}, [refreshScanTerminalManually]);
|
||||
|
||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>([]);
|
||||
const [cityFallbackRows, setCityFallbackRows] = useState<ScanOpportunityRow[]>(() =>
|
||||
cityListItemsToScanRows(STATIC_CITY_LIST),
|
||||
);
|
||||
const rows = useMemo(
|
||||
() => {
|
||||
const scanRows = terminalData?.rows || [];
|
||||
|
||||
@@ -121,9 +121,11 @@ export function runTests() {
|
||||
|
||||
assert(
|
||||
dashboardSource.includes("cityListItemsToScanRows") &&
|
||||
dashboardSource.includes("STATIC_CITY_LIST") &&
|
||||
dashboardSource.includes("useState<ScanOpportunityRow[]>(() =>") &&
|
||||
dashboardSource.includes("/api/cities") &&
|
||||
dashboardSource.includes("cityFallbackRows"),
|
||||
"terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready",
|
||||
"terminal dashboard should seed fallback rows from the static city snapshot before refreshing /api/cities",
|
||||
);
|
||||
assert(fs.existsSync(staticCitiesPath), "/api/cities route should have a static city snapshot fallback");
|
||||
assert(
|
||||
|
||||
@@ -85,6 +85,17 @@ export async function runTests() {
|
||||
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("/api/cities/detail-batch") &&
|
||||
chartLogicSource.includes("flushCityDetailBatch") &&
|
||||
chartLogicSource.includes("primeCityDetailCache"),
|
||||
"visible terminal chart detail fetches should be coalesced into one batch request and prime the shared chart cache",
|
||||
);
|
||||
assert(
|
||||
chartLogicSource.includes("options.ignoreCache\n ? runQueuedHourlyDetailRequest") &&
|
||||
chartLogicSource.includes(": queueCityDetailBatch(city, resParam)"),
|
||||
"normal first-paint city detail requests should enter the batch queue before single-request concurrency limiting",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("IntersectionObserver") &&
|
||||
chartSource.includes("shouldFetchCityDetailForChart") &&
|
||||
|
||||
@@ -965,6 +965,26 @@ type HourlyForecastFetchOptions = {
|
||||
resolution?: string;
|
||||
};
|
||||
|
||||
type CityDetailBatchPayload = {
|
||||
details?: Record<string, CityDetail | null | undefined>;
|
||||
errors?: Record<string, string>;
|
||||
};
|
||||
|
||||
type CityDetailBatchWaiter = {
|
||||
resolve: (value: HourlyForecast) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type CityDetailBatchQueue = {
|
||||
cities: Set<string>;
|
||||
waiters: Map<string, CityDetailBatchWaiter[]>;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const CITY_DETAIL_BATCH_WINDOW_MS = 25;
|
||||
const CITY_DETAIL_BATCH_MAX_CITIES = 12;
|
||||
const _cityDetailBatchQueues = new Map<string, CityDetailBatchQueue>();
|
||||
|
||||
function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForecast {
|
||||
const hourlySource = (json as any)?.hourly ?? (json as any)?.timeseries?.hourly;
|
||||
if (!json || !hourlySource) return null;
|
||||
@@ -993,6 +1013,133 @@ function parseHourlyForecastFromCityDetail(json: CityDetail | null): HourlyForec
|
||||
};
|
||||
}
|
||||
|
||||
function primeCityDetailCache(
|
||||
city: string,
|
||||
resolution: string,
|
||||
detail: CityDetail | null | undefined,
|
||||
): HourlyForecast {
|
||||
const data = parseHourlyForecastFromCityDetail(detail || null);
|
||||
if (!data) return null;
|
||||
const cacheKey = `${city}:${resolution}`;
|
||||
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
|
||||
writeSessionCache(cacheKey, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchSingleHourlyForecastForCity(
|
||||
city: string,
|
||||
resolution: string,
|
||||
): Promise<HourlyForecast> {
|
||||
const res = await fetchCityDetailWithTimeout(city, resolution);
|
||||
if (!res || !res.ok) return null;
|
||||
const json = await res.json() as CityDetail;
|
||||
return primeCityDetailCache(city, resolution, json);
|
||||
}
|
||||
|
||||
function queueCityDetailBatch(city: string, resolution: string): Promise<HourlyForecast> {
|
||||
return new Promise<HourlyForecast>((resolve, reject) => {
|
||||
const queue = _cityDetailBatchQueues.get(resolution) || {
|
||||
cities: new Set<string>(),
|
||||
waiters: new Map<string, CityDetailBatchWaiter[]>(),
|
||||
timer: null,
|
||||
};
|
||||
_cityDetailBatchQueues.set(resolution, queue);
|
||||
|
||||
const cityWaiters = queue.waiters.get(city) || [];
|
||||
cityWaiters.push({ resolve, reject });
|
||||
queue.waiters.set(city, cityWaiters);
|
||||
queue.cities.add(city);
|
||||
|
||||
if (queue.timer === null) {
|
||||
queue.timer = setTimeout(() => flushCityDetailBatch(resolution), CITY_DETAIL_BATCH_WINDOW_MS);
|
||||
}
|
||||
if (queue.cities.size >= CITY_DETAIL_BATCH_MAX_CITIES) {
|
||||
flushCityDetailBatch(resolution);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBatchWaiters(
|
||||
waiters: CityDetailBatchWaiter[] | undefined,
|
||||
value: HourlyForecast,
|
||||
) {
|
||||
(waiters || []).forEach((waiter) => waiter.resolve(value));
|
||||
}
|
||||
|
||||
function rejectBatchWaiters(
|
||||
waiters: CityDetailBatchWaiter[] | undefined,
|
||||
reason: unknown,
|
||||
) {
|
||||
(waiters || []).forEach((waiter) => waiter.reject(reason));
|
||||
}
|
||||
|
||||
async function flushCityDetailBatch(resolution: string) {
|
||||
const queue = _cityDetailBatchQueues.get(resolution);
|
||||
if (!queue) return;
|
||||
_cityDetailBatchQueues.delete(resolution);
|
||||
if (queue.timer !== null) {
|
||||
clearTimeout(queue.timer);
|
||||
queue.timer = null;
|
||||
}
|
||||
|
||||
const cities = Array.from(queue.cities).sort();
|
||||
if (!cities.length) return;
|
||||
|
||||
try {
|
||||
const payload = await fetchCityDetailBatchWithTimeout(cities, resolution);
|
||||
const details = payload?.details || {};
|
||||
await Promise.all(
|
||||
cities.map(async (city) => {
|
||||
const waiters = queue.waiters.get(city);
|
||||
const detail = details[city];
|
||||
const data = primeCityDetailCache(city, resolution, detail);
|
||||
if (data) {
|
||||
resolveBatchWaiters(waiters, data);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution));
|
||||
} catch (error) {
|
||||
rejectBatchWaiters(waiters, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
cities.map(async (city) => {
|
||||
const waiters = queue.waiters.get(city);
|
||||
try {
|
||||
resolveBatchWaiters(waiters, await fetchSingleHourlyForecastForCity(city, resolution));
|
||||
} catch (fallbackError) {
|
||||
rejectBatchWaiters(waiters, fallbackError || error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchCityDetailBatchWithTimeout(cities: string[], resolution: string) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(() => controller.abort(), HOURLY_DETAIL_REQUEST_TIMEOUT_MS);
|
||||
const params = new URLSearchParams({
|
||||
cities: cities.join(","),
|
||||
depth: "full",
|
||||
force_refresh: "false",
|
||||
limit: String(Math.max(cities.length, CITY_DETAIL_BATCH_MAX_CITIES)),
|
||||
resolution,
|
||||
});
|
||||
return fetch(`/api/cities/detail-batch?${params.toString()}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<CityDetailBatchPayload>;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => globalThis.clearTimeout(timeoutId));
|
||||
}
|
||||
|
||||
async function fetchHourlyForecastForCity(
|
||||
city: string,
|
||||
options: HourlyForecastFetchOptions = {},
|
||||
@@ -1011,19 +1158,10 @@ async function fetchHourlyForecastForCity(
|
||||
const pending = _hourlyRequestCache.get(requestKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const request = runQueuedHourlyDetailRequest(() =>
|
||||
fetchCityDetailWithTimeout(city, resParam)
|
||||
.then(async (res) => {
|
||||
if (!res || !res.ok) return null;
|
||||
return res.json() as Promise<CityDetail>;
|
||||
})
|
||||
.then((json) => {
|
||||
const data = parseHourlyForecastFromCityDetail(json);
|
||||
if (!data) return null;
|
||||
_hourlyCache.set(cacheKey, { ts: Date.now(), data });
|
||||
writeSessionCache(cacheKey, data);
|
||||
return data;
|
||||
}),
|
||||
const request = (
|
||||
options.ignoreCache
|
||||
? runQueuedHourlyDetailRequest(() => fetchSingleHourlyForecastForCity(city, resParam))
|
||||
: queueCityDetailBatch(city, resParam)
|
||||
)
|
||||
.finally(() => {
|
||||
_hourlyRequestCache.delete(requestKey);
|
||||
|
||||
@@ -27,8 +27,12 @@ export function runTests() {
|
||||
systemPage.includes("sourceHealth") &&
|
||||
systemPage.includes("MGM、KNMI、IMS") &&
|
||||
systemPage.includes("断线") &&
|
||||
systemPage.includes("延迟"),
|
||||
"ops system page must show source latency/disconnect status for operational city sources",
|
||||
systemPage.includes("延迟") &&
|
||||
systemPage.includes("sourceReasonLabel") &&
|
||||
systemPage.includes("观测时间缺失") &&
|
||||
systemPage.includes("formatOpsValue") &&
|
||||
systemPage.includes("强制刷新"),
|
||||
"ops system page must show readable source reasons, object values, and cache force-refresh metrics",
|
||||
);
|
||||
assert(
|
||||
nextRoute.includes("requireOpsProxyAuth") &&
|
||||
|
||||
@@ -50,6 +50,25 @@ function compactDate(value?: string) {
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function paymentReasonLabel(reason?: string) {
|
||||
const key = String(reason || "").trim().toLowerCase();
|
||||
if (key === "receiver_mismatch") return "收款地址不匹配";
|
||||
if (key === "amount_mismatch") return "金额不匹配";
|
||||
if (key === "chain_mismatch") return "支付网络不匹配";
|
||||
if (key === "tx_not_found") return "链上交易未找到";
|
||||
if (key === "tx_reverted") return "链上交易失败";
|
||||
if (key === "expired") return "订单已过期";
|
||||
if (key === "unknown") return "未知原因";
|
||||
return key || "未知原因";
|
||||
}
|
||||
|
||||
function compactMono(value?: string, head = 10, tail = 6) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "—";
|
||||
if (text.length <= head + tail + 3) return text;
|
||||
return `${text.slice(0, head)}...${text.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function RiskStat({
|
||||
label,
|
||||
value,
|
||||
@@ -148,7 +167,7 @@ export function PaymentsPageClient() {
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
<RiskStat label="总异常" value={Number(riskSummary.issues ?? 0)} sub="需要人工关注" tone={hasRisk ? "text-red-600" : "text-emerald-600"} />
|
||||
<RiskStat label="Intent 卡住" value={Number(riskSummary.stuck_intents ?? 0)} sub="submitted/过期 created" />
|
||||
<RiskStat label="试用漏开" value={Number(riskSummary.trial_gaps ?? 0)} sub="signup 无 trial_created" />
|
||||
<RiskStat label="试用漏开" value={Number(riskSummary.trial_gaps ?? 0)} sub="后端试用/订阅证据缺失" />
|
||||
<RiskStat label="支付异常" value={Number(riskSummary.payment_incidents ?? incidents.length)} sub="未标记处理" />
|
||||
<RiskStat label="积分异常" value={Number(riskSummary.points_discount_issues ?? 0)} sub="确认后未扣/少扣" />
|
||||
<RiskStat label="推荐异常" value={Number(riskSummary.referral_settlement_issues ?? 0)} sub="转化无奖励记录" />
|
||||
@@ -287,7 +306,8 @@ export function PaymentsPageClient() {
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-slate-400">
|
||||
<th className="py-2 pr-4 font-medium">ID</th>
|
||||
<th className="py-2 pr-4 font-medium">原因</th>
|
||||
<th className="py-2 pr-4 font-medium">原因 / 详情</th>
|
||||
<th className="py-2 pr-4 font-medium">用户 / Intent</th>
|
||||
<th className="py-2 pr-4 font-medium">时间</th>
|
||||
<th className="py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
@@ -296,7 +316,16 @@ export function PaymentsPageClient() {
|
||||
{incidents.map((inc) => (
|
||||
<tr key={inc.id} className="border-b border-white/5">
|
||||
<td className="py-2 pr-4 text-slate-500 font-mono">{inc.id}</td>
|
||||
<td className="py-2 pr-4 text-amber-300">{inc.reason ?? "—"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="font-bold text-amber-600">{paymentReasonLabel(inc.reason)}</div>
|
||||
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
|
||||
{inc.detail || inc.reason || "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-slate-500">
|
||||
<div className="font-mono" title={inc.user_id || ""}>{compactMono(inc.user_id)}</div>
|
||||
<div className="mt-0.5 font-mono text-blue-700" title={inc.intent_id || ""}>{compactMono(inc.intent_id, 12, 6)}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-400 text-xs">{inc.created_at?.slice(0, 19) ?? "—"}</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
|
||||
@@ -31,6 +31,35 @@ function formatAge(ageMin?: number | null) {
|
||||
return `${(ageMin / 60).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function sourceReasonLabel(reason?: string | null) {
|
||||
const key = String(reason || "").trim().toLowerCase();
|
||||
if (key === "observation_time_missing") return "观测时间缺失";
|
||||
if (key === "expected_source_not_present_in_cached_detail") return "缓存详情未包含预期来源";
|
||||
if (key === "past_expected_cadence") return "超过预期更新节奏";
|
||||
if (key === "within_expected_cadence") return "仍在预期更新窗口";
|
||||
if (key === "missing_observation") return "缺少观测值";
|
||||
if (key === "no_cached_detail") return "缺少城市详情缓存";
|
||||
return key || "—";
|
||||
}
|
||||
|
||||
function formatOpsValue(value: unknown) {
|
||||
if (typeof value === "boolean") {
|
||||
return { label: value ? "TRUE" : "FALSE", active: value };
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return { label: String(value), active: Boolean(value) };
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return { label: `${value.length} 项`, active: value.length > 0 };
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.values(value as Record<string, unknown>);
|
||||
const enabled = entries.filter(Boolean).length;
|
||||
return { label: `${enabled}/${entries.length} 已配置`, active: enabled > 0 };
|
||||
}
|
||||
return { label: "—", active: false };
|
||||
}
|
||||
|
||||
export function SystemPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
@@ -137,12 +166,15 @@ export function SystemPageClient() {
|
||||
<CardContent>
|
||||
<dl className="space-y-2 text-sm">
|
||||
{status?.features
|
||||
? Object.entries(status.features).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<Badge variant={v ? "default" : "secondary"}>{String(v)}</Badge>
|
||||
</div>
|
||||
))
|
||||
? Object.entries(status.features).map(([k, v]) => {
|
||||
const formatted = formatOpsValue(v);
|
||||
return (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<Badge variant={formatted.active ? "default" : "secondary"}>{formatted.label}</Badge>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <span className="text-slate-500">无数据</span>}
|
||||
</dl>
|
||||
</CardContent>
|
||||
@@ -155,12 +187,15 @@ export function SystemPageClient() {
|
||||
<CardContent>
|
||||
<dl className="space-y-2 text-sm">
|
||||
{status?.integrations
|
||||
? Object.entries(status.integrations).map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<Badge variant={v ? "default" : "secondary"}>{String(v)}</Badge>
|
||||
</div>
|
||||
))
|
||||
? Object.entries(status.integrations).map(([k, v]) => {
|
||||
const formatted = formatOpsValue(v);
|
||||
return (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<Badge variant={formatted.active ? "default" : "secondary"}>{formatted.label}</Badge>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <span className="text-slate-500">无数据</span>}
|
||||
</dl>
|
||||
</CardContent>
|
||||
@@ -174,7 +209,7 @@ export function SystemPageClient() {
|
||||
<CardTitle>缓存分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-5">
|
||||
<div>
|
||||
<div className="text-slate-500">总请求</div>
|
||||
<div className="text-lg font-bold text-white">{cacheAnalysis.total_requests ?? 0}</div>
|
||||
@@ -187,6 +222,10 @@ export function SystemPageClient() {
|
||||
<div className="text-slate-500">未命中</div>
|
||||
<div className="text-lg font-bold text-amber-400">{cacheAnalysis.cache_misses ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">强制刷新</div>
|
||||
<div className="text-lg font-bold text-blue-500">{cacheAnalysis.force_refresh_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500">命中率</div>
|
||||
<div className="text-lg font-bold text-cyan-400">
|
||||
@@ -194,6 +233,9 @@ export function SystemPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-500">
|
||||
这里统计当前后端进程的分析缓存请求;部署重启后会重新累计。
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
@@ -243,7 +285,9 @@ export function SystemPageClient() {
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatAge(source.age_min)}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{source.observed_at || "—"}</td>
|
||||
<td className="px-3 py-2 text-slate-500">{source.reason || "—"}</td>
|
||||
<td className="px-3 py-2 text-slate-500" title={source.reason || ""}>
|
||||
{sourceReasonLabel(source.reason)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user