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
@@ -62,13 +62,33 @@ fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.req
|
||||
*
|
||||
* 这三帧**不在**那 7 个「不含内容的状态机信号」白名单里,服务端按 fail-closed 只发
|
||||
* **owner + admin**。注意这与 [AdminPage.Overview] 的可见性**是两回事**:概览页本身是
|
||||
* [AdminTier.Monitor],`viewer` 也能进、也能读那两个 REST 端点(v0.40 只读监控档),
|
||||
* [AdminTier.Monitor],`viewer` 也能进、也能读它背后的三个 REST 端点(v0.40 只读监控档),
|
||||
* 但它**一帧推送都收不到**(服务端不推,客户端也就不该开连接去等)。
|
||||
*
|
||||
* 客户端按同一档自行降级:不够档就**不开连接、不合并帧**,而不是等「怎么一直没帧」再猜。
|
||||
* 注意概览页背后的三个 REST 读取(metrics / trace / logs)都落在同一档只读监控里、`viewer` 全读得到:
|
||||
* 也就是 `viewer` 看得到**进页面那一刻的快照**、收不到任何增量。这条不对称对三块都成立,
|
||||
* 日志那块之所以另写成 [canReadObservabilityLogHistory],是因为它还要决定「区块渲不渲染」。
|
||||
*/
|
||||
fun canReceiveObservabilityFrames(role: AdminRole): Boolean =
|
||||
role.rank >= AdminTier.OwnerAdmin.rank
|
||||
|
||||
/**
|
||||
* 能否读取「最近日志」的**历史**(`GET /api/v1/admin/observability/logs`)。
|
||||
*
|
||||
* 该端点在 v0.40 的 12 条 `viewerAuth` 只读监控档里,与 [AdminPage.Overview] 同档 ⇒
|
||||
* `viewer` 也读得到。它与 [canReceiveObservabilityFrames] **刻意不对称**,这是契约的安排、
|
||||
* 不是缺口:
|
||||
*
|
||||
* - `viewer`:读得到进页面那一刻的历史,**收不到**之后的增量;
|
||||
* - `owner` / `admin`:历史与增量都有。
|
||||
*
|
||||
* ⇒ 「最近日志」区块的**内容**只由这次历史读取决定;收不到增量既不是错误也不是空态,
|
||||
* UI 不得据此给出任何失败 / 空提示,也不该为它开一条连接去干等(连接只在
|
||||
* [canReceiveObservabilityFrames] 够档时才建立)。写成纯函数是为了让这条口径只有一个落点,
|
||||
* 而不是散在各处 `if` 里靠人记。
|
||||
*/
|
||||
fun canReadObservabilityLogHistory(role: AdminRole): Boolean = canAccess(role, AdminPage.Overview)
|
||||
|
||||
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
|
||||
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
|
||||
@@ -26,6 +26,17 @@ fun formatRfc3339(raw: String?): String? {
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC3339 → epoch 秒,供按时间排序用(日志 `time` 只有秒精度,同秒是很常见的并列)。
|
||||
*
|
||||
* 解析不了返回 null——**绝不**在这里退回「当成 Unix 毫秒」:日志 / trace 的 `time` 是 RFC3339,
|
||||
* backthink 的 `at` 才是 Unix 毫秒([formatEpochMillis]),两种形状刻意不共用解析路径
|
||||
* (api-client §1)。调用方对 null(时间判不出来)需自行给一个保守的次序,但**不得丢弃**条目。
|
||||
*/
|
||||
fun rfc3339EpochSecond(raw: String?): Long? = raw
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { runCatching { OffsetDateTime.parse(it).toEpochSecond() }.getOrNull() }
|
||||
|
||||
/**
|
||||
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
|
||||
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
|
||||
|
||||
Reference in new issue
Block a user