Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-30
115 changes: 115 additions & 0 deletions openspec/changes/archive/2026-05-30-live-pulse-bar/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
## Context

AutoRouter 已有一套进程内实时链路,本变更在其之上叠加一个面向「运行健康概览」的实时脉搏视图,而不是新造一套推送机制。现状如下:

```
请求收口 (request-logger.ts)
└─ notifyRequestLogChange(logEntry) // 完整请求行:statusCode/durationMs/totalTokens/upstreamId
└─ publishRequestLogLiveUpdate(event) // 进程内发布,当前仅转发 logId + statusCode
└─ subscribeRequestLogLiveUpdates // 订阅者
└─ /api/admin/logs/live (SSE) // 仅推送 request-log-changed,供日志页失效查询
└─ use-request-log-live // 前端 SSE 客户端,三态 + 降级轮询
```

现有仪表盘 `StatsCards` 走 `/admin/stats/overview`,按「今日/昨日」DB 聚合,60 秒刷新,粒度粗、非实时。`Topbar` 组件当前仅渲染标题,右侧整片空白,且为 `hidden md:block`(移动端隐藏)。全部 11 个管理页各自渲染 `<Topbar>`,因此把状态条做进 `Topbar` 即可全局常驻。

网关健康信号的现成来源:`health-checker.ts` 的 `getAllHealthStatusWithCircuitBreaker()` 同时返回上游健康状态与熔断状态,可一次性拼出「健康上游数/总数」与「熔断打开数」。

## Goals / Non-Goals

**Goals:**

- 在所有管理页顶栏常驻一条秒级实时运行状态条,复用既有 pub/sub + SSE + 三态降级语义。
- 指标为网关增强版:滚动 60 秒窗口的 req/min、错误率、平均延迟、TPM,外加健康上游数/总数与熔断打开数。
- 服务端滚动窗口聚合,避免前端在页面加载后从零累积窗口;DB 负载尽量低。
- 移动端提供紧凑形态,桌面顶栏隐藏时仍可见核心状态。

**Non-Goals:**

- 不替换或改写既有 `StatsCards`「今日/昨日」汇总视图。
- 不改动 `/api/admin/logs/live` 既有事件契约与日志页行为。
- 不引入跨实例共享状态(Redis 等);滚动窗口为进程内内存状态,沿用既有进程内实时链路的单实例语义。
- 不做历史回放或持久化;脉搏只反映「当下最近 60 秒」。

## Decisions

### 决策 1:服务端滚动窗口聚合,而非前端累积

新增 `live-pulse-aggregator.ts`,在内存中维护最近 60 秒的时间分桶计数(按秒分桶的环形结构)。每个桶累计:请求数、非 2xx 数、成功请求延迟之和与成功请求数、token 总量。读取快照时把窗口内各桶合并换算为 req/min、错误率、平均延迟、TPM。

为什么选服务端:前端累积方案在页面刚加载时窗口为空,req/min 需要 60 秒才能爬满,且只反映「连接后」的流量,失真明显。服务端聚合在任意时刻读到的都是真实最近 60 秒。

考虑过的替代方案:每隔 2~3 秒查一次 `requestLogs` 最近 60 秒(DB 聚合)。准确但每个在线管理员每数秒触发一次聚合查询,DB 负载随在线人数线性增长;内存环形桶则与查询人数无关,且与现有进程内 pub/sub 模型同构。故采用内存聚合。

### 决策 2:取样源接入请求收口发布点

聚合器订阅既有 `subscribeRequestLogLiveUpdates`,但当前事件只带 `logId + statusCode`,缺 `durationMs/totalTokens/upstreamId`。两种接法:

- 方案 A:扩展 `RequestLogLiveUpdate` 事件,附带 `durationMs/totalTokens` 等字段,聚合器从订阅回调取样。
- 方案 B:在 `request-logger.ts` 收口路径直接调用 `recordPulseSample(...)`,与 pub/sub 解耦。

采用方案 B。脉搏取样只需在「请求收口为终态」这一刻发生(`durationMs/totalTokens` 此时才确定),而 `notifyRequestLogChange` 还会在请求创建(进行中、无耗时/ token)时触发;复用同一事件会引入「进行中样本」噪声,需要额外过滤。直接在收口路径显式取样语义更清晰,也避免改动既有事件契约。`recordPulseSample` 仅接收收口终态样本。

### 决策 3:独立 SSE 端点 `/api/admin/stats/live`

新增 `/api/admin/stats/live`,鉴权与 `/api/admin/logs/live` 一致(`validateAdminAuth` + `ADMIN_TOKEN` Bearer)。连接建立后立即推送一帧快照,随后每约 2 秒推送一帧 `live-pulse` 事件;保留 15 秒心跳注释行。

为什么独立端点而非复用 logs/live:脉搏快照是「定时拼装的聚合帧」,与 logs/live 的「按请求变更触发」语义不同;混用会让日志页接收无关 `live-pulse` 帧、脉搏页接收无关 `request-log-changed` 帧。独立端点与现有「一个关注点一个端点」的目录结构一致(`/admin/logs/live`、`/admin/stats/*`)。

快照拼装时读取 `getAllHealthStatusWithCircuitBreaker()` 得到健康/熔断信号,与滚动窗口指标合并为一帧。

### 决策 4:前端复用三态与降级,新增 `use-live-pulse`

新增 `use-live-pulse.ts`,结构对齐 `use-request-log-live.ts`:连接 `/api/admin/stats/live`,解析 `live-pulse` 事件,维护快照状态与 `connecting/live/fallback` 三态。降级时改用对同一端点的快照拉取(或退化为定时请求一次性快照接口),保证指标持续更新。

状态条组件 `live-pulse-bar.tsx` 消费该 hook,纯展示。`Topbar` 扩展为可选承载状态条,所有页面经 `Topbar` 自动获得。

### 决策 5:放置与响应式布局

桌面端把状态条挂在 `Topbar` 右侧(`justify-between` 的右栏)。移动端 `Topbar` 为 `hidden md:block`,因此状态条在移动端走紧凑形态:仅呈现在线指示灯 + req/min + 错误率,挂在移动端可见的页头区域,不挤压标题与返回导航。

桌面顶栏布局示意:

```
┌────────────────────────────────────────────────────────────────────────┐
│ >> DASHBOARD ● Live 128 req/min · 0.4% err · 842ms · 1.2M TPM │
│ ▣ 8/9 上游健康 · ⚡ 1 熔断打开 │
├────────────────────────────────────────────────────────────────────────┤
│ 今日请求 │ 平均响应 │ Token │ 成本 ... (StatsCards 区域不变) │
└────────────────────────────────────────────────────────────────────────┘
```

移动端紧凑形态示意(顶部窄条):

```
┌──────────────────────────────┐
│ ● Live 128 req/min 0.4% err│
└──────────────────────────────┘
```

指示灯与状态色映射:

| 连接态 | 指示灯 | 含义 |
|---|---|---|
| `live` | 绿色常亮/脉动 | SSE 在线,快照随推送更新 |
| `connecting` | 灰色 | 正在建立连接 |
| `fallback` | 琥珀色 | 已降级为定时拉取 |

错误率与健康信号的强调色:错误率超过阈值(例如 >5%)时以错误色强调;熔断打开数 >0 时以警示色强调,与既有日志页错误强调样式风格一致。

## Risks / Trade-offs

- [进程内内存状态,多实例下各自为政] → 沿用既有进程内 SSE 链路的现状约束;在 proposal/design 中显式声明按单实例部署语义工作,多实例场景不在本变更范围。
- [进程重启后窗口清空,req/min 短暂从低值爬升] → 可接受:脉搏本就只反映「最近 60 秒」,重启后 60 秒内自然恢复;状态条无流量时显示零值而非报错。
- [每个在线管理员各持一条 SSE 连接,约 2 秒一帧] → 帧体小(数百字节级),且快照在服务端按需拼装;健康信号读取走既有 `getAllHealthStatusWithCircuitBreaker`,必要时对快照拼装结果做短 TTL 缓存以避免高频读取放大。
- [取样点遗漏导致脉搏与日志数据不一致] → 取样统一接入请求收口路径这一唯一终态写入点,并以单元测试覆盖「过期样本移出窗口」「错误率只算非 2xx」「平均延迟只算成功请求」等场景,保证聚合口径与 spec 一致。
- [移动端空间紧张] → 紧凑形态仅保留在线指示与最关键的 req/min、错误率,避免与标题、返回导航争抢空间。

## Migration Plan

纯增量特性,无数据迁移。新增端点与组件,默认在所有管理页生效。回滚方式为移除 `Topbar` 中的状态条挂载点与新增端点/服务文件,不影响既有统计与日志链路。

## Open Questions

- 错误率/熔断的强调色阈值取值(如错误率 5%)需在实现时对照现有设计 token 与既有强调样式确定,默认采用与日志页错误强调一致的色板。
- 移动端紧凑形态的具体挂载位置(复用 `layout.tsx` 中既有移动端 `header`,还是独立窄条)在实现时按视觉协调度确定,spec 仅约束「核心状态可见且不挤压标题与导航」。
35 changes: 35 additions & 0 deletions openspec/changes/archive/2026-05-30-live-pulse-bar/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## Why

当前仪表盘的 `StatsCards` 只展示「今日 vs 昨日」的粗粒度汇总,刷新间隔为 60 秒,管理员无法一眼看到网关此刻的运行状态。当上游突发抖动、错误率上升或熔断器打开时,需要切换到日志页或时序图才能察觉,缺少一个常驻、秒级、跨页面可见的运行健康概览。

AutoRouter 本身已经具备进程内实时发布订阅(`request-log-live-updates.ts`)、SSE 推送端点(`/api/admin/logs/live`)以及带降级轮询的前端实时客户端(`use-request-log-live.ts`)。在这套既有基础设施之上,可以低成本地提供一条始终可见的实时运行状态条,并结合 AutoRouter 作为多上游网关独有的健康信号(上游健康度、熔断器状态),形成区别于通用中转站的网关运行脉搏视图。

## What Changes

- 新增「实时脉搏状态条」(Live Pulse Bar),常驻在所有管理页 `Topbar` 的右侧(移动端提供紧凑形态)。
- 新增服务端滚动窗口聚合器:以最近 60 秒为窗口,对已收口请求按时间分桶累计请求数、非 2xx 错误数、成功请求延迟、token 总量;从 `request-logger` 的请求收口发布点取样(携带 `durationMs`、`totalTokens`、`statusCode`、`upstreamId`)。
- 新增 SSE 推送通道,周期性向已认证管理员推送实时脉搏快照(滚动窗口的 req/min、错误率、平均延迟、TPM),并附带网关健康信号「健康上游数 / 上游总数」与「熔断打开数」。
- 新增前端实时脉搏客户端与状态条组件,复用现有 `connecting / live / fallback` 三态语义:SSE 不可用时自动降级为定时拉取,连接状态以指示灯形式呈现。
- 扩展 `Topbar` 组件,使其右侧可承载状态条;所有管理页因复用 `Topbar` 自动获得该状态条。
- 新增中英文文案(`messages/en.json`、`messages/zh.json`)。

无破坏性变更:不改动既有 `/api/admin/logs/live` 的事件契约,不改动既有统计接口的返回结构。

## Capabilities

### New Capabilities

- `live-pulse-bar`: 实时脉搏状态条能力。覆盖滚动窗口运行指标的服务端聚合与取样、实时快照的 SSE 推送与降级拉取、网关健康信号(上游健康度与熔断状态)的纳入,以及跨管理页常驻的状态条展示与连接状态指示。

### Modified Capabilities

无。本变更复用既有进程内实时发布订阅机制属于实现层面的复用,不改变 `request-log-live-status` 已定义的需求与场景,因此不产生 spec 层面的契约变更。

## Impact

- 服务层:新增滚动窗口聚合服务(取样源为 `src/lib/services/request-logger.ts` 的请求收口路径);读取 `circuit-breaker.ts` 与 `health-checker.ts` 的上游健康与熔断状态用于快照拼装。
- API 层:新增管理端 SSE 端点用于推送实时脉搏快照(鉴权方式与现有 `/api/admin/logs/live` 一致,使用 `ADMIN_TOKEN` Bearer)。
- 前端:新增实时脉搏 hook 与状态条组件;扩展 `src/components/admin/topbar.tsx` 承载状态条;移动端 `Topbar` 隐藏时提供紧凑展示路径。
- 文案:新增 `messages/{en,zh}.json` 中实时脉搏相关键。
- 测试:新增滚动窗口聚合器单元测试与快照拼装单元测试。
- 部署假设:滚动窗口为进程内内存状态,与既有进程内 SSE 推送机制一致,按单实例部署(docker-compose 单容器)语义工作;多实例下各实例反映自身流量,此约束沿用既有实时链路的现状。
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## ADDED Requirements

### Requirement: 管理页必须常驻展示实时脉搏状态条

系统 MUST 在所有管理页的顶栏常驻展示一条实时脉搏状态条,使管理员无需进入任何子页面即可看到网关当前的运行状态概览。状态条 MUST 至少包含滚动窗口的每分钟请求数、错误率、平均延迟、每分钟 token 量,以及网关健康信号(健康上游数与上游总数、熔断打开数)。

#### Scenario: 桌面端各管理页常驻可见

- **WHEN** 管理员在桌面端浏览仪表盘、密钥、上游、日志、设置或系统类任意管理页
- **THEN** 顶栏 SHALL 始终展示实时脉搏状态条
- **AND** 状态条 SHALL 同时呈现请求速率、错误率、平均延迟、每分钟 token 量与网关健康信号

#### Scenario: 暂无流量时显示零值而非空白

- **WHEN** 最近 60 秒内没有任何已收口请求
- **THEN** 状态条 SHALL 将请求速率、错误率、平均延迟、每分钟 token 量显示为零值
- **AND** 不得显示为空白、占位符 `-` 或加载中停滞状态

### Requirement: 实时脉搏指标必须基于最近 60 秒滚动窗口聚合

系统 MUST 以最近 60 秒为滚动窗口聚合运行指标。请求速率 MUST 按窗口内已收口请求数换算为每分钟值;错误率 MUST 为窗口内非 2xx 请求占比;平均延迟 MUST 为窗口内成功请求的平均处理耗时;每分钟 token 量 MUST 按窗口内 token 总量换算为每分钟值。窗口外的样本 MUST 被排除出聚合结果。

#### Scenario: 过期样本被移出窗口

- **WHEN** 某个请求样本的发生时间已超过当前时刻 60 秒
- **THEN** 聚合结果 SHALL 不再计入该样本
- **AND** 请求速率、错误率、平均延迟与每分钟 token 量 SHALL 仅反映窗口内样本

#### Scenario: 错误率只统计非 2xx

- **WHEN** 窗口内既有 2xx 成功请求也有非 2xx 失败请求
- **THEN** 错误率 SHALL 等于非 2xx 请求数除以窗口内总请求数
- **AND** 平均延迟 SHALL 仅基于成功请求的处理耗时计算

#### Scenario: 取样来源为请求收口路径

- **WHEN** 某个请求在网关侧收口并写入终态(含状态码、处理耗时与 token 用量)
- **THEN** 系统 SHALL 以该请求的状态码、处理耗时、token 用量与上游标识生成一个滚动窗口样本

### Requirement: 实时脉搏状态条必须纳入网关健康信号

系统 MUST 在实时脉搏快照中纳入 AutoRouter 作为多上游网关独有的健康信号,至少包含当前健康上游数与上游总数、以及处于熔断打开状态的上游数量。健康与熔断状态 MUST 与系统内真实的健康检查与熔断器状态保持一致。

#### Scenario: 熔断打开数与真实状态一致

- **WHEN** 存在若干上游的熔断器处于打开状态
- **THEN** 状态条展示的熔断打开数 SHALL 等于真实处于打开状态的上游数量
- **AND** 不得把打开或半开状态统一显示为关闭

#### Scenario: 健康上游比例反映真实健康检查结果

- **WHEN** 系统中部分上游被健康检查标记为不健康
- **THEN** 状态条展示的健康上游数 SHALL 等于真实健康上游数量
- **AND** 上游总数 SHALL 等于参与统计的上游数量

### Requirement: 实时链路必须复用三态语义并在不可用时降级

系统 MUST 复用既有实时链路的 `connecting / live / fallback` 三态语义为实时脉搏提供推送。实时推送可用时,状态条 MUST 以指示灯形式呈现在线状态并随推送更新;实时推送不可用或中断时,系统 MUST 自动降级为定时拉取,保证指标在合理时间内持续更新,而不是长期停滞。

#### Scenario: 实时推送可用时呈现在线指示

- **WHEN** 实时脉搏推送链路连接成功并持续推送快照
- **THEN** 状态条 SHALL 显示在线指示灯
- **AND** 指标 SHALL 随推送快照更新

#### Scenario: 推送中断时自动降级拉取

- **WHEN** 实时脉搏推送链路暂时不可用或中断
- **THEN** 系统 SHALL 自动回退到定时拉取机制
- **AND** 指标 SHALL 在合理时间内继续更新,不得长期停滞在旧值

### Requirement: 实时脉搏端点必须鉴权

系统 MUST 对实时脉搏的推送与拉取端点执行管理员鉴权,鉴权方式与既有管理端实时端点一致。未携带或携带无效管理员凭据的请求 MUST 被拒绝,不得返回任何运行指标。

#### Scenario: 缺失或无效凭据被拒绝

- **WHEN** 请求未携带有效的管理员凭据访问实时脉搏端点
- **THEN** 系统 SHALL 返回未授权响应
- **AND** 不得返回任何实时运行指标数据

### Requirement: 移动端必须提供紧凑展示形态

系统 MUST 在桌面顶栏不展示的移动端视口下,为实时脉搏提供紧凑展示形态,使管理员在移动端仍能看到核心运行状态,且不挤压页面标题与既有导航。

#### Scenario: 移动端展示紧凑核心指标

- **WHEN** 管理员在移动端视口浏览管理页
- **THEN** 系统 SHALL 以紧凑形态展示实时脉搏的核心状态(至少包含在线指示与请求速率、错误率)
- **AND** 紧凑形态 SHALL 不遮挡或挤压页面标题与既有导航元素
Loading
Loading