feat(logs): 分页显示总页数(新增 /logs/stats,走 index/stats 只读索引)
用户反馈:底部只有「上一页 第 1 页 下一页」,看不到末尾/总页数。
原因:日志结果是 Loki 游标分页(cursor = 纳秒时间戳),本身不返回总数,
前端只能知道"有没有下一页",算不出总页数。
解法:新增 POST /api/v1/logs/stats
→ 调 Loki /loki/api/v1/index/stats(只读 TSDB 索引里的 chunk 元数据)
→ 返回 {streams, chunks, bytes, entries}
→ 前端用 entries / limit 得到总页数
实测成本极低:1h 51ms、24h 30ms(3.28 亿条 / 44.2 GB)。
改动:
1. backend: services.LokiStatsFor + controllers.LogsController.Stats + 路由 /logs/stats
2. frontend: logsApi.stats;查询成功后拉取总数(复用同一 AbortController,
失败静默不影响日志展示);计算 totalPages 传给结果面板
3. LogResultsPanel: 有总数时显示「第 N / M 页」,否则维持「第 N 页」
4. i18n: 新增 results.pageOfTotal(中英)
镜像:lghost/lokistack:latest + v0.1 = sha256:41d6576ec232
This commit is contained in:
1 parent
de376a1284
commit
5d530036e6
8 files changed
+88
-5
No files matched your search
@@ -297,6 +297,31 @@ func (ctrl *LogsController) Health(c *gin.Context) {
|
|||||||
utils.Success(c, gin.H{"ready": services.LokiReady(ctx, src)})
|
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) {
|
func (ctrl *LogsController) Histogram(c *gin.Context) {
|
||||||
var req dto.LogQueryRequest
|
var req dto.LogQueryRequest
|
||||||
if !utils.BindAndValidate(c, &req) {
|
if !utils.BindAndValidate(c, &req) {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ func SetupRouter() *gin.Engine {
|
|||||||
logs.GET("/label/:name/values", logsCtrl.LabelValues)
|
logs.GET("/label/:name/values", logsCtrl.LabelValues)
|
||||||
logs.GET("/my-scope", logsCtrl.MyScope)
|
logs.GET("/my-scope", logsCtrl.MyScope)
|
||||||
logs.POST("/histogram", logsCtrl.Histogram)
|
logs.POST("/histogram", logsCtrl.Histogram)
|
||||||
|
// 总量统计(index/stats):只读索引,用于算总页数
|
||||||
|
logs.POST("/stats", logsCtrl.Stats)
|
||||||
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
logs.POST("/metrics", middleware.AdminAuth(), logsCtrl.Metrics)
|
||||||
logs.GET("/health", logsCtrl.Health)
|
logs.GET("/health", logsCtrl.Health)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,6 +544,44 @@ func lokiHistogramByCount(ctx context.Context, src *models.LogSource, query, ste
|
|||||||
return buckets, nil
|
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 指标序列上的一个采样点
|
// MetricSeriesPoint 指标序列上的一个采样点
|
||||||
type MetricSeriesPoint struct {
|
type MetricSeriesPoint struct {
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const logsApi = {
|
|||||||
query: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
query: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
||||||
http.post<LogResult>('/logs/query', data, { timeout: 60000, ...opts }),
|
http.post<LogResult>('/logs/query', data, { timeout: 60000, ...opts }),
|
||||||
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
|
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => 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 超时,前端超时略大于它 */
|
/** 日志量统计:后端有 7 天范围上限与 30s 超时,前端超时略大于它 */
|
||||||
histogram: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
histogram: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
||||||
http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 40000, ...opts }),
|
http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 40000, ...opts }),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export default {
|
|||||||
prevPage: 'Previous',
|
prevPage: 'Previous',
|
||||||
nextPage: 'Next',
|
nextPage: 'Next',
|
||||||
pageOf: 'Page {{page}}',
|
pageOf: 'Page {{page}}',
|
||||||
|
pageOfTotal: 'Page {{page}} of {{total}}',
|
||||||
noSourceTitle: 'No queryable data source',
|
noSourceTitle: 'No queryable data source',
|
||||||
noSourceHint: 'Ask an administrator to add or authorize a data source',
|
noSourceHint: 'Ask an administrator to add or authorize a data source',
|
||||||
noMatchTitle: 'No matching log entries',
|
noMatchTitle: 'No matching log entries',
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default {
|
|||||||
prevPage: '上一页',
|
prevPage: '上一页',
|
||||||
nextPage: '下一页',
|
nextPage: '下一页',
|
||||||
pageOf: '第 {{page}} 页',
|
pageOf: '第 {{page}} 页',
|
||||||
|
pageOfTotal: '第 {{page}} / {{total}} 页',
|
||||||
noSourceTitle: '暂无可查询的数据源',
|
noSourceTitle: '暂无可查询的数据源',
|
||||||
noSourceHint: '请联系管理员添加或授权数据源',
|
noSourceHint: '请联系管理员添加或授权数据源',
|
||||||
noMatchTitle: '未找到匹配的日志记录',
|
noMatchTitle: '未找到匹配的日志记录',
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ function levelBadgeClass(level: string) {
|
|||||||
|
|
||||||
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
|
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(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
|
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
|
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('logs')
|
const { t } = useTranslation('logs')
|
||||||
@@ -80,7 +80,11 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
|||||||
</div>}
|
</div>}
|
||||||
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
|
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
|
||||||
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
|
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
|
||||||
<span className="text-xs tabular-nums text-muted-foreground">{t('results.pageOf', { page: pageIndex + 1 })}</span>
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
|
{totalPages && totalPages > 1
|
||||||
|
? t('results.pageOfTotal', { page: pageIndex + 1, total: totalPages })
|
||||||
|
: t('results.pageOf', { page: pageIndex + 1 })}
|
||||||
|
</span>
|
||||||
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>{t('results.nextPage')}</Button>
|
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>{t('results.nextPage')}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export default function LogsPage() {
|
|||||||
// 从 boolean 改为字符串:原来只显示"日志量统计失败",
|
// 从 boolean 改为字符串:原来只显示"日志量统计失败",
|
||||||
// 真实原因(429 并发超限 / 400 范围过大 / Loki 不可用)全被吞掉。
|
// 真实原因(429 并发超限 / 400 范围过大 / Loki 不可用)全被吞掉。
|
||||||
const [histogramError, setHistogramError] = useState<string>('')
|
const [histogramError, setHistogramError] = useState<string>('')
|
||||||
|
/** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */
|
||||||
|
const [totalEntries, setTotalEntries] = useState<number>(0)
|
||||||
/** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */
|
/** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */
|
||||||
const histAbortRef = useRef<AbortController | null>(null)
|
const histAbortRef = useRef<AbortController | null>(null)
|
||||||
const [cursor, setCursor] = useState('')
|
const [cursor, setCursor] = useState('')
|
||||||
@@ -99,7 +101,7 @@ export default function LogsPage() {
|
|||||||
} catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) }
|
} catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) }
|
||||||
}, [t])
|
}, [t])
|
||||||
useEffect(() => { void reloadSources() }, [reloadSources])
|
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= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。
|
// 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。
|
||||||
// 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断
|
// 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断
|
||||||
@@ -148,6 +150,12 @@ export default function LogsPage() {
|
|||||||
setPageIndex(targetPage ?? 0)
|
setPageIndex(targetPage ?? 0)
|
||||||
setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : [''])
|
setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : [''])
|
||||||
if (!paging) {
|
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)
|
setHistogramLoading(true)
|
||||||
setHistogramError('')
|
setHistogramError('')
|
||||||
void logsApi.histogram(data, { signal: histController.signal }).then(histogram => {
|
void logsApi.histogram(data, { signal: histController.signal }).then(histogram => {
|
||||||
@@ -259,7 +267,7 @@ export default function LogsPage() {
|
|||||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
|
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
|
||||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{histogramError}</p>}
|
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{histogramError}</p>}
|
||||||
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
||||||
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} searched={searched} hasSource={sources.length > 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' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} searched={searched} hasSource={sources.length > 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)} />}
|
||||||
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
|
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
Reference in new issue
Block a user