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

@@ -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)
}
}