diff --git a/backend/controllers/logs.go b/backend/controllers/logs.go index bf85d0b..7539718 100644 --- a/backend/controllers/logs.go +++ b/backend/controllers/logs.go @@ -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 diff --git a/backend/dto/logs.go b/backend/dto/logs.go index 0291952..272d800 100644 --- a/backend/dto/logs.go +++ b/backend/dto/logs.go @@ -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"` } diff --git a/frontend/src/api/logs/index.ts b/frontend/src/api/logs/index.ts index d8e23c7..ebe85fb 100644 --- a/frontend/src/api/logs/index.ts +++ b/frontend/src/api/logs/index.ts @@ -29,7 +29,9 @@ export const logsApi = { // 否则旧请求会在后端继续占用 Loki CPU(历史上把实例拖到 OOM/不 Ready)。 query: (data: LogQuery, opts?: { signal?: AbortSignal }) => http.post('/logs/query', data, { timeout: 60000, ...opts }), - context: (data: { sourceId: number; timestampNs: string; labels: Record }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data), + /** 日志上下文:before/after 为前后各取几条(后端默认 2、上限 50,原来写死 5) */ + context: (data: { sourceId: number; timestampNs: string; labels: Record; 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 }>( diff --git a/frontend/src/i18n/locales/en/logs.ts b/frontend/src/i18n/locales/en/logs.ts index a39d6b6..c09308a 100644 --- a/frontend/src/i18n/locales/en/logs.ts +++ b/frontend/src/i18n/locales/en/logs.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/logs.ts b/frontend/src/i18n/locales/zh/logs.ts index ad17df6..58a2e97 100644 --- a/frontend/src/i18n/locales/zh/logs.ts +++ b/frontend/src/i18n/locales/zh/logs.ts @@ -196,9 +196,10 @@ export default { }, context: { title: '同一日志流的前后文', + linesLabel: '前后各', sameFile: '同一文件', sameStream: '同一日志流', - meta: '前后各最多 5 条 · 前后 5 分钟', + meta: '同一条日志流,按时间顺序排列', file: '文件: {{name}}', current: '当前日志', currentAria: '当前日志', diff --git a/frontend/src/pages/logs/components/LogContextDialog.tsx b/frontend/src/pages/logs/components/LogContextDialog.tsx index f41bdac..4312dc5 100644 --- a/frontend/src/pages/logs/components/LogContextDialog.tsx +++ b/frontend/src/pages/logs/components/LogContextDialog.tsx @@ -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; } -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 { if (!open) onClose() }}> {t('context.title')} - {context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')} + + {context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')} + + {t('context.linesLabel')} + + + {context &&
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => {key}={context.row.labels[key]})} {context.row.labels.filename && {t('context.file', { name: context.row.labels.filename.split('/').slice(-2).join('/') })}} diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index 03a0b7f..3ea23fe 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -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(() => { + const saved = Number(localStorage.getItem('logContextLines')) + return saved > 0 && saved <= 50 ? saved : 2 + }) + /** 记住当前查看上下文的那一行,切换条数时原地重新拉取 */ + const [contextRow, setContextRow] = useState(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() { { setStart(nextStart); setEnd(nextEnd); setTail(false) }} /> - +
LogQL @@ -268,7 +293,7 @@ export default function LogsPage() { {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)} />} - setContext(null)} /> + { setContext(null); setContextRow(null) }} />