From 3191af8b6aa76bb48ea585b02dadac97d52b6e2f Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 28 Oct 2026 16:14:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(logs):=20=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E5=89=8D=E5=90=8E=E6=9D=A1=E6=95=B0=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=88=E9=BB=98=E8=AE=A4=202=EF=BC=8C=E5=8E=9F=E6=9D=A5?= =?UTF-8?q?=E5=86=99=E6=AD=BB=205=EF=BC=89+=20=E6=AF=8F=E9=A1=B5=E6=9D=A1?= =?UTF-8?q?=E6=95=B0=E6=94=B9=E9=A2=84=E8=AE=BE=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit === 一、问题 === 查看日志上下文时,前后各取几条是**写死**在控制器里的: 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 --- backend/controllers/logs.go | 37 +++++++++++++++++-- backend/dto/logs.go | 4 ++ frontend/src/api/logs/index.ts | 4 +- frontend/src/i18n/locales/en/logs.ts | 3 +- frontend/src/i18n/locales/zh/logs.ts | 3 +- .../logs/components/LogContextDialog.tsx | 23 +++++++++++- frontend/src/pages/logs/index.tsx | 35 +++++++++++++++--- 7 files changed, 96 insertions(+), 13 deletions(-) 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) }} />