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,
@@ -0,0 +1,62 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const analyticsPage = fs.readFileSync(
path.join(projectRoot, "components", "ops", "analytics", "AnalyticsPageClient.tsx"),
"utf8",
);
const overviewPage = fs.readFileSync(
path.join(projectRoot, "components", "ops", "overview", "OverviewPageClient.tsx"),
"utf8",
);
const appAnalytics = fs.readFileSync(path.join(projectRoot, "lib", "app-analytics.ts"), "utf8");
const analyticsRoute = fs.readFileSync(
path.join(projectRoot, "app", "api", "analytics", "events", "route.ts"),
"utf8",
);
const authMeRoute = fs.readFileSync(path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), "utf8");
assert(
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
opsApi.includes("diagnostics?:") &&
opsApi.includes("traffic?:") &&
opsApi.includes("uniqueActors"),
"ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions",
);
assert(
analyticsPage.includes("落地页访问") &&
analyticsPage.includes("进入终端") &&
analyticsPage.includes("注册成功") &&
analyticsPage.includes("鉴权降级") &&
analyticsPage.includes("来源与设备") &&
analyticsPage.includes("paymentSuccess?.count") &&
!analyticsPage.includes("总注册") &&
!analyticsPage.includes("点击高级功能"),
"ops analytics page must show the real funnel semantics instead of stale index-based labels",
);
assert(
overviewPage.includes("stepByKey.payment_success?.count") &&
!overviewPage.includes("steps[5]?.count"),
"ops overview must derive paid conversion from payment_success by key, not from a brittle funnel index",
);
assert(
appAnalytics.includes("referrer: document.referrer") &&
appAnalytics.includes("device_type: getDeviceType()") &&
analyticsRoute.includes("cf-ipcountry") &&
analyticsRoute.includes("user_agent"),
"client analytics events must carry source, country, and device metadata for acquisition analysis",
);
assert(
authMeRoute.includes('event_type: "degraded_auth_profile"') &&
authMeRoute.includes("response_mode") &&
authMeRoute.includes("trackAuthDiagnosticEvent"),
"auth/me fallback paths must emit degraded_auth_profile diagnostics for ops monitoring",
);
}
@@ -0,0 +1,51 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const paymentsPage = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
const billingRiskRoute = fs.readFileSync(
path.join(projectRoot, "app", "api", "ops", "billing-risk", "route.ts"),
"utf8",
);
const paymentsRoute = fs.readFileSync(
path.join(projectRoot, "app", "api", "ops", "payments", "route.ts"),
"utf8",
);
assert(
opsApi.includes("billingRisk") &&
opsApi.includes("/api/ops/billing-risk") &&
opsApi.includes("/api/ops/payments"),
"ops client must expose billing risk and successful payments endpoints",
);
assert(
paymentsPage.includes("支付与邀请风控流水") &&
paymentsPage.includes("试用漏开") &&
paymentsPage.includes("Intent 卡住") &&
paymentsPage.includes("积分异常") &&
paymentsPage.includes("推荐奖励结算") &&
paymentsPage.includes("月度邀请封顶"),
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
);
assert(
billingRiskRoute.includes("requireOpsProxyAuth") &&
billingRiskRoute.includes("/api/ops/billing-risk") &&
billingRiskRoute.includes("no-store"),
"billing risk proxy must stay ops-admin protected and uncached",
);
assert(
paymentsRoute.includes("requireOpsProxyAuth") &&
paymentsRoute.includes("/api/ops/payments") &&
paymentsRoute.includes("no-store"),
"ops payments proxy must stay ops-admin protected and uncached",
);
}
@@ -0,0 +1,45 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const shell = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminShell.tsx"),
"utf8",
);
const sidebar = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const css = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminShell.module.css"),
"utf8",
);
assert(
shell.includes("AdminShell.module.css") &&
shell.includes("styles.root") &&
shell.includes("styles.main") &&
shell.includes("styles.content"),
"ops shell must use scoped terminal-style admin theme classes",
);
assert(
sidebar.includes("bg-white") &&
sidebar.includes("bg-blue-50") &&
sidebar.includes("shadow-[inset_3px_0_0_#2563eb]"),
"ops sidebar must use the light terminal navigation treatment with blue active state",
);
assert(
css.includes("#eef2f7") &&
css.includes("[class~=\"text-white\"]") &&
css.includes("[class*=\"bg-[#0f172a]\"]") &&
css.includes("[class~=\"rounded-3xl\"]") &&
css.includes("[class*=\"border-white/\"]") &&
css.includes(".recharts-cartesian-grid line"),
"ops scoped CSS must map legacy dark ops utility classes to the light terminal workbench palette",
);
}
@@ -0,0 +1,39 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const systemPage = fs.readFileSync(
path.join(projectRoot, "components", "ops", "system", "SystemPageClient.tsx"),
"utf8",
);
const nextRoute = fs.readFileSync(
path.join(projectRoot, "app", "api", "ops", "source-health", "route.ts"),
"utf8",
);
assert(
opsApi.includes("sourceHealth") &&
opsApi.includes("/api/ops/source-health"),
"ops client must expose the city source health endpoint",
);
assert(
systemPage.includes("城市数据源健康") &&
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",
);
assert(
nextRoute.includes("requireOpsProxyAuth") &&
nextRoute.includes("/api/ops/source-health") &&
nextRoute.includes("no-store"),
"source health proxy must stay ops-admin protected and uncached",
);
}
@@ -0,0 +1,44 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
const COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981", "#14b8a6"];
export function AnalyticsFunnelChart({
data,
}: {
data: { name: string; count: number; pct?: number }[];
}) {
return (
<ResponsiveContainer width="100%" height={data.length * 60 + 40}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#dbe7f3" />
<XAxis type="number" stroke="#cbd7e6" tick={{ fill: "#64748b", fontSize: 12 }} />
<YAxis type="category" dataKey="name" stroke="#cbd7e6" tick={{ fill: "#334155", fontSize: 13 }} width={130} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value) => [`${value}`, "数量"]}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,28 +1,40 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
type FunnelStep = { label: string; count: number; pct_of_prev?: number };
const AnalyticsFunnelChart = dynamic(
() => import("./AnalyticsFunnelChart").then((mod) => mod.AnalyticsFunnelChart),
{
ssr: false,
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
},
);
type FunnelStep = {
key: string;
label: string;
count: number;
uniqueActors: number;
pct_of_prev?: number;
};
type TopItem = { name: string; count: number };
type AuthDiagnostic = {
total?: number;
unique_actors?: number;
by_reason?: TopItem[];
};
export function AnalyticsPageClient() {
const [loading, setLoading] = useState(true);
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
const [rates, setRates] = useState<Record<string, number> | null>(null);
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
const [days, setDays] = useState(30);
const load = async () => {
@@ -30,7 +42,9 @@ export function AnalyticsPageClient() {
try {
const data = await opsApi.funnel(days);
setFunnel(data.steps);
setDiagnostics(data.diagnostics ?? {});
setRates(data.rates ?? null);
setTraffic(data.traffic ?? {});
} catch { /* */ }
setLoading(false);
};
@@ -43,7 +57,18 @@ export function AnalyticsPageClient() {
pct: s.pct_of_prev,
}));
const maxCount = Math.max(...funnel.map((s) => s.count), 1);
const stepByKey = Object.fromEntries(funnel.map((step) => [step.key, step]));
const degradedAuth = diagnostics.degraded_auth_profile ?? {};
const landingView = stepByKey.landing_view;
const terminalEntry = stepByKey.enter_terminal;
const signupSuccess = stepByKey.signup_success;
const trialCreated = stepByKey.trial_created;
const paymentStart = stepByKey.payment_start;
const paymentSuccess = stepByKey.payment_success;
const overallRate =
landingView?.uniqueActors && paymentSuccess?.uniqueActors
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
: "—";
if (loading) return <div className="text-slate-400 animate-pulse">...</div>;
@@ -68,72 +93,115 @@ export function AnalyticsPageClient() {
</div>
</div>
{/* Summary cards */}
{funnel.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-6">
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-white">{funnel[0]?.count ?? 0}</div>
<div className="text-xs text-slate-500">访</div>
<div className="text-xl font-bold text-white">{landingView?.count ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500"> {landingView?.uniqueActors ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-cyan-400">{funnel[2]?.count ?? 0}</div>
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-cyan-400">{terminalEntry?.count ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500"> {terminalEntry?.uniqueActors ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-blue-500">{signupSuccess?.count ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500"> {trialCreated?.count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-amber-400">{funnel[4]?.count ?? 0}</div>
<div className="text-xl font-bold text-amber-400">{paymentStart?.count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-emerald-400">{funnel[5]?.count ?? 0}</div>
<div className="text-xs text-slate-500 mt-0.5">
{maxCount > 0 ? `${((funnel[5]?.count ?? 0) / maxCount * 100).toFixed(1)}%` : "—"}
<div className="text-xl font-bold text-emerald-400">{paymentSuccess?.count ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500">
{overallRate === "—" ? "—" : `${overallRate}%`}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-xs text-slate-500"></div>
<div className="text-xl font-bold text-rose-500">{degradedAuth.total ?? 0}</div>
<div className="mt-0.5 text-xs text-slate-500"> {degradedAuth.unique_actors ?? 0}</div>
</CardContent>
</Card>
</div>
)}
{/* Funnel chart */}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-4">
{[
["来源", traffic.referrers ?? []],
["国家/地区", traffic.countries ?? []],
["设备", traffic.devices ?? []],
["落地页路径", traffic.landing_paths ?? []],
].map(([title, rows]) => (
<div key={String(title)} className="space-y-2">
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
{(rows as TopItem[]).length === 0 ? (
<div className="text-xs text-slate-500"></div>
) : (
<ul className="space-y-1.5">
{(rows as TopItem[]).slice(0, 5).map((item) => (
<li key={item.name} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
<span className="font-mono text-blue-600">{item.count}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
{(degradedAuth.by_reason ?? []).length === 0 ? (
<p className="text-sm text-slate-500"> degraded_auth_profile</p>
) : (
<ul className="space-y-2 text-sm">
{(degradedAuth.by_reason ?? []).map((item) => (
<li key={item.name} className="flex items-center gap-3">
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
<span className="font-mono text-rose-500">{item.count}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader><CardTitle> (Recharts)</CardTitle></CardHeader>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
{chartData.length === 0 ? (
<p className="text-slate-500 text-sm py-8 text-center"></p>
<p className="py-8 text-center text-sm text-slate-500"></p>
) : (
<ResponsiveContainer width="100%" height={chartData.length * 60 + 40}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis type="number" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#94a3b8", fontSize: 12 }} />
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#e2e8f0", fontSize: 13 }} width={130} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value) => [`${value}`, "数量"]}
/>
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
{chartData.map((_, i) => {
const colors = ["#06b6d4", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981"];
return <Cell key={i} fill={colors[i % colors.length]} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
<AnalyticsFunnelChart data={chartData} />
)}
</CardContent>
</Card>
{/* Drop-off table */}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent>
@@ -141,28 +209,35 @@ export function AnalyticsPageClient() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium text-right"></th>
<th className="py-2 px-3 font-medium text-right"></th>
<th className="py-2 px-3 font-medium text-right"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium">/访</th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{funnel.map((step, i) => {
const pct = step.pct_of_prev;
const dropPct = pct != null ? 100 - pct : 0;
const dropPct = pct != null ? Math.max(0, 100 - pct) : 0;
return (
<tr key={i} className="border-b border-white/5">
<td className="py-2 px-3 text-white">{step.label}</td>
<td className="py-2 px-3 text-right font-mono text-white">{step.count}</td>
<td className="py-2 px-3 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
<td className="py-2 px-3 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
<tr key={step.key} className="border-b border-white/5">
<td className="px-3 py-2 text-white">{step.label}</td>
<td className="px-3 py-2 text-right font-mono text-white">{step.count}</td>
<td className="px-3 py-2 text-right font-mono text-white">{step.uniqueActors}</td>
<td className="px-3 py-2 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
<td className="px-3 py-2 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{rates ? (
<p className="mt-3 text-xs text-slate-500">
rates unique actors
</p>
) : null}
</CardContent>
</Card>
</div>
@@ -0,0 +1,35 @@
"use client";
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
const COLORS = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"];
export function HealthLatencyChart({
data,
}: {
data: { name: string; latency: number }[];
}) {
return (
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 28 + 40)}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value) => [`${value} ms`, "延迟"]}
/>
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
@@ -1,14 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Cell,
} from "recharts";
const HealthLatencyChart = dynamic(
() => import("./HealthLatencyChart").then((mod) => mod.HealthLatencyChart),
{
ssr: false,
loading: () => <div className="h-[220px] animate-pulse rounded-lg bg-slate-100" />,
},
);
type ServiceResult = {
ok: boolean;
@@ -107,27 +111,7 @@ export function HealthPageClient() {
<Card className="border-slate-800 bg-slate-900/50">
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-white mb-4"> (ms)</h3>
<ResponsiveContainer width="100%" height={Math.max(160, latencyData.length * 28 + 40)}>
<BarChart
data={latencyData}
layout="vertical"
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value) => [`${value} ms`, "延迟"]}
/>
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
{latencyData.map((d, i) => {
const colors = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"];
return <Cell key={i} fill={colors[i % colors.length]} />;
})}
</Bar>
</BarChart>
</ResponsiveContainer>
<HealthLatencyChart data={latencyData} />
</CardContent>
</Card>
)}
@@ -0,0 +1,182 @@
.root {
min-height: 100vh;
background:
radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.08), transparent 28%),
linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
color: var(--color-text-primary);
}
.main {
min-height: 100vh;
margin-left: 14rem;
padding: 18px;
}
.content {
color: var(--color-text-primary);
}
.content :global(h1) {
color: var(--color-text-primary);
letter-spacing: 0;
}
.content :global([class~="text-white"]),
.content :global([class~="text-slate-100"]),
.content :global([class~="text-slate-200"]),
.content :global([class~="text-slate-300"]) {
color: var(--color-text-primary);
}
.content :global([class~="text-slate-400"]) {
color: #52637a;
}
.content :global([class~="text-slate-500"]),
.content :global([class~="text-slate-600"]) {
color: #6b7a91;
}
.content :global([class*="border-white/"]),
.content :global([class*="divide-white/"]),
.content :global([class*="border-black/"]) {
border-color: var(--color-border-default);
}
.content :global([class*="border-slate-7"]),
.content :global([class*="border-slate-8"]) {
border-color: var(--color-border-default);
}
.content :global([class*="bg-card"]),
.content :global([class*="bg-slate-900"]),
.content :global([class*="bg-slate-950"]),
.content :global([class*="bg-[#0f172a]"]),
.content :global([class*="bg-black/"]),
.content :global([class*="bg-white/5"]),
.content :global([class*="bg-white/10"]) {
background: #ffffff;
}
.content :global([class~="rounded-2xl"]),
.content :global([class~="rounded-xl"]),
.content :global([class~="rounded-3xl"]) {
border-radius: 8px;
}
.content :global([class*="shadow-xl"]),
.content :global([class*="shadow-2xl"]) {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 12px 28px rgba(15, 23, 42, 0.05);
}
.content :global([class*="backdrop-blur"]) {
backdrop-filter: none;
}
.content :global([class*="hover:bg-white/"]:hover) {
background: #f8fafc;
}
.content :global([class*="hover:bg-slate-"]:hover),
.content :global([class*="hover:bg-black/"]:hover) {
background: #f1f5f9;
}
.content :global([class*="hover:text-white"]:hover),
.content :global([class*="hover:text-slate-2"]:hover),
.content :global([class*="hover:text-slate-3"]:hover) {
color: var(--color-text-primary);
}
.content :global(code),
.content :global(pre),
.content :global(.font-mono) {
color: #1d4ed8;
}
.content :global(input),
.content :global(select),
.content :global(textarea) {
border-color: var(--color-border-default);
background: #ffffff;
color: var(--color-text-primary);
}
.content :global(input::placeholder),
.content :global(textarea::placeholder) {
color: #8ba0b7;
}
.content :global(input:focus),
.content :global(select:focus),
.content :global(textarea:focus) {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.content :global(button[class*="border"]) {
border-color: #cbd7e6;
}
.content :global(button[class*="bg-transparent"]),
.content :global(button[class*="outline"]) {
background: #ffffff;
}
.content :global(table) {
color: #1e293b;
border-collapse: separate;
border-spacing: 0;
}
.content :global(thead),
.content :global(thead tr),
.content :global([class*="bg-slate-800"]) {
background: #f1f5f9;
}
.content :global(th) {
color: #52637a;
font-weight: 800;
letter-spacing: 0.02em;
}
.content :global(td) {
color: #1e293b;
}
.content :global(tbody tr) {
border-color: #e5edf6;
}
.content :global(tbody tr:hover) {
background: #f8fbff;
}
.content :global(.recharts-cartesian-grid line) {
stroke: #dbe7f3;
}
.content :global(.recharts-cartesian-axis-line),
.content :global(.recharts-cartesian-axis-tick-line) {
stroke: #cbd7e6;
}
.content :global(.recharts-cartesian-axis-tick-value) {
fill: #64748b;
}
.content :global(.recharts-legend-item-text) {
color: #475569 !important;
}
.content :global([class*="animate-pulse"]) {
color: #64748b;
}
@media (max-width: 900px) {
.main {
margin-left: 0;
padding: 12px;
}
}
@@ -1,11 +1,12 @@
import { AdminSidebar } from "./AdminSidebar";
import styles from "./AdminShell.module.css";
export function AdminShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<div className={styles.root}>
<AdminSidebar />
<main className="ml-56 min-h-screen p-6">
{children}
<main className={styles.main}>
<div className={styles.content}>{children}</div>
</main>
</div>
);
+11 -11
View File
@@ -57,17 +57,17 @@ export function AdminSidebar() {
const pathname = usePathname();
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-56 border-r border-white/10 bg-slate-950 flex flex-col">
<div className="flex h-14 items-center gap-2 border-b border-white/10 px-4">
<LayoutDashboard className="h-5 w-5 text-cyan-400" />
<Link href="/ops/system" className="text-sm font-bold text-white">
<aside className="fixed left-0 top-0 z-40 flex h-screen w-56 flex-col border-r border-slate-200 bg-white shadow-[1px_0_2px_rgba(15,23,42,0.04)]">
<div className="flex h-14 items-center gap-2 border-b border-slate-200 px-4">
<LayoutDashboard className="h-5 w-5 text-blue-600" />
<Link href="/ops/system" className="text-sm font-extrabold text-slate-950">
PolyWeather Ops
</Link>
</div>
<nav className="flex-1 overflow-y-auto py-4 space-y-6">
<nav className="flex-1 space-y-6 overflow-y-auto py-4">
{navGroups.map((group) => (
<div key={group.label} className="px-3">
<h4 className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500">
<h4 className="mb-2 px-2 text-[11px] font-bold uppercase tracking-wider text-slate-500">
{group.label}
</h4>
<ul className="space-y-0.5">
@@ -78,10 +78,10 @@ export function AdminSidebar() {
<Link
href={item.href}
className={
"flex items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors " +
"flex items-center gap-2.5 rounded-md border border-transparent px-2 py-2 text-sm transition-colors " +
(isActive
? "bg-white/10 text-white font-medium"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200")
? "border-blue-200 bg-blue-50 text-blue-700 font-semibold shadow-[inset_3px_0_0_#2563eb]"
: "text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950")
}
>
<item.icon className="h-4 w-4 shrink-0" />
@@ -94,10 +94,10 @@ export function AdminSidebar() {
</div>
))}
</nav>
<div className="border-t border-white/10 px-4 py-3">
<div className="border-t border-slate-200 px-4 py-3">
<Link
href="/"
className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors"
className="flex items-center gap-2 text-xs font-semibold text-slate-500 transition-colors hover:text-blue-700"
>
</Link>
@@ -0,0 +1,44 @@
"use client";
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
export function MembershipGrowthChart({
growth,
}: {
growth: GrowthPoint[];
}) {
return (
<>
<div className="mb-6">
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
</AreaChart>
</ResponsiveContainer>
</div>
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
</AreaChart>
</ResponsiveContainer>
</div>
</>
);
}
@@ -1,16 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, X, Search } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { MembershipEntry } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, Legend, Area, AreaChart,
} from "recharts";
const MembershipGrowthChart = dynamic(
() => import("./MembershipGrowthChart").then((mod) => mod.MembershipGrowthChart),
{
ssr: false,
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
},
);
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
@@ -160,35 +164,7 @@ export function MembershipsPageClient() {
</div>
</div>
{/* Cumulative area chart */}
<div className="mb-6">
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
</AreaChart>
</ResponsiveContainer>
</div>
{/* Daily bars */}
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
</AreaChart>
</ResponsiveContainer>
</div>
<MembershipGrowthChart growth={growth} />
</CardContent>
</Card>
)}
@@ -0,0 +1,226 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
type GrowthRow = { date: string; trial: number; paid: number; total: number; cumulative: number };
type StepRow = { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number };
type BucketRow = { name: string; value: number };
type PieRow = { name: string; value: number; color?: string };
type CacheAnalysis = {
total_requests?: number;
cache_hits?: number;
cache_misses?: number;
force_refresh_requests?: number;
hit_rate?: number | null;
};
export function OverviewCharts({
growth,
steps,
cacheBuckets,
memberPie,
planBreakdown,
cachePie,
cacheAnalysis,
}: {
growth: GrowthRow[];
steps: StepRow[];
cacheBuckets: BucketRow[];
memberPie: PieRow[];
planBreakdown: PieRow[];
cachePie: PieRow[];
cacheAnalysis?: CacheAnalysis;
}) {
return (
<div className="space-y-5">
{growth.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> 30 </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<defs>
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
</AreaChart>
</ResponsiveContainer>
</div>
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<div className="space-y-5">
{steps.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">30</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{cacheBuckets.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
</div>
<div className="space-y-5">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-36 h-36 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
{memberPie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1.5 text-sm">
{memberPie.map((item) => (
<div key={item.name} className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-slate-400">{item.name}</span>
<span className="text-white font-bold ml-auto">{item.value}</span>
</div>
))}
{planBreakdown.length > 0 && (
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
{planBreakdown.map((item) => (
<div key={item.name} className="flex justify-between text-xs">
<span className="text-slate-500">{item.name}</span>
<span className="text-slate-300">{item.value}</span>
</div>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
{cachePie.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{" "}
{cacheAnalysis?.hit_rate != null && (
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% </span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-36 h-36 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
{cachePie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
@@ -1,19 +1,22 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity, Cpu } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { opsApi } from "@/lib/ops-api";
import dynamic from "next/dynamic";
import { Activity, Cpu, CreditCard, Database, RefreshCcw, TrendingUp, Users } from "lucide-react";
import Link from "next/link";
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area, Legend,
} from "recharts";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { opsApi } from "@/lib/ops-api";
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
const OverviewCharts = dynamic(
() => import("./OverviewCharts").then((mod) => mod.OverviewCharts),
{
ssr: false,
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
},
);
function KpiCard({ href, icon: Icon, label, value, color, sub }: {
href: string; icon: React.ElementType; label: string; value: string | number; color: string; sub?: string;
@@ -38,7 +41,7 @@ export function OverviewPageClient() {
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
const [funnel, setFunnel] = useState<{ steps: { label: string; count: number; pct_of_prev?: number }[] } | null>(null);
const [funnel, setFunnel] = useState<{ steps: { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number }[] } | null>(null);
const [growth, setGrowth] = useState<{ date: string; trial: number; paid: number; total: number; cumulative: number }[]>([]);
const load = async () => {
@@ -70,19 +73,20 @@ export function OverviewPageClient() {
</div>
);
// ── Derived data ──
const paid = memberships.filter((m) => !m.is_trial).length;
const trials = memberships.filter((m) => m.is_trial).length;
const steps = funnel?.steps ?? [];
const totalUsers = steps[0]?.count ?? 0;
const payingUsers = steps[5]?.count ?? 0;
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
const payingUsers = stepByKey.payment_success?.count ?? 0;
const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
const cache = status?.cache;
const cacheAnalysis = cache?.analysis;
const td = status?.training_data;
const features = status?.features;
const coverage = td?.city_coverage;
const truthRows = td?.truth_records?.row_count ?? 0;
// Cache bucket data for bar chart
const cacheBuckets = cache ? [
{ name: "API", value: cache.api_cache_entries ?? 0 },
{ name: "预报", value: cache.open_meteo_forecast_entries ?? 0 },
@@ -91,20 +95,17 @@ export function OverviewPageClient() {
{ name: "结算", value: cache.settlement_entries ?? 0 },
].filter((d) => d.value > 0) : [];
// Cache pie
const cachePie = cacheAnalysis ? [
{ name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" },
{ name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" },
{ name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" },
] : [];
// Membership pie
const memberPie = [
{ name: "付费", value: paid, color: "#22c55e" },
...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []),
];
// Plan breakdown
const planCounts: Record<string, number> = {};
memberships.forEach((m) => {
const code = m.plan_code ?? "unknown";
@@ -117,9 +118,6 @@ export function OverviewPageClient() {
})
.sort((a, b) => b.value - a.value);
const truthRows = td?.truth_records?.row_count ?? 0;
const coverage = td?.city_coverage;
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
@@ -132,7 +130,6 @@ export function OverviewPageClient() {
</Button>
</div>
{/* KPI row */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<KpiCard href="/ops/system" icon={Activity} label="系统" value={status?.db?.ok ? "OK" : "FAIL"} color={status?.db?.ok ? "text-emerald-400" : "text-red-400"} />
<KpiCard href="/ops/memberships" icon={Users} label="付费会员" value={paid} color="text-cyan-400" sub={trials > 0 ? `+${trials} 体验` : undefined} />
@@ -142,237 +139,61 @@ export function OverviewPageClient() {
<KpiCard href="/ops/system" icon={Cpu} label="概率引擎" value={status?.probability?.engine_mode ?? "—"} color="text-amber-400" />
</div>
{/* Membership Growth Trend */}
{growth.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"> 30 </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Cumulative area chart */}
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<defs>
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
</AreaChart>
</ResponsiveContainer>
</div>
<OverviewCharts
growth={growth}
steps={steps}
cacheBuckets={cacheBuckets}
memberPie={memberPie}
planBreakdown={planBreakdown}
cachePie={cachePie}
cacheAnalysis={cacheAnalysis}
/>
{/* Daily stack chart */}
<div>
<h4 className="text-xs text-slate-500 mb-2"></h4>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={growth}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
)}
{/* Main grid: 2 columns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Column 1 */}
<div className="space-y-5">
{/* Funnel mini chart */}
{steps.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">30</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Cache buckets */}
{cacheBuckets.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Features */}
{features && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-1.5">
{Object.entries(features).map(([k, v]) => (
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
{k.replace(/_/g, " ")}: {String(v)}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Column 2 */}
<div className="space-y-5">
{/* Membership donut + plan breakdown side by side */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
{features && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-36 h-36 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-1.5">
{Object.entries(features).map(([k, v]) => (
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
{k.replace(/_/g, " ")}: {String(v)}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{coverage && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="flex-1 space-y-1.5 text-sm">
{memberPie.map((d) => (
<div key={d.name} className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
<span className="text-slate-400">{d.name}</span>
<span className="text-white font-bold ml-auto">{d.value}</span>
</div>
))}
{planBreakdown.length > 0 && (
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
{planBreakdown.map((p) => (
<div key={p.name} className="flex justify-between text-xs">
<span className="text-slate-500">{p.name}</span>
<span className="text-slate-300">{p.value}</span>
</div>
))}
</div>
)}
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
</div>
</CardContent>
</Card>
{/* Cache hit rate donut */}
{cachePie.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{" "}
{cacheAnalysis?.hit_rate != null && (
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% </span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<div className="w-36 h-36 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
{cachePie.map((d, i) => (<Cell key={i} fill={d.color} />))}
</Pie>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
</div>
<div>
<div className="text-slate-500 text-xs"></div>
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Training data summary */}
{coverage && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
<div className="text-[11px] text-slate-500"></div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)}
</div>
</div>
);
@@ -0,0 +1,49 @@
"use client";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
export function PaymentIncidentPieChart({
data,
}: {
data: { name: string; value: number }[];
}) {
return (
<div className="w-full h-full flex items-center gap-2">
<div className="w-[100px] h-[100px] shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
innerRadius={24}
outerRadius={40}
paddingAngle={2}
dataKey="value"
>
{data.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
{data.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 truncate">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: COLORS[i % COLORS.length] }}
/>
<span className="text-slate-400 truncate" title={item.name}>{item.name}</span>
<span className="text-white font-bold ml-auto">{item.value}</span>
</div>
))}
</div>
</div>
);
}
@@ -1,34 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw, CheckCircle2, ExternalLink } from "lucide-react";
import dynamic from "next/dynamic";
import { RefreshCcw, CheckCircle2, ExternalLink, AlertTriangle, ShieldCheck } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
} from "recharts";
import type {
BillingRiskIssue,
BillingRiskPayload,
PaymentRuntimePayload,
PaymentIncident,
PaymentRecord,
} from "@/types/ops";
const PaymentIncidentPieChart = dynamic(
() => import("./PaymentIncidentPieChart").then((mod) => mod.PaymentIncidentPieChart),
{
ssr: false,
loading: () => <span className="text-sm text-slate-500">...</span>,
},
);
function paymentExplorerUrl(payment: PaymentRecord): string {
const txHash = String(payment.tx_hash || "").trim();
if (!txHash) return "";
const chain = String(payment.chain || "").trim().toLowerCase();
const base = chain.includes("eth")
? "https://etherscan.io"
: "https://polygonscan.com";
return `${base}/tx/${txHash}`;
}
function severityTone(severity?: string) {
if (severity === "high") return "border-red-200 bg-red-50 text-red-700";
if (severity === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
return "border-slate-200 bg-slate-50 text-slate-600";
}
function severityLabel(severity?: string) {
if (severity === "high") return "高";
if (severity === "medium") return "中";
if (severity === "low") return "低";
return severity || "未知";
}
function compactDate(value?: string) {
if (!value) return "—";
return value.slice(0, 19).replace("T", " ");
}
function RiskStat({
label,
value,
sub,
tone = "text-slate-950",
}: {
label: string;
value: number;
sub: string;
tone?: string;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
<div className={`mt-1 text-2xl font-black ${tone}`}>{value}</div>
<div className="mt-1 text-xs text-slate-500">{sub}</div>
</div>
);
}
export function PaymentsPageClient() {
const [loading, setLoading] = useState(true);
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
const [payments, setPayments] = useState<PaymentRecord[]>([]);
const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
const [resolving, setResolving] = useState<Set<number>>(new Set());
const load = async () => {
setLoading(true);
try {
const [rt, inc, pay] = await Promise.all([
const [rt, inc, pay, riskPayload] = await Promise.all([
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
opsApi.incidents(50),
opsApi.listPayments(50),
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
]);
setRuntime(rt);
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
setRisk(riskPayload);
} catch { /* */ }
setLoading(false);
};
@@ -60,18 +122,10 @@ export function PaymentsPageClient() {
name,
value,
}));
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
function paymentExplorerUrl(payment: PaymentRecord): string {
const txHash = String(payment.tx_hash || "").trim();
if (!txHash) return "";
const chain = String(payment.chain || "").trim().toLowerCase();
const base = chain.includes("eth")
? "https://etherscan.io"
: "https://polygonscan.com";
return `${base}/tx/${txHash}`;
}
const riskSummary = risk?.summary ?? {};
const riskIssues = risk?.issues ?? [];
const referralRewards = risk?.recent_referral_rewards ?? [];
const hasRisk = Number(riskSummary.issues ?? 0) > 0;
return (
<div className="space-y-6">
@@ -82,6 +136,73 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
</Button>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
{hasRisk ? <AlertTriangle className="h-4 w-4 text-amber-500" /> : <ShieldCheck className="h-4 w-4 text-emerald-500" />}
</CardTitle>
<span className="text-xs text-slate-500"> {risk?.window_days ?? 30} · {compactDate(risk?.checked_at)}</span>
</CardHeader>
<CardContent className="space-y-4">
<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.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="转化无奖励记录" />
<RiskStat label="上限命中" value={Number(riskSummary.monthly_cap_hits ?? 0)} sub="月度邀请封顶" />
</div>
{risk?.query_errors?.length ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Supabase {risk.query_errors.map((item) => item.table).filter(Boolean).join(", ")}
</div>
) : null}
{riskIssues.length === 0 ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-slate-500">
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
</tr>
</thead>
<tbody>
{riskIssues.slice(0, 30).map((issue: BillingRiskIssue, index) => (
<tr key={`${issue.category}-${issue.reference}-${index}`} className="border-b border-slate-100">
<td className="py-2 pr-4">
<span className={`inline-flex min-w-8 items-center justify-center rounded-md border px-2 py-1 text-xs font-bold ${severityTone(issue.severity)}`}>
{severityLabel(issue.severity)}
</span>
</td>
<td className="py-2 pr-4 text-slate-500">{issue.category ?? "—"}</td>
<td className="py-2 pr-4">
<div className="font-bold text-slate-900">{issue.title ?? "—"}</div>
<div className="mt-0.5 max-w-xl text-xs text-slate-500">{issue.detail ?? "—"}</div>
</td>
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{issue.user_id ? `${issue.user_id.slice(0, 10)}...` : "—"}</td>
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(issue.created_at)}</td>
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{issue.reference ? String(issue.reference).slice(0, 14) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2">
<CardHeader><CardTitle></CardTitle></CardHeader>
@@ -113,42 +234,48 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
{incidentPieData.length === 0 ? (
<span className="text-sm text-slate-500"></span>
) : (
<div className="w-full h-full flex items-center gap-2">
<div className="w-[100px] h-[100px] shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={incidentPieData}
cx="50%"
cy="50%"
innerRadius={24}
outerRadius={40}
paddingAngle={2}
dataKey="value"
>
{incidentPieData.map((d, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
{incidentPieData.map((d, i) => (
<div key={d.name} className="flex items-center gap-1.5 truncate">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
<span className="text-slate-400 truncate" title={d.name}>{d.name}</span>
<span className="text-white font-bold ml-auto">{d.value}</span>
</div>
))}
</div>
</div>
<PaymentIncidentPieChart data={incidentPieData} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader><CardTitle> ({referralRewards.length})</CardTitle></CardHeader>
<CardContent>
{referralRewards.length === 0 ? (
<span className="text-sm text-slate-500"></span>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-slate-500">
<th className="py-2 pr-4 font-bold">ID</th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold">Intent</th>
<th className="py-2 pr-4 font-bold"></th>
</tr>
</thead>
<tbody>
{referralRewards.slice(0, 20).map((row, index) => (
<tr key={`${row.id}-${index}`} className="border-b border-slate-100">
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.id ?? "—")}</td>
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referrer_user_id ?? "—").slice(0, 10)}...</td>
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referred_user_id ?? "—").slice(0, 10)}...</td>
<td className="py-2 pr-4 text-emerald-700 font-bold">+{Number(row.reward_points ?? 0).toLocaleString()} </td>
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{String(row.payment_intent_id ?? "—").slice(0, 14)}</td>
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(String(row.created_at ?? ""))}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle> ({incidents.length})</CardTitle></CardHeader>
<CardContent>
@@ -1,29 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw, ShieldCheck, Database, Cpu, HardDrive } from "lucide-react";
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { SystemStatusPayload, HealthPayload } from "@/types/ops";
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
function sourceStatusTone(status?: string) {
if (status === "fresh") return "text-emerald-500";
if (status === "expected_wait") return "text-blue-500";
if (status === "delayed") return "text-amber-500";
if (status === "stale" || status === "missing") return "text-red-500";
return "text-slate-500";
}
function sourceStatusLabel(status?: string) {
if (status === "fresh") return "正常";
if (status === "expected_wait") return "等待更新";
if (status === "delayed") return "延迟";
if (status === "stale") return "断线";
if (status === "missing") return "缺失";
return "未知";
}
function formatAge(ageMin?: number | null) {
if (ageMin == null) return "—";
if (ageMin < 60) return `${Math.round(ageMin)}m`;
return `${(ageMin / 60).toFixed(1)}h`;
}
export function SystemPageClient() {
const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
const [error, setError] = useState("");
const load = async () => {
setLoading(true);
setError("");
try {
const [h, s] = await Promise.all([
const [h, s, sh] = await Promise.all([
opsApi.health(),
opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
]);
setHealth(h);
setStatus(s);
setSourceHealth(sh);
} catch (e) {
setError(String(e).slice(0, 200));
} finally {
@@ -45,6 +71,13 @@ export function SystemPageClient() {
const dbOk = status?.db?.ok ?? health?.db?.ok;
const cacheAnalysis = status?.cache?.analysis;
const sourceIssues = (sourceHealth?.cities || [])
.flatMap((city) =>
(city.sources || [])
.filter((source) => ["delayed", "stale", "missing", "unknown"].includes(String(source.status || "")))
.map((source) => ({ city: city.city, source })),
)
.slice(0, 10);
return (
<div className="space-y-6">
@@ -165,6 +198,73 @@ export function SystemPageClient() {
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RadioTower className="h-4 w-4 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
{["fresh", "expected_wait", "delayed", "stale", "missing"].map((key) => (
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<div className="text-[11px] text-slate-500">{sourceStatusLabel(key)}</div>
<div className={`text-lg font-black ${sourceStatusTone(key)}`}>
{sourceHealth?.status_counts?.[key] ?? 0}
</div>
</div>
))}
</div>
{sourceIssues.length ? (
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[760px] text-left text-xs">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{sourceIssues.map(({ city, source }, index) => (
<tr key={`${city}-${source.role}-${source.source_code}-${index}`} className="border-t border-slate-100">
<td className="px-3 py-2 font-mono font-bold text-slate-800">{city}</td>
<td className="px-3 py-2">
<div className="font-semibold text-slate-800">{source.source_label || source.source_code}</div>
<div className="text-[11px] text-slate-500">{source.role}</div>
</td>
<td className={`px-3 py-2 font-bold ${sourceStatusTone(source.status)}`}>
{sourceStatusLabel(source.status)}
</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>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
<ShieldCheck className="h-4 w-4" />
MGMKNMIIMS 线/
</div>
)}
{(sourceHealth?.cities || []).some((city) => !city.cache_exists) ? (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
<AlertTriangle className="h-4 w-4" />
full/panel
</div>
) : null}
</CardContent>
</Card>
{/* DB Path */}
{status?.db?.db_path ? (
<Card>
@@ -0,0 +1,177 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ComposedChart,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
type DebChartRow = {
name: string;
cityId: string;
hitRate: number;
mae: number;
days: number;
};
type MuChartRow = {
name: string;
cityId: string;
brierScore: number;
hitRate: number;
mae: number;
days: number;
};
const CHART_COLORS = {
green: "#22c55e",
yellow: "#eab308",
red: "#ef4444",
};
function hitColor(hitRate: number) {
if (hitRate >= 80) return CHART_COLORS.green;
if (hitRate >= 60) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
function maeColor(mae: number) {
if (mae <= 1.5) return CHART_COLORS.green;
if (mae <= 2.5) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
function brierColor(score: number) {
if (score <= 0.1) return CHART_COLORS.green;
if (score <= 0.25) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
export function TrainingAccuracyCharts({
debChartData,
muChartData,
}: {
debChartData: DebChartRow[];
muChartData: MuChartRow[];
}) {
return (
<>
{debChartData.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>DEB by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
/>
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
{debChartData.map((entry, i) => (
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
))}
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>DEB MAE by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
/>
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
{debChartData.map((entry, i) => (
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
))}
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
) : null}
{muChartData.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle> μ Brier Score by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
/>
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
{muChartData.map((entry, i) => (
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
))}
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle> μ by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
/>
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
{muChartData.map((entry, i) => (
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
))}
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
) : null}
</>
);
}
@@ -1,18 +1,22 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api";
import type { SystemStatusPayload } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import Link from "next/link";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
ComposedChart, Line, Legend, Cell, LabelList,
} from "recharts";
const TrainingAccuracyCharts = dynamic(
() => import("./TrainingAccuracyCharts").then((mod) => mod.TrainingAccuracyCharts),
{
ssr: false,
loading: () => <div className="h-[400px] animate-pulse rounded-lg bg-slate-100" />,
},
);
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -61,37 +65,6 @@ interface CityAccuracy {
} | null;
}
const CHART_COLORS = {
green: "#22c55e",
yellow: "#eab308",
red: "#ef4444",
blue: "#3b82f6",
cyan: "#06b6d4",
purple: "#a855f7",
slate: "#64748b",
emerald: "#10b981",
amber: "#f59e0b",
rose: "#f43f5e",
};
function hitColor(hitRate: number) {
if (hitRate >= 80) return CHART_COLORS.green;
if (hitRate >= 60) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
function maeColor(mae: number) {
if (mae <= 1.5) return CHART_COLORS.green;
if (mae <= 2.5) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
function brierColor(score: number) {
if (score <= 0.1) return CHART_COLORS.green;
if (score <= 0.25) return CHART_COLORS.yellow;
return CHART_COLORS.red;
}
export function TrainingPageClient() {
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
@@ -221,115 +194,7 @@ export function TrainingPageClient() {
</div>
) : null}
{/* DEB Accuracy Charts */}
{debChartData.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle>DEB by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
/>
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
{debChartData.map((entry, i) => (
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
))}
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>DEB MAE by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
/>
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
{debChartData.map((entry, i) => (
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
))}
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
</Bar>
</ComposedChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
) : null}
{/* Mu Probability Charts */}
{muChartData.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle> μ Brier Score by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
/>
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
{muChartData.map((entry, i) => (
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
))}
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle> μ by </CardTitle></CardHeader>
<CardContent>
<div className="h-[400px]">
<ResponsiveContainer>
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
<Tooltip
contentStyle={CHART_TOOLTIP_STYLE}
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
/>
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
{muChartData.map((entry, i) => (
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
))}
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
) : null}
<TrainingAccuracyCharts debChartData={debChartData} muChartData={muChartData} />
{/* City coverage */}
{modelCities ? (