merge: W4 — 概览页「按路径」分组折叠(可读性)
This commit is contained in:
commit
a6b4bae2a0
3 files changed
+562
-2
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user