diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index 806ac41..147bac7 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -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)…', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 1cdfa64..f123cbc 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -67,6 +67,10 @@ export default { }, table: { + showCount: '显示条数', + pagerInfo: '共 {{total}} 个应用 · 第 {{page}}/{{pages}} 页', + prevPage: '上一页', + nextPage: '下一页', subtitleHistory: '{{range}} 分钟范围超过了实时扫描上限,这里读平台按小时预聚合的快照(已留存 {{hours}} 小时)——毫秒级返回,但不含错误数与趋势。', title: '应用日志量排行', subtitle: '按日志条数排前 10 的应用,点击「查看日志」跳转至查询页', diff --git a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts index 3e18d3c..6759932 100644 --- a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts +++ b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts @@ -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(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 } } diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index 67e4822..1040ce8 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -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(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() { {/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */} - {(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : data.apps).length ? ( + {/* 一次取多少个应用:点击即触发查询(topk 放宽不增加扫描成本) */} +
+ {t('table.showCount')} + {[10, 30, 50].map(count => ( + + ))} +
+ {(useHistoryRanking ? historyApps === null : (appsLoading || loading)) && !(useHistoryRanking ? (historyApps ?? []) : (data?.apps ?? [])).length ? ( // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。
@@ -357,14 +391,41 @@ const AnalysisPage = memo(function AnalysisPage() {
) : ( sum + app.total, 0)} + total={rankTotal} appLabel={appLabel} onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)} /> )} + {/* 分页在前端做:排行是排序后的静态列表,翻页不需要再查 Loki。 + 服务端只按 topk(N) 返回一次,N=50 时 5 页。 */} + {rankPageCount > 1 && ( +
+ {t('table.pagerInfo', { total: rankRows.length, page: rankSafePage, pages: rankPageCount })} +
+ + +
+
+ )}