feat(admin): 概览「按路径」改成分组可折叠列表 —— 26 条路径不再挤成一行
`perPath`(真实响应 26 条)此前被 `formatCounts` 压成一行右对齐文本,手机上极密。
本次只改呈现层,取数(DTO / Retrofit / ViewModel)一字未动。
- domain/admin/AdminPathStats.kt(新增,纯函数):分组键 = **剥掉 `/api/v1` 后的首段**
(`/api/v1/admin/llm/catalog` → `/admin`,`/sw.js` → `/sw.js`,`/` → `/`)。
规则是纯字符串推导、**没有任何路径清单**:服务端加路由不用改客户端
(`/api/v1/quantum/*` 自动成 `/quantum` 组);逐段比较,`/api/v1x/foo` 不会被误剥成 `/x`。
剥离只作用于分组键,行里显示的仍是完整路径。
排序:组按命中次数倒序、组内同样倒序,**两处同数都按路径升序**——指标页每次刷新都会重排,
没有稳定次序读者就跟丢了。`hits`(次数)与 `pathCount`(条数)分开,是组头的两个数。
长路径(段数 > 4)展示时折叠中间段、保留尾部 3 段:`…/admin/observability/metrics`;
尾部才是区分同类路径的部分,整串在手机上会被尾部省略号吃掉、两条路径就长得一样了。
- ui/admin/AdminOverviewScreen.kt:「按路径」从 `countRows` 里挪出来单独成块——
区块标题给「26 条 · 326 次」,卡片内一行一组,组头「N 条 · M 次」+ 可旋转箭头,
整行可点即展开/收起;组内一行一条路径,右侧计数右对齐、路径等宽字体 + 中间省略。
**条数 > 4 的组默认收起**(真实数据里 15 条 admin 路径收成一行组头),小分组默认展开省一次点击;
折叠状态只记**用户动过的组**(`remember { mutableStateMapOf() }`,放在 LazyColumn 之外),
刷新拿到新数据不会被上一轮点按锁死。
只用 Miuix 现成组件(`PreferenceCard` + `BasicComponent` + `HorizontalDivider` +
`MiuixIcons.ChevronForward` 旋转 90°),**没有自造折叠控件**;路径行用内容重载自己画 Text
才能拿到 `maxLines = 1` + `MiddleEllipsis`(收 `String` 的那个重载只会尾部省略)。
失败态不变:`metrics` 缺席时整块不渲染(上面那张「指标不可用」卡已经说明了原因),
只有 `perPath` **请求成功且为空**时才给「暂无路径统计」。
测试:353 → 370 全绿(+17,AdminPathStatsTest):空输入 / 单条(不产生组头)/ 真实 26 条 →
10 组且总次数 326 = 同一份真实响应里的 `totalRequests`(分组不吞路径也不重算次数)/
未知前缀自动成组 / 基址逐段剥离的边界(`/api/v1x/foo`、基址本身)/ 组与组内的排序与并列次序 /
长路径折叠(两条兄弟路径折叠后仍不同)/ 折叠默认值的阈值边界(4 条展开、5 条收起)/
显式切换双向覆盖默认。
未验证(无模拟器、也不许碰 adb,只有构建 + 单测):
- 观感(组头取 `primary`、路径等宽字号取 `footnote1`、子行缩进 12dp、箭头旋转)**没有截图**,
取舍待主人拍板;
- `BasicComponent` 对长路径会不会折行没有实机确认,所以路径文本额外做了「段级中间折叠」,
不依赖库的省略行为;
- 深色模式未单独验证,颜色全部取自 `MiuixTheme.colorScheme`(与页面其它卡片同源)。
This commit is contained in:
1 parent
664fd8dcef
commit
f91a8558ea
3 files changed
+562
-2
No files matched your search
@@ -0,0 +1,156 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
/**
|
||||
* `perPath` 的一项:路径原文 + 命中次数 + 展示文本。
|
||||
*
|
||||
* [path] **不做任何改写**(便于与网关日志 / 后端逐字对齐、复制粘贴),
|
||||
* 手机上要读的那一份在 [display](见 [PathStats.displayPath])。
|
||||
*/
|
||||
data class PathHit(
|
||||
val path: String,
|
||||
val count: Long,
|
||||
val display: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 同一个前缀下的一组路径,组内已按命中次数倒序(同数按路径升序)。
|
||||
* [hits] / [pathCount] 是组头要显示的两个数:**条数与次数不是一回事**
|
||||
* (真实响应里 `/admin` 一组 = 15 条路径、283 次命中)。
|
||||
*/
|
||||
data class PathGroup(
|
||||
val prefix: String,
|
||||
val paths: List<PathHit>,
|
||||
) {
|
||||
val hits: Long get() = paths.sumOf { it.count }
|
||||
|
||||
val pathCount: Int get() = paths.size
|
||||
|
||||
/**
|
||||
* 默认展开还是收起:条数多的大组默认**收起**,首屏因此只留一行组头,
|
||||
* 展开是用户的一次明确动作(见 [PathStats.COLLAPSE_THRESHOLD])。
|
||||
*/
|
||||
val expandedByDefault: Boolean get() = pathCount <= PathStats.COLLAPSE_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组列表里的一行。[Header] 可点、可折叠;[Leaf] 是一条路径。
|
||||
* 只有一条路径的组**不产生组头**(组头与内容完全重复),直接给一条 [Leaf]。
|
||||
*/
|
||||
sealed interface PathRow {
|
||||
data class Header(val group: PathGroup) : PathRow
|
||||
|
||||
/** [nested] = 该行在一个已展开的组里,UI 据此缩进;组外散条不缩进。 */
|
||||
data class Leaf(val hit: PathHit, val nested: Boolean) : PathRow
|
||||
}
|
||||
|
||||
/**
|
||||
* 「按路径」统计的分组与展示模型(纯函数,不含任何 Compose / Android 依赖)。
|
||||
*
|
||||
* ## 为什么要客户端分组
|
||||
* 契约(api-client §2「可观测性契约」)里 `perPath` 是**扁平**的 `路径 → 次数` 映射——
|
||||
* 真实响应 26 条,后端不提供层级信息。之前它被塞进一行右对齐文本([formatCounts]),
|
||||
* 手机上 26 条挤成一团、不可读,所以这里定义一套**可解释的分组规则**:
|
||||
*
|
||||
* **分组键 = 剥掉 [API_BASE_PREFIX] 后的首段**(`/api/v1/admin/llm/catalog` → `/admin`,
|
||||
* `/assets/index-x.js` → `/assets`,`/sw.js` → `/sw.js`,`/` → `/`)。
|
||||
*
|
||||
* - **没有任何路径清单**:规则是纯字符串推导,服务端新增路由、新增路径形状都不需要改客户端
|
||||
* (未知前缀自动成组,见单测里的 `/api/v1/quantum/entangle`)。
|
||||
* - 剥掉 `/api/v1` 是因为它对 21/26 条路径完全相同,占满宽度却零区分度;
|
||||
* 剥离**只影响分组键**,行里显示的仍是完整路径。
|
||||
* - 比「按前两段分组」好:那样 `/admin/automations`、`/admin/personas` … 会切出十几个
|
||||
* 只有一条路径的组,组头与内容重复,比不分组还难读。
|
||||
* - 深层路径(段数 > [MAX_DISPLAY_SEGMENTS])在展示时**折叠中间段、保留尾部**:
|
||||
* 尾部才是把同类路径区分开的部分(`…/admin/observability/metrics`),而整串在手机上
|
||||
* 会被省略号吃掉尾部、丢掉区分度。
|
||||
*
|
||||
* 排序:组按 [PathGroup.hits] 倒序(热路径在前),组内按次数倒序——两处同数都按路径升序,
|
||||
* 保证刷新前后顺序稳定(指标页每次刷新都重排会让读者跟丢)。
|
||||
*/
|
||||
object PathStats {
|
||||
|
||||
/** 所有 REST 端点共有的基址段,只在**分组键**里剥掉。 */
|
||||
const val API_BASE_PREFIX = "/api/v1"
|
||||
|
||||
/** 组内路径条数超过它就默认收起。4 条以内的小组直接展开,省一次点击。 */
|
||||
const val COLLAPSE_THRESHOLD = 4
|
||||
|
||||
/** 段数超过它的路径在展示时折叠中间段。 */
|
||||
const val MAX_DISPLAY_SEGMENTS = 4
|
||||
|
||||
/** 折叠后保留的尾部段数。 */
|
||||
const val RETAINED_TAIL_SEGMENTS = 3
|
||||
|
||||
/** 根路径(`/`)与异常空串的组名。 */
|
||||
private const val ROOT_PREFIX = "/"
|
||||
|
||||
private val API_BASE_SEGMENTS = segments(API_BASE_PREFIX)
|
||||
|
||||
/**
|
||||
* `perPath` → 分组列表:组按命中次数倒序,组内路径同样倒序。
|
||||
* 空输入给空列表(UI 据此渲染「暂无路径统计」,**它是成功后的空**,与失败态无关)。
|
||||
*/
|
||||
fun groupPaths(perPath: Map<String, Long>): List<PathGroup> =
|
||||
perPath.entries
|
||||
.groupBy(
|
||||
keySelector = { groupPrefix(it.key) },
|
||||
valueTransform = { PathHit(path = it.key, count = it.value, display = displayPath(it.key)) },
|
||||
)
|
||||
.map { (prefix, paths) ->
|
||||
PathGroup(
|
||||
prefix = prefix,
|
||||
paths = paths.sortedWith(compareByDescending<PathHit> { it.count }.thenBy { it.path }),
|
||||
)
|
||||
}
|
||||
.sortedWith(compareByDescending<PathGroup> { it.hits }.thenBy { it.prefix })
|
||||
|
||||
/**
|
||||
* 展开状态:**只记用户动过的组**([toggled] = 组前缀 → 是否展开),
|
||||
* 其余按 [PathGroup.expandedByDefault]。这样刷新拿到新数据后,默认收起的大组不会被
|
||||
* 上一轮的点按状态锁死,新出现的分组也自然落到默认档。
|
||||
*/
|
||||
fun expanded(group: PathGroup, toggled: Map<String, Boolean> = emptyMap()): Boolean =
|
||||
toggled[group.prefix] ?: group.expandedByDefault
|
||||
|
||||
/** 分组列表 + 展开状态 → 一维行序列,UI 直接顺序渲染(组头之后紧跟它的子行)。 */
|
||||
fun rows(groups: List<PathGroup>, toggled: Map<String, Boolean> = emptyMap()): List<PathRow> =
|
||||
groups.flatMap { group ->
|
||||
// 两个分支都写明 [PathRow]:否则 `flatMap` 会拿第一个分支的元素类型
|
||||
// (`PathRow.Leaf`)去约束第二个分支,组头就加不进去了。
|
||||
if (group.pathCount == 1) {
|
||||
listOf<PathRow>(PathRow.Leaf(group.paths.single(), nested = false))
|
||||
} else {
|
||||
buildList<PathRow> {
|
||||
add(PathRow.Header(group))
|
||||
if (expanded(group, toggled)) {
|
||||
group.paths.forEach { add(PathRow.Leaf(it, nested = true)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示文本:段数不超过 [MAX_DISPLAY_SEGMENTS] 时**原样返回**(不做任何归一化,
|
||||
* 尾斜杠之类都保留);超过则折叠中间段、保留尾部 [RETAINED_TAIL_SEGMENTS] 段,
|
||||
* 前缀 `…/` 明确表示左边还有内容被省掉。
|
||||
*/
|
||||
fun displayPath(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.size <= MAX_DISPLAY_SEGMENTS) return path
|
||||
return "…/" + parts.takeLast(RETAINED_TAIL_SEGMENTS).joinToString("/")
|
||||
}
|
||||
|
||||
/** 分组键:剥掉 [API_BASE_PREFIX] 后的首段,逐段比较(`/api/v1x/a` 不算基址下的路径)。 */
|
||||
private fun groupPrefix(path: String): String {
|
||||
val parts = segments(path)
|
||||
if (parts.isEmpty()) return ROOT_PREFIX
|
||||
val rest = if (parts.take(API_BASE_SEGMENTS.size) == API_BASE_SEGMENTS) {
|
||||
parts.drop(API_BASE_SEGMENTS.size)
|
||||
} else {
|
||||
parts
|
||||
}
|
||||
return rest.firstOrNull()?.let { "/$it" } ?: API_BASE_PREFIX
|
||||
}
|
||||
|
||||
private fun segments(path: String): List<String> = path.split('/').filter { it.isNotEmpty() }
|
||||
}
|
||||
@@ -13,13 +13,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
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.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.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.formatCounts
|
||||
import top.yeij.cyrene.domain.admin.formatDouble
|
||||
import top.yeij.cyrene.domain.admin.formatNanos
|
||||
@@ -28,16 +38,28 @@ 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.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.Text
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.ChevronForward
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
/**
|
||||
* 概览页:`metrics` 卡片 + 其下的 `trace` 列表(两次请求,见 api-client §2「可观测性契约」)。
|
||||
* 概览页:`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) }
|
||||
|
||||
// 折叠状态放在 LazyColumn 之外:行随滚动回收,状态不能挂在 item 里。
|
||||
// 只记**用户动过的组**,其余按分组默认档(见 PathStats.expanded),刷新不会把大组锁在打开状态。
|
||||
val toggledGroups = remember { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
AdminPageScaffold(title = AdminPage.Overview.title, onBack = onBack, onRefresh = reload) { padding ->
|
||||
@@ -72,6 +94,13 @@ fun AdminOverviewScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "__paths__") {
|
||||
// metrics 缺席时这里整段不渲染:上面那张卡已经说明了「指标不可用」,
|
||||
// 再给一个空列表就是把失败画成「没有路径」。
|
||||
data.metrics?.let { metrics ->
|
||||
PathStatsSection(perPath = metrics.perPath, toggled = toggledGroups)
|
||||
}
|
||||
}
|
||||
item(key = "__trace_title__") { AdminSectionTitle("最近 trace") }
|
||||
when {
|
||||
// trace 失败时绝不渲染成「暂无数据」——那是把失败伪装成空成功。
|
||||
@@ -103,12 +132,112 @@ private fun metricRows(metrics: AdminMetricsDto): List<Pair<String, String>> = l
|
||||
"子代理事件" 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 { "-" },
|
||||
)
|
||||
|
||||
/**
|
||||
* 「按路径」区块:分组 + 可折叠(分组规则与排序全在 [PathStats],这里只负责画)。
|
||||
*
|
||||
* 首屏可读性靠两件事:**条数多的大组默认收起**(真实响应里 15 条 admin 路径收成一行组头)、
|
||||
* 小组默认展开(省一次点击);行里同时给「条数」与「次数」,因为「15 条路径」和「283 次命中」
|
||||
* 是两个不同的信息。
|
||||
*/
|
||||
@Composable
|
||||
private fun PathStatsSection(perPath: Map<String, Long>, toggled: MutableMap<String, Boolean>) {
|
||||
val groups = remember(perPath) { PathStats.groupPaths(perPath) }
|
||||
val pathCount = groups.sumOf { it.pathCount }
|
||||
val hits = groups.sumOf { it.hits }
|
||||
// 这一行每次重组都要重算(toggled 是快照状态,读它才能跟着点按更新);纯计算,成本可忽略。
|
||||
val rows = PathStats.rows(groups, toggled)
|
||||
|
||||
AdminSectionTitle("按路径($pathCount 条 · $hits 次)")
|
||||
if (rows.isEmpty()) {
|
||||
// 走到这里说明 metrics 请求**成功**了而 `perPath` 为空——成功后的空,不是失败。
|
||||
AdminInlineHint("暂无路径统计")
|
||||
return
|
||||
}
|
||||
PreferenceCard {
|
||||
Column {
|
||||
rows.forEachIndexed { index, row ->
|
||||
if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp))
|
||||
when (row) {
|
||||
is PathRow.Header -> PathGroupRow(
|
||||
group = row.group,
|
||||
expanded = PathStats.expanded(row.group, toggled),
|
||||
onToggle = { toggled[row.group.prefix] = !PathStats.expanded(row.group, toggled) },
|
||||
)
|
||||
|
||||
is PathRow.Leaf -> PathLeafRow(hit = row.hit, nested = row.nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 组头:前缀 + 「N 条 · M 次」+ 箭头(展开时转向下)。整行可点,是唯一的折叠入口。 */
|
||||
@Composable
|
||||
private fun PathGroupRow(group: PathGroup, expanded: Boolean, onToggle: () -> Unit) {
|
||||
BasicComponent(
|
||||
onClick = onToggle,
|
||||
endActions = {
|
||||
Text(
|
||||
text = "${group.pathCount} 条 · ${group.hits} 次",
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
Icon(
|
||||
imageVector = MiuixIcons.ChevronForward,
|
||||
contentDescription = if (expanded) "收起" else "展开",
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.rotate(if (expanded) 90f else 0f),
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = group.prefix,
|
||||
fontSize = MiuixTheme.textStyles.main.fontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径行:左路径、右计数。
|
||||
*
|
||||
* 路径用**等宽字体**(路径是给人对比字符用的,等宽最好扫读),字号取 `footnote1` 与计数一致;
|
||||
* 过长时**中间省略**——尾部(资源名)才是区分同类路径的部分,尾部省略等于把两条路径画成一样。
|
||||
*/
|
||||
@Composable
|
||||
private fun PathLeafRow(hit: PathHit, nested: Boolean) {
|
||||
BasicComponent(
|
||||
endActions = {
|
||||
Text(
|
||||
text = hit.count.toString(),
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = hit.display,
|
||||
modifier = if (nested) Modifier.padding(start = 12.dp) else Modifier,
|
||||
fontSize = MiuixTheme.textStyles.footnote1.fontSize,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MiuixTheme.colorScheme.onSurfaceContainer,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TraceSpanCard(span: AdminTraceSpanDto) {
|
||||
AdminTextCard(
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* 「按路径」分组与折叠模型(C-2 概览页)。
|
||||
*
|
||||
* `realPerPath` 是**真实响应体**里的 `perPath`(26 条,与 `AdminDtoRealPayloadTest` 同一份样本),
|
||||
* 不是编造的样例——分组规则必须在真实数据上成立才算数。
|
||||
*/
|
||||
class AdminPathStatsTest {
|
||||
|
||||
private val realPerPath = mapOf(
|
||||
"/" to 2L,
|
||||
"/api/v1/admin/automations" to 17L,
|
||||
"/api/v1/admin/backthink" to 1L,
|
||||
"/api/v1/admin/channels" to 30L,
|
||||
"/api/v1/admin/iot" to 34L,
|
||||
"/api/v1/admin/llm" to 18L,
|
||||
"/api/v1/admin/llm/catalog" to 2L,
|
||||
"/api/v1/admin/memories" to 21L,
|
||||
"/api/v1/admin/observability/metrics" to 24L,
|
||||
"/api/v1/admin/observability/trace" to 1L,
|
||||
"/api/v1/admin/personas" to 31L,
|
||||
"/api/v1/admin/plugins" to 23L,
|
||||
"/api/v1/admin/plugins/installed" to 17L,
|
||||
"/api/v1/admin/rag" to 17L,
|
||||
"/api/v1/admin/sessions" to 30L,
|
||||
"/api/v1/admin/users" to 17L,
|
||||
"/api/v1/auth/login" to 5L,
|
||||
"/api/v1/health" to 27L,
|
||||
"/api/v1/meta/enums" to 2L,
|
||||
"/api/v1/personas" to 1L,
|
||||
"/api/v1/sessions" to 1L,
|
||||
"/api/v1/sessions/web/messages" to 1L,
|
||||
"/assets/index-DrDuaeXG.js" to 1L,
|
||||
"/assets/index-SA9l26Q_.css" to 1L,
|
||||
"/sw.js" to 1L,
|
||||
"/ws/chat" to 1L,
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 空输入 / 单条
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `empty input yields no groups and no rows`() {
|
||||
assertTrue(PathStats.groupPaths(emptyMap()).isEmpty())
|
||||
assertTrue(PathStats.rows(emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single path is one group and renders as a headerless row`() {
|
||||
val groups = PathStats.groupPaths(mapOf("/api/v1/health" to 7L))
|
||||
|
||||
assertEquals(1, groups.size)
|
||||
assertEquals("/health", groups[0].prefix)
|
||||
assertEquals(1, groups[0].pathCount)
|
||||
assertEquals(7L, groups[0].hits)
|
||||
// 只有一条路径的组不产生组头:组头与内容完全重复,只会让列表变长。
|
||||
assertEquals(listOf(PathRow.Leaf(PathHit("/api/v1/health", 7L, "/api/v1/health"), false)), PathStats.rows(groups))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 分组规则(无路径清单)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `grouping keys off the first segment below the api base, not a hardcoded route list`() {
|
||||
val groups = PathStats.groupPaths(
|
||||
mapOf(
|
||||
"/api/v1/quantum/entangle" to 3L, // 客户端不认识的新路由:照样自动成组
|
||||
"/api/v1/admin/automations" to 1L,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("/quantum", "/admin"), groups.map { it.prefix })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api base prefix is stripped for grouping only, the row keeps the full path`() {
|
||||
val group = PathStats.groupPaths(mapOf("/api/v1/admin/llm/catalog" to 2L)).single()
|
||||
|
||||
assertEquals("/admin", group.prefix)
|
||||
assertEquals("/api/v1/admin/llm/catalog", group.paths.single().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a path that only looks like the base is not stripped`() {
|
||||
// 逐段比较:`/api/v1x/...` 不是 `/api/v1` 下的路径,不能被剥成 `/x`。
|
||||
assertEquals("/api", PathStats.groupPaths(mapOf("/api/v1x/foo" to 1L)).single().prefix)
|
||||
// 基址本身(没有更深的一段)自成一档,而不是落进空字符串。
|
||||
assertEquals("/api/v1", PathStats.groupPaths(mapOf("/api/v1" to 1L)).single().prefix)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `root and non-api paths each get their own group`() {
|
||||
val groups = PathStats.groupPaths(
|
||||
mapOf(
|
||||
"/" to 2L,
|
||||
"" to 1L,
|
||||
"/assets/index-DrDuaeXG.js" to 1L,
|
||||
"/assets/index-SA9l26Q_.css" to 1L,
|
||||
"/sw.js" to 1L,
|
||||
"/ws/chat" to 1L,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("/", "/assets", "/sw.js", "/ws"), groups.map { it.prefix })
|
||||
assertEquals(2, groups.first { it.prefix == "/assets" }.pathCount)
|
||||
assertEquals(2, groups.first { it.prefix == "/" }.pathCount)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 真实 26 条
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `real payload groups into ten prefixes without losing a path or a hit`() {
|
||||
val groups = PathStats.groupPaths(realPerPath)
|
||||
|
||||
assertEquals(
|
||||
listOf("/admin", "/health", "/auth", "/", "/assets", "/meta", "/sessions", "/personas", "/sw.js", "/ws"),
|
||||
groups.map { it.prefix },
|
||||
)
|
||||
assertEquals(26, groups.sumOf { it.pathCount })
|
||||
// 与同一份真实响应里的 totalRequests 一致:分组既不吞路径也不重算次数。
|
||||
assertEquals(326L, groups.sumOf { it.hits })
|
||||
assertEquals(15, groups.first().pathCount)
|
||||
assertEquals(283L, groups.first().hits)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groups are ordered by hits, ties by prefix`() {
|
||||
val groups = PathStats.groupPaths(realPerPath)
|
||||
|
||||
val hits = groups.map { it.hits }
|
||||
assertEquals(hits.sortedDescending(), hits)
|
||||
// 并列的 2 次:按前缀升序,保证刷新前后顺序稳定。
|
||||
assertEquals(listOf("/", "/assets", "/meta", "/sessions"), groups.filter { it.hits == 2L }.map { it.prefix })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paths inside a group are ordered by count, ties by path`() {
|
||||
val admin = PathStats.groupPaths(realPerPath).first().paths
|
||||
|
||||
val counts = admin.map { it.count }
|
||||
assertEquals(counts.sortedDescending(), counts)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"/api/v1/admin/automations",
|
||||
"/api/v1/admin/plugins/installed",
|
||||
"/api/v1/admin/rag",
|
||||
"/api/v1/admin/users",
|
||||
),
|
||||
admin.filter { it.count == 17L }.map { it.path },
|
||||
)
|
||||
assertEquals("/api/v1/admin/iot", admin.first().path)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 长路径的展示文本
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `deep paths collapse the middle and keep the tail`() {
|
||||
assertEquals("…/admin/observability/metrics", PathStats.displayPath("/api/v1/admin/observability/metrics"))
|
||||
assertEquals("…/sessions/web/messages", PathStats.displayPath("/api/v1/sessions/web/messages"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `paths within the segment budget are rendered verbatim`() {
|
||||
// 不归一化:尾斜杠、文件名里的点、问号都原样留着(键名就是后端给的原串)。
|
||||
assertEquals("/api/v1/auth/login", PathStats.displayPath("/api/v1/auth/login"))
|
||||
assertEquals("/assets/index-DrDuaeXG.js", PathStats.displayPath("/assets/index-DrDuaeXG.js"))
|
||||
assertEquals("/sw.js", PathStats.displayPath("/sw.js"))
|
||||
assertEquals("/", PathStats.displayPath("/"))
|
||||
assertEquals("", PathStats.displayPath(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collapsing never makes two sibling paths look identical`() {
|
||||
val a = PathStats.displayPath("/api/v1/admin/observability/metrics")
|
||||
val b = PathStats.displayPath("/api/v1/admin/observability/trace")
|
||||
|
||||
assertTrue(a, a != b)
|
||||
assertTrue(a, a.endsWith("/metrics"))
|
||||
assertTrue(b, b.endsWith("/trace"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `display text is wired into the grouped rows`() {
|
||||
val hit = PathStats.groupPaths(realPerPath)
|
||||
.flatMap { it.paths }
|
||||
.first { it.path == "/api/v1/admin/observability/metrics" }
|
||||
|
||||
assertEquals("…/admin/observability/metrics", hit.display)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 折叠状态
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `big groups collapse by default, small ones expand`() {
|
||||
val groups = PathStats.groupPaths(realPerPath)
|
||||
|
||||
val admin = groups.first { it.prefix == "/admin" }
|
||||
assertFalse(admin.expandedByDefault)
|
||||
assertTrue(groups.first { it.prefix == "/assets" }.expandedByDefault)
|
||||
|
||||
// 阈值边界:恰好 [PathStats.COLLAPSE_THRESHOLD] 条仍展开,多一条就收起。
|
||||
val atThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD).map { "/api/v1/t/p$it" })
|
||||
val aboveThreshold = group("/t", (1..PathStats.COLLAPSE_THRESHOLD + 1).map { "/api/v1/t/p$it" })
|
||||
assertTrue(atThreshold.expandedByDefault)
|
||||
assertFalse(aboveThreshold.expandedByDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default rows show only the header of a collapsed group`() {
|
||||
val rows = PathStats.rows(PathStats.groupPaths(realPerPath))
|
||||
|
||||
// 15 条 admin 路径 + 其余散条都在,但首屏只有组头承载它们。
|
||||
assertEquals(14, rows.size)
|
||||
assertEquals(PathRow.Header(PathStats.groupPaths(realPerPath).first()), rows.first())
|
||||
assertEquals(1, rows.count { it is PathRow.Header && it.group.prefix == "/admin" })
|
||||
assertTrue(rows.none { it is PathRow.Leaf && it.hit.path.startsWith("/api/v1/admin/") })
|
||||
// 小分组默认展开:组头之后紧跟它自己的子行。
|
||||
val assetsHeader = rows.indexOfFirst { it is PathRow.Header && it.group.prefix == "/assets" }
|
||||
assertTrue(rows[assetsHeader + 1] is PathRow.Leaf)
|
||||
assertEquals(listOf(true, true), (assetsHeader + 1..assetsHeader + 2).map { (rows[it] as PathRow.Leaf).nested })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit toggles win over the default in both directions`() {
|
||||
val groups = PathStats.groupPaths(realPerPath)
|
||||
val admin = groups.first { it.prefix == "/admin" }
|
||||
val assets = groups.first { it.prefix == "/assets" }
|
||||
|
||||
// 收起的大组被点开:组头之后紧跟着它的 [admin.pathCount] 条子行(缩进)。
|
||||
val opened = PathStats.rows(groups, mapOf("/admin" to true))
|
||||
val adminHeader = opened.indexOfFirst { it is PathRow.Header && it.group.prefix == "/admin" }
|
||||
val children = opened.subList(adminHeader + 1, adminHeader + 1 + admin.pathCount)
|
||||
assertEquals(admin.paths.map { it.path }, children.map { (it as PathRow.Leaf).hit.path })
|
||||
assertTrue(children.all { it is PathRow.Leaf && it.nested })
|
||||
assertEquals("/api/v1/admin/iot", (opened[adminHeader + 1] as PathRow.Leaf).hit.path)
|
||||
|
||||
// 默认展开的小组被收起:只剩组头。
|
||||
val closed = PathStats.rows(groups, mapOf("/assets" to false))
|
||||
assertTrue(closed.none { it is PathRow.Leaf && it.hit.path.startsWith("/assets/") })
|
||||
assertTrue(PathStats.expanded(assets, mapOf("/assets" to false)).not())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rows keep the group order and never drop a path when everything is expanded`() {
|
||||
val groups = PathStats.groupPaths(realPerPath)
|
||||
val toggled = groups.associate { it.prefix to true }
|
||||
|
||||
val rows = PathStats.rows(groups, toggled)
|
||||
// 只有多路径的组才有组头(单条组直接是一行路径),全部展开时行数 = 组头 + 全部路径。
|
||||
val multiPathGroups = groups.filter { it.pathCount > 1 }
|
||||
|
||||
assertEquals(multiPathGroups.size + 26, rows.size)
|
||||
assertEquals(
|
||||
multiPathGroups.map { it.prefix },
|
||||
rows.filterIsInstance<PathRow.Header>().map { it.group.prefix },
|
||||
)
|
||||
}
|
||||
|
||||
private fun group(prefix: String, paths: List<String>): PathGroup =
|
||||
PathStats.groupPaths(paths.associateWith { 1L }).single().also { assertEquals(prefix, it.prefix) }
|
||||
}
|
||||
Reference in new issue
Block a user