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
+17 -1
View File
@@ -26,6 +26,22 @@ export async function POST(req: NextRequest) {
try { try {
const body = await req.json(); const body = await req.json();
const payload =
body && typeof body.payload === "object" && body.payload != null
? body.payload
: {};
const enrichedBody = {
...(body ?? {}),
payload: {
...payload,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
},
};
const auth = await buildBackendRequestHeaders(req, { const auth = await buildBackendRequestHeaders(req, {
includeSupabaseIdentity: false, includeSupabaseIdentity: false,
}); });
@@ -34,7 +50,7 @@ export async function POST(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/analytics/events`, { const res = await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(body ?? {}), body: JSON.stringify(enrichedBody),
cache: "no-store", cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {
+72 -2
View File
@@ -27,6 +27,56 @@ import {
const API_BASE = process.env.POLYWEATHER_API_BASE_URL; const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
async function trackAuthDiagnosticEvent(
req: NextRequest,
{
email,
reason,
responseMode,
userId,
}: {
email: string | null;
reason: string;
responseMode: "snapshot" | "degraded" | "anonymous";
userId?: string | null;
},
) {
if (!API_BASE) return;
const normalizedUserId = String(userId || "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1200);
try {
await fetch(`${API_BASE}/api/analytics/events`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event_type: "degraded_auth_profile",
client_id: normalizedUserId ? `auth:${normalizedUserId}` : undefined,
payload: {
route: "/api/auth/me",
reason: String(reason || "unknown").slice(0, 240),
response_mode: responseMode,
user_id: normalizedUserId || undefined,
email_domain: email?.includes("@") ? email.split("@").pop() : undefined,
cf_country:
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
"",
user_agent: req.headers.get("user-agent") || "",
referer_header: req.headers.get("referer") || "",
captured_at: new Date().toISOString(),
},
}),
cache: "no-store",
signal: controller.signal,
});
} catch {
// Diagnostics must never block auth/profile fallback responses.
} finally {
clearTimeout(timeoutId);
}
}
type VerifiedBearerIdentity = { type VerifiedBearerIdentity = {
email: string | null; email: string | null;
userId: string; userId: string;
@@ -77,7 +127,7 @@ async function getVerifiedBearerIdentity(
} }
} }
function degradedAuthProfileResponse({ async function degradedAuthProfileResponse({
email, email,
reason, reason,
req, req,
@@ -94,6 +144,12 @@ function degradedAuthProfileResponse({
readEntitlementSnapshot(req, userId), readEntitlementSnapshot(req, userId),
); );
if (snapshotPayload) { if (snapshotPayload) {
await trackAuthDiagnosticEvent(req, {
email: snapshotPayload.email || email,
reason,
responseMode: "snapshot",
userId,
});
const snapshotResponse = NextResponse.json({ const snapshotResponse = NextResponse.json({
...snapshotPayload, ...snapshotPayload,
email: snapshotPayload.email || email, email: snapshotPayload.email || email,
@@ -102,6 +158,12 @@ function degradedAuthProfileResponse({
return applyAuthResponseCookies(snapshotResponse, response); return applyAuthResponseCookies(snapshotResponse, response);
} }
await trackAuthDiagnosticEvent(req, {
email,
reason,
responseMode: "degraded",
userId,
});
const degraded = NextResponse.json({ const degraded = NextResponse.json({
authenticated: true, authenticated: true,
user_id: userId, user_id: userId,
@@ -135,13 +197,20 @@ function subscriptionRequiredAuthProfileResponse({
return applyAuthResponseCookies(inactive, response); return applyAuthResponseCookies(inactive, response);
} }
function unauthenticatedAuthProfileResponse({ async function unauthenticatedAuthProfileResponse({
reason, reason,
req,
response, response,
}: { }: {
reason: string; reason: string;
req: NextRequest;
response: NextResponse | null; response: NextResponse | null;
}) { }) {
await trackAuthDiagnosticEvent(req, {
email: null,
reason,
responseMode: "anonymous",
});
const anonymous = NextResponse.json( const anonymous = NextResponse.json(
{ {
authenticated: false, authenticated: false,
@@ -409,6 +478,7 @@ export async function GET(req: NextRequest) {
} }
return unauthenticatedAuthProfileResponse({ return unauthenticatedAuthProfileResponse({
reason: String(error), reason: String(error),
req,
response: auth?.response || null, response: auth?.response || null,
}); });
} }
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/billing-risk`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Billing risk check failed",
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/payments`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops payments",
});
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const authError = requireOpsProxyAuth(req, auth);
if (authError) return authError;
const upstream = new URL(`${API_BASE}/api/ops/source-health`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json",
},
status: res.status,
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Source health check failed",
});
}
}
@@ -105,6 +105,18 @@ function getWundergroundDailyHigh(hourly: HourlyForecast) {
return validNumber(hourly?.wundergroundCurrent?.max_so_far) ?? null; 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 ───────────────────────────────────────────────────── // ── Main component ─────────────────────────────────────────────────────
export function LiveTemperatureThresholdChart({ export function LiveTemperatureThresholdChart({
@@ -141,11 +153,18 @@ export function LiveTemperatureThresholdChart({
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({}); const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
const [liveTemp, setLiveTemp] = useState<number | null>(null); const [liveTemp, setLiveTemp] = useState<number | null>(null);
const [isHourlyLoading, setIsHourlyLoading] = useState(false); 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 hasLoadedHourlyDetailRef = useRef(false);
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
const lastPatchAtRef = useRef<number>(Date.now()); const lastPatchAtRef = useRef<number>(Date.now());
const lastAppliedPatchRevisionRef = useRef<number>(0); const lastAppliedPatchRevisionRef = useRef<number>(0);
const lastProbabilityRefreshAtRef = useRef<number>(0); const lastProbabilityRefreshAtRef = useRef<number>(0);
const localDayRolloverFetchDateRef = useRef<string>(""); const localDayRolloverFetchDateRef = useRef<string>("");
const [isChartVisible, setIsChartVisible] = useState(
() => typeof IntersectionObserver === "undefined",
);
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true); const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null); const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
@@ -167,6 +186,9 @@ export function LiveTemperatureThresholdChart({
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(row));
setLiveTemp(null); setLiveTemp(null);
setIsHourlyLoading(Boolean(city)); setIsHourlyLoading(Boolean(city));
setDetailError(null);
setDetailRetryNonce(0);
setShowingStaleDetail(false);
hasLoadedHourlyDetailRef.current = false; hasLoadedHourlyDetailRef.current = false;
lastPatchAtRef.current = Date.now(); lastPatchAtRef.current = Date.now();
lastAppliedPatchRevisionRef.current = 0; lastAppliedPatchRevisionRef.current = 0;
@@ -175,6 +197,23 @@ export function LiveTemperatureThresholdChart({
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
}, [city]); }, [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(() => { useEffect(() => {
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds)); setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
const id = setInterval(() => { const id = setInterval(() => {
@@ -188,30 +227,47 @@ export function LiveTemperatureThresholdChart({
setIsHourlyLoading(false); setIsHourlyLoading(false);
return; return;
} }
if (
!shouldFetchCityDetailForChart({
city,
documentHidden:
typeof document !== "undefined" && document.visibilityState === "hidden",
isChartVisible,
})
) {
return;
}
const cacheKey = `${city}:${targetResolution}`; const cacheKey = `${city}:${targetResolution}`;
// Check in-memory cache first
let cached = _hourlyCache.get(cacheKey); let cached = _hourlyCache.get(cacheKey);
if (!cached) { if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
// Fallback to session cache const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
const sessionEntry = readSessionCache(cacheKey);
if (sessionEntry) { if (sessionEntry) {
cached = sessionEntry; cached = sessionEntry;
_hourlyCache.set(cacheKey, 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; hasLoadedHourlyDetailRef.current = true;
setHourly(cached.data); setHourly(cached.data);
setShowingStaleDetail(!hasFreshCache);
}
if (hasFreshCache) {
setIsHourlyLoading(false); setIsHourlyLoading(false);
setDetailError(null);
return; return;
} }
if (!hasLoadedHourlyDetailRef.current) { if (!cached && !hasLoadedHourlyDetailRef.current) {
setHourly(seedHourlyForecastFromRow(row)); setHourly(seedHourlyForecastFromRow(row));
setShowingStaleDetail(false);
} }
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current); setIsHourlyLoading(true);
let cancelled = false; let cancelled = false;
// Prioritize active slots, stagger/delay background slots to optimize load performance // Prioritize active slots, stagger/delay background slots to optimize load performance
@@ -220,11 +276,19 @@ export function LiveTemperatureThresholdChart({
const timer = setTimeout(() => { const timer = setTimeout(() => {
fetchHourlyForecastForCity(city, { resolution: targetResolution }) fetchHourlyForecastForCity(city, { resolution: targetResolution })
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (cancelled) return;
if (!data) {
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
return;
}
hasLoadedHourlyDetailRef.current = true; hasLoadedHourlyDetailRef.current = true;
setHourly(data); setHourly(data);
setDetailError(null);
setShowingStaleDetail(false);
})
.catch(() => {
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
}) })
.catch(() => {})
.finally(() => { .finally(() => {
if (!cancelled) setIsHourlyLoading(false); if (!cancelled) setIsHourlyLoading(false);
}); });
@@ -234,7 +298,7 @@ export function LiveTemperatureThresholdChart({
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [city, row, isActive, slotIndex, targetResolution]); }, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
useEffect(() => { useEffect(() => {
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return; if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
@@ -636,6 +700,12 @@ export function LiveTemperatureThresholdChart({
})); }));
}, [isSeriesVisible]); }, [isSeriesVisible]);
const handleRetryDetail = useCallback(() => {
setDetailError(null);
setIsHourlyLoading(true);
setDetailRetryNonce((value) => value + 1);
}, []);
const panelTitle = row ? ( const panelTitle = row ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
@@ -743,7 +813,7 @@ export function LiveTemperatureThresholdChart({
actions={timeframeActions} actions={timeframeActions}
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]} 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 <TemperatureStatsBars
isEn={isEn} isEn={isEn}
compact={compact} compact={compact}
@@ -790,6 +860,8 @@ export function LiveTemperatureThresholdChart({
hasRunwayData={hasRunwayData} hasRunwayData={hasRunwayData}
showRunwayDetails={showRunwayDetails} showRunwayDetails={showRunwayDetails}
isHourlyLoading={isHourlyLoading} isHourlyLoading={isHourlyLoading}
detailError={detailError}
showingStaleDetail={showingStaleDetail}
refAreaLeft={refAreaLeft} refAreaLeft={refAreaLeft}
refAreaRight={refAreaRight} refAreaRight={refAreaRight}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@@ -799,6 +871,7 @@ export function LiveTemperatureThresholdChart({
isSeriesVisible={isSeriesVisible} isSeriesVisible={isSeriesVisible}
onSeriesToggle={handleSeriesToggle} onSeriesToggle={handleSeriesToggle}
onShowRunwayDetailsChange={setShowRunwayDetails} onShowRunwayDetailsChange={setShowRunwayDetails}
onRetryDetail={handleRetryDetail}
/> />
</div> </div>
</Panel> </Panel>
@@ -822,6 +895,7 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics; export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
export const __getPeakGlowStateForTest = getPeakGlowState; export const __getPeakGlowStateForTest = getPeakGlowState;
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh; export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
export const __shouldPollLiveChartForTest = shouldPollLiveChart; export const __shouldPollLiveChartForTest = shouldPollLiveChart;
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly; export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp; export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
@@ -110,6 +110,8 @@ function TemperatureChartCanvasComponent({
hasRunwayData, hasRunwayData,
showRunwayDetails, showRunwayDetails,
isHourlyLoading, isHourlyLoading,
detailError,
showingStaleDetail,
refAreaLeft, refAreaLeft,
refAreaRight, refAreaRight,
onMouseDown, onMouseDown,
@@ -119,6 +121,7 @@ function TemperatureChartCanvasComponent({
isSeriesVisible, isSeriesVisible,
onSeriesToggle, onSeriesToggle,
onShowRunwayDetailsChange, onShowRunwayDetailsChange,
onRetryDetail,
}: { }: {
isEn: boolean; isEn: boolean;
compact: boolean; compact: boolean;
@@ -134,6 +137,8 @@ function TemperatureChartCanvasComponent({
hasRunwayData: boolean; hasRunwayData: boolean;
showRunwayDetails: boolean; showRunwayDetails: boolean;
isHourlyLoading: boolean; isHourlyLoading: boolean;
detailError?: string | null;
showingStaleDetail?: boolean;
refAreaLeft: number | null; refAreaLeft: number | null;
refAreaRight: number | null; refAreaRight: number | null;
onMouseDown: (event: any) => void; onMouseDown: (event: any) => void;
@@ -143,6 +148,7 @@ function TemperatureChartCanvasComponent({
isSeriesVisible: (seriesKey: string) => boolean; isSeriesVisible: (seriesKey: string) => boolean;
onSeriesToggle: (seriesKey: string) => void; onSeriesToggle: (seriesKey: string) => void;
onShowRunwayDetailsChange: (value: boolean) => void; onShowRunwayDetailsChange: (value: boolean) => void;
onRetryDetail?: () => void;
}) { }) {
const chartHostRef = useRef<HTMLDivElement | null>(null); const chartHostRef = useRef<HTMLDivElement | null>(null);
const [chartSize, setChartSize] = useState({ width: 0, height: 0 }); const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
@@ -215,6 +221,9 @@ function TemperatureChartCanvasComponent({
const shouldRenderChart = canRenderChart && hasDrawableChartContent; const shouldRenderChart = canRenderChart && hasDrawableChartContent;
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent; const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
const shouldShowBackgroundRefresh = 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 ( return (
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}> <div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
@@ -420,7 +429,21 @@ function TemperatureChartCanvasComponent({
))} ))}
</ReComposedChart> </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="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"> <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" : "暂无可绘制图表数据"} {isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
@@ -428,6 +451,18 @@ function TemperatureChartCanvasComponent({
</div> </div>
)} )}
</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 && ( {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]"> <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" /> <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, DASHBOARD_REFRESH_POLICY_SEC,
} from "@/lib/refresh-policy"; } from "@/lib/refresh-policy";
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client"; import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import {
__shouldFetchCityDetailForChartForTest,
__shouldPollLiveChartForTest,
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
import { import {
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
HOURLY_CACHE_TTL_MS,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
clearCityDetailCache,
} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; } from "@/components/dashboard/scan-terminal/temperature-chart-logic";
function assert(condition: unknown, message: string) { function assert(condition: unknown, message: string) {
@@ -79,6 +85,30 @@ export async function runTests() {
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
"terminal charts should render from row data immediately and dedupe concurrent city detail requests", "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(); __resetHourlyDetailRequestQueueForTest();
let activeRequests = 0; let activeRequests = 0;
@@ -130,4 +160,63 @@ export async function runTests() {
"city detail queue should resolve every queued request in order", "city detail queue should resolve every queued request in order",
); );
__resetHourlyDetailRequestQueueForTest(); __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_PREFIX = "polyweather_city_detail_v1:";
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
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; if (typeof window === "undefined") return null;
try { try {
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`); const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
if (!raw) return null; if (!raw) return null;
const item = JSON.parse(raw); 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; return item;
} }
} catch {} } catch {}
return null; 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) { function writeSessionCache(city: string, data: HourlyForecast) {
if (typeof window === "undefined" || !data) return; if (typeof window === "undefined" || !data) return;
try { try {
@@ -436,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() {
} }
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest; const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
const __readHourlyCacheEntryForTest = readHourlyCacheEntry;
function validNumber(value: unknown): number | null { function validNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null; return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -969,16 +1001,10 @@ async function fetchHourlyForecastForCity(
const cacheKey = `${city}:${resParam}`; const cacheKey = `${city}:${resParam}`;
if (!options.ignoreCache) { if (!options.ignoreCache) {
const cached = _hourlyCache.get(cacheKey); const cached = readHourlyCacheEntry(cacheKey);
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) { if (cached) {
return cached.data; 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}`; const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
@@ -2259,6 +2285,7 @@ export {
HOURLY_DETAIL_REQUEST_TIMEOUT_MS, HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
HOURLY_CACHE_TTL_MS, HOURLY_CACHE_TTL_MS,
_hourlyCache, _hourlyCache,
__readHourlyCacheEntryForTest,
__resetHourlyDetailRequestQueueForTest, __resetHourlyDetailRequestQueueForTest,
__runQueuedHourlyDetailRequestForTest, __runQueuedHourlyDetailRequestForTest,
buildChartDomain, 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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api"; 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() { export function AnalyticsPageClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [funnel, setFunnel] = useState<FunnelStep[]>([]); const [funnel, setFunnel] = useState<FunnelStep[]>([]);
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
const [rates, setRates] = useState<Record<string, number> | null>(null); const [rates, setRates] = useState<Record<string, number> | null>(null);
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
const [days, setDays] = useState(30); const [days, setDays] = useState(30);
const load = async () => { const load = async () => {
@@ -30,7 +42,9 @@ export function AnalyticsPageClient() {
try { try {
const data = await opsApi.funnel(days); const data = await opsApi.funnel(days);
setFunnel(data.steps); setFunnel(data.steps);
setDiagnostics(data.diagnostics ?? {});
setRates(data.rates ?? null); setRates(data.rates ?? null);
setTraffic(data.traffic ?? {});
} catch { /* */ } } catch { /* */ }
setLoading(false); setLoading(false);
}; };
@@ -43,7 +57,18 @@ export function AnalyticsPageClient() {
pct: s.pct_of_prev, 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>; if (loading) return <div className="text-slate-400 animate-pulse">...</div>;
@@ -68,72 +93,115 @@ export function AnalyticsPageClient() {
</div> </div>
</div> </div>
{/* Summary cards */}
{funnel.length > 0 && ( {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> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-xs text-slate-500"></div> <div className="text-xs text-slate-500">访</div>
<div className="text-xl font-bold text-white">{funnel[0]?.count ?? 0}</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> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-xs text-slate-500"></div> <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-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> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-xs text-slate-500"></div> <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> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-xs text-slate-500"></div> <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-xl font-bold text-emerald-400">{paymentSuccess?.count ?? 0}</div>
<div className="text-xs text-slate-500 mt-0.5"> <div className="mt-0.5 text-xs text-slate-500">
{maxCount > 0 ? `${((funnel[5]?.count ?? 0) / maxCount * 100).toFixed(1)}%` : "—"} {overallRate === "—" ? "—" : `${overallRate}%`}
</div> </div>
</CardContent> </CardContent>
</Card> </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> </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> <Card>
<CardHeader><CardTitle> (Recharts)</CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent> <CardContent>
{chartData.length === 0 ? ( {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}> <AnalyticsFunnelChart data={chartData} />
<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>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Drop-off table */}
<Card> <Card>
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent> <CardContent>
@@ -141,28 +209,35 @@ export function AnalyticsPageClient() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-white/10 text-left text-slate-400"> <tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-2 px-3 font-medium"></th> <th className="px-3 py-2 font-medium"></th>
<th className="py-2 px-3 font-medium text-right"></th> <th className="px-3 py-2 text-right font-medium"></th>
<th className="py-2 px-3 font-medium text-right"></th> <th className="px-3 py-2 text-right font-medium">/访</th>
<th className="py-2 px-3 font-medium text-right"></th> <th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{funnel.map((step, i) => { {funnel.map((step, i) => {
const pct = step.pct_of_prev; const pct = step.pct_of_prev;
const dropPct = pct != null ? 100 - pct : 0; const dropPct = pct != null ? Math.max(0, 100 - pct) : 0;
return ( return (
<tr key={i} className="border-b border-white/5"> <tr key={step.key} className="border-b border-white/5">
<td className="py-2 px-3 text-white">{step.label}</td> <td className="px-3 py-2 text-white">{step.label}</td>
<td className="py-2 px-3 text-right font-mono text-white">{step.count}</td> <td className="px-3 py-2 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="px-3 py-2 text-right font-mono text-white">{step.uniqueActors}</td>
<td className="py-2 px-3 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</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> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
{rates ? (
<p className="mt-3 text-xs text-slate-500">
rates unique actors
</p>
) : null}
</CardContent> </CardContent>
</Card> </Card>
</div> </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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import { const HealthLatencyChart = dynamic(
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, () => import("./HealthLatencyChart").then((mod) => mod.HealthLatencyChart),
ResponsiveContainer, Cell, {
} from "recharts"; ssr: false,
loading: () => <div className="h-[220px] animate-pulse rounded-lg bg-slate-100" />,
},
);
type ServiceResult = { type ServiceResult = {
ok: boolean; ok: boolean;
@@ -107,27 +111,7 @@ export function HealthPageClient() {
<Card className="border-slate-800 bg-slate-900/50"> <Card className="border-slate-800 bg-slate-900/50">
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="text-sm font-semibold text-white mb-4"> (ms)</h3> <h3 className="text-sm font-semibold text-white mb-4"> (ms)</h3>
<ResponsiveContainer width="100%" height={Math.max(160, latencyData.length * 28 + 40)}> <HealthLatencyChart data={latencyData} />
<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>
</CardContent> </CardContent>
</Card> </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 { AdminSidebar } from "./AdminSidebar";
import styles from "./AdminShell.module.css";
export function AdminShell({ children }: { children: React.ReactNode }) { export function AdminShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className={styles.root}>
<AdminSidebar /> <AdminSidebar />
<main className="ml-56 min-h-screen p-6"> <main className={styles.main}>
{children} <div className={styles.content}>{children}</div>
</main> </main>
</div> </div>
); );
+11 -11
View File
@@ -57,17 +57,17 @@ export function AdminSidebar() {
const pathname = usePathname(); const pathname = usePathname();
return ( 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"> <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-white/10 px-4"> <div className="flex h-14 items-center gap-2 border-b border-slate-200 px-4">
<LayoutDashboard className="h-5 w-5 text-cyan-400" /> <LayoutDashboard className="h-5 w-5 text-blue-600" />
<Link href="/ops/system" className="text-sm font-bold text-white"> <Link href="/ops/system" className="text-sm font-extrabold text-slate-950">
PolyWeather Ops PolyWeather Ops
</Link> </Link>
</div> </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) => ( {navGroups.map((group) => (
<div key={group.label} className="px-3"> <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} {group.label}
</h4> </h4>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
@@ -78,10 +78,10 @@ export function AdminSidebar() {
<Link <Link
href={item.href} href={item.href}
className={ 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 (isActive
? "bg-white/10 text-white font-medium" ? "border-blue-200 bg-blue-50 text-blue-700 font-semibold shadow-[inset_3px_0_0_#2563eb]"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200") : "text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950")
} }
> >
<item.icon className="h-4 w-4 shrink-0" /> <item.icon className="h-4 w-4 shrink-0" />
@@ -94,10 +94,10 @@ export function AdminSidebar() {
</div> </div>
))} ))}
</nav> </nav>
<div className="border-t border-white/10 px-4 py-3"> <div className="border-t border-slate-200 px-4 py-3">
<Link <Link
href="/" 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> </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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, X, Search } from "lucide-react"; import { RefreshCcw, X, Search } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api"; import { opsApi } from "@/lib/ops-api";
import type { MembershipEntry } from "@/types/ops"; import type { MembershipEntry } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import { const MembershipGrowthChart = dynamic(
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, () => import("./MembershipGrowthChart").then((mod) => mod.MembershipGrowthChart),
ResponsiveContainer, Legend, Area, AreaChart, {
} from "recharts"; 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 }; type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
@@ -160,35 +164,7 @@ export function MembershipsPageClient() {
</div> </div>
</div> </div>
{/* Cumulative area chart */} <MembershipGrowthChart growth={growth} />
<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>
</CardContent> </CardContent>
</Card> </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"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity, Cpu } from "lucide-react"; import dynamic from "next/dynamic";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Activity, Cpu, CreditCard, Database, RefreshCcw, TrendingUp, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { opsApi } from "@/lib/ops-api";
import Link from "next/link"; import Link from "next/link";
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops"; import { Badge } from "@/components/ui/badge";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, import { opsApi } from "@/lib/ops-api";
BarChart, Bar, XAxis, YAxis, CartesianGrid, import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
AreaChart, Area, Legend,
} from "recharts"; 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 }: { function KpiCard({ href, icon: Icon, label, value, color, sub }: {
href: string; icon: React.ElementType; label: string; value: string | number; color: string; sub?: string; 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 [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null); const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]); 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 [growth, setGrowth] = useState<{ date: string; trial: number; paid: number; total: number; cumulative: number }[]>([]);
const load = async () => { const load = async () => {
@@ -70,19 +73,20 @@ export function OverviewPageClient() {
</div> </div>
); );
// ── Derived data ──
const paid = memberships.filter((m) => !m.is_trial).length; const paid = memberships.filter((m) => !m.is_trial).length;
const trials = memberships.filter((m) => m.is_trial).length; const trials = memberships.filter((m) => m.is_trial).length;
const steps = funnel?.steps ?? []; const steps = funnel?.steps ?? [];
const totalUsers = steps[0]?.count ?? 0; 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 convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
const cache = status?.cache; const cache = status?.cache;
const cacheAnalysis = cache?.analysis; const cacheAnalysis = cache?.analysis;
const td = status?.training_data; const td = status?.training_data;
const features = status?.features; 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 ? [ const cacheBuckets = cache ? [
{ name: "API", value: cache.api_cache_entries ?? 0 }, { name: "API", value: cache.api_cache_entries ?? 0 },
{ name: "预报", value: cache.open_meteo_forecast_entries ?? 0 }, { name: "预报", value: cache.open_meteo_forecast_entries ?? 0 },
@@ -91,20 +95,17 @@ export function OverviewPageClient() {
{ name: "结算", value: cache.settlement_entries ?? 0 }, { name: "结算", value: cache.settlement_entries ?? 0 },
].filter((d) => d.value > 0) : []; ].filter((d) => d.value > 0) : [];
// Cache pie
const cachePie = cacheAnalysis ? [ const cachePie = cacheAnalysis ? [
{ name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" }, { name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" },
{ name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" }, { name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" },
{ name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" }, { name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" },
] : []; ] : [];
// Membership pie
const memberPie = [ const memberPie = [
{ name: "付费", value: paid, color: "#22c55e" }, { name: "付费", value: paid, color: "#22c55e" },
...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []), ...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []),
]; ];
// Plan breakdown
const planCounts: Record<string, number> = {}; const planCounts: Record<string, number> = {};
memberships.forEach((m) => { memberships.forEach((m) => {
const code = m.plan_code ?? "unknown"; const code = m.plan_code ?? "unknown";
@@ -117,9 +118,6 @@ export function OverviewPageClient() {
}) })
.sort((a, b) => b.value - a.value); .sort((a, b) => b.value - a.value);
const truthRows = td?.truth_records?.row_count ?? 0;
const coverage = td?.city_coverage;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -132,7 +130,6 @@ export function OverviewPageClient() {
</Button> </Button>
</div> </div>
{/* KPI row */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5"> <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/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} /> <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" /> <KpiCard href="/ops/system" icon={Cpu} label="概率引擎" value={status?.probability?.engine_mode ?? "—"} color="text-amber-400" />
</div> </div>
{/* Membership Growth Trend */} <OverviewCharts
{growth.length > 0 && ( growth={growth}
<Card> steps={steps}
<CardHeader className="pb-2"> cacheBuckets={cacheBuckets}
<CardTitle className="text-sm"> 30 </CardTitle> memberPie={memberPie}
</CardHeader> planBreakdown={planBreakdown}
<CardContent> cachePie={cachePie}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> cacheAnalysis={cacheAnalysis}
{/* 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>
{/* Daily stack chart */} <div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
<div> {features && (
<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 */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm"></CardTitle> <CardTitle className="text-sm"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-4"> <div className="flex flex-wrap gap-1.5">
<div className="w-36 h-36 shrink-0"> {Object.entries(features).map(([k, v]) => (
<ResponsiveContainer> <Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
<PieChart> {k.replace(/_/g, " ")}: {String(v)}
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value"> </Badge>
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))} ))}
</Pie> </div>
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} /> </CardContent>
</PieChart> </Card>
</ResponsiveContainer> )}
{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>
<div className="flex-1 space-y-1.5 text-sm"> <div className="rounded-xl bg-white/5 p-3 text-center">
{memberPie.map((d) => ( <div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
<div key={d.name} className="flex items-center gap-2"> <div className="text-[11px] text-slate-500"></div>
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} /> </div>
<span className="text-slate-400">{d.name}</span> <div className="rounded-xl bg-white/5 p-3 text-center">
<span className="text-white font-bold ml-auto">{d.value}</span> <div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
</div> <div className="text-[11px] text-slate-500"></div>
))} </div>
{planBreakdown.length > 0 && ( <div className="rounded-xl bg-white/5 p-3 text-center">
<div className="pt-2 mt-2 border-t border-white/10 space-y-1"> <div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
{planBreakdown.map((p) => ( <div className="text-[11px] text-slate-500"></div>
<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> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </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>
</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"; "use client";
import { useEffect, useState } from "react"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api"; import { opsApi } from "@/lib/ops-api";
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops"; import type {
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils"; BillingRiskIssue,
import { BillingRiskPayload,
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, PaymentRuntimePayload,
} from "recharts"; 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() { export function PaymentsPageClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null); const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
const [incidents, setIncidents] = useState<PaymentIncident[]>([]); const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
const [payments, setPayments] = useState<PaymentRecord[]>([]); const [payments, setPayments] = useState<PaymentRecord[]>([]);
const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
const [resolving, setResolving] = useState<Set<number>>(new Set()); const [resolving, setResolving] = useState<Set<number>>(new Set());
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const [rt, inc, pay] = await Promise.all([ const [rt, inc, pay, riskPayload] = await Promise.all([
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>, opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
opsApi.incidents(50), opsApi.incidents(50),
opsApi.listPayments(50), opsApi.listPayments(50),
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
]); ]);
setRuntime(rt); setRuntime(rt);
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []); setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []); setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
setRisk(riskPayload);
} catch { /* */ } } catch { /* */ }
setLoading(false); setLoading(false);
}; };
@@ -60,18 +122,10 @@ export function PaymentsPageClient() {
name, name,
value, value,
})); }));
const riskSummary = risk?.summary ?? {};
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"]; const riskIssues = risk?.issues ?? [];
const referralRewards = risk?.recent_referral_rewards ?? [];
function paymentExplorerUrl(payment: PaymentRecord): string { const hasRisk = Number(riskSummary.issues ?? 0) > 0;
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}`;
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -82,6 +136,73 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
</Button> </Button>
</div> </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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2"> <Card className="lg:col-span-2">
<CardHeader><CardTitle></CardTitle></CardHeader> <CardHeader><CardTitle></CardTitle></CardHeader>
@@ -113,42 +234,48 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
{incidentPieData.length === 0 ? ( {incidentPieData.length === 0 ? (
<span className="text-sm text-slate-500"></span> <span className="text-sm text-slate-500"></span>
) : ( ) : (
<div className="w-full h-full flex items-center gap-2"> <PaymentIncidentPieChart data={incidentPieData} />
<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>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div> </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> <Card>
<CardHeader><CardTitle> ({incidents.length})</CardTitle></CardHeader> <CardHeader><CardTitle> ({incidents.length})</CardTitle></CardHeader>
<CardContent> <CardContent>
@@ -1,29 +1,55 @@
"use client"; "use client";
import { useEffect, useState } from "react"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api"; 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() { export function SystemPageClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<HealthPayload | null>(null); const [health, setHealth] = useState<HealthPayload | null>(null);
const [status, setStatus] = useState<SystemStatusPayload | null>(null); const [status, setStatus] = useState<SystemStatusPayload | null>(null);
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const [h, s] = await Promise.all([ const [h, s, sh] = await Promise.all([
opsApi.health(), opsApi.health(),
opsApi.systemStatus() as Promise<SystemStatusPayload>, opsApi.systemStatus() as Promise<SystemStatusPayload>,
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
]); ]);
setHealth(h); setHealth(h);
setStatus(s); setStatus(s);
setSourceHealth(sh);
} catch (e) { } catch (e) {
setError(String(e).slice(0, 200)); setError(String(e).slice(0, 200));
} finally { } finally {
@@ -45,6 +71,13 @@ export function SystemPageClient() {
const dbOk = status?.db?.ok ?? health?.db?.ok; const dbOk = status?.db?.ok ?? health?.db?.ok;
const cacheAnalysis = status?.cache?.analysis; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -165,6 +198,73 @@ export function SystemPageClient() {
</Card> </Card>
) : null} ) : 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 */} {/* DB Path */}
{status?.db?.db_path ? ( {status?.db?.db_path ? (
<Card> <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"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react"; import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { opsApi } from "@/lib/ops-api"; import { opsApi } from "@/lib/ops-api";
import type { SystemStatusPayload } from "@/types/ops"; import type { SystemStatusPayload } from "@/types/ops";
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
import Link from "next/link"; import Link from "next/link";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, const TrainingAccuracyCharts = dynamic(
ComposedChart, Line, Legend, Cell, LabelList, () => import("./TrainingAccuracyCharts").then((mod) => mod.TrainingAccuracyCharts),
} from "recharts"; {
ssr: false,
loading: () => <div className="h-[400px] animate-pulse rounded-lg bg-slate-100" />,
},
);
function StatRow({ label, value }: { label: string; value: React.ReactNode }) { function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
@@ -61,37 +65,6 @@ interface CityAccuracy {
} | null; } | 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() { export function TrainingPageClient() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<SystemStatusPayload | null>(null); const [status, setStatus] = useState<SystemStatusPayload | null>(null);
@@ -221,115 +194,7 @@ export function TrainingPageClient() {
</div> </div>
) : null} ) : null}
{/* DEB Accuracy Charts */} <TrainingAccuracyCharts debChartData={debChartData} muChartData={muChartData} />
{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}
{/* City coverage */} {/* City coverage */}
{modelCities ? ( {modelCities ? (
+15
View File
@@ -11,6 +11,7 @@ type TrackableAnalyticsEvent =
| "trial_created" | "trial_created"
| "payment_start" | "payment_start"
| "payment_success" | "payment_success"
| "degraded_auth_profile"
| "signup_completed" | "signup_completed"
| "dashboard_active" | "dashboard_active"
| "paywall_feature_clicked" | "paywall_feature_clicked"
@@ -41,6 +42,15 @@ function getStoredId(storage: Storage, key: string) {
return value; return value;
} }
function getDeviceType() {
if (!isClient()) return "unknown";
const width = window.innerWidth || 0;
const ua = navigator.userAgent || "";
if (/ipad|tablet/i.test(ua) || (width >= 768 && width < 1100)) return "tablet";
if (/mobi|android|iphone/i.test(ua) || width < 768) return "mobile";
return "desktop";
}
export function getAnalyticsClientId() { export function getAnalyticsClientId() {
if (!isClient()) return ""; if (!isClient()) return "";
try { try {
@@ -88,6 +98,11 @@ export function trackAppEvent(
...payload, ...payload,
path: window.location.pathname, path: window.location.pathname,
href: window.location.href, href: window.location.href,
referrer: document.referrer || "",
language: navigator.language || "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
device_type: getDeviceType(),
viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
captured_at: new Date().toISOString(), captured_at: new Date().toISOString(),
}, },
}; };
+23 -3
View File
@@ -16,16 +16,29 @@ export const opsApi = {
systemStatus() { systemStatus() {
return opsFetch<Record<string, unknown>>("/api/system/status"); return opsFetch<Record<string, unknown>>("/api/system/status");
}, },
sourceHealth(limit = 80) {
return opsFetch<Record<string, unknown>>(`/api/ops/source-health?limit=${limit}`);
},
paymentRuntime() { paymentRuntime() {
return opsFetch<Record<string, unknown>>("/api/payments/runtime"); return opsFetch<Record<string, unknown>>("/api/payments/runtime");
}, },
listPayments(limit = 50) { listPayments(limit = 50) {
return opsFetch<{ payments?: Array<Record<string, unknown>>; total?: number }>(`/api/ops/payments?limit=${limit}`); return opsFetch<{ payments?: Array<Record<string, unknown>>; total?: number }>(`/api/ops/payments?limit=${limit}`);
}, },
billingRisk(days = 30, limit = 80) {
return opsFetch<Record<string, unknown>>(`/api/ops/billing-risk?days=${days}&limit=${limit}`);
},
async funnel(days = 30) { async funnel(days = 30) {
const raw = await opsFetch<{ const raw = await opsFetch<{
events?: Record<string, { total?: number; unique_users?: number }>; events?: Record<string, { total?: number; unique_users?: number; unique_actors?: number }>;
diagnostics?: Record<string, { total?: number; unique_actors?: number; by_reason?: { name: string; count: number }[] }>;
rates?: Record<string, number>; rates?: Record<string, number>;
traffic?: {
referrers?: { name: string; count: number }[];
countries?: { name: string; count: number }[];
devices?: { name: string; count: number }[];
landing_paths?: { name: string; count: number }[];
};
window_days?: number; window_days?: number;
}>(`/api/ops/analytics/funnel?days=${days}`); }>(`/api/ops/analytics/funnel?days=${days}`);
const stepOrder = ["landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"]; const stepOrder = ["landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"];
@@ -41,6 +54,7 @@ export const opsApi = {
const steps = stepOrder.map((key, i) => { const steps = stepOrder.map((key, i) => {
const evt = raw?.events?.[key]; const evt = raw?.events?.[key];
const count = evt?.total ?? 0; const count = evt?.total ?? 0;
const uniqueActors = evt?.unique_actors ?? evt?.unique_users ?? 0;
let pct_of_prev: number | undefined; let pct_of_prev: number | undefined;
if (i > 0) { if (i > 0) {
const prevCount = raw?.events?.[stepOrder[i - 1]]?.total ?? 0; const prevCount = raw?.events?.[stepOrder[i - 1]]?.total ?? 0;
@@ -48,9 +62,15 @@ export const opsApi = {
} else { } else {
pct_of_prev = 100; pct_of_prev = 100;
} }
return { label: stepLabels[key] ?? key, count, pct_of_prev }; return { key, label: stepLabels[key] ?? key, count, uniqueActors, pct_of_prev };
}); });
return { steps, rates: raw?.rates, window_days: raw?.window_days }; return {
diagnostics: raw?.diagnostics ?? {},
rates: raw?.rates,
steps,
traffic: raw?.traffic ?? {},
window_days: raw?.window_days,
};
}, },
users(q: string, limit = 20) { users(q: string, limit = 20) {
return opsFetch<Record<string, unknown>>(`/api/ops/users?q=${encodeURIComponent(q)}&limit=${limit}`); return opsFetch<Record<string, unknown>>(`/api/ops/users?q=${encodeURIComponent(q)}&limit=${limit}`);
+69
View File
@@ -52,6 +52,38 @@ export type SystemStatusPayload = {
}; };
}; };
export type SourceHealthSource = {
role?: string;
source_code?: string;
source_label?: string;
station_code?: string | null;
station_label?: string | null;
status?: "fresh" | "expected_wait" | "delayed" | "stale" | "missing" | "unknown" | string;
reason?: string | null;
age_sec?: number | null;
age_min?: number | null;
observed_at?: string | null;
expected_next_update_at?: string | null;
temp?: number | null;
};
export type SourceHealthCity = {
city: string;
cache_exists?: boolean;
cache_updated_at?: string | null;
cache_age_sec?: number | null;
source_count?: number;
worst_status?: string;
sources?: SourceHealthSource[];
};
export type SourceHealthPayload = {
checked_at?: string;
cities?: SourceHealthCity[];
status_counts?: Record<string, number>;
total_cities?: number;
};
export type PaymentRuntimePayload = { export type PaymentRuntimePayload = {
rpc?: string; rpc?: string;
chain_id?: number; chain_id?: number;
@@ -91,6 +123,43 @@ export type PaymentsPayload = {
total?: number; total?: number;
}; };
export type BillingRiskIssue = {
category?: string;
severity?: "high" | "medium" | "low" | string;
title?: string;
detail?: string;
user_id?: string;
created_at?: string;
reference?: string;
payload?: Record<string, unknown>;
};
export type BillingRiskPayload = {
checked_at?: string;
window_days?: number;
summary?: {
issues?: number;
stuck_intents?: number;
trial_gaps?: number;
payment_incidents?: number;
points_discount_issues?: number;
referral_settlement_issues?: number;
monthly_cap_hits?: number;
recent_referral_rewards?: number;
recent_trial_claims?: number;
};
issues?: BillingRiskIssue[];
stuck_intents?: Array<Record<string, unknown>>;
trial_gaps?: Array<Record<string, unknown>>;
payment_incidents?: Array<Record<string, unknown>>;
points_discount_issues?: Array<Record<string, unknown>>;
referral_settlement_issues?: Array<Record<string, unknown>>;
monthly_cap_hits?: Array<Record<string, unknown>>;
recent_referral_rewards?: Array<Record<string, unknown>>;
recent_trial_claims?: Array<Record<string, unknown>>;
query_errors?: Array<{ table?: string; error?: string }>;
};
export type OpsUser = { export type OpsUser = {
telegram_id?: number; telegram_id?: number;
username?: string; username?: string;
+81 -12
View File
@@ -5,8 +5,10 @@ import json
import secrets import secrets
import threading import threading
import time import time
from collections import Counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List, Set from typing import Optional, Dict, Any, List, Set
from urllib.parse import urlparse
import requests import requests
from loguru import logger from loguru import logger
@@ -1002,7 +1004,7 @@ class DBManager:
event_type: Optional[str] = None, event_type: Optional[str] = None,
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 200), 2000)) safe_limit = max(1, min(int(limit or 200), 20000))
kind = str(event_type or "").strip().lower() kind = str(event_type or "").strip().lower()
params: List[Any] = [] params: List[Any] = []
clauses: List[str] = [] clauses: List[str] = []
@@ -1049,7 +1051,7 @@ class DBManager:
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]: def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
safe_days = max(1, min(int(days or 30), 365)) safe_days = max(1, min(int(days or 30), 365))
since_dt = datetime.now() - timedelta(days=safe_days) since_dt = datetime.now() - timedelta(days=safe_days)
rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat()) rows = self.list_app_analytics_events(limit=20000, since_iso=since_dt.isoformat())
event_names = [ event_names = [
"landing_view", "landing_view",
"enter_terminal", "enter_terminal",
@@ -1059,6 +1061,7 @@ class DBManager:
"payment_start", "payment_start",
"payment_success", "payment_success",
] ]
diagnostic_event_names = ["degraded_auth_profile"]
event_aliases = { event_aliases = {
"landing_view": ("landing_view",), "landing_view": ("landing_view",),
"enter_terminal": ("enter_terminal", "dashboard_active"), "enter_terminal": ("enter_terminal", "dashboard_active"),
@@ -1083,31 +1086,90 @@ class DBManager:
} }
actor_sets: Dict[str, set[str]] = {name: set() for name in event_names} actor_sets: Dict[str, set[str]] = {name: set() for name in event_names}
user_sets: Dict[str, set[str]] = {name: set() for name in event_names} user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
diagnostics: Dict[str, Dict[str, Any]] = {
name: {"total": 0, "unique_actors": 0, "by_reason": []}
for name in diagnostic_event_names
}
diagnostic_actor_sets: Dict[str, set[str]] = {
name: set() for name in diagnostic_event_names
}
diagnostic_reason_counts: Dict[str, Counter] = {
name: Counter() for name in diagnostic_event_names
}
referrer_counts: Counter = Counter()
country_counts: Counter = Counter()
device_counts: Counter = Counter()
landing_path_counts: Counter = Counter()
def _payload(row: Dict[str, Any]) -> Dict[str, Any]:
payload = row.get("payload")
return payload if isinstance(payload, dict) else {}
def _actor_key(row: Dict[str, Any]) -> str:
payload = _payload(row)
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
if user_id:
return f"user:{user_id}"
if client_id:
return f"client:{client_id}"
if session_id:
return f"session:{session_id}"
return f"event:{row.get('id')}"
def _top(counter: Counter, *, limit: int = 8) -> List[Dict[str, Any]]:
return [
{"name": name, "count": count}
for name, count in counter.most_common(limit)
]
def _normalize_referrer(value: Any) -> str:
raw = str(value or "").strip()
if not raw:
return "(direct)"
try:
parsed = urlparse(raw)
host = (parsed.netloc or parsed.path or raw).lower()
return host.replace("www.", "", 1) or "(direct)"
except Exception:
return raw[:80] or "(direct)"
for row in rows: for row in rows:
raw_event_type = str(row.get("event_type") or "").strip().lower() raw_event_type = str(row.get("event_type") or "").strip().lower()
payload = _payload(row)
if raw_event_type in diagnostics:
diagnostics[raw_event_type]["total"] += 1
diagnostic_actor_sets[raw_event_type].add(_actor_key(row))
reason = str(payload.get("reason") or payload.get("degraded_reason") or "unknown").strip()
diagnostic_reason_counts[raw_event_type][reason[:120] or "unknown"] += 1
continue
event_type = alias_to_event.get(raw_event_type) event_type = alias_to_event.get(raw_event_type)
if not event_type: if not event_type:
continue continue
summary[event_type]["total"] += 1 summary[event_type]["total"] += 1
user_id = str(row.get("user_id") or "").strip().lower() user_id = str(row.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
actor_key = ""
if user_id: if user_id:
actor_key = f"user:{user_id}"
user_sets[event_type].add(user_id) user_sets[event_type].add(user_id)
elif client_id: actor_key = _actor_key(row)
actor_key = f"client:{client_id}"
elif session_id:
actor_key = f"session:{session_id}"
else:
actor_key = f"event:{row.get('id')}"
actor_sets[event_type].add(actor_key) actor_sets[event_type].add(actor_key)
if event_type == "landing_view":
referrer_counts[_normalize_referrer(payload.get("referrer"))] += 1
country = str(payload.get("cf_country") or payload.get("country") or "").strip().upper()
country_counts[country or "UNKNOWN"] += 1
device = str(payload.get("device_type") or "unknown").strip().lower()
device_counts[device or "unknown"] += 1
path = str(payload.get("path") or "/").strip()[:120]
landing_path_counts[path or "/"] += 1
for name in event_names: for name in event_names:
summary[name]["unique_users"] = len(user_sets[name]) summary[name]["unique_users"] = len(user_sets[name])
summary[name]["unique_actors"] = len(actor_sets[name]) summary[name]["unique_actors"] = len(actor_sets[name])
for name in diagnostic_event_names:
diagnostics[name]["unique_actors"] = len(diagnostic_actor_sets[name])
diagnostics[name]["by_reason"] = _top(diagnostic_reason_counts[name], limit=6)
def _rate(numerator_key: str, denominator_key: str) -> Optional[float]: def _rate(numerator_key: str, denominator_key: str) -> Optional[float]:
denominator = int(summary[denominator_key]["unique_actors"] or 0) denominator = int(summary[denominator_key]["unique_actors"] or 0)
@@ -1120,6 +1182,13 @@ class DBManager:
"window_days": safe_days, "window_days": safe_days,
"since": since_dt.isoformat(), "since": since_dt.isoformat(),
"events": summary, "events": summary,
"diagnostics": diagnostics,
"traffic": {
"referrers": _top(referrer_counts),
"countries": _top(country_counts),
"devices": _top(device_counts),
"landing_paths": _top(landing_path_counts),
},
"rates": { "rates": {
"enter_terminal_rate": _rate("enter_terminal", "landing_view"), "enter_terminal_rate": _rate("enter_terminal", "landing_view"),
"login_start_rate": _rate("login_start", "enter_terminal"), "login_start_rate": _rate("login_start", "enter_terminal"),
+236 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.requests import Request from starlette.requests import Request
@@ -71,6 +71,7 @@ def test_standard_growth_funnel_events_are_trackable():
"trial_created", "trial_created",
"payment_start", "payment_start",
"payment_success", "payment_success",
"degraded_auth_profile",
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS) }.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
@@ -85,6 +86,7 @@ def test_standard_growth_funnel_summary_order(monkeypatch):
{"id": 5, "event_type": "trial_created", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, {"id": 5, "event_type": "trial_created", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 6, "event_type": "payment_start", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, {"id": 6, "event_type": "payment_start", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 7, "event_type": "payment_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"}, {"id": 7, "event_type": "payment_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 8, "event_type": "degraded_auth_profile", "user_id": "", "client_id": "auth:u1", "session_id": "", "payload": {"reason": "backend_500"}},
] ]
monkeypatch.setattr( monkeypatch.setattr(
DBManager, DBManager,
@@ -103,8 +105,241 @@ def test_standard_growth_funnel_summary_order(monkeypatch):
"payment_success", "payment_success",
] ]
assert summary["rates"]["payment_success_rate"] == 1.0 assert summary["rates"]["payment_success_rate"] == 1.0
assert summary["diagnostics"]["degraded_auth_profile"]["total"] == 1
assert summary["diagnostics"]["degraded_auth_profile"]["by_reason"][0] == {
"name": "backend_500",
"count": 1,
}
def test_growth_funnel_summarizes_traffic_sources(monkeypatch):
from src.database.db_manager import DBManager
rows = [
{
"id": 1,
"event_type": "landing_view",
"user_id": "",
"client_id": "c1",
"session_id": "s1",
"payload": {
"referrer": "https://x.com/polyweather",
"cf_country": "us",
"device_type": "mobile",
"path": "/",
},
},
{
"id": 2,
"event_type": "landing_view",
"user_id": "",
"client_id": "c2",
"session_id": "s2",
"payload": {
"referrer": "",
"cf_country": "hk",
"device_type": "desktop",
"path": "/?ref=abc",
},
},
]
monkeypatch.setattr(
DBManager,
"list_app_analytics_events",
lambda self, limit=20000, since_iso=None: rows,
)
summary = DBManager().get_app_analytics_funnel_summary(days=7)
assert summary["traffic"]["referrers"][0] == {"name": "x.com", "count": 1}
assert {"name": "(direct)", "count": 1} in summary["traffic"]["referrers"]
assert {"name": "US", "count": 1} in summary["traffic"]["countries"]
assert {"name": "mobile", "count": 1} in summary["traffic"]["devices"]
def test_ops_source_health_flags_expected_official_sources(monkeypatch):
class FakeCache:
def get_city_cache(self, kind, city):
if kind != "full":
return None
payloads = {
"ankara": {
"airport_primary": {
"source_code": "mgm",
"source_label": "MGM",
"obs_age_min": 80,
"temp": 17,
}
},
"amsterdam": {
"airport_primary": {
"source_code": "knmi",
"source_label": "KNMI",
"obs_age_min": 5,
"temp": 19,
}
},
"tel aviv": {
"airport_current": {
"source_code": "metar",
"source_label": "METAR",
"obs_age_min": 5,
"temp": 25,
}
},
}
payload = payloads.get(city)
if not payload:
return None
return {
"payload": payload,
"updated_at": "2026-05-31T10:00:00Z",
"updated_at_ts": 1,
}
monkeypatch.setattr(ops_api.legacy_routes, "_require_ops_admin", lambda request: {"email": "ops@example.com"})
monkeypatch.setattr(ops_api.legacy_routes, "_CACHE_DB", FakeCache())
monkeypatch.setattr(
ops_api.legacy_routes,
"CITIES",
{"ankara": {}, "amsterdam": {}, "tel aviv": {}},
raising=False,
)
payload = ops_api.get_ops_source_health(None, limit=10)
by_city = {row["city"]: row for row in payload["cities"]}
assert by_city["ankara"]["worst_status"] == "stale"
assert any(source["source_code"] == "mgm" for source in by_city["ankara"]["sources"])
assert by_city["amsterdam"]["worst_status"] == "fresh"
assert any(
source["source_code"] == "ims" and source["status"] == "missing"
for source in by_city["tel aviv"]["sources"]
)
def test_ops_billing_risk_surfaces_trial_payment_referral_and_points(monkeypatch):
from src.database.db_manager import DBManager
now = datetime.now(timezone.utc)
old = (now - timedelta(minutes=20)).isoformat()
recent = now.isoformat()
def fake_supabase_rows(table, params, *, timeout=10):
if table == "payment_intents":
return [
{
"id": "intent-stuck",
"user_id": "user-pay",
"plan_code": "pro_monthly",
"status": "submitted",
"updated_at": old,
"created_at": old,
"tx_hash": "0x" + "a" * 64,
"metadata": {},
},
{
"id": "intent-points",
"user_id": "user-points",
"plan_code": "pro_monthly",
"status": "confirmed",
"updated_at": recent,
"created_at": recent,
"metadata": {
"points_redemption": {
"applied": True,
"points_to_consume": 1500,
}
},
},
]
if table == "referral_attributions":
return [
{
"id": 1,
"code": "CAP1",
"referrer_user_id": "referrer-cap",
"referred_user_id": "referred-cap",
"status": "capped",
"updated_at": recent,
"created_at": recent,
},
{
"id": 2,
"code": "MISS1",
"referrer_user_id": "referrer-missing",
"referred_user_id": "referred-missing",
"status": "converted",
"converted_payment_intent_id": "intent-converted",
"converted_at": recent,
"updated_at": recent,
"created_at": recent,
},
]
if table == "referral_rewards":
return [
{
"id": 10,
"referral_attribution_id": 99,
"referrer_user_id": "referrer-ok",
"referred_user_id": "referred-ok",
"payment_intent_id": "intent-ok",
"reward_points": 3500,
"reward_days": 0,
"created_at": recent,
}
]
if table == "trial_claims":
return []
return []
monkeypatch.setattr(ops_api.legacy_routes, "_require_ops_admin", lambda request: {"email": "ops@example.com"})
monkeypatch.setattr(ops_api, "_supabase_rest_rows", fake_supabase_rows)
monkeypatch.setattr(
DBManager,
"list_app_analytics_events",
lambda self, limit=20000, since_iso=None: [
{
"id": 11,
"event_type": "signup_success",
"user_id": "user-trial-gap",
"client_id": "",
"session_id": "session-gap",
"created_at": recent,
"payload": {},
}
],
)
monkeypatch.setattr(
DBManager,
"list_payment_audit_events",
lambda self, limit=50, event_type=None: [
{
"id": 21,
"event_type": "payment_intent_failed",
"payload": {"reason": "receiver_mismatch"},
"created_at": recent,
}
],
)
payload = ops_api.get_ops_billing_risk(None, days=30, limit=20)
summary = payload["summary"]
assert summary["stuck_intents"] == 1
assert summary["trial_gaps"] == 1
assert summary["points_discount_issues"] == 1
assert summary["referral_settlement_issues"] == 1
assert summary["monthly_cap_hits"] == 1
assert summary["payment_incidents"] == 1
assert payload["recent_referral_rewards"][0]["reward_points"] == 3500
assert {
"payment_intent",
"signup_trial",
"points_redemption",
"referral",
}.issubset({issue["category"] for issue in payload["issues"]})
def test_cities_endpoint_uses_denver_display_name_for_aurora_market(): def test_cities_endpoint_uses_denver_display_name_for_aurora_market():
response = client.get("/api/cities") response = client.get("/api/cities")
+16
View File
@@ -6,12 +6,14 @@ from web.core import GrantPointsRequest
from web.services.ops_api import ( from web.services.ops_api import (
extend_ops_subscription, extend_ops_subscription,
get_ops_analytics_funnel, get_ops_analytics_funnel,
get_ops_billing_risk,
get_ops_config, get_ops_config,
get_ops_sensitive_config, get_ops_sensitive_config,
get_ops_memberships_growth, get_ops_memberships_growth,
get_ops_memberships_overview, get_ops_memberships_overview,
get_ops_health_check, get_ops_health_check,
get_ops_logs, get_ops_logs,
get_ops_source_health,
get_ops_truth_history, get_ops_truth_history,
get_ops_weekly_leaderboard, get_ops_weekly_leaderboard,
get_ops_user_subscriptions, get_ops_user_subscriptions,
@@ -92,6 +94,11 @@ async def ops_payments(request: Request, limit: int = 50):
return list_ops_payments(request, limit=limit) return list_ops_payments(request, limit=limit)
@router.get("/api/ops/billing-risk")
async def ops_billing_risk(request: Request, days: int = 30, limit: int = 80):
return get_ops_billing_risk(request, days=days, limit=limit)
@router.post("/api/ops/users/grant-points") @router.post("/api/ops/users/grant-points")
async def ops_grant_points(request: Request, body: GrantPointsRequest): async def ops_grant_points(request: Request, body: GrantPointsRequest):
return grant_ops_points(request, body) return grant_ops_points(request, body)
@@ -213,6 +220,15 @@ async def ops_health_check(request: Request):
return get_ops_health_check(request) return get_ops_health_check(request)
@router.get("/api/ops/source-health")
async def ops_source_health(
request: Request,
cities: str = "",
limit: int = 80,
):
return get_ops_source_health(request, cities=cities, limit=limit)
@router.get("/api/ops/training/accuracy") @router.get("/api/ops/training/accuracy")
async def ops_training_accuracy(request: Request): async def ops_training_accuracy(request: Request):
return get_ops_training_accuracy(request) return get_ops_training_accuracy(request)
+1
View File
@@ -73,6 +73,7 @@ TRACKABLE_ANALYTICS_EVENTS = {
"trial_created", "trial_created",
"payment_start", "payment_start",
"payment_success", "payment_success",
"degraded_auth_profile",
"signup_completed", "signup_completed",
"dashboard_active", "dashboard_active",
"paywall_feature_clicked", "paywall_feature_clicked",
+45
View File
@@ -67,6 +67,41 @@ _OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = {
"expected_grace_sec": 900, "expected_grace_sec": 900,
"stale_after_sec": 3600, "stale_after_sec": 3600,
}, },
"ims": {
"label": "IMS",
"native_update_interval_sec": 600,
"fresh_window_sec": 900,
"expected_grace_sec": 600,
"stale_after_sec": 2700,
},
"madis": {
"label": "NOAA MADIS",
"native_update_interval_sec": 900,
"fresh_window_sec": 600,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"aeroweb": {
"label": "AEROWEB",
"native_update_interval_sec": 900,
"fresh_window_sec": 900,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"ncm": {
"label": "NCM",
"native_update_interval_sec": 900,
"fresh_window_sec": 900,
"expected_grace_sec": 900,
"stale_after_sec": 3600,
},
"singapore_mss": {
"label": "Singapore MSS",
"native_update_interval_sec": 600,
"fresh_window_sec": 900,
"expected_grace_sec": 600,
"stale_after_sec": 2700,
},
"metar": { "metar": {
"label": "METAR", "label": "METAR",
"native_update_interval_sec": 900, "native_update_interval_sec": 900,
@@ -117,6 +152,16 @@ def canonical_observation_source_code(value: Any) -> str:
return "cwa" return "cwa"
if "mgm" in raw: if "mgm" in raw:
return "mgm" return "mgm"
if "ims" in raw:
return "ims"
if "madis" in raw:
return "madis"
if "aeroweb" in raw:
return "aeroweb"
if "ncm" in raw:
return "ncm"
if "singapore_mss" in raw or raw == "mss":
return "singapore_mss"
if "noaa" in raw: if "noaa" in raw:
return "noaa" return "noaa"
if "wunderground" in raw or raw == "wu": if "wunderground" in raw or raw == "wu":
+669 -21
View File
@@ -3,13 +3,18 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import Any, Dict from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
import requests as _requests import requests as _requests
from src.database.db_manager import DBManager from src.database.db_manager import DBManager
from src.utils.runtime_secrets import get_runtime_secret, get_runtime_secret_status from src.utils.runtime_secrets import get_runtime_secret, get_runtime_secret_status
from web.services.observation_freshness import (
build_observation_freshness,
canonical_observation_source_code,
)
from web.core import GrantPointsRequest from web.core import GrantPointsRequest
import web.routes as legacy_routes import web.routes as legacy_routes
@@ -20,6 +25,104 @@ def _require_ops(request: Request) -> Dict[str, Any] | None:
return legacy_routes._require_ops_admin(request) return legacy_routes._require_ops_admin(request)
def _parse_iso_datetime(value: Any) -> Optional[datetime]:
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except Exception:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _to_utc_iso(value: datetime) -> str:
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _supabase_rest_rows(
table: str,
params: Dict[str, Any],
*,
timeout: int = 10,
) -> List[Dict[str, Any]]:
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not supabase_url or not service_role_key:
raise HTTPException(status_code=503, detail="Supabase not configured")
headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
}
resp = _requests.get(
f"{supabase_url}/rest/v1/{table}",
headers=headers,
params=params,
timeout=timeout,
)
if not resp.ok:
raise HTTPException(
status_code=502,
detail=f"Supabase query failed for {table}: {resp.status_code}",
)
rows = resp.json() if resp.content else []
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _payment_explorer_url(chain: Any, tx_hash: Any) -> str:
tx = str(tx_hash or "").strip()
if not tx:
return ""
chain_text = str(chain or "").strip().lower()
base = "https://etherscan.io" if "eth" in chain_text else "https://polygonscan.com"
return f"{base}/tx/{tx}"
def _app_analytics_actor_key(row: Dict[str, Any]) -> str:
payload = row.get("payload")
payload = payload if isinstance(payload, dict) else {}
user_id = str(row.get("user_id") or payload.get("user_id") or "").strip().lower()
client_id = str(row.get("client_id") or "").strip()
session_id = str(row.get("session_id") or "").strip()
if user_id:
return f"user:{user_id}"
if client_id:
return f"client:{client_id}"
if session_id:
return f"session:{session_id}"
return f"event:{row.get('id')}"
def _risk_issue(
*,
category: str,
severity: str,
title: str,
detail: str,
user_id: Any = "",
created_at: Any = "",
reference: Any = "",
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
return {
"category": category,
"severity": severity,
"title": title,
"detail": detail,
"user_id": str(user_id or ""),
"created_at": str(created_at or ""),
"reference": str(reference or ""),
"payload": payload or {},
}
def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]: def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str, Any]:
_require_ops(request) _require_ops(request)
db = DBManager() db = DBManager()
@@ -321,35 +424,351 @@ def list_ops_payments(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""List successful payment records from Supabase.""" """List successful payment records from Supabase."""
_require_ops(request) _require_ops(request)
import os
import requests as _requests
supabase_url = str(os.getenv("SUPABASE_URL") or "").strip().rstrip("/")
service_role_key = str(os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "").strip()
if not supabase_url or not service_role_key:
raise HTTPException(status_code=503, detail="Supabase not configured")
headers = {
"apikey": service_role_key,
"Authorization": f"Bearer {service_role_key}",
}
safe_limit = max(1, min(int(limit or 50), 200)) safe_limit = max(1, min(int(limit or 50), 200))
resp = _requests.get( rows = _supabase_rest_rows(
f"{supabase_url}/rest/v1/payments", "payments",
headers=headers, {
params={
"select": "id,user_id,amount,currency,chain,tx_hash,status,created_at", "select": "id,user_id,amount,currency,chain,tx_hash,status,created_at",
"order": "created_at.desc", "order": "created_at.desc",
"limit": str(safe_limit), "limit": str(safe_limit),
}, },
timeout=10,
) )
rows = resp.json() if resp.ok and resp.content else []
if not isinstance(rows, list):
rows = []
return {"payments": rows, "total": len(rows)} return {"payments": rows, "total": len(rows)}
def get_ops_billing_risk(
request: Request,
days: int = 30,
limit: int = 80,
) -> Dict[str, Any]:
"""Summarize trial, payment, referral, and points risk signals for ops."""
_require_ops(request)
db = DBManager()
now = datetime.now(timezone.utc)
safe_days = max(1, min(int(days or 30), 120))
safe_limit = max(10, min(int(limit or 80), 200))
since_dt = now - timedelta(days=safe_days)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
query_errors: List[Dict[str, str]] = []
def collect(table: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
try:
return _supabase_rest_rows(table, params)
except Exception as exc:
query_errors.append({"table": table, "error": str(exc)[:180]})
return []
intents = collect(
"payment_intents",
{
"select": (
"id,user_id,plan_code,chain_id,status,expires_at,tx_hash,"
"metadata,created_at,updated_at"
),
"order": "updated_at.desc",
"limit": str(max(safe_limit * 3, 100)),
},
)
referral_attributions = collect(
"referral_attributions",
{
"select": (
"id,referrer_user_id,referred_user_id,code,status,"
"converted_payment_intent_id,converted_tx_hash,converted_at,"
"created_at,updated_at"
),
"order": "updated_at.desc",
"limit": str(safe_limit),
},
)
referral_rewards = collect(
"referral_rewards",
{
"select": (
"id,referral_attribution_id,referrer_user_id,referred_user_id,"
"payment_intent_id,tx_hash,reward_days,reward_points,created_at"
),
"order": "created_at.desc",
"limit": str(safe_limit),
},
)
trial_claims = collect(
"trial_claims",
{
"select": "id,user_id,email,telegram_user_id,claimed_at,created_at",
"order": "created_at.desc",
"limit": str(min(safe_limit, 80)),
},
)
issues: List[Dict[str, Any]] = []
stuck_intents: List[Dict[str, Any]] = []
points_issues: List[Dict[str, Any]] = []
for intent in intents:
status = str(intent.get("status") or "").strip().lower()
updated_at = _parse_iso_datetime(intent.get("updated_at"))
created_at = _parse_iso_datetime(intent.get("created_at"))
expires_at = _parse_iso_datetime(intent.get("expires_at"))
age_min = (
int((now - (updated_at or created_at or now)).total_seconds() // 60)
if (updated_at or created_at)
else 0
)
intent_id = str(intent.get("id") or "")
user_id = str(intent.get("user_id") or "")
metadata = intent.get("metadata")
metadata = metadata if isinstance(metadata, dict) else {}
redemption = metadata.get("points_redemption")
redemption = redemption if isinstance(redemption, dict) else {}
if status == "submitted" and age_min >= 10:
row = {
"id": intent_id,
"user_id": user_id,
"plan_code": intent.get("plan_code"),
"status": status,
"age_min": age_min,
"tx_hash": intent.get("tx_hash"),
"updated_at": intent.get("updated_at"),
}
stuck_intents.append(row)
issues.append(
_risk_issue(
category="payment_intent",
severity="high",
title="Submitted intent 超过 10 分钟未确认",
detail=f"{intent_id} 已提交 {age_min} 分钟,可能需要补单或检查确认循环。",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
elif status == "created" and expires_at and expires_at < now:
row = {
"id": intent_id,
"user_id": user_id,
"plan_code": intent.get("plan_code"),
"status": status,
"expires_at": intent.get("expires_at"),
"age_min": age_min,
}
stuck_intents.append(row)
issues.append(
_risk_issue(
category="payment_intent",
severity="medium",
title="Created intent 已过期但未关闭",
detail=f"{intent_id} 已过期,用户可能离开支付流程。",
user_id=user_id,
created_at=intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
if bool(redemption.get("applied")):
planned = int(redemption.get("points_to_consume") or 0)
consumed = bool(redemption.get("consumed"))
consumed_points = int(redemption.get("consumed_points") or 0)
if status == "confirmed" and not consumed:
row = {
"intent_id": intent_id,
"user_id": user_id,
"status": status,
"planned_points": planned,
"consumed_points": consumed_points,
"updated_at": intent.get("updated_at"),
}
points_issues.append(row)
issues.append(
_risk_issue(
category="points_redemption",
severity="high",
title="订单已确认但积分未扣减",
detail=f"{intent_id} 标记使用积分,但 confirmed metadata 未显示 consumed。",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
elif status == "confirmed" and planned > 0 and 0 < consumed_points < planned:
row = {
"intent_id": intent_id,
"user_id": user_id,
"status": status,
"planned_points": planned,
"consumed_points": consumed_points,
"updated_at": intent.get("updated_at"),
}
points_issues.append(row)
issues.append(
_risk_issue(
category="points_redemption",
severity="medium",
title="积分抵扣只扣了部分积分",
detail=f"{intent_id} 计划扣 {planned},实际扣 {consumed_points}",
user_id=user_id,
created_at=intent.get("updated_at") or intent.get("created_at"),
reference=intent_id,
payload=row,
)
)
reward_by_attribution = {
str(row.get("referral_attribution_id") or ""): row
for row in referral_rewards
if row.get("referral_attribution_id") is not None
}
monthly_cap_hits: List[Dict[str, Any]] = []
referral_settlement_issues: List[Dict[str, Any]] = []
for attribution in referral_attributions:
status = str(attribution.get("status") or "").strip().lower()
attribution_id = str(attribution.get("id") or "")
updated_at = _parse_iso_datetime(
attribution.get("updated_at") or attribution.get("converted_at") or attribution.get("created_at")
)
if status == "capped" and (not updated_at or updated_at >= month_start):
row = {
"id": attribution_id,
"code": attribution.get("code"),
"referrer_user_id": attribution.get("referrer_user_id"),
"referred_user_id": attribution.get("referred_user_id"),
"updated_at": attribution.get("updated_at"),
}
monthly_cap_hits.append(row)
issues.append(
_risk_issue(
category="referral",
severity="medium",
title="邀请奖励月度上限命中",
detail=f"邀请码 {attribution.get('code') or ''} 的推荐奖励已被月度上限拦截。",
user_id=attribution.get("referrer_user_id"),
created_at=attribution.get("updated_at") or attribution.get("created_at"),
reference=attribution_id,
payload=row,
)
)
if status == "converted" and attribution_id not in reward_by_attribution:
row = {
"id": attribution_id,
"code": attribution.get("code"),
"referrer_user_id": attribution.get("referrer_user_id"),
"referred_user_id": attribution.get("referred_user_id"),
"converted_payment_intent_id": attribution.get("converted_payment_intent_id"),
"converted_at": attribution.get("converted_at"),
}
referral_settlement_issues.append(row)
issues.append(
_risk_issue(
category="referral",
severity="high",
title="推荐已转化但没有奖励记录",
detail=f"归因 {attribution_id} 已 converted,但 referral_rewards 未找到对应记录。",
user_id=attribution.get("referrer_user_id"),
created_at=attribution.get("converted_at") or attribution.get("updated_at"),
reference=attribution_id,
payload=row,
)
)
events = db.list_app_analytics_events(limit=20000, since_iso=since_dt.isoformat())
signup_rows = [
row
for row in events
if str(row.get("event_type") or "").strip().lower()
in {"signup_success", "signup_completed"}
]
trial_actor_keys = {
_app_analytics_actor_key(row)
for row in events
if str(row.get("event_type") or "").strip().lower() == "trial_created"
}
trial_gaps: List[Dict[str, Any]] = []
for row in signup_rows[:300]:
actor_key = _app_analytics_actor_key(row)
if actor_key in trial_actor_keys:
continue
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
gap = {
"event_id": row.get("id"),
"actor_key": actor_key,
"user_id": row.get("user_id") or payload.get("user_id"),
"created_at": row.get("created_at"),
}
trial_gaps.append(gap)
if len(trial_gaps) <= 20:
issues.append(
_risk_issue(
category="signup_trial",
severity="high",
title="注册成功后未记录试用开通",
detail="该用户进入 signup_success,但同窗口内没有 trial_created 事件。",
user_id=gap.get("user_id"),
created_at=gap.get("created_at"),
reference=str(gap.get("event_id") or ""),
payload=gap,
)
)
payment_incidents = db.list_payment_audit_events(
limit=safe_limit,
event_type="payment_intent_failed",
)
unresolved_incidents = [
item
for item in payment_incidents
if not str((item.get("payload") or {}).get("resolved_at") or "").strip()
]
issues.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
recent_rewards = [
{
"id": row.get("id"),
"referral_attribution_id": row.get("referral_attribution_id"),
"referrer_user_id": row.get("referrer_user_id"),
"referred_user_id": row.get("referred_user_id"),
"payment_intent_id": row.get("payment_intent_id"),
"reward_points": int(row.get("reward_points") or 0),
"reward_days": int(row.get("reward_days") or 0),
"tx_hash": row.get("tx_hash"),
"explorer_url": _payment_explorer_url("polygon", row.get("tx_hash")),
"created_at": row.get("created_at"),
}
for row in referral_rewards[:safe_limit]
]
return {
"checked_at": _to_utc_iso(now),
"window_days": safe_days,
"summary": {
"issues": len(issues),
"stuck_intents": len(stuck_intents),
"trial_gaps": len(trial_gaps),
"payment_incidents": len(unresolved_incidents),
"points_discount_issues": len(points_issues),
"referral_settlement_issues": len(referral_settlement_issues),
"monthly_cap_hits": len(monthly_cap_hits),
"recent_referral_rewards": len(recent_rewards),
"recent_trial_claims": len(trial_claims),
},
"issues": issues[:safe_limit],
"stuck_intents": stuck_intents[:safe_limit],
"trial_gaps": trial_gaps[:safe_limit],
"payment_incidents": unresolved_incidents[:safe_limit],
"points_discount_issues": points_issues[:safe_limit],
"referral_settlement_issues": referral_settlement_issues[:safe_limit],
"monthly_cap_hits": monthly_cap_hits[:safe_limit],
"recent_referral_rewards": recent_rewards,
"recent_trial_claims": trial_claims,
"query_errors": query_errors,
}
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]: def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
admin = _require_ops(request) or {} admin = _require_ops(request) or {}
db = DBManager() db = DBManager()
@@ -987,6 +1406,235 @@ def get_ops_logs(
} }
def _safe_source_text(value: Any) -> str:
return str(value or "").strip()
def _source_observed_at(source: dict[str, Any]) -> Any:
for key in (
"observed_at",
"obs_time",
"report_time",
"observation_time",
"observation_time_local",
"time",
"timestamp",
):
value = source.get(key)
if _safe_source_text(value):
return value
return None
def _source_code(source: dict[str, Any], fallback: str = "") -> str:
for key in ("source_code", "source", "provider_code", "network_provider"):
value = _safe_source_text(source.get(key))
if value:
return canonical_observation_source_code(value)
return canonical_observation_source_code(fallback)
def _source_label(source: dict[str, Any], code: str, fallback: str = "") -> str:
for key in ("source_label", "station_label", "station_name", "label", "provider_label"):
value = _safe_source_text(source.get(key))
if value:
return value
return fallback or code.upper()
def _source_health_entry(
*,
city: str,
role: str,
source: dict[str, Any],
fallback_code: str = "",
fallback_label: str = "",
) -> dict[str, Any] | None:
if not isinstance(source, dict) or not source:
return None
code = _source_code(source, fallback_code)
label = _source_label(source, code, fallback_label)
observed_at = _source_observed_at(source)
freshness = source.get("freshness") if isinstance(source.get("freshness"), dict) else None
if freshness:
status = _safe_source_text(freshness.get("freshness_status")) or "unknown"
age_sec = freshness.get("age_sec")
observed_at = freshness.get("observed_at") or freshness.get("observed_at_local") or observed_at
expected_next = freshness.get("expected_next_update_at")
reason = freshness.get("freshness_reason")
else:
age_min = source.get("obs_age_min")
try:
age_min_int = int(age_min) if age_min is not None else None
except Exception:
age_min_int = None
freshness = build_observation_freshness(
source_code=code,
source_label=label,
observed_at=observed_at,
age_min=age_min_int,
)
status = str(freshness.get("freshness_status") or "unknown")
age_sec = freshness.get("age_sec")
expected_next = freshness.get("expected_next_update_at")
reason = freshness.get("freshness_reason")
return {
"city": city,
"role": role,
"source_code": code,
"source_label": label,
"station_code": source.get("station_code") or source.get("icao"),
"station_label": source.get("station_label") or source.get("station_name"),
"status": status,
"reason": reason,
"age_sec": age_sec,
"age_min": round(float(age_sec) / 60, 1) if isinstance(age_sec, (int, float)) else None,
"observed_at": observed_at,
"expected_next_update_at": expected_next,
"temp": source.get("temp"),
}
def _expected_city_source_codes(city: str, payload: dict[str, Any]) -> list[str]:
city_key = str(city or "").strip().lower().replace(" ", "")
expected: list[str] = []
if city_key in {"ankara", "istanbul"}:
expected.append("mgm")
if city_key == "amsterdam":
expected.append("knmi")
if city_key in {"telaviv", "telavivyafo"}:
expected.append("ims")
provider = canonical_observation_source_code(payload.get("official_network_source"))
if provider and provider not in {"metar", "none"}:
expected.append(provider)
return list(dict.fromkeys(expected))
def _collect_city_source_health(city: str, entry: dict[str, Any] | None) -> dict[str, Any]:
payload = (entry or {}).get("payload") if isinstance(entry, dict) else {}
if not isinstance(payload, dict):
payload = {}
sources: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
def add(role: str, source: Any, fallback_code: str = "", fallback_label: str = "") -> None:
item = _source_health_entry(
city=city,
role=role,
source=source if isinstance(source, dict) else {},
fallback_code=fallback_code,
fallback_label=fallback_label,
)
if not item:
return
key = (str(item.get("role")), str(item.get("source_code")))
if key in seen:
return
seen.add(key)
sources.append(item)
add("settlement", payload.get("current"), fallback_label="Settlement")
add("airport_metar", payload.get("airport_current"), fallback_code="metar", fallback_label="METAR")
add("airport_primary", payload.get("airport_primary"), fallback_label="Airport station")
official = payload.get("official") if isinstance(payload.get("official"), dict) else {}
add("official_airport_primary", official.get("airport_primary"), fallback_label="Official airport station")
add("official_airport_primary", official.get("airport_primary_current"), fallback_label="Official airport station")
mgm = payload.get("mgm") if isinstance(payload.get("mgm"), dict) else {}
if mgm:
mgm_current = mgm.get("current") if isinstance(mgm.get("current"), dict) else {}
add(
"official_network",
{
**mgm_current,
"source_code": "mgm",
"source_label": "MGM",
"obs_time": mgm.get("obs_time") or mgm_current.get("time"),
},
fallback_code="mgm",
fallback_label="MGM",
)
for nearby in payload.get("official_nearby") or payload.get("mgm_nearby") or []:
if isinstance(nearby, dict):
add("nearby_official", nearby, fallback_label="Nearby official")
expected_codes = _expected_city_source_codes(city, payload)
present_codes = {str(item.get("source_code") or "") for item in sources}
for code in expected_codes:
if code and code not in present_codes:
sources.append(
{
"city": city,
"role": "expected_source",
"source_code": code,
"source_label": code.upper(),
"status": "missing",
"reason": "expected_source_not_present_in_cached_detail",
"age_sec": None,
"age_min": None,
"observed_at": None,
"expected_next_update_at": None,
"temp": None,
}
)
priority = {"stale": 4, "missing": 4, "delayed": 3, "unknown": 2, "expected_wait": 1, "fresh": 0}
worst = max(sources, key=lambda item: priority.get(str(item.get("status") or ""), 2), default=None)
full_age_sec = (
round(max(0.0, __import__("time").time() - float(entry.get("updated_at_ts") or 0.0)), 1)
if entry
else None
)
return {
"city": city,
"cache_exists": bool(entry),
"cache_updated_at": entry.get("updated_at") if entry else None,
"cache_age_sec": full_age_sec,
"source_count": len(sources),
"worst_status": str(worst.get("status") if worst else "missing"),
"sources": sources,
}
def get_ops_source_health(
request: Request,
cities: str = "",
limit: int = 80,
) -> dict[str, Any]:
_require_ops(request)
requested = legacy_routes._normalize_city_list(cities) if cities else []
if requested:
selected = requested
else:
all_cities = getattr(legacy_routes, "CITIES", {})
selected = list(all_cities.keys()) if isinstance(all_cities, dict) else []
safe_limit = max(1, min(int(limit or 80), 200))
rows = []
for city in selected[:safe_limit]:
entry = legacy_routes._CACHE_DB.get_city_cache("full", city)
if not entry:
entry = legacy_routes._CACHE_DB.get_city_cache("panel", city)
rows.append(_collect_city_source_health(city, entry))
status_counts: dict[str, int] = {}
for row in rows:
for source in row.get("sources") or []:
status = str(source.get("status") or "unknown")
status_counts[status] = status_counts.get(status, 0) + 1
return {
"checked_at": __import__("datetime").datetime.utcnow().isoformat() + "Z",
"cities": rows,
"status_counts": status_counts,
"total_cities": len(rows),
}
def get_ops_health_check(request: Request) -> dict[str, Any]: def get_ops_health_check(request: Request) -> dict[str, Any]:
_require_ops(request) _require_ops(request)
import os import os