fix(frontend): logs/analysis P0 — 职责拆分与布局收口
砍 analysis 明细可视化改「去日志查询」;无数据源 EmptyState;日图标固定近7天。 logs 去掉负 margin、页头常显、筛选/查询分层;level 色阶;Label 保持默认收起。
This commit is contained in:
1 parent
4f75ef8578
commit
f993d5b420
5 files changed
+431
-110
No files matched your search
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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: '没有应用维度的数据',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user