feat(analysis): Ingress 访问分析卡 — 自动加载、无 ingress 自动折叠

- 新增常驻「Ingress 访问分析」卡:自动探测 ingress-nginx/ingress 命名空间并聚合访问日志
  (PV/UV、4xx/5xx、慢请求、接口排行、异常/慢请求 Top10、请求明细),随数据源与时间范围刷新
- 从历史恢复 ingress 洞察表与聚合工具到 analysis 域(ingress-access-log 解析已在 lib)
- 折叠交互:未检测到 ingress-nginx 时自动收起并显示徽章;手动切换记入 localStorage 优先生效
This commit is contained in:
opsx committed 2026-10-28 22:43:57 +08:00
1 parent 16306a9532
commit 2da0a2bece
4 files changed
+280 -6

No files matched your search

@@ -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<Array<{ access: NonNullable<ReturnType<typeof parseIngressAccessLog>>; 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<boolean | null>(() => {
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 (
<Card className="border-border">
<CardHeader className="pb-3">
<button
type="button"
aria-expanded={!collapsed}
aria-label={t('ingress.toggleAria')}
onClick={toggle}
className="flex w-full items-center justify-between gap-2 text-left"
>
<span className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{t('ingress.title')}</CardTitle>
{noIngress && !loading && (
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{t('ingress.notDetected')}</span>
)}
</span>
{collapsed ? <ChevronRight className="size-4 shrink-0 text-muted-foreground" /> : <ChevronDown className="size-4 shrink-0 text-muted-foreground" />}
</button>
</CardHeader>
{!collapsed && (
<CardContent>
{error ? (
<ErrorState message={error} onRetry={refresh} />
) : noIngress ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t('ingress.noIngress')}</p>
) : samples.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{loading ? t('ingress.loading') : t('ingress.empty')}</p>
) : (
<IngressInsightsTable samples={samples} loadedCount={loadedCount} />
)}
</CardContent>
)}
</Card>
)
})
@@ -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<Mode, string> = {
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 <><td className={`${cell} max-w-48 truncate`} title={route.domain}>{route.domain}</td>
<td className={`${cell} max-w-96 truncate font-mono`} title={route.path}>{route.path}</td></>
}
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
const { t } = useTranslation('logs')
const [mode, setMode] = useState<Mode>('traffic')
const [errorClass, setErrorClass] = useState<ErrorClass>('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 <div className="space-y-3">
<p className="text-xs text-muted-foreground">{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
[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]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label={t('ingress.modesAria')} className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
</div>
{mode === 'errors' && <select aria-label={t('ingress.errorClassAria')} value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">{t('ingress.errorAll')}</option><option value="5xx">{t('ingress.error5xx')}</option><option value="4xx">{t('ingress.error4xx')}</option></select>}
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">{t('ingress.slowThreshold')} <select aria-label={t('ingress.slowThreshold')} value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>{t('ingress.seconds', { count: 0.5 })}</option><option value={1}>{t('ingress.seconds', { count: 1 })}</option><option value={3}>{t('ingress.seconds', { count: 3 })}</option><option value={5}>{t('ingress.seconds', { count: 5 })}</option><option value={10}>{t('ingress.seconds', { count: 10 })}</option></select></label>}
</div>
{mode === 'slow' && <p className="text-xs text-muted-foreground">{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}</p>}
{mode === 'traffic' && <p className="text-xs text-muted-foreground">{t('ingress.trafficHint')}</p>}
<div className="max-h-[60vh] overflow-auto rounded-lg border">
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">{t('ingress.empty')}</p>}
</div>
</div>
}
+8 -6
View File
@@ -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() {
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('trend.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('trend.subtitle')}</p>
</CardHeader>
<CardContent><TrendChart trend={data.trend} /></CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('pie.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('pie.subtitle')}</p>
</CardHeader>
@@ -136,7 +137,7 @@ const AnalysisPage = memo(function AnalysisPage() {
</div>
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('stream.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}</p>
</CardHeader>
@@ -144,7 +145,7 @@ const AnalysisPage = memo(function AnalysisPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{t('daily.fixedBadge')}</span>
@@ -153,7 +154,7 @@ const AnalysisPage = memo(function AnalysisPage() {
</CardHeader>
<CardContent>
{daily.error ? (
<p className="py-6 text-center text-xs text-destructive">{daily.error}</p>
<ErrorState message={daily.error} onRetry={daily.refresh} />
) : daily.loading && !daily.data ? (
<LoadingSpinner className="h-40" />
) : (
@@ -163,7 +164,7 @@ const AnalysisPage = memo(function AnalysisPage() {
</Card>
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('table.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
</CardHeader>
@@ -177,6 +178,7 @@ const AnalysisPage = memo(function AnalysisPage() {
</CardContent>
</Card>
<IngressAnalysisSection sourceId={sourceId} start={start} end={end} />
</>
)}
</PageContainer>
@@ -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<string, {
domain: string; path: string; pv: number; success: number; error4xx: number; error5xx: number
slow: number; timeoutSignals: number; ips: Set<string>; seconds: number[]; statuses: Map<string, number>
}>()
const allIps = new Set<string>()
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,
}
}