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:
AskaEth committed 2026-10-04 19:18:42 +08:00
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))