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:
logan committed 2026-10-25 21:28:32 +08:00
1 parent f9c6c1d826
commit 2d73661dd1
127 files changed
+8117 -2876

No files matched your search

@@ -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>
}