diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index cfc25ba..1472358 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -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 } diff --git a/backend/services/log_query.go b/backend/services/log_query.go index dde1e84..3886a0b 100644 --- a/backend/services/log_query.go +++ b/backend/services/log_query.go @@ -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() } diff --git a/frontend/src/hooks/useInView.ts b/frontend/src/hooks/useInView.ts index 5b13baf..adccf14 100644 --- a/frontend/src/hooks/useInView.ts +++ b/frontend/src/hooks/useInView.ts @@ -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(rootMargin = '200px') { const ref = useRef(null) @@ -17,22 +24,40 @@ export function useInView(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 } diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts index 6d72126..9091721 100644 --- a/frontend/src/i18n/locales/en/logs.ts +++ b/frontend/src/i18n/locales/en/logs.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts index 1e1c54e..a217656 100644 --- a/frontend/src/i18n/locales/zh/analysis.ts +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -46,7 +46,7 @@ export default { pie: { title: '应用日志量分布', subtitle: '按应用看谁在刷日志(前 8 名,其余归入"其他")', - empty: '没有命名空间维度的数据', + empty: '没有应用维度的数据', other: '其他', }, daily: { diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts index c3005bb..ff23d3b 100644 --- a/frontend/src/i18n/locales/zh/logs.ts +++ b/frontend/src/i18n/locales/zh/logs.ts @@ -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: '未找到匹配的日志记录', diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index 20dc2df..5b37f8f 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -253,7 +253,8 @@ const AnalysisPage = memo(function AnalysisPage() { - }> +
+
{t('daily.title')} @@ -275,8 +276,10 @@ const AnalysisPage = memo(function AnalysisPage() { )} +
- }> +
+ {t('table.title')}

{t('table.subtitle')}

@@ -302,6 +305,7 @@ const AnalysisPage = memo(function AnalysisPage() { )}
+
{/* Ingress 分析要按 host/status 做多次聚组查询,最后加载 */}
}> diff --git a/frontend/src/pages/dashboard/components/SelfCheckCard.tsx b/frontend/src/pages/dashboard/components/SelfCheckCard.tsx index ba2ca45..f322894 100644 --- a/frontend/src/pages/dashboard/components/SelfCheckCard.tsx +++ b/frontend/src/pages/dashboard/components/SelfCheckCard.tsx @@ -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 ( + {/* 按使用反馈去掉「平台自检 / N 项需关注」这两段文字: + 卡片本身就是自检结果,标题重复一遍没有信息量。 + 只保留状态图标着色(边框颜色已经能表达 overall)+ 时间 + 刷新。 */} - - {t('selfCheck.title')} - - {overallText} - + {new Date(data.checkedAt).toLocaleTimeString()} diff --git a/frontend/src/pages/logs/components/LogResultsPanel.tsx b/frontend/src/pages/logs/components/LogResultsPanel.tsx index 6591d61..83a9b94 100644 --- a/frontend/src/pages/logs/components/LogResultsPanel.tsx +++ b/frontend/src/pages/logs/components/LogResultsPanel.tsx @@ -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(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
-

{t('results.title')} {hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}

+

{t('results.title')} {t('results.summaryLoaded', { count: entries.length })}{hasNextPage ? t('results.summaryMoreSuffix') : ''}

{loading &&
@@ -83,14 +104,31 @@ export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNe />
)}
} - {searched && entries.length > 0 &&
- - - {totalPages && totalPages > 1 - ? t('results.pageOfTotal', { page: pageIndex + 1, total: totalPages }) - : t('results.pageOf', { page: pageIndex + 1 })} - - -
} + {/* 分页改成"加载更多": + Loki 是游标分页、不支持 offset,"第 N 页 / 共 M 页"既跳不了也看不懂 + (15 分钟 4000 万条 = 40 万页),而且带 |= / |~ 过滤时总数根本算不出来。 + 日志排查的真实交互是"继续往下看",所以用 已加载 N 条 + 加载更多。 */} + {searched && entries.length > 0 && ( +
+ + {t('results.loadedCount', { count: entries.length })} + {totalEntries && totalEntries > 0 + ? ` · ${t('results.totalCount', { count: compactNumber(totalEntries) })}` + : ''} + + {loadMoreReached ? ( + {t('results.loadMoreReached', { count: entries.length })} + ) : hasNextPage ? ( + + ) : ( + {t('results.noMore')} + )} + {statsNote && {statsNote}} + {/* 触底哨兵:滚到底部时自动加载(有节流,见下方 effect) */} + {hasNextPage && !loadMoreReached &&
} +
+ )} } diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index f9f68d8..9ae8616 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -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('') /** 当前查询的总条数(来自 Loki index/stats,只读索引),用于展示总页数 */ const [totalEntries, setTotalEntries] = useState(0) + /** 已加载条数达到上限(停止"加载更多",提示用户缩小范围) */ + const [loadMoreReached, setLoadMoreReached] = useState(false) + /** 总页数统计不可用时的提示(如查询带 |= / |~ 行过滤,Loki 只支持按标签统计) */ + const [statsNote, setStatsNote] = useState('') /** 当前在跑的查询/统计控制器:发起新请求前 abort 掉,避免重查询叠加 */ const histAbortRef = useRef(null) const [cursor, setCursor] = useState('') const [pageIndex, setPageIndex] = useState(0) - const [pageCursors, setPageCursors] = useState(['']) const [loading, setLoading] = useState(false) const [loadingStartedAt, setLoadingStartedAt] = useState(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 &&
{t('volume.statsLoading')}
} {tab === 'query' && histogramError &&

{histogramError}

} {tab === 'query' && buckets.length > 0 && } - {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)} />} + {tab === 'query' && 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)} />} { setContext(null); setContextRow(null) }} />