feat(analysis): 应用排行支持 10/30/50 切换 + 前端分页
=== 可行性评估(先实测再实现)=== 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:
1 parent
7b1ffd0274
commit
e4cefa1fa7
4 files changed
+85
-9
No files matched your search
@@ -67,6 +67,10 @@ export default {
|
||||
},
|
||||
|
||||
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.',
|
||||
title: 'App volume ranking',
|
||||
computing: 'Computing per-app volume (reads log content, usually 20–30s)…',
|
||||
|
||||
@@ -67,6 +67,10 @@ export default {
|
||||
},
|
||||
|
||||
table: {
|
||||
showCount: '显示条数',
|
||||
pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。',
|
||||
title: '应用日志量排行',
|
||||
subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页',
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface AnalysisParams {
|
||||
start: string
|
||||
end: string
|
||||
namespace: string
|
||||
/**
|
||||
* 排行返回多少个应用(10/30/50)。topk 只影响返回序列数,
|
||||
* 扫描成本与 N 无关,所以放宽不增加查询耗时。
|
||||
*/
|
||||
rankLimit?: number
|
||||
/**
|
||||
* 是否执行"按应用统计"的重查询(要读日志内容,20~30 秒)。
|
||||
* 分析页会在卡片滚动到视口内时再置 true,避免首屏一起等。
|
||||
@@ -71,7 +76,7 @@ function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] {
|
||||
* 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。
|
||||
* 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 [data, setData] = useState<AnalysisData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -202,8 +207,10 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEna
|
||||
// 这两条并行而不是串行:实测总量 22s、error 8.6s,串行 31s,并行 22s。
|
||||
// 并发上限是 4,两条并行不会撞护栏(之前串行是为了避免 5 条并发的场景)。
|
||||
const [byAppSeries, errByAppSeries] = await runInBatches([
|
||||
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
|
||||
() => fetchRetry(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`),
|
||||
// topk 只是卡返回的序列数:扫描的 chunk 数完全相同(实测 10/30/50/100
|
||||
// 耗时都在 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)
|
||||
if (version !== versionRef.current) return
|
||||
|
||||
@@ -283,7 +290,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEna
|
||||
})()
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -160,6 +160,12 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
Math.round((new Date(end).getTime() - new Date(start).getTime()) / 60000),
|
||||
)
|
||||
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 [historyHours, setHistoryHours] = useState(0)
|
||||
|
||||
@@ -171,7 +177,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
const controller = new AbortController()
|
||||
setHistoryApps(null)
|
||||
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,
|
||||
})
|
||||
.then(res => {
|
||||
@@ -189,7 +195,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
})
|
||||
.catch(() => setHistoryApps([]))
|
||||
return () => controller.abort()
|
||||
}, [useHistoryRanking, effectiveSourceId, start, end])
|
||||
}, [useHistoryRanking, effectiveSourceId, start, end, rankLimit])
|
||||
|
||||
const { data, loading, appsLoading, error, appLabel, refresh } = useAnalysisMetrics({
|
||||
sourceId: effectiveSourceId,
|
||||
@@ -201,6 +207,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
// 用户滚到排行卡才发那条 10~15 秒的重查询。
|
||||
// 这是真正减少查询压力的一步——之前图在首屏,所以只能全量查。
|
||||
rankingEnabled: !useHistoryRanking && (rankingView.inView || forceRanking),
|
||||
rankLimit,
|
||||
})
|
||||
|
||||
// 选择值与已生效值不一致 → 提示"点刷新生效"
|
||||
@@ -209,6 +216,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
|
||||
/** 把筛选栏里的选择值应用到查询:点一次「刷新」才生效 */
|
||||
const applySelection = useCallback(() => {
|
||||
setRankPage(1) // 新条件从第一页看起
|
||||
setNamespace(selNamespace)
|
||||
setStart(selStart)
|
||||
setEnd(selEnd)
|
||||
@@ -227,6 +235,13 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
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 兜底)
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
@@ -348,7 +363,26 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
{/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
|
||||
</CardHeader>
|
||||
<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 秒。
|
||||
// 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
|
||||
<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>
|
||||
) : (
|
||||
<TopAppsTable
|
||||
apps={data.apps.slice(0, 10)}
|
||||
apps={rankPageRows}
|
||||
/* 分母必须是"条数合计":apps 里是条数,stats.total 是字节,
|
||||
混用会让占比全部算成 0.0x% */
|
||||
total={data.apps.reduce((sum, app) => sum + app.total, 0)}
|
||||
total={rankTotal}
|
||||
appLabel={appLabel}
|
||||
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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user