用户反馈:底部只有「上一页 第 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
92 lines
6.3 KiB
TypeScript
92 lines
6.3 KiB
TypeScript
import { useEffect, useMemo, 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 { applyLabelFilter } from '../query-utils'
|
|
import { HighlightedLogLine } from './HighlightedLogLine'
|
|
import { QueryEmptyState } from './QueryEmptyState'
|
|
|
|
function levelBadgeClass(level: string) {
|
|
const value = (level || '').toLowerCase()
|
|
if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') {
|
|
return 'rounded bg-destructive/15 px-1 text-destructive'
|
|
}
|
|
if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning'
|
|
if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info'
|
|
if (value.includes('debug') || value.includes('trace')) return 'rounded bg-muted px-1 text-muted-foreground'
|
|
return 'rounded bg-muted px-1 text-muted-foreground'
|
|
}
|
|
|
|
// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
|
|
|
|
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; 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')
|
|
const [domainFilter, setDomainFilter] = useState('')
|
|
const [clock, setClock] = useState(0)
|
|
useEffect(() => {
|
|
if (!loading) return
|
|
const timer = window.setInterval(() => setClock(Date.now()), 1000)
|
|
return () => window.clearInterval(timer)
|
|
}, [loading])
|
|
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()
|
|
return needle ? entries.filter(entry => parseIngressAccessLog(entry.message)?.domain.includes(needle)) : entries
|
|
}, [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>
|
|
</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" />
|
|
<span>{entries.length ? t('results.loadingPage') : t('results.loadingQuery')} · {t('results.loadingWait', { seconds: clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0 })}</span>
|
|
</div>}
|
|
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">{t('results.pagingHint')}</p>}
|
|
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
|
<span className="shrink-0 text-muted-foreground">{t('results.domainFilter')}</span>
|
|
<SearchableValueSelect
|
|
hideLabel
|
|
ariaLabel={t('results.domainFilter')}
|
|
values={domains}
|
|
value={domainFilter}
|
|
onChange={setDomainFilter}
|
|
allowEmpty
|
|
emptyLabel={t('results.domainAll')}
|
|
commitOnType
|
|
placeholder={t('results.domainPlaceholder')}
|
|
className="min-w-60"
|
|
triggerClassName="h-8"
|
|
/>
|
|
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
|
|
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
|
|
</div>}
|
|
{entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : <div className="divide-y">
|
|
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
|
|
<span className={levelBadgeClass(row.level)}>{row.level}</span>
|
|
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
|
|
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button>
|
|
</div>
|
|
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
|
|
</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>}
|
|
</section>
|
|
}
|