fix(frontend): logs/analysis P0 — 职责拆分与布局收口

砍 analysis 明细可视化改「去日志查询」;无数据源 EmptyState;日图标固定近7天。
logs 去掉负 margin、页头常显、筛选/查询分层;level 色阶;Label 保持默认收起。
This commit is contained in:
Codex committed 2026-10-28 21:54:40 +08:00
1 parent 4f75ef8578
commit f993d5b420
5 files changed
+431 -110

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
// Log analysis page (admin) — registered via i18n/domains.ts; page entry imports '@/i18n/domains'
export default {
title: 'Log Analysis',
subtitle: 'Log volume, errors and trends by app and namespace — spot the noisiest workloads',
adminOnly: {
title: 'Admin only',
desc: 'Log analysis provides platform-wide statistics for administrators. Contact an administrator for access.',
},
emptySource: {
title: 'No Loki data source yet',
desc: 'Connect a source on the log query page, then come back for aggregates.',
goLogs: 'Go to log query',
},
filter: {
source: 'Source',
sourceEmpty: 'No sources',
namespace: 'Namespace',
allNamespaces: 'All namespaces',
refresh: 'Refresh',
},
stats: {
total: 'Total logs',
errors: 'Error lines',
warnings: 'Warning lines',
apps: 'Active apps',
},
trend: {
title: 'Log volume trend',
subtitle: 'Total / errors / warnings bucketed over time',
seriesTotal: 'Total',
seriesError: 'Errors',
seriesWarn: 'Warnings',
empty: 'No log data in this time range',
},
stream: {
title: 'App volume stream',
subtitle: 'Stacked distribution over time for the top {{count}} apps by volume',
empty: 'No app-dimension data',
},
pie: {
title: 'Namespace distribution',
subtitle: 'Log volume share by namespace',
empty: 'No namespace-dimension data',
other: 'Other',
},
goQuery: {
title: 'Need raw logs?',
desc: 'Search, Tail, and context live on the log query page; this page is aggregates only.',
action: 'Go to log query',
},
daily: {
fixedBadge: 'Fixed last 7 days',
title: 'Daily volume by app',
subtitle: 'Top apps by calendar day for the last 7 days — not controlled by the range filter above',
empty: 'No app-dimension data in the last 7 days',
},
detail: {
title: 'Log detail visualization',
subtitle: 'Enter LogQL or use a preset to analyze up to 1000 sampled lines as table / line / stream / pie charts; ingress-nginx access logs are auto-detected for domain / status / path / upstream dimensions',
inputAria: 'LogQL detail query',
run: 'Run',
presetAll: 'All logs',
presetIngress: 'Ingress access',
presetError: 'Error logs',
viewAria: 'Visualization views',
view: { table: 'Table', line: 'Line', stream: 'Stream', pie: 'Pie' },
hint: 'Enter a LogQL query or click a preset to visualize',
queryFailed: 'Query failed',
},
table: {
title: 'App volume ranking',
subtitle: 'Top 10 apps by volume; click "View logs" to jump to the query page',
rank: '#',
app: 'App',
appWithKey: 'App ({{key}})',
namespace: 'Namespace',
volume: 'Volume',
share: 'Share',
errors: 'Errors',
errorRate: 'Error rate',
trend: 'Trend',
actions: 'Actions',
viewLogs: 'View logs',
untagged: '(untagged)',
empty: 'No app-dimension data',
},
}
+87
View File
@@ -0,0 +1,87 @@
// 日志分析页(admin)— 经 i18n/domains.ts 注册,页面入口 import '@/i18n/domains'
export default {
title: '日志分析',
subtitle: '按应用与命名空间统计日志量、错误量与趋势,定位日志大户',
adminOnly: {
title: '仅管理员可访问',
desc: '日志分析面向管理员提供全量日志统计,请联系管理员开通。',
},
emptySource: {
title: '还没有可用的 Loki 数据源',
desc: '请先在日志查询页关联数据源,再回来看聚合分析。',
goLogs: '去日志查询',
},
filter: {
source: '数据源',
sourceEmpty: '暂无数据源',
namespace: '命名空间',
allNamespaces: '全部命名空间',
refresh: '刷新',
},
stats: {
total: '日志总量',
errors: '错误行数',
warnings: '告警行数',
apps: '活跃应用',
},
trend: {
title: '日志量趋势',
subtitle: '总量 / 错误 / 告警 按时间分桶',
seriesTotal: '总量',
seriesError: '错误',
seriesWarn: '告警',
empty: '该时间范围内没有日志数据',
},
stream: {
title: '应用日志量流图',
subtitle: '日志量 Top {{count}} 应用随时间的堆叠分布',
empty: '没有应用维度的数据',
},
pie: {
title: '命名空间分布',
subtitle: '按命名空间统计的日志量占比',
empty: '没有命名空间维度的数据',
other: '其他',
},
goQuery: {
title: '需要看原始日志?',
desc: '检索、Tail、前后文请到日志查询页;本页只做聚合统计。',
action: '去日志查询',
},
daily: {
fixedBadge: '固定近 7 天',
title: '应用每日日志量',
subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响',
empty: '近 7 天没有应用维度的数据',
},
detail: {
title: '日志明细可视化',
subtitle: '输入 LogQL 或点预设,以表格 / 折线图 / 流图 / 饼图分析最多 1000 条样本;ingress-nginx 访问日志自动识别域名 / 状态码 / 路径 / 上游维度',
inputAria: 'LogQL 明细查询',
run: '查询',
presetAll: '全部日志',
presetIngress: 'Ingress 访问',
presetError: '错误日志',
viewAria: '可视化视图',
view: { table: '表格', line: '折线图', stream: '流图', pie: '饼图' },
hint: '输入 LogQL 或点击预设查询后展示可视化',
queryFailed: '查询失败',
},
table: {
title: '应用日志量排行',
subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页',
rank: '#',
app: '应用',
appWithKey: '应用({{key}})',
namespace: '命名空间',
volume: '日志量',
share: '占比',
errors: '错误量',
errorRate: '错误率',
trend: '趋势',
actions: '操作',
viewLogs: '查看日志',
untagged: '(未打标签)',
empty: '没有应用维度的数据',
},
}
+213
View File
@@ -0,0 +1,213 @@
import { memo, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { BarChart3, Boxes, FileWarning, Loader2, RefreshCw, ScrollText, TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { PageContainer } from '@/components/PageContainer'
import { EmptyState } from '@/components/EmptyState'
import { ErrorState } from '@/components/ErrorState'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { StatCard } from '@/components/StatCard'
import { TimeRangePicker } from '@/components/TimeRangePicker'
import { logsApi, type LogSource } from '@/api/logs'
import { getUser } from '@/lib/auth-storage'
import { useAnalysisMetrics } from './hooks/useAnalysisMetrics'
import { useDailyVolume } from './hooks/useDailyVolume'
import { TrendChart } from './components/TrendChart'
import { AppStreamChart } from './components/AppStreamChart'
import { DailyVolumeChart } from './components/DailyVolumeChart'
import { NamespacePie } from './components/NamespacePie'
import { TopAppsTable } from './components/TopAppsTable'
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
/**
* 日志分析页(admin 专用):SLS 风格仪表盘 — 统计卡 + 趋势折线 + 命名空间饼图 +
* 应用流图 + 应用排行表;数据来自 5 条 LogQL 指标查询(POST /logs/metrics)。
*/
const AnalysisPage = memo(function AnalysisPage() {
const { t } = useTranslation('analysis')
const navigate = useNavigate()
const isAdmin = getUser()?.role === 'admin'
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
const [namespaces, setNamespaces] = useState<string[]>([])
const [namespace, setNamespace] = useState('')
const [start, setStart] = useState(() => toLocal(new Date(Date.now() - 3600000)))
const [end, setEnd] = useState(() => toLocal(new Date()))
useEffect(() => {
logsApi.sources()
.then(result => {
setSources(result.list)
setSourceId(current =>
result.list.some(source => source.id === current)
? current
: result.list.find(source => source.isDefault)?.id ?? result.list[0]?.id ?? 0,
)
})
.catch(() => setSources([]))
}, [])
// 命名空间候选随数据源变化(切换数据源时在 onChange 里同步清掉已选命名空间)
useEffect(() => {
if (!sourceId) return
let active = true
logsApi.labelValues(sourceId, 'namespace')
.then(list => { if (active) setNamespaces(Array.isArray(list) ? list : []) })
.catch(() => { if (active) setNamespaces([]) })
return () => { active = false }
}, [sourceId])
const { data, loading, error, appLabel, refresh } = useAnalysisMetrics({ sourceId, start, end, namespace })
// 每日统计固定近 7 天,不随上方时间范围变化;错误就地展示,不影响主区
const daily = useDailyVolume({ sourceId, namespace, appLabel })
// 非 admin:菜单不可见,直访时给明确提示(后端 AdminAuth 403 兜底)
if (!isAdmin) {
return (
<PageContainer title={t('title')}>
<EmptyState icon={BarChart3} title={t('adminOnly.title')} description={t('adminOnly.desc')} />
</PageContainer>
)
}
return (
<PageContainer
title={t('title')}
description={t('subtitle')}
action={
<Button variant="outline" onClick={refresh} disabled={loading || !sourceId}>
{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <RefreshCw className="mr-1 size-4" />}
{t('filter.refresh')}
</Button>
}
>
{/* 筛选栏:数据源 / 命名空间 / 时间范围 */}
<div className="flex flex-wrap items-center gap-2">
<select
aria-label={t('filter.source')}
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)); setNamespace('') }}
>
{sources.length === 0 && <option value={0}>{t('filter.sourceEmpty')}</option>}
{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}
</select>
<select
aria-label={t('filter.namespace')}
className="h-9 min-w-36 rounded-md border bg-background px-3 text-sm text-foreground"
value={namespace}
onChange={event => setNamespace(event.target.value)}
disabled={!sourceId}
>
<option value="">{t('filter.allNamespaces')}</option>
{namespaces.map(item => <option key={item} value={item}>{item}</option>)}
</select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd) }} />
</div>
{sources.length === 0 ? (
<EmptyState
icon={BarChart3}
title={t('emptySource.title')}
description={t('emptySource.desc')}
action={
<Button variant="outline" onClick={() => navigate('/logs')}>
{t('emptySource.goLogs')}
</Button>
}
/>
) : error ? (
<ErrorState message={error} onRetry={refresh} />
) : loading && !data ? (
<LoadingSpinner className="h-64" />
) : !data || !sourceId ? null : (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label={t('stats.total')} value={data.stats.total.toLocaleString()} icon={ScrollText} iconColor="text-info" iconBg="bg-info/10" />
<StatCard label={t('stats.errors')} value={data.stats.errorCount.toLocaleString()} icon={FileWarning} iconColor="text-destructive" iconBg="bg-destructive/10" />
<StatCard label={t('stats.warnings')} value={data.stats.warnCount.toLocaleString()} icon={TriangleAlert} iconColor="text-warning" iconBg="bg-warning/10" />
<StatCard label={t('stats.apps')} value={data.stats.appCount} icon={Boxes} iconColor="text-primary" iconBg="bg-primary/10" />
</div>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('trend.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('trend.subtitle')}</p>
</CardHeader>
<CardContent><TrendChart trend={data.trend} /></CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('pie.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('pie.subtitle')}</p>
</CardHeader>
<CardContent><NamespacePie shares={data.namespaceShares} /></CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('stream.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}</p>
</CardHeader>
<CardContent><AppStreamChart apps={data.apps} appTimes={data.appTimes} /></CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{t('daily.title')}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{t('daily.fixedBadge')}</span>
</div>
<p className="text-xs text-muted-foreground">{t('daily.subtitle')}</p>
</CardHeader>
<CardContent>
{daily.error ? (
<p className="py-6 text-center text-xs text-destructive">{daily.error}</p>
) : daily.loading && !daily.data ? (
<LoadingSpinner className="h-40" />
) : (
<DailyVolumeChart days={daily.data?.days ?? []} apps={daily.data?.apps ?? []} />
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{t('table.title')}</CardTitle>
<p className="text-xs text-muted-foreground">{t('table.subtitle')}</p>
</CardHeader>
<CardContent>
<TopAppsTable
apps={data.apps.slice(0, 10)}
total={data.stats.total}
appLabel={appLabel}
onViewLogs={query => navigate(`/logs?q=${encodeURIComponent(query)}`)}
/>
</CardContent>
</Card>
<Card className="border-dashed">
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
<div>
<p className="text-sm font-medium">{t('goQuery.title')}</p>
<p className="text-xs text-muted-foreground">{t('goQuery.desc')}</p>
</div>
<Button variant="outline" onClick={() => navigate('/logs')}>
<ScrollText className="mr-1 size-4" />
{t('goQuery.action')}
</Button>
</CardContent>
</Card>
</>
)}
</PageContainer>
)
})
export default AnalysisPage
@@ -4,29 +4,30 @@ import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import type { LogEntry } from '@/api/logs' import type { LogEntry } from '@/api/logs'
import { parseIngressAccessLog } from '../ingress-access-log' import { parseIngressAccessLog } from '@/lib/ingress-access-log'
import { applyLabelFilter } from '../query-utils' import { applyLabelFilter } from '../query-utils'
import { HighlightedLogLine } from './HighlightedLogLine' import { HighlightedLogLine } from './HighlightedLogLine'
import { LogResultViews, type ResultView } from './LogResultViews'
import { QueryEmptyState } from './QueryEmptyState' import { QueryEmptyState } from './QueryEmptyState'
type Bucket = { timestamp: string; count: number } function levelBadgeClass(level: string) {
const viewIds: ResultView[] = ['raw', 'table', 'line', 'stream', 'pie'] const value = (level || '').toLowerCase()
const viewLabelKeys: Record<ResultView, string> = { if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') {
raw: 'results.viewRaw', return 'rounded bg-destructive/15 px-1 text-destructive'
table: 'results.viewTable', }
line: 'results.viewLine', if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning'
stream: 'results.viewStream', if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info'
pie: 'results.viewPie', if (value.includes('debug') || value.includes('trace')) return 'rounded bg-muted px-1 text-muted-foreground'
return 'rounded bg-muted px-1 text-muted-foreground'
} }
export function LogResultsPanel({ entries, buckets, start, end, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: { // 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection)
entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean
export function LogResultsPanel({ entries, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
entries: LogEntry[]; searched: boolean; hasSource: boolean
pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[] 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 onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
}) { }) {
const { t } = useTranslation('logs') const { t } = useTranslation('logs')
const [view, setView] = useState<ResultView>('raw')
const [domainFilter, setDomainFilter] = useState('') const [domainFilter, setDomainFilter] = useState('')
const [clock, setClock] = useState(0) const [clock, setClock] = useState(0)
useEffect(() => { useEffect(() => {
@@ -34,10 +35,6 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
const timer = window.setInterval(() => setClock(Date.now()), 1000) const timer = window.setInterval(() => setClock(Date.now()), 1000)
return () => window.clearInterval(timer) return () => window.clearInterval(timer)
}, [loading]) }, [loading])
const views = useMemo(
() => viewIds.map(id => ({ id, label: t(viewLabelKeys[id]) })),
[t],
)
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries]) 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 visibleEntries = useMemo(() => {
const needle = domainFilter.trim().toLowerCase() const needle = domainFilter.trim().toLowerCase()
@@ -46,9 +43,6 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}> return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2> <h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2>
<div role="tablist" aria-label={t('results.viewAria')} 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> </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"> {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" /> <Loader2 className="size-4 shrink-0 animate-spin text-info" />
@@ -62,17 +56,17 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button> <Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span> <span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
</div>} </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">{t('results.domainNoMatch')}</p> : <div className="divide-y"> {entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : <div className="divide-y">
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm"> {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"> <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> <time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
<span className="rounded bg-muted px-1">{row.level}</span> <span className={levelBadgeClass(row.level)}>{row.level}</span>
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)} {Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button> <button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button>
</div> </div>
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" /> <HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
</div>)} </div>)}
</div> : <LogResultViews view={view} entries={visibleEntries} buckets={buckets} start={start} end={end} domainFiltered={Boolean(domainFilter.trim())} />} </div>}
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2"> {searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button> <Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
<span className="text-xs tabular-nums text-muted-foreground">{t('results.pageOf', { page: pageIndex + 1 })}</span> <span className="text-xs tabular-nums text-muted-foreground">{t('results.pageOf', { page: pageIndex + 1 })}</span>
+27 -87
View File
@@ -1,32 +1,25 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { PageContainer } from '@/components/PageContainer' import { PageContainer } from '@/components/PageContainer'
import { TimeRangePicker } from '@/components/TimeRangePicker'
import { ErrorState } from '@/components/ErrorState' import { ErrorState } from '@/components/ErrorState'
import { getUser } from '@/lib/auth-storage' import { getUser } from '@/lib/auth-storage'
import i18n from '@/i18n'
import zhLogs from '@/i18n/locales/zh/logs'
import enLogs from '@/i18n/locales/en/logs'
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs' import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
import { clusterApi } from '@/api' import { Play, Radio, Loader2 } from 'lucide-react'
import type { Cluster } from '@/types'
import { Play, Radio, Lock, Loader2 } from 'lucide-react'
import { QueryInput } from './components/QueryInput' import { QueryInput } from './components/QueryInput'
import { LokiLabelBrowser } from './components/LokiLabelBrowser' import { LokiLabelBrowser } from './components/LokiLabelBrowser'
import { LogVolumeTimeline } from './components/LogVolumeTimeline' import { LogVolumeTimeline } from './components/LogVolumeTimeline'
import { TimeRangePicker } from './components/TimeRangePicker'
import { PolicyGroupsPage } from './components/PolicyGroupsPage' import { PolicyGroupsPage } from './components/PolicyGroupsPage'
import { SourcesManager } from './components/SourcesManager'
import { LogContextDialog, type LogContext } from './components/LogContextDialog' import { LogContextDialog, type LogContext } from './components/LogContextDialog'
import { LogResultsPanel } from './components/LogResultsPanel' import { LogResultsPanel } from './components/LogResultsPanel'
import { literalFilters } from './query-utils' import { literalFilters } from './query-utils'
import { readQueryHistory, rememberQuery } from './query-history' import { readQueryHistory, rememberQuery } from './query-history'
// 注册 logs 命名空间(域词典集中在页面入口挂载,域内组件统一 useTranslation('logs'))
i18n.addResourceBundle('zh', 'logs', zhLogs)
i18n.addResourceBundle('en', 'logs', enLogs)
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const ns = (ms: number) => Math.round(ms).toString() + '000000' const ns = (ms: number) => Math.round(ms).toString() + '000000'
@@ -57,12 +50,8 @@ export default function LogsPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [context, setContext] = useState<LogContext | null>(null) const [context, setContext] = useState<LogContext | null>(null)
const [tab, setTab] = useState<'query' | 'sources' | 'access'>('query') 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 [tail, setTail] = useState(false)
const requestVersion = useRef(0) 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])
const reloadSources = useCallback(async () => { const reloadSources = useCallback(async () => {
try { try {
@@ -74,6 +63,17 @@ export default function LogsPage() {
useEffect(() => { void reloadSources() }, [reloadSources]) useEffect(() => { void reloadSources() }, [reloadSources])
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId]) useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
// 从日志分析页等入口跳转:带 ?q= 预填输入框并尝试执行一次;执行后清掉参数防刷新重复执行。
// 预填不等数据源就绪(无数据源时也要让用户看到带来的查询),是否真正执行由 search 内部判断
useEffect(() => {
const prefill = new URLSearchParams(window.location.search).get('q')?.trim()
if (!prefill) return
setQuery(prefill)
void search('', false, undefined, prefill)
window.history.replaceState(null, '', window.location.pathname)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function range(): { start: number; end: number } | null { function range(): { start: number; end: number } | null {
const a = new Date(start).getTime(), b = new Date(end).getTime() const a = new Date(start).getTime(), b = new Date(end).getTime()
@@ -141,11 +141,6 @@ export default function LogsPage() {
setContext({ row, before: result.before.slice().reverse(), after: result.after }) setContext({ row, before: result.before.slice().reverse(), after: result.after })
} catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) } } catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) }
} }
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 : t('source.saveFailed'))).finally(() => setSaving(false))
}
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery]) const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
const applyDomainFilter = (domain: string) => { const applyDomainFilter = (domain: string) => {
const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}` const nextQuery = `${query.trim()} |= ${JSON.stringify(domain)}`
@@ -161,14 +156,13 @@ export default function LogsPage() {
const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources') const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources')
return ( return (
<div className="-m-4 md:-m-6"> <div>
<nav aria-label={t('nav.aria')} className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6"> <nav aria-label={t('nav.aria')} className="mb-4 flex flex-wrap gap-1 border-b pb-3">
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button> <Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button>
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>} {admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>}
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>} {admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>}
</nav> </nav>
<div className="p-4 md:p-6"> <PageContainer title={pageTitle} description={description}>
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
<div className="space-y-6"> <div className="space-y-6">
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />} {error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
{tab === 'query' && <section className="space-y-4"> {tab === 'query' && <section className="space-y-4">
@@ -183,83 +177,29 @@ export default function LogsPage() {
)} )}
</div> </div>
)} )}
<div className="flex flex-wrap items-center gap-2">
<select aria-label={t('toolbar.sourceAria')} 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}>{t('toolbar.sourceEmptyOption')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<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>
</div>
<div className="flex flex-wrap items-center gap-2"> <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> <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> <div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={(runQuery) => void search('', false, undefined, runQuery)} 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 ? t('toolbar.running') : t('toolbar.run')}</Button> <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 ? t('toolbar.running') : t('toolbar.run')}</Button>
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button> <Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <p className="text-xs text-muted-foreground">{t('toolbar.limitHint')}</p>
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<select aria-label={t('toolbar.sourceAria')} 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}>{t('toolbar.sourceEmptyOption')}</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">{t('toolbar.limitLabel')}<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">{t('toolbar.limitHint')}</span>
</div>
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} /> <LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
</section>} </section>}
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />} {admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label={t('source.sectionAria')}> {admin && tab === 'sources' && <SourcesManager sources={sources} onSaved={() => void reloadSources()} />}
<h2 className="font-semibold">{t('source.heading')}</h2>
<p className="text-xs text-muted-foreground">{t('source.hint')}</p>
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
<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 ? t('source.managedCluster', { name: 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">{t('source.managedBadge')}</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: [] })}>{t('source.add')}</Button>
</div>
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
<div className="grid gap-2 sm:grid-cols-2">
<label>{t('source.nameLabel')}<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' })}>{t('source.targetUrl')}</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 })}>{t('source.targetClusterSvc')}</button>
</div>
{sourceForm.targetType === 'url'
? <label className="sm:col-span-2">{t('source.addressLabel')}<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
: <>
<label>{t('source.clusterLabel')}<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="">{t('source.clusterPlaceholder')}</option>
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select></label>
<label>{t('source.namespaceLabel')}<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
<label>{t('source.serviceLabel')}<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
<label>{t('source.portLabel')}<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
</>}
<label>{t('source.authLabel')}<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">{t('source.authNone')}</option><option value="basic">{t('source.authBasic')}</option><option value="bearer">{t('source.authBearer')}</option><option value="headers">{t('source.authHeaders')}</option></select></label>
{sourceForm.authType === 'basic' && <><label>{t('source.usernameLabel')}<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>{t('source.passwordLabel')}<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
{sourceForm.authType === 'bearer' && <label>{t('source.tokenLabel')}<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">{t('source.headersLabel')}<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(t('source.headersInvalid')) } }} 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 })} />{t('source.skipTLS')}</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />{t('source.setDefault')}</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))}>{t('source.test')}</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>{t('source.save')}</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))}>{t('source.remove')}</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" />{t('volume.statsLoading')}</div>} {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" />{t('volume.statsLoading')}</div>}
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{t('volume.statsError')}</p>} {tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{t('volume.statsError')}</p>}
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />} {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)} />} {tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} 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)} /> <LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
</div> </div>
</PageContainer> </PageContainer>
</div>
</div> </div>
) )
} }