feat(logs): 上下文前后条数可配置(默认 2,原来写死 5)+ 每页条数改预设选项

=== 一、问题 ===
查看日志上下文时,前后各取几条是**写死**在控制器里的:
  span := 5 * time.Minute
  QueryLokiLogs(..., ts-span, ts-1, 5, "backward")   // 5 条
  QueryLokiLogs(..., ts+1, ts+span, 5, "forward")    // 5 条
三处写死:条数 5、时间窗 5 分钟、且用户无法调整。

写死的三个实际弊端:
1. 排障时想看更多(比如前后 20 条找调用链)做不到;
2. 只想看紧邻 2 条时,仍然要扫 5 分钟窗口、返回 5 条,浪费;
3. 时间窗与条数不匹配——某服务日志稀疏时,5 分钟窗口里可能一条都没有,
   于是"前后各 5 条"实际返回 0 条。

=== 二、改动 ===
1. backend/dto: LogContextRequest 新增 before/after(可选)
2. backend/controllers: normalizeContextLines()
   - 不传或 ≤0 → 默认 2 条
   - 上限 50 条(防止一次弹窗拉几百条)
   - 时间窗改为**按条数自适应**:每条按 30s 估算,最少 1 分钟、最多 2 小时
     (不再固定 5 分钟,解决"稀疏服务取不到上下文"的问题)
3. frontend
   - 上下文弹窗标题栏加「前后各 [2/5/10/20/50]」选择器,切换后原地重新拉取
   - 选择结果写入 localStorage,下次沿用
   - 每页条数:数字输入框 → 预设下拉 [100/500/1000/2000/5000]
     (原来只能手输且硬顶 1000,现在上限提到 Loki 默认的 5000)
   - i18n:去掉"前后各最多 5 条 · 前后 5 分钟"的写死文案

=== 三、实测(平台 API)===
  before=2   after=2   → 2 / 2 条
  before=5   after=5   → 5 / 5 条
  before=10  after=10  → 10 / 10 条
  before=50  after=50  → 50 / 50 条
  before=999 after=999 → 50 / 50 条(超上限自动收敛)
  不传                 → 2 / 2 条(默认值生效)

镜像:lghost/lokistack:latest + v0.1 = sha256:d19239fa65f3
This commit is contained in:
opsx committed 2026-10-28 16:14:05 +00:00
1 parent 5d530036e6
commit 3191af8b6a
7 files changed
+96 -13

No files matched your search

@@ -1,6 +1,7 @@
import { Crosshair } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { LogEntry } from '@/api/logs'
import { HighlightedLogLine } from './HighlightedLogLine'
@@ -20,13 +21,31 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean;
</article>
}
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
export function LogContextDialog({ context, terms, lines, onLinesChange, onClose }: {
context: LogContext | null
terms: string[]
/** 前后各取几条(原来是写死的 5 条) */
lines: number
onLinesChange: (lines: number) => void
onClose: () => void
}) {
const { t } = useTranslation('logs')
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>{t('context.title')}</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')}</DialogDescription>
<DialogDescription className="flex flex-wrap items-center gap-2">
<span>{context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')}</span>
<span className="inline-flex items-center gap-1">
{t('context.linesLabel')}
<Select value={String(lines)} onValueChange={value => onLinesChange(Number(value))}>
<SelectTrigger className="h-6 w-16 px-2 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{[2, 5, 10, 20, 50].map(value => <SelectItem key={value} value={String(value)}>{value}</SelectItem>)}
</SelectContent>
</Select>
</span>
</DialogDescription>
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">{t('context.file', { name: context.row.labels.filename.split('/').slice(-2).join('/') })}</span>}