feat(analysis): 应用排行支持 10/30/50 切换 + 前端分页
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

=== 可行性评估(先实测再实现)===
topk 的 N 是否影响查询成本?5 分钟窗口实测:
  topk(10)  17833ms / 11 条
  topk(30)  18019ms / 34 条
  topk(50)   7394ms / 54 条
  topk(100) 17553ms / 74 条
波动来自集群负载,与 N 无关 —— 扫描的 chunk 集合完全相同,
topk 只决定最后返回多少序列。所以放宽到 50 不增加查询成本。

=== 分页为什么不每页发一次查询 ===
排行是"排序后的聚合结果":要拿第 11~20 名,必须先算出全部应用的量再排序,
不存在"只算 11~20 名"的做法。所以正确设计是:
  topk(N) 一次取全 → 前端按 10/页切片
N=50 时 5 页,只查 1 次;每页点一次查询反而会重复扫同一批 chunk,纯浪费。

=== 实现 ===
- hook 参数 rankLimit(10/30/50),两条查询都包 topk(N)
- 分析页:显示条数按钮组,点击即触发查询(同时重置到第 1 页)
- 分页条:共 N 个应用 · 第 x/y 页 · 上一页/下一页
- 长范围走预聚合时,limit 也跟随 N(接口上限 100)
- 切换筛选(刷新)时重置到第 1 页

Pod = Hub latest = 4d4e1a7ec334
This commit is contained in:
opsx committed 2026-10-30 16:51:21 +00:00
1 parent 7b1ffd0274
commit e4cefa1fa7
4 files changed
+85 -9

No files matched your search

+4
View File
@@ -67,6 +67,10 @@ export default {
}, },
table: { table: {
showCount: 'Rows',
pagerInfo: '{{total}} apps · page {{page}}/{{pages}}',
prevPage: 'Prev',
nextPage: 'Next',
subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, without error counts or trends.', subtitleHistory: 'This {{range}}-minute range exceeds live-scan limits, so this reads the platform\'s hourly pre-aggregated snapshots ({{hours}} hours retained) — returns in milliseconds, without error counts or trends.',
title: 'App volume ranking', title: 'App volume ranking',
computing: 'Computing per-app volume (reads log content, usually 20–30s)…', computing: 'Computing per-app volume (reads log content, usually 20–30s)…',
+4
View File
@@ -67,6 +67,10 @@ export default {
}, },
table: { table: {
showCount: '显示条数',
pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页',
prevPage: '上一页',
nextPage: '下一页',
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。', subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
title: '应用日志量排行', title: '应用日志量排行',
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页', subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
@@ -33,6 +33,11 @@ export interface AnalysisParams {
start: string start: string
end: string end: string
namespace: string namespace: string
/**
* 排行返回多少个应用(10/30/50)。topk 只影响返回序列数,
* 扫描成本与 N 无关,所以放宽不增加查询耗时。
*/
rankLimit?: number
/** /**
* 是否执行"按应用统计"的重查询(要读日志内容,20~30 秒)。 * 是否执行"按应用统计"的重查询(要读日志内容,20~30 秒)。
* 分析页会在卡片滚动到视口内时再置 true,避免首屏一起等。 * 分析页会在卡片滚动到视口内时再置 true,避免首屏一起等。
@@ -71,7 +76,7 @@ function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] {
* 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。 * 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。
* app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。 * app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。
*/ */
export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEnabled = true }: AnalysisParams) { export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEnabled = true, rankLimit = 10 }: AnalysisParams) {
const [appLabel, setAppLabel] = useState('app') const [appLabel, setAppLabel] = useState('app')
const [data, setData] = useState<AnalysisData | null>(null) const [data, setData] = useState<AnalysisData | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -202,8 +207,10 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEna
// 这两条并行而不是串行:实测总量 22s、error 8.6s,串行 31s,并行 22s。 // 这两条并行而不是串行:实测总量 22s、error 8.6s,串行 31s,并行 22s。
// 并发上限是 4,两条并行不会撞护栏(之前串行是为了避免 5 条并发的场景)。 // 并发上限是 4,两条并行不会撞护栏(之前串行是为了避免 5 条并发的场景)。
const [byAppSeries, errByAppSeries] = await runInBatches([ const [byAppSeries, errByAppSeries] = await runInBatches([
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`), // topk 只是卡返回的序列数:扫描的 chunk 数完全相同(实测 10/30/50/100
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`), // 耗时都在 7~18 秒的同一区间波动),所以放宽 N 不增加查询成本。
() => fetchRetry(`topk(${rankLimit}, sum by (${appLabel}, namespace) (count_over_time(${selector}[$step])))`),
() => fetchRetry(`topk(${rankLimit}, sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step])))`),
], 2) ], 2)
if (version !== versionRef.current) return if (version !== versionRef.current) return
@@ -283,7 +290,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEna
})() })()
return () => { controller.abort() } return () => { controller.abort() }
}, [sourceId, start, end, namespace, appLabel, refreshTick, rankingEnabled]) }, [sourceId, start, end, namespace, appLabel, refreshTick, rankingEnabled, rankLimit])
return { data, loading, appsLoading, error, appLabel, refresh } return { data, loading, appsLoading, error, appLabel, refresh }
} }
+66 -5
View File
@@ -160,6 +160,12 @@ const AnalysisPage = memo(function AnalysisPage() {
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000), Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
) )
const useHistoryRanking = rangeMinutes > 60 const useHistoryRanking = rangeMinutes > 60
// 排行一次取多少(10/30/50)。topk 只影响返回序列数,扫描成本与 N
// 无关(实测 10/30/50/100 都在 7~18 秒同一区间),所以放宽不增加查询成本。
// 分页在前端做:排行是"排序后的静态列表",翻页不需要再查 Loki。
const [rankLimit, setRankLimit] = useState(10)
const [rankPage, setRankPage] = useState(1)
const RANK_PAGE_SIZE = 10
const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null) const [historyApps, setHistoryApps] = useState<AppRow[] | null>(null)
const [historyHours, setHistoryHours] = useState(0) const [historyHours, setHistoryHours] = useState(0)
@@ -171,7 +177,7 @@ const AnalysisPage = memo(function AnalysisPage() {
const controller = new AbortController() const controller = new AbortController()
setHistoryApps(null) setHistoryApps(null)
logsApi logsApi
.appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, 10, { .appRanking(effectiveSourceId, new Date(start).getTime() * 1e6, new Date(end).getTime() * 1e6, rankLimit, {
signal: controller.signal, signal: controller.signal,
}) })
.then(res => { .then(res => {
@@ -189,7 +195,7 @@ const AnalysisPage = memo(function AnalysisPage() {
}) })
.catch(() => setHistoryApps([])) .catch(() => setHistoryApps([]))
return () => controller.abort() return () => controller.abort()
}, [useHistoryRanking, effectiveSourceId, start, end]) }, [useHistoryRanking, effectiveSourceId, start, end, rankLimit])
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({ const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
sourceId: effectiveSourceId, sourceId: effectiveSourceId,
@@ -201,6 +207,7 @@ const AnalysisPage = memo(function AnalysisPage() {
// 用户滚到排行卡才发那条 10~15 秒的重查询。 // 用户滚到排行卡才发那条 10~15 秒的重查询。
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。 // 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking), rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
rankLimit,
}) })
// 选择值与已生效值不一致 → 提示"点刷新生效" // 选择值与已生效值不一致 → 提示"点刷新生效"
@@ -209,6 +216,7 @@ const AnalysisPage = memo(function AnalysisPage() {
/** 把筛选栏里的选择值应用到查询:点一次「刷新」才生效 */ /** 把筛选栏里的选择值应用到查询:点一次「刷新」才生效 */
const applySelection = useCallback(() => { const applySelection = useCallback(() => {
setRankPage(1) // 新条件从第一页看起
setNamespace(selNamespace) setNamespace(selNamespace)
setStart(selStart) setStart(selStart)
setEnd(selEnd) setEnd(selEnd)
@@ -227,6 +235,13 @@ const AnalysisPage = memo(function AnalysisPage() {
enabled: Boolean(effectiveSourceId), enabled: Boolean(effectiveSourceId),
}) })
// 排行数据:长范围读预聚合快照,短范围读实时扫描结果
const rankRows = useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])
const rankTotal = rankRows.reduce((sum, app) => sum + app.total, 0)
const rankPageCount = Math.max(1, Math.ceil(rankRows.length / RANK_PAGE_SIZE))
const rankSafePage = Math.min(Math.max(1, rankPage), rankPageCount)
const rankPageRows = rankRows.slice((rankSafePage - 1) * RANK_PAGE_SIZE, rankSafePage * RANK_PAGE_SIZE)
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底) // 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!isAdmin) { if (!isAdmin) {
return ( return (
@@ -348,7 +363,26 @@ const AnalysisPage = memo(function AnalysisPage() {
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */} {/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : data.apps).length ? ( {/* 一次取多少个应用:点击即触发查询(topk 放宽不增加扫描成本) */}
<div className="mb-2 flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">{t('table.showCount')}</span>
{[10, 30, 50].map(count => (
<Button
key={count}
size="sm"
variant={rankLimit === count ? 'default' : 'outline'}
className="h-7 px-2.5 text-xs"
onClick={() => {
setRankLimit(count)
setRankPage(1)
refresh()
}}
>
{count}
</Button>
))}
</div>
{(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? (
// 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。 // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
<div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex h-40 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
@@ -357,14 +391,41 @@ const AnalysisPage = memo(function AnalysisPage() {
</div> </div>
) : ( ) : (
<TopAppsTable <TopAppsTable
apps={data.apps.slice(0, 10)} apps={rankPageRows}
/* 分母必须是"条数合计":apps 里是条数,stats.total 是字节, /* 分母必须是"条数合计":apps 里是条数,stats.total 是字节,
混用会让占比全部算成 0.0x% */ 混用会让占比全部算成 0.0x% */
total={data.apps.reduce((sum, app) => sum + app.total, 0)} total={rankTotal}
appLabel={appLabel} appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)} onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/> />
)} )}
{/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。
服务端只按 topk(N) 返回一次,N=50 时 5 页。 */}
{rankPageCount > 1 && (
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<span>{t('table.pagerInfo', { total: rankRows.length, page: rankSafePage, pages: rankPageCount })}</span>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={rankSafePage <= 1}
onClick={() => setRankPage(rankSafePage - 1)}
>
{t('table.prevPage')}
</Button>
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
disabled={rankSafePage >= rankPageCount}
onClick={() => setRankPage(rankSafePage + 1)}
>
{t('table.nextPage')}
</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>