diff --git a/src/app/(main)/analytics-health/page.tsx b/src/app/(main)/analytics-health/page.tsx new file mode 100644 index 0000000..82766a9 --- /dev/null +++ b/src/app/(main)/analytics-health/page.tsx @@ -0,0 +1,350 @@ +'use client'; + +// 埋点 / 上报成功率大盘:度量客户端埋点数据可信度,两段漏斗两个大率。 +// 数据源 analytics_selfstat 快照差分(与埋点日志表无关);三个只读端点在 admin 服务(8771)。 +// 时区:端点吃 UTC 时刻(见 toUtcRange);trend.day 已是北京日,直接展示不再换算。 +import { useCallback, useEffect, useMemo, useState } from 'react'; +import dynamic from 'next/dynamic'; +import type { ColumnsType } from 'antd/es/table'; +import { + App, + Button, + Card, + Col, + DatePicker, + Empty, + Row, + Segmented, + Space, + Statistic, + Table, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import { api, errMsg } from '@/lib/api'; +import type { HealthBreakdownRow, HealthMetrics, HealthTrendPoint } from '@/lib/types'; + +dayjs.extend(utc); +dayjs.extend(timezone); +const CN = 'Asia/Shanghai'; + +// @ant-design/plots 依赖 DOM,关掉 SSR 仅客户端渲染(否则 Next 预渲染报 document undefined)。 +// 用仓内 v2 写法(colorField/scale/axis/interaction),非指南里的 v1(seriesField/yAxis)。 +const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false }); + +const { RangePicker } = DatePicker; +type Dim = 'event' | 'app_ver' | 'oem'; +const DIM_LABEL: Record = { event: '事件', app_ver: '版本', oem: 'ROM' }; +const LOW = 0.9; // 成功率低于此阈值标红(定位掉数据最狠的点位/版本/ROM) + +// 北京日范围 → UTC ISO 瞬时(右开区间;date_to = 结束日 +1 天的北京零点)。 +// 端点按 UTC 时刻比较 created_at,直接传 YYYY-MM-DD 会被当 UTC 零点,与北京日差 8 小时。 +function toUtcRange(start: Dayjs, end: Dayjs) { + return { + date_from: dayjs.tz(start.format('YYYY-MM-DD'), CN).utc().toISOString(), + date_to: dayjs.tz(end.add(1, 'day').format('YYYY-MM-DD'), CN).utc().toISOString(), + }; +} + +// 率:null → "-"(分母为 0 ≠ 0%);否则百分比一位小数。 +const pct = (v: number | null | undefined) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`); +const int = (v: number | null | undefined) => + v == null ? '-' : new Intl.NumberFormat('zh-CN').format(v); + +// 四原子计数说明(总览卡标题 + 下钻表头 tooltip 共用,一处改两处同步)。 +const METRIC_TIP = { + attempted: '端上 track() 被调用的次数,是埋点段分母。埋点成功率 =(attempted − drop_capture)/ attempted。', + drop_capture: '采集环节因异常(队列满 / 序列化失败等)被丢弃、未能落地的事件数,计入埋点失败。', + delivered: '成功送达并入库后端的事件数。上报成功率 = delivered /(delivered + drop_undelivered)。', + drop_undelivered: + '已落盘但久未送达、被队列淘汰的事件数(弱网 / 被 ROM 杀后台等);在途未送达的不计入。', +} as const; + +// 标题/表头后跟一个说明图标(hover 出 tooltip)。 +const withTip = (label: string, tip: string) => ( + + {label} + + + + +); + +// 大率卡:率偏低标红 + 「偏低」标。 +function RateStat({ + title, + value, + loading, +}: { + title: string; + value: number | null | undefined; + loading: boolean; +}) { + const low = value != null && value < LOW; + return ( + + + {title} + {low && 偏低} + + } + value={value == null ? '-' : (value * 100).toFixed(1)} + suffix={value == null ? undefined : '%'} + valueStyle={low ? { color: '#cf1322' } : undefined} + /> + + ); +} + +// 原子量卡(attempted / drop_capture / delivered / drop_undelivered)。 +function CountStat({ + title, + value, + loading, + warn, + tip, +}: { + title: string; + value: number | null | undefined; + loading: boolean; + warn?: boolean; // drop_* > 0 时橙色提示 + tip?: string; +}) { + const bad = warn && value != null && value > 0; + return ( + + + + ); +} + +export default function AnalyticsHealthPage() { + const { message } = App.useApp(); + const [range, setRange] = useState<[Dayjs, Dayjs]>([ + dayjs().tz(CN).subtract(6, 'day'), + dayjs().tz(CN), + ]); + const [dim, setDim] = useState('event'); + const [overview, setOverview] = useState(null); + const [trend, setTrend] = useState([]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); // 总览 + 趋势 + const [rowsLoading, setRowsLoading] = useState(false); // 下钻表 + const [updatedAt, setUpdatedAt] = useState(null); + + // 总览 + 趋势:只随区间变。 + const loadMain = useCallback(async () => { + const params = toUtcRange(range[0], range[1]); + setLoading(true); + try { + const [ov, tr] = await Promise.all([ + api.get('/admin/api/analytics-health/overview', { params }), + api.get('/admin/api/analytics-health/trend', { params }), + ]); + setOverview(ov.data); + setTrend(tr.data); + setUpdatedAt(dayjs().tz(CN).format('YYYY-MM-DD HH:mm')); + } catch (e) { + message.error(errMsg(e, '加载成功率数据失败')); + } finally { + setLoading(false); + } + }, [range, message]); + + // 下钻表:随区间或维度变(切 dim 只重拉表,不动总览/趋势)。 + const loadRows = useCallback(async () => { + const params = toUtcRange(range[0], range[1]); + setRowsLoading(true); + try { + const bd = await api.get('/admin/api/analytics-health/breakdown', { + params: { ...params, dim }, + }); + setRows(bd.data); + } catch (e) { + message.error(errMsg(e, '加载下钻数据失败')); + } finally { + setRowsLoading(false); + } + }, [range, dim, message]); + + useEffect(() => { + void loadMain(); + }, [loadMain]); + useEffect(() => { + void loadRows(); + }, [loadRows]); + + const refresh = () => { + void loadMain(); + void loadRows(); + }; + + // 折线长表:每天 ×{埋点率, 上报率}。保留 rate=null 的点(不 filter)→ G2 在断点处断线, + // 真正实现「null 点跳过不连线」;若 filter 掉 null 会把两侧点连成一条误导直线。 + const trendLong = useMemo( + () => + trend.flatMap((p) => [ + { day: p.day, metric: '埋点成功率', rate: p.track_success_rate }, + { day: p.day, metric: '上报成功率', rate: p.report_success_rate }, + ]), + [trend], + ); + + const chartConfig = { + data: trendLong, + xField: 'day', + yField: 'rate', + colorField: 'metric', + height: 300, + scale: { + y: { domainMin: 0, domainMax: 1, nice: false }, + color: { range: ['#1677ff', '#fa8c16'] }, // 埋点率蓝 / 上报率橙(按长表首次出现序) + }, + axis: { y: { labelFormatter: (v: number) => `${(v * 100).toFixed(0)}%` } }, + point: { style: { r: 3 } }, + legend: { color: { position: 'top' as const } }, + tooltip: { items: [{ channel: 'y' as const, valueFormatter: (v: number) => pct(v) }] }, + interaction: { tooltip: { shared: true } }, + }; + + const columns: ColumnsType = [ + { title: DIM_LABEL[dim], dataIndex: 'key', key: 'key', render: (v: string) => v || '(unknown)' }, + { + title: '埋点成功率', + key: 'track', + width: 110, + align: 'right', + render: (_: unknown, r: HealthBreakdownRow) => ( + + {pct(r.track_success_rate)} + + ), + }, + { + title: '上报成功率', + key: 'report', + width: 110, + align: 'right', + render: (_: unknown, r: HealthBreakdownRow) => ( + + {pct(r.report_success_rate)} + + ), + }, + { title: withTip('尝试总数', METRIC_TIP.attempted), dataIndex: 'attempted', key: 'attempted', width: 120, align: 'right', render: (v: number) => int(v) }, + { title: withTip('采集丢弃', METRIC_TIP.drop_capture), dataIndex: 'drop_capture', key: 'drop_capture', width: 128, align: 'right', render: (v: number) => int(v) }, + { title: withTip('送达', METRIC_TIP.delivered), dataIndex: 'delivered', key: 'delivered', width: 108, align: 'right', render: (v: number) => int(v) }, + { title: withTip('淘汰未送达', METRIC_TIP.drop_undelivered), dataIndex: 'drop_undelivered', key: 'drop_undelivered', width: 150, align: 'right', render: (v: number) => int(v) }, + ]; + + return ( +
+
+ +

埋点成功率

+ + 端上埋点/上报可信度;数据分钟级延迟,非实时 + +
+ + {updatedAt && ( + + 数据更新于 {updatedAt} + + )} + + +
+ + + + 日期(北京) + v && v[0] && v[1] && setRange([v[0], v[1]])} + allowClear={false} + presets={[ + { label: '今天', value: [dayjs().tz(CN), dayjs().tz(CN)] }, + { label: '近 7 天', value: [dayjs().tz(CN).subtract(6, 'day'), dayjs().tz(CN)] }, + { label: '近 30 天', value: [dayjs().tz(CN).subtract(29, 'day'), dayjs().tz(CN)] }, + ]} + /> + + + + {/* 总览:两大率居前,四原子量次之 */} + + + + + + + + + + + + + + + + + + + + + + + + {/* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */} + + {trendLong.length > 0 ? ( + + ) : ( + + )} + + + {/* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */} + setDim(v as Dim)} + options={[ + { label: '按事件', value: 'event' }, + { label: '按版本', value: 'app_ver' }, + { label: '按 ROM', value: 'oem' }, + ]} + /> + } + > + + rowKey="key" + dataSource={rows} + columns={columns} + loading={rowsLoading} + pagination={false} + size="small" + scroll={{ x: 820 }} + locale={{ emptyText: rowsLoading ? '加载中…' : '该区间暂无数据' }} + /> + +
+ ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index db58979..b2c7c8d 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -10,6 +10,7 @@ import { FlagOutlined, GiftOutlined, HeartOutlined, + LineChartOutlined, LogoutOutlined, MessageOutlined, MoneyCollectOutlined, @@ -57,6 +58,7 @@ const NAV_GROUPS: NavGroup[] = [ { key: '/comparison-records', icon: , label: '比价记录' }, { key: '/cps', icon: , label: 'CPS收益' }, { key: '/device-liveness', icon: , label: '设备存活' }, + { key: '/analytics-health', icon: , label: '埋点成功率' }, ], }, { diff --git a/src/lib/types.ts b/src/lib/types.ts index ecb6de1..81ec88f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -727,3 +727,27 @@ export interface CpsStats { total_est_commission_cents: number; total_settled_commission_cents: number; } + +// ===== 埋点 / 上报成功率大盘(analytics_selfstat 快照差分聚合)===== +// 后端 feat/analytics-success-rate 分支的三个只读端点(/admin/api/analytics-health/*)。 +// rate 分母为 0 时后端给 null(前端显示 "-",不是 0%)。 + +/** 一组区间聚合指标:两大率 + 四原子量。 */ +export interface HealthMetrics { + attempted: number; // track 被调用次数(埋点段分母) + drop_capture: number; // 采集时异常丢弃(队列/序列化失败) + delivered: number; // 成功送达后端 + drop_undelivered: number; // 已落盘但久未送达被队列淘汰 + track_success_rate: number | null; // 埋点成功率 ∈ [0,1]:(attempted − drop_capture) / attempted + report_success_rate: number | null; // 上报成功率 ∈ [0,1]:delivered / (delivered + drop_undelivered) +} + +/** 趋势点:某北京天的指标(trend 端点,按北京天升序)。 */ +export interface HealthTrendPoint extends HealthMetrics { + day: string; // 北京日期 "YYYY-MM-DD" +} + +/** 下钻行:某维度值的指标(breakdown 端点,已按上报率升序,最差在前)。 */ +export interface HealthBreakdownRow extends HealthMetrics { + key: string; // 维度值(event 名 / app_ver / oem);缺失为 "(unknown)" +}