Improve ops monitoring and chart loading
This commit is contained in:
@@ -26,6 +26,22 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
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, {
|
||||
includeSupabaseIdentity: false,
|
||||
});
|
||||
@@ -34,7 +50,7 @@ export async function POST(req: NextRequest) {
|
||||
const res = await fetch(`${API_BASE}/api/analytics/events`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body ?? {}),
|
||||
body: JSON.stringify(enrichedBody),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -27,6 +27,56 @@ import {
|
||||
|
||||
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 = {
|
||||
email: string | null;
|
||||
userId: string;
|
||||
@@ -77,7 +127,7 @@ async function getVerifiedBearerIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
function degradedAuthProfileResponse({
|
||||
async function degradedAuthProfileResponse({
|
||||
email,
|
||||
reason,
|
||||
req,
|
||||
@@ -94,6 +144,12 @@ function degradedAuthProfileResponse({
|
||||
readEntitlementSnapshot(req, userId),
|
||||
);
|
||||
if (snapshotPayload) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: snapshotPayload.email || email,
|
||||
reason,
|
||||
responseMode: "snapshot",
|
||||
userId,
|
||||
});
|
||||
const snapshotResponse = NextResponse.json({
|
||||
...snapshotPayload,
|
||||
email: snapshotPayload.email || email,
|
||||
@@ -102,6 +158,12 @@ function degradedAuthProfileResponse({
|
||||
return applyAuthResponseCookies(snapshotResponse, response);
|
||||
}
|
||||
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email,
|
||||
reason,
|
||||
responseMode: "degraded",
|
||||
userId,
|
||||
});
|
||||
const degraded = NextResponse.json({
|
||||
authenticated: true,
|
||||
user_id: userId,
|
||||
@@ -135,13 +197,20 @@ function subscriptionRequiredAuthProfileResponse({
|
||||
return applyAuthResponseCookies(inactive, response);
|
||||
}
|
||||
|
||||
function unauthenticatedAuthProfileResponse({
|
||||
async function unauthenticatedAuthProfileResponse({
|
||||
reason,
|
||||
req,
|
||||
response,
|
||||
}: {
|
||||
reason: string;
|
||||
req: NextRequest;
|
||||
response: NextResponse | null;
|
||||
}) {
|
||||
await trackAuthDiagnosticEvent(req, {
|
||||
email: null,
|
||||
reason,
|
||||
responseMode: "anonymous",
|
||||
});
|
||||
const anonymous = NextResponse.json(
|
||||
{
|
||||
authenticated: false,
|
||||
@@ -409,6 +478,7 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
return unauthenticatedAuthProfileResponse({
|
||||
reason: String(error),
|
||||
req,
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
function shouldFetchCityDetailForChart({
|
||||
city,
|
||||
documentHidden,
|
||||
isChartVisible,
|
||||
}: {
|
||||
city: string;
|
||||
documentHidden: boolean;
|
||||
isChartVisible: boolean;
|
||||
}) {
|
||||
return Boolean(city) && isChartVisible && !documentHidden;
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────
|
||||
|
||||
export function LiveTemperatureThresholdChart({
|
||||
@@ -141,11 +153,18 @@ export function LiveTemperatureThresholdChart({
|
||||
const [userToggledKeys, setUserToggledKeys] = useState<Record<string, boolean>>({});
|
||||
const [liveTemp, setLiveTemp] = useState<number | null>(null);
|
||||
const [isHourlyLoading, setIsHourlyLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const [detailRetryNonce, setDetailRetryNonce] = useState(0);
|
||||
const [showingStaleDetail, setShowingStaleDetail] = useState(false);
|
||||
const hasLoadedHourlyDetailRef = useRef(false);
|
||||
const chartVisibilityRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPatchAtRef = useRef<number>(Date.now());
|
||||
const lastAppliedPatchRevisionRef = useRef<number>(0);
|
||||
const lastProbabilityRefreshAtRef = useRef<number>(0);
|
||||
const localDayRolloverFetchDateRef = useRef<string>("");
|
||||
const [isChartVisible, setIsChartVisible] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
|
||||
const [showRunwayDetails, setShowRunwayDetails] = useState<boolean>(true);
|
||||
const [refAreaLeft, setRefAreaLeft] = useState<number | null>(null);
|
||||
@@ -167,6 +186,9 @@ export function LiveTemperatureThresholdChart({
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
setLiveTemp(null);
|
||||
setIsHourlyLoading(Boolean(city));
|
||||
setDetailError(null);
|
||||
setDetailRetryNonce(0);
|
||||
setShowingStaleDetail(false);
|
||||
hasLoadedHourlyDetailRef.current = false;
|
||||
lastPatchAtRef.current = Date.now();
|
||||
lastAppliedPatchRevisionRef.current = 0;
|
||||
@@ -175,6 +197,23 @@ export function LiveTemperatureThresholdChart({
|
||||
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
|
||||
}, [city]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = chartVisibilityRef.current;
|
||||
if (!node || typeof IntersectionObserver === "undefined") {
|
||||
setIsChartVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsChartVisible(entry.isIntersecting || entry.intersectionRatio > 0);
|
||||
},
|
||||
{ root: null, rootMargin: "160px 0px", threshold: 0.01 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentCityLocalDate(formatCityLocalDate(row?.tz_offset_seconds));
|
||||
const id = setInterval(() => {
|
||||
@@ -188,30 +227,47 @@ export function LiveTemperatureThresholdChart({
|
||||
setIsHourlyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldFetchCityDetailForChart({
|
||||
city,
|
||||
documentHidden:
|
||||
typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||
isChartVisible,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${city}:${targetResolution}`;
|
||||
// Check in-memory cache first
|
||||
let cached = _hourlyCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
// Fallback to session cache
|
||||
const sessionEntry = readSessionCache(cacheKey);
|
||||
if (!cached || Date.now() - Number(cached.ts || 0) >= HOURLY_CACHE_TTL_MS) {
|
||||
const sessionEntry = readSessionCache(cacheKey, { allowStale: true });
|
||||
if (sessionEntry) {
|
||||
cached = sessionEntry;
|
||||
_hourlyCache.set(cacheKey, sessionEntry);
|
||||
}
|
||||
}
|
||||
const cacheAge = cached ? Date.now() - Number(cached.ts || 0) : Number.POSITIVE_INFINITY;
|
||||
const hasFreshCache = cached && cacheAge >= 0 && cacheAge < HOURLY_CACHE_TTL_MS;
|
||||
|
||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||
if (cached) {
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(cached.data);
|
||||
setShowingStaleDetail(!hasFreshCache);
|
||||
}
|
||||
|
||||
if (hasFreshCache) {
|
||||
setIsHourlyLoading(false);
|
||||
setDetailError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLoadedHourlyDetailRef.current) {
|
||||
if (!cached && !hasLoadedHourlyDetailRef.current) {
|
||||
setHourly(seedHourlyForecastFromRow(row));
|
||||
setShowingStaleDetail(false);
|
||||
}
|
||||
setIsHourlyLoading(!hasLoadedHourlyDetailRef.current);
|
||||
setIsHourlyLoading(true);
|
||||
let cancelled = false;
|
||||
|
||||
// Prioritize active slots, stagger/delay background slots to optimize load performance
|
||||
@@ -220,11 +276,19 @@ export function LiveTemperatureThresholdChart({
|
||||
const timer = setTimeout(() => {
|
||||
fetchHourlyForecastForCity(city, { resolution: targetResolution })
|
||||
.then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
if (cancelled) return;
|
||||
if (!data) {
|
||||
setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
return;
|
||||
}
|
||||
hasLoadedHourlyDetailRef.current = true;
|
||||
setHourly(data);
|
||||
setDetailError(null);
|
||||
setShowingStaleDetail(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetailError(isEn ? "Data temporarily unavailable." : "数据暂不可用");
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsHourlyLoading(false);
|
||||
});
|
||||
@@ -234,7 +298,7 @@ export function LiveTemperatureThresholdChart({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [city, row, isActive, slotIndex, targetResolution]);
|
||||
}, [city, row, isActive, slotIndex, targetResolution, isChartVisible, detailRetryNonce, isEn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestPatch || latestPatch.revision <= lastAppliedPatchRevisionRef.current) return;
|
||||
@@ -636,6 +700,12 @@ export function LiveTemperatureThresholdChart({
|
||||
}));
|
||||
}, [isSeriesVisible]);
|
||||
|
||||
const handleRetryDetail = useCallback(() => {
|
||||
setDetailError(null);
|
||||
setIsHourlyLoading(true);
|
||||
setDetailRetryNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const panelTitle = row ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
@@ -743,7 +813,7 @@ export function LiveTemperatureThresholdChart({
|
||||
actions={timeframeActions}
|
||||
className={PEAK_GLOW_PANEL_CLASS[peakGlow.state]}
|
||||
>
|
||||
<div className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
|
||||
<div ref={chartVisibilityRef} className={clsx("flex h-full flex-col", compact ? "min-h-0" : "min-h-[300px]")}>
|
||||
<TemperatureStatsBars
|
||||
isEn={isEn}
|
||||
compact={compact}
|
||||
@@ -790,6 +860,8 @@ export function LiveTemperatureThresholdChart({
|
||||
hasRunwayData={hasRunwayData}
|
||||
showRunwayDetails={showRunwayDetails}
|
||||
isHourlyLoading={isHourlyLoading}
|
||||
detailError={detailError}
|
||||
showingStaleDetail={showingStaleDetail}
|
||||
refAreaLeft={refAreaLeft}
|
||||
refAreaRight={refAreaRight}
|
||||
onMouseDown={handleMouseDown}
|
||||
@@ -799,6 +871,7 @@ export function LiveTemperatureThresholdChart({
|
||||
isSeriesVisible={isSeriesVisible}
|
||||
onSeriesToggle={handleSeriesToggle}
|
||||
onShowRunwayDetailsChange={setShowRunwayDetails}
|
||||
onRetryDetail={handleRetryDetail}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -822,6 +895,7 @@ export const __getLiveObservationLabelsForTest = getLiveObservationLabels;
|
||||
export const __getObservationDisplayMetricsForTest = getObservationDisplayMetrics;
|
||||
export const __getPeakGlowStateForTest = getPeakGlowState;
|
||||
export const __getWundergroundDailyHighForTest = getWundergroundDailyHigh;
|
||||
export const __shouldFetchCityDetailForChartForTest = shouldFetchCityDetailForChart;
|
||||
export const __shouldPollLiveChartForTest = shouldPollLiveChart;
|
||||
export const __mergePatchIntoHourlyForTest = mergePatchIntoHourly;
|
||||
export const __selectDisplayRunwayTempForTest = selectDisplayRunwayTemp;
|
||||
|
||||
@@ -110,6 +110,8 @@ function TemperatureChartCanvasComponent({
|
||||
hasRunwayData,
|
||||
showRunwayDetails,
|
||||
isHourlyLoading,
|
||||
detailError,
|
||||
showingStaleDetail,
|
||||
refAreaLeft,
|
||||
refAreaRight,
|
||||
onMouseDown,
|
||||
@@ -119,6 +121,7 @@ function TemperatureChartCanvasComponent({
|
||||
isSeriesVisible,
|
||||
onSeriesToggle,
|
||||
onShowRunwayDetailsChange,
|
||||
onRetryDetail,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
compact: boolean;
|
||||
@@ -134,6 +137,8 @@ function TemperatureChartCanvasComponent({
|
||||
hasRunwayData: boolean;
|
||||
showRunwayDetails: boolean;
|
||||
isHourlyLoading: boolean;
|
||||
detailError?: string | null;
|
||||
showingStaleDetail?: boolean;
|
||||
refAreaLeft: number | null;
|
||||
refAreaRight: number | null;
|
||||
onMouseDown: (event: any) => void;
|
||||
@@ -143,6 +148,7 @@ function TemperatureChartCanvasComponent({
|
||||
isSeriesVisible: (seriesKey: string) => boolean;
|
||||
onSeriesToggle: (seriesKey: string) => void;
|
||||
onShowRunwayDetailsChange: (value: boolean) => void;
|
||||
onRetryDetail?: () => void;
|
||||
}) {
|
||||
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
||||
@@ -215,6 +221,9 @@ function TemperatureChartCanvasComponent({
|
||||
const shouldRenderChart = canRenderChart && hasDrawableChartContent;
|
||||
const shouldShowEmptyState = Boolean(row?.city) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundRefresh = isHourlyLoading && hasDrawableChartContent;
|
||||
const shouldShowUnavailableState = Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && !hasDrawableChartContent;
|
||||
const shouldShowBackgroundError =
|
||||
Boolean(row?.city) && Boolean(detailError) && !isHourlyLoading && hasDrawableChartContent;
|
||||
|
||||
return (
|
||||
<div className={clsx("relative flex flex-1 flex-col p-2", compact ? "min-h-[120px]" : "min-h-[240px]")}>
|
||||
@@ -420,7 +429,21 @@ function TemperatureChartCanvasComponent({
|
||||
))}
|
||||
</ReComposedChart>
|
||||
)}
|
||||
{shouldShowEmptyState && (
|
||||
{shouldShowUnavailableState && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center px-4 text-center">
|
||||
<div className="max-w-[260px] rounded border border-amber-200 bg-amber-50/95 px-3 py-2 text-[11px] font-semibold text-amber-700 shadow-sm">
|
||||
<div>{isEn ? "Data temporarily unavailable" : "数据暂不可用"}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
className="mt-2 rounded border border-amber-300 bg-white px-2 py-1 text-[10px] font-bold text-amber-700 shadow-sm transition-colors hover:bg-amber-100"
|
||||
>
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowEmptyState && !shouldShowUnavailableState && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center px-4 text-center">
|
||||
<div className="rounded border border-slate-200 bg-white/90 px-3 py-2 text-[11px] font-semibold text-slate-500 shadow-sm">
|
||||
{isEn ? "No drawable chart data yet" : "暂无可绘制图表数据"}
|
||||
@@ -428,6 +451,18 @@ function TemperatureChartCanvasComponent({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowBackgroundError && (
|
||||
<div className="absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-amber-200 bg-amber-50/95 px-2 py-1 text-[10px] font-semibold text-amber-700 shadow-sm">
|
||||
<span>{showingStaleDetail ? (isEn ? "Showing cache" : "显示缓存") : (isEn ? "Update failed" : "更新失败")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetryDetail}
|
||||
className="rounded border border-amber-300 bg-white px-1.5 py-0.5 font-bold transition-colors hover:bg-amber-100"
|
||||
>
|
||||
{isEn ? "Retry" : "重试"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowBackgroundRefresh && (
|
||||
<div className="pointer-events-none absolute right-3 top-12 z-10 inline-flex items-center gap-1.5 rounded border border-slate-200 bg-white/80 px-2 py-1 text-[10px] font-semibold text-slate-500 shadow-sm backdrop-blur-[1px]">
|
||||
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-slate-200 border-t-blue-500" />
|
||||
|
||||
@@ -5,11 +5,17 @@ import {
|
||||
DASHBOARD_REFRESH_POLICY_SEC,
|
||||
} from "@/lib/refresh-policy";
|
||||
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import {
|
||||
__shouldFetchCityDetailForChartForTest,
|
||||
__shouldPollLiveChartForTest,
|
||||
} from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
||||
import {
|
||||
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
__readHourlyCacheEntryForTest,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
clearCityDetailCache,
|
||||
} from "@/components/dashboard/scan-terminal/temperature-chart-logic";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
@@ -79,6 +85,30 @@ export async function runTests() {
|
||||
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("IntersectionObserver") &&
|
||||
chartSource.includes("shouldFetchCityDetailForChart") &&
|
||||
chartSource.includes("isChartVisible"),
|
||||
"city detail prefetch should be gated to visible chart cards instead of every mounted slot",
|
||||
);
|
||||
assert(
|
||||
chartSource.includes("allowStale: true") &&
|
||||
chartCanvasSourceIncludes(chartSource, "数据暂不可用") &&
|
||||
chartCanvasSourceIncludes(chartSource, "handleRetryDetail"),
|
||||
"city detail charts should show stale cache first and expose a retryable unavailable state",
|
||||
);
|
||||
assert(
|
||||
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: true }) === true,
|
||||
"visible chart cards should fetch city detail",
|
||||
);
|
||||
assert(
|
||||
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: false, isChartVisible: false }) === false,
|
||||
"offscreen chart cards should not prefetch city detail",
|
||||
);
|
||||
assert(
|
||||
__shouldFetchCityDetailForChartForTest({ city: "paris", documentHidden: true, isChartVisible: true }) === false,
|
||||
"hidden browser tabs should not prefetch city detail",
|
||||
);
|
||||
|
||||
__resetHourlyDetailRequestQueueForTest();
|
||||
let activeRequests = 0;
|
||||
@@ -130,4 +160,63 @@ export async function runTests() {
|
||||
"city detail queue should resolve every queued request in order",
|
||||
);
|
||||
__resetHourlyDetailRequestQueueForTest();
|
||||
|
||||
const originalWindow = (globalThis as any).window;
|
||||
const originalSessionStorage = (globalThis as any).sessionStorage;
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as any).window = {};
|
||||
(globalThis as any).sessionStorage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
try {
|
||||
clearCityDetailCache();
|
||||
const cacheKey = "paris:10m";
|
||||
store.set(
|
||||
`polyweather_city_detail_v1:${cacheKey}`,
|
||||
JSON.stringify({
|
||||
ts: Date.now() - HOURLY_CACHE_TTL_MS - 1000,
|
||||
data: {
|
||||
forecastDaily: [],
|
||||
localDate: "2026-05-31",
|
||||
multiModelDaily: {},
|
||||
probabilities: null,
|
||||
temps: [21],
|
||||
times: ["00:00"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
__readHourlyCacheEntryForTest(cacheKey) === null,
|
||||
"stale city detail cache should not suppress a fresh revalidation",
|
||||
);
|
||||
assert(
|
||||
__readHourlyCacheEntryForTest(cacheKey, { allowStale: true })?.data?.temps[0] === 21,
|
||||
"stale city detail cache should still be available for immediate chart rendering",
|
||||
);
|
||||
} finally {
|
||||
clearCityDetailCache();
|
||||
(globalThis as any).window = originalWindow;
|
||||
(globalThis as any).sessionStorage = originalSessionStorage;
|
||||
}
|
||||
}
|
||||
|
||||
function chartCanvasSourceIncludes(source: string, pattern: string) {
|
||||
return source.includes(pattern) || fs.readFileSync(
|
||||
path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureChartCanvas.tsx"),
|
||||
"utf8",
|
||||
).includes(pattern);
|
||||
}
|
||||
|
||||
@@ -364,19 +364,50 @@ const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c
|
||||
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
||||
const SESSION_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||
|
||||
function readSessionCache(city: string): { ts: number; data: HourlyForecast } | null {
|
||||
type HourlyCacheEntry = { ts: number; data: HourlyForecast };
|
||||
|
||||
function isFreshHourlyCacheEntry(entry: HourlyCacheEntry | null | undefined) {
|
||||
return Boolean(entry && Date.now() - Number(entry.ts || 0) < SESSION_CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
function readSessionCache(
|
||||
city: string,
|
||||
options: { allowStale?: boolean } = {},
|
||||
): HourlyCacheEntry | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${SESSION_CACHE_PREFIX}${city}`);
|
||||
if (!raw) return null;
|
||||
const item = JSON.parse(raw);
|
||||
if (item && item.ts && Date.now() - item.ts < SESSION_CACHE_TTL_MS) {
|
||||
if (
|
||||
item &&
|
||||
item.ts &&
|
||||
(options.allowStale || Date.now() - item.ts < SESSION_CACHE_TTL_MS)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readHourlyCacheEntry(
|
||||
cacheKey: string,
|
||||
options: { allowStale?: boolean } = {},
|
||||
): HourlyCacheEntry | null {
|
||||
const cached = _hourlyCache.get(cacheKey);
|
||||
if (cached && (options.allowStale || isFreshHourlyCacheEntry(cached))) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const sessionEntry = readSessionCache(cacheKey, options);
|
||||
if (sessionEntry) {
|
||||
_hourlyCache.set(cacheKey, sessionEntry);
|
||||
return sessionEntry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeSessionCache(city: string, data: HourlyForecast) {
|
||||
if (typeof window === "undefined" || !data) return;
|
||||
try {
|
||||
@@ -436,6 +467,7 @@ function __resetHourlyDetailRequestQueueForTest() {
|
||||
}
|
||||
|
||||
const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest;
|
||||
const __readHourlyCacheEntryForTest = readHourlyCacheEntry;
|
||||
|
||||
function validNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
@@ -969,16 +1001,10 @@ async function fetchHourlyForecastForCity(
|
||||
const cacheKey = `${city}:${resParam}`;
|
||||
|
||||
if (!options.ignoreCache) {
|
||||
const cached = _hourlyCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.ts < HOURLY_CACHE_TTL_MS) {
|
||||
const cached = readHourlyCacheEntry(cacheKey);
|
||||
if (cached) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const sessionCached = readSessionCache(cacheKey);
|
||||
if (sessionCached) {
|
||||
_hourlyCache.set(cacheKey, sessionCached);
|
||||
return sessionCached.data;
|
||||
}
|
||||
}
|
||||
|
||||
const requestKey = options.ignoreCache ? `${city}:${resParam}:live` : `${city}:${resParam}`;
|
||||
@@ -2259,6 +2285,7 @@ export {
|
||||
HOURLY_DETAIL_REQUEST_TIMEOUT_MS,
|
||||
HOURLY_CACHE_TTL_MS,
|
||||
_hourlyCache,
|
||||
__readHourlyCacheEntryForTest,
|
||||
__resetHourlyDetailRequestQueueForTest,
|
||||
__runQueuedHourlyDetailRequestForTest,
|
||||
buildChartDomain,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const analyticsPage = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "analytics", "AnalyticsPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const overviewPage = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "overview", "OverviewPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const appAnalytics = fs.readFileSync(path.join(projectRoot, "lib", "app-analytics.ts"), "utf8");
|
||||
const analyticsRoute = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "analytics", "events", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const authMeRoute = fs.readFileSync(path.join(projectRoot, "app", "api", "auth", "me", "route.ts"), "utf8");
|
||||
|
||||
assert(
|
||||
opsApi.includes('"landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"') &&
|
||||
opsApi.includes("diagnostics?:") &&
|
||||
opsApi.includes("traffic?:") &&
|
||||
opsApi.includes("uniqueActors"),
|
||||
"ops funnel API client must preserve the full standard funnel and expose diagnostics/traffic dimensions",
|
||||
);
|
||||
assert(
|
||||
analyticsPage.includes("落地页访问") &&
|
||||
analyticsPage.includes("进入终端") &&
|
||||
analyticsPage.includes("注册成功") &&
|
||||
analyticsPage.includes("鉴权降级") &&
|
||||
analyticsPage.includes("来源与设备") &&
|
||||
analyticsPage.includes("paymentSuccess?.count") &&
|
||||
!analyticsPage.includes("总注册") &&
|
||||
!analyticsPage.includes("点击高级功能"),
|
||||
"ops analytics page must show the real funnel semantics instead of stale index-based labels",
|
||||
);
|
||||
assert(
|
||||
overviewPage.includes("stepByKey.payment_success?.count") &&
|
||||
!overviewPage.includes("steps[5]?.count"),
|
||||
"ops overview must derive paid conversion from payment_success by key, not from a brittle funnel index",
|
||||
);
|
||||
assert(
|
||||
appAnalytics.includes("referrer: document.referrer") &&
|
||||
appAnalytics.includes("device_type: getDeviceType()") &&
|
||||
analyticsRoute.includes("cf-ipcountry") &&
|
||||
analyticsRoute.includes("user_agent"),
|
||||
"client analytics events must carry source, country, and device metadata for acquisition analysis",
|
||||
);
|
||||
assert(
|
||||
authMeRoute.includes('event_type: "degraded_auth_profile"') &&
|
||||
authMeRoute.includes("response_mode") &&
|
||||
authMeRoute.includes("trackAuthDiagnosticEvent"),
|
||||
"auth/me fallback paths must emit degraded_auth_profile diagnostics for ops monitoring",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const paymentsPage = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const billingRiskRoute = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "ops", "billing-risk", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const paymentsRoute = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "ops", "payments", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
opsApi.includes("billingRisk") &&
|
||||
opsApi.includes("/api/ops/billing-risk") &&
|
||||
opsApi.includes("/api/ops/payments"),
|
||||
"ops client must expose billing risk and successful payments endpoints",
|
||||
);
|
||||
assert(
|
||||
paymentsPage.includes("支付与邀请风控流水") &&
|
||||
paymentsPage.includes("试用漏开") &&
|
||||
paymentsPage.includes("Intent 卡住") &&
|
||||
paymentsPage.includes("积分异常") &&
|
||||
paymentsPage.includes("推荐奖励结算") &&
|
||||
paymentsPage.includes("月度邀请封顶"),
|
||||
"ops payment page must surface trial, stuck intent, referral, points, and monthly-cap risk signals",
|
||||
);
|
||||
assert(
|
||||
billingRiskRoute.includes("requireOpsProxyAuth") &&
|
||||
billingRiskRoute.includes("/api/ops/billing-risk") &&
|
||||
billingRiskRoute.includes("no-store"),
|
||||
"billing risk proxy must stay ops-admin protected and uncached",
|
||||
);
|
||||
assert(
|
||||
paymentsRoute.includes("requireOpsProxyAuth") &&
|
||||
paymentsRoute.includes("/api/ops/payments") &&
|
||||
paymentsRoute.includes("no-store"),
|
||||
"ops payments proxy must stay ops-admin protected and uncached",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const shell = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminShell.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const sidebar = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const css = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "layout", "AdminShell.module.css"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
shell.includes("AdminShell.module.css") &&
|
||||
shell.includes("styles.root") &&
|
||||
shell.includes("styles.main") &&
|
||||
shell.includes("styles.content"),
|
||||
"ops shell must use scoped terminal-style admin theme classes",
|
||||
);
|
||||
assert(
|
||||
sidebar.includes("bg-white") &&
|
||||
sidebar.includes("bg-blue-50") &&
|
||||
sidebar.includes("shadow-[inset_3px_0_0_#2563eb]"),
|
||||
"ops sidebar must use the light terminal navigation treatment with blue active state",
|
||||
);
|
||||
assert(
|
||||
css.includes("#eef2f7") &&
|
||||
css.includes("[class~=\"text-white\"]") &&
|
||||
css.includes("[class*=\"bg-[#0f172a]\"]") &&
|
||||
css.includes("[class~=\"rounded-3xl\"]") &&
|
||||
css.includes("[class*=\"border-white/\"]") &&
|
||||
css.includes(".recharts-cartesian-grid line"),
|
||||
"ops scoped CSS must map legacy dark ops utility classes to the light terminal workbench palette",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
|
||||
const systemPage = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "system", "SystemPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const nextRoute = fs.readFileSync(
|
||||
path.join(projectRoot, "app", "api", "ops", "source-health", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
opsApi.includes("sourceHealth") &&
|
||||
opsApi.includes("/api/ops/source-health"),
|
||||
"ops client must expose the city source health endpoint",
|
||||
);
|
||||
assert(
|
||||
systemPage.includes("城市数据源健康") &&
|
||||
systemPage.includes("sourceHealth") &&
|
||||
systemPage.includes("MGM、KNMI、IMS") &&
|
||||
systemPage.includes("断线") &&
|
||||
systemPage.includes("延迟"),
|
||||
"ops system page must show source latency/disconnect status for operational city sources",
|
||||
);
|
||||
assert(
|
||||
nextRoute.includes("requireOpsProxyAuth") &&
|
||||
nextRoute.includes("/api/ops/source-health") &&
|
||||
nextRoute.includes("no-store"),
|
||||
"source health proxy must stay ops-admin protected and uncached",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
const COLORS = ["#2563eb", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981", "#14b8a6"];
|
||||
|
||||
export function AnalyticsFunnelChart({
|
||||
data,
|
||||
}: {
|
||||
data: { name: string; count: number; pct?: number }[];
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={data.length * 60 + 40}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#dbe7f3" />
|
||||
<XAxis type="number" stroke="#cbd7e6" tick={{ fill: "#64748b", fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="#cbd7e6" tick={{ fill: "#334155", fontSize: 13 }} width={130} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} 次`, "数量"]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
|
||||
type FunnelStep = { label: string; count: number; pct_of_prev?: number };
|
||||
const AnalyticsFunnelChart = dynamic(
|
||||
() => import("./AnalyticsFunnelChart").then((mod) => mod.AnalyticsFunnelChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
type FunnelStep = {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
uniqueActors: number;
|
||||
pct_of_prev?: number;
|
||||
};
|
||||
type TopItem = { name: string; count: number };
|
||||
type AuthDiagnostic = {
|
||||
total?: number;
|
||||
unique_actors?: number;
|
||||
by_reason?: TopItem[];
|
||||
};
|
||||
|
||||
export function AnalyticsPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [funnel, setFunnel] = useState<FunnelStep[]>([]);
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, AuthDiagnostic>>({});
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null);
|
||||
const [traffic, setTraffic] = useState<Record<string, TopItem[]>>({});
|
||||
const [days, setDays] = useState(30);
|
||||
|
||||
const load = async () => {
|
||||
@@ -30,7 +42,9 @@ export function AnalyticsPageClient() {
|
||||
try {
|
||||
const data = await opsApi.funnel(days);
|
||||
setFunnel(data.steps);
|
||||
setDiagnostics(data.diagnostics ?? {});
|
||||
setRates(data.rates ?? null);
|
||||
setTraffic(data.traffic ?? {});
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -43,7 +57,18 @@ export function AnalyticsPageClient() {
|
||||
pct: s.pct_of_prev,
|
||||
}));
|
||||
|
||||
const maxCount = Math.max(...funnel.map((s) => s.count), 1);
|
||||
const stepByKey = Object.fromEntries(funnel.map((step) => [step.key, step]));
|
||||
const degradedAuth = diagnostics.degraded_auth_profile ?? {};
|
||||
const landingView = stepByKey.landing_view;
|
||||
const terminalEntry = stepByKey.enter_terminal;
|
||||
const signupSuccess = stepByKey.signup_success;
|
||||
const trialCreated = stepByKey.trial_created;
|
||||
const paymentStart = stepByKey.payment_start;
|
||||
const paymentSuccess = stepByKey.payment_success;
|
||||
const overallRate =
|
||||
landingView?.uniqueActors && paymentSuccess?.uniqueActors
|
||||
? ((paymentSuccess.uniqueActors / landingView.uniqueActors) * 100).toFixed(1)
|
||||
: "—";
|
||||
|
||||
if (loading) return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||
|
||||
@@ -68,72 +93,115 @@ export function AnalyticsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
{funnel.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">总注册</div>
|
||||
<div className="text-xl font-bold text-white">{funnel[0]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">落地页访问</div>
|
||||
<div className="text-xl font-bold text-white">{landingView?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {landingView?.uniqueActors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">点击高级功能</div>
|
||||
<div className="text-xl font-bold text-cyan-400">{funnel[2]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500">进入终端</div>
|
||||
<div className="text-xl font-bold text-cyan-400">{terminalEntry?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {terminalEntry?.uniqueActors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">注册成功</div>
|
||||
<div className="text-xl font-bold text-blue-500">{signupSuccess?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">试用 {trialCreated?.count ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">发起支付</div>
|
||||
<div className="text-xl font-bold text-amber-400">{funnel[4]?.count ?? 0}</div>
|
||||
<div className="text-xl font-bold text-amber-400">{paymentStart?.count ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">支付成功</div>
|
||||
<div className="text-xl font-bold text-emerald-400">{funnel[5]?.count ?? 0}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
总体转化 {maxCount > 0 ? `${((funnel[5]?.count ?? 0) / maxCount * 100).toFixed(1)}%` : "—"}
|
||||
<div className="text-xl font-bold text-emerald-400">{paymentSuccess?.count ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">
|
||||
总体转化 {overallRate === "—" ? "—" : `${overallRate}%`}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">鉴权降级</div>
|
||||
<div className="text-xl font-bold text-rose-500">{degradedAuth.total ?? 0}</div>
|
||||
<div className="mt-0.5 text-xs text-slate-500">独立 {degradedAuth.unique_actors ?? 0}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Funnel chart */}
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(320px,0.6fr)]">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>来源与设备</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 text-sm md:grid-cols-4">
|
||||
{[
|
||||
["来源", traffic.referrers ?? []],
|
||||
["国家/地区", traffic.countries ?? []],
|
||||
["设备", traffic.devices ?? []],
|
||||
["落地页路径", traffic.landing_paths ?? []],
|
||||
].map(([title, rows]) => (
|
||||
<div key={String(title)} className="space-y-2">
|
||||
<div className="text-xs font-bold text-slate-500">{String(title)}</div>
|
||||
{(rows as TopItem[]).length === 0 ? (
|
||||
<div className="text-xs text-slate-500">暂无数据</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{(rows as TopItem[]).slice(0, 5).map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="font-mono text-blue-600">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>鉴权降级原因</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(degradedAuth.by_reason ?? []).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无 degraded_auth_profile</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{(degradedAuth.by_reason ?? []).map((item) => (
|
||||
<li key={item.name} className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="font-mono text-rose-500">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>转化漏斗 (Recharts)</CardTitle></CardHeader>
|
||||
<CardHeader><CardTitle>转化漏斗</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm py-8 text-center">暂无数据</p>
|
||||
<p className="py-8 text-center text-sm text-slate-500">暂无数据</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={chartData.length * 60 + 40}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 40, left: 140, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#94a3b8", fontSize: 12 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.3)" tick={{ fill: "#e2e8f0", fontSize: 13 }} width={130} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} 人`, "数量"]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 6, 6, 0]}>
|
||||
{chartData.map((_, i) => {
|
||||
const colors = ["#06b6d4", "#0ea5e9", "#6366f1", "#f59e0b", "#22c55e", "#10b981"];
|
||||
return <Cell key={i} fill={colors[i % colors.length]} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<AnalyticsFunnelChart data={chartData} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Drop-off table */}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>各阶段详情</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
@@ -141,28 +209,35 @@ export function AnalyticsPageClient() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-slate-400">
|
||||
<th className="py-2 px-3 font-medium">阶段</th>
|
||||
<th className="py-2 px-3 font-medium text-right">人数</th>
|
||||
<th className="py-2 px-3 font-medium text-right">转化率</th>
|
||||
<th className="py-2 px-3 font-medium text-right">流失率</th>
|
||||
<th className="px-3 py-2 font-medium">阶段</th>
|
||||
<th className="px-3 py-2 text-right font-medium">次数</th>
|
||||
<th className="px-3 py-2 text-right font-medium">独立用户/访客</th>
|
||||
<th className="px-3 py-2 text-right font-medium">转化率</th>
|
||||
<th className="px-3 py-2 text-right font-medium">流失率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{funnel.map((step, i) => {
|
||||
const pct = step.pct_of_prev;
|
||||
const dropPct = pct != null ? 100 - pct : 0;
|
||||
const dropPct = pct != null ? Math.max(0, 100 - pct) : 0;
|
||||
return (
|
||||
<tr key={i} className="border-b border-white/5">
|
||||
<td className="py-2 px-3 text-white">{step.label}</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-white">{step.count}</td>
|
||||
<td className="py-2 px-3 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
|
||||
<td className="py-2 px-3 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
|
||||
<tr key={step.key} className="border-b border-white/5">
|
||||
<td className="px-3 py-2 text-white">{step.label}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-white">{step.count}</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-white">{step.uniqueActors}</td>
|
||||
<td className="px-3 py-2 text-right text-emerald-400">{pct != null ? `${pct}%` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-amber-400">{i > 0 ? `${dropPct}%` : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{rates ? (
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
rates 以 unique actors 计算,次数用于观察重复尝试和重试行为。
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
const COLORS = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"];
|
||||
|
||||
export function HealthLatencyChart({
|
||||
data,
|
||||
}: {
|
||||
data: { name: string; latency: number }[];
|
||||
}) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, data.length * 28 + 40)}>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} ms`, "延迟"]}
|
||||
/>
|
||||
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Cell,
|
||||
} from "recharts";
|
||||
|
||||
const HealthLatencyChart = dynamic(
|
||||
() => import("./HealthLatencyChart").then((mod) => mod.HealthLatencyChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[220px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
type ServiceResult = {
|
||||
ok: boolean;
|
||||
@@ -107,27 +111,7 @@ export function HealthPageClient() {
|
||||
<Card className="border-slate-800 bg-slate-900/50">
|
||||
<CardContent className="p-4">
|
||||
<h3 className="text-sm font-semibold text-white mb-4">服务响应延迟对比 (ms)</h3>
|
||||
<ResponsiveContainer width="100%" height={Math.max(160, latencyData.length * 28 + 40)}>
|
||||
<BarChart
|
||||
data={latencyData}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, left: 100, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#64748b", fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" stroke="rgba(255,255,255,0.2)" tick={{ fill: "#94a3b8", fontSize: 11 }} width={120} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value) => [`${value} ms`, "延迟"]}
|
||||
/>
|
||||
<Bar dataKey="latency" radius={[0, 4, 4, 0]}>
|
||||
{latencyData.map((d, i) => {
|
||||
const colors = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"];
|
||||
return <Cell key={i} fill={colors[i % colors.length]} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<HealthLatencyChart data={latencyData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 18% 10%, rgba(37, 99, 235, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, #f7f9fc 0%, #eef2f7 100%);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
margin-left: 14rem;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.content :global(h1) {
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.content :global([class~="text-white"]),
|
||||
.content :global([class~="text-slate-100"]),
|
||||
.content :global([class~="text-slate-200"]),
|
||||
.content :global([class~="text-slate-300"]) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.content :global([class~="text-slate-400"]) {
|
||||
color: #52637a;
|
||||
}
|
||||
|
||||
.content :global([class~="text-slate-500"]),
|
||||
.content :global([class~="text-slate-600"]) {
|
||||
color: #6b7a91;
|
||||
}
|
||||
|
||||
.content :global([class*="border-white/"]),
|
||||
.content :global([class*="divide-white/"]),
|
||||
.content :global([class*="border-black/"]) {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
.content :global([class*="border-slate-7"]),
|
||||
.content :global([class*="border-slate-8"]) {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
.content :global([class*="bg-card"]),
|
||||
.content :global([class*="bg-slate-900"]),
|
||||
.content :global([class*="bg-slate-950"]),
|
||||
.content :global([class*="bg-[#0f172a]"]),
|
||||
.content :global([class*="bg-black/"]),
|
||||
.content :global([class*="bg-white/5"]),
|
||||
.content :global([class*="bg-white/10"]) {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content :global([class~="rounded-2xl"]),
|
||||
.content :global([class~="rounded-xl"]),
|
||||
.content :global([class~="rounded-3xl"]) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.content :global([class*="shadow-xl"]),
|
||||
.content :global([class*="shadow-2xl"]) {
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 12px 28px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.content :global([class*="backdrop-blur"]) {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.content :global([class*="hover:bg-white/"]:hover) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.content :global([class*="hover:bg-slate-"]:hover),
|
||||
.content :global([class*="hover:bg-black/"]:hover) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.content :global([class*="hover:text-white"]:hover),
|
||||
.content :global([class*="hover:text-slate-2"]:hover),
|
||||
.content :global([class*="hover:text-slate-3"]:hover) {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.content :global(code),
|
||||
.content :global(pre),
|
||||
.content :global(.font-mono) {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.content :global(input),
|
||||
.content :global(select),
|
||||
.content :global(textarea) {
|
||||
border-color: var(--color-border-default);
|
||||
background: #ffffff;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.content :global(input::placeholder),
|
||||
.content :global(textarea::placeholder) {
|
||||
color: #8ba0b7;
|
||||
}
|
||||
|
||||
.content :global(input:focus),
|
||||
.content :global(select:focus),
|
||||
.content :global(textarea:focus) {
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.content :global(button[class*="border"]) {
|
||||
border-color: #cbd7e6;
|
||||
}
|
||||
|
||||
.content :global(button[class*="bg-transparent"]),
|
||||
.content :global(button[class*="outline"]) {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.content :global(table) {
|
||||
color: #1e293b;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.content :global(thead),
|
||||
.content :global(thead tr),
|
||||
.content :global([class*="bg-slate-800"]) {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.content :global(th) {
|
||||
color: #52637a;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.content :global(td) {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.content :global(tbody tr) {
|
||||
border-color: #e5edf6;
|
||||
}
|
||||
|
||||
.content :global(tbody tr:hover) {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.content :global(.recharts-cartesian-grid line) {
|
||||
stroke: #dbe7f3;
|
||||
}
|
||||
|
||||
.content :global(.recharts-cartesian-axis-line),
|
||||
.content :global(.recharts-cartesian-axis-tick-line) {
|
||||
stroke: #cbd7e6;
|
||||
}
|
||||
|
||||
.content :global(.recharts-cartesian-axis-tick-value) {
|
||||
fill: #64748b;
|
||||
}
|
||||
|
||||
.content :global(.recharts-legend-item-text) {
|
||||
color: #475569 !important;
|
||||
}
|
||||
|
||||
.content :global([class*="animate-pulse"]) {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main {
|
||||
margin-left: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import styles from "./AdminShell.module.css";
|
||||
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div className={styles.root}>
|
||||
<AdminSidebar />
|
||||
<main className="ml-56 min-h-screen p-6">
|
||||
{children}
|
||||
<main className={styles.main}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -57,17 +57,17 @@ export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-56 border-r border-white/10 bg-slate-950 flex flex-col">
|
||||
<div className="flex h-14 items-center gap-2 border-b border-white/10 px-4">
|
||||
<LayoutDashboard className="h-5 w-5 text-cyan-400" />
|
||||
<Link href="/ops/system" className="text-sm font-bold text-white">
|
||||
<aside className="fixed left-0 top-0 z-40 flex h-screen w-56 flex-col border-r border-slate-200 bg-white shadow-[1px_0_2px_rgba(15,23,42,0.04)]">
|
||||
<div className="flex h-14 items-center gap-2 border-b border-slate-200 px-4">
|
||||
<LayoutDashboard className="h-5 w-5 text-blue-600" />
|
||||
<Link href="/ops/system" className="text-sm font-extrabold text-slate-950">
|
||||
PolyWeather Ops
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-4 space-y-6">
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto py-4">
|
||||
{navGroups.map((group) => (
|
||||
<div key={group.label} className="px-3">
|
||||
<h4 className="mb-2 px-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500">
|
||||
<h4 className="mb-2 px-2 text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{group.label}
|
||||
</h4>
|
||||
<ul className="space-y-0.5">
|
||||
@@ -78,10 +78,10 @@ export function AdminSidebar() {
|
||||
<Link
|
||||
href={item.href}
|
||||
className={
|
||||
"flex items-center gap-2.5 rounded-lg px-2 py-2 text-sm transition-colors " +
|
||||
"flex items-center gap-2.5 rounded-md border border-transparent px-2 py-2 text-sm transition-colors " +
|
||||
(isActive
|
||||
? "bg-white/10 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200")
|
||||
? "border-blue-200 bg-blue-50 text-blue-700 font-semibold shadow-[inset_3px_0_0_#2563eb]"
|
||||
: "text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950")
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
@@ -94,10 +94,10 @@ export function AdminSidebar() {
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div className="border-t border-slate-200 px-4 py-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
className="flex items-center gap-2 text-xs font-semibold text-slate-500 transition-colors hover:text-blue-700"
|
||||
>
|
||||
← 返回前台
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
|
||||
export function MembershipGrowthChart({
|
||||
growth,
|
||||
}: {
|
||||
growth: GrowthPoint[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, X, Search } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend, Area, AreaChart,
|
||||
} from "recharts";
|
||||
|
||||
const MembershipGrowthChart = dynamic(
|
||||
() => import("./MembershipGrowthChart").then((mod) => mod.MembershipGrowthChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
type GrowthPoint = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
|
||||
@@ -160,35 +164,7 @@ export function MembershipsPageClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cumulative area chart */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fill="#06b6d420" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Daily bars */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={30} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e60" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b60" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<MembershipGrowthChart growth={growth} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type GrowthRow = { date: string; trial: number; paid: number; total: number; cumulative: number };
|
||||
type StepRow = { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number };
|
||||
type BucketRow = { name: string; value: number };
|
||||
type PieRow = { name: string; value: number; color?: string };
|
||||
type CacheAnalysis = {
|
||||
total_requests?: number;
|
||||
cache_hits?: number;
|
||||
cache_misses?: number;
|
||||
force_refresh_requests?: number;
|
||||
hit_rate?: number | null;
|
||||
};
|
||||
|
||||
export function OverviewCharts({
|
||||
growth,
|
||||
steps,
|
||||
cacheBuckets,
|
||||
memberPie,
|
||||
planBreakdown,
|
||||
cachePie,
|
||||
cacheAnalysis,
|
||||
}: {
|
||||
growth: GrowthRow[];
|
||||
steps: StepRow[];
|
||||
cacheBuckets: BucketRow[];
|
||||
memberPie: PieRow[];
|
||||
planBreakdown: PieRow[];
|
||||
cachePie: PieRow[];
|
||||
cacheAnalysis?: CacheAnalysis;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{growth.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员增长趋势 — 近 30 天</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<defs>
|
||||
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div className="space-y-5">
|
||||
{steps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">30天转化漏斗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{cacheBuckets.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">缓存桶分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
||||
{memberPie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
{memberPie.map((item) => (
|
||||
<div key={item.name} className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<span className="text-slate-400">{item.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{planBreakdown.length > 0 && (
|
||||
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
|
||||
{planBreakdown.map((item) => (
|
||||
<div key={item.name} className="flex justify-between text-xs">
|
||||
<span className="text-slate-500">{item.name}</span>
|
||||
<span className="text-slate-300">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{cachePie.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
缓存分析{" "}
|
||||
{cacheAnalysis?.hit_rate != null && (
|
||||
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% 命中</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
||||
{cachePie.map((item, i) => (<Cell key={i} fill={item.color || "#64748b"} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">总请求</div>
|
||||
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">强制刷新</div>
|
||||
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">命中</div>
|
||||
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">未命中</div>
|
||||
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, TrendingUp, Users, CreditCard, Database, Activity, Cpu } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Activity, Cpu, CreditCard, Database, RefreshCcw, TrendingUp, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
AreaChart, Area, Legend,
|
||||
} from "recharts";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { MembershipEntry, MembershipsPayload, SystemStatusPayload } from "@/types/ops";
|
||||
|
||||
const OverviewCharts = dynamic(
|
||||
() => import("./OverviewCharts").then((mod) => mod.OverviewCharts),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[360px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
function KpiCard({ href, icon: Icon, label, value, color, sub }: {
|
||||
href: string; icon: React.ElementType; label: string; value: string | number; color: string; sub?: string;
|
||||
@@ -38,7 +41,7 @@ export function OverviewPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
|
||||
const [funnel, setFunnel] = useState<{ steps: { label: string; count: number; pct_of_prev?: number }[] } | null>(null);
|
||||
const [funnel, setFunnel] = useState<{ steps: { key?: string; label: string; count: number; pct_of_prev?: number; uniqueActors?: number }[] } | null>(null);
|
||||
const [growth, setGrowth] = useState<{ date: string; trial: number; paid: number; total: number; cumulative: number }[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
@@ -70,19 +73,20 @@ export function OverviewPageClient() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Derived data ──
|
||||
const paid = memberships.filter((m) => !m.is_trial).length;
|
||||
const trials = memberships.filter((m) => m.is_trial).length;
|
||||
const steps = funnel?.steps ?? [];
|
||||
const totalUsers = steps[0]?.count ?? 0;
|
||||
const payingUsers = steps[5]?.count ?? 0;
|
||||
const stepByKey = Object.fromEntries(steps.map((step) => [step.key || step.label, step]));
|
||||
const payingUsers = stepByKey.payment_success?.count ?? 0;
|
||||
const convRate = totalUsers > 0 ? ((payingUsers / totalUsers) * 100).toFixed(1) : "—";
|
||||
const cache = status?.cache;
|
||||
const cacheAnalysis = cache?.analysis;
|
||||
const td = status?.training_data;
|
||||
const features = status?.features;
|
||||
const coverage = td?.city_coverage;
|
||||
const truthRows = td?.truth_records?.row_count ?? 0;
|
||||
|
||||
// Cache bucket data for bar chart
|
||||
const cacheBuckets = cache ? [
|
||||
{ name: "API", value: cache.api_cache_entries ?? 0 },
|
||||
{ name: "预报", value: cache.open_meteo_forecast_entries ?? 0 },
|
||||
@@ -91,20 +95,17 @@ export function OverviewPageClient() {
|
||||
{ name: "结算", value: cache.settlement_entries ?? 0 },
|
||||
].filter((d) => d.value > 0) : [];
|
||||
|
||||
// Cache pie
|
||||
const cachePie = cacheAnalysis ? [
|
||||
{ name: "命中", value: cacheAnalysis.cache_hits ?? 0, color: "#22c55e" },
|
||||
{ name: "未命中", value: cacheAnalysis.cache_misses ?? 0, color: "#f59e0b" },
|
||||
{ name: "强制刷新", value: cacheAnalysis.force_refresh_requests ?? 0, color: "#3b82f6" },
|
||||
] : [];
|
||||
|
||||
// Membership pie
|
||||
const memberPie = [
|
||||
{ name: "付费", value: paid, color: "#22c55e" },
|
||||
...(trials > 0 ? [{ name: "体验", value: trials, color: "#f59e0b" }] : []),
|
||||
];
|
||||
|
||||
// Plan breakdown
|
||||
const planCounts: Record<string, number> = {};
|
||||
memberships.forEach((m) => {
|
||||
const code = m.plan_code ?? "unknown";
|
||||
@@ -117,9 +118,6 @@ export function OverviewPageClient() {
|
||||
})
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const truthRows = td?.truth_records?.row_count ?? 0;
|
||||
const coverage = td?.city_coverage;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -132,7 +130,6 @@ export function OverviewPageClient() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<KpiCard href="/ops/system" icon={Activity} label="系统" value={status?.db?.ok ? "OK" : "FAIL"} color={status?.db?.ok ? "text-emerald-400" : "text-red-400"} />
|
||||
<KpiCard href="/ops/memberships" icon={Users} label="付费会员" value={paid} color="text-cyan-400" sub={trials > 0 ? `+${trials} 体验` : undefined} />
|
||||
@@ -142,237 +139,61 @@ export function OverviewPageClient() {
|
||||
<KpiCard href="/ops/system" icon={Cpu} label="概率引擎" value={status?.probability?.engine_mode ?? "—"} color="text-amber-400" />
|
||||
</div>
|
||||
|
||||
{/* Membership Growth Trend */}
|
||||
{growth.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员增长趋势 — 近 30 天</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Cumulative area chart */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">累计会员</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<defs>
|
||||
<linearGradient id="colorCumulative" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.2}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={35} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Area type="monotone" dataKey="cumulative" stroke="#06b6d4" fillOpacity={1} fill="url(#colorCumulative)" name="累计" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<OverviewCharts
|
||||
growth={growth}
|
||||
steps={steps}
|
||||
cacheBuckets={cacheBuckets}
|
||||
memberPie={memberPie}
|
||||
planBreakdown={planBreakdown}
|
||||
cachePie={cachePie}
|
||||
cacheAnalysis={cacheAnalysis}
|
||||
/>
|
||||
|
||||
{/* Daily stack chart */}
|
||||
<div>
|
||||
<h4 className="text-xs text-slate-500 mb-2">每日新增</h4>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={growth}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis dataKey="date" tick={{ fill: "#64748b", fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#64748b", fontSize: 10 }} width={25} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Area type="monotone" dataKey="paid" stackId="1" stroke="#22c55e" fill="#22c55e40" name="付费" />
|
||||
<Area type="monotone" dataKey="trial" stackId="1" stroke="#f59e0b" fill="#f59e0b40" name="体验" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Main grid: 2 columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
|
||||
{/* Column 1 */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Funnel mini chart */}
|
||||
{steps.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">30天转化漏斗</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={[...steps].reverse().map((s) => ({ name: s.label, count: s.count }))} layout="vertical" margin={{ left: 80, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={75} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} fill="#06b6d4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cache buckets */}
|
||||
{cacheBuckets.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">缓存桶分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={cacheBuckets} layout="vertical" margin={{ left: 50, right: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
||||
<XAxis type="number" tick={{ fill: "#64748b", fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fill: "#94a3b8", fontSize: 11 }} width={45} />
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]} fill="#6366f1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{features && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">功能开关</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(features).map(([k, v]) => (
|
||||
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
|
||||
{k.replace(/_/g, " ")}: {String(v)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column 2 */}
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Membership donut + plan breakdown side by side */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
||||
{features && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">会员分布</CardTitle>
|
||||
<CardTitle className="text-sm">功能开关</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={memberPie} cx="50%" cy="50%" innerRadius={40} outerRadius={60} paddingAngle={2} dataKey="value">
|
||||
{memberPie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(features).map(([k, v]) => (
|
||||
<Badge key={k} variant={v ? "default" : "secondary"} className="text-[11px]">
|
||||
{k.replace(/_/g, " ")}: {String(v)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{coverage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">城市模型覆盖</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">总城市</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5 text-sm">
|
||||
{memberPie.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: d.color }} />
|
||||
<span className="text-slate-400">{d.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{planBreakdown.length > 0 && (
|
||||
<div className="pt-2 mt-2 border-t border-white/10 space-y-1">
|
||||
{planBreakdown.map((p) => (
|
||||
<div key={p.name} className="flex justify-between text-xs">
|
||||
<span className="text-slate-500">{p.name}</span>
|
||||
<span className="text-slate-300">{p.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有真值</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有特征</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">真值行数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache hit rate donut */}
|
||||
{cachePie.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
缓存分析{" "}
|
||||
{cacheAnalysis?.hit_rate != null && (
|
||||
<span className="text-emerald-400 font-normal">{(cacheAnalysis.hit_rate * 100).toFixed(1)}% 命中</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-36 h-36 shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={cachePie} cx="50%" cy="50%" innerRadius={36} outerRadius={58} paddingAngle={2} dataKey="value">
|
||||
{cachePie.map((d, i) => (<Cell key={i} fill={d.color} />))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={CHART_TOOLTIP_STYLE} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">总请求</div>
|
||||
<div className="text-white font-bold">{cacheAnalysis?.total_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">强制刷新</div>
|
||||
<div className="text-blue-400 font-bold">{cacheAnalysis?.force_refresh_requests ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">命中</div>
|
||||
<div className="text-emerald-400 font-bold">{cacheAnalysis?.cache_hits ?? 0}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-slate-500 text-xs">未命中</div>
|
||||
<div className="text-amber-400 font-bold">{cacheAnalysis?.cache_misses ?? 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Training data summary */}
|
||||
{coverage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">城市模型覆盖</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-cyan-400">{coverage.total_cities ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">总城市</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-400">{coverage.with_truth_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有真值</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-400">{coverage.with_feature_rows ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">有特征</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white/5 p-3 text-center">
|
||||
<div className="text-xl font-bold text-amber-400">{td?.truth_records?.row_count ?? 0}</div>
|
||||
<div className="text-[11px] text-slate-500">真值行数</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
|
||||
export function PaymentIncidentPieChart({
|
||||
data,
|
||||
}: {
|
||||
data: { name: string; value: number }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-[100px] h-[100px] shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={24}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
|
||||
{data.map((item, i) => (
|
||||
<div key={item.name} className="flex items-center gap-1.5 truncate">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: COLORS[i % COLORS.length] }}
|
||||
/>
|
||||
<span className="text-slate-400 truncate" title={item.name}>{item.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, CheckCircle2, ExternalLink } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, CheckCircle2, ExternalLink, AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { PaymentRuntimePayload, PaymentIncident, PaymentRecord } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
|
||||
} from "recharts";
|
||||
import type {
|
||||
BillingRiskIssue,
|
||||
BillingRiskPayload,
|
||||
PaymentRuntimePayload,
|
||||
PaymentIncident,
|
||||
PaymentRecord,
|
||||
} from "@/types/ops";
|
||||
|
||||
const PaymentIncidentPieChart = dynamic(
|
||||
() => import("./PaymentIncidentPieChart").then((mod) => mod.PaymentIncidentPieChart),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <span className="text-sm text-slate-500">加载图表...</span>,
|
||||
},
|
||||
);
|
||||
|
||||
function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
const txHash = String(payment.tx_hash || "").trim();
|
||||
if (!txHash) return "";
|
||||
const chain = String(payment.chain || "").trim().toLowerCase();
|
||||
const base = chain.includes("eth")
|
||||
? "https://etherscan.io"
|
||||
: "https://polygonscan.com";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
function severityTone(severity?: string) {
|
||||
if (severity === "high") return "border-red-200 bg-red-50 text-red-700";
|
||||
if (severity === "medium") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
}
|
||||
|
||||
function severityLabel(severity?: string) {
|
||||
if (severity === "high") return "高";
|
||||
if (severity === "medium") return "中";
|
||||
if (severity === "low") return "低";
|
||||
return severity || "未知";
|
||||
}
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function RiskStat({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
tone = "text-slate-950",
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
sub: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<div className="text-xs font-bold uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-black ${tone}`}>{value}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentsPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
||||
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
||||
const [payments, setPayments] = useState<PaymentRecord[]>([]);
|
||||
const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
|
||||
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rt, inc, pay] = await Promise.all([
|
||||
const [rt, inc, pay, riskPayload] = await Promise.all([
|
||||
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
||||
opsApi.incidents(50),
|
||||
opsApi.listPayments(50),
|
||||
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
|
||||
]);
|
||||
setRuntime(rt);
|
||||
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
||||
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
|
||||
setRisk(riskPayload);
|
||||
} catch { /* */ }
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -60,18 +122,10 @@ export function PaymentsPageClient() {
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
|
||||
function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
const txHash = String(payment.tx_hash || "").trim();
|
||||
if (!txHash) return "";
|
||||
const chain = String(payment.chain || "").trim().toLowerCase();
|
||||
const base = chain.includes("eth")
|
||||
? "https://etherscan.io"
|
||||
: "https://polygonscan.com";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
const riskSummary = risk?.summary ?? {};
|
||||
const riskIssues = risk?.issues ?? [];
|
||||
const referralRewards = risk?.recent_referral_rewards ?? [];
|
||||
const hasRisk = Number(riskSummary.issues ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -82,6 +136,73 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{hasRisk ? <AlertTriangle className="h-4 w-4 text-amber-500" /> : <ShieldCheck className="h-4 w-4 text-emerald-500" />}
|
||||
支付与邀请风控流水
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500">最近 {risk?.window_days ?? 30} 天 · {compactDate(risk?.checked_at)}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
|
||||
<RiskStat label="总异常" value={Number(riskSummary.issues ?? 0)} sub="需要人工关注" tone={hasRisk ? "text-red-600" : "text-emerald-600"} />
|
||||
<RiskStat label="Intent 卡住" value={Number(riskSummary.stuck_intents ?? 0)} sub="submitted/过期 created" />
|
||||
<RiskStat label="试用漏开" value={Number(riskSummary.trial_gaps ?? 0)} sub="signup 无 trial_created" />
|
||||
<RiskStat label="支付异常" value={Number(riskSummary.payment_incidents ?? incidents.length)} sub="未标记处理" />
|
||||
<RiskStat label="积分异常" value={Number(riskSummary.points_discount_issues ?? 0)} sub="确认后未扣/少扣" />
|
||||
<RiskStat label="推荐异常" value={Number(riskSummary.referral_settlement_issues ?? 0)} sub="转化无奖励记录" />
|
||||
<RiskStat label="上限命中" value={Number(riskSummary.monthly_cap_hits ?? 0)} sub="月度邀请封顶" />
|
||||
</div>
|
||||
|
||||
{risk?.query_errors?.length ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
部分 Supabase 表查询失败:{risk.query_errors.map((item) => item.table).filter(Boolean).join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{riskIssues.length === 0 ? (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
暂无试用、支付、推荐奖励或积分抵扣风险信号。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">级别</th>
|
||||
<th className="py-2 pr-4 font-bold">类型</th>
|
||||
<th className="py-2 pr-4 font-bold">问题</th>
|
||||
<th className="py-2 pr-4 font-bold">用户</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
<th className="py-2 pr-4 font-bold">引用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{riskIssues.slice(0, 30).map((issue: BillingRiskIssue, index) => (
|
||||
<tr key={`${issue.category}-${issue.reference}-${index}`} className="border-b border-slate-100">
|
||||
<td className="py-2 pr-4">
|
||||
<span className={`inline-flex min-w-8 items-center justify-center rounded-md border px-2 py-1 text-xs font-bold ${severityTone(issue.severity)}`}>
|
||||
{severityLabel(issue.severity)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500">{issue.category ?? "—"}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="font-bold text-slate-900">{issue.title ?? "—"}</div>
|
||||
<div className="mt-0.5 max-w-xl text-xs text-slate-500">{issue.detail ?? "—"}</div>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{issue.user_id ? `${issue.user_id.slice(0, 10)}...` : "—"}</td>
|
||||
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(issue.created_at)}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{issue.reference ? String(issue.reference).slice(0, 14) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader><CardTitle>支付运行时</CardTitle></CardHeader>
|
||||
@@ -113,42 +234,48 @@ function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
{incidentPieData.length === 0 ? (
|
||||
<span className="text-sm text-slate-500">暂无异常数据</span>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center gap-2">
|
||||
<div className="w-[100px] h-[100px] shrink-0">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={incidentPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={24}
|
||||
outerRadius={40}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{incidentPieData.map((d, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ ...CHART_TOOLTIP_STYLE, fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 overflow-y-auto max-h-[100px] text-xs">
|
||||
{incidentPieData.map((d, i) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 truncate">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: COLORS[i % COLORS.length] }} />
|
||||
<span className="text-slate-400 truncate" title={d.name}>{d.name}</span>
|
||||
<span className="text-white font-bold ml-auto">{d.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PaymentIncidentPieChart data={incidentPieData} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>推荐奖励结算 ({referralRewards.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{referralRewards.length === 0 ? (
|
||||
<span className="text-sm text-slate-500">最近没有推荐奖励记录</span>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">ID</th>
|
||||
<th className="py-2 pr-4 font-bold">邀请人</th>
|
||||
<th className="py-2 pr-4 font-bold">被邀请人</th>
|
||||
<th className="py-2 pr-4 font-bold">奖励</th>
|
||||
<th className="py-2 pr-4 font-bold">Intent</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{referralRewards.slice(0, 20).map((row, index) => (
|
||||
<tr key={`${row.id}-${index}`} className="border-b border-slate-100">
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.id ?? "—")}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referrer_user_id ?? "—").slice(0, 10)}...</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{String(row.referred_user_id ?? "—").slice(0, 10)}...</td>
|
||||
<td className="py-2 pr-4 text-emerald-700 font-bold">+{Number(row.reward_points ?? 0).toLocaleString()} 分</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-blue-700">{String(row.payment_intent_id ?? "—").slice(0, 14)}</td>
|
||||
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">{compactDate(String(row.created_at ?? ""))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>支付异常 ({incidents.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,29 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw, ShieldCheck, Database, Cpu, HardDrive } from "lucide-react";
|
||||
import { AlertTriangle, RefreshCcw, ShieldCheck, Database, Cpu, HardDrive, RadioTower } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload, HealthPayload } from "@/types/ops";
|
||||
import type { SourceHealthPayload, SystemStatusPayload, HealthPayload } from "@/types/ops";
|
||||
|
||||
function sourceStatusTone(status?: string) {
|
||||
if (status === "fresh") return "text-emerald-500";
|
||||
if (status === "expected_wait") return "text-blue-500";
|
||||
if (status === "delayed") return "text-amber-500";
|
||||
if (status === "stale" || status === "missing") return "text-red-500";
|
||||
return "text-slate-500";
|
||||
}
|
||||
|
||||
function sourceStatusLabel(status?: string) {
|
||||
if (status === "fresh") return "正常";
|
||||
if (status === "expected_wait") return "等待更新";
|
||||
if (status === "delayed") return "延迟";
|
||||
if (status === "stale") return "断线";
|
||||
if (status === "missing") return "缺失";
|
||||
return "未知";
|
||||
}
|
||||
|
||||
function formatAge(ageMin?: number | null) {
|
||||
if (ageMin == null) return "—";
|
||||
if (ageMin < 60) return `${Math.round(ageMin)}m`;
|
||||
return `${(ageMin / 60).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function SystemPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [health, setHealth] = useState<HealthPayload | null>(null);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
const [sourceHealth, setSourceHealth] = useState<SourceHealthPayload | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [h, s] = await Promise.all([
|
||||
const [h, s, sh] = await Promise.all([
|
||||
opsApi.health(),
|
||||
opsApi.systemStatus() as Promise<SystemStatusPayload>,
|
||||
opsApi.sourceHealth(80) as Promise<SourceHealthPayload>,
|
||||
]);
|
||||
setHealth(h);
|
||||
setStatus(s);
|
||||
setSourceHealth(sh);
|
||||
} catch (e) {
|
||||
setError(String(e).slice(0, 200));
|
||||
} finally {
|
||||
@@ -45,6 +71,13 @@ export function SystemPageClient() {
|
||||
|
||||
const dbOk = status?.db?.ok ?? health?.db?.ok;
|
||||
const cacheAnalysis = status?.cache?.analysis;
|
||||
const sourceIssues = (sourceHealth?.cities || [])
|
||||
.flatMap((city) =>
|
||||
(city.sources || [])
|
||||
.filter((source) => ["delayed", "stale", "missing", "unknown"].includes(String(source.status || "")))
|
||||
.map((source) => ({ city: city.city, source })),
|
||||
)
|
||||
.slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -165,6 +198,73 @@ export function SystemPageClient() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RadioTower className="h-4 w-4 text-blue-500" />
|
||||
城市数据源健康
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
{["fresh", "expected_wait", "delayed", "stale", "missing"].map((key) => (
|
||||
<div key={key} className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="text-[11px] text-slate-500">{sourceStatusLabel(key)}</div>
|
||||
<div className={`text-lg font-black ${sourceStatusTone(key)}`}>
|
||||
{sourceHealth?.status_counts?.[key] ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceIssues.length ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full min-w-[760px] text-left text-xs">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2">城市</th>
|
||||
<th className="px-3 py-2">来源</th>
|
||||
<th className="px-3 py-2">状态</th>
|
||||
<th className="px-3 py-2">延迟</th>
|
||||
<th className="px-3 py-2">最近观测</th>
|
||||
<th className="px-3 py-2">原因</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sourceIssues.map(({ city, source }, index) => (
|
||||
<tr key={`${city}-${source.role}-${source.source_code}-${index}`} className="border-t border-slate-100">
|
||||
<td className="px-3 py-2 font-mono font-bold text-slate-800">{city}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-semibold text-slate-800">{source.source_label || source.source_code}</div>
|
||||
<div className="text-[11px] text-slate-500">{source.role}</div>
|
||||
</td>
|
||||
<td className={`px-3 py-2 font-bold ${sourceStatusTone(source.status)}`}>
|
||||
{sourceStatusLabel(source.status)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{formatAge(source.age_min)}</td>
|
||||
<td className="px-3 py-2 font-mono text-slate-600">{source.observed_at || "—"}</td>
|
||||
<td className="px-3 py-2 text-slate-500">{source.reason || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-700">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
当前缓存内未发现 MGM、KNMI、IMS 或机场站断线/延迟异常。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sourceHealth?.cities || []).some((city) => !city.cache_exists) ? (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
有城市缺少 full/panel 缓存,可能是后台冷启动或该城市尚未预热。
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* DB Path */}
|
||||
{status?.db?.db_path ? (
|
||||
<Card>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ComposedChart,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
|
||||
type DebChartRow = {
|
||||
name: string;
|
||||
cityId: string;
|
||||
hitRate: number;
|
||||
mae: number;
|
||||
days: number;
|
||||
};
|
||||
|
||||
type MuChartRow = {
|
||||
name: string;
|
||||
cityId: string;
|
||||
brierScore: number;
|
||||
hitRate: number;
|
||||
mae: number;
|
||||
days: number;
|
||||
};
|
||||
|
||||
const CHART_COLORS = {
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
red: "#ef4444",
|
||||
};
|
||||
|
||||
function hitColor(hitRate: number) {
|
||||
if (hitRate >= 80) return CHART_COLORS.green;
|
||||
if (hitRate >= 60) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function maeColor(mae: number) {
|
||||
if (mae <= 1.5) return CHART_COLORS.green;
|
||||
if (mae <= 2.5) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function brierColor(score: number) {
|
||||
if (score <= 0.1) return CHART_COLORS.green;
|
||||
if (score <= 0.25) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
export function TrainingAccuracyCharts({
|
||||
debChartData,
|
||||
muChartData,
|
||||
}: {
|
||||
debChartData: DebChartRow[];
|
||||
muChartData: MuChartRow[];
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{debChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||
/>
|
||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{muChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ Brier Score by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||
/>
|
||||
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCcw, TrendingUp, TrendingDown, Target, Activity } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { SystemStatusPayload } from "@/types/ops";
|
||||
import { CHART_TOOLTIP_STYLE } from "@/lib/chart-utils";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
ComposedChart, Line, Legend, Cell, LabelList,
|
||||
} from "recharts";
|
||||
|
||||
const TrainingAccuracyCharts = dynamic(
|
||||
() => import("./TrainingAccuracyCharts").then((mod) => mod.TrainingAccuracyCharts),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="h-[400px] animate-pulse rounded-lg bg-slate-100" />,
|
||||
},
|
||||
);
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
@@ -61,37 +65,6 @@ interface CityAccuracy {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
green: "#22c55e",
|
||||
yellow: "#eab308",
|
||||
red: "#ef4444",
|
||||
blue: "#3b82f6",
|
||||
cyan: "#06b6d4",
|
||||
purple: "#a855f7",
|
||||
slate: "#64748b",
|
||||
emerald: "#10b981",
|
||||
amber: "#f59e0b",
|
||||
rose: "#f43f5e",
|
||||
};
|
||||
|
||||
function hitColor(hitRate: number) {
|
||||
if (hitRate >= 80) return CHART_COLORS.green;
|
||||
if (hitRate >= 60) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function maeColor(mae: number) {
|
||||
if (mae <= 1.5) return CHART_COLORS.green;
|
||||
if (mae <= 2.5) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
function brierColor(score: number) {
|
||||
if (score <= 0.1) return CHART_COLORS.green;
|
||||
if (score <= 0.25) return CHART_COLORS.yellow;
|
||||
return CHART_COLORS.red;
|
||||
}
|
||||
|
||||
export function TrainingPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [status, setStatus] = useState<SystemStatusPayload | null>(null);
|
||||
@@ -221,115 +194,7 @@ export function TrainingPageClient() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* DEB Accuracy Charts */}
|
||||
{debChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DEB MAE by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={debChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} unit="°" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}°`, "MAE"]}
|
||||
/>
|
||||
<Bar dataKey="mae" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{debChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={maeColor(entry.mae)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="mae" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}°`} />
|
||||
</Bar>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Mu Probability Charts */}
|
||||
{muChartData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ Brier Score by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 0.5]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [Number(value).toFixed(4), "Brier Score"]}
|
||||
/>
|
||||
<Bar dataKey="brierScore" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={brierColor(entry.brierScore)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="brierScore" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => Number(v).toFixed(3)} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>概率 μ 命中率 by 城市</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={muChartData} margin={{ top: 8, right: 8, left: 8, bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
|
||||
<XAxis dataKey="name" angle={-45} textAnchor="end" tick={{ fill: "#94a3b8", fontSize: 11 }} interval={0} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={CHART_TOOLTIP_STYLE}
|
||||
formatter={(value: unknown) => [`${Number(value).toFixed(1)}%`, "命中率"]}
|
||||
/>
|
||||
<Bar dataKey="hitRate" radius={[4, 4, 0, 0]} maxBarSize={36}>
|
||||
{muChartData.map((entry, i) => (
|
||||
<Cell key={i} fill={hitColor(entry.hitRate)} fillOpacity={0.85} />
|
||||
))}
|
||||
<LabelList dataKey="hitRate" position="top" style={{ fill: "#94a3b8", fontSize: 10 }} formatter={(v: unknown) => `${Number(v)}%`} />
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
<TrainingAccuracyCharts debChartData={debChartData} muChartData={muChartData} />
|
||||
|
||||
{/* City coverage */}
|
||||
{modelCities ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ type TrackableAnalyticsEvent =
|
||||
| "trial_created"
|
||||
| "payment_start"
|
||||
| "payment_success"
|
||||
| "degraded_auth_profile"
|
||||
| "signup_completed"
|
||||
| "dashboard_active"
|
||||
| "paywall_feature_clicked"
|
||||
@@ -41,6 +42,15 @@ function getStoredId(storage: Storage, key: string) {
|
||||
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() {
|
||||
if (!isClient()) return "";
|
||||
try {
|
||||
@@ -88,6 +98,11 @@ export function trackAppEvent(
|
||||
...payload,
|
||||
path: window.location.pathname,
|
||||
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(),
|
||||
},
|
||||
};
|
||||
|
||||
+23
-3
@@ -16,16 +16,29 @@ export const opsApi = {
|
||||
systemStatus() {
|
||||
return opsFetch<Record<string, unknown>>("/api/system/status");
|
||||
},
|
||||
sourceHealth(limit = 80) {
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/source-health?limit=${limit}`);
|
||||
},
|
||||
paymentRuntime() {
|
||||
return opsFetch<Record<string, unknown>>("/api/payments/runtime");
|
||||
},
|
||||
listPayments(limit = 50) {
|
||||
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) {
|
||||
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>;
|
||||
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;
|
||||
}>(`/api/ops/analytics/funnel?days=${days}`);
|
||||
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 evt = raw?.events?.[key];
|
||||
const count = evt?.total ?? 0;
|
||||
const uniqueActors = evt?.unique_actors ?? evt?.unique_users ?? 0;
|
||||
let pct_of_prev: number | undefined;
|
||||
if (i > 0) {
|
||||
const prevCount = raw?.events?.[stepOrder[i - 1]]?.total ?? 0;
|
||||
@@ -48,9 +62,15 @@ export const opsApi = {
|
||||
} else {
|
||||
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) {
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/users?q=${encodeURIComponent(q)}&limit=${limit}`);
|
||||
|
||||
@@ -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 = {
|
||||
rpc?: string;
|
||||
chain_id?: number;
|
||||
@@ -91,6 +123,43 @@ export type PaymentsPayload = {
|
||||
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 = {
|
||||
telegram_id?: number;
|
||||
username?: string;
|
||||
|
||||
+81
-12
@@ -5,8 +5,10 @@ import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List, Set
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
@@ -1002,7 +1004,7 @@ class DBManager:
|
||||
event_type: Optional[str] = None,
|
||||
since_iso: Optional[str] = None,
|
||||
) -> 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()
|
||||
params: List[Any] = []
|
||||
clauses: List[str] = []
|
||||
@@ -1049,7 +1051,7 @@ class DBManager:
|
||||
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
|
||||
safe_days = max(1, min(int(days or 30), 365))
|
||||
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 = [
|
||||
"landing_view",
|
||||
"enter_terminal",
|
||||
@@ -1059,6 +1061,7 @@ class DBManager:
|
||||
"payment_start",
|
||||
"payment_success",
|
||||
]
|
||||
diagnostic_event_names = ["degraded_auth_profile"]
|
||||
event_aliases = {
|
||||
"landing_view": ("landing_view",),
|
||||
"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}
|
||||
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:
|
||||
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)
|
||||
if not event_type:
|
||||
continue
|
||||
summary[event_type]["total"] += 1
|
||||
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:
|
||||
actor_key = f"user:{user_id}"
|
||||
user_sets[event_type].add(user_id)
|
||||
elif client_id:
|
||||
actor_key = f"client:{client_id}"
|
||||
elif session_id:
|
||||
actor_key = f"session:{session_id}"
|
||||
else:
|
||||
actor_key = f"event:{row.get('id')}"
|
||||
actor_key = _actor_key(row)
|
||||
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:
|
||||
summary[name]["unique_users"] = len(user_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]:
|
||||
denominator = int(summary[denominator_key]["unique_actors"] or 0)
|
||||
@@ -1120,6 +1182,13 @@ class DBManager:
|
||||
"window_days": safe_days,
|
||||
"since": since_dt.isoformat(),
|
||||
"events": summary,
|
||||
"diagnostics": diagnostics,
|
||||
"traffic": {
|
||||
"referrers": _top(referrer_counts),
|
||||
"countries": _top(country_counts),
|
||||
"devices": _top(device_counts),
|
||||
"landing_paths": _top(landing_path_counts),
|
||||
},
|
||||
"rates": {
|
||||
"enter_terminal_rate": _rate("enter_terminal", "landing_view"),
|
||||
"login_start_rate": _rate("login_start", "enter_terminal"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
@@ -71,6 +71,7 @@ def test_standard_growth_funnel_events_are_trackable():
|
||||
"trial_created",
|
||||
"payment_start",
|
||||
"payment_success",
|
||||
"degraded_auth_profile",
|
||||
}.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": 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": 8, "event_type": "degraded_auth_profile", "user_id": "", "client_id": "auth:u1", "session_id": "", "payload": {"reason": "backend_500"}},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
DBManager,
|
||||
@@ -103,8 +105,241 @@ def test_standard_growth_funnel_summary_order(monkeypatch):
|
||||
"payment_success",
|
||||
]
|
||||
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():
|
||||
response = client.get("/api/cities")
|
||||
|
||||
@@ -6,12 +6,14 @@ from web.core import GrantPointsRequest
|
||||
from web.services.ops_api import (
|
||||
extend_ops_subscription,
|
||||
get_ops_analytics_funnel,
|
||||
get_ops_billing_risk,
|
||||
get_ops_config,
|
||||
get_ops_sensitive_config,
|
||||
get_ops_memberships_growth,
|
||||
get_ops_memberships_overview,
|
||||
get_ops_health_check,
|
||||
get_ops_logs,
|
||||
get_ops_source_health,
|
||||
get_ops_truth_history,
|
||||
get_ops_weekly_leaderboard,
|
||||
get_ops_user_subscriptions,
|
||||
@@ -92,6 +94,11 @@ async def ops_payments(request: Request, limit: int = 50):
|
||||
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")
|
||||
async def ops_grant_points(request: Request, body: GrantPointsRequest):
|
||||
return grant_ops_points(request, body)
|
||||
@@ -213,6 +220,15 @@ async def ops_health_check(request: 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")
|
||||
async def ops_training_accuracy(request: Request):
|
||||
return get_ops_training_accuracy(request)
|
||||
|
||||
@@ -73,6 +73,7 @@ TRACKABLE_ANALYTICS_EVENTS = {
|
||||
"trial_created",
|
||||
"payment_start",
|
||||
"payment_success",
|
||||
"degraded_auth_profile",
|
||||
"signup_completed",
|
||||
"dashboard_active",
|
||||
"paywall_feature_clicked",
|
||||
|
||||
@@ -67,6 +67,41 @@ _OBSERVATION_SOURCE_PROFILES: Dict[str, Dict[str, Any]] = {
|
||||
"expected_grace_sec": 900,
|
||||
"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": {
|
||||
"label": "METAR",
|
||||
"native_update_interval_sec": 900,
|
||||
@@ -117,6 +152,16 @@ def canonical_observation_source_code(value: Any) -> str:
|
||||
return "cwa"
|
||||
if "mgm" in raw:
|
||||
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:
|
||||
return "noaa"
|
||||
if "wunderground" in raw or raw == "wu":
|
||||
|
||||
+669
-21
@@ -3,13 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
import requests as _requests
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
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]:
|
||||
_require_ops(request)
|
||||
db = DBManager()
|
||||
@@ -321,35 +424,351 @@ def list_ops_payments(
|
||||
) -> Dict[str, Any]:
|
||||
"""List successful payment records from Supabase."""
|
||||
_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))
|
||||
resp = _requests.get(
|
||||
f"{supabase_url}/rest/v1/payments",
|
||||
headers=headers,
|
||||
params={
|
||||
rows = _supabase_rest_rows(
|
||||
"payments",
|
||||
{
|
||||
"select": "id,user_id,amount,currency,chain,tx_hash,status,created_at",
|
||||
"order": "created_at.desc",
|
||||
"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)}
|
||||
|
||||
|
||||
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]:
|
||||
admin = _require_ops(request) or {}
|
||||
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]:
|
||||
_require_ops(request)
|
||||
import os
|
||||
|
||||
Reference in New Issue
Block a user