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(
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user