feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读, 不调用任何 /admin/* 写端点。 - data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository, 经 ApiClientFactory 创建以复用既有 token / 401 处理 - domain:AdminAccess.kt 为权限真源(纯函数,便于单测); AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析 - ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举), 四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent - RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档—— viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+ 服务端 403 也渲染「无权限」态,三层 fail-closed; 任何失败都不渲染成「暂无数据」 为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点 均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②, 凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。 测试:74 个单测全绿,其中新增 29 个 (AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
This commit is contained in:
1 parent
9bc45dc315
commit
a7b3452e6d
19 files changed
+1672
-6
No files matched your search
@@ -5,8 +5,10 @@ import top.yeij.cyrene.core.auth.AuthManager
|
||||
import top.yeij.cyrene.core.auth.SessionStore
|
||||
import top.yeij.cyrene.core.prefs.AppSettings
|
||||
import top.yeij.cyrene.core.prefs.SessionPreferences
|
||||
import top.yeij.cyrene.data.api.AdminApi
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import top.yeij.cyrene.data.repository.AdminRepository
|
||||
import top.yeij.cyrene.data.repository.MessageRepository
|
||||
import top.yeij.cyrene.data.repository.SessionRepository
|
||||
import top.yeij.cyrene.data.ws.ChatSocket
|
||||
@@ -43,6 +45,28 @@ class AppContainer(context: Context) {
|
||||
|
||||
val messageRepository: MessageRepository get() = MessageRepository(sessionsApi)
|
||||
|
||||
private var cachedAdminApi: AdminApi? = null
|
||||
private var cachedAdminBaseUrl: String? = null
|
||||
|
||||
/** 管理只读监控客户端;与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */
|
||||
val adminApi: AdminApi
|
||||
get() {
|
||||
val baseUrl = authManager.currentBaseUrl
|
||||
val cached = cachedAdminApi
|
||||
if (cached != null && cachedAdminBaseUrl == baseUrl) return cached
|
||||
val created = ApiClientFactory.create(
|
||||
baseUrl = baseUrl,
|
||||
service = AdminApi::class.java,
|
||||
tokenProvider = { sessionStore.load().token },
|
||||
onUnauthorized = { authManager.onSessionExpired() },
|
||||
)
|
||||
cachedAdminBaseUrl = baseUrl
|
||||
cachedAdminApi = created
|
||||
return created
|
||||
}
|
||||
|
||||
val adminRepository: AdminRepository get() = AdminRepository(adminApi)
|
||||
|
||||
fun createChatSocket(): ChatSocket = ChatSocket(
|
||||
baseUrlProvider = { authManager.currentBaseUrl },
|
||||
tokenProvider = { sessionStore.load().token },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package top.yeij.cyrene.data.api
|
||||
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
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 retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 管理只读监控端点(api-client §2,全部 `GET`,无写操作)。
|
||||
*
|
||||
* RBAC 由服务端兜底(`viewer` 对 ②③ 档一律 403),客户端按 §2.0 权限矩阵**自行降级**,
|
||||
* 见 [top.yeij.cyrene.domain.admin.AdminAccess];这里只负责取数,不做权限判断。
|
||||
*/
|
||||
interface AdminApi {
|
||||
|
||||
/** 网关请求指标;`qps` 为近 1 秒窗口计数。 */
|
||||
@GET("api/v1/admin/observability/metrics")
|
||||
suspend fun metrics(): Response<ApiEnvelope<AdminMetricsDto>>
|
||||
|
||||
/** 最近 trace span,服务端按时间倒序(最新在前);`limit` 缺省 50、上限 500。 */
|
||||
@GET("api/v1/admin/observability/trace")
|
||||
suspend fun trace(@Query("limit") limit: Int? = null): Response<ApiEnvelope<List<AdminTraceSpanDto>>>
|
||||
|
||||
/** 人格列表(含 admin 专属 `thinking`)。 */
|
||||
@GET("api/v1/admin/personas")
|
||||
suspend fun personas(): Response<ApiEnvelope<List<PersonaSummaryDto>>>
|
||||
|
||||
/** 私人记忆正文;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/memories")
|
||||
suspend fun memories(): Response<ApiEnvelope<List<MemorySummaryDto>>>
|
||||
|
||||
/** 后台思考记录;`viewer` 会拿到 403。 */
|
||||
@GET("api/v1/admin/backthink")
|
||||
suspend fun backthink(): Response<ApiEnvelope<List<AdminBackthinkItemDto>>>
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/metrics`(api-client §2「可观测性契约」)。
|
||||
* 后端不统计 token / 成本,因此这里也没有对应字段——不伪造指标。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminMetricsDto(
|
||||
val totalRequests: Long = 0,
|
||||
val qps: Double = 0.0,
|
||||
val avgLatencyMs: Double = 0.0,
|
||||
val maxLatencyMs: Long = 0,
|
||||
val perMethod: Map<String, Long> = emptyMap(),
|
||||
val perPath: Map<String, Long> = emptyMap(),
|
||||
val statusCounts: Map<String, Long> = emptyMap(),
|
||||
val llmCalls: Long = 0,
|
||||
val subagentEvents: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/observability/trace?limit=N` 单个 span。
|
||||
* `kind` / `status` 是开放集合,保留原始字符串(不建枚举)。
|
||||
* `startedAt` / `endedAt` 为 RFC3339;`props` / `events` 为任意结构,原样透传。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTraceSpanDto(
|
||||
val id: String,
|
||||
val parentId: String? = null,
|
||||
val name: String = "",
|
||||
val kind: String = "",
|
||||
val status: String = "",
|
||||
val startedAt: String = "",
|
||||
val endedAt: String? = null,
|
||||
val durationNs: Long = 0,
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
val events: List<AdminTraceEventDto>? = null,
|
||||
)
|
||||
|
||||
/** `trace.events[]` 一项;`time` 为 RFC3339(纳秒精度)。 */
|
||||
@Serializable
|
||||
data class AdminTraceEventDto(
|
||||
val time: String = "",
|
||||
val type: String = "",
|
||||
val props: Map<String, JsonElement>? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格摘要(用户侧 `GET /api/v1/personas` 与 admin 侧 `GET /api/v1/admin/personas` 同形)。
|
||||
* admin 侧多一个 [thinking];未配置该人格时**该键完全不出现**,故为 nullable。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaSummaryDto(
|
||||
val id: String,
|
||||
val name: String = "",
|
||||
val character: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val mode: String = "",
|
||||
val memoryNamespace: String = "",
|
||||
val avatar: String? = null,
|
||||
val chatBackground: ChatBackgroundDto? = null,
|
||||
val mood: String? = null,
|
||||
val thinking: PersonaThinkingDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatBackgroundDto(
|
||||
val landscape: String? = null,
|
||||
val portrait: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 人格 `thinking` 读侧形状(v0.43)。
|
||||
* [showToUser] 是**三态**:`null` = 未配置(保持现行为),true/false = 显式选择;
|
||||
* 不得把 null 拍平成 false。
|
||||
*/
|
||||
@Serializable
|
||||
data class PersonaThinkingDto(
|
||||
val budgetTokens: Int = 0,
|
||||
val effort: String = "",
|
||||
val showToUser: Boolean? = null,
|
||||
val mode: String = "",
|
||||
)
|
||||
|
||||
/** `GET /api/v1/admin/memories` 一条记忆;`scope` 为开放集合(`user|persona|project`)。 */
|
||||
@Serializable
|
||||
data class MemorySummaryDto(
|
||||
val id: String,
|
||||
val scope: String = "",
|
||||
val text: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /api/v1/admin/backthink` 一条后台思考记录。
|
||||
* [at] 是 **Unix 毫秒**(与 trace 的 RFC3339 不同源,切勿共用解析)。
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminBackthinkItemDto(
|
||||
val id: String,
|
||||
val trigger: String = "",
|
||||
val conclusion: String? = null,
|
||||
val shouldNotify: Boolean = false,
|
||||
val pushed: Boolean = false,
|
||||
val at: Long = 0,
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
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.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 kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* 管理只读监控取数层(api-client §2)。
|
||||
* 权限判断不在这里:仓库只管取数,403 由调用方按 [top.yeij.cyrene.domain.admin.AdminFailure] 收敛。
|
||||
*/
|
||||
class AdminRepository(
|
||||
private val api: AdminApi,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
suspend fun metrics(): Result<AdminMetricsDto> = withContext(dispatcher) {
|
||||
apiCall { api.metrics() }
|
||||
}
|
||||
|
||||
/** `GET /observability/trace`;`limit` 与服务端对齐,钳制在 [1, [MAX_TRACE_LIMIT]]。 */
|
||||
suspend fun trace(limit: Int = DEFAULT_TRACE_LIMIT): Result<List<AdminTraceSpanDto>> =
|
||||
withContext(dispatcher) {
|
||||
apiCall { api.trace(limit.coerceIn(1, MAX_TRACE_LIMIT)) }
|
||||
}
|
||||
|
||||
suspend fun personas(): Result<List<PersonaSummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.personas() }
|
||||
}
|
||||
|
||||
suspend fun memories(): Result<List<MemorySummaryDto>> = withContext(dispatcher) {
|
||||
apiCall { api.memories() }
|
||||
}
|
||||
|
||||
suspend fun backthink(): Result<List<AdminBackthinkItemDto>> = withContext(dispatcher) {
|
||||
apiCall { api.backthink() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** trace 拉取条数:够看近期活动,又不会把一屏塞满。 */
|
||||
const val DEFAULT_TRACE_LIMIT = 100
|
||||
|
||||
/** 服务端 `?limit=` 上限。 */
|
||||
const val MAX_TRACE_LIMIT = 500
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
|
||||
*
|
||||
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
|
||||
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 管理页可见性分档,按权限递增。
|
||||
*
|
||||
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
|
||||
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
|
||||
*/
|
||||
enum class AdminTier(val rank: Int) {
|
||||
Monitor(0),
|
||||
OwnerAdmin(1),
|
||||
}
|
||||
|
||||
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
|
||||
enum class AdminRole(val rank: Int) {
|
||||
Viewer(0),
|
||||
Admin(1),
|
||||
Owner(2),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
|
||||
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
|
||||
*/
|
||||
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
|
||||
"owner" -> Owner
|
||||
"admin" -> Admin
|
||||
else -> Viewer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
|
||||
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
|
||||
*/
|
||||
enum class AdminPage(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val required: AdminTier,
|
||||
) {
|
||||
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
|
||||
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
|
||||
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
|
||||
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
|
||||
}
|
||||
|
||||
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
|
||||
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
|
||||
|
||||
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
|
||||
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
|
||||
@@ -0,0 +1,26 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
|
||||
* 而不是把 403 当成空列表渲染成「暂无数据」。
|
||||
*/
|
||||
sealed interface AdminFailure {
|
||||
/** HTTP 403(角色不够)或 401(会话失效)。 */
|
||||
data object Forbidden : AdminFailure
|
||||
|
||||
data class Other(val message: String) : AdminFailure
|
||||
}
|
||||
|
||||
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
|
||||
fun Throwable.toAdminFailure(): AdminFailure {
|
||||
val error = (this as? CyreneException)?.error
|
||||
return when {
|
||||
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
|
||||
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
|
||||
error != null -> AdminFailure.Other(error.userMessage)
|
||||
else -> AdminFailure.Other("操作失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
/**
|
||||
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
|
||||
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
|
||||
*/
|
||||
fun formatRfc3339(raw: String?): String? {
|
||||
val text = raw?.takeIf { it.isNotBlank() } ?: return null
|
||||
return runCatching {
|
||||
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
|
||||
}.getOrDefault(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
|
||||
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
|
||||
*/
|
||||
fun formatEpochMillis(millis: Long): String =
|
||||
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
|
||||
|
||||
/** 纳秒时长 → 按量级挑单位。 */
|
||||
fun formatNanos(nanos: Long): String = when {
|
||||
nanos < 1_000 -> "$nanos ns"
|
||||
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
|
||||
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
|
||||
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
|
||||
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
|
||||
*/
|
||||
fun formatCounts(counts: Map<String, Long>): String =
|
||||
counts.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
|
||||
.joinToString(" · ") { (key, value) -> "$key $value" }
|
||||
|
||||
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
|
||||
fun formatDouble(value: Double, decimals: Int = 2): String =
|
||||
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
|
||||
|
||||
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
|
||||
fun formatProps(props: Map<String, JsonElement>?): String? =
|
||||
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
|
||||
|
||||
private val traceKindLabels = mapOf(
|
||||
"llm_call" to "LLM 调用",
|
||||
"tool_call" to "工具调用",
|
||||
"orchestration" to "编排",
|
||||
"state_transition" to "状态迁移",
|
||||
"subagent" to "子代理",
|
||||
"memory" to "记忆",
|
||||
"guardrail" to "护栏",
|
||||
)
|
||||
|
||||
private val traceStatusLabels = mapOf(
|
||||
"ok" to "成功",
|
||||
"error" to "失败",
|
||||
)
|
||||
|
||||
private val personaModeLabels = mapOf(
|
||||
"chat" to "对话",
|
||||
"code" to "代码",
|
||||
"home" to "家居",
|
||||
"work" to "工作",
|
||||
"docs" to "文档",
|
||||
"voice" to "语音",
|
||||
"vision" to "视觉",
|
||||
"agents" to "智能体",
|
||||
"settings" to "设置",
|
||||
)
|
||||
|
||||
private val memoryScopeLabels = mapOf(
|
||||
"user" to "用户",
|
||||
"persona" to "人格",
|
||||
"project" to "项目",
|
||||
)
|
||||
|
||||
private val moodLabels = mapOf(
|
||||
"calm" to "平静",
|
||||
"happy" to "开心",
|
||||
"excited" to "兴奋",
|
||||
"sad" to "低落",
|
||||
"angry" to "生气",
|
||||
)
|
||||
|
||||
/**
|
||||
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
|
||||
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
|
||||
*/
|
||||
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
|
||||
|
||||
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
|
||||
|
||||
fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw
|
||||
|
||||
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
|
||||
|
||||
fun moodLabel(raw: String): String = moodLabels[raw] ?: raw
|
||||
|
||||
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
|
||||
fun triStateLabel(value: Boolean?): String = when (value) {
|
||||
null -> "未配置"
|
||||
true -> "显示"
|
||||
false -> "不显示"
|
||||
}
|
||||
|
||||
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
|
||||
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
|
||||
@@ -0,0 +1,67 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.formatEpochMillis
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
|
||||
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.backthink.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Backthink, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Backthink.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { records ->
|
||||
if (records.isEmpty()) {
|
||||
AdminCenterHint("暂无后台思考记录")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = records, key = { it.id }) { record -> BackthinkCard(record) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackthinkCard(record: AdminBackthinkItemDto) {
|
||||
AdminTextCard(
|
||||
title = orDash(record.trigger),
|
||||
body = backthinkBody(record),
|
||||
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
|
||||
)
|
||||
}
|
||||
|
||||
private fun backthinkBody(record: AdminBackthinkItemDto): String = buildList {
|
||||
record.conclusion?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
add("通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}")
|
||||
}.joinToString("\n")
|
||||
@@ -0,0 +1,231 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminFailure
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.HorizontalDivider
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.SmallTopAppBar
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronBackward
|
||||
import top.yukonga.miuix.kmp.icon.extended.Refresh
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理页统一外壳:独立的 Miuix [Scaffold] + 返回按钮,各页只填内容。
|
||||
* 管理员区是聊天之外的独立导航(design D15),所以每页都有自己的 Scaffold。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminPageScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
SmallTopAppBar(
|
||||
title = title,
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(imageVector = MiuixIcons.ChevronBackward, contentDescription = "返回")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (onRefresh != null) {
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(imageVector = MiuixIcons.Refresh, contentDescription = "刷新")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminSectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.subtitle,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 20.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** 居中提示(加载中 / 空态 / 无权限),整页级别。 */
|
||||
@Composable
|
||||
fun AdminCenterHint(
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MiuixTheme.textStyles.main,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
if (detail != null) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = detail,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
if (onRetry != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
TextButton("重试", onClick = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块内的行内提示(不占满高度,可放进可滚动列 / LazyColumn 的 item)。 */
|
||||
@Composable
|
||||
fun AdminInlineHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取数失败时的整页状态。
|
||||
* **无权限与其它失败必须分开**:403/401 给「无权限」+ 角色说明,绝不渲染成空列表。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminFailureState(
|
||||
failure: AdminFailure?,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (failure) {
|
||||
is AdminFailure.Forbidden -> AdminCenterHint(
|
||||
title = "无权限",
|
||||
detail = "当前角色不能读取该页,请用 owner 或 admin 账号登录。",
|
||||
)
|
||||
|
||||
else -> AdminCenterHint(
|
||||
title = "加载失败",
|
||||
detail = (failure as? AdminFailure.Other)?.message ?: "请稍后重试",
|
||||
onRetry = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表/详情页共用的取数外壳:先判权限,再判加载,最后才渲染数据。
|
||||
* 顺序不能反——反了就会把 403 渲染成「暂无数据」。
|
||||
*/
|
||||
@Composable
|
||||
fun <T> AdminLoadShell(
|
||||
state: AdminUiState<T>,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val data = state.data
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
state.failure is AdminFailure.Forbidden -> AdminFailureState(state.failure, onRetry)
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
data == null -> AdminFailureState(state.failure, onRetry)
|
||||
else -> content(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「左标签 + 右值」一行;必须放在 [PreferenceCard] 内。 */
|
||||
@Composable
|
||||
fun AdminValueRow(title: String, value: String, valueColor: Color? = null) {
|
||||
BasicComponent(
|
||||
title = title,
|
||||
endActions = {
|
||||
Text(
|
||||
text = value,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = valueColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** 指标卡片:一组「标签 → 值」,单张卡片内多行。 */
|
||||
@Composable
|
||||
fun AdminMetricCard(rows: List<Pair<String, String>>) {
|
||||
PreferenceCard {
|
||||
Column {
|
||||
rows.forEachIndexed { index, (label, value) ->
|
||||
if (index > 0) {
|
||||
HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
AdminValueRow(title = label, value = value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
|
||||
@Composable
|
||||
fun AdminTextCard(
|
||||
title: String,
|
||||
body: String,
|
||||
trailing: String? = null,
|
||||
trailingColor: Color? = null,
|
||||
) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
|
||||
if (trailing != null) {
|
||||
Text(
|
||||
text = trailing,
|
||||
modifier = Modifier.widthIn(max = 160.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (body.isNotBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = body,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.core.auth.SessionState
|
||||
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.orDash
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 管理只读监控(C-2):独立的顶层屏幕 + 独立 Scaffold,**不在聊天界面里**(design D15)。
|
||||
*
|
||||
* 结构是「入口页列出可用的管理页 → 逐页进入」的**可扩展壳**:页清单由
|
||||
* [AdminPage] 驱动,新增一个管理模块只需在清单里加一项、在 [AdminPageScreen] 里加一个分支,
|
||||
* 不需要改动本文件的布局。
|
||||
*
|
||||
* 权限降级(api-client §2.0):入口按角色禁用越权页(**不发请求**),
|
||||
* ViewModel 再做一次同样的短路,服务端返回 403/401 时还有第三层「无权限」状态。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminHomeScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit = {},
|
||||
) {
|
||||
val session by container.authManager.state.collectAsState()
|
||||
val rawRole = (session as? SessionState.LoggedIn)?.role
|
||||
val role = AdminRole.of(rawRole)
|
||||
var openPage by remember { mutableStateOf<AdminPage?>(null) }
|
||||
val viewModel: AdminViewModel = viewModel(
|
||||
factory = viewModelFactory { initializer { AdminViewModel(container) } },
|
||||
)
|
||||
|
||||
val page = openPage
|
||||
BackHandler(enabled = page != null) { openPage = null }
|
||||
|
||||
if (page == null) {
|
||||
AdminIndexScreen(
|
||||
rawRole = rawRole,
|
||||
role = role,
|
||||
onOpen = { openPage = it },
|
||||
onBack = onBack,
|
||||
)
|
||||
} else {
|
||||
AdminPageScreen(
|
||||
page = page,
|
||||
role = role,
|
||||
viewModel = viewModel,
|
||||
onBack = { openPage = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理入口页:列出全部监控页,越权页显示为禁用的「无权限」行而不是隐藏。 */
|
||||
@Composable
|
||||
private fun AdminIndexScreen(
|
||||
rawRole: String?,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
AdminPageScaffold(title = "管理员监控", onBack = onBack) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = "只读监控 · 当前角色 ${orDash(rawRole)}",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp),
|
||||
)
|
||||
AdminSectionTitle("监控页")
|
||||
AdminPage.entries.forEach { page ->
|
||||
PreferenceCard { AdminPageRow(page = page, role = role, onOpen = onOpen) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminPageRow(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
onOpen: (AdminPage) -> Unit,
|
||||
) {
|
||||
if (canAccess(role, page)) {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = page.summary,
|
||||
onClick = { onOpen(page) },
|
||||
endActions = {
|
||||
Icon(imageVector = MiuixIcons.ChevronForward, contentDescription = null)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
BasicComponent(
|
||||
title = page.title,
|
||||
summary = "需要 owner 或 admin 权限",
|
||||
endActions = {
|
||||
Text(
|
||||
text = "无权限",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理页分发:每个页在自己的文件里带 Scaffold,这里只做映射。 */
|
||||
@Composable
|
||||
private fun AdminPageScreen(
|
||||
page: AdminPage,
|
||||
role: AdminRole,
|
||||
viewModel: AdminViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> AdminOverviewScreen(viewModel, role, onBack)
|
||||
AdminPage.Personas -> AdminPersonaScreen(viewModel, role, onBack)
|
||||
AdminPage.Memories -> AdminMemoryScreen(viewModel, role, onBack)
|
||||
AdminPage.Backthink -> AdminBackthinkScreen(viewModel, role, onBack)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.memoryScopeLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
|
||||
/**
|
||||
* 记忆页:`GET /api/v1/admin/memories`(**仅 `owner`/`admin`**,含私人记忆正文)。
|
||||
* `viewer` 进不来:入口已禁用、ViewModel 也短路,服务端还会 403。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminMemoryScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.memories.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Memories, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Memories.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { memories ->
|
||||
if (memories.isEmpty()) {
|
||||
AdminCenterHint("暂无记忆")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = memories, key = { it.id }) { memory ->
|
||||
AdminTextCard(
|
||||
// scope 是开放集合:未知取值原样显示。
|
||||
title = orDash(memoryScopeLabel(memory.scope)),
|
||||
body = memory.text,
|
||||
trailing = memory.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.AdminMetricsDto
|
||||
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
|
||||
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.formatCounts
|
||||
import top.yeij.cyrene.domain.admin.formatDouble
|
||||
import top.yeij.cyrene.domain.admin.formatNanos
|
||||
import top.yeij.cyrene.domain.admin.formatProps
|
||||
import top.yeij.cyrene.domain.admin.formatRfc3339
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.traceKindLabel
|
||||
import top.yeij.cyrene.domain.admin.traceStatusLabel
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
|
||||
*/
|
||||
@Composable
|
||||
fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.overview.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Overview, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
val data = state.data
|
||||
val failure = state.failure
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
when {
|
||||
failure is AdminFailure.Forbidden -> AdminFailureState(failure, reload)
|
||||
|
||||
data == null && state.loading -> AdminCenterHint("加载中…")
|
||||
|
||||
data == null || (data.metrics == null && data.trace.isEmpty()) ->
|
||||
AdminFailureState(failure, reload)
|
||||
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
item(key = "__metrics__") {
|
||||
Column {
|
||||
AdminSectionTitle("指标")
|
||||
if (data.metrics != null) {
|
||||
AdminMetricCard(metricRows(data.metrics))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
AdminMetricCard(countRows(data.metrics))
|
||||
} else {
|
||||
AdminTextCard("指标不可用", failureMessage(failure))
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
|
||||
when {
|
||||
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
|
||||
failure != null -> item(key = "__trace_error__") {
|
||||
AdminTextCard("trace 不可用", failureMessage(failure))
|
||||
}
|
||||
|
||||
data.trace.isEmpty() -> item(key = "__trace_empty__") {
|
||||
AdminInlineHint("暂无 trace span")
|
||||
}
|
||||
|
||||
else -> items(items = data.trace, key = { it.id }) { span ->
|
||||
TraceSpanCard(span)
|
||||
}
|
||||
}
|
||||
item(key = "__bottom__") { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"总请求数" to metrics.totalRequests.toString(),
|
||||
"QPS(近 1 秒)" to formatDouble(metrics.qps),
|
||||
"平均延迟" to "${formatDouble(metrics.avgLatencyMs)} ms",
|
||||
"最大延迟" to "${metrics.maxLatencyMs} ms",
|
||||
"LLM 调用" to metrics.llmCalls.toString(),
|
||||
"子代理事件" to metrics.subagentEvents.toString(),
|
||||
)
|
||||
|
||||
private fun countRows(metrics: AdminMetricsDto): List<Pair<String, String>> = listOf(
|
||||
"按方法" to formatCounts(metrics.perMethod).ifBlank { "-" },
|
||||
"按路径" to formatCounts(metrics.perPath).ifBlank { "-" },
|
||||
"状态码" to formatCounts(metrics.statusCounts).ifBlank { "-" },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TraceSpanCard(span: AdminTraceSpanDto) {
|
||||
AdminTextCard(
|
||||
title = span.name.ifBlank { span.id },
|
||||
body = traceBody(span),
|
||||
trailing = orDash(traceStatusLabel(span.status)),
|
||||
trailingColor = if (span.status == "error") MiuixTheme.colorScheme.error else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun traceBody(span: AdminTraceSpanDto): String = buildList {
|
||||
add("${traceKindLabel(span.kind)} · ${formatNanos(span.durationNs)}")
|
||||
add("开始 ${formatRfc3339(span.startedAt) ?: orDash(span.startedAt)}")
|
||||
span.endedAt?.let { add("结束 ${formatRfc3339(it) ?: it}") }
|
||||
span.parentId?.let { add("父 span $it") }
|
||||
formatProps(span.props)?.let { add("props $it") }
|
||||
span.events?.forEach { event ->
|
||||
val props = formatProps(event.props)?.let { " $it" }.orEmpty()
|
||||
add("事件 ${event.type} @ ${formatRfc3339(event.time) ?: event.time}$props")
|
||||
}
|
||||
}.joinToString("\n")
|
||||
|
||||
private fun failureMessage(failure: AdminFailure?): String =
|
||||
(failure as? AdminFailure.Other)?.message ?: "请求失败,请重试"
|
||||
@@ -0,0 +1,112 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yeij.cyrene.data.api.dto.PersonaSummaryDto
|
||||
import top.yeij.cyrene.data.api.dto.PersonaThinkingDto
|
||||
import top.yeij.cyrene.domain.admin.AdminPage
|
||||
import top.yeij.cyrene.domain.admin.AdminRole
|
||||
import top.yeij.cyrene.domain.admin.moodLabel
|
||||
import top.yeij.cyrene.domain.admin.orDash
|
||||
import top.yeij.cyrene.domain.admin.personaModeLabel
|
||||
import top.yeij.cyrene.domain.admin.triStateLabel
|
||||
import top.yeij.cyrene.ui.common.PreferenceCard
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/** 人格页:`GET /api/v1/admin/personas`(`viewer` 可读),含 admin 专属 `thinking` 旋钮。 */
|
||||
@Composable
|
||||
fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
|
||||
val state by viewModel.personas.collectAsState()
|
||||
val reload = { viewModel.load(AdminPage.Personas, role) }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Personas.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
AdminLoadShell(
|
||||
state = state,
|
||||
onRetry = reload,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) { personas ->
|
||||
if (personas.isEmpty()) {
|
||||
AdminCenterHint("暂无人格")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(items = personas, key = { it.id }) { PersonaCard(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaCard(persona: PersonaSummaryDto) {
|
||||
PreferenceCard {
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(persona.name.ifBlank { persona.id }, style = MiuixTheme.textStyles.main)
|
||||
if (persona.isDefault) {
|
||||
Text(
|
||||
text = " · 默认",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
PersonaDetail("id", persona.id)
|
||||
PersonaDetail("character", persona.character.ifBlank { "-" })
|
||||
// mode 是开放集合:未知取值原样显示。
|
||||
PersonaDetail("mode", personaModeLabel(persona.mode).ifBlank { "-" })
|
||||
PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" })
|
||||
// mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。
|
||||
PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无")
|
||||
PersonaDetail("思考", thinkingLabel(persona.thinking))
|
||||
persona.avatar?.let { PersonaDetail("avatar", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PersonaDetail(label: String, value: String) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "$label:$value",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `thinking` 未配置时该键**完全不出现**(不是全零对象);已配置时 `showToUser` 是三态,
|
||||
* null 必须显示成「未配置」,不能拍平成「不显示」。
|
||||
*/
|
||||
private fun thinkingLabel(thinking: PersonaThinkingDto?): String {
|
||||
if (thinking == null) return "未配置"
|
||||
return listOf(
|
||||
"budgetTokens ${thinking.budgetTokens}",
|
||||
"effort ${orDash(thinking.effort)}",
|
||||
"mode ${orDash(thinking.mode)}",
|
||||
"showToUser ${triStateLabel(thinking.showToUser)}",
|
||||
).joinToString(" · ")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package top.yeij.cyrene.ui.admin
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
|
||||
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.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.toAdminFailure
|
||||
|
||||
/**
|
||||
* 单个管理页的取数状态。
|
||||
* [data] 为 null 表示**还没拿到**(加载中或失败),空集合表示**确实没有数据**——
|
||||
* 两者在 UI 上必须分开,否则 403 会伪装成「暂无数据」。
|
||||
*/
|
||||
data class AdminUiState<T>(
|
||||
val loading: Boolean = false,
|
||||
val data: T? = null,
|
||||
val failure: AdminFailure? = null,
|
||||
)
|
||||
|
||||
/** 概览页:metrics 与 trace 两次请求合成一份状态(`trace` 作为指标卡片下方列表)。 */
|
||||
data class AdminOverviewData(
|
||||
val metrics: AdminMetricsDto? = null,
|
||||
val trace: List<AdminTraceSpanDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* 管理只读监控的四页取数(api-client §2)。
|
||||
*
|
||||
* [load] 先按 [AdminRole] 短路:角色不够的页**不发请求**,直接给
|
||||
* [AdminFailure.Forbidden],UI 因此能显示「无权限」而不是等一个 403 回来。
|
||||
*/
|
||||
class AdminViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
private val _overview = MutableStateFlow(AdminUiState<AdminOverviewData>())
|
||||
val overview: StateFlow<AdminUiState<AdminOverviewData>> = _overview.asStateFlow()
|
||||
|
||||
private val _personas = MutableStateFlow(AdminUiState<List<PersonaSummaryDto>>())
|
||||
val personas: StateFlow<AdminUiState<List<PersonaSummaryDto>>> = _personas.asStateFlow()
|
||||
|
||||
private val _memories = MutableStateFlow(AdminUiState<List<MemorySummaryDto>>())
|
||||
val memories: StateFlow<AdminUiState<List<MemorySummaryDto>>> = _memories.asStateFlow()
|
||||
|
||||
private val _backthink = MutableStateFlow(AdminUiState<List<AdminBackthinkItemDto>>())
|
||||
val backthink: StateFlow<AdminUiState<List<AdminBackthinkItemDto>>> = _backthink.asStateFlow()
|
||||
|
||||
/** 进入页面时调用;每次进入都重新取数(监控数据要新鲜)。 */
|
||||
fun load(page: AdminPage, role: AdminRole) {
|
||||
if (!canAccess(role, page)) {
|
||||
deny(page)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (page) {
|
||||
AdminPage.Overview -> loadOverview()
|
||||
AdminPage.Personas -> loadList(_personas) { container.adminRepository.personas() }
|
||||
AdminPage.Memories -> loadList(_memories) { container.adminRepository.memories() }
|
||||
AdminPage.Backthink -> loadList(_backthink) { container.adminRepository.backthink() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 概览页两个请求并发;任一 403 即整页无权限,其它失败则保留已到的部分。 */
|
||||
private suspend fun loadOverview() {
|
||||
_overview.value = _overview.value.copy(loading = true, failure = null)
|
||||
val repository = container.adminRepository
|
||||
val (metrics, trace) = 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() }
|
||||
_overview.value = AdminUiState(
|
||||
data = AdminOverviewData(
|
||||
metrics = metrics.getOrNull(),
|
||||
trace = trace.getOrNull().orEmpty(),
|
||||
),
|
||||
failure = failures.firstOrNull { it is AdminFailure.Forbidden } ?: failures.firstOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> loadList(
|
||||
target: MutableStateFlow<AdminUiState<List<T>>>,
|
||||
fetch: suspend () -> Result<List<T>>,
|
||||
) {
|
||||
target.value = target.value.copy(loading = true, failure = null)
|
||||
fetch()
|
||||
.onSuccess { list -> target.value = AdminUiState(data = list) }
|
||||
.onFailure { e -> target.value = AdminUiState(failure = e.toAdminFailure()) }
|
||||
}
|
||||
|
||||
private fun deny(page: AdminPage) {
|
||||
when (page) {
|
||||
AdminPage.Overview -> _overview.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Personas -> _personas.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Memories -> _memories.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
AdminPage.Backthink -> _backthink.value = AdminUiState(failure = AdminFailure.Forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import top.yeij.cyrene.app.AppContainer
|
||||
import top.yeij.cyrene.ui.admin.AdminHomeScreen
|
||||
import top.yeij.cyrene.ui.common.MiuixDrawer
|
||||
import top.yeij.cyrene.ui.sessions.SessionDrawerContent
|
||||
import top.yeij.cyrene.ui.sessions.SessionsViewModel
|
||||
import top.yeij.cyrene.ui.settings.SettingsScreen
|
||||
|
||||
/**
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页。
|
||||
* 返回键逐层回退:设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
* 聊天页 + 侧边栏(会话切换)+ 设置页 + 管理员监控。
|
||||
* 返回键逐层回退:管理/设置 → 聊天;抽屉打开时先关抽屉;都关着才交给系统(回桌面)。
|
||||
*/
|
||||
@Composable
|
||||
fun ChatHomeScreen(
|
||||
@@ -31,18 +32,25 @@ fun ChatHomeScreen(
|
||||
val state by sessionsViewModel.state.collectAsState()
|
||||
var drawerOpen by remember { mutableStateOf(false) }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
var showAdmin by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = showAdmin) { showAdmin = false }
|
||||
BackHandler(enabled = showSettings) { showSettings = false }
|
||||
BackHandler(enabled = drawerOpen) { drawerOpen = false }
|
||||
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
when {
|
||||
showAdmin -> AdminHomeScreen(
|
||||
container = container,
|
||||
onBack = { showAdmin = false },
|
||||
)
|
||||
|
||||
showSettings -> SettingsScreen(
|
||||
container = container,
|
||||
onBack = { showSettings = false },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
} else {
|
||||
MiuixDrawer(
|
||||
|
||||
else -> MiuixDrawer(
|
||||
open = drawerOpen,
|
||||
onOpenChange = { drawerOpen = it },
|
||||
content = {
|
||||
@@ -63,6 +71,10 @@ fun ChatHomeScreen(
|
||||
onCreate = sessionsViewModel::createSession,
|
||||
onDelete = { sessionsViewModel.deleteSession(it.id) },
|
||||
onOpenIot = { /* P2:IoT 页面 */ },
|
||||
onOpenAdmin = {
|
||||
drawerOpen = false
|
||||
showAdmin = true
|
||||
},
|
||||
onOpenSettings = {
|
||||
drawerOpen = false
|
||||
showSettings = true
|
||||
|
||||
@@ -42,6 +42,7 @@ fun ColumnScope.SessionDrawerContent(
|
||||
onCreate: () -> Unit,
|
||||
onDelete: (SessionItem) -> Unit,
|
||||
onOpenIot: () -> Unit,
|
||||
onOpenAdmin: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
var pendingDelete by remember { mutableStateOf<SessionItem?>(null) }
|
||||
@@ -98,6 +99,8 @@ fun ColumnScope.SessionDrawerContent(
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PreferenceCard { BasicComponent(title = "管理员监控", onClick = onOpenAdmin) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard { BasicComponent(title = "IoT(规划中)", onClick = onOpenIot) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
PreferenceCard { BasicComponent(title = "App 设置", onClick = onOpenSettings) }
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
|
||||
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
|
||||
*/
|
||||
class AdminDtoDecodingTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
|
||||
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
|
||||
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
|
||||
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
|
||||
|
||||
@Test
|
||||
fun `metrics envelope decodes integer-shaped doubles and count maps`() {
|
||||
val body = """
|
||||
{"data":{"totalRequests":12,"qps":3,"avgLatencyMs":10,"maxLatencyMs":40,
|
||||
"perMethod":{"GET":12},"perPath":{"/api/v1/x":12},"statusCounts":{"200":11,"500":1},
|
||||
"llmCalls":2,"subagentEvents":0}}
|
||||
""".trimIndent()
|
||||
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), body)
|
||||
.data!!
|
||||
|
||||
assertEquals(12L, data.totalRequests)
|
||||
assertEquals(3.0, data.qps, 0.0)
|
||||
assertEquals(10.0, data.avgLatencyMs, 0.0)
|
||||
assertEquals(mapOf("GET" to 12L), data.perMethod)
|
||||
assertEquals(mapOf("200" to 11L, "500" to 1L), data.statusCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metrics tolerates missing optional fields`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), """{"data":{}}""")
|
||||
.data!!
|
||||
|
||||
assertEquals(0L, data.totalRequests)
|
||||
assertTrue(data.perMethod.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trace span keeps unknown kind and arbitrary props`() {
|
||||
val body = """
|
||||
{"data":[{"id":"s1","parentId":"s0","name":"loop","kind":"quantum_hop","status":"ok",
|
||||
"startedAt":"2026-03-04T04:34:56Z","endedAt":"2026-03-04T04:34:57Z","durationNs":1000000000,
|
||||
"props":{"model":"gpt","n":3,"flag":true},
|
||||
"events":[{"time":"2026-03-04T04:34:56.123456789Z","type":"retry","props":{"attempt":2}}]}]}
|
||||
""".trimIndent()
|
||||
|
||||
val span = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), body)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertEquals("quantum_hop", span.kind)
|
||||
assertEquals("s0", span.parentId)
|
||||
assertEquals(1_000_000_000L, span.durationNs)
|
||||
val props = span.props!!
|
||||
assertTrue(props.isNotEmpty())
|
||||
assertEquals("gpt", (props["model"] as JsonPrimitive).content)
|
||||
assertEquals(3, (props["n"] as JsonPrimitive).int)
|
||||
assertTrue((props["flag"] as JsonPrimitive).boolean)
|
||||
val events = span.events!!
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("retry", events.single().type)
|
||||
assertEquals(2, (events.single().props!!["attempt"] as JsonPrimitive).int)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trace span tolerates missing parent and events`() {
|
||||
val span = json
|
||||
.decodeFromString(
|
||||
ApiEnvelope.serializer(traceSpans),
|
||||
"""{"data":[{"id":"s1","name":"loop","kind":"llm_call","status":"error","startedAt":"2026-03-04T04:34:56Z","durationNs":1}]}""",
|
||||
)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertNull(span.parentId)
|
||||
assertNull(span.endedAt)
|
||||
assertNull(span.props)
|
||||
assertNull(span.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persona thinking is absent when unconfigured and keeps tri-state when present`() {
|
||||
val body = """
|
||||
{"data":[
|
||||
{"id":"cyrene","name":"昔涟","character":"memokeeper","isDefault":true,"mode":"chat",
|
||||
"memoryNamespace":"cyrene","mood":"",
|
||||
"thinking":{"budgetTokens":0,"effort":"","showToUser":null,"mode":""}},
|
||||
{"id":"other","name":"?","character":"","isDefault":false,"mode":"telepathy",
|
||||
"memoryNamespace":"","mood":"euphoric"}]}
|
||||
""".trimIndent()
|
||||
|
||||
val personas = json
|
||||
.decodeFromString(ApiEnvelope.serializer(personaList), body)
|
||||
.data!!
|
||||
|
||||
assertEquals(2, personas.size)
|
||||
val configured = personas[0].thinking!!
|
||||
assertNull("null 不能拍平成 false", configured.showToUser)
|
||||
assertEquals(0, configured.budgetTokens)
|
||||
|
||||
assertNull("未配置的人格 thinking 键完全不出现", personas[1].thinking)
|
||||
assertEquals("euphoric", personas[1].mood)
|
||||
// 未知 mode 原样保留,不丢弃、不替换占位符。
|
||||
assertEquals("telepathy", personas[1].mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `memory scope and backthink millis decode as declared`() {
|
||||
val memories = json
|
||||
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"shared","text":"hi"}]}""")
|
||||
.data!!
|
||||
assertEquals("shared", memories.single().scope)
|
||||
|
||||
val backthink = json
|
||||
.decodeFromString(
|
||||
ApiEnvelope.serializer(backthinkList),
|
||||
"""{"data":[{"id":"7","trigger":"cron","shouldNotify":true,"pushed":false,"at":1772598896000}]}""",
|
||||
)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertNull(backthink.conclusion)
|
||||
assertTrue(backthink.shouldNotify)
|
||||
assertFalse(backthink.pushed)
|
||||
assertEquals(1_772_598_896_000L, backthink.at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown scope is preserved raw instead of being dropped`() {
|
||||
val memories = json
|
||||
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"galaxy","text":"hi"}]}""")
|
||||
.data!!
|
||||
assertEquals("galaxy", memories.single().scope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** 管理页权限矩阵(api-client §2.0)的客户端降级判定。 */
|
||||
class AdminAccessTest {
|
||||
|
||||
@Test
|
||||
fun `owner can use every page`() {
|
||||
AdminPage.entries.forEach { page ->
|
||||
assertTrue(page.name, canAccess(AdminRole.Owner, page))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin can use every page`() {
|
||||
AdminPage.entries.forEach { page ->
|
||||
assertTrue(page.name, canAccess(AdminRole.Admin, page))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer can read monitor pages only`() {
|
||||
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview))
|
||||
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Personas))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer cannot read memories or backthink`() {
|
||||
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Memories))
|
||||
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Backthink))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown role degrades to viewer`() {
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of("superowner"))
|
||||
assertFalse(canAccess(AdminRole.of("superowner"), AdminPage.Memories))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null and blank role degrade to viewer`() {
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of(null))
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `role parsing tolerates case and surrounding blanks`() {
|
||||
assertEquals(AdminRole.Owner, AdminRole.of(" OWNER "))
|
||||
assertEquals(AdminRole.Admin, AdminRole.of("Admin"))
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of("viewer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `available pages follow the declared order`() {
|
||||
assertEquals(
|
||||
listOf(AdminPage.Overview, AdminPage.Personas),
|
||||
availablePages(AdminRole.Viewer),
|
||||
)
|
||||
assertEquals(AdminPage.entries.toList(), availablePages(AdminRole.Owner))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monitor tier is weaker than owner admin tier`() {
|
||||
assertTrue(AdminTier.Monitor.rank < AdminTier.OwnerAdmin.rank)
|
||||
assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank)
|
||||
assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 两种时间形状 + 未知枚举回落 + 三态展示。
|
||||
*
|
||||
* 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成
|
||||
* `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。
|
||||
*/
|
||||
class AdminFormatTest {
|
||||
|
||||
private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""")
|
||||
|
||||
@Test
|
||||
fun `offset and zulu spellings of the same instant agree`() {
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatRfc3339("2026-03-04T12:34:56+08:00"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 with nanosecond precision parses`() {
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatRfc3339("2026-03-04T04:34:56.123456789Z"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 renders as a local timestamp`() {
|
||||
val formatted = formatRfc3339("2026-03-04T04:34:56Z")!!
|
||||
assertTrue(formatted, timestamp.matches(formatted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unparsable rfc3339 is returned raw instead of dropped`() {
|
||||
assertEquals("not-a-time", formatRfc3339("not-a-time"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank rfc3339 becomes null`() {
|
||||
assertNull(formatRfc3339(null))
|
||||
assertNull(formatRfc3339(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backthink millis use the epoch-millis parser, not the rfc3339 one`() {
|
||||
// 同一时刻的两种形状:Unix 毫秒 1772598896000 = 2026-03-04T04:34:56Z
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatEpochMillis(1_772_598_896_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nanos pick a unit by magnitude`() {
|
||||
assertEquals("512 ns", formatNanos(512))
|
||||
assertEquals("1.5 µs", formatNanos(1_500))
|
||||
assertEquals("2.5 ms", formatNanos(2_500_000))
|
||||
assertEquals("1.25 s", formatNanos(1_250_000_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts are sorted by hits descending`() {
|
||||
assertEquals("GET 5 · POST 2", formatCounts(mapOf("POST" to 2L, "GET" to 5L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts keep unknown keys verbatim`() {
|
||||
assertEquals("999 1", formatCounts(mapOf("999" to 1L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole doubles lose their decimals`() {
|
||||
assertEquals("12", formatDouble(12.0))
|
||||
assertEquals("0", formatDouble(0.0))
|
||||
assertEquals("1.5", formatDouble(1.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown enum values render raw`() {
|
||||
assertEquals("LLM 调用", traceKindLabel("llm_call"))
|
||||
assertEquals("quantum_hop", traceKindLabel("quantum_hop"))
|
||||
assertEquals("成功", traceStatusLabel("ok"))
|
||||
assertEquals("degraded", traceStatusLabel("degraded"))
|
||||
assertEquals("对话", personaModeLabel("chat"))
|
||||
assertEquals("telepathy", personaModeLabel("telepathy"))
|
||||
assertEquals("人格", memoryScopeLabel("persona"))
|
||||
assertEquals("shared", memoryScopeLabel("shared"))
|
||||
assertEquals("平静", moodLabel("calm"))
|
||||
assertEquals("euphoric", moodLabel("euphoric"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showToUser keeps three distinct states`() {
|
||||
assertEquals("未配置", triStateLabel(null))
|
||||
assertEquals("显示", triStateLabel(true))
|
||||
assertEquals("不显示", triStateLabel(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `props are rendered as compact json without losing keys`() {
|
||||
assertEquals(
|
||||
"""{"model":"gpt"}""",
|
||||
formatProps(mapOf<String, JsonElement>("model" to JsonPrimitive("gpt"))),
|
||||
)
|
||||
val two = formatProps(
|
||||
mapOf<String, JsonElement>(
|
||||
"a" to JsonPrimitive(1),
|
||||
"b" to JsonPrimitive(2),
|
||||
),
|
||||
)!!
|
||||
assertTrue(two, two.contains("\"a\":1") && two.contains("\"b\":2"))
|
||||
assertNull(formatProps(emptyMap()))
|
||||
assertNull(formatProps(null))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user