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,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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user