feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口
W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有 `GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的 条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把 「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。 - **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、 帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次, 会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。 - **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把 `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto` (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。 - **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置** 截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住 同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。 trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。 - **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功 ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。 三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。 - **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档, v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。 ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。 测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。 `assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
This commit is contained in:
1 parent
e59ef08cf9
commit
642bdb13f0
15 files changed
+681
-119
No files matched your search
@@ -90,4 +90,38 @@ class AdminAccessTest {
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.of("superowner")))
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.of(null)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer reads the log history but never the increments`() {
|
||||
// 契约 §2.0 / §3.12:`GET /admin/observability/logs` 在 v0.40 的 viewerAuth 只读监控档里
|
||||
// (viewer 读得到),而 `observability_log` 帧 fail-closed 只发 owner + admin。
|
||||
// 两条门**刻意不对称**:viewer 看得到进页面那一刻的历史、收不到之后的增量——
|
||||
// 所以「最近日志」区块的存在不看帧到没到过,UI 不得据此报错或报空。
|
||||
assertTrue(canReadObservabilityLogHistory(AdminRole.Viewer))
|
||||
assertFalse(canReceiveObservabilityFrames(AdminRole.Viewer))
|
||||
|
||||
listOf(AdminRole.Admin, AdminRole.Owner).forEach { role ->
|
||||
assertTrue(role.name, canReadObservabilityLogHistory(role))
|
||||
assertTrue(role.name, canReceiveObservabilityFrames(role))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown role reads the log history and still gets no increments`() {
|
||||
// 未知角色按 viewer 处理:历史这条门是「一次普通只读监控请求」,照旧读得到;
|
||||
// 增量那条 fail-closed,照旧收不到。
|
||||
listOf(AdminRole.of("superowner"), AdminRole.of(null), AdminRole.of(" ")).forEach { role ->
|
||||
assertTrue(canReadObservabilityLogHistory(role))
|
||||
assertFalse(canReceiveObservabilityFrames(role))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the log history follows the overview page tier instead of a second allowlist`() {
|
||||
// 日志历史与概览页同档(都是 Monitor),这条相等关系是契约里的分档在客户端的落点:
|
||||
// 一旦后端把该端点挪出 viewerAuth,改这里的档位就同时改了「读不读」与「有没有区块」。
|
||||
AdminRole.entries.forEach { role ->
|
||||
assertEquals(role.name, canAccess(role, AdminPage.Overview), canReadObservabilityLogHistory(role))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,31 @@ class AdminFormatTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 epoch seconds agree across the two spellings and the fractions`() {
|
||||
// 排序用:同一时刻的两种写法必须给出同一个 key,纳秒精度也要吃得下。
|
||||
assertEquals(1_772_598_896L, rfc3339EpochSecond("2026-03-04T04:34:56Z"))
|
||||
assertEquals(
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56Z"),
|
||||
rfc3339EpochSecond("2026-03-04T12:34:56+08:00"),
|
||||
)
|
||||
assertEquals(
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56Z"),
|
||||
rfc3339EpochSecond("2026-03-04T04:34:56.123456789Z"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a unix millisecond value is never parsed as rfc3339`() {
|
||||
// 排序用的解析器只认 RFC3339:一串毫秒数字(backthink 的 `at` 形状)必须判成「没有时间」,
|
||||
// 否则它会被当成一个有时间的条目排到前面,把真实顺序排乱。
|
||||
assertNull(rfc3339EpochSecond("1772598896000"))
|
||||
assertNull(rfc3339EpochSecond("2026-03-04"))
|
||||
assertNull(rfc3339EpochSecond("not-a-time"))
|
||||
assertNull(rfc3339EpochSecond(null))
|
||||
assertNull(rfc3339EpochSecond(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nanos pick a unit by magnitude`() {
|
||||
assertEquals("512 ns", formatNanos(512))
|
||||
|
||||
Reference in new issue
Block a user