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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user