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

@@ -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<ApiEnvelope<List<AdminTraceSpanDto>>>
/**
* 后端日志 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<ApiEnvelope<List<AdminLogEntryDto>>>
/** 人格列表(含 admin 专属 `thinking`)。 */
@GET("api/v1/admin/personas")
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
@@ -47,6 +47,26 @@ data class AdminTraceEventDto(
val props: Map<String, JsonElement>? = 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。
@@ -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<List<AdminLogEntryDto>> =
withContext(dispatcher) {
apiCall { api.logs(limit.coerceIn(1, MAX_LOG_LIMIT)) }
}
suspend fun personas(): Result<List<PersonaSummaryDto>> = 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
}
}
@@ -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 = "",
)
@@ -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<ObservabilityLogEntryDto>,
val entries: List<AdminLogEntryDto>,
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<AdminTraceSpanDto> = emptyList(
/** `observability_log` 载荷(后端 `gateway.AdminLogPush`):`{entries:[…]}`,最新在前。 */
@Serializable
private data class LogPushData(val entries: List<ObservabilityLogEntryDto> = emptyList())
private data class LogPushData(val entries: List<AdminLogEntryDto> = emptyList())
@Serializable
private data class VoiceOutputData(
@@ -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 的两种时间形状)。
@@ -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<AdminTraceSpanDto> = mergeNewestFirst(existing, incoming, window) { it.id }
/**
* 合并日志帧:新条目在前,按业务键(时间 + 等级 + 来源 + 消息)去重,保留最近 [window] 条。
* 归一化一份日志快照(REST 引导读取,或已经合并过的列表):**去重 → 按时间倒序 → 截到窗口**。
* 这三步的**顺序不能换**:窗口是位置截断,先截再排就会让一条时间上更旧的条目挤掉更新的那条。
*
* 去重键取整条内容,是因为日志**没有 id**(REST `logs` 的条目形状就是这四个字段)。
* 代价如实说:同一秒内、来源与消息完全相同的两条日志会被收成一条——与 Web 面板
* (`observabilityStream.ts` 按业务键合并)同一口径。
* 去重键取整条内容([logEntryKey]),是因为日志**没有 id**;重复时保留**先出现**的那一份,
* 所以 [mergeLogEntries] 以「新到的在前」拼输入,帧里的那份会留在原位。
*/
internal fun normalizeLogEntries(
entries: List<AdminLogEntryDto>,
window: Int = LOG_WINDOW,
): List<AdminLogEntryDto> = entries
.distinctBy(::logEntryKey)
.sortedWith(NEWEST_LOG_FIRST)
.take(window)
/**
* 合并日志帧:[incoming] 是**增量**(订阅之后新增的日志),[existing] 是已有基线
* (REST 快照 + 之前几拍帧),结果满足 [normalizeLogEntries] 的同一套不变式。
*
* [incoming] 为空时**原样返回** [existing]:空增量不是「清空」,也让那次 REST 快照保持原样。
*/
internal fun mergeLogEntries(
existing: List<ObservabilityLogEntryDto>,
incoming: List<ObservabilityLogEntryDto>,
existing: List<AdminLogEntryDto>,
incoming: List<AdminLogEntryDto>,
window: Int = LOG_WINDOW,
): List<ObservabilityLogEntryDto> = mergeNewestFirst(existing, incoming, window, ::logEntryKey)
): List<AdminLogEntryDto> =
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<AdminLogEntryDto> =
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<ObservabilityLogEntryDto>,
entries: List<AdminLogEntryDto>,
minLevel: String?,
): List<ObservabilityLogEntryDto> {
): List<AdminLogEntryDto> {
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 <T> mergeNewestFirst(
existing: List<T>,
@@ -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(" · ")
@@ -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<T>(
val failure: AdminFailure? = null,
)
/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
/** 概览页:metrics / trace / 日志三路取数合成一份状态。 */
data class AdminOverviewData(
val metrics: AdminMetricsDto? = null,
val trace: List<AdminTraceSpanDto> = emptyList(),
/**
* 日志:本端**没有** logs REST 端点(`data/api` 不在本模块改动面内),
* 所以它**只**来自 `observability_log` 帧累积;一帧都没到过时是空列表。
* UI 据此**整段不渲染**——不写「暂无日志」,那等于把「没有推送」说成「确实没有日志」。
* 「最近日志」:null = **还没有历史基线**(引导读取在飞,或那次读取失败)⇒ 界面整段不渲染;
* 非 null(**含空列表**)= REST 读过一次且成功 ⇒ 渲染区块,空列表就是**确实没有日志**。
*
* 推送帧是**增量**(`observability_log` 以订阅那一刻的最新条目为锚,不回灌历史),
* 所以帧**不**负责把它从 null 变成非 null:没有基线就没有可以叠加的东西。
*/
val logs: List<ObservabilityLogEntryDto> = emptyList(),
val logs: List<AdminLogEntryDto>? = 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 <T> 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) }
}
}
@@ -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`() {
@@ -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<RecordedRequest>()
@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,
)
}
@@ -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))
@@ -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)
}
}