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

@@ -0,0 +1,135 @@
package top.yeij.cyrene.ui.admin
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
/**
* 可观测推送帧的合并与本地过滤(契约 v0.55 §3.12)的纯函数。
*
* 断言的是**三条不会写进类型里的语义**:新帧在前(列表两端本来就最新在前,不排序)、
* 按业务键去重(帧与 REST 竞态时不会出现两条同样的数据)、窗口截断(不许无界增长);
* 以及日志的等级过滤保留「判不出等级」的条目。
*/
class AdminOverviewMergeTest {
private fun span(id: String) = AdminTraceSpanDto(id = id, name = "span-$id", startedAt = "2026-10-04T12:00:00Z")
private fun entry(msg: String, level: String = "info") =
ObservabilityLogEntryDto(time = "2026-10-04T12:00:00Z", level = level, source = "gateway", msg = msg)
@Test
fun `an empty delta leaves the snapshot untouched`() {
val existing = listOf(span("a"))
// 空增量不是「清空」:REST 引导读取拿到的快照必须原样留着。
assertSame(existing, mergeTraceSpans(existing, emptyList()))
val logs = listOf(entry("启动"))
assertSame(logs, mergeLogEntries(logs, emptyList()))
}
@Test
fun `new spans go in front of the guide snapshot`() {
val merged = mergeTraceSpans(listOf(span("old")), listOf(span("new")))
assertEquals(listOf("new", "old"), merged.map { it.id })
}
@Test
fun `a span already present does not appear twice`() {
// 帧与那次 REST 引导读取可能覆盖同一段:按 id 去重,且**新到的那一份**留在前面。
val merged = mergeTraceSpans(listOf(span("a"), span("b")), listOf(span("b"), span("c")))
assertEquals(listOf("b", "c", "a"), merged.map { it.id })
}
@Test
fun `the trace window keeps only the newest entries`() {
val existing = (1..5).map { span("s$it") }
val merged = mergeTraceSpans(existing, listOf(span("n")), window = 3)
assertEquals(listOf("n", "s1", "s2"), merged.map { it.id })
}
@Test
fun `log entries are deduplicated by their whole business key`() {
// 服务端两端都是**最新在前**,合并保留各自的相对顺序(不排序):帧里新到的在前,
// 已经在列表里的那条不会被重复追加。
val merged = mergeLogEntries(listOf(entry("启动")), listOf(entry("慢请求"), entry("启动")))
assertEquals(listOf("慢请求", "启动"), merged.map { it.msg })
}
@Test
fun `log entries differing only in level are distinct`() {
val merged = mergeLogEntries(
listOf(entry("同一句", level = "info")),
listOf(entry("同一句", level = "error")),
)
assertEquals(2, merged.size)
}
@Test
fun `the log window keeps only the newest entries`() {
val existing = (1..4).map { entry("m$it") }
val merged = mergeLogEntries(existing, listOf(entry("fresh")), window = 2)
assertEquals(listOf("fresh", "m1"), merged.map { it.msg })
}
@Test
fun `no level filter keeps every entry`() {
val entries = listOf(entry("a", "debug"), entry("b", "info"), entry("c", "error"))
assertSame(entries, filterLogEntries(entries, null))
assertSame(entries, filterLogEntries(entries, ""))
// 未知的过滤档不猜:退回「不过滤」,而不是把列表过滤空。
assertSame(entries, filterLogEntries(entries, "verbose"))
}
@Test
fun `a minimum level keeps that level and above`() {
val entries = listOf(entry("d", "debug"), entry("i", "info"), entry("w", "warn"), entry("e", "error"))
// 与后端 `?level=` 同口径:是最低等级,不是精确匹配。
assertEquals(listOf("w", "e"), filterLogEntries(entries, "warn").map { it.msg })
assertEquals(listOf("e"), filterLogEntries(entries, "error").map { it.msg })
assertEquals(4, filterLogEntries(entries, "debug").size)
}
@Test
fun `the level filter tolerates case and blanks and keeps unknown levels`() {
val entries = listOf(entry("w", " WARN "), entry("u", "trace"), entry("i", "info"))
// 等级是开放集合:判不出高低的条目一律保留,绝不因为「可能低于阈值」被静默藏掉。
assertEquals(listOf("w", "u"), filterLogEntries(entries, "warn").map { it.msg })
}
@Test
fun `log keys distinguish the four fields of an entry`() {
val base = entry("同一句")
val keys = setOf(
logEntryKey(base),
logEntryKey(base.copy(time = "2026-10-04T12:00:01Z")),
logEntryKey(base.copy(level = "warn")),
logEntryKey(base.copy(source = "other")),
logEntryKey(base.copy(msg = "另一句")),
)
assertEquals(5, keys.size)
}
@Test
fun `window constants match what the contract promises`() {
// 契约 §3.12:单帧上限 trace 100 / log 200,客户端保留窗口与它们对齐。
assertEquals(100, TRACE_WINDOW)
assertEquals(200, LOG_WINDOW)
assertTrue(TRACE_WINDOW > 0)
}
}