feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口
W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有 `GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的 条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把 「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。 - **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、 帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次, 会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。 - **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把 `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto` (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。 - **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置** 截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住 同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。 trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。 - **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功 ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。 三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。 - **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档, v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。 ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。 测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。 `assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
This commit is contained in:
1 parent
e59ef08cf9
commit
642bdb13f0
15 files changed
+681
-119
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user