feat(admin): 接上可观测性下行帧,概览页实时刷新指标 / trace / 日志

契约 v0.55 §3.12 的三帧(`observability_metrics` / `observability_trace` / `observability_log`)
此前在本端没有 handler,落到未知帧被静默丢弃,概览页只能「进页面 / 手动刷新各拉一次 REST」。

- 解析(data/ws):三个事件类 + `dispatchObservability` 分支,载荷按契约逐字对齐
  (metrics = 扁平本体、trace = `{spans}`、log = `{entries}`),帧壳 `sessionId` 有就透传。
  载荷缺席时**整帧安全忽略**——metrics 回落成默认值就是一份全零快照,会把界面上的真实指标
  擦成 0;未知帧、坏帧仍安静丢弃(回归用例钉住,绝不抛)。
- 消费(ui/admin):AdminViewModel 自持一条 socket(照 SessionsViewModel 的做法:登录且
  角色够档时连、登出 / 角色降档 / onCleared 关),把三帧合并进概览状态。REST 仍是兜底真源:
  进页面与手动刷新照旧各拉一次全量;每次「连上」(含首次连接与断线重连)后再重读一次——
  断线窗口里的帧永久丢失,而服务端只为「有在线 owner/admin 连接」的凭据保留订阅,
  重读同时也重建订阅(契约 §3.12:读取即订阅握手)。
- 角色门在客户端也判(domain/admin 新增纯函数 `canReceiveObservabilityFrames`):这三帧服务端
  fail-closed 只发 owner + admin,与概览页自身的 Monitor 档是两条门,viewer 既不开连接也不合帧。
- 日志只有推送这一个来源(本端没有 logs REST 端点):一帧都没到过时整段不渲染——不写
  「暂无日志」,那等于把「没有推送」说成「没有数据」;等级过滤按契约在客户端本地做。
- 合并与过滤是 ui/admin 下的纯函数(新帧在前、按业务键去重、窗口 100/200),单测覆盖。

测试 485 → 514(+29),0 失败 0 错误;`assembleDebug` 通过。
This commit is contained in:
AskaEth committed 2026-10-04 18:42:23 +08:00
1 parent a6b4bae2a0
commit b5e6f78910
9 files changed
+831 -11

No files matched your search

@@ -68,4 +68,26 @@ class AdminAccessTest {
assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank)
assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank)
}
@Test
fun `only owner and admin receive observability frames`() {
// 契约 §3.12:这三个帧不在「状态机信号」白名单里 ⇒ 后端 fail-closed 只发 owner + admin。
assertTrue(canReceiveObservabilityFrames(AdminRole.Owner))
assertTrue(canReceiveObservabilityFrames(AdminRole.Admin))
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
}
@Test
fun `being able to open the overview page is not enough for the frames`() {
// 概览页是 Monitor 档(viewer 也能进、也能读那两个 REST),但它收不到推送——
// 两条门必须分开判,否则 viewer 会开着连接干等一份永远不来的帧。
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview))
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
}
@Test
fun `an unknown role is treated as a viewer for the frames too`() {
assertFalse(canReceiveObservabilityFrames(AdminRole.of("superowner")))
assertFalse(canReceiveObservabilityFrames(AdminRole.of(null)))
}
}