feat(analytics-health): 新增埋点/上报成功率看板(super_admin) (#42)
功能明细
总览卡:埋点成功率、上报成功率两大率居前(<90% 标红并打「偏低」标);attempted / drop_capture / delivered / drop_undelivered 四原子量次之(drop_* > 0 橙色提示),每项带 tooltip 口径说明。
按天双折线趋势:埋点率(蓝)/ 上报率(橙),Y 轴锁 [0,1] 显示百分比。
维度下钻表:event / app_ver / oem 三档切换,后端已按上报率升序返回(最差在前),率 <90% 标红。
顶部「刷新」+「数据更新于」时间戳;日期区间支持 今天 / 近 7 天 / 近 30 天 预设。
上线依赖(部署顺序)
页面依赖后端 feat/analytics-success-rate 分支的三个只读端点 /admin/api/analytics-health/{overview,trend,breakdown}。后端须先合并部署,否则前端会 404。
---------
Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
@@ -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<Dim, string> = { 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) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{label}
|
||||||
|
<Tooltip title={tip}>
|
||||||
|
<InfoCircleOutlined style={{ color: '#bfbfbf', cursor: 'help' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 大率卡:率偏低标红 + 「偏低」标。
|
||||||
|
function RateStat({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: number | null | undefined;
|
||||||
|
loading: boolean;
|
||||||
|
}) {
|
||||||
|
const low = value != null && value < LOW;
|
||||||
|
return (
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<Space size={6}>
|
||||||
|
{title}
|
||||||
|
{low && <Tag color="error">偏低</Tag>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
value={value == null ? '-' : (value * 100).toFixed(1)}
|
||||||
|
suffix={value == null ? undefined : '%'}
|
||||||
|
valueStyle={low ? { color: '#cf1322' } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原子量卡(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 (
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic
|
||||||
|
title={tip ? withTip(title, tip) : title}
|
||||||
|
value={value == null ? '-' : value}
|
||||||
|
valueStyle={bad ? { color: '#d46b08' } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Dim>('event');
|
||||||
|
const [overview, setOverview] = useState<HealthMetrics | null>(null);
|
||||||
|
const [trend, setTrend] = useState<HealthTrendPoint[]>([]);
|
||||||
|
const [rows, setRows] = useState<HealthBreakdownRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false); // 总览 + 趋势
|
||||||
|
const [rowsLoading, setRowsLoading] = useState(false); // 下钻表
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 总览 + 趋势:只随区间变。
|
||||||
|
const loadMain = useCallback(async () => {
|
||||||
|
const params = toUtcRange(range[0], range[1]);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [ov, tr] = await Promise.all([
|
||||||
|
api.get<HealthMetrics>('/admin/api/analytics-health/overview', { params }),
|
||||||
|
api.get<HealthTrendPoint[]>('/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<HealthBreakdownRow[]>('/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<HealthBreakdownRow> = [
|
||||||
|
{ title: DIM_LABEL[dim], dataIndex: 'key', key: 'key', render: (v: string) => v || '(unknown)' },
|
||||||
|
{
|
||||||
|
title: '埋点成功率',
|
||||||
|
key: 'track',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
render: (_: unknown, r: HealthBreakdownRow) => (
|
||||||
|
<span style={r.track_success_rate != null && r.track_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
|
||||||
|
{pct(r.track_success_rate)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '上报成功率',
|
||||||
|
key: 'report',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
render: (_: unknown, r: HealthBreakdownRow) => (
|
||||||
|
<span style={r.report_success_rate != null && r.report_success_rate < LOW ? { color: '#cf1322', fontWeight: 600 } : undefined}>
|
||||||
|
{pct(r.report_success_rate)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
||||||
|
<Space align="center">
|
||||||
|
<h2 style={{ margin: 0 }}>埋点成功率</h2>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
端上埋点/上报可信度;数据分钟级延迟,非实时
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Space align="center">
|
||||||
|
{updatedAt && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
数据更新于 {updatedAt}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={refresh} loading={loading || rowsLoading}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space size={6} align="center">
|
||||||
|
<Typography.Text type="secondary">日期(北京)</Typography.Text>
|
||||||
|
<RangePicker
|
||||||
|
value={range}
|
||||||
|
onChange={(v) => 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)] },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 总览:两大率居前,四原子量次之 */}
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<RateStat title="埋点成功率" value={overview?.track_success_rate} loading={loading} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<RateStat title="上报成功率" value={overview?.report_success_rate} loading={loading} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<CountStat title="尝试总数 attempted" value={overview?.attempted} loading={loading} tip={METRIC_TIP.attempted} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<CountStat title="采集丢弃 drop_capture" value={overview?.drop_capture} loading={loading} warn tip={METRIC_TIP.drop_capture} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<CountStat title="送达 delivered" value={overview?.delivered} loading={loading} tip={METRIC_TIP.delivered} />
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6}>
|
||||||
|
<CountStat title="淘汰未送达 drop_undelivered" value={overview?.drop_undelivered} loading={loading} warn tip={METRIC_TIP.drop_undelivered} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */}
|
||||||
|
<Card title="按天趋势(北京时间)" size="small" style={{ marginBottom: 16 }}>
|
||||||
|
{trendLong.length > 0 ? (
|
||||||
|
<Line {...chartConfig} />
|
||||||
|
) : (
|
||||||
|
<Empty style={{ padding: '60px 0' }} description={loading ? '加载中…' : '该区间暂无埋点数据'} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */}
|
||||||
|
<Card
|
||||||
|
title="维度下钻(按上报成功率升序,最差在前)"
|
||||||
|
size="small"
|
||||||
|
extra={
|
||||||
|
<Segmented
|
||||||
|
value={dim}
|
||||||
|
onChange={(v) => setDim(v as Dim)}
|
||||||
|
options={[
|
||||||
|
{ label: '按事件', value: 'event' },
|
||||||
|
{ label: '按版本', value: 'app_ver' },
|
||||||
|
{ label: '按 ROM', value: 'oem' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table<HealthBreakdownRow>
|
||||||
|
rowKey="key"
|
||||||
|
dataSource={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={rowsLoading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 820 }}
|
||||||
|
locale={{ emptyText: rowsLoading ? '加载中…' : '该区间暂无数据' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
GiftOutlined,
|
GiftOutlined,
|
||||||
HeartOutlined,
|
HeartOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
@@ -57,6 +58,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
|
||||||
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||||
|
{ key: '/analytics-health', icon: <LineChartOutlined />, label: '埋点成功率' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -727,3 +727,27 @@ export interface CpsStats {
|
|||||||
total_est_commission_cents: number;
|
total_est_commission_cents: number;
|
||||||
total_settled_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)"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user