From 2da0a2bece942b2235f3f226eed0d61dcdeb9fe6 Mon Sep 17 00:00:00 2001 From: opsx Date: Wed, 28 Oct 2026 22:43:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20Ingress=20=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8D=A1=20=E2=80=94=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E3=80=81=E6=97=A0=20ingress=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8A=98=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增常驻「Ingress 访问分析」卡:自动探测 ingress-nginx/ingress 命名空间并聚合访问日志 (PV/UV、4xx/5xx、慢请求、接口排行、异常/慢请求 Top10、请求明细),随数据源与时间范围刷新 - 从历史恢复 ingress 洞察表与聚合工具到 analysis 域(ingress-access-log 解析已在 lib) - 折叠交互:未检测到 ingress-nginx 时自动收起并显示徽章;手动切换记入 localStorage 优先生效 --- .../components/IngressAnalysisSection.tsx | 128 ++++++++++++++++++ .../components/IngressInsightsTable.tsx | 66 +++++++++ frontend/src/pages/analysis/index.tsx | 14 +- .../src/pages/analysis/ingress-insights.ts | 78 +++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/analysis/components/IngressAnalysisSection.tsx create mode 100644 frontend/src/pages/analysis/components/IngressInsightsTable.tsx create mode 100644 frontend/src/pages/analysis/ingress-insights.ts diff --git a/frontend/src/pages/analysis/components/IngressAnalysisSection.tsx b/frontend/src/pages/analysis/components/IngressAnalysisSection.tsx new file mode 100644 index 0000000..d6c849b --- /dev/null +++ b/frontend/src/pages/analysis/components/IngressAnalysisSection.tsx @@ -0,0 +1,128 @@ +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { logsApi, type LogEntry } from '@/api/logs' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { ErrorState } from '@/components/ErrorState' +import { parseIngressAccessLog } from '@/lib/ingress-access-log' +import { IngressInsightsTable } from './IngressInsightsTable' + +const INGRESS_NS_CANDIDATES = ['ingress-nginx', 'ingress'] +const COLLAPSE_KEY = 'lokistack.analysis.ingress-collapse' +const SAMPLE_LIMIT = 1000 +const ns = (ms: number) => Math.round(ms).toString() + '000000' + +/** + * 社区 ingress-nginx 访问日志分析(自动加载):探测 ingress 命名空间后自动拉取访问日志, + * 聚合出 PV/UV、状态码、慢请求与接口排行,无需手动查询;时间范围跟随页面筛选。 + */ +export const IngressAnalysisSection = memo(function IngressAnalysisSection({ sourceId, start, end }: { + sourceId: number + start: string + end: string +}) { + const { t } = useTranslation('analysis') + const [samples, setSamples] = useState>; timestamp: string }>>([]) + const [loadedCount, setLoadedCount] = useState(0) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [noIngress, setNoIngress] = useState(false) + const [refreshTick, setRefreshTick] = useState(0) + const version = useRef(0) + + const refresh = useCallback(() => setRefreshTick(tick => tick + 1), []) + + useEffect(() => { + if (!sourceId) return + const current = ++version.current + const from = new Date(start).getTime() + const to = new Date(end).getTime() + if (!(from < to)) return + + setLoading(true) + setError('') + setNoIngress(false) + void (async () => { + try { + // 探测实际存在的 ingress 命名空间 + const namespaces = await logsApi.labelValues(sourceId, 'namespace', undefined, Number(ns(from)), Number(ns(to))) + const ingressNs = INGRESS_NS_CANDIDATES.find(item => (Array.isArray(namespaces) ? namespaces : []).includes(item)) + if (current !== version.current) return + if (!ingressNs) { + setNoIngress(true) + setSamples([]) + setLoadedCount(0) + return + } + const result = await logsApi.query({ + sourceId, + query: `{namespace="${ingressNs}"} |= "HTTP/"`, + start: Number(ns(from)), + end: Number(ns(to)), + limit: SAMPLE_LIMIT, + direction: 'backward', + }) + if (current !== version.current) return + const rows: LogEntry[] = result.entries + setSamples(rows.flatMap(entry => { + const access = parseIngressAccessLog(entry.message) + return access ? [{ access, timestamp: entry.timestamp }] : [] + })) + setLoadedCount(rows.length) + } catch (err) { + if (current !== version.current) return + setError(err instanceof Error ? err.message : t('ingress.loadFailed')) + } finally { + if (current === version.current) setLoading(false) + } + })() + }, [sourceId, start, end, refreshTick, t]) + + // 折叠:未检测到 ingress-nginx 时自动收起;手动切换后以用户选择为准并记住 + const [manual, setManual] = useState(() => { + try { + const saved = localStorage.getItem(COLLAPSE_KEY) + return saved === null ? null : saved === '1' + } catch { return null } + }) + const collapsed = manual ?? (noIngress && !loading) + const toggle = () => { + setManual(!collapsed) + try { localStorage.setItem(COLLAPSE_KEY, collapsed ? '0' : '1') } catch { /* Storage may be unavailable. */ } + } + + return ( + + + + + {!collapsed && ( + + {error ? ( + + ) : noIngress ? ( +

{t('ingress.noIngress')}

+ ) : samples.length === 0 ? ( +

{loading ? t('ingress.loading') : t('ingress.empty')}

+ ) : ( + + )} +
+ )} +
+ ) +}) diff --git a/frontend/src/pages/analysis/components/IngressInsightsTable.tsx b/frontend/src/pages/analysis/components/IngressInsightsTable.tsx new file mode 100644 index 0000000..811e1d6 --- /dev/null +++ b/frontend/src/pages/analysis/components/IngressInsightsTable.tsx @@ -0,0 +1,66 @@ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { IngressSample, IngressRouteStats } from '../ingress-insights' +import { summarizeIngress } from '../ingress-insights' + +type Mode = 'traffic' | 'errors' | 'slow' | 'detail' +type ErrorClass = 'all' | '5xx' | '4xx' +const modeIds: Mode[] = ['traffic', 'errors', 'slow', 'detail'] +const modeLabelKeys: Record = { + traffic: 'ingress.modeTraffic', + errors: 'ingress.modeErrors', + slow: 'ingress.modeSlow', + detail: 'ingress.modeDetail', +} +const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap' +const cell = 'px-3 py-2 tabular-nums' +const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s` +const routeKey = (route: IngressRouteStats) => `${route.domain}\u0000${route.path}` + +function Route({ route }: { route: IngressRouteStats }) { + return <>{route.domain} + {route.path} +} + +export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) { + const { t } = useTranslation('logs') + const [mode, setMode] = useState('traffic') + const [errorClass, setErrorClass] = useState('all') + const [slowThreshold, setSlowThreshold] = useState(1) + const summary = useMemo(() => summarizeIngress(samples, slowThreshold), [samples, slowThreshold]) + const traffic = useMemo(() => [...summary.routes].sort((a, b) => b.pv - a.pv || b.error5xx - a.error5xx).slice(0, 10), [summary.routes]) + const errors = useMemo(() => [...summary.routes] + .filter(route => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx) + .sort((a, b) => { + const count = (route: IngressRouteStats) => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx + return count(b) - count(a) || b.error5xx - a.error5xx + }).slice(0, 10), [summary.routes, errorClass]) + const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals) + .sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes]) + const modes = useMemo(() => modeIds.map(id => ({ id, label: t(modeLabelKeys[id]) })), [t]) + + return
+

{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}

+
+ {[ + [t('ingress.statPv'), summary.pv], [t('ingress.statUv'), summary.uv], [t('ingress.statErrors'), summary.errors], [t('ingress.statSlow', { threshold: slowThreshold }), summary.slow], + ].map(([label, value]) =>
{label}
{value}
)} +
+
+
+ {modes.map(item => )} +
+ {mode === 'errors' && } + {mode === 'slow' && } +
+ {mode === 'slow' &&

{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}

} + {mode === 'traffic' &&

{t('ingress.trafficHint')}

} +
+ {mode === 'traffic' && {[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => )}{traffic.map(route => )}
{title}
{route.pv}{route.uv}{(route.success / route.pv * 100).toFixed(1)}%{route.error4xx}{route.error5xx}
} + {mode === 'errors' && {[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => )}{errors.map(route => )}
{title}
{route.error4xx}{route.error5xx}{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%{route.statusCodes}
} + {mode === 'slow' && {[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => )}{slow.map(route => )}
{title}
{route.slow}{formatSeconds(route.maxSeconds)}{formatSeconds(route.averageSeconds)}{route.timeoutSignals}
} + {mode === 'detail' && {[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => )}{samples.map(({ access, timestamp }, index) => )}
{title}
{new Date(timestamp).toLocaleString()}{access.domain || '—'}{access.method} {access.path}{access.clientIp || '—'}{access.status}{access.requestTime ? `${access.requestTime} s` : '—'}
} + {((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) &&

{t('ingress.empty')}

} +
+
+} diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx index ecd77a8..d7e4826 100644 --- a/frontend/src/pages/analysis/index.tsx +++ b/frontend/src/pages/analysis/index.tsx @@ -21,6 +21,7 @@ import { AppStreamChart } from './components/AppStreamChart' import { DailyVolumeChart } from './components/DailyVolumeChart' import { NamespacePie } from './components/NamespacePie' import { TopAppsTable } from './components/TopAppsTable' +import { IngressAnalysisSection } from './components/IngressAnalysisSection' const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) @@ -120,14 +121,14 @@ const AnalysisPage = memo(function AnalysisPage() {
- + {t('trend.title')}

{t('trend.subtitle')}

- + {t('pie.title')}

{t('pie.subtitle')}

@@ -136,7 +137,7 @@ const AnalysisPage = memo(function AnalysisPage() {
- + {t('stream.title')}

{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}

@@ -144,7 +145,7 @@ const AnalysisPage = memo(function AnalysisPage() {
- +
{t('daily.title')} {t('daily.fixedBadge')} @@ -153,7 +154,7 @@ const AnalysisPage = memo(function AnalysisPage() { {daily.error ? ( -

{daily.error}

+ ) : daily.loading && !daily.data ? ( ) : ( @@ -163,7 +164,7 @@ const AnalysisPage = memo(function AnalysisPage() { - + {t('table.title')}

{t('table.subtitle')}

@@ -177,6 +178,7 @@ const AnalysisPage = memo(function AnalysisPage() {
+ )} diff --git a/frontend/src/pages/analysis/ingress-insights.ts b/frontend/src/pages/analysis/ingress-insights.ts new file mode 100644 index 0000000..2c1e8be --- /dev/null +++ b/frontend/src/pages/analysis/ingress-insights.ts @@ -0,0 +1,78 @@ +import i18n from '@/i18n' +import type { IngressAccessLog } from '@/lib/ingress-access-log' +import { pathWithoutQuery } from '@/lib/ingress-access-log' + +export interface IngressSample { + access: IngressAccessLog + timestamp: string +} + +export interface IngressRouteStats { + domain: string + path: string + pv: number + uv: number + success: number + error4xx: number + error5xx: number + slow: number + timeoutSignals: number + averageSeconds: number | null + maxSeconds: number | null + statusCodes: string +} + +export function summarizeIngress(samples: IngressSample[], slowThreshold: number): { + routes: IngressRouteStats[] + pv: number + uv: number + errors: number + slow: number + timed: number +} { + const routes = new Map; seconds: number[]; statuses: Map + }>() + const allIps = new Set() + let errors = 0, slow = 0, timed = 0 + for (const { access } of samples) { + const domain = access.domain || i18n.t('ingress.noDomain', { ns: 'logs' }) + const path = pathWithoutQuery(access.path) + const key = `${domain}\u0000${path}` + let route = routes.get(key) + if (!route) { + route = { domain, path, pv: 0, success: 0, error4xx: 0, error5xx: 0, slow: 0, + timeoutSignals: 0, ips: new Set(), seconds: [], statuses: new Map() } + routes.set(key, route) + } + route.pv++ + if (access.clientIp && access.clientIp !== '-') { + route.ips.add(access.clientIp) + allIps.add(access.clientIp) + } + route.statuses.set(access.status, (route.statuses.get(access.status) ?? 0) + 1) + const status = Number(access.status) + if (status >= 200 && status < 400) route.success++ + if (status >= 400 && status < 500) { route.error4xx++; errors++ } + if (status >= 500 && status < 600) { route.error5xx++; errors++ } + if (status === 408 || status === 499 || status === 504) route.timeoutSignals++ + const seconds = Number(access.requestTime) + if (access.requestTime.trim() && Number.isFinite(seconds) && seconds >= 0) { + route.seconds.push(seconds) + timed++ + if (seconds >= slowThreshold) { route.slow++; slow++ } + } + } + return { + routes: [...routes.values()].map(route => ({ + domain: route.domain, path: route.path, pv: route.pv, uv: route.ips.size, + success: route.success, error4xx: route.error4xx, error5xx: route.error5xx, + slow: route.slow, timeoutSignals: route.timeoutSignals, + averageSeconds: route.seconds.length ? route.seconds.reduce((sum, value) => sum + value, 0) / route.seconds.length : null, + maxSeconds: route.seconds.length ? Math.max(...route.seconds) : null, + statusCodes: [...route.statuses].filter(([code]) => Number(code) >= 400).sort((a, b) => Number(a[0]) - Number(b[0])).map(([code, count]) => `${code}×${count}`).join(' · '), + })), + pv: samples.length, uv: allIps.size, errors, slow, timed, + } +}