fix(frontend): 补齐 analysis 构建依赖与共享组件搬迁
补 commit hooks/图表组件、路由与 i18n domains、logs metrics API; StatCard/TimeRangePicker/ingress-access-log 抽到共享路径,干净 checkout 可编过。
This commit is contained in:
1 parent
f993d5b420
commit
11d0fff764
22 files changed
+552
-313
No files matched your search
@@ -1,66 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
|
||||
import { IngressInsightsTable } from './IngressInsightsTable'
|
||||
|
||||
export type ResultView = 'raw' | 'table' | 'line' | 'stream' | 'pie'
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
const colors = ['hsl(var(--info))', 'hsl(var(--success))', 'hsl(var(--warning))', 'hsl(var(--primary))', 'hsl(var(--destructive))', 'hsl(var(--muted-foreground))']
|
||||
|
||||
function EmptyChart({ message }: { message: string }) {
|
||||
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
|
||||
}
|
||||
|
||||
export function LogResultViews({ view, entries, buckets, start, end, domainFiltered = false }: {
|
||||
view: Exclude<ResultView, 'raw'>
|
||||
entries: LogEntry[]
|
||||
buckets: Bucket[]
|
||||
start: string
|
||||
end: string
|
||||
domainFiltered?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
// 聚合占位名(无域名/其他域名等):同时用作数据列名与图例展示,统一从词典取,保证语言切换后聚合与展示一致
|
||||
const noDomain = t('views.noDomain')
|
||||
const otherDomain = t('views.otherDomain')
|
||||
const unknownName = t('views.unknown')
|
||||
const untaggedName = t('views.untagged')
|
||||
const otherName = t('views.other')
|
||||
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
|
||||
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
|
||||
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
|
||||
const parsedRows = useMemo(() => entries.map(entry => parseIngressAccessLog(entry.message)), [entries])
|
||||
const ingressCount = useMemo(() => parsedRows.filter(Boolean).length, [parsedRows])
|
||||
const ingressSamples = useMemo(() => entries.flatMap((entry, index) => {
|
||||
const access = parsedRows[index]
|
||||
return access ? [{ access, timestamp: entry.timestamp }] : []
|
||||
}), [entries, parsedRows])
|
||||
const streamDomains = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of parsedRows) if (row) {
|
||||
const domain = row.domain || noDomain
|
||||
counts.set(domain, (counts.get(domain) ?? 0) + 1)
|
||||
}
|
||||
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
|
||||
}, [parsedRows, noDomain])
|
||||
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
|
||||
const lineData = useMemo(() => {
|
||||
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
|
||||
const from = new Date(start).getTime(), to = new Date(end).getTime()
|
||||
if (!(from < to)) return []
|
||||
const steps = 16
|
||||
const counts: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: 0 }))
|
||||
for (const [rowIndex, entry] of entries.entries()) {
|
||||
const index = Math.floor((new Date(entry.timestamp).getTime() - from) / (to - from) * steps)
|
||||
if (index < 0 || index >= steps) continue
|
||||
if (lineBy === 'domain' && ingressCount) {
|
||||
const row = parsedRows[rowIndex]
|
||||
if (!row) continue
|
||||
const domain = row.domain || noDomain
|
||||
const key = streamDomains.includes(domain) ? domain : otherDomain
|
||||
counts[index][key] = Number(counts[index][key] ?? 0) + 1
|
||||
} else counts[index].count = Number(counts[index].count) + 1
|
||||
}
|
||||
return counts
|
||||
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains, noDomain, otherDomain])
|
||||
const streamData = useMemo(() => {
|
||||
const from = new Date(start).getTime(), to = new Date(end).getTime()
|
||||
if (!(from < to)) return []
|
||||
const steps = 16
|
||||
const rows: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), error: 0, warn: 0, info: 0, other: 0, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 }))
|
||||
for (const [rowIndex, entry] of entries.entries()) {
|
||||
const time = new Date(entry.timestamp).getTime()
|
||||
const index = Math.floor((time - from) / (to - from) * steps)
|
||||
if (index < 0 || index >= steps) continue
|
||||
const ingress = parsedRows[rowIndex]
|
||||
if (ingressCount) {
|
||||
if (ingress) {
|
||||
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || noDomain) ? ingress.domain || noDomain : otherDomain) : httpStatusClass(ingress.status)
|
||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
const level = entry.level.toLowerCase()
|
||||
const key = level === 'error' || level === 'warn' || level === 'info' ? level : 'other'
|
||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
||||
}
|
||||
return rows
|
||||
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end, noDomain, otherDomain])
|
||||
const pieData = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const ingress = parsedRows[index]
|
||||
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
|
||||
const name = effectiveGroupBy === 'domain' ? ingress?.domain || noDomain
|
||||
: effectiveGroupBy === 'status' ? ingress?.status || unknownName
|
||||
: effectiveGroupBy === 'method' ? ingress?.method || unknownName
|
||||
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
|
||||
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || untaggedName
|
||||
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
|
||||
: entry.labels[effectiveGroupBy] || untaggedName
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...counts].sort((a, b) => b[1] - a[1])
|
||||
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
|
||||
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
|
||||
if (other) top.push({ name: otherName, count: other })
|
||||
return top
|
||||
}, [entries, parsedRows, effectiveGroupBy, noDomain, unknownName, untaggedName, otherName])
|
||||
|
||||
if (view === 'table') return ingressCount > 0
|
||||
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
|
||||
: <div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t('views.tableLoaded', { count: entries.length })}</p>
|
||||
{entries.length === 0 ? <EmptyChart message={t('views.tableEmpty')} /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('views.thTime'), t('views.thNsPod'), t('views.thLevel'), t('views.thLog')].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
|
||||
</div>
|
||||
|
||||
if (view === 'line') return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? t('views.lineByLoaded') : t('views.lineByRange')}</p>{ingressCount > 0 && <select aria-label={t('views.lineGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="total">{t('views.lineByTotal')}</option></select>}</div>
|
||||
{lineData.length === 0 ? <EmptyChart message={t('views.lineEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, otherDomain].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name={t('views.lineSeriesTotal')} stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
|
||||
</div>
|
||||
|
||||
if (view === 'stream') return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? t('views.streamParsed', { count: ingressCount }) : t('views.streamByLevel', { count: entries.length })}{t('views.streamSuffix')}</p>{ingressCount > 0 && <select aria-label={t('views.streamGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="status">{t('views.streamByStatus')}</option></select>}</div>
|
||||
{entries.length === 0 ? <EmptyChart message={t('views.streamEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, otherDomain] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
|
||||
</div>
|
||||
|
||||
return <div>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? t('views.pieByAccess', { count: ingressCount }) : t('views.pieByLabel', { count: entries.length })}</p><select aria-label={t('views.pieGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">{t('views.pieDomain')}</option><option value="status">{t('views.pieStatus')}</option><option value="method">{t('views.pieMethod')}</option><option value="path">{t('views.piePath')}</option><option value="upstream">{t('views.pieUpstream')}</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">{t('views.pieLevel')}</option></select></div>
|
||||
{pieData.length === 0 ? <EmptyChart message={t('views.pieEmpty')} /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CalendarRange, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数
|
||||
const presets = [
|
||||
{ unit: 'minutes', count: 5, ms: 300000 },
|
||||
{ unit: 'minutes', count: 15, ms: 900000 },
|
||||
{ unit: 'hours', count: 1, ms: 3600000 },
|
||||
{ unit: 'hours', count: 6, ms: 21600000 },
|
||||
{ unit: 'hours', count: 24, ms: 86400000 },
|
||||
] as const
|
||||
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const historyKey = () => `lokistack.logs.range-history.${getUser()?.id ?? 'anonymous'}`
|
||||
type SavedRange = { start: string; end: string }
|
||||
|
||||
function readHistory(): SavedRange[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(historyKey()) ?? '[]')
|
||||
return Array.isArray(value) ? value.filter((item): item is SavedRange => typeof item?.start === 'string' && typeof item?.end === 'string').slice(0, 5) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draftStart, setDraftStart] = useState(start)
|
||||
const [draftEnd, setDraftEnd] = useState(end)
|
||||
const [history, setHistory] = useState(readHistory)
|
||||
const [selection, setSelection] = useState({ start, end, ms: 300000 as number | null })
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false) }
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [])
|
||||
|
||||
function choose(nextStart: string, nextEnd: string, nextPreset: number | null = null) {
|
||||
if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return
|
||||
setSelection({ start: nextStart, end: nextEnd, ms: nextPreset })
|
||||
onChange(nextStart, nextEnd)
|
||||
setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false)
|
||||
const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5)
|
||||
setHistory(next)
|
||||
try { localStorage.setItem(historyKey(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
|
||||
}
|
||||
|
||||
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
|
||||
? t('range.lastMinutes', { count: item.count })
|
||||
: t('range.lastHours', { count: item.count })
|
||||
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
|
||||
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
|
||||
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
|
||||
|
||||
return <div ref={root} className="relative min-w-0">
|
||||
<Button type="button" variant="outline" aria-expanded={open} className="h-9 max-w-full gap-2" onClick={() => { setDraftStart(start); setDraftEnd(end); setOpen(!open) }}>
|
||||
<CalendarRange className="size-4 shrink-0" /><span className="shrink-0 font-medium">{label}</span><span className="hidden truncate font-normal text-muted-foreground xl:inline">{rangeText}</span><ChevronDown className="size-4 shrink-0" />
|
||||
</Button>
|
||||
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
|
||||
<h2 className="text-sm font-medium">{t('range.title')}</h2>
|
||||
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{presetLabel(item)}</Button>)}</div>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
|
||||
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>{t('range.apply')}</Button></div>
|
||||
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">{t('range.recent')}</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/** ingress-nginx 默认 access log;采集器可能在前面附加 CRI 时间/stdout 包装。 */
|
||||
export interface IngressAccessLog {
|
||||
clientIp: string
|
||||
method: string
|
||||
path: string
|
||||
status: string
|
||||
bytesSent: string
|
||||
referer: string
|
||||
domain: string
|
||||
domainSource: 'host' | 'referer' | ''
|
||||
userAgent: string
|
||||
requestLength: string
|
||||
requestTime: string
|
||||
upstreamName: string
|
||||
upstreamAddress: string
|
||||
upstreamStatus: string
|
||||
upstreamResponseTime: string
|
||||
}
|
||||
|
||||
export function stripLogEnvelope(message: string): string {
|
||||
return message.replace(/^\d{4}-\d\d-\d\dT\S+\s+(?:stdout|stderr)\s+[FP]\s+/, '')
|
||||
}
|
||||
|
||||
const accessPattern = /^(\S+)\s+\S+\s+\S+\s+\[[^\]]+\]\s+"([A-Z]+)\s+(\S+)\s+[^"]+"\s+(\d{3})\s+(\S+)\s+"([^"]*)"\s+"([^"]*)"(?:\s+(.*))?$/
|
||||
const upstreamPattern = /^(\S+)\s+(\S+)\s+\[([^\]]*)\]\s+\[[^\]]*\]\s+(\S+)\s+\S+\s+(\S+)\s+(\S+)(?:\s+\S+)?$/
|
||||
|
||||
const asText = (value: unknown) => value === undefined || value === null ? '' : String(value)
|
||||
|
||||
function domainFromHost(value: string): string {
|
||||
const host = value.trim().toLowerCase()
|
||||
if (!host || host === '-') return ''
|
||||
try {
|
||||
return new URL(/^https?:\/\//.test(host) ? host : `http://${host}`).hostname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function domainFromReferer(value: string): string {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function accessDomain(host: string, path: string): Pick<IngressAccessLog, 'domain' | 'domainSource'> {
|
||||
const requestHost = domainFromHost(host) || (/^https?:\/\//i.test(path) ? domainFromReferer(path) : '')
|
||||
if (requestHost) return { domain: requestHost, domainSource: 'host' }
|
||||
return { domain: '', domainSource: '' }
|
||||
}
|
||||
|
||||
export function parseIngressAccessLog(message: string): IngressAccessLog | null {
|
||||
const content = stripLogEnvelope(message).trim()
|
||||
if (content.startsWith('{')) {
|
||||
try {
|
||||
const value: unknown = JSON.parse(content)
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const row = value as Record<string, unknown>
|
||||
const method = asText(row.method ?? row.request_method)
|
||||
const path = asText(row.path ?? row.uri ?? row.request_uri)
|
||||
const status = asText(row.status ?? row.status_code)
|
||||
const referer = asText(row.http_referer ?? row.referer)
|
||||
if (method && path && /^\d{3}$/.test(status)) return {
|
||||
clientIp: asText(row.remote_addr ?? row.client_ip), method, path, status,
|
||||
bytesSent: asText(row.bytes_sent ?? row.body_bytes_sent),
|
||||
referer,
|
||||
...accessDomain(asText(row.http_host || row.host || row.server_name), path),
|
||||
userAgent: asText(row.http_user_agent ?? row.user_agent),
|
||||
requestLength: asText(row.request_length), requestTime: asText(row.request_time),
|
||||
upstreamName: asText(row.proxy_upstream_name ?? row.upstream_name),
|
||||
upstreamAddress: asText(row.upstream_addr),
|
||||
upstreamStatus: asText(row.upstream_status),
|
||||
upstreamResponseTime: asText(row.upstream_response_time),
|
||||
}
|
||||
}
|
||||
} catch { /* Fall through to plain access log parsing. */ }
|
||||
}
|
||||
|
||||
const match = content.match(accessPattern)
|
||||
if (!match) return null
|
||||
const upstream = (match[8] ?? '').match(upstreamPattern)
|
||||
return {
|
||||
clientIp: match[1], method: match[2], path: match[3], status: match[4],
|
||||
bytesSent: match[5], referer: match[6], userAgent: match[7],
|
||||
...accessDomain('', match[3]),
|
||||
requestLength: upstream?.[1] ?? '', requestTime: upstream?.[2] ?? '',
|
||||
upstreamName: upstream?.[3] ?? '', upstreamAddress: upstream?.[4] ?? '',
|
||||
upstreamResponseTime: upstream?.[5] ?? '', upstreamStatus: upstream?.[6] ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
export function httpStatusClass(status: string): '2xx' | '3xx' | '4xx' | '5xx' | 'other' {
|
||||
const group = status[0]
|
||||
return group === '2' || group === '3' || group === '4' || group === '5' ? `${group}xx` : 'other'
|
||||
}
|
||||
|
||||
export function pathWithoutQuery(path: string): string {
|
||||
return path.split('?')[0] || '/'
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import i18n from '@/i18n'
|
||||
import type { IngressAccessLog } from './ingress-access-log'
|
||||
import { pathWithoutQuery } from './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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user