Improve ops monitoring and chart loading

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