From e4cefa1fa7281c0171d38d9dc2a2ba3df297363b Mon Sep 17 00:00:00 2001 From: opsx Date: Fri, 30 Oct 2026 16:51:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=E5=BA=94=E7=94=A8=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E6=94=AF=E6=8C=81=2010/30/50=20=E5=88=87=E6=8D=A2=20+?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 可行性评估(先实测再实现)=== 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 --- frontend/src/i18n/locales/en/analysis.ts | 4 ++ frontend/src/i18n/locales/zh/analysis.ts | 4 ++ .../analysis/hooks/useAnalysisMetrics.ts | 15 ++-- frontend/src/pages/analysis/index.tsx | 71 +++++++++++++++++-- 4 files changed, 85 insertions(+), 9 deletions(-) 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 })} +
+ + +
+
+ )}