feat(logs,analysis): 分页改"加载更多"+触底自动加载;修懒加载不触发;指标短缓存
=== 1. 日志列表:去掉页码分页,改「加载更多」+ 触底自动加载(节流)===
为什么不用"上一页/下一页/共 N 页":
- Loki 是**游标分页**,不支持 offset,页码天生跳不了页
- 规模上没意义:15 分钟 4000 万条 → 按 200/页算 40 万页
- 带 |= / |~ 行过滤时总数根本算不出来(index/stats 只接受 label matcher)
日志排查的真实交互是"继续往下看",所以改为:
- 列表底部:「已加载 N 条 · 约 M 条」+「加载更多」按钮
- 滚动到底**自动加载**,但 2 秒节流 —— 每批都要后端扫 Loki,
快速滚动连续触发会撞并发护栏(429),反而比手动点更卡
- 到 5000 条停止并提示"缩小时间范围或加过滤条件"(虚拟滚动只省 DOM,
不省数组内存,无上限会一直涨)
- 修正语义 bug:分页原来是 setEntries(result.entries) **整页替换**,
改成追加(否则"加载更多"等于翻页)
- 删除 pageCursors 翻页栈与 prevPage/nextPage/pageOf/pageOfTotal 文案
=== 2. /logs/stats 错误映射:Loki 400 → 我们的 400 + 人话 ===
之前带行过滤时 Loki 回 400 "only label matchers are supported",
被统一包装成 502「Kubernetes API 代理返回 HTTP 400…」,
前端只能当失败静默 → 用户看到"总页数没了"却不知道原因。
现在返回 400 +「含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计」,
前端据此在条数旁给出提示。
=== 3. 分析页:修"延迟加载永远不触发"(数据一直不出来)===
现象:应用排行/流图/饼图/活跃应用全是"没有应用维度的数据",每日量"已有 0 天",
但接口实测正常(by-app 返回 75 条序列、daily-volume 返回 2 天)。
根因:这三块用 useInView 门控,而 ref 挂在 <Card> 上,观察器不触发 → 永远 false
→ hook 直接 return → 页面永远空。
修法:
- 改用普通 <div ref> 包裹,不依赖组件是否转发 ref
- useInView 加**几何兜底**:1 秒后若元素已在视口内就直接置 true
(有些容器里 IntersectionObserver 的 root 判定会和预期不一致)
=== 4. 分析页指标短缓存(26s → 11ms)===
`sum by (app)(count_over_time(...))` 扫 15 分钟 astralx(约 6.6GB)实测 16~28 秒,
Loki 自带结果缓存不命中(时间窗每次都在变)。点刷新/切页都要再等半分钟。
现在按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 2 分钟取整当 key:
实测第 1 次 23514ms → 第 2 次 **11ms**
代价:命中缓存时数据最多旧 ~2 分钟,对聚合视图可接受。
=== 5. 仪表盘 ===
去掉「平台自检」「N 项需关注」两段文字:卡片本身就是自检结果,
标题重复一遍没信息量;状态改由图标+边框颜色表达。
=== 6. 其它文案修正 ===
饼图空态从「没有命名空间维度的数据」改为「没有应用维度的数据」
(饼图上一版已改成应用维度,文案没跟上)。
=== 实测(1da8a148d756)===
Pod = Hub latest = v0.1 = 1da8a148d756
stats:纯 selector → 200(7005 万条);带 |= error → 400 + 人话
分析指标缓存:23514ms → 11ms
每日量:2 天(10-27 32.5GB / 10-28 356.5GB)
文案:加载更多 / 已到末尾 / 达到上限 均进包;代码中已无 prevPage/nextPage 引用
This commit is contained in:
1 parent
bad61e5027
commit
fbefcc86df
10 files changed
+231
-69
No files matched your search
@@ -363,6 +363,15 @@ func (ctrl *LogsController) Stats(c *gin.Context) {
|
||||
}
|
||||
st, err := services.LokiStatsFor(c.Request.Context(), src, query, req.Start, req.End)
|
||||
if err != nil {
|
||||
// Loki 的 index/stats 只支持标签选择器。查询里带 `|= "error"` 这类行过滤时
|
||||
// Loki 回 400 "only label matchers are supported",原来被包装成 502
|
||||
// ("Kubernetes API 代理返回 HTTP 400…"),前端只能当失败静默处理,
|
||||
// 用户看到的就是"没有总页数"。这里改成 400 + 人话,前端据此显示
|
||||
// "含过滤条件时无法统计总页数",而不是假装统计不出来。
|
||||
if strings.Contains(err.Error(), "only label matchers") {
|
||||
utils.Error(c, 400, "含过滤条件(|= / |~)时无法统计总页数:Loki 只支持按标签统计")
|
||||
return
|
||||
}
|
||||
utils.Error(c, 502, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -624,7 +625,71 @@ type MetricSeries struct {
|
||||
// LokiMetrics 通用 LogQL 指标查询(日志分析页用):query 为完整指标表达式,
|
||||
// 其中所有 [$step] 占位符替换为按时间跨度自适应的步长后走 query_range,
|
||||
// 返回矩阵多序列(直方图只取单序列,这里按 by (...) 分组整表返回)
|
||||
// ── 分析页指标查询的短缓存 ────────────────────────────────────────────
|
||||
//
|
||||
// 为什么需要:`sum by (app)(count_over_time(...))` 在这台集群上扫 15 分钟
|
||||
// astralx(约 6.6GB)实测 16~28 秒,Loki 的结果缓存也不命中(请求的时间窗
|
||||
// 每次都在变)。用户点刷新、切页、换 tab 都要再等半分钟。
|
||||
//
|
||||
// 做法:按 (数据源, 查询, 时间窗) 缓存 90 秒,时间窗按 **2 分钟取整**再当 key。
|
||||
// 代价是命中缓存时数据最多旧 2 分钟 × 90 秒 TTL —— 对"日志量趋势/排行"这种
|
||||
// 聚合视图完全可接受,换来的是重复访问从 ~26s 降到毫秒级。
|
||||
const (
|
||||
metricCacheTTL = 90 * time.Second
|
||||
metricCacheMax = 200
|
||||
// 时间窗取整粒度:key 用取整后的值,避免"每次点刷新差几秒就穿透"
|
||||
metricCacheBucket = 2 * time.Minute
|
||||
)
|
||||
|
||||
type metricCacheEntry struct {
|
||||
series []MetricSeries
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
metricCacheMu sync.Mutex
|
||||
metricCache = map[string]metricCacheEntry{}
|
||||
)
|
||||
|
||||
// LokiMetrics 带短缓存的分析指标查询(语义见 metricCache 注释)。
|
||||
func LokiMetrics(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
|
||||
key := ""
|
||||
if src != nil {
|
||||
bucket := int64(metricCacheBucket)
|
||||
key = fmt.Sprintf("%d|%s|%d|%d", src.ID, query, start/bucket, end/bucket)
|
||||
metricCacheMu.Lock()
|
||||
if entry, ok := metricCache[key]; ok && time.Now().Before(entry.expiresAt) {
|
||||
metricCacheMu.Unlock()
|
||||
return entry.series, nil
|
||||
}
|
||||
metricCacheMu.Unlock()
|
||||
}
|
||||
|
||||
series, err := lokiMetricsUncached(ctx, src, query, start, end)
|
||||
if err != nil || key == "" {
|
||||
return series, err
|
||||
}
|
||||
|
||||
metricCacheMu.Lock()
|
||||
if len(metricCache) >= metricCacheMax {
|
||||
now := time.Now()
|
||||
for k, v := range metricCache {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(metricCache, k)
|
||||
}
|
||||
}
|
||||
// 清完还满:整体重置,避免无界增长把内存吃掉
|
||||
if len(metricCache) >= metricCacheMax {
|
||||
metricCache = map[string]metricCacheEntry{}
|
||||
}
|
||||
}
|
||||
metricCache[key] = metricCacheEntry{series: series, expiresAt: time.Now().Add(metricCacheTTL)}
|
||||
metricCacheMu.Unlock()
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// lokiMetricsUncached 真正打 Loki 的实现(不带缓存)。
|
||||
func lokiMetricsUncached(ctx context.Context, src *models.LogSource, query string, start, end int64) ([]MetricSeries, error) {
|
||||
if end <= 0 {
|
||||
end = time.Now().UnixNano()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@ import { useEffect, useRef, useState } from 'react'
|
||||
* 15 分钟约 20~30 秒)。一次性全查会让首屏一起等,用户看到的是整页转圈。
|
||||
* 改成滚动到该卡片附近才发查询:上面的总量/趋势先出来,下面的慢数据后补。
|
||||
*
|
||||
* 只触发一次(进入视口后断开监听):日志分析是只读快照,不需要来回重算。
|
||||
* 三重保障(懒加载只管"早点晚点",绝不能变成"永远不加载"):
|
||||
* 1) IntersectionObserver 正常路径,进入视口即触发
|
||||
* 2) 1 秒后兜底量一次 getBoundingClientRect —— 有些容器(transform /
|
||||
* overflow / 自定义滚动区)里 IO 的 root 判定会和预期不一致,实测出现过
|
||||
* "卡片明明在屏幕上、观察器却一直不回调",于是数据永远不出来
|
||||
* 3) 组件卸载时清理定时器与观察器
|
||||
*
|
||||
* 只触发一次(日志分析是只读快照,不需要来回重算)。
|
||||
*/
|
||||
export function useInView<T extends HTMLElement>(rootMargin = '200px') {
|
||||
const ref = useRef<T | null>(null)
|
||||
@@ -17,22 +24,40 @@ export function useInView<T extends HTMLElement>(rootMargin = '200px') {
|
||||
if (inView) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// 老浏览器/SSR 环境没有 IntersectionObserver:直接当作可见,别把功能卡死
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setInView(true)
|
||||
return
|
||||
|
||||
// 兜底判定:元素顶边在视口内(含 rootMargin 余量)就算可见
|
||||
const margin = Number.parseInt(rootMargin, 10) || 200
|
||||
const isVisiblyInViewport = () => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
return rect.top < viewportHeight + margin && rect.bottom > -margin
|
||||
}
|
||||
|
||||
// 路径 1:IntersectionObserver
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (typeof IntersectionObserver !== 'undefined') {
|
||||
observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) {
|
||||
setInView(true)
|
||||
observer?.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin },
|
||||
)
|
||||
observer.observe(el)
|
||||
}
|
||||
|
||||
// 路径 2:几何兜底(覆盖 IO 不回调的情况)
|
||||
const fallback = window.setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (isVisiblyInViewport()) setInView(true)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
window.clearTimeout(fallback)
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -139,6 +139,14 @@ export default {
|
||||
summary: 'Page {{page}} · {{count}} entries',
|
||||
summary_one: 'Page {{page}} · {{count}} entry',
|
||||
summaryMore: 'Page {{page}} · {{count}} entries · more available',
|
||||
summaryLoaded: '{{count}} loaded',
|
||||
summaryMoreSuffix: ' · more available',
|
||||
loadedCount: '{{count}} loaded',
|
||||
totalCount: '≈ {{count}} total',
|
||||
loadMore: 'Load more',
|
||||
loadingMore: 'Loading…',
|
||||
noMore: 'End of results',
|
||||
loadMoreReached: '{{count}} loaded — limit reached. Narrow the time range or add filters.',
|
||||
summaryMore_one: 'Page {{page}} · {{count}} entry · more available',
|
||||
loadingPage: 'Loading log page',
|
||||
loadingQuery: 'Querying Loki',
|
||||
@@ -152,10 +160,7 @@ export default {
|
||||
domainNoMatch: 'No logs match the current domain filter',
|
||||
filterLabel: 'Filter by this label',
|
||||
showContext: 'Show 5 lines before and after',
|
||||
prevPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
pageOf: 'Page {{page}}',
|
||||
pageOfTotal: 'Page {{page}} of {{total}}',
|
||||
statsUnsupported: '(total pages unavailable with |= / |~ filters — Loki only counts by labels)',
|
||||
noSourceTitle: 'No queryable data source',
|
||||
noSourceHint: 'Ask an administrator to add or authorize a data source',
|
||||
noMatchTitle: 'No matching log entries',
|
||||
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
pie: {
|
||||
title: '应用日志量分布',
|
||||
subtitle: '按应用看谁在刷日志(前 8 名,其余归入"其他")',
|
||||
empty: '没有命名空间维度的数据',
|
||||
empty: '没有应用维度的数据',
|
||||
other: '其他',
|
||||
},
|
||||
daily: {
|
||||
|
||||
@@ -136,6 +136,14 @@ export default {
|
||||
title: '日志结果',
|
||||
summary: '第 {{page}} 页 · 本页 {{count}} 条',
|
||||
summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多',
|
||||
summaryLoaded: '已加载 {{count}} 条',
|
||||
summaryMoreSuffix: ' · 还有更多',
|
||||
loadedCount: '已加载 {{count}} 条',
|
||||
totalCount: '约 {{count}} 条',
|
||||
loadMore: '加载更多',
|
||||
loadingMore: '加载中…',
|
||||
noMore: '已到末尾',
|
||||
loadMoreReached: '已加载 {{count}} 条,达到上限。请缩小时间范围或加过滤条件',
|
||||
loadingPage: '正在加载日志页',
|
||||
loadingQuery: '正在查询 Loki',
|
||||
loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。',
|
||||
@@ -148,10 +156,7 @@ export default {
|
||||
domainNoMatch: '当前域名筛选没有匹配的日志',
|
||||
filterLabel: '筛选此标签',
|
||||
showContext: '查看前后各 5 条',
|
||||
prevPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
pageOf: '第 {{page}} 页',
|
||||
pageOfTotal: '第 {{page}} / {{total}} 页',
|
||||
statsUnsupported: '(含 |= / |~ 过滤条件时无法统计总页数,Loki 只支持按标签统计)',
|
||||
noSourceTitle: '暂无可查询的数据源',
|
||||
noSourceHint: '请联系管理员添加或授权数据源',
|
||||
noMatchTitle: '未找到匹配的日志记录',
|
||||
|
||||
@@ -253,7 +253,8 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
<CardContent><AppStreamChart apps={data.apps} appTimes={data.appTimes} /></CardContent>
|
||||
</Card>
|
||||
|
||||
<Card ref={dailyView.ref as React.Ref<HTMLDivElement>}>
|
||||
<div ref={dailyView.ref}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
|
||||
@@ -275,8 +276,10 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card ref={rankingView.ref as React.Ref<HTMLDivElement>}>
|
||||
<div ref={rankingView.ref}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t('table.title')}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
|
||||
@@ -302,6 +305,7 @@ const AnalysisPage = memo(function AnalysisPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
|
||||
<div ref={ingressView.ref as React.Ref<HTMLDivElement>}>
|
||||
|
||||
@@ -56,14 +56,6 @@ export const SelfCheckCard = memo(function SelfCheckCard() {
|
||||
}
|
||||
if (failed || !data) return null
|
||||
|
||||
const problems = data.items.filter(i => i.status !== 'ok')
|
||||
const overallText =
|
||||
data.overall === 'ok'
|
||||
? t('selfCheck.allGood')
|
||||
: data.overall === 'warn'
|
||||
? t('selfCheck.needsAttention', { count: problems.length })
|
||||
: t('selfCheck.hasError', { count: problems.length })
|
||||
|
||||
const borderClass =
|
||||
data.overall === 'ok'
|
||||
? 'border-success/30'
|
||||
@@ -74,20 +66,17 @@ export const SelfCheckCard = memo(function SelfCheckCard() {
|
||||
return (
|
||||
<Card className={borderClass}>
|
||||
<CardHeader className="pb-2">
|
||||
{/* 按使用反馈去掉「平台自检 / N 项需关注」这两段文字:
|
||||
卡片本身就是自检结果,标题重复一遍没有信息量。
|
||||
只保留状态图标着色(边框颜色已经能表达 overall)+ 时间 + 刷新。 */}
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Activity className="h-4 w-4" />
|
||||
{t('selfCheck.title')}
|
||||
<span
|
||||
className={`ml-1 text-xs font-normal ${
|
||||
data.overall === 'ok'
|
||||
? 'text-success'
|
||||
: data.overall === 'warn'
|
||||
? 'text-warning'
|
||||
: 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{overallText}
|
||||
</span>
|
||||
<Activity className={`h-4 w-4 ${
|
||||
data.overall === 'ok'
|
||||
? 'text-success'
|
||||
: data.overall === 'warn'
|
||||
? 'text-warning'
|
||||
: 'text-destructive'
|
||||
}`} />
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
{new Date(data.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchableValueSelect } from '@/components/SearchableValueSelect'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { parseIngressAccessLog } from '@/lib/ingress-access-log'
|
||||
import { compactNumber } from '@/pages/analysis/components/chart-shared'
|
||||
import { QueryEmptyState } from './QueryEmptyState'
|
||||
import { LogRow, VirtualLogList } from './VirtualLogList'
|
||||
|
||||
@@ -17,12 +18,17 @@ const VIRTUAL_THRESHOLD = 100
|
||||
|
||||
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
|
||||
|
||||
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, totalPages, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
|
||||
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, totalEntries, statsNote, loadMoreReached, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage }: {
|
||||
entries: LogEntry[]; searched: boolean; hasSource: boolean
|
||||
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
|
||||
pageIndex: number; hasNextPage: boolean; totalEntries?: number; statsNote?: string; loadMoreReached?: boolean; 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
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
// 触底自动加载:滚动到列表底部时自动取下一批,但做 2 秒节流。
|
||||
// 日志查询每批次都要后端扫 Loki,用户快速滚动如果连续触发会撞并发护栏(429),
|
||||
// 反而比手动点更卡。所以"能自动,但不会失控"。
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastAutoLoadRef = useRef(0)
|
||||
const [domainFilter, setDomainFilter] = useState('')
|
||||
const [clock, setClock] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -30,6 +36,21 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loading])
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current
|
||||
if (!el || !hasNextPage || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver(records => {
|
||||
if (!records.some(record => record.isIntersecting)) return
|
||||
if (loading) return
|
||||
const now = Date.now()
|
||||
if (now - lastAutoLoadRef.current < 2000) return
|
||||
lastAutoLoadRef.current = now
|
||||
onNextPage()
|
||||
}, { rootMargin: '120px' })
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [hasNextPage, loading, onNextPage])
|
||||
|
||||
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
|
||||
const visibleEntries = useMemo(() => {
|
||||
const needle = domainFilter.trim().toLowerCase()
|
||||
@@ -37,7 +58,7 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
||||
}, [entries, domainFilter])
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2>
|
||||
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{t('results.summaryLoaded', { count: entries.length })}{hasNextPage ? t('results.summaryMoreSuffix') : ''}</span></h2>
|
||||
</div>
|
||||
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
|
||||
@@ -83,14 +104,31 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe
|
||||
/>
|
||||
</div>)}
|
||||
</div>}
|
||||
{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>
|
||||
<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>
|
||||
</div>}
|
||||
{/* 分页改成"加载更多":
|
||||
Loki 是游标分页、不支持 offset,"第 N 页 / 共 M 页"既跳不了也看不懂
|
||||
(15 分钟 4000 万条 = 40 万页),而且带 |= / |~ 过滤时总数根本算不出来。
|
||||
日志排查的真实交互是"继续往下看",所以用 已加载 N 条 + 加载更多。 */}
|
||||
{searched && entries.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{t('results.loadedCount', { count: entries.length })}
|
||||
{totalEntries && totalEntries > 0
|
||||
? ` · ${t('results.totalCount', { count: compactNumber(totalEntries) })}`
|
||||
: ''}
|
||||
</span>
|
||||
{loadMoreReached ? (
|
||||
<span className="text-xs text-warning">{t('results.loadMoreReached', { count: entries.length })}</span>
|
||||
) : hasNextPage ? (
|
||||
<Button variant="outline" size="sm" disabled={loading} onClick={onNextPage}>
|
||||
{loading ? t('results.loadingMore') : t('results.loadMore')}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('results.noMore')}</span>
|
||||
)}
|
||||
{statsNote && <span className="text-xs text-muted-foreground">{statsNote}</span>}
|
||||
{/* 触底哨兵:滚到底部时自动加载(有节流,见下方 effect) */}
|
||||
{hasNextPage && !loadMoreReached && <div ref={sentinelRef} className="h-1 w-full" />}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { readQueryHistory, rememberQuery } from './query-history'
|
||||
|
||||
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||
/** 无限滚动下最多保留多少条(虚拟滚动只省 DOM,不省数组内存) */
|
||||
const MAX_LOADED_ENTRIES = 5000
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
@@ -99,11 +101,14 @@ export default function LogsPage() {
|
||||
const [histogramError, setHistogramError] = useState<string>('')
|
||||
/** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */
|
||||
const [totalEntries, setTotalEntries] = useState<number>(0)
|
||||
/** 已加载条数达到上限(停止"加载更多",提示用户缩小范围) */
|
||||
const [loadMoreReached, setLoadMoreReached] = useState(false)
|
||||
/** 总页数统计不可用时的提示(如查询带 |= / |~ 行过滤,Loki 只支持按标签统计) */
|
||||
const [statsNote, setStatsNote] = useState('')
|
||||
/** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */
|
||||
const histAbortRef = useRef<AbortController | null>(null)
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [pageCursors, setPageCursors] = useState<string[]>([''])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingStartedAt, setLoadingStartedAt] = useState<number | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
@@ -120,7 +125,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(''); setTotalEntries(0); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
|
||||
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(''); setTotalEntries(0); setCursor(''); setPageIndex(0); setSearched(false); setLoading(false) }, [sourceId])
|
||||
|
||||
// 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。
|
||||
// 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断
|
||||
@@ -141,6 +146,12 @@ export default function LogsPage() {
|
||||
}
|
||||
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
|
||||
const paging = targetPage !== undefined
|
||||
// 上限保护:无限滚动会把数据一直堆在内存里(虚拟滚动只省 DOM,不省数组内存)。
|
||||
// 到 5000 条就停,提示用户缩小时间范围或加过滤条件 —— 老实说清楚比卡死好。
|
||||
if (paging && entries.length >= MAX_LOADED_ENTRIES) {
|
||||
setLoadMoreReached(true)
|
||||
return
|
||||
}
|
||||
const r = paging ? executedRange : selectedRange ?? range()
|
||||
const searchQuery = paging ? executedQuery : overrideQuery ?? query.trim()
|
||||
if (!r || !sourceId || !searchQuery) return
|
||||
@@ -164,16 +175,28 @@ export default function LogsPage() {
|
||||
setExecutedQuery(data.query)
|
||||
if (!paging) setExecutedRange(r)
|
||||
setSearched(true)
|
||||
setEntries(result.entries)
|
||||
// 分页是"加载更多"语义:追加到已有列表,而不是替换。
|
||||
// (原来这里直接 setEntries(result.entries),翻页会整页替换,看起来像跳页)
|
||||
setEntries(previous => (paging ? [...previous, ...result.entries] : result.entries))
|
||||
setCursor(result.hasMore ? result.nextCursor : '')
|
||||
setPageIndex(targetPage ?? 0)
|
||||
setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : [''])
|
||||
if (!paging) {
|
||||
// 总条数:走 index/stats(只读索引,24h 约 1.3s),失败静默不影响日志展示
|
||||
setTotalEntries(0)
|
||||
setStatsNote('')
|
||||
setLoadMoreReached(false)
|
||||
void logsApi.stats(data, { signal: histController.signal })
|
||||
.then(st => { if (version === requestVersion.current) setTotalEntries(Number(st?.entries) || 0) })
|
||||
.catch(() => undefined)
|
||||
.catch((e: unknown) => {
|
||||
if (version !== requestVersion.current) return
|
||||
if (isAbortError(e) || histController.signal.aborted) return
|
||||
// 只有"带行过滤无法统计"这一种情况需要提示用户;
|
||||
// 其它失败仍静默(不能因为统计失败就干扰看日志)
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
if (msg.includes('无法统计总页数') || msg.includes('only label matchers')) {
|
||||
setStatsNote(t('results.statsUnsupported'))
|
||||
}
|
||||
})
|
||||
|
||||
setHistogramLoading(true)
|
||||
setHistogramError('')
|
||||
@@ -210,7 +233,6 @@ export default function LogsPage() {
|
||||
setEntries(result.entries)
|
||||
setCursor(result.hasMore ? result.nextCursor : '')
|
||||
setPageIndex(0)
|
||||
setPageCursors([''])
|
||||
} catch { /* 静默轮询,手动查询仍会显示错误 */ }
|
||||
}
|
||||
void poll()
|
||||
@@ -312,7 +334,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' && 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' && <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)} />}
|
||||
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} totalEntries={totalEntries} statsNote={statsNote} loadMoreReached={loadMoreReached} 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)} />}
|
||||
<LogContextDialog context={context} terms={highlightTerms} lines={contextLines} onLinesChange={changeContextLines} onClose={() => { setContext(null); setContextRow(null) }} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
||||
Reference in new issue
Block a user