diff --git a/openspec/changes/archive/2026-05-30-live-pulse-bar/.openspec.yaml b/openspec/changes/archive/2026-05-30-live-pulse-bar/.openspec.yaml new file mode 100644 index 00000000..b44fb0ec --- /dev/null +++ b/openspec/changes/archive/2026-05-30-live-pulse-bar/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-30 diff --git a/openspec/changes/archive/2026-05-30-live-pulse-bar/design.md b/openspec/changes/archive/2026-05-30-live-pulse-bar/design.md new file mode 100644 index 00000000..97ebe95d --- /dev/null +++ b/openspec/changes/archive/2026-05-30-live-pulse-bar/design.md @@ -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` 即可全局常驻。 + +网关健康信号的现成来源:`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 仅约束「核心状态可见且不挤压标题与导航」。 diff --git a/openspec/changes/archive/2026-05-30-live-pulse-bar/proposal.md b/openspec/changes/archive/2026-05-30-live-pulse-bar/proposal.md new file mode 100644 index 00000000..b4986d0b --- /dev/null +++ b/openspec/changes/archive/2026-05-30-live-pulse-bar/proposal.md @@ -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 单容器)语义工作;多实例下各实例反映自身流量,此约束沿用既有实时链路的现状。 diff --git a/openspec/changes/archive/2026-05-30-live-pulse-bar/specs/live-pulse-bar/spec.md b/openspec/changes/archive/2026-05-30-live-pulse-bar/specs/live-pulse-bar/spec.md new file mode 100644 index 00000000..4c473467 --- /dev/null +++ b/openspec/changes/archive/2026-05-30-live-pulse-bar/specs/live-pulse-bar/spec.md @@ -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 不遮挡或挤压页面标题与既有导航元素 diff --git a/openspec/changes/archive/2026-05-30-live-pulse-bar/tasks.md b/openspec/changes/archive/2026-05-30-live-pulse-bar/tasks.md new file mode 100644 index 00000000..0c5eccb2 --- /dev/null +++ b/openspec/changes/archive/2026-05-30-live-pulse-bar/tasks.md @@ -0,0 +1,45 @@ +## 1. 服务端滚动窗口聚合器 + +- [x] 1.1 新增 `src/lib/services/live-pulse-aggregator.ts`,实现按秒分桶的 60 秒环形窗口:导出 `recordPulseSample({ statusCode, durationMs, totalTokens, occurredAt? })` 与 `getPulseWindowSnapshot()`;快照换算 req/min、错误率(非 2xx 占比)、平均延迟(仅成功请求)、TPM。 +- [x] 1.2 实现过期桶清理逻辑:读取与写入时均剔除超过 60 秒的样本,保证窗口外样本不计入。 +- [x] 1.3 新增 `tests/unit/services/live-pulse-aggregator.test.ts`,覆盖:窗口内聚合正确、过期样本移出窗口、错误率只统计非 2xx、平均延迟只算成功请求、无样本时返回零值快照。 +- [x] 1.4 运行 `pnpm test:run` 相关用例与 `pnpm exec tsc --noEmit` 通过;提交本阶段。 + - 验收:聚合器单测全绿,类型检查通过;指标口径与 `specs/live-pulse-bar/spec.md` 中「滚动窗口」需求一致。 + +## 2. 取样接入与快照拼装(含网关健康信号) + +- [x] 2.1 在 `src/lib/services/request-logger.ts` 的请求收口路径(终态写入处)调用 `recordPulseSample(...)`,仅在请求收口为终态、`durationMs/totalTokens` 已确定时取样;不在请求创建(进行中)处取样。 +- [x] 2.2 新增快照拼装函数(新增 `live-pulse-service.ts`),合并滚动窗口快照与 `getAllHealthStatusWithCircuitBreaker()` 得到的健康上游数/总数、熔断打开数,产出完整 `LivePulseSnapshot`;定义其 TypeScript 类型。 +- [x] 2.3 新增快照拼装单元测试:健康/熔断计数与传入的健康检查结果一致(打开/半开不计为关闭);窗口指标与健康信号正确合并。 +- [x] 2.4 运行相关测试与类型检查通过;提交本阶段。 + - 验收:取样仅发生在终态收口;快照拼装单测全绿;健康/熔断口径与 `routing-failover-observability` 既有真实状态一致。 + +## 3. 实时脉搏 SSE 端点 + +- [x] 3.1 新增 `src/app/api/admin/stats/live/route.ts`,鉴权复用 `validateAdminAuth`(`ADMIN_TOKEN` Bearer),与 `/api/admin/logs/live` 一致;`runtime = "nodejs"`、`dynamic = "force-dynamic"`。 +- [x] 3.2 连接建立即推送一帧 `live-pulse` 快照,随后约每 2 秒推送一帧;保留约 15 秒心跳注释行;正确清理定时器与中止监听,避免断开后写入。 +- [x] 3.3 提供降级拉取路径:`?mode=snapshot` 以普通 GET 返回一次性快照(供前端 fallback 使用);缺失/无效凭据返回 401 且不泄露任何指标。 +- [x] 3.4 运行 `pnpm lint` 与 `pnpm exec tsc --noEmit` 通过;提交本阶段。 + - 验收:未授权请求返回 401 且无指标数据;端点能稳定推送 `live-pulse` 帧并按断开清理资源。 + +## 4. 前端实时脉搏客户端与桌面状态条 + +- [x] 4.1 新增 `src/hooks/use-live-pulse.ts`,结构对齐 `use-request-log-live.ts`:连接 `/api/admin/stats/live`,解析 `live-pulse` 事件,维护快照与 `connecting/live/fallback` 三态;断线降级为定时拉取一次性快照。新增 `src/providers/live-pulse-provider.tsx`,在布局层只建立一条共享连接,供顶栏与移动端窄条共用,避免逐页重连。 +- [x] 4.2 新增 `src/components/admin/live-pulse-bar.tsx`,纯展示组件:呈现在线指示灯(按三态着色)、req/min、错误率、平均延迟、TPM、健康上游数/总数、熔断打开数;错误率超阈值与熔断打开数 >0 时按既有错误/警示样式强调;数字格式跟随 next-intl 当前语言。含组件单元测试。 +- [x] 4.3 扩展 `src/components/admin/topbar.tsx`,在右栏承载状态条(桌面端完整版,窄屏退化为紧凑版);所有管理页因复用 `Topbar` 自动获得。 +- [x] 4.4 在 `src/messages/en.json` 与 `src/messages/zh-CN.json` 新增 `livePulse` 命名空间文案键(指标标签、在线/降级提示等)。 +- [x] 4.5 运行 `eslint`、`pnpm exec tsc --noEmit`、`prettier --check` 通过;提交本阶段。 + - 验收:桌面端各管理页顶栏常驻状态条,指标随推送更新,降级时指示灯转琥珀色并持续刷新;无流量时显示零值。 + +## 5. 移动端紧凑形态 + +- [x] 5.1 为状态条提供紧凑形态(在线指示灯 + req/min + 错误率),在移动端 `Topbar` 隐藏时通过 `layout.tsx` 移动端顶栏展示;与返回按钮合并为同一条 sticky 顶栏(左返回、右脉搏),避免双 sticky 叠加,不挤压标题与导航。 +- [x] 5.2 校验移动端紧凑形态:`eslint`、`pnpm exec tsc --noEmit`、`prettier --check` 通过,未破坏既有移动端布局;提交本阶段。 + - 验收:移动端可见核心状态,标题与返回导航不被遮挡或挤压。 + +## 6. 校验与收尾 + +- [x] 6.1 运行完整 `pnpm test:run`(151 文件 2507 passed / 1 skipped)与 `pnpm build`(成功,新端点 `/api/admin/stats/live` 已编译),确认无回归。 +- [x] 6.2 运行 `npx openspec validate live-pulse-bar --strict` 通过;逐项核对 `tasks.md` 勾选完成。 +- [x] 6.3 隔离功能分支 `feat/live-pulse-bar` 已推送,开启 PR #195(遵循仓库 OpenSpec PR 工作流);合并交由用户决定,不自行合并。 + - 验收:CI 通过,PR 已开启待评审;变更可按需归档。 diff --git a/openspec/specs/live-pulse-bar/spec.md b/openspec/specs/live-pulse-bar/spec.md new file mode 100644 index 00000000..974f1b5d --- /dev/null +++ b/openspec/specs/live-pulse-bar/spec.md @@ -0,0 +1,94 @@ +# live-pulse-bar Specification + +## Purpose +TBD - created by archiving change live-pulse-bar. Update Purpose after archive. +## 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 不遮挡或挤压页面标题与既有导航元素 + diff --git a/src/app/[locale]/(dashboard)/layout.tsx b/src/app/[locale]/(dashboard)/layout.tsx index 8d6c02d8..07e16ba2 100644 --- a/src/app/[locale]/(dashboard)/layout.tsx +++ b/src/app/[locale]/(dashboard)/layout.tsx @@ -5,9 +5,11 @@ import { useTranslations } from "next-intl"; import { ChevronLeft } from "lucide-react"; import { Sidebar } from "@/components/admin/sidebar"; +import { MobilePulseStrip } from "@/components/admin/mobile-pulse-strip"; import { usePathname, useRouter } from "@/i18n/navigation"; import { cn } from "@/lib/utils"; import { useAuth } from "@/providers/auth-provider"; +import { LivePulseProvider } from "@/providers/live-pulse-provider"; import { Button } from "@/components/ui/button"; const MOBILE_ROOT_ROUTES = ["/dashboard", "/keys", "/upstreams", "/logs", "/settings"] as const; @@ -87,38 +89,44 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } return ( -
- setIsSidebarCollapsed((value) => !value)} - /> + +
+ setIsSidebarCollapsed((value) => !value)} + /> -
- {!isMobileRootRoute && ( +
-
- +
+
+ {!isMobileRootRoute && ( + + )} +
+ +
- )} - {children} -
-
+ {children} + +
+ ); } diff --git a/src/app/api/admin/stats/live/route.ts b/src/app/api/admin/stats/live/route.ts new file mode 100644 index 00000000..13a3880f --- /dev/null +++ b/src/app/api/admin/stats/live/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/utils/api-auth"; +import { validateAdminAuth } from "@/lib/utils/auth"; +import { getLivePulseSnapshot } from "@/lib/services/live-pulse-service"; +import { createLogger } from "@/lib/utils/logger"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const log = createLogger("admin-stats-live"); +const KEEPALIVE_INTERVAL_MS = 15000; +const PULSE_INTERVAL_MS = 2000; + +function formatSseEvent(eventName: string, data: unknown): string { + return `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`; +} + +/** + * Stream live pulse snapshots over Server-Sent Events for authenticated admins. + * + * Default mode streams a `live-pulse` snapshot frame on connect and every + * PULSE_INTERVAL_MS thereafter. `?mode=snapshot` returns a single snapshot as + * JSON, used by the frontend fallback polling when SSE is unavailable. + */ +export async function GET(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + if (!validateAdminAuth(authHeader)) { + return errorResponse("Unauthorized", 401); + } + + if (request.nextUrl.searchParams.get("mode") === "snapshot") { + try { + const snapshot = await getLivePulseSnapshot(); + return NextResponse.json(snapshot); + } catch (error) { + log.error({ err: error }, "failed to build live pulse snapshot"); + return errorResponse("Internal server error", 500); + } + } + + const encoder = new TextEncoder(); + let cleanup = () => undefined; + + const stream = new ReadableStream({ + start(controller) { + const send = (chunk: string) => { + controller.enqueue(encoder.encode(chunk)); + }; + + const pushSnapshot = async () => { + try { + const snapshot = await getLivePulseSnapshot(); + send(formatSseEvent("live-pulse", snapshot)); + } catch (error) { + log.debug({ err: error }, "live pulse snapshot push failed"); + } + }; + + const keepalive = setInterval(() => { + try { + send(`: keep-alive ${new Date().toISOString()}\n\n`); + } catch { + // Ignore write errors after disconnect. + } + }, KEEPALIVE_INTERVAL_MS); + + const pulseTimer = setInterval(() => { + void pushSnapshot(); + }, PULSE_INTERVAL_MS); + + const abortHandler = () => { + cleanup(); + try { + controller.close(); + } catch { + // Controller may already be closed. + } + }; + + request.signal.addEventListener("abort", abortHandler, { once: true }); + + cleanup = () => { + clearInterval(keepalive); + clearInterval(pulseTimer); + request.signal.removeEventListener("abort", abortHandler); + }; + + // Push the first snapshot immediately so the bar shows data on connect. + void pushSnapshot(); + }, + cancel() { + cleanup(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/src/components/admin/live-pulse-bar.tsx b/src/components/admin/live-pulse-bar.tsx new file mode 100644 index 00000000..34026503 --- /dev/null +++ b/src/components/admin/live-pulse-bar.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useFormatter, useTranslations } from "next-intl"; +import { AlertTriangle, Server, Zap } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import type { LivePulseConnectionState, LivePulseSnapshot } from "@/hooks/use-live-pulse"; + +const ERROR_RATE_WARN_THRESHOLD_PCT = 5; + +const ZERO_SNAPSHOT: LivePulseSnapshot = { + requestsPerMinute: 0, + errorRatePct: 0, + avgLatencyMs: 0, + tokensPerMinute: 0, + sampleCount: 0, + windowSeconds: 60, + generatedAt: "", + gateway: { healthyUpstreams: 0, totalUpstreams: 0, openCircuitBreakers: 0 }, +}; + +interface LivePulseBarProps { + snapshot: LivePulseSnapshot | null; + connectionState: LivePulseConnectionState; + variant?: "full" | "compact"; + className?: string; +} + +function StatusDot({ connectionState }: { connectionState: LivePulseConnectionState }) { + const dotClass = + connectionState === "live" + ? "bg-status-success animate-log-badge-live motion-reduce:animate-none" + : connectionState === "connecting" + ? "bg-muted-foreground animate-log-badge-connect motion-reduce:animate-none" + : "bg-status-warning"; + + return