From 642bdb13f03181a6167b68a9a97fe2a855294891 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 19:18:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E3=80=8C=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E3=80=8D=E6=8E=A5=E4=B8=8A=20logs=20REST=20?= =?UTF-8?q?=E5=BC=95=E5=AF=BC=E8=AF=BB=E5=8F=96=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20W10=20=E5=8F=AA=E6=98=BE=E7=A4=BA=E8=BF=9B?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=B9=8B=E5=90=8E=E9=82=A3=E6=AE=B5=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=9A=84=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 错误)。未做真机与真后端验收。 --- .../top/yeij/cyrene/data/api/AdminApi.kt | 15 ++ .../top/yeij/cyrene/data/api/dto/AdminDto.kt | 20 ++ .../cyrene/data/repository/AdminRepository.kt | 20 ++ .../cyrene/data/ws/ObservabilityLogEntry.kt | 22 -- .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 13 +- .../yeij/cyrene/domain/admin/AdminAccess.kt | 22 +- .../yeij/cyrene/domain/admin/AdminFormat.kt | 11 + .../cyrene/ui/admin/AdminOverviewMerge.kt | 78 ++++++-- .../cyrene/ui/admin/AdminOverviewScreen.kt | 67 +++++-- .../yeij/cyrene/ui/admin/AdminViewModel.kt | 108 ++++++---- .../data/api/dto/AdminDtoDecodingTest.kt | 59 ++++++ .../repository/AdminRepositoryLogsTest.kt | 188 ++++++++++++++++++ .../cyrene/domain/admin/AdminAccessTest.kt | 34 ++++ .../cyrene/domain/admin/AdminFormatTest.kt | 25 +++ .../cyrene/ui/admin/AdminOverviewMergeTest.kt | 118 ++++++++++- 15 files changed, 681 insertions(+), 119 deletions(-) delete mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/ws/ObservabilityLogEntry.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/AdminRepositoryLogsTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt index 366175b..ca96025 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/AdminApi.kt @@ -1,6 +1,7 @@ package top.yeij.cyrene.data.api import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto import top.yeij.cyrene.data.api.dto.MemorySummaryDto @@ -25,6 +26,20 @@ interface AdminApi { @GET("api/v1/admin/observability/trace") suspend fun trace(@Query("limit") limit: Int? = null): Response>> + /** + * 后端日志 tail(api-client §2「可观测性契约」,v0.35):`{"data":[{time,level,source,msg}]}`, + * **最新在前**;`limit` 缺省 200、上限 1000(≤0 或非法回落缺省)。 + * + * 不暴露契约里的 `?level=`:它是**最低等级**过滤,只在服务端裁剪一次结果——而界面上那排 + * 等级档要能在同一份数据上来回切换(契约 §3.12 也明确推送帧的过滤只能本地做), + * 所以这里一律取全量、由客户端本地过滤(`ui/admin/AdminOverviewMerge.kt`)。 + * + * RBAC 是 v0.40 的 `viewerAuth`:**viewer** 也读得到(owner / admin 本来就可以), + * 与 `trace` / `metrics` 同档。 + */ + @GET("api/v1/admin/observability/logs") + suspend fun logs(@Query("limit") limit: Int? = null): Response>> + /** 人格列表(含 admin 专属 `thinking`)。 */ @GET("api/v1/admin/personas") suspend fun personas(): Response>> diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt index 9d2235f..4770ec0 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/AdminDto.kt @@ -47,6 +47,26 @@ data class AdminTraceEventDto( val props: Map? = null, ) +/** + * 一条后端日志,**两个来源共用这一个形状**(api-client §2「可观测性契约」与 §3.12): + * `GET /api/v1/admin/observability/logs` 的 `data[]`,以及 `observability_log` 帧的 + * `data.entries[]`。后端是同一个生产者(`gateway.AdminLogEntry`,`adminLogOf` 映射), + * 契约也明说帧「与 REST 同形」——所以这里只有一份 DTO,不另建一份等价形状。 + * + * - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状, + * 而**不是** backthink 那种 Unix 毫秒([AdminBackthinkItemDto.at]),两者不可共用解析器。 + * - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。 + * - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。 + * - 条目**没有 id**:去重只能按这四个字段(见 `ui/admin/AdminOverviewMerge.kt`)。 + */ +@Serializable +data class AdminLogEntryDto( + val time: String = "", + val level: String = "", + val source: String = "", + val msg: String = "", +) + /** * 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。 * admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt index c9e07d3..fc6b9cf 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/AdminRepository.kt @@ -3,6 +3,7 @@ package top.yeij.cyrene.data.repository import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.apiCall import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto import top.yeij.cyrene.data.api.dto.MemorySummaryDto @@ -30,6 +31,16 @@ class AdminRepository( apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) } } + /** + * `GET /observability/logs`(后端日志 tail)。`limit` 钳制在 [1, [MAX_LOG_LIMIT]] 后 + * **显式**发出去(服务端对 ≤0 / 非法值有一条「回落缺省」的兜底,这里不依赖它): + * 这样「向服务端要多少条」与客户端的保留窗口(`LOG_WINDOW`)永远同源。 + */ + suspend fun logs(limit: Int = DEFAULT_LOG_LIMIT): Result> = + withContext(dispatcher) { + apiCall { api.logs(limit.coerceIn(1, MAX_LOG_LIMIT)) } + } + suspend fun personas(): Result> = withContext(dispatcher) { apiCall { api.personas() } } @@ -48,5 +59,14 @@ class AdminRepository( /** 服务端 `?limit=` 上限。 */ const val MAX_TRACE_LIMIT = 500 + + /** + * 日志拉取条数:契约的缺省值,也就是「最近日志」区块的保留窗口 + * (`ui/admin/AdminOverviewMerge.kt` 的 `LOG_WINDOW`)与推送帧的单帧上限。 + */ + const val DEFAULT_LOG_LIMIT = 200 + + /** 服务端 logs `?limit=` 上限(契约 §2)。 */ + const val MAX_LOG_LIMIT = 1000 } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ObservabilityLogEntry.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/ObservabilityLogEntry.kt deleted file mode 100644 index 489845b..0000000 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/ObservabilityLogEntry.kt +++ /dev/null @@ -1,22 +0,0 @@ -package top.yeij.cyrene.data.ws - -import kotlinx.serialization.Serializable - -/** - * `observability_log` 帧的一条日志(契约 v0.55 §3.12,与 `GET /api/v1/admin/observability/logs` - * 同形,后端生产者 `gateway.AdminLogEntry`):`{time,level,source,msg}`。 - * - * - [time] 是 **RFC3339(UTC,秒精度)**——与 `trace.startedAt` / `audit.when` 同源同形状, - * 而**不是** backthink 那种 Unix 毫秒,两者不可共用解析器。 - * - [level] ∈ `debug|info|warn|error`,是**开放集合**:本层原样保留,未知等级既不丢弃也不归一。 - * - [source] 是 logger 的来源标签,记录没带来源时是空串(不是 null)。 - * - * 放在 `data.ws` 而不是 `data.api.dto`:本端**没有**日志 REST 端点,这个类型唯一的来源就是推送帧。 - */ -@Serializable -data class ObservabilityLogEntryDto( - val time: String = "", - val level: String = "", - val source: String = "", - val msg: String = "", -) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index 4b6050b..9c36e07 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -1,6 +1,7 @@ package top.yeij.cyrene.data.ws import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto @@ -391,13 +392,13 @@ sealed class WsEvent { /** * 新增日志(契约 v0.55 §3.12)。 * - * 载荷 `{entries:[…]}` **最新在前**(与 `GET /admin/observability/logs` 同形), - * 单帧上限 200 条;触发是「订阅之后**新增**的日志」(订阅锚点 = 当时最新的条目时间), - * 所以它同样是**增量**。服务端的 `?level=` 分级过滤对推送不适用(帧是广播的,hub 不按连接分级), - * 分级选择由客户端本地做。 + * 载荷 `{entries:[…]}` **最新在前**(与 `GET /admin/observability/logs` 同形,共用 + * [AdminLogEntryDto]),单帧上限 200 条;触发是「订阅之后**新增**的日志」(订阅锚点 = 当时 + * 最新的条目时间),所以它同样是**增量**。服务端的 `?level=` 分级过滤对推送不适用 + * (帧是广播的,hub 不按连接分级),分级选择由客户端本地做。 */ data class ObservabilityLog( - val entries: List, + val entries: List, override val turnId: String? = null, override val seq: Long? = null, override val sessionId: String? = null, @@ -692,7 +693,7 @@ private data class TracePushData(val spans: List = emptyList( /** `observability_log` 载荷(后端 `gateway.AdminLogPush`):`{entries:[…]}`,最新在前。 */ @Serializable -private data class LogPushData(val entries: List = emptyList()) +private data class LogPushData(val entries: List = emptyList()) @Serializable private data class VoiceOutputData( diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt index b7b65bd..c078638 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminAccess.kt @@ -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.entries.filter { canAccess(role, it) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt index 839ac73..820c2a9 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt @@ -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 的两种时间形状)。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMerge.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMerge.kt index 1bb9585..47d8d05 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMerge.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMerge.kt @@ -1,10 +1,11 @@ package top.yeij.cyrene.ui.admin +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto -import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto +import top.yeij.cyrene.domain.admin.rfc3339EpochSecond /** - * 可观测实时推送帧的**合并**与**本地过滤**(纯函数,契约 v0.55 §3.12)。 + * 可观测面的**合并**与**本地过滤**(纯函数,契约 §2「可观测性契约」+ §3.12)。 * * 两个来源各自负责一半,谁都不替代谁: * - **REST 引导读取**(进页面 / 手动刷新 / 断线重连)给一份**全量**快照(最新在前); @@ -13,15 +14,22 @@ import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto * 把环形缓冲里的历史灌成巨型帧);`observability_metrics` 是**整份快照**,见 * `AdminViewModel`(它是替换而不是合并,所以不在这里)。 * - * 因此这里的合并语义是「**往前接**」:新到的在前,与已有按业务键去重,再按窗口截断。 - * 两端本来就是最新在前,所以**不做排序**——排序需要解析时间戳(日志是 RFC3339 秒精度、 - * span 是纳秒精度、还可能带不同偏移),一旦解析口径与后端不一致,反而会把正确的顺序排乱。 + * 两份东西的合并口径**刻意不一样**: + * - **日志**按 `time` 排成**时间倒序**(见 [normalizeLogEntries])——列表的整体顺序是客户端 + * 自己拼出来的,契约只对**单份来源**保证「最新在前」,拼接口上还有一个精度差(服务端推送锚点 + * 用的是纳秒内部时间,下发的 `time` 却是秒精度),所以这里不去**推**顺序,而是自己**定义**它; + * - **trace** 保持「新帧接在前面」——span 有服务端写入序号,帧本来就是按序来的, + * 而它的 `startedAt` 是纳秒精度、还可能是不同偏移,重排反而会把正确的顺序排乱。 */ /** trace 保留窗口,与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_TRACE_LIMIT`)一致。 */ internal const val TRACE_WINDOW = 100 -/** 日志保留窗口,与契约 §3.12 的单帧上限一致(更旧的条目本就落在保留窗口之外)。 */ +/** + * 日志保留窗口:与 REST 引导读取的 `limit`(`AdminRepository.DEFAULT_LOG_LIMIT`)以及契约 + * §3.12 的单帧上限一致(更旧的条目本就落在保留窗口之外)。三个数必须同源,否则会出现 + * 「服务端给了 200 条、客户端只留 100 条」这种悄悄丢数据的偏差。 + */ internal const val LOG_WINDOW = 200 /** @@ -36,20 +44,51 @@ internal fun mergeTraceSpans( ): List = mergeNewestFirst(existing, incoming, window) { it.id } /** - * 合并日志帧:新条目在前,按业务键(时间 + 等级 + 来源 + 消息)去重,保留最近 [window] 条。 + * 归一化一份日志快照(REST 引导读取,或已经合并过的列表):**去重 → 按时间倒序 → 截到窗口**。 + * 这三步的**顺序不能换**:窗口是位置截断,先截再排就会让一条时间上更旧的条目挤掉更新的那条。 * - * 去重键取整条内容,是因为日志**没有 id**(REST `logs` 的条目形状就是这四个字段)。 - * 代价如实说:同一秒内、来源与消息完全相同的两条日志会被收成一条——与 Web 面板 - * (`observabilityStream.ts` 按业务键合并)同一口径。 + * 去重键取整条内容([logEntryKey]),是因为日志**没有 id**;重复时保留**先出现**的那一份, + * 所以 [mergeLogEntries] 以「新到的在前」拼输入,帧里的那份会留在原位。 + */ +internal fun normalizeLogEntries( + entries: List, + window: Int = LOG_WINDOW, +): List = entries + .distinctBy(::logEntryKey) + .sortedWith(NEWEST_LOG_FIRST) + .take(window) + +/** + * 合并日志帧:[incoming] 是**增量**(订阅之后新增的日志),[existing] 是已有基线 + * (REST 快照 + 之前几拍帧),结果满足 [normalizeLogEntries] 的同一套不变式。 + * + * [incoming] 为空时**原样返回** [existing]:空增量不是「清空」,也让那次 REST 快照保持原样。 */ internal fun mergeLogEntries( - existing: List, - incoming: List, + existing: List, + incoming: List, window: Int = LOG_WINDOW, -): List = mergeNewestFirst(existing, incoming, window, ::logEntryKey) +): List = + if (incoming.isEmpty()) existing else normalizeLogEntries(incoming + existing, window) -/** 日志条目的业务键;合并去重与列表 `key` 都用它,两处必须一致。 */ -internal fun logEntryKey(entry: ObservabilityLogEntryDto): String = +/** + * 日志的时间倒序:按 RFC3339 解析出的**秒**降序,用**稳定排序**(Kotlin 的 `sortedWith` 是稳定的) + * 保住同秒条目的**到达顺序**——服务端下发的 `time` 只有秒精度,一秒内多条是常态, + * 顺序信息只在拼接顺序里,不能被排序打乱。 + * + * 时间判不出来的条目(正常线上不会出现:`time` 恒是 `Format(time.RFC3339)`)排在**最后**、 + * 但**不丢**:宁可让它落到列表尾部,也不因为「时间读不出来」把它从界面上抹掉。 + */ +private val NEWEST_LOG_FIRST: Comparator = + compareByDescending { rfc3339EpochSecond(it.time) ?: Long.MIN_VALUE } + +/** + * 日志条目的业务键;合并去重与列表 `key` 都用它,两处必须一致。 + * + * 去重键取整条内容,是因为日志**没有 id**。代价如实说:同一秒内、来源与消息完全相同的两条 + * 日志会被收成一条——与 Web 面板(`observabilityStream.ts` 按业务键合并)同一口径。 + */ +internal fun logEntryKey(entry: AdminLogEntryDto): String = "${entry.time}|${entry.level}|${entry.source}|${entry.msg}" /** @@ -60,9 +99,9 @@ internal fun logEntryKey(entry: ObservabilityLogEntryDto): String = * 判不出高低的条目不能因为「可能低于阈值」被静默藏掉(与原样保留未知值的约定一致)。 */ internal fun filterLogEntries( - entries: List, + entries: List, minLevel: String?, -): List { +): List { val threshold = levelRank(minLevel) ?: return entries return entries.filter { (levelRank(it.level) ?: Int.MAX_VALUE) >= threshold } } @@ -77,8 +116,9 @@ private fun levelRank(level: String?): Int? = when (level?.trim()?.lowercase()) } /** - * 「最新在前」列表的合并:先把 [incoming] 按原序接在前面(内部同样按 [key] 去重), - * 再补上 [existing] 里没出现过的那些;最后截断到 [window]。 + * 「最新在前」列表的合并(**只给 trace 用**;日志走 [normalizeLogEntries],因为它还要按时间排序): + * 先把 [incoming] 按原序接在前面(内部同样按 [key] 去重),再补上 [existing] 里没出现过的那些; + * 最后截断到 [window]。 */ private fun mergeNewestFirst( existing: List, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt index c598af6..0c50f72 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewScreen.kt @@ -23,9 +23,9 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto -import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto import top.yeij.cyrene.domain.admin.AdminFailure import top.yeij.cyrene.domain.admin.AdminPage import top.yeij.cyrene.domain.admin.AdminRole @@ -33,6 +33,7 @@ import top.yeij.cyrene.domain.admin.PathGroup import top.yeij.cyrene.domain.admin.PathHit import top.yeij.cyrene.domain.admin.PathRow import top.yeij.cyrene.domain.admin.PathStats +import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory import top.yeij.cyrene.domain.admin.formatCounts import top.yeij.cyrene.domain.admin.formatDouble import top.yeij.cyrene.domain.admin.formatNanos @@ -52,12 +53,18 @@ import top.yukonga.miuix.kmp.icon.extended.ChevronForward import top.yukonga.miuix.kmp.theme.MiuixTheme /** - * 概览页:`metrics` 卡片 + 「按路径」分组列表 + 其下的 `trace` 列表 + 实时推送的日志 + * 概览页:`metrics` 卡片 + 「按路径」分组列表 + 其下的 `trace` 列表 + 「最近日志」 * (api-client §2「可观测性契约」与 §3.12「可观测实时推送」)。 * - * 首屏仍是**两次 REST**(metrics / trace);之后 `observability_*` 三帧(仅 owner/admin 收得到) - * 由 [AdminViewModel] 合并进同一份状态。日志只有推送这一个来源,因此**一帧都没到过时整段不渲染** - * (详见 `AdminOverviewData.logs`)。 + * 三块都是**REST 打底、帧叠加**:首屏(以及每次刷新 / 断线重连)拉一次全量快照, + * 之后 `observability_*` 三帧(仅 owner/admin 收得到)由 [AdminViewModel] 合进同一份状态。 + * 因此**每一块都按自己那次读取的结果决定怎么画**:区块里绝不出现「用别人的失败解释我」—— + * 三路的失败原因是分开的(见 `AdminOverviewData`)。 + * + * 「最近日志」的历史来自 `GET /api/v1/admin/observability/logs`,那是 v0.40 的只读监控档, + * **`viewer` 也读得到**;而增量帧只发 owner/admin。所以区块是否存在只看 + * [canReadObservabilityLogHistory](= 历史读不读得到),**不看帧有没有到过**—— + * `viewer` 的列表不增长,不是错误、也不是空态。 */ @Composable fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) { @@ -91,7 +98,9 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () - data == null && state.loading -> AdminCenterHint("加载中…") - data == null || (data.metrics == null && data.trace.isEmpty()) -> + // 三块都没有可画的东西才整页报错;只要有一路成功,就让它画出来、 + // 失败的区块各自说明自己(把一次成功藏在一句「加载失败」后面是另一种撒谎)。 + data == null || (data.metrics == null && data.trace.isEmpty() && data.logs == null) -> AdminFailureState(failure, reload) else -> LazyColumn( @@ -106,7 +115,7 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () - Spacer(Modifier.height(8.dp)) AdminMetricCard(countRows(data.metrics)) } else { - AdminTextCard("指标不可用", failureMessage(failure)) + AdminTextCard("指标不可用", failureMessage(data.metricsFailure)) } } } @@ -120,8 +129,9 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () - item(key = "__trace_title__") { AdminSectionTitle("最近 trace") } when { // trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。 - failure != null -> item(key = "__trace_error__") { - AdminTextCard("trace 不可用", failureMessage(failure)) + // 这里只看 **trace 自己**的失败:日志那边挂了不该让这块说「不可用」。 + data.traceFailure != null -> item(key = "__trace_error__") { + AdminTextCard("trace 不可用", failureMessage(data.traceFailure)) } data.trace.isEmpty() -> item(key = "__trace_empty__") { @@ -132,18 +142,33 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () - TraceSpanCard(span) } } - // 日志:**只有推送一个来源**(本端没有 logs REST 端点)。 - // 一帧都没到过就整段不渲染——这里既没有 REST 引导,也没有「确实没有日志」的依据, - // 写「暂无日志」等于把「没有推送」说成「没有数据」。 - if (data.logs.isNotEmpty()) { + // 日志:历史来自 REST 引导读取(viewer 也读得到),增量来自 observability_log 帧 + // (只发 owner/admin)。区块的存在只由「历史读不读得到」决定,帧有没有到过**不参与** + // ——viewer 收不到增量,不该因此看到任何错误或空态(契约 §2.0 / §3.12)。 + if (canReadObservabilityLogHistory(role)) { item(key = "__logs_title__") { AdminSectionTitle("最近日志") } - item(key = "__logs_level__") { LogLevelTabs(logLevel) { logLevel = it } } - if (visibleLogs.isEmpty()) { - item(key = "__logs_empty__") { AdminInlineHint("当前等级下没有日志") } - } else { - items(items = visibleLogs, key = { logEntryKey(it) }) { entry -> - LogEntryCard(entry) + when { + // 有基线(含空列表):这一块的内容就是它 + 之后的帧增量。 + data.logs != null -> if (data.logs.isEmpty()) { + item(key = "__logs_empty__") { AdminInlineHint("暂无日志") } + } else { + item(key = "__logs_level__") { LogLevelTabs(logLevel) { logLevel = it } } + if (visibleLogs.isEmpty()) { + item(key = "__logs_filtered__") { AdminInlineHint("当前等级下没有日志") } + } else { + items(items = visibleLogs, key = { logEntryKey(it) }) { entry -> + LogEntryCard(entry) + } + } } + + // 那次引导读取失败:说失败,绝不说「暂无日志」。 + data.logsFailure != null -> item(key = "__logs_error__") { + AdminTextCard("日志不可用", failureMessage(data.logsFailure)) + } + + // 读取还在飞(例如上一次「日志不可用」之后按了刷新):说加载中。 + else -> item(key = "__logs_loading__") { AdminInlineHint("日志加载中…") } } } item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) } @@ -285,7 +310,7 @@ private fun TraceSpanCard(span: AdminTraceSpanDto) { * 不猜一个近似色)。消息**不截断**:这是诊断视图,被截掉的往往正是要看的那半句。 */ @Composable -private fun LogEntryCard(entry: ObservabilityLogEntryDto) { +private fun LogEntryCard(entry: AdminLogEntryDto) { AdminTextCard( title = entry.msg.ifBlank { "(无消息)" }, body = logMeta(entry), @@ -299,7 +324,7 @@ private fun LogEntryCard(entry: ObservabilityLogEntryDto) { } /** 副文本:来源 + 本地时间;两者都可能缺,缺了就不占位。 */ -private fun logMeta(entry: ObservabilityLogEntryDto): String = listOfNotNull( +private fun logMeta(entry: AdminLogEntryDto): String = listOfNotNull( entry.source.takeIf { it.isNotBlank() }, formatRfc3339(entry.time), ).joinToString(" · ") diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt index b4795df..d415a45 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminViewModel.kt @@ -12,18 +12,19 @@ import kotlinx.coroutines.launch import top.yeij.cyrene.app.AppContainer import top.yeij.cyrene.core.auth.SessionState import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto +import top.yeij.cyrene.data.api.dto.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminMetricsDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto import top.yeij.cyrene.data.api.dto.MemorySummaryDto import top.yeij.cyrene.data.api.dto.PersonaSummaryDto import top.yeij.cyrene.data.ws.ChatSocket -import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto import top.yeij.cyrene.data.ws.WsEvent import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.domain.admin.AdminFailure import top.yeij.cyrene.domain.admin.AdminPage import top.yeij.cyrene.domain.admin.AdminRole import top.yeij.cyrene.domain.admin.canAccess +import top.yeij.cyrene.domain.admin.canReadObservabilityLogHistory import top.yeij.cyrene.domain.admin.canReceiveObservabilityFrames import top.yeij.cyrene.domain.admin.toAdminFailure @@ -38,16 +39,26 @@ data class AdminUiState( val failure: AdminFailure? = null, ) -/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */ +/** 概览页:metrics / trace / 日志三路取数合成一份状态。 */ data class AdminOverviewData( val metrics: AdminMetricsDto? = null, val trace: List = emptyList(), /** - * 日志:本端**没有** logs REST 端点(`data/api` 不在本模块改动面内), - * 所以它**只**来自 `observability_log` 帧累积;一帧都没到过时是空列表。 - * UI 据此**整段不渲染**——不写「暂无日志」,那等于把「没有推送」说成「确实没有日志」。 + * 「最近日志」:null = **还没有历史基线**(引导读取在飞,或那次读取失败)⇒ 界面整段不渲染; + * 非 null(**含空列表**)= REST 读过一次且成功 ⇒ 渲染区块,空列表就是**确实没有日志**。 + * + * 推送帧是**增量**(`observability_log` 以订阅那一刻的最新条目为锚,不回灌历史), + * 所以帧**不**负责把它从 null 变成非 null:没有基线就没有可以叠加的东西。 */ - val logs: List = emptyList(), + val logs: List? = null, + /** + * 三路各自失败的原因。**必须分开记**:三路各自成败是常态(例如 logs 缓冲未注入回 501, + * 而 metrics / trace 正常),共用一个 `failure` 会让「日志挂了」显示成「trace 不可用」—— + * 那是把一次成功说成失败。 + */ + val metricsFailure: AdminFailure? = null, + val traceFailure: AdminFailure? = null, + val logsFailure: AdminFailure? = null, ) /** @@ -58,10 +69,11 @@ data class AdminOverviewData( * * ## 可观测实时推送(契约 v0.55 §3.12) * 概览页在已连接时把 `observability_metrics` / `observability_trace` / `observability_log` - * 三帧合并进 [overview]:**REST 仍是兜底真源**(进页面 / 手动刷新 / 断线重连照旧各拉一次 - * 全量快照),帧只是叠在上面的增量。三帧都只发给 `owner` / `admin`(fail-closed), + * 三帧合并进 [overview]:**REST 是真源**(进页面 / 手动刷新 / 断线重连各拉一次快照, + * 日志的历史基线也由它给),帧只是叠在上面的增量。三帧都只发给 `owner` / `admin`(fail-closed), * 客户端按 [canReceiveObservabilityFrames] 自行降级:`viewer` 既不开连接也不合帧, - * 继续走它本就有权读的那两个 REST 端点。 + * 但它**读得到**日志历史([canReadObservabilityLogHistory]),所以概览页上它照样看得到 + * 「最近日志」,只是那份列表不增长——这不是错误也不是空态。 * * ## 为什么本类自己开一条 WS 连接 * `observability_*` 三帧在 WS 上,而 `AppContainer` 只提供 `createChatSocket()` @@ -96,12 +108,12 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() { when (auth) { SessionState.Unknown -> Unit - // 登出:断开连接并丢掉**帧来源**的状态(日志)。它会跨账号残留是因为 - // 它没有 REST 兜底——metrics / trace 每次进页面都会被重拉的快照整体替换。 + // 登出:断开连接并丢掉日志基线(`null` = 没有基线 ⇒ 区块立刻整段收起), + // 不留下一个换账号后还挂着上个账号数据的列表。 SessionState.LoggedOut -> { socket.close() _overview.update { state -> - state.copy(data = state.data?.copy(logs = emptyList())) + state.copy(data = state.data?.copy(logs = null)) } } @@ -132,7 +144,7 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() { } viewModelScope.launch { when (page) { - AdminPage.Overview -> loadOverview() + AdminPage.Overview -> loadOverview(role) AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() } AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() } AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() } @@ -141,38 +153,43 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() { } /** - * 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 + * 概览页三路请求并发;任一 403 即整页无权限(聚合进 [AdminUiState.failure]), + * 各路自己的失败原因另外记进 [AdminOverviewData],让每个区块说自己的话。 * - * 帧累积的 [AdminOverviewData.logs] **不参与本次替换**:日志没有 REST 来源, - * 重拉清掉它就等于让它永久消失(帧只送增量,不会把历史再灌一遍)。 - * 用 `update` 原子地取「此刻的日志」,是为了不把**请求在飞期间**到达的日志帧抹掉。 + * 三路都**以本次结果为准**:帧送的是增量,而 REST 是全量(metrics 是整份快照、 + * trace 含所有 span、日志是那份环形缓冲的尾部)。请求在飞期间到达的帧会被这次快照盖掉—— + * 那几条要么本来就在快照里,要么是快照取数之后新产生的、下一拍帧或下次刷新会再带回, + * 不值得为这点竞态引入版本号。 * - * metrics / trace 则反过来**以本次快照为准**:帧送的是增量,而 REST 是全量(含所有新 span), - * 竞态下丢掉的那一拍增量会被下一次条目/指标变化补回来,不值得为它引入版本号。 + * 日志这条在 W10 是例外(帧是唯一来源,重拉会把累积的列表永久抹掉);接了 REST 之后 + * 例外消失:**基线随时可以重建**,所以这里与另两路同口径,一次失败不留下「看着像当前、 + * 其实停在过去」的列表(见 [AdminOverviewData.logs])。 */ - private suspend fun loadOverview() { + private suspend fun loadOverview(role: AdminRole) { _overview.update { it.copy(loading = true, failure = null) } val repository = container.adminRepository - val (metrics, trace) = coroutineScope { + val (metrics, trace, logs) = coroutineScope { val metricsCall = async { repository.metrics() } val traceCall = async { repository.trace() } - metricsCall.await() to traceCall.await() - } - val failures = listOfNotNull(metrics.exceptionOrNull(), trace.exceptionOrNull()) - .map { it.toAdminFailure() } - val freshMetrics = metrics.getOrNull() - val freshTrace = trace.getOrNull().orEmpty() - val failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull() - _overview.update { state -> - AdminUiState( - data = AdminOverviewData( - metrics = freshMetrics, - trace = freshTrace, - logs = state.data?.logs.orEmpty(), - ), - failure = failure, - ) + // 日志历史按其角色的档位决定读不读(viewer 也读得到);不够档的页在 load() 就被拦下了。 + val logsCall = if (canReadObservabilityLogHistory(role)) async { repository.logs() } else null + Triple(metricsCall.await(), traceCall.await(), logsCall?.await()) } + val failures = listOfNotNull(metrics, trace, logs) + .mapNotNull { it.exceptionOrNull()?.toAdminFailure() } + _overview.value = AdminUiState( + data = AdminOverviewData( + metrics = metrics.getOrNull(), + metricsFailure = metrics.exceptionOrNull()?.toAdminFailure(), + trace = trace.getOrNull().orEmpty(), + traceFailure = trace.exceptionOrNull()?.toAdminFailure(), + // 归一化到与帧合并同一套不变式(去重 + 时间倒序 + 窗口 200), + // 于是「REST 基线」与「基线 + 帧」在 UI 眼里完全同形。 + logs = logs?.getOrNull()?.let(::normalizeLogEntries), + logsFailure = logs?.exceptionOrNull()?.toAdminFailure(), + ), + failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(), + ) } private suspend fun loadList( @@ -237,12 +254,20 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() { } } - /** 日志帧是**增量**:与已有条目按业务键去重合并(见 [mergeLogEntries])。 */ + /** + * 日志帧是**增量**:叠在 REST 基线上(去重 + 按时间倒序 + 窗口 200,见 [mergeLogEntries])。 + * + * 没有基线([AdminOverviewData.logs] 为 null)时**整帧丢弃**:增量只能叠在基线上, + * 凭空造一份「只有帧、没有历史」的列表,会把「还没拉到」渲染成「就这几条」。 + * 丢掉的那几条不会永久消失——服务端的环形缓冲留着 2000 条,下次成功的引导读取会带回最新 + * 200 条(只要这期间没产生超过一个窗口的新日志,它们就在里面)。 + */ private fun onLogFrame(event: WsEvent.ObservabilityLog) { if (event.entries.isEmpty()) return _overview.update { state -> val data = state.data ?: return@update state - state.copy(data = data.copy(logs = mergeLogEntries(data.logs, event.entries))) + val baseline = data.logs ?: return@update state + state.copy(data = data.copy(logs = mergeLogEntries(baseline, event.entries))) } } @@ -266,8 +291,9 @@ class AdminViewModel(private val container: AppContainer) : ViewModel() { } private fun reloadOverview() { - if (!canReceiveObservabilityFrames(currentRole())) return + val role = currentRole() + if (!canReceiveObservabilityFrames(role)) return if (_overview.value.data == null) return - viewModelScope.launch { loadOverview() } + viewModelScope.launch { loadOverview(role) } } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt index 0de5ca0..4258457 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/AdminDtoDecodingTest.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.json.boolean import kotlinx.serialization.json.int import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -28,6 +29,64 @@ class AdminDtoDecodingTest { private val personaList = ListSerializer(PersonaSummaryDto.serializer()) private val memoryList = ListSerializer(MemorySummaryDto.serializer()) private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer()) + private val logTail = ListSerializer(AdminLogEntryDto.serializer()) + + @Test + fun `the logs tail decodes newest first and keeps the four fields verbatim`() { + val body = """ + {"data":[{"time":"2026-10-04T04:36:15Z","level":"warn","source":"gateway","msg":"慢请求"}, + {"time":"2026-10-04T04:36:14Z","level":"info","source":"","msg":"启动完成"}]} + """.trimIndent() + + val entries = json.decodeFromString(ApiEnvelope.serializer(logTail), body).data!! + + // 服务端已按 time 倒序(最新在前);这一层**不重排**,顺序原样交给合并层。 + assertEquals(listOf("慢请求", "启动完成"), entries.map { it.msg }) + assertEquals("2026-10-04T04:36:15Z", entries.first().time) + assertEquals("warn", entries.first().level) + // 记录没带来源时是空串(不是 null)——原样保留,不做归一。 + assertEquals("", entries[1].source) + } + + @Test + fun `an empty logs tail is an empty success rather than a missing payload`() { + // `{"data":[]}` 是**成功且确实没有日志**:解出来必须是空列表而不是 null, + // 否则 apiCall 会把它判成 Parse 失败,UI 就会把「没有日志」说成「日志不可用」。 + val data = json.decodeFromString(ApiEnvelope.serializer(logTail), """{"data":[]}""").data + + assertNotNull(data) + assertTrue(data!!.isEmpty()) + } + + @Test + fun `a log entry tolerates missing fields and keeps an unknown level raw`() { + val entries = json.decodeFromString( + ApiEnvelope.serializer(logTail), + """{"data":[{"msg":"只有消息"},{"time":"2026-10-04T04:36:15Z","level":"trace","source":"x","msg":"未来等级"}]}""", + ).data!! + + // 后端恒给四个键;缺了只回落空串,绝不让整条(乃至整页)解不出来。 + assertEquals("", entries[0].time) + assertEquals("", entries[0].level) + assertEquals("", entries[0].source) + assertEquals("只有消息", entries[0].msg) + // level 是开放集合:未知取值原样保留,不丢弃也不归一。 + assertEquals("trace", entries[1].level) + } + + @Test + fun `the log time stays a verbatim string instead of being parsed or converted`() { + // time 是 RFC3339(秒精度),而 backthink 的 at 是 Unix 毫秒:DTO 层两边都只当字符串收, + // 解析留给各自的解析器(domain/admin/AdminFormat.kt)——这里钉的是「不在这层偷换形状」。 + val entries = json.decodeFromString( + ApiEnvelope.serializer(logTail), + """{"data":[{"time":"2026-10-04T04:36:15Z","msg":"真时间"},{"time":"1730721600000","msg":"毫秒形状"}]}""", + ).data!! + + assertEquals("2026-10-04T04:36:15Z", entries[0].time) + // 毫秒形状**原样**留着(不转成 RFC3339),它会在排序时被判成「没有时间」而不是排到前面。 + assertEquals("1730721600000", entries[1].time) + } @Test fun `metrics envelope decodes integer-shaped doubles and count maps`() { diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/AdminRepositoryLogsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/AdminRepositoryLogsTest.kt new file mode 100644 index 0000000..d28a40e --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/AdminRepositoryLogsTest.kt @@ -0,0 +1,188 @@ +package top.yeij.cyrene.data.repository + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.AdminApi +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.domain.admin.AdminFailure +import top.yeij.cyrene.domain.admin.toAdminFailure +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * 日志 tail 端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端 + * (与 [UploadRepositoryTest] 同一手法,不引新依赖)。 + * + * 这里要证明的不是「能解析一段 JSON」,而是三件线上真会出事的事: + * 1. 请求真的是契约里那条路径(`GET /api/v1/admin/observability/logs`),`limit` 真的是客户端的 + * 保留窗口——服务端给 200 条而本地只留 100 条,就是无声地丢数据; + * 2. `{"data":[]}` 是**成功且确实没有日志**,而 `501 not_implemented`(组合根没挂日志环形缓冲) + * 与 `403` 都是**失败**:UI 上「暂无日志」与「日志不可用」必须是两件不同的事; + * 3. 形状不对的载荷(`time` 是数字而不是 RFC3339 串)判**失败**,绝不静默解成一条都没有。 + */ +class AdminRepositoryLogsTest { + + private val backend = FakeLogsBackend() + + @After + fun tearDown() { + backend.close() + } + + private fun repository() = AdminRepository( + ApiClientFactory.create(baseUrl = backend.baseUrl, service = AdminApi::class.java), + ) + + @Test + fun `the tail is fetched from the contract path with the client window as the limit`() = runBlocking { + backend.reply(200, backend.tailBody()) + + repository().logs().getOrThrow() + + val request = backend.requests.single() + assertEquals("GET", request.method) + assertEquals("/api/v1/admin/observability/logs", request.path) + // 服务端缺省也是 200,但这里**显式**发出去:两端必须同源,否则改动任意一侧都会悄悄丢数据。 + assertEquals("limit=${AdminRepository.DEFAULT_LOG_LIMIT}", request.query) + } + + @Test + fun `the requested limit is clamped to what the server accepts`() = runBlocking { + backend.reply(200, """{"data":[]}""") + + repository().logs(limit = 100_000).getOrThrow() + + assertEquals("limit=${AdminRepository.MAX_LOG_LIMIT}", backend.requests.single().query) + } + + @Test + fun `the tail keeps the server order and every field verbatim`() = runBlocking { + backend.reply(200, backend.tailBody()) + + val entries = repository().logs().getOrThrow() + + assertEquals(listOf("慢请求", "启动完成"), entries.map { it.msg }) + assertEquals("2026-10-04T04:36:15Z", entries.first().time) + assertEquals("warn", entries.first().level) + assertEquals("gateway", entries.first().source) + // 记录没带来源时是空串——原样保留,不补值。 + assertEquals("", entries[1].source) + } + + @Test + fun `an empty tail is a success with no entries`() = runBlocking { + // 「确实没有日志」是成功的一种:解成空列表,而不是 Parse 失败—— + // 否则界面上「暂无日志」会被说成「日志不可用」。 + backend.reply(200, """{"data":[]}""") + + assertTrue(repository().logs().getOrThrow().isEmpty()) + } + + @Test + fun `a 501 without the ring buffer is a failure, never an empty tail`() = runBlocking { + // 组合根没挂日志缓冲时后端如实回 501(不伪造空 tail):这条必须走失败分支, + // 让它一路走到 UI 的「日志不可用」。 + backend.reply( + 501, + """{"error":{"code":"not_implemented","message":"日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)"}}""", + ) + + val error = repository().logs().exceptionOrNull() as CyreneException + + assertEquals( + CyreneError.Server("not_implemented", "日志环形缓冲未注入(组合根未挂载 logger.RingBuffer)", 501), + error.error, + ) + } + + @Test + fun `a 403 becomes the forbidden state instead of empty logs`() = runBlocking { + // 角色不够是「无权限」,不是「没有日志」:这条链路一直走到 domain 的收敛函数。 + backend.reply(403, """{"error":{"code":"forbidden","message":"auth: forbidden"}}""") + + val error = repository().logs().exceptionOrNull()!! + + assertEquals(AdminFailure.Forbidden, error.toAdminFailure()) + } + + @Test + fun `a malformed entry fails instead of quietly returning nothing`() = runBlocking { + // `time` 是数字而不是 RFC3339 串:形状不对就得失败,不能当成「一条都没有」。 + backend.reply(200, """{"data":[{"time":1730721600000,"level":"info","source":"x","msg":"m"}]}""") + + val error = repository().logs().exceptionOrNull() as CyreneException + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error) + } + + @Test + fun `an unreachable server is a local failure`() = runBlocking { + backend.close() + + val error = repository().logs().exceptionOrNull() as CyreneException + + assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local) + } + + /** 记录请求并按 `reply(...)` 回话的假后端(只服务日志 tail 一条路径)。 */ + private inner class FakeLogsBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/admin/observability/logs", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val requests = CopyOnWriteArrayList() + + @Volatile + private var status: Int = 200 + + @Volatile + private var body: String = "" + + fun reply(status: Int, body: String) { + this.status = status + this.body = body + } + + /** 线上的成功形状:`{data:[{time,level,source,msg}]}`,最新在前(后端 `adminLogOf`)。 */ + fun tailBody(): String = """ + {"data":[ + {"time":"2026-10-04T04:36:15Z","level":"warn","source":"gateway","msg":"慢请求"}, + {"time":"2026-10-04T04:36:14Z","level":"info","source":"","msg":"启动完成"}]} + """.trimIndent().replace("\n", "") + + private fun handle(exchange: HttpExchange) { + requests += RecordedRequest( + method = exchange.requestMethod, + path = exchange.requestURI.path, + query = exchange.requestURI.query.orEmpty(), + ) + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() { + runCatching { server.stop(0) } + } + } + + private data class RecordedRequest( + val method: String, + val path: String, + val query: String, + ) +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt index df54df7..000b5fe 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminAccessTest.kt @@ -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)) + } + } } diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt index 2800ba8..a1da065 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt @@ -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)) diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMergeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMergeTest.kt index 9659867..423d35d 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMergeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/admin/AdminOverviewMergeTest.kt @@ -4,22 +4,25 @@ 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.AdminLogEntryDto import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto -import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto +import top.yeij.cyrene.data.repository.AdminRepository /** - * 可观测推送帧的合并与本地过滤(契约 v0.55 §3.12)的纯函数。 + * 可观测面合并与本地过滤的纯函数(契约 §2「可观测性契约」+ §3.12)。 * - * 断言的是**三条不会写进类型里的语义**:新帧在前(列表两端本来就最新在前,不排序)、 - * 按业务键去重(帧与 REST 竞态时不会出现两条同样的数据)、窗口截断(不许无界增长); - * 以及日志的等级过滤保留「判不出等级」的条目。 + * 断言的是**四条不会写进类型里的语义**: + * 1. 合并的两端形态不同——REST 基线是**全量**、帧是**增量**,空增量不改任何东西; + * 2. 日志列表始终满足「**无重复 + 时间倒序 + 至多窗口条**」这个不变式(trace 不排序,见文件内说明); + * 3. 窗口是**位置**截断,所以必须排在排序**之后**,否则一条更旧的条目会挤掉更新的那条; + * 4. 日志的等级过滤保留「判不出等级」的条目。 */ 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) + private fun entry(msg: String, level: String = "info", time: String = "2026-10-04T12:00:00Z") = + AdminLogEntryDto(time = time, level = level, source = "gateway", msg = msg) @Test fun `an empty delta leaves the snapshot untouched`() { @@ -57,13 +60,107 @@ class AdminOverviewMergeTest { @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 `a rest baseline and the frame delta merge into one time ordered list`() { + // REST 基线(最新在前)+ 之后的帧增量:去重、按 time 倒序,同一条不出现两次。 + val baseline = listOf( + entry("b3", time = "2026-10-04T12:00:03Z"), + entry("b2", time = "2026-10-04T12:00:02Z"), + entry("b1", time = "2026-10-04T12:00:01Z"), + ) + val delta = listOf( + entry("f5", time = "2026-10-04T12:00:05Z"), + // 订阅握手与引导读取之间可能重叠:同一条既在快照里、又被帧带了一次。 + entry("b3", time = "2026-10-04T12:00:03Z"), + ) + + val merged = mergeLogEntries(baseline, delta) + + assertEquals(listOf("f5", "b3", "b2", "b1"), merged.map { it.msg }) + } + + @Test + fun `a stale entry arriving with a frame does not jump to the top`() { + // 拼接顺序不等于时间顺序:帧里若夹带一条**更旧**的条目(服务端锚点用的是纳秒内部时间、 + // 下发的 time 只有秒精度,拼接口上就有这种可能),它必须落到该落的位置。 + val merged = mergeLogEntries( + listOf(entry("new", time = "2026-10-04T12:00:09Z")), + listOf(entry("old", time = "2026-10-04T12:00:01Z")), + ) + + assertEquals(listOf("new", "old"), merged.map { it.msg }) + } + + @Test + fun `the window keeps the newest by time, not the ones that arrived last`() { + // 窗口是**位置**截断 ⇒ 必须先排序再截断。否则这条更旧的 "old" 会占掉一格、把 b2 挤掉。 + val merged = mergeLogEntries( + existing = listOf(entry("b1", time = "2026-10-04T12:00:05Z"), entry("b2", time = "2026-10-04T12:00:04Z")), + incoming = listOf(entry("old", time = "2026-10-04T12:00:03Z")), + window = 2, + ) + + assertEquals(listOf("b1", "b2"), merged.map { it.msg }) + } + + @Test + fun `entries sharing a second keep their arrival order`() { + // time 只有秒精度,一秒内多条是常态:稳定排序必须保住到达顺序(新到的在前), + // 否则同一秒里的时间线会被排乱。 + val merged = mergeLogEntries( + existing = listOf(entry("先", time = "2026-10-04T12:00:07Z")), + incoming = listOf(entry("后", time = "2026-10-04T12:00:07Z")), + ) + + assertEquals(listOf("后", "先"), merged.map { it.msg }) + } + + @Test + fun `an unparsable time sinks to the tail but is never dropped`() { + val entries = listOf( + entry("没有时间", time = ""), + entry("有时间", time = "2026-10-04T12:00:01Z"), + entry("时间坏了", time = "not-a-time"), + ) + + val normalized = normalizeLogEntries(entries) + + assertEquals(listOf("有时间", "没有时间", "时间坏了"), normalized.map { it.msg }) + } + + @Test + fun `a unix millisecond timestamp is not mistaken for an rfc3339 one`() { + // 两种时间形状刻意不共用解析器:1730721600000 是**毫秒**,按 RFC3339 解析必须失败 + //(否则会被当成「有时间的条目」排到前面,把真实顺序排乱)。 + val normalized = normalizeLogEntries( + listOf( + entry("毫秒形状", time = "1730721600000"), + entry("真时间", time = "2026-10-04T12:00:01Z"), + ), + ) + + assertEquals(listOf("真时间", "毫秒形状"), normalized.map { it.msg }) + } + + @Test + fun `normalizing a rest snapshot is idempotent`() { + // 服务端本来就给「最新在前」,归一化不该改变它;再归一化一次也必须一样(不变式自洽)。 + val snapshot = listOf( + entry("a", time = "2026-10-04T12:00:03Z"), + entry("b", time = "2026-10-04T12:00:02Z"), + ) + + val once = normalizeLogEntries(snapshot) + + assertEquals(snapshot, once) + assertEquals(once, normalizeLogEntries(once)) + } + @Test fun `log entries differing only in level are distinct`() { val merged = mergeLogEntries( @@ -131,5 +228,8 @@ class AdminOverviewMergeTest { assertEquals(100, TRACE_WINDOW) assertEquals(200, LOG_WINDOW) assertTrue(TRACE_WINDOW > 0) + // 「向服务端要多少条」与「留多少条」必须同源:不一致就是悄悄丢数据。 + assertEquals(AdminRepository.DEFAULT_LOG_LIMIT, LOG_WINDOW) + assertEquals(AdminRepository.DEFAULT_TRACE_LIMIT, TRACE_WINDOW) } }