# 展示数据查询(前端取数) 本文定义前端从 Backend 拉取展示数据的方式:时间段、步长、指标选择、响应格式、实时跟随、缓存与限额。端点清单见 [api.md](../api/api.md) 第 2 节;指标与累加器见 [analysis.md](analysis.md)。 ## 1. 设计目标 1. **任意时间段**:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。 2. **短时间段足够精细**:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。 3. **汇总精确**:“这段时间内的丢包率、p95”由累加器精确合并得出,**不是各点的平均**。 4. **快**:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。 5. **新增指标时前端不用改**:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。 ## 2. 时间段与步长 ### 2.1 参数 | 参数 | 取值 | 说明 | | --- | --- | --- | | `range` | `5m`、`15m`、`30m`、`1h`、`3h`、`6h`、`12h`、`24h`、`7d`、`30d`、`90d`、`365d`,或任意 `<数字>` | 相对时间段,`to = now`,可以跟随实时数据 | | `from` + `to` | RFC 3339 UTC | 绝对区间,左闭右开 `[from, to)`;与 `range` 互斥 | | `step` | `auto`(默认),或下列常用步长之一 | 指定的步长比数据源所能提供的更细时,自动降级,并在响应中标记 `degraded` | | `anchor` | `now`(默认)或 `latest` | 只用于序列接口的相对时间段(`range=`):`latest` 以这条线路最后一个样本为终点(最后样本在最近 5 秒内就仍以当前时间为终点)。离线的线路因此显示它最后一段数据,而不是空图;响应里的 `from/to` 是实际使用的窗口,`live` 为 false | | `tz` | IANA 时区,默认 `UTC` | 只影响 ≥ 1d 的分桶对齐,比如“按天”统计时从本地零点切分 | 常用步长:`1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d` ### 2.2 自动步长 `step = 常用步长中 ≥ 区间长度 / 720 的最小值`,即每条曲线最多约 720 个点。 | 时间段 | 步长 | 点数 | 数据源 | | --- | --- | --- | --- | | 5m | 1s | 300 | 内存热层 | | 15m | 2s | 450 | 内存热层 | | 30m | 5s | 360 | 内存热层 | | 1h | 5s | 720 | 内存热层 | | 6h | 30s | 720 | 10s 层 | | 24h | 2m | 720 | 1m 层 | | 7d | 15m | 672 | 5m 层 | | 30d | 1h | 720 | 1h 层 | | 90d | 3h | 720 | 1h 层 | - 桶对齐到 UTC 的整数倍步长。 - 起点不对齐的绝对区间:第一个桶和最后一个桶可能不完整,会在响应的 `edge_partial` 中标出。 ## 3. 数据分层 ```text 精细 ◀────────────────────────────────────────────────────▶ 粗略 ┌─────────────────┬──────────────┬──────────────┬─────────────┬─────────────┐ │ 热层(内存) │ metric_10s │ metric_1m │ metric_5m │ metric_1h │ │ 1s 累加器 │ 保留 48 小时 │ 保留 30 天 │ 保留 365 天 │ 永久 │ │ 最近 1 小时(环形缓冲)│ SQLite │ SQLite │ SQLite │ SQLite │ └────────┬────────┴──────────────┴──────────────┴─────────────┴─────────────┘ │ 重启后热层为空 → 按需从原始块回填(最近 1 小时,后台以低优先级执行) 原始段文件(30 天)── 超出热层时间范围的 1s~5s 精度查询 → 从原始块现算(区间 ≤ 15 分钟) ``` **选择数据源的规则**:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择**最粗**的一层,然后把累加器按步长现场合并。 示例: - `step = 30s` 时,取 10s 层,每 3 个桶合并一次; - `step = 2m` 时,取 1m 层,每 2 个桶合并一次。 合并方式是计数相加、直方图逐桶相加,所以结果精确。 | 情况 | 处理方式 | | --- | --- | | 时间段在最近 1 小时内,步长 ≥ 1s | 热层 | | 时间段在 1 小时之前、30 天之内,步长 < 10s,区间 ≤ 15 分钟 | 从原始块现算(全局最多同时 2 个查询) | | 同上,但区间 > 15 分钟 | 降级为 10s 层或 1m 层,并标记 `degraded: true` | | 所需的层已经过期 | 降级为下一个更粗的可用层,并标记 `degraded: true` | **热层内存估算**:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。 ## 4. 指标选择 ### 4.1 按 id 选取 `metrics=loss.down,tcp.retrans,rtt.p95` id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节;所有可用的 id 可以从 `/api/public/catalog/metrics` 获取。 ### 4.2 按分组(preset)选取 `preset=udp,tcp` 分组在 `Backend.Analysis` 的注册表中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。 | 分组 | 指标 | | --- | --- | | `udp` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run_max`、`reorder`、`dup` | | `tcp` | `tcp.rtt.p50`、`tcp.rtt.p95`、`tcp.rtt.p99`、`tcp.rtt.p999`、`tcp.rtt.max`、`tcp.rtt.tail`、`tcp.slow`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans`、`tcp.srtt.p95` | | `connect` | `connect.success`、`connect.tcp_ms.p95`、`connect.tls_ms.p95`、`connect.fail.timeout`、`connect.fail.rst`、`connect.fail.tls` | | `session` | `hold.resets.per_h`、`hold.lifetime_max`、`probe.endpoint_changes` | | `scores` | `score.base`、`score.mc`、`score.hy2`、`score.reality` | 目录中每个 id 都附带 `unit`、`label`、`description`、`better`(`lower` 或 `higher`,用于前端配色),以及 `thresholds`(优、良、差三档阈值,由模型给出)。 ## 5. 响应格式(列式) ```json { "agent_id": "agt_…", "from": "2026-09-28T10:00:00.000Z", "to": "2026-09-28T10:15:00.000Z", "step": "2s", "source": "hot", "live": true, "degraded": false, "t": [1759053600000, 1759053602000, 1759053604000], "values": { "loss.down": [0.0, 0.0, 10.0], "rtt.p95": [48.1, 49.0, 61.2], "tcp.retrans": [0.0, 0.1, null] }, "summary": { "metrics": { "loss.down": 0.42, "rtt.p95": 61.3, "tcp.retrans": 0.08 }, "samples": { "udp_echo": 8990, "tcp_echo": 17980 }, "coverage": 0.998 }, "edge_partial": { "first": false, "last": true }, "gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }], "events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }], "versions": { "loss.down": "a1f3…" } } ``` - 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api.md](../api/api.md) 第 7.4 节“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。 - 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。 - `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。 - `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。 ## 6. 端点 | 端点 | 用途 | 主要参数 | | --- | --- | --- | | `GET /api/public/overview` | 首页排行:每条线路在该时间段内的汇总,并支持排序 | `range`(默认 `5m`)、`preset` 或 `metrics`、`sort`、`order` | | `GET /api/public/lines/{line_id}/series` | 单条线路的时序 | `range` 或 `from/to`、`step`、`preset` 或 `metrics` | | `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `agents`、`metric`(单个)、`range` 或 `from/to`、`step` | | `GET /api/public/lines/{line_id}/distribution` | 该时间段内的分布(直方图、CDF) | `metric`(直方图类,如 `rtt`、`jitter.down`、`tcp.rtt`)、`range` 或 `from/to` | | `GET /api/public/lines/{line_id}/heatmap` | 时间 × 延迟桶的热力图(类似 smokeping) | `metric`、`range` 或 `from/to`、`step` | | `GET /api/public/catalog/presets` | 分组定义 | — | `overview` 的响应示例: ```json { "range": "15m", "from": "…", "to": "…", "lines": [ { "agent_id": "agt_…", "name": "jp-1", "online": true, "summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 }, "coverage": 1.0 } ] } ``` `compare` 的响应示例:`{ "t":[…], "values":{ "agt_a":[…], "agt_b":[…] }, "summary":{ "agt_a":0.1, "agt_b":0.4 } }` `distribution` 的响应示例:`{ "metric":"rtt", "unit":"ms", "buckets":[{"le":40.0,"count":812}, …], "p50":44.2, "p95":61.0, "p99":140.2, "count":8990 }` `heatmap` 的响应示例:`{ "t":[…], "edges":[…], "counts":[[…],[…]] }`,其中 `counts[i]` 是时间桶 `t[i]` 在各个延迟桶中的计数。 ## 7. 实时跟随 ```text 前端选择“近 15 分钟” ① GET /lines/{id}/series?range=15m&preset=udp,tcp → 一次性获取 450 个点和汇总 ② SSE /api/public/live?topics=series:{id}:2s:udp,tcp → 每当原始块到达(约每 10 s), 推送一批新的 2s 桶和最新的汇总 event: series data: {"agent_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}} ③ 前端把新点追加到末尾,并丢弃超出窗口的旧点;用推送来的汇总替换原来的汇总 前端切换为“自定义 09:00–10:00”→ 只执行 ①(live=false) ``` - SSE 的订阅主题格式为 `series:{line_id}:{step}:{preset}`,只允许 ≤ 1m 的步长;更粗的步长由前端定时重新拉取即可。 - `overview` 也可以跟随实时:订阅 `overview:{range}:{preset}`,每 10 s 推送一次最新汇总。 - 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,**典型值为 10~12 s**。1s 步长的点会按批次到达,而不是每秒一个。 ## 8. 缓存与限额 | 请求类型 | 缓存策略 | | --- | --- | | 已经结束的绝对区间(`to` 早于当前时间 − 2 分钟) | `Cache-Control: public, max-age=31536000, immutable`,并带 ETag(由参数和指标版本计算) | | 相对时间段(`to = now`) | `Cache-Control: no-store` | | 目录(catalog) | `max-age=300`,并带 ETag | | 限额 | 默认值 | 超出时 | | --- | --- | --- | | 单次响应的点数 | 每条曲线 ≤ 2000 | 422 `range_too_large`,并给出建议的步长 | | 单次请求的指标数 | ≤ 32 | 422 `too_many_metrics` | | `compare` 的线路数 | ≤ 20 | 422 | | 从原始块现算的查询 | 全局最多同时 2 个,区间 ≤ 15 分钟 | 429 `too_many_requests`(带 `Retry-After`)或者降级 | | 查询超时 | 10 s | 503 | ## 9. 实现位置 ```text Backend.Api/Public/SeriesEndpoints.cs 参数解析、校验、缓存头(不含业务) Backend.Application/Query/ ├─ RangeResolver range 或 from/to 与 step → 对齐后的桶序列 ├─ SourcePlanner 选择数据源(热层 / 10s / 1m / 5m / 1h / 原始块现算),判断是否降级 ├─ SeriesQuery 读取累加器 → 按步长合并 → 导出统计量 → 列式结果 ├─ SummaryQuery 整个时间段的精确合并(overview 和 series 共用) └─ DistributionQuery 合并直方图 → 分桶、CDF、热力图 Backend.Application/Live/LiveHub 热层环形缓冲(1s,1 小时)+ SSE 主题 Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码) ``` 新增指标时,这套查询代码不需要任何改动:`SeriesQuery` 只通过注册表按 id 读取累加器并导出值。