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

+30 -5
View File
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { PageContainer } from '@/components/PageContainer'
import { TimeRangePicker } from '@/components/TimeRangePicker'
import { ErrorState } from '@/components/ErrorState'
@@ -199,12 +198,31 @@ export default function LogsPage() {
return () => window.clearInterval(timer)
}, [tail, sourceId, query, limit])
async function showContext(row: LogEntry) {
/** 上下文前后条数:默认 2 条(原来写死 5),选择后记住,弹窗内可随时改 */
const [contextLines, setContextLines] = useState<number>(() => {
const saved = Number(localStorage.getItem('logContextLines'))
return saved > 0 && saved <= 50 ? saved : 2
})
/** 记住当前查看上下文的那一行,切换条数时原地重新拉取 */
const [contextRow, setContextRow] = useState<LogEntry | null>(null)
async function showContext(row: LogEntry, lines = contextLines) {
setContextRow(row)
try {
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
const result = await logsApi.context({
sourceId, timestampNs: row.timestampNs, labels: row.labels,
before: lines, after: lines,
})
setContext({ row, before: result.before.slice().reverse(), after: result.after })
} catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) }
}
/** 改前后条数:持久化 + 若弹窗开着就原地刷新 */
const changeContextLines = (lines: number) => {
setContextLines(lines)
try { localStorage.setItem('logContextLines', String(lines)) } catch { /* 隐私模式忽略 */ }
if (contextRow) void showContext(contextRow, lines)
}
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
const applyDomainFilter = (domain: string) => {
const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}`
@@ -251,7 +269,14 @@ export default function LogsPage() {
</SelectContent>
</Select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}
<Select value={String(limit)} onValueChange={value => setLimit(Number(value))}>
<SelectTrigger className="h-9 w-24" aria-label={t('toolbar.limitLabel')}><SelectValue /></SelectTrigger>
<SelectContent>
{[100, 500, 1000, 2000, 5000].map(value => <SelectItem key={value} value={String(value)}>{value}</SelectItem>)}
</SelectContent>
</Select>
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
@@ -268,7 +293,7 @@ export default function LogsPage() {
{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)} />}
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
<LogContextDialog context={context} terms={highlightTerms} lines={contextLines} onLinesChange={changeContextLines} onClose={() => { setContext(null); setContextRow(null) }} />
</div>
</PageContainer>
</div>