diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index d2de8a0..bf85d0b 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -297,6 +297,31 @@ func (ctrl *LogsController) Health(c *gin.Context) { utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)}) } +// Stats 返回该查询在时间范围内的「总条数/字节/块数」,用于前端展示总页数。 +// 走 index/stats(只读索引),成本远低于真数日志。 +func (ctrl *LogsController) Stats(c *gin.Context) { + var req dto.LogQueryRequest + if !utils.BindAndValidate(c, &req) { + return + } + src := services.GetLogSource(req.SourceID) + if src == nil { + utils.BadRequest(c, "数据源不存在") + return + } + query, err := services.AuthorizedLogQuery(src, c.GetUint("userId"), c.GetString("role") == "admin", req.Query) + if err != nil { + utils.Forbidden(c, err.Error()) + return + } + st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End) + if err != nil { + utils.Error(c, 502, err.Error()) + return + } + utils.Success(c, st) +} + func (ctrl *LogsController) Histogram(c *gin.Context) { var req dto.LogQueryRequest if !utils.BindAndValidate(c, &req) { diff --git a/backend/routers/router.go b/backend/routers/router.go index 651b5d2..20789c3 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -91,6 +91,8 @@ func SetupRouter() *gin.Engine { logs.GET("/label/:name/values", logsCtrl.LabelValues) logs.GET("/my-scope", logsCtrl.MyScope) logs.POST("/histogram", logsCtrl.Histogram) + // 总量统计(index/stats):只读索引,用于算总页数 + logs.POST("/stats", logsCtrl.Stats) logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics) logs.GET("/health", logsCtrl.Health) } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index 4749738..2c4d16f 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -544,6 +544,44 @@ func lokiHistogramByCount(ctx context.Context, src *models.LogSource, query, ste return buckets, nil } +// LokiStats 用官方 /loki/api/v1/index/stats 取「条数/字节/块数」。 +// +// 为什么不数日志条数:index/stats 只读 TSDB 索引里的 chunk 元数据, +// 不解码日志内容。实测同一 app 24 小时:count_over_time 53s 失败, +// index/stats 1.3s。用它来算「共 N 条 / 共 M 页」成本可以忽略。 +type LokiStats struct { + Streams int64 `json:"streams"` + Chunks int64 `json:"chunks"` + Bytes int64 `json:"bytes"` + Entries int64 `json:"entries"` +} + +func LokiStatsFor(ctx context.Context, src *models.LogSource, query string, start, end int64) (*LokiStats, error) { + if end <= 0 { + end = time.Now().UnixNano() + } + if start <= 0 { + start = end - int64(time.Hour) + } + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + q := url.Values{} + q.Set("query", query) + q.Set("start", strconv.FormatInt(start, 10)) + q.Set("end", strconv.FormatInt(end, 10)) + + body, _, err := lokiProxyGET(ctx, src, "/loki/api/v1/index/stats", q) + if err != nil { + return nil, err + } + var st LokiStats + if err := json.Unmarshal(body, &st); err != nil { + return nil, err + } + return &st, nil +} + // MetricSeriesPoint 指标序列上的一个采样点 type MetricSeriesPoint struct { Timestamp string `json:"timestamp"` diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index 995becb..d8e23c7 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -30,6 +30,10 @@ export const logsApi = { query: (data: LogQuery, opts?: { signal?: AbortSignal }) => http.post('/logs/query', data, { timeout: 60000, ...opts }), context: (data: { sourceId: number; timestampNs: string; labels: Record }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data), + /** 总量统计(Loki index/stats,只读索引):用于计算总页数 */ + stats: (data: LogQuery, opts?: { signal?: AbortSignal }) => + http.post<{ streams: number; chunks: number; bytes: number; entries: number }>( + '/logs/stats', data, { timeout: 25000, ...opts }), /** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */ histogram: (data: LogQuery, opts?: { signal?: AbortSignal }) => http.post>>('/logs/histogram', data, { timeout: 40000, ...opts }), diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts index cad28a5..a39d6b6 100644 --- a/frontend/src/i18n/locales/en/logs.ts +++ b/frontend/src/i18n/locales/en/logs.ts @@ -152,6 +152,7 @@ export default { prevPage: 'Previous', nextPage: 'Next', pageOf: 'Page {{page}}', + pageOfTotal: 'Page {{page}} of {{total}}', noSourceTitle: 'No queryable data source', noSourceHint: 'Ask an administrator to add or authorize a data source', noMatchTitle: 'No matching log entries', diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts index 8852fbc..ad17df6 100644 --- a/frontend/src/i18n/locales/zh/logs.ts +++ b/frontend/src/i18n/locales/zh/logs.ts @@ -148,6 +148,7 @@ export default { prevPage: '上一页', nextPage: '下一页', pageOf: '第 {{page}} 页', + pageOfTotal: '第 {{page}} / {{total}} 页', noSourceTitle: '暂无可查询的数据源', noSourceHint: '请联系管理员添加或授权数据源', noMatchTitle: '未找到匹配的日志记录', diff --git a/frontend/src/pages/logs/components/LogResultsPanel.tsx b/frontend/src/pages/logs/components/LogResultsPanel.tsx index bd45ea1..98ab653 100644 --- a/frontend/src/pages/logs/components/LogResultsPanel.tsx +++ b/frontend/src/pages/logs/components/LogResultsPanel.tsx @@ -22,9 +22,9 @@ function levelBadgeClass(level: string) { // 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection) -export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: { +export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, totalPages, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: { entries: LogEntry[]; searched: boolean; hasSource: boolean - pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[] + pageIndex: number; hasNextPage: boolean; totalPages?: number; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[] onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void }) { const { t } = useTranslation('logs') @@ -80,7 +80,11 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe } {searched && entries.length > 0 &&
- {t('results.pageOf', { page: pageIndex + 1 })} + + {totalPages && totalPages > 1 + ? t('results.pageOfTotal', { page: pageIndex + 1, total: totalPages }) + : t('results.pageOf', { page: pageIndex + 1 })} +
} diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index f4ddb23..03a0b7f 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -78,6 +78,8 @@ export default function LogsPage() { // 从 boolean 改为字符串:原来只显示"日志量统计失败", // 真实原因(429 并发超限 / 400 范围过大 / Loki 不可用)全被吞掉。 const [histogramError, setHistogramError] = useState('') + /** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */ + const [totalEntries, setTotalEntries] = useState(0) /** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */ const histAbortRef = useRef(null) const [cursor, setCursor] = useState('') @@ -99,7 +101,7 @@ export default function LogsPage() { } catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) } }, [t]) useEffect(() => { void reloadSources() }, [reloadSources]) - useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(''); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId]) + useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(''); setTotalEntries(0); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId]) // 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。 // 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断 @@ -148,6 +150,12 @@ export default function LogsPage() { setPageIndex(targetPage ?? 0) setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : ['']) if (!paging) { + // 总条数:走 index/stats(只读索引,24h 约 1.3s),失败静默不影响日志展示 + setTotalEntries(0) + void logsApi.stats(data, { signal: histController.signal }) + .then(st => { if (version === requestVersion.current) setTotalEntries(Number(st?.entries) || 0) }) + .catch(() => undefined) + setHistogramLoading(true) setHistogramError('') void logsApi.histogram(data, { signal: histController.signal }).then(histogram => { @@ -259,7 +267,7 @@ export default function LogsPage() { {tab === 'query' && histogramLoading &&
{t('volume.statsLoading')}
} {tab === 'query' && histogramError &&

{histogramError}

} {tab === 'query' && buckets.length > 0 && } - {tab === 'query' && 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />} + {tab === 'query' && 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} totalPages={totalEntries > 0 ? Math.max(1, Math.ceil(totalEntries / Math.max(1, limit))) : undefined} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />} setContext(null)} />