feat: complete Loki query, insights, settings and deployment workflows
Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application. Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
1 parent
f9c6c1d826
commit
2d73661dd1
127 files changed
+8117
-2876
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { SearchableValueSelect } from './SearchableValueSelect'
|
||||
|
||||
const policyLabels = ['namespace', 'app', 'pod'] as const
|
||||
|
||||
type PolicyLabel = typeof policyLabels[number]
|
||||
|
||||
interface AccessRuleEditorProps {
|
||||
sourceId: number
|
||||
username: string
|
||||
labels: Record<string, string>
|
||||
onChange: (labels: Record<string, string>) => void
|
||||
}
|
||||
|
||||
/** Exact stream-label restrictions for one user's access to a Loki source. */
|
||||
export function AccessRuleEditor({ sourceId, username, labels, onChange }: AccessRuleEditorProps) {
|
||||
const [options, setOptions] = useState<Record<PolicyLabel, string[]>>({ namespace: [], app: [], pod: [] })
|
||||
const [loading, setLoading] = useState<Record<PolicyLabel, boolean>>({ namespace: false, app: false, pod: false })
|
||||
const namespace = labels.namespace ?? ''
|
||||
const app = labels.app ?? ''
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId) return
|
||||
let active = true
|
||||
const load = (name: PolicyLabel, scope: Record<string, string>) => {
|
||||
setLoading(current => ({ ...current, [name]: true }))
|
||||
logsApi.labelValues(sourceId, name, Object.keys(scope).length ? `{${Object.entries(scope).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}` : '')
|
||||
.then(values => { if (active) setOptions(current => ({ ...current, [name]: values })) })
|
||||
.catch(() => { if (active) setOptions(current => ({ ...current, [name]: [] })) })
|
||||
.finally(() => { if (active) setLoading(current => ({ ...current, [name]: false })) })
|
||||
}
|
||||
load('namespace', {})
|
||||
load('app', namespace ? { namespace } : {})
|
||||
load('pod', { ...(namespace ? { namespace } : {}), ...(app ? { app } : {}) })
|
||||
return () => { active = false }
|
||||
}, [sourceId, namespace, app])
|
||||
|
||||
function setLabel(name: PolicyLabel, value: string) {
|
||||
const next = { ...labels }
|
||||
if (value) next[name] = value
|
||||
else delete next[name]
|
||||
if (name === 'namespace') { delete next.app; delete next.pod }
|
||||
if (name === 'app') delete next.pod
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={`${username} 查询范围`}>
|
||||
{policyLabels.map(name => <SearchableValueSelect key={name} label={name === 'namespace' ? '命名空间' : name === 'app' ? 'App' : '副本 / Pod'} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
function escapeRegExp(text: string) { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
||||
|
||||
export function HighlightedLogLine({ message, terms, className = '' }: { message: string; terms: string[]; className?: string }) {
|
||||
if (!terms.length) return <pre className={className}>{message}</pre>
|
||||
const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'g')
|
||||
const pieces = message.split(pattern)
|
||||
const matches = new Set(terms)
|
||||
return <pre className={className}>{pieces.map((piece, index) => matches.has(piece)
|
||||
? <mark key={index} className="rounded-sm bg-warning/30 px-0.5 text-foreground">{piece}</mark>
|
||||
: piece)}</pre>
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
|
||||
import { summarizeIngress } from '../ingress-insights'
|
||||
|
||||
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
|
||||
type ErrorClass = 'all' | '5xx' | '4xx'
|
||||
const modes: { id: Mode; label: string }[] = [
|
||||
{ id: 'traffic', label: '接口 PV / UV' },
|
||||
{ id: 'errors', label: '异常 Top 10' },
|
||||
{ id: 'slow', label: '慢请求 Top 10' },
|
||||
{ id: 'detail', label: '请求明细' },
|
||||
]
|
||||
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 [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])
|
||||
|
||||
return <div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">仅统计当前页已加载的 {loadedCount} 条日志,其中 {samples.length} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[
|
||||
['PV', summary.pv], ['UV(IP)', summary.uv], ['4xx / 5xx', summary.errors], [`慢请求 ≥ ${slowThreshold}s`, 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="Ingress 统计方式" 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="异常状态码范围" value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">全部 4xx / 5xx</option><option value="5xx">只看 5xx</option><option value="4xx">只看 4xx</option></select>}
|
||||
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">慢请求阈值 <select aria-label="慢请求阈值" value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>0.5 秒</option><option value={1}>1 秒</option><option value={3}>3 秒</option><option value={5}>5 秒</option><option value={10}>10 秒</option></select></label>}
|
||||
</div>
|
||||
{mode === 'slow' && <p className="text-xs text-muted-foreground">依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。{summary.timed < summary.pv && ` ${summary.pv - summary.timed} 条缺少有效耗时。`}</p>}
|
||||
{mode === 'traffic' && <p className="text-xs text-muted-foreground">访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。</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>{['请求 Host', '接口路径', 'PV', 'UV(IP)', '成功率', '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>{['请求 Host', '接口路径', '4xx', '5xx', '异常率', '状态码'].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>{['请求 Host', '接口路径', '慢请求', '最长耗时', '平均耗时', '408 / 499 / 504'].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>{['时间', '请求 Host', '接口路径', '客户端 IP', '状态', '耗时'].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">当前页没有符合条件的请求</p>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Crosshair } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
|
||||
export type LogContext = { row: LogEntry; before: LogEntry[]; after: LogEntry[] }
|
||||
|
||||
function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; terms: string[] }) {
|
||||
const match = row.message.match(/^\d{4}-\d\d-\d\dT\S+\s+(stdout|stderr)\s+[FP]\s+([\s\S]*)$/)
|
||||
return <article className={`border-l-[3px] px-3 py-2 ${current ? 'border-l-warning bg-warning/15' : 'border-l-transparent bg-card'}`}>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />当前日志</span>}
|
||||
<time className="font-mono tabular-nums text-muted-foreground">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
{match && <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground">{match[1]}</span>}
|
||||
{row.level && <span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{row.level}</span>}
|
||||
</div>
|
||||
<HighlightedLogLine message={match?.[2] ?? row.message} terms={terms} className="m-0 whitespace-pre-wrap break-all font-mono text-xs leading-5 text-foreground" />
|
||||
</article>
|
||||
}
|
||||
|
||||
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
|
||||
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
|
||||
<DialogHeader className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle>同一日志流的前后文</DialogTitle>
|
||||
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
|
||||
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
|
||||
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
|
||||
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">文件: {context.row.labels.filename.split('/').slice(-2).join('/')}</span>}
|
||||
</div>}
|
||||
</DialogHeader>
|
||||
{context && <div className="space-y-3 overflow-y-auto bg-muted/20 px-5 py-4">
|
||||
<section aria-label="之前的日志">
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之前 · {context.before.length} 条</h3>
|
||||
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此前 5 分钟内无日志</p>}
|
||||
</section>
|
||||
<section aria-label="当前日志" className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
|
||||
<section aria-label="之后的日志">
|
||||
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之后 · {context.after.length} 条</h3>
|
||||
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此后 5 分钟内无日志</p>}
|
||||
</section>
|
||||
</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { memo } from 'react'
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
|
||||
import type { HistogramBucket } from '@/api/logs'
|
||||
|
||||
interface LogHistogramProps {
|
||||
buckets: HistogramBucket[]
|
||||
loading: boolean
|
||||
/** 点击柱体 → 缩放到相邻 bucket 时间范围 */
|
||||
onZoomRange: (start: number, end: number) => void
|
||||
}
|
||||
|
||||
/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
|
||||
export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
|
||||
const data = buckets.map((b) => ({
|
||||
ts: b.timestamp.slice(11, 16),
|
||||
count: b.count,
|
||||
raw: new Date(b.timestamp).getTime(),
|
||||
}))
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">统计中…</div>
|
||||
}
|
||||
if (data.length === 0) {
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">暂无日志量数据</div>
|
||||
}
|
||||
|
||||
const handleClick = (idx: number) => {
|
||||
const cur = data[idx]?.raw
|
||||
if (!cur) return
|
||||
const step = data.length > 1 ? Math.max(data[1].raw - data[0].raw, 60000) : 300000
|
||||
onZoomRange(cur - step / 2, cur + step / 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
|
||||
onClick={(state: { activeTooltipIndex?: number }) => {
|
||||
if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logVol" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="ts" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" width={28} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 11, borderRadius: 8, background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))' }}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="url(#logVol)"
|
||||
strokeWidth={1.5}
|
||||
cursor="pointer"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
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 [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 || '未记录域名'
|
||||
counts.set(domain, (counts.get(domain) ?? 0) + 1)
|
||||
}
|
||||
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
|
||||
}, [parsedRows])
|
||||
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 || '未记录域名'
|
||||
const key = streamDomains.includes(domain) ? domain : '其他域名'
|
||||
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])
|
||||
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 || '未记录域名') ? ingress.domain || '未记录域名' : '其他域名') : 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])
|
||||
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 || '未记录域名'
|
||||
: effectiveGroupBy === 'status' ? ingress?.status || '未知'
|
||||
: effectiveGroupBy === 'method' ? ingress?.method || '未知'
|
||||
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
|
||||
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || '未标记'
|
||||
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
|
||||
: entry.labels[effectiveGroupBy] || '未标记'
|
||||
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: '其他', count: other })
|
||||
return top
|
||||
}, [entries, parsedRows, effectiveGroupBy])
|
||||
|
||||
if (view === 'table') return ingressCount > 0
|
||||
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
|
||||
: <div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">当前页已加载 {entries.length} 条日志。</p>
|
||||
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志" /> : <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>{['时间', 'namespace / pod', '级别', '日志'].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 ? '按当前已加载日志统计;更多结果请继续加载。' : '按查询时间范围统计的日志量,来自 Loki 时间聚合。'}</p>{ingressCount > 0 && <select aria-label="折线图分组字段" 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">按域名</option><option value="total">总量</option></select>}</div>
|
||||
{lineData.length === 0 ? <EmptyChart message="当前查询没有可用的时间统计" /> : <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, '其他域名'].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="日志条数" 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 ? `展示 ${ingressCount} 条已解析日志` : `按级别展示当前已加载的 ${entries.length} 条日志`},基于当前已加载结果。</p>{ingressCount > 0 && <select aria-label="流图分组字段" 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">按域名</option><option value="status">按 HTTP 状态段</option></select>}</div>
|
||||
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志流" /> : <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, '其他域名'] : ['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) ? `按访问字段统计已解析的 ${ingressCount} 条日志` : `按标签统计当前已加载的 ${entries.length} 条日志`}</p><select aria-label="饼图分组字段" 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">域名(请求 Host)</option><option value="status">HTTP 状态码</option><option value="method">请求方法</option><option value="path">请求路径</option><option value="upstream">上游服务</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">级别</option></select></div>
|
||||
{pieData.length === 0 ? <EmptyChart message="暂无可展示的标签分布" /> : <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>
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
|
||||
// 级别 → 颜色(语义 token)
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
error: 'text-destructive',
|
||||
warn: 'text-warning',
|
||||
info: 'text-success',
|
||||
debug: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
/** 从日志行提取 k=v / JSON 字段(点击过滤用) */
|
||||
function detectFields(message: string): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = []
|
||||
const trimmed = message.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(trimmed)
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
||||
out.push([k, String(v)])
|
||||
}
|
||||
}
|
||||
return out.slice(0, 12)
|
||||
} catch {
|
||||
/* 非 JSON 继续 */
|
||||
}
|
||||
}
|
||||
const re = /([a-zA-Z_][\w.-]*)=("[^"]*"|\S+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(message)) !== null && out.length < 12) {
|
||||
out.push([m[1], m[2].replace(/^"|"$/g, '')])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface LogResultsProps {
|
||||
entries: LogEntry[]
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 点击标签过滤(改写查询) */
|
||||
onFilter: (nextQuery: string) => void
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志结果区(Grafana 风格裁剪版):级别着色、行展开(原文 + labels + 提取字段,
|
||||
* 点击即过滤/排除)、去重开关
|
||||
*/
|
||||
export const LogResults = memo(function LogResults({
|
||||
entries,
|
||||
loading,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
onFilter,
|
||||
query,
|
||||
}: LogResultsProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [dedup, setDedup] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!dedup) return entries
|
||||
const seen = new Set<string>()
|
||||
return entries.filter((e) => {
|
||||
const k = e.message.replace(/\d+/g, '#')
|
||||
if (seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
}, [entries, dedup])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// 公共标签(所有结果共有)
|
||||
const commonLabels = useMemo(() => {
|
||||
if (entries.length === 0) return [] as string[]
|
||||
let keys = Object.keys(entries[0].labels)
|
||||
for (const e of entries) {
|
||||
keys = keys.filter((k) => e.labels[k] !== undefined && e.labels[k] === entries[0].labels[k])
|
||||
}
|
||||
return keys
|
||||
}, [entries])
|
||||
|
||||
if (loading && entries.length === 0) {
|
||||
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">查询中…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{display.length} 条{dedup && `(去重后,原 ${entries.length} 条)`}
|
||||
{commonLabels.length > 0 && (
|
||||
<span className="ml-2 inline-flex flex-wrap gap-1">
|
||||
{commonLabels.slice(0, 6).map((k) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted">
|
||||
{k}={entries[0].labels[k]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
|
||||
去重
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
|
||||
{display.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的日志</div>
|
||||
) : (
|
||||
display.map((e, idx) => {
|
||||
const key = `${e.timestamp}-${idx}`
|
||||
const open = expanded.has(key)
|
||||
const fields = open ? detectFields(e.message) : []
|
||||
return (
|
||||
<div key={key} className="text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(key)}
|
||||
className="w-full text-left flex items-start gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0 tabular-nums">{e.timestamp.slice(11, 23)}</span>
|
||||
<span className={cn('font-semibold flex-shrink-0 uppercase w-10', LEVEL_COLOR[e.level] || 'text-muted-foreground')}>
|
||||
{e.level}
|
||||
</span>
|
||||
<span className="font-mono break-all min-w-0">{e.message.slice(0, 500)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pl-10 space-y-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">日志原文</p>
|
||||
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">标签(点击过滤)</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(e.labels).map(([k, v]) => (
|
||||
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-primary"
|
||||
title="过滤此值"
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v))}
|
||||
>
|
||||
{k}={v}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
title="排除此值"
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">提取字段</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map(([k, v]) => (
|
||||
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
|
||||
<button type="button" className="hover:underline" onClick={() => onFilter(applyLabelFilter(query, k, v))}>
|
||||
{k}={v}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { parseIngressAccessLog } from '../ingress-access-log'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
import { HighlightedLogLine } from './HighlightedLogLine'
|
||||
import { LogResultViews, type ResultView } from './LogResultViews'
|
||||
import { QueryEmptyState } from './QueryEmptyState'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
const views: { id: ResultView; label: string }[] = [
|
||||
{ id: 'raw', label: '原始日志' }, { id: 'table', label: '表格' },
|
||||
{ id: 'line', label: '折线图' }, { id: 'stream', label: '流图' }, { id: 'pie', label: '饼图' },
|
||||
]
|
||||
|
||||
export function LogResultsPanel({ entries, buckets, start, end, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
|
||||
entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean
|
||||
pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
|
||||
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
|
||||
}) {
|
||||
const [view, setView] = useState<ResultView>('raw')
|
||||
const [domainFilter, setDomainFilter] = useState('')
|
||||
const [clock, setClock] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!loading) return
|
||||
const timer = window.setInterval(() => setClock(Date.now()), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [loading])
|
||||
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
|
||||
const visibleEntries = useMemo(() => {
|
||||
const needle = domainFilter.trim().toLowerCase()
|
||||
return needle ? entries.filter(entry => parseIngressAccessLog(entry.message)?.domain.includes(needle)) : entries
|
||||
}, [entries, domainFilter])
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label="日志结果">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-semibold">日志结果 <span className="text-sm font-normal text-muted-foreground">第 {pageIndex + 1} 页 · 本页 {entries.length} 条{hasNextPage && ' · 还有更多'}</span></h2>
|
||||
<div role="tablist" aria-label="日志展示方式" className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
|
||||
{views.map(item => <button key={item.id} role="tab" aria-selected={view === item.id} type="button" className={`rounded px-2.5 py-1.5 text-xs transition-colors ${view === item.id ? 'bg-card font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setView(item.id)}>{item.label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
|
||||
<span>{entries.length ? '正在加载日志页' : '正在查询 Loki'} · 已等待 {clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0} 秒。大范围查询可能需要较长时间。</span>
|
||||
</div>}
|
||||
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。</p>}
|
||||
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">域名筛选</label>
|
||||
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder="输入或选择域名" />
|
||||
<datalist id="log-domain-options">{domains.map(domain => <option key={domain} value={domain} />)}</datalist>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>在 Loki 中查询</Button>
|
||||
<span className="text-muted-foreground">仅匹配日志记录的请求 Host。当前 {visibleEntries.length} / {entries.length} 条。</span>
|
||||
</div>}
|
||||
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">当前域名筛选没有匹配的日志</p> : <div className="divide-y">
|
||||
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
|
||||
<span className="rounded bg-muted px-1">{row.level}</span>
|
||||
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title="筛选此标签" className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
|
||||
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>查看前后各 5 条</button>
|
||||
</div>
|
||||
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
|
||||
</div>)}
|
||||
</div> : <LogResultViews view={view} entries={visibleEntries} buckets={buckets} start={start} end={end} domainFiltered={Boolean(domainFilter.trim())} />}
|
||||
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
|
||||
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>上一页</Button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">第 {pageIndex + 1} 页</span>
|
||||
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>下一页</Button>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
const formatTime = (timestamp: string) => new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
const formatCount = (count: number) => new Intl.NumberFormat('zh-CN').format(count)
|
||||
|
||||
export function LogVolumeTimeline({ buckets, onSelect }: {
|
||||
buckets: Bucket[]
|
||||
onSelect: (start: number, end: number) => void
|
||||
}) {
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const [selection, setSelection] = useState<[number, number] | null>(null)
|
||||
const startIndex = useRef<number | null>(null)
|
||||
const max = Math.max(1, ...buckets.map(bucket => bucket.count))
|
||||
const total = buckets.reduce((sum, bucket) => sum + bucket.count, 0)
|
||||
const timestamps = buckets.map(bucket => new Date(bucket.timestamp).getTime())
|
||||
const step = timestamps.length > 1 ? Math.max(1000, timestamps[1] - timestamps[0]) : 60000
|
||||
|
||||
function indexAt(clientX: number, element: HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(buckets.length - 1, Math.floor((clientX - bounds.left) / bounds.width * buckets.length)))
|
||||
}
|
||||
|
||||
function finish(index: number) {
|
||||
if (startIndex.current === null) return
|
||||
const from = Math.min(startIndex.current, index)
|
||||
const to = Math.max(startIndex.current, index)
|
||||
startIndex.current = null
|
||||
setSelection([from, to])
|
||||
onSelect(timestamps[from], timestamps[to] + step)
|
||||
}
|
||||
|
||||
if (buckets.length === 0) return null
|
||||
|
||||
return <section className="rounded-xl border bg-card p-4" aria-label="日志量时间线">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><h2 className="text-sm font-semibold">日志量时间线</h2><p className="text-xs text-muted-foreground">悬停查看数量,按住任意高度拖选时间;选择后自动查询。</p></div>
|
||||
<span className="text-sm tabular-nums">时间桶合计 <strong>{formatCount(total)}</strong> 条</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative h-44 min-w-0 flex-1 select-none touch-none cursor-crosshair"
|
||||
onPointerDown={event => { event.currentTarget.setPointerCapture(event.pointerId); const index = indexAt(event.clientX, event.currentTarget); startIndex.current = index; setSelection([index, index]); setHover(index) }}
|
||||
onPointerMove={event => { const index = indexAt(event.clientX, event.currentTarget); setHover(index); if (startIndex.current !== null) setSelection([Math.min(startIndex.current, index), Math.max(startIndex.current, index)]) }}
|
||||
onPointerUp={event => finish(indexAt(event.clientX, event.currentTarget))}
|
||||
onPointerCancel={() => { startIndex.current = null; setSelection(null) }}
|
||||
onPointerLeave={() => { if (startIndex.current === null) setHover(null) }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between"><span className="border-t border-dashed" /><span className="border-t border-dashed" /><span className="border-t border-dashed" /></div>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-end gap-px">
|
||||
{buckets.map((bucket, index) => <div key={`${bucket.timestamp}-${index}`} className="flex h-full min-w-0 flex-1 items-end">
|
||||
<span className={`w-full min-h-0 rounded-t-sm ${selection && index >= selection[0] && index <= selection[1] ? 'bg-primary' : index === hover ? 'bg-info' : 'bg-info/40'}`} style={{ height: `${Math.max(2, bucket.count / max * 100)}%` }} />
|
||||
</div>)}
|
||||
</div>
|
||||
{hover !== null && <div className="pointer-events-none absolute z-10 top-2 rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-sm" style={{ left: `${Math.min(78, Math.max(2, (hover + 0.5) / buckets.length * 100))}%` }}>
|
||||
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{formatCount(buckets[hover].count)} 条</strong>
|
||||
</div>}
|
||||
</div>
|
||||
<div className="flex h-44 w-14 flex-col justify-between text-right text-xs tabular-nums text-muted-foreground"><span>{formatCount(max)}</span><span>{formatCount(Math.round(max / 2))}</span><span>0</span></div>
|
||||
</div>
|
||||
<div className="mr-16 mt-2 flex justify-between text-xs tabular-nums text-muted-foreground">
|
||||
<span>{formatTime(buckets[0].timestamp)}</span><span>{formatTime(buckets[Math.floor((buckets.length - 1) / 2)].timestamp)}</span><span>{formatTime(buckets[buckets.length - 1].timestamp)}</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Tags } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { applyLabelFilter, extractSelector } from '../query-utils'
|
||||
|
||||
function currentLabels(query: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const match of extractSelector(query).matchAll(/([a-zA-Z_][\w]*)\s*=\s*("(?:\\.|[^"\\])*")/g)) {
|
||||
try { result[match[1]] = JSON.parse(match[2]) as string } catch { /* Ignore incomplete input. */ }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function streamSelector(labels: Record<string, string>) {
|
||||
return `{${Object.entries(labels).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join(',')}}`
|
||||
}
|
||||
|
||||
export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: { sourceId: number; query: string; onQueryChange: (query: string) => void; start: string; end: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [activeLabel, setActiveLabel] = useState('')
|
||||
const [values, setValues] = useState<string[]>([])
|
||||
const [filter, setFilter] = useState('')
|
||||
const [loadingLabels, setLoadingLabels] = useState(false)
|
||||
const [loadingValues, setLoadingValues] = useState(false)
|
||||
const [scoped, setScoped] = useState(false)
|
||||
const selected = useMemo(() => currentLabels(query), [query])
|
||||
const startNs = Number.isFinite(new Date(start).getTime()) ? new Date(start).getTime() * 1e6 : undefined
|
||||
const endNs = Number.isFinite(new Date(end).getTime()) ? new Date(end).getTime() * 1e6 : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sourceId) return
|
||||
let active = true
|
||||
logsApi.labels(sourceId, startNs, endNs).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
|
||||
return () => { active = false }
|
||||
}, [open, sourceId, startNs, endNs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !sourceId || !activeLabel) return
|
||||
let active = true
|
||||
const scope = { ...selected }
|
||||
delete scope[activeLabel]
|
||||
logsApi.labelValues(sourceId, activeLabel, scoped && Object.keys(scope).length ? streamSelector(scope) : '', startNs, endNs)
|
||||
.then(next => { if (active) setValues(next) }).catch(() => { if (active) setValues([]) }).finally(() => { if (active) setLoadingValues(false) })
|
||||
return () => { active = false }
|
||||
}, [open, sourceId, activeLabel, selected, scoped, startNs, endNs])
|
||||
|
||||
const shown = values.filter(value => value.toLowerCase().includes(filter.toLowerCase())).slice(0, 200)
|
||||
|
||||
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
|
||||
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
|
||||
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
<Tags className="size-4 text-info" />Label 浏览器
|
||||
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">已选 {Object.keys(selected).length}</span>}
|
||||
</button>
|
||||
{open && <div className="grid min-h-44 border-t sm:grid-cols-[minmax(150px,220px)_1fr]">
|
||||
<div className="max-h-64 overflow-auto border-b p-1 sm:border-b-0 sm:border-r">
|
||||
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">加载标签中…</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">暂无可用标签</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
|
||||
</div>
|
||||
<div className="min-w-0 p-3">
|
||||
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
|
||||
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
|
||||
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, value))} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
|
||||
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, filter.trim()))}>使用“{filter.trim()}”作为值</button>}
|
||||
<p className="mt-2 text-xs text-muted-foreground">候选来自当前时间范围内已写入 Loki 的日志;Kubernetes 存在的命名空间未必有日志。授权范围仍由服务端限制。</p>
|
||||
</>}
|
||||
</div>
|
||||
</div>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { logsApi, type LogPolicyGroup, type LogSource } from '@/api/logs'
|
||||
import { userApi } from '@/api/user'
|
||||
import type { User } from '@/types'
|
||||
import { AccessRuleEditor } from './AccessRuleEditor'
|
||||
|
||||
type PolicyForm = Omit<LogPolicyGroup, 'id'> & { id?: number }
|
||||
const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, labels: {}, userIds: [] })
|
||||
|
||||
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
|
||||
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [form, setForm] = useState<PolicyForm>(() => emptyForm(sources[0]?.id ?? 0))
|
||||
const [filter, setFilter] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<LogPolicyGroup | null>(null)
|
||||
|
||||
async function reload() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [policies, firstPage] = await Promise.all([logsApi.policyGroups(), userApi.list({ page: 1, pageSize: 100 })])
|
||||
const allUsers = [...firstPage.list]
|
||||
for (let page = 2; allUsers.length < firstPage.total; page++) {
|
||||
const next = await userApi.list({ page, pageSize: 100 })
|
||||
allUsers.push(...next.list)
|
||||
if (next.list.length === 0) break
|
||||
}
|
||||
setGroups(policies)
|
||||
setUsers(allUsers.filter(user => user.role !== 'admin'))
|
||||
setError('')
|
||||
} catch (err) { setError(err instanceof Error ? err.message : '加载查询策略失败') }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { void reload() }, [])
|
||||
useEffect(() => { if (!form.sourceId && sources[0]?.id) setForm(current => ({ ...current, sourceId: sources[0].id })) }, [sources, form.sourceId])
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
|
||||
toast.error('请填写策略组名称,选择数据源和至少一位用户')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const body = { name: form.name.trim(), sourceId: form.sourceId, labels: form.labels, userIds: form.userIds }
|
||||
if (form.id) await logsApi.updatePolicyGroup(form.id, body)
|
||||
else await logsApi.createPolicyGroup(body)
|
||||
await reload()
|
||||
setForm(emptyForm(form.sourceId))
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : '保存策略组失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deleteTarget) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await logsApi.deletePolicyGroup(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
if (form.id === deleteTarget.id) setForm(emptyForm(form.sourceId))
|
||||
await reload()
|
||||
} catch (err) { toast.error(err instanceof Error ? err.message : '删除策略组失败') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const visibleUsers = users.filter(user => user.username.toLowerCase().includes(filter.toLowerCase()) || (user.nickname ?? '').toLowerCase().includes(filter.toLowerCase()))
|
||||
const assignedElsewhere = new Set(groups.filter(group => group.sourceId === form.sourceId && group.id !== form.id).flatMap(group => group.userIds))
|
||||
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? `数据源 #${id}`
|
||||
|
||||
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
|
||||
<aside className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">查询策略组</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>新建</Button></div>
|
||||
<p className="text-sm text-muted-foreground">一套日志范围可关联多个用户,后续修改会同时生效。</p>
|
||||
{loading && <p className="text-sm text-muted-foreground">加载中…</p>}
|
||||
{error && <ErrorState message={error} onRetry={() => void reload()} />}
|
||||
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
|
||||
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
|
||||
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.length} 位用户</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
|
||||
</button>)}</div>
|
||||
</aside>
|
||||
<section className="rounded-xl border bg-card p-5 space-y-6">
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? '编辑策略组' : '新建策略组'}</h2><p className="mt-1 text-sm text-muted-foreground">选择数据源及允许的日志范围,再关联需要复用此策略的用户。</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>删除</Button>}</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">策略组名称<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="例如:生产环境 Agent 日志" /></label><label className="text-sm font-medium">数据源<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>请先添加数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
|
||||
<div><h3 className="mb-2 text-sm font-medium">允许查询的日志范围</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || '策略组'} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。</p></div>
|
||||
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.length} 位</span></h3><div className="flex gap-2"><Button size="sm" variant="outline" onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
|
||||
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
|
||||
</section>
|
||||
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function QueryEmptyState({ searched, hasSource, onRetry }: { searched: boolean; hasSource: boolean; onRetry: () => void }) {
|
||||
return <div className="flex min-h-56 flex-col items-center justify-center gap-3 rounded-lg bg-muted/20 px-4 text-center">
|
||||
<div className="rounded-full bg-muted p-3"><Search className="size-6 text-muted-foreground" /></div>
|
||||
<p className="font-medium">{!hasSource ? '暂无可查询的数据源' : searched ? '未找到匹配的日志记录' : '输入 LogQL 并运行查询'}</p>
|
||||
<p className="text-sm text-muted-foreground">{!hasSource ? '请联系管理员添加或授权数据源' : searched ? '当前时间范围和筛选条件下没有匹配结果' : '也可以使用 Label 浏览器快速构建查询'}</p>
|
||||
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>重新查询</Button>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { QuickQueryTemplates } from './QuickQueryTemplates'
|
||||
|
||||
const PIPE_OPS = ['|= ', '!= ', '|~ ', '!~ ', '| json', '| regexp ', '| line_format ', '| unwrap ', '| pattern ']
|
||||
const LOGQL_TOKENS = /("(?:[^"\\]|\\.)*")|([a-zA-Z_][\w-]*)(?=\s*(?:=|!=|=~|!~))|(\|\s*(?:json|regexp|line_format|unwrap|pattern|label_format)\b)|\b(count_over_time|sum|rate|avg|max|min|count|by|over)\b|([{}])|(\|=|!=|=~|!~|\|~|=)/g
|
||||
|
||||
function colorizedLogQL(query: string) {
|
||||
const parts: React.ReactNode[] = []
|
||||
let start = 0
|
||||
for (const match of query.matchAll(LOGQL_TOKENS)) {
|
||||
const index = match.index ?? 0
|
||||
if (index > start) parts.push(query.slice(start, index))
|
||||
const tone = match[1] ? 'text-success' : match[2] || match[5] ? 'text-warning' : match[3] ? 'text-primary' : match[4] ? 'text-info' : 'text-destructive'
|
||||
parts.push(<span className={tone} key={index}>{match[0]}</span>)
|
||||
start = index + match[0].length
|
||||
}
|
||||
if (start < query.length) parts.push(query.slice(start))
|
||||
return parts
|
||||
}
|
||||
|
||||
interface QueryInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
sourceId: number
|
||||
history: string[]
|
||||
}
|
||||
|
||||
/** LogQL editor with syntax colors and label/operator suggestions. */
|
||||
export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit, sourceId, history }: QueryInputProps) {
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
const [hints, setHints] = useState<string[]>([])
|
||||
const [active, setActive] = useState(0)
|
||||
const [hintKind, setHintKind] = useState<'label' | 'value' | 'pipe' | null>(null)
|
||||
const [currentLabel, setCurrentLabel] = useState('')
|
||||
const editor = useRef<HTMLTextAreaElement>(null)
|
||||
const overlay = useRef<HTMLPreElement>(null)
|
||||
const requestVersion = useRef(0)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const [assistantTab, setAssistantTab] = useState<'quick' | 'history'>('quick')
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setAssistantOpen(false) }
|
||||
document.addEventListener('pointerdown', close)
|
||||
return () => document.removeEventListener('pointerdown', close)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId) return
|
||||
let active = true
|
||||
logsApi.labels(sourceId).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) })
|
||||
return () => { active = false }
|
||||
}, [sourceId])
|
||||
|
||||
function computeHints(text: string, caret: number) {
|
||||
const before = text.slice(0, caret)
|
||||
const valueMatch = before.match(/([a-zA-Z_][\w]*)\s*=\s*"([^"}]*)$/)
|
||||
if (valueMatch && before.lastIndexOf('{') > before.lastIndexOf('}')) {
|
||||
const label = valueMatch[1]
|
||||
const typed = valueMatch[2].toLowerCase()
|
||||
setCurrentLabel(label)
|
||||
setHintKind('value')
|
||||
const version = ++requestVersion.current
|
||||
const selector = text.match(/\{[^}]*\}/)?.[0] ?? ''
|
||||
logsApi.labelValues(sourceId, label, selector).then(values => {
|
||||
if (version === requestVersion.current) { setHints(values.filter(item => item.toLowerCase().includes(typed)).slice(0, 12)); setActive(0) }
|
||||
}).catch(() => { if (version === requestVersion.current) setHints([]) })
|
||||
return
|
||||
}
|
||||
requestVersion.current++
|
||||
if (/\|\s*[\w]*$/.test(before)) { setHintKind('pipe'); setHints(PIPE_OPS); setActive(0); return }
|
||||
if (before.lastIndexOf('{') > before.lastIndexOf('}')) {
|
||||
const typed = before.slice(before.lastIndexOf('{') + 1).split(/[\s,]/).pop() ?? ''
|
||||
setHintKind('label')
|
||||
setHints(labels.filter(label => label.toLowerCase().startsWith(typed.toLowerCase())).slice(0, 12))
|
||||
setActive(0)
|
||||
return
|
||||
}
|
||||
setHintKind(null); setHints([])
|
||||
}
|
||||
|
||||
function applyHint(hint: string) {
|
||||
const caret = editor.current?.selectionStart ?? value.length
|
||||
const before = value.slice(0, caret)
|
||||
const after = value.slice(caret)
|
||||
let next = value
|
||||
let nextCaret = caret
|
||||
if (hintKind === 'value') {
|
||||
const prefix = before.replace(/"[^"}]*$/, '')
|
||||
const quoted = JSON.stringify(hint)
|
||||
next = prefix + quoted + (after.startsWith('"') ? after.slice(1) : after)
|
||||
nextCaret = prefix.length + quoted.length
|
||||
}
|
||||
if (hintKind === 'label') {
|
||||
const typed = before.slice(before.lastIndexOf('{') + 1).split(/[\s,]/).pop() ?? ''
|
||||
const prefix = before.slice(0, before.length - typed.length)
|
||||
next = prefix + hint + '=' + after
|
||||
nextCaret = prefix.length + hint.length + 1
|
||||
}
|
||||
if (hintKind === 'pipe') {
|
||||
const prefix = before.replace(/\|\s*[\w]*$/, '')
|
||||
next = prefix + hint + after
|
||||
nextCaret = prefix.length + hint.length
|
||||
}
|
||||
onChange(next)
|
||||
setHints([]); setHintKind(null)
|
||||
requestAnimationFrame(() => {
|
||||
editor.current?.focus()
|
||||
editor.current?.setSelectionRange(nextCaret, nextCaret)
|
||||
})
|
||||
}
|
||||
|
||||
function chooseQuery(next: string) {
|
||||
onChange(next)
|
||||
requestAnimationFrame(() => { editor.current?.focus(); setAssistantOpen(false) })
|
||||
}
|
||||
|
||||
return <div ref={root} className="relative min-w-0 flex-1">
|
||||
<div className="relative h-10 overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<pre ref={overlay} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre p-2.5 font-mono text-sm leading-5 text-foreground">{colorizedLogQL(value)}{'\u200b'}</pre>
|
||||
{!value && <span aria-hidden className="pointer-events-none absolute inset-0 flex items-center px-2.5 font-mono text-sm text-muted-foreground">{'{app="service"} |= "error"'}</span>}
|
||||
<textarea ref={editor} aria-label="LogQL 查询语句" placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
|
||||
onFocus={() => setAssistantOpen(true)}
|
||||
onChange={event => { onChange(event.target.value); computeHints(event.target.value, event.target.selectionStart) }}
|
||||
onClick={event => computeHints(event.currentTarget.value, event.currentTarget.selectionStart)}
|
||||
onScroll={() => { if (overlay.current && editor.current) overlay.current.scrollLeft = editor.current.scrollLeft }}
|
||||
onKeyDown={event => {
|
||||
if (hints.length && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault(); setActive(index => (index + (event.key === 'ArrowDown' ? 1 : hints.length - 1)) % hints.length); return }
|
||||
if (hints.length && (event.key === 'Enter' || event.key === 'Tab')) { event.preventDefault(); applyHint(hints[active]); return }
|
||||
if (event.key === 'Escape') { setHints([]); setAssistantOpen(false); return }
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); setAssistantOpen(false); onSubmit() }
|
||||
}}
|
||||
onBlur={() => window.setTimeout(() => setHints([]), 150)}
|
||||
className="absolute inset-0 h-full w-full resize-none overflow-auto whitespace-pre bg-transparent p-2.5 font-mono text-sm leading-5 text-transparent caret-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
{hints.length > 0 && <div role="listbox" className="absolute left-0 right-0 top-full z-40 mt-1 max-h-56 overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{currentLabel} 的值</p>}
|
||||
{hints.map((hint, index) => <button role="option" aria-selected={index === active} type="button" key={`${hint}-${index}`} onMouseDown={event => { event.preventDefault(); applyHint(hint) }} className={`block w-full rounded px-2 py-1.5 text-left font-mono text-xs ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}>{hint}</button>)}
|
||||
</div>}
|
||||
{assistantOpen && hints.length === 0 && <div className="absolute left-0 top-full z-30 mt-1 w-[min(90vw,760px)] max-w-full rounded-lg border bg-popover p-3 shadow-xl">
|
||||
<div className="mb-2 flex gap-1 border-b pb-2">
|
||||
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>快捷 LogQL</button>
|
||||
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>查询历史 {history.length > 0 && `(${history.length})`}</button>
|
||||
<span className="ml-auto self-center text-xs text-muted-foreground">Esc 关闭</span>
|
||||
</div>
|
||||
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value} onQueryChange={chooseQuery} /> :
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">运行查询后,常用 LogQL 会出现在这里</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
|
||||
</div>}
|
||||
</div>}
|
||||
</div>
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { applyLabelFilter, extractSelector } from '../query-utils'
|
||||
|
||||
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
|
||||
|
||||
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
|
||||
sourceId: number
|
||||
query: string
|
||||
onQueryChange: (query: string) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
const [app, setApp] = useState('')
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [pod, setPod] = useState('')
|
||||
const [apps, setApps] = useState<string[]>([])
|
||||
const [namespaces, setNamespaces] = useState<string[]>([])
|
||||
const [pods, setPods] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceId) return
|
||||
let active = true
|
||||
Promise.allSettled([
|
||||
logsApi.labelValues(sourceId, 'app'),
|
||||
logsApi.labelValues(sourceId, 'namespace'),
|
||||
logsApi.labelValues(sourceId, 'pod'),
|
||||
]).then(([nextApps, nextNamespaces, nextPods]) => {
|
||||
if (active) {
|
||||
setApps(nextApps.status === 'fulfilled' ? nextApps.value : [])
|
||||
setNamespaces(nextNamespaces.status === 'fulfilled' ? nextNamespaces.value : [])
|
||||
setPods(nextPods.status === 'fulfilled' ? nextPods.value : [])
|
||||
}
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [sourceId])
|
||||
|
||||
function generate(template: Template) {
|
||||
let next = extractSelector(query) || '{}'
|
||||
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
|
||||
else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
|
||||
if (app.trim()) next = applyLabelFilter(next, 'app', app.trim())
|
||||
if (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim())
|
||||
if (next === '{}') return
|
||||
if (template === 'error') next += ' |= "error"'
|
||||
if (template === 'warn') next += ' |= "warn"'
|
||||
if (template === 'ingress') next += ' |= "HTTP/"'
|
||||
onQueryChange(next)
|
||||
}
|
||||
|
||||
const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0
|
||||
const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector)
|
||||
|
||||
return <section className="space-y-2" aria-label="快捷查询模板">
|
||||
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
|
||||
{!compact && <p className="text-xs text-muted-foreground">选 app、namespace 或副本,再生成可编辑的 LogQL。</p>}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
|
||||
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => setApp(event.target.value)} placeholder="搜索或输入 app" />
|
||||
</label>
|
||||
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
|
||||
<label className="min-w-36 flex-1 text-xs text-muted-foreground">namespace
|
||||
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => setNamespace(event.target.value)} placeholder="可选" />
|
||||
</label>
|
||||
<datalist id="quick-log-namespaces">{namespaces.map(value => <option value={value} key={value} />)}</datalist>
|
||||
<label className="min-w-36 flex-1 text-xs text-muted-foreground">副本 pod
|
||||
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder="可选" />
|
||||
</label>
|
||||
<datalist id="quick-log-pods">{pods.map(value => <option value={value} key={value} />)}</datalist>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>查询该范围日志</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>错误 error</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
|
||||
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
|
||||
{!hasScope && <span className="self-center text-xs text-muted-foreground">先填写 app、namespace 或 pod,也可在 Label 浏览器选值</span>}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface SearchableValueSelectProps {
|
||||
label: string
|
||||
values: string[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
allowEmpty?: boolean
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** A searchable single-value combobox for Loki label values. */
|
||||
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const id = useId()
|
||||
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [])
|
||||
|
||||
function choose(next: string) {
|
||||
onChange(next)
|
||||
setFilter('')
|
||||
setActive(0)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return <div ref={root} className="relative min-w-48">
|
||||
<label className="block text-sm font-medium mb-1" htmlFor={id}>{label}</label>
|
||||
<Input
|
||||
id={id}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-options`}
|
||||
disabled={disabled}
|
||||
value={open ? filter : value}
|
||||
placeholder={placeholder ?? '搜索并选择'}
|
||||
onFocus={() => { setFilter(''); setOpen(true) }}
|
||||
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); setActive(current => Math.max(0, Math.min(current + 1, choices.length - 1))) }
|
||||
if (event.key === 'ArrowUp') { event.preventDefault(); setActive(current => Math.max(0, current - 1)) }
|
||||
if (event.key === 'Enter' && open && choices[active]) { event.preventDefault(); choose(choices[active]) }
|
||||
}}
|
||||
/>
|
||||
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>不限</button>}
|
||||
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">加载中…</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">无匹配值</p> : choices.slice(0, 200).map((item, index) =>
|
||||
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CalendarRange, ChevronDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
const presets = [
|
||||
{ label: '最近 5 分钟', ms: 300000 },
|
||||
{ label: '最近 15 分钟', ms: 900000 },
|
||||
{ label: '最近 1 小时', ms: 3600000 },
|
||||
{ label: '最近 6 小时', ms: 21600000 },
|
||||
{ label: '最近 24 小时', ms: 86400000 },
|
||||
]
|
||||
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const historyKey = () => `opsxloki.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 [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 activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
|
||||
const label = activePreset?.label ?? '自定义时间'
|
||||
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">选择时间范围</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) }}>{item.label}</Button>)}</div>
|
||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||
<label className="text-xs text-muted-foreground">开始时间<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">结束时间<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)}>应用时间</Button></div>
|
||||
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">最近使用</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>
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PageContainer } from '@/components/PageContainer'
|
||||
import { ErrorState } from '@/components/ErrorState'
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
|
||||
import { clusterApi } from '@/api'
|
||||
import type { Cluster } from '@/types'
|
||||
import { Play, Radio, Lock, Loader2 } from 'lucide-react'
|
||||
import { QueryInput } from './components/QueryInput'
|
||||
import { LokiLabelBrowser } from './components/LokiLabelBrowser'
|
||||
import { LogVolumeTimeline } from './components/LogVolumeTimeline'
|
||||
import { TimeRangePicker } from './components/TimeRangePicker'
|
||||
import { PolicyGroupsPage } from './components/PolicyGroupsPage'
|
||||
import { LogContextDialog, type LogContext } from './components/LogContextDialog'
|
||||
import { LogResultsPanel } from './components/LogResultsPanel'
|
||||
import { literalFilters } from './query-utils'
|
||||
import { readQueryHistory, rememberQuery } from './query-history'
|
||||
|
||||
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
|
||||
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||
|
||||
type Bucket = { timestamp: string; count: number }
|
||||
|
||||
export default function LogsPage() {
|
||||
const admin = getUser()?.role === 'admin'
|
||||
const [sources, setSources] = useState<LogSource[]>([])
|
||||
const [sourceId, setSourceId] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [executedQuery, setExecutedQuery] = useState('')
|
||||
const [executedRange, setExecutedRange] = useState<{ start: number; end: number } | null>(null)
|
||||
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 300000)))
|
||||
const [end, setEnd] = useState(() => toLocal(new Date()))
|
||||
const [limit, setLimit] = useState(1000)
|
||||
const [queryHistory, setQueryHistory] = useState(readQueryHistory)
|
||||
const [entries, setEntries] = useState<LogEntry[]>([])
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||
const [histogramLoading, setHistogramLoading] = useState(false)
|
||||
const [histogramError, setHistogramError] = useState(false)
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [pageCursors, setPageCursors] = useState<string[]>([''])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingStartedAt, setLoadingStartedAt] = useState<number | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [context, setContext] = useState<LogContext | null>(null)
|
||||
const [tab, setTab] = useState<'query' | 'sources' | 'access'>('query')
|
||||
const [sourceForm, setSourceForm] = useState<Partial<LogSource> & { basicPass?: string; bearerToken?: string }>({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [tail, setTail] = useState(false)
|
||||
const requestVersion = useRef(0)
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin])
|
||||
|
||||
async function reloadSources() {
|
||||
try {
|
||||
const result = await logsApi.sources()
|
||||
setSources(result.list)
|
||||
setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0))
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '加载数据源失败') }
|
||||
}
|
||||
useEffect(() => { void reloadSources() }, [])
|
||||
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
|
||||
|
||||
|
||||
function range(): { start: number; end: number } | null {
|
||||
const a = new Date(start).getTime(), b = new Date(end).getTime()
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError('请选择有效的起止时间'); return null }
|
||||
return { start: Number(ns(a)), end: Number(ns(b)) }
|
||||
}
|
||||
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
|
||||
const paging = targetPage !== undefined
|
||||
const r = paging ? executedRange : selectedRange ?? range()
|
||||
const searchQuery = paging ? executedQuery : overrideQuery ?? query.trim()
|
||||
if (!r || !sourceId || !searchQuery) return
|
||||
const version = ++requestVersion.current
|
||||
setLoading(true); setLoadingStartedAt(Date.now()); setError('')
|
||||
if (!paging) { setBuckets([]); setHistogramLoading(false); setHistogramError(false) }
|
||||
const data: LogQuery = { sourceId, query: searchQuery, ...r, limit, direction: 'backward', cursor: nextCursor }
|
||||
try {
|
||||
const result = await logsApi.query(data)
|
||||
if (version !== requestVersion.current) return
|
||||
if (paging && result.entries.length === 0) { setCursor(''); return }
|
||||
if (!paging) setQueryHistory(rememberQuery(data.query))
|
||||
setExecutedQuery(data.query)
|
||||
if (!paging) setExecutedRange(r)
|
||||
setSearched(true)
|
||||
setEntries(result.entries)
|
||||
setCursor(result.hasMore ? result.nextCursor : '')
|
||||
setPageIndex(targetPage ?? 0)
|
||||
setPageCursors(previous => paging ? [...previous.slice(0, targetPage), nextCursor] : [''])
|
||||
if (!paging) {
|
||||
setHistogramLoading(true)
|
||||
setHistogramError(false)
|
||||
void logsApi.histogram(data).then(histogram => {
|
||||
if (version === requestVersion.current) {
|
||||
setBuckets(histogram.map(item => ({ timestamp: String(item.timestamp), count: Number(item.count) || 0 })))
|
||||
}
|
||||
}).catch(() => { if (version === requestVersion.current) setHistogramError(true) })
|
||||
.finally(() => { if (version === requestVersion.current) setHistogramLoading(false) })
|
||||
}
|
||||
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : '查询失败') }
|
||||
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!tail || !sourceId || !query.trim()) return
|
||||
const poll = async () => {
|
||||
const now = Date.now()
|
||||
setEnd(toLocal(new Date(now)))
|
||||
setStart(toLocal(new Date(now - 300000)))
|
||||
try {
|
||||
const result = await logsApi.query({ sourceId, query, start: Number(ns(now - 300000)), end: Number(ns(now)), limit, direction: 'backward' })
|
||||
setExecutedQuery(query)
|
||||
setSearched(true)
|
||||
setEntries(result.entries)
|
||||
setCursor(result.hasMore ? result.nextCursor : '')
|
||||
setPageIndex(0)
|
||||
setPageCursors([''])
|
||||
} catch { /* 静默轮询,手动查询仍会显示错误 */ }
|
||||
}
|
||||
void poll()
|
||||
const timer = window.setInterval(() => void poll(), 3000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [tail, sourceId, query, limit])
|
||||
|
||||
async function showContext(row: LogEntry) {
|
||||
try {
|
||||
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
|
||||
setContext({ row, before: result.before.slice().reverse(), after: result.after })
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : '读取上下文失败') }
|
||||
}
|
||||
function saveSource() {
|
||||
setSaving(true)
|
||||
const action = sourceForm.id ? logsApi.updateSource(sourceForm.id, sourceForm) : logsApi.createSource(sourceForm)
|
||||
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : '保存失败')).finally(() => setSaving(false))
|
||||
}
|
||||
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
|
||||
const applyDomainFilter = (domain: string) => {
|
||||
const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}`
|
||||
setQuery(nextQuery)
|
||||
void search('', false, undefined, nextQuery)
|
||||
}
|
||||
const selectTimeRange = (from: number, to: number) => {
|
||||
setStart(toLocal(new Date(from))); setEnd(toLocal(new Date(to))); setTail(false)
|
||||
void search('', false, { start: Number(ns(from)), end: Number(ns(to)) })
|
||||
}
|
||||
|
||||
const pageTitle = tab === 'query' ? 'Loki 查询' : tab === 'access' ? '查询授权' : '数据源管理'
|
||||
const description = tab === 'query' ? '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。' : tab === 'access' ? '用策略组复用日志范围,并一次关联多个用户。' : '配置 Loki 连接与认证方式。'
|
||||
|
||||
return (
|
||||
<div className="-m-4 md:-m-6">
|
||||
<nav aria-label="日志中心导航" className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
|
||||
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>Loki 查询</Button>
|
||||
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>查询授权</Button>}
|
||||
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>数据源管理</Button>}
|
||||
</nav>
|
||||
<div className="p-4 md:p-6">
|
||||
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
|
||||
<div className="space-y-6">
|
||||
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
|
||||
{tab === 'query' && <section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
|
||||
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
|
||||
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? '查询中…' : '查询'}</Button>
|
||||
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? '停止 Tail' : 'Tail'}</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
|
||||
<select aria-label="数据源" className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>暂无可用数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground">每页条数<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
|
||||
<span className="text-xs text-muted-foreground">每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。</span>
|
||||
</div>
|
||||
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
|
||||
</section>}
|
||||
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
|
||||
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
|
||||
<h2 className="font-semibold">Loki 数据源</h2>
|
||||
|
||||
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
|
||||
<div className="space-y-2">
|
||||
{sources.map(source => {
|
||||
const managed = source.managed
|
||||
const cluster = clusters.find(c => c.id === source.clusterID)
|
||||
return managed ? (
|
||||
<div key={source.id} className="flex items-center gap-3 rounded-lg border bg-muted/40 px-3 py-2 text-sm">
|
||||
<Lock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="font-medium">{source.name}</span>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{cluster ? `集群 ${cluster.name}` : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">平台部署 · 随部署管理</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button key={source.id} size="sm" variant="outline" onClick={() => setSourceForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
|
||||
{source.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>+ 手动添加</Button>
|
||||
</div>
|
||||
|
||||
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<label>名称<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
|
||||
<div className="flex gap-2 items-end">
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>URL 直连</button>
|
||||
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>集群服务</button>
|
||||
</div>
|
||||
{sourceForm.targetType === 'url'
|
||||
? <label className="sm:col-span-2">Loki 地址<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
|
||||
: <>
|
||||
<label>集群<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
|
||||
<option value="">选择集群…</option>
|
||||
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select></label>
|
||||
<label>命名空间<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
|
||||
<label>Service<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
|
||||
<label>端口<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
|
||||
</>}
|
||||
<label>认证方式<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">无认证</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option><option value="headers">自定义 Headers</option></select></label>
|
||||
{sourceForm.authType === 'basic' && <><label>用户名<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>密码<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
|
||||
{sourceForm.authType === 'bearer' && <label>令牌<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
|
||||
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">自定义 Headers(JSON 对象)<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error('Headers 必须是字符串键值 JSON 对象') } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />跳过 TLS 校验</label>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />设为默认</label>
|
||||
</div>
|
||||
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>测试连接</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>保存数据源</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>删除</Button>}</div>
|
||||
</section>}
|
||||
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />正在统计整个时间范围的日志量,日志页可继续浏览。</div>}
|
||||
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。</p>}
|
||||
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
|
||||
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} buckets={buckets} start={executedRange ? toLocal(new Date(executedRange.start / 1e6)) : start} end={executedRange ? toLocal(new Date(executedRange.end / 1e6)) : end} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} 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)} />}
|
||||
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/** 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] || '/'
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 || '未记录 Host'
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getUser } from '@/lib/auth-storage'
|
||||
|
||||
const key = () => `opsxloki.logs.query-history.${getUser()?.id ?? 'anonymous'}`
|
||||
|
||||
export function readQueryHistory(): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(key()) ?? '[]')
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').slice(0, 20) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export function rememberQuery(query: string): string[] {
|
||||
const next = [query, ...readQueryHistory().filter(item => item !== query)].slice(0, 20)
|
||||
try { localStorage.setItem(key(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 日志查询纯工具(与组件分离)
|
||||
|
||||
/** 从 LogQL 提取 {...} 选择器部分 */
|
||||
export function extractSelector(query: string): string {
|
||||
const i = query.indexOf('{')
|
||||
const j = query.indexOf('}', i)
|
||||
return i >= 0 && j > i ? query.slice(i, j + 1) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key="value" 合并进 LogQL 的 {...} 选择器(同名标签替换旧值);exclude=true 用 != 排除
|
||||
*/
|
||||
export function applyLabelFilter(query: string, key: string, value: string, exclude = false): string {
|
||||
const op = exclude ? '!=' : '='
|
||||
const frag = `${key}${op}"${value}"`
|
||||
const i = query.indexOf('{')
|
||||
if (i < 0) return `{${frag}}`
|
||||
const j = query.indexOf('}', i)
|
||||
if (j < 0) return query
|
||||
const inner = query.slice(i + 1, j).trim()
|
||||
// 同名标签(=、!=、=~、!~)替换,其余保留
|
||||
const parts = inner ? inner.split(',').map((s) => s.trim()).filter(Boolean) : []
|
||||
const kept = parts.filter((p) => !new RegExp(`^${key}(=|!=|=~|!~)`).test(p))
|
||||
kept.push(frag)
|
||||
return query.slice(0, i + 1) + kept.join(', ') + query.slice(j)
|
||||
}
|
||||
|
||||
/** 提取 LogQL 管道中的字面过滤词(|= "x" 与 != "y"),用于结果行高亮 */
|
||||
export function literalFilters(query: string): string[] {
|
||||
const terms: string[] = []
|
||||
const re = /\|=\s*"((?:[^"\\]|\\.)*)"/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(query)) !== null) {
|
||||
if (m[1]) terms.push(m[1].replace(/\\"/g, '"'))
|
||||
}
|
||||
return terms
|
||||
}
|
||||
Reference in new issue
Block a user