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:
1 parent
5d530036e6
commit
3191af8b6a
7 files changed
+96
-13
No files matched your search
@@ -16,6 +16,24 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 上下文前后条数的默认值与上限(原来是硬编码 5)
|
||||
const (
|
||||
DefaultContextLines = 2
|
||||
MaxContextLines = 50
|
||||
)
|
||||
|
||||
// normalizeContextLines 把前端传入的条数收敛到 [1, MaxContextLines],
|
||||
// 不传(0)或负数一律用默认值。
|
||||
func normalizeContextLines(n int) int {
|
||||
if n <= 0 {
|
||||
return DefaultContextLines
|
||||
}
|
||||
if n > MaxContextLines {
|
||||
return MaxContextLines
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (ctrl *LogsController) Context(c *gin.Context) {
|
||||
var req dto.LogContextRequest
|
||||
if !utils.BindAndValidate(c, &req) {
|
||||
@@ -41,13 +59,26 @@ func (ctrl *LogsController) Context(c *gin.Context) {
|
||||
utils.Forbidden(c, err.Error())
|
||||
return
|
||||
}
|
||||
span := int64(5 * time.Minute)
|
||||
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, 5, "backward", "")
|
||||
// 前后条数由前端传入(默认 2 条,上限 50 —— 再多一次弹窗就是几百条日志)
|
||||
beforeN := normalizeContextLines(req.Before)
|
||||
afterN := normalizeContextLines(req.After)
|
||||
|
||||
// 时间窗随条数自适应:原来固定 5 分钟,条数调大时 5 分钟可能装不下,
|
||||
// 条数调小时又白扫一大片。按「每条 30s」估算,最少 1 分钟,最多 2 小时。
|
||||
span := int64(time.Duration(max(beforeN, afterN)*30) * time.Second)
|
||||
if span < int64(time.Minute) {
|
||||
span = int64(time.Minute)
|
||||
}
|
||||
if span > int64(2*time.Hour) {
|
||||
span = int64(2 * time.Hour)
|
||||
}
|
||||
|
||||
before, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts-span, ts-1, beforeN, "backward", "")
|
||||
if err != nil {
|
||||
utils.Error(c, 502, err.Error())
|
||||
return
|
||||
}
|
||||
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, 5, "forward", "")
|
||||
after, err := services.QueryLokiLogs(c.Request.Context(), src, query, ts+1, ts+span, afterN, "forward", "")
|
||||
if err != nil {
|
||||
utils.Error(c, 502, err.Error())
|
||||
return
|
||||
|
||||
@@ -45,4 +45,8 @@ type LogContextRequest struct {
|
||||
SourceID uint `json:"sourceId" binding:"required"`
|
||||
TimestampNs string `json:"timestampNs" binding:"required"`
|
||||
Labels map[string]string `json:"labels" binding:"required"`
|
||||
// 上下文前后各取几条(可选)。不传按 DefaultContextLines,
|
||||
// 上限 MaxContextLines —— 原来前后各 5 条是写死的,不能调。
|
||||
Before int `json:"before"`
|
||||
After int `json:"after"`
|
||||
}
|
||||
@@ -29,7 +29,9 @@ export const logsApi = {
|
||||
// 否则旧请求会在后端继续占用 Loki CPU(历史上把实例拖到 OOM/不 Ready)。
|
||||
query: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
||||
http.post<LogResult>('/logs/query', data, { timeout: 60000, ...opts }),
|
||||
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
|
||||
/** 日志上下文:before/after 为前后各取几条(后端默认 2、上限 50,原来写死 5) */
|
||||
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string>; before?: number; after?: number }) =>
|
||||
http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
|
||||
/** 总量统计(Loki index/stats,只读索引):用于计算总页数 */
|
||||
stats: (data: LogQuery, opts?: { signal?: AbortSignal }) =>
|
||||
http.post<{ streams: number; chunks: number; bytes: number; entries: number }>(
|
||||
|
||||
@@ -200,9 +200,10 @@ export default {
|
||||
},
|
||||
context: {
|
||||
title: 'Context of the same log stream',
|
||||
linesLabel: 'Lines:',
|
||||
sameFile: 'Same file',
|
||||
sameStream: 'Same log stream',
|
||||
meta: 'Up to 5 entries before and after · ±5 minutes',
|
||||
meta: 'Same log stream, in time order',
|
||||
file: 'File: {{name}}',
|
||||
current: 'Current',
|
||||
currentAria: 'Current log',
|
||||
|
||||
@@ -196,9 +196,10 @@ export default {
|
||||
},
|
||||
context: {
|
||||
title: '同一日志流的前后文',
|
||||
linesLabel: '前后各',
|
||||
sameFile: '同一文件',
|
||||
sameStream: '同一日志流',
|
||||
meta: '前后各最多 5 条 · 前后 5 分钟',
|
||||
meta: '同一条日志流,按时间顺序排列',
|
||||
file: '文件: {{name}}',
|
||||
current: '当前日志',
|
||||
currentAria: '当前日志',
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user