diff --git a/frontend/src/hooks/useInView.ts b/frontend/src/hooks/useInView.ts new file mode 100644 index 0000000..5b13baf --- /dev/null +++ b/frontend/src/hooks/useInView.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * useInView 判断元素是否进入视口(默认提前 200px 触发)。 + * + * 用途:分析页里"按应用统计"这类必须读日志内容的查询很贵(实测大集群 + * 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。 + * 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。 + * + * 只触发一次(进入视口后断开监听):日志分析是只读快照,不需要来回重算。 + */ +export function useInView(rootMargin = '200px') { + const ref = useRef(null) + const [inView, setInView] = useState(false) + + useEffect(() => { + if (inView) return + const el = ref.current + if (!el) return + // 老浏览器/SSR 环境没有 IntersectionObserver:直接当作可见,别把功能卡死 + if (typeof IntersectionObserver === 'undefined') { + setInView(true) + return + } + const observer = new IntersectionObserver( + entries => { + if (entries.some(entry => entry.isIntersecting)) { + setInView(true) + observer.disconnect() + } + }, + { rootMargin }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [inView, rootMargin]) + + return { ref, inView } +} diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts index b0e295d..3517c87 100644 --- a/frontend/src/i18n/locales/en/analysis.ts +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -21,6 +21,8 @@ export default { }, stats: { total: 'Total logs', + totalVolume: 'Volume (index)', + totalEntries: 'Entries (index)', errors: 'Error lines', warnings: 'Warning lines', apps: 'Active apps', @@ -53,6 +55,7 @@ export default { table: { title: 'App volume ranking', + computing: 'Computing per-app volume (reads log content, usually 20–30s)…', subtitle: 'Top 10 apps by volume; click "View logs" to jump to the query page', rank: '#', app: 'App', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 4d57f1f..3837dab 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -20,12 +20,15 @@ export default { }, stats: { total: '日志总量', + totalVolume: '日志体积(索引统计)', + totalEntries: '日志条数(索引统计)', errors: '错误行数', warnings: '告警行数', apps: '活跃应用', }, trend: { title: '日志量趋势', + subtitle: '按时间分桶的日志量(来自索引统计,秒级返回)', seriesTotal: '总量', seriesError: '错误', seriesWarn: '告警', @@ -33,21 +36,26 @@ export default { }, stream: { title: '应用日志量流图', + subtitle: '按量排前 {{count}} 个应用的堆叠分布', empty: '没有应用维度的数据', }, pie: { title: '命名空间分布', + subtitle: '各命名空间的日志量占比', empty: '没有命名空间维度的数据', other: '其他', }, daily: { fixedBadge: '固定近 7 天', - title: '应用每日日志量', + title: '每日日志量', + subtitle: '近 7 个自然日的每日总量(来自索引统计,不受上方时间范围影响)', empty: '近 7 天没有应用维度的数据', }, table: { title: '应用日志量排行', + subtitle: '按日志条数排前 10 的应用,点「查看日志」跳到查询页', + computing: '正在按应用统计(需读取日志内容,通常 20~30 秒)…', rank: '#', app: '应用', appWithKey: '应用({{key}})', diff --git a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts index d6591ed..a73308b 100644 --- a/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts +++ b/frontend/src/pages/analysis/hooks/useAnalysisMetrics.ts @@ -17,7 +17,15 @@ export interface AnalysisData { apps: AppRow[] /** 流图/趋势缩略的时间桶标签(本地时间 HH:mm) */ appTimes: string[] - stats: { total: number; errorCount: number; warnCount: number; appCount: number } + stats: { + /** 总字节数(来自 index/volume_range,毫秒级) */ + total: number + /** 总条数(来自 index/stats,毫秒级;比体积更直观) */ + totalEntries: number + errorCount: number + warnCount: number + appCount: number + } } export interface AnalysisParams { @@ -25,6 +33,11 @@ export interface AnalysisParams { start: string end: string namespace: string + /** + * 是否执行"按应用统计"的重查询(要读日志内容,20~30 秒)。 + * 分析页会在卡片滚动到视口内时再置 true,避免首屏一起等。 + */ + rankingEnabled?: boolean } const ns = (ms: number) => Math.round(ms).toString() + '000000' @@ -58,7 +71,7 @@ function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] { * 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。 * app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。 */ -export function useAnalysisMetrics({ sourceId, start, end, namespace }: AnalysisParams) { +export function useAnalysisMetrics({ sourceId, start, end, namespace, rankingEnabled = true }: AnalysisParams) { const [appLabel, setAppLabel] = useState('app') const [data, setData] = useState(null) const [loading, setLoading] = useState(false) @@ -132,10 +145,14 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis // 单条要 exec 29s + 排队,5 条串行下来两三分钟都出不来,页面就是"死在转圈"。 // /logs/histogram 内部用 index/volume_range,只读 TSDB 的 chunk 元数据, // 不解码日志内容,同一条件毫秒级返回(对比实测 ~1300 倍差距)。 - const hist = await logsApi.histogram( - { sourceId, query: selector, start: Number(ns(new Date(start).getTime())), end: Number(ns(new Date(end).getTime())) }, - { signal: controller.signal }, - ) + // histogram(体积曲线)与 stats(总条数)都是索引接口,一起发、都是毫秒级 + const startNs = Number(ns(new Date(start).getTime())) + const endNs = Number(ns(new Date(end).getTime())) + const [hist, idxStats] = await Promise.all([ + logsApi.histogram({ sourceId, query: selector, start: startNs, end: endNs }, { signal: controller.signal }), + logsApi.stats({ sourceId, query: selector, start: startNs, end: endNs }, { signal: controller.signal }) + .catch(() => null), + ]) const totalSeries: MetricSeries[] = [{ labels: {}, points: (hist || []).map(bucket => ({ @@ -154,6 +171,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis appTimes: prev?.appTimes ?? [], stats: { total: seriesTotal(totalSeries), + totalEntries: idxStats?.entries ?? 0, errorCount: prev?.stats.errorCount ?? 0, warnCount: prev?.stats.warnCount ?? 0, appCount: prev?.stats.appCount ?? 0, @@ -168,10 +186,19 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis // - 全局总量已由上面的 histogram(索引)提供,不用再查 // - 全局 error 由 errByApp 各序列求和得出,不用再查 // - 全局 warn 直接放弃(优先级最低,却要花 20 秒;趋势图里留空而不是假装 0) + // 排行卡片还没滚到视口内时,先不跑这两条重查询(各 20s+), + // 让上面的趋势/占比先出;卡片可见后 analysis 会重跑本 effect。 + if (!rankingEnabled) { + setLoading(false) + return + } + + // 这两条并行而不是串行:实测总量 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]))`), - ]) + ], 2) if (version !== versionRef.current) return // 由 by-app 序列反推全局 error / warn 曲线,省掉两条重查询 @@ -231,6 +258,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis appTimes: times.map(timeLabel), stats: { total: seriesTotal(totalSeries), + totalEntries: idxStats?.entries ?? 0, errorCount: seriesTotal(errorSeries), warnCount: seriesTotal(warnSeries), appCount: apps.length, @@ -246,7 +274,7 @@ export function useAnalysisMetrics({ sourceId, start, end, namespace }: Analysis })() return () => { controller.abort() } - }, [sourceId, start, end, namespace, appLabel, refreshTick]) + }, [sourceId, start, end, namespace, appLabel, refreshTick, rankingEnabled]) return { data, loading, error, appLabel, refresh } } diff --git a/frontend/src/pages/analysis/hooks/useDailyVolume.ts b/frontend/src/pages/analysis/hooks/useDailyVolume.ts index e293bcd..97d926e 100644 --- a/frontend/src/pages/analysis/hooks/useDailyVolume.ts +++ b/frontend/src/pages/analysis/hooks/useDailyVolume.ts @@ -24,10 +24,12 @@ const dayLabel = (ts: number) => new Date(ts).toLocaleDateString([], { month: '2 * 一条 `sum by (app, namespace)(count_over_time(sel[$step]))` 查询(7 天跨度后端自动选 1h 步长), * 客户端把小时桶按本地日历日聚合,避免为每天单独发查询。 */ -export function useDailyVolume({ sourceId, namespace, appLabel }: { +export function useDailyVolume({ sourceId, namespace, appLabel, enabled = true }: { sourceId: number namespace: string appLabel: string + /** 卡片滚动到视口内才发查询(索引接口很快,但没必要首屏就抢带宽) */ + enabled?: boolean }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(false) @@ -39,7 +41,7 @@ export function useDailyVolume({ sourceId, namespace, appLabel }: { const refresh = useCallback(() => setRefreshTick(tick => tick + 1), []) useEffect(() => { - if (!sourceId) return + if (!sourceId || !enabled) return const end = Date.now() // 从今天往前取整 7 个自然日(首尾两天可能是不完整天) const today = Math.floor(end / DAY_MS) * DAY_MS @@ -98,7 +100,7 @@ export function useDailyVolume({ sourceId, namespace, appLabel }: { })() return () => { controller.abort() } - }, [sourceId, namespace, appLabel, refreshTick]) + }, [sourceId, namespace, appLabel, refreshTick, enabled]) return { data, loading, error, refresh } } diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index c8bafd1..4ecc457 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -2,7 +2,7 @@ import { memo, useEffect, useState } from 'react' import { useNavigate } from 'react-router' import { useTranslation } from 'react-i18next' import '@/i18n/domains' -import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react' +import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -16,6 +16,7 @@ import { logsApi, type LogSource } from '@/api/logs' import { getUser } from '@/lib/auth-storage' import { useAnalysisMetrics } from './hooks/useAnalysisMetrics' import { useDailyVolume } from './hooks/useDailyVolume' +import { useInView } from '@/hooks/useInView' import { TrendChart } from './components/TrendChart' import { AppStreamChart } from './components/AppStreamChart' import { DailyVolumeChart } from './components/DailyVolumeChart' @@ -35,7 +36,19 @@ const ALL_NAMESPACES = '__all__' const AnalysisPage = memo(function AnalysisPage() { const { t } = useTranslation('analysis') const navigate = useNavigate() - const isAdmin = getUser()?.role === 'admin' + /** 索引接口返回的是字节数,直接 toLocaleString 会显示 7956179750 这种看不懂的数字 */ +const formatBytes = (bytes: number) => { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let value = bytes + let unit = 0 + while (value >= 1024 && unit < units.length - 1) { + value /= 1024 + unit++ + } + return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}` +} + +const isAdmin = getUser()?.role === 'admin' const [sources, setSources] = useState([]) const [sourceId, setSourceId] = useState(0) @@ -116,9 +129,25 @@ const AnalysisPage = memo(function AnalysisPage() { // 1) 避免首屏先用空 namespace 发一次 {namespace=~".+"} 的全集群查询(很贵) // 2) 避免"查了两次"——策略返回后 namespace 变化会再查一次,前一次白跑 const effectiveSourceId = namespaceReady ? sourceId : 0 - const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId: effectiveSourceId, start, end, namespace: scopedNamespace }) + // 分层加载:上面的总量/趋势/占比先出,下面"要读日志内容"的卡片 + // 只在滚动到视口附近时才发查询(每天量、应用排行、Ingress 分析)。 + const dailyView = useInView() + const rankingView = useInView() + const ingressView = useInView() + const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ + sourceId: effectiveSourceId, + start, + end, + namespace: scopedNamespace, + rankingEnabled: rankingView.inView, + }) // 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区 - const daily = useDailyVolume({ sourceId: effectiveSourceId, namespace: scopedNamespace, appLabel }) + const daily = useDailyVolume({ + sourceId: effectiveSourceId, + namespace: scopedNamespace, + appLabel, + enabled: dailyView.inView, + }) // 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底) if (!isAdmin) { @@ -194,10 +223,12 @@ const AnalysisPage = memo(function AnalysisPage() { ) : !data || !sourceId ? null : ( <> + {/* 不再显示"告警行数"卡片:为了把它从 3 条重查询降到 2 条, + 已放弃全局 warn 统计。显示一个恒为 0 的数字比不显示更误导。 */}
- + + -
@@ -226,7 +257,7 @@ const AnalysisPage = memo(function AnalysisPage() { - + }>
{t('daily.title')} @@ -245,22 +276,37 @@ const AnalysisPage = memo(function AnalysisPage() { - + }> {t('table.title')}

{t('table.subtitle')}

+ {/* 副标题说明这块不需要读日志内容,避免和上面的排行混淆 */}
- navigate(`/logs?q=${encodeURIComponent(query)}`)} - /> + {loading && !data.apps.length ? ( + // 这块必须按 app 分组统计(读日志内容),实测大集群 15 分钟约 20~30 秒。 + // 明确告诉用户在算什么、要等多久,比空表格或整页转圈好。 +
+ +

{t('table.computing')}

+
+ ) : ( + sum + app.total, 0)} + appLabel={appLabel} + onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)} + /> + )}
- + {/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */} +
}> + {ingressView.inView && } +
)}