diff --git a/frontend/src/i18n/locales/en/analysis.ts b/frontend/src/i18n/locales/en/analysis.ts new file mode 100644 index 0000000..f9781b7 --- /dev/null +++ b/frontend/src/i18n/locales/en/analysis.ts @@ -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', + }, +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/zh/analysis.ts b/frontend/src/i18n/locales/zh/analysis.ts new file mode 100644 index 0000000..0afefa6 --- /dev/null +++ b/frontend/src/i18n/locales/zh/analysis.ts @@ -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: '没有应用维度的数据', + }, +} \ No newline at end of file diff --git a/frontend/src/pages/analysis/index.tsx b/frontend/src/pages/analysis/index.tsx new file mode 100644 index 0000000..093aa42 --- /dev/null +++ b/frontend/src/pages/analysis/index.tsx @@ -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([]) + const [sourceId, setSourceId] = useState(0) + const [namespaces, setNamespaces] = useState([]) + 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 ( + + + + ) + } + + return ( + + {loading ? : } + {t('filter.refresh')} + + } + > + {/* 筛选栏:数据源 / 命名空间 / 时间范围 */} +
+ + + { setStart(nextStart); setEnd(nextEnd) }} /> +
+ + {sources.length === 0 ? ( + navigate('/logs')}> + {t('emptySource.goLogs')} + + } + /> + ) : error ? ( + + ) : loading && !data ? ( + + ) : !data || !sourceId ? null : ( + <> +
+ + + + +
+ +
+ + + {t('trend.title')} +

{t('trend.subtitle')}

+
+ +
+ + + {t('pie.title')} +

{t('pie.subtitle')}

+
+ +
+
+ + + + {t('stream.title')} +

{t('stream.subtitle', { count: Math.min(data.apps.length, 10) })}

+
+ +
+ + + +
+ {t('daily.title')} + {t('daily.fixedBadge')} +
+

{t('daily.subtitle')}

+
+ + {daily.error ? ( +

{daily.error}

+ ) : daily.loading && !daily.data ? ( + + ) : ( + + )} +
+
+ + + + {t('table.title')} +

{t('table.subtitle')}

+
+ + navigate(`/logs?q=${encodeURIComponent(query)}`)} + /> + +
+ + + +
+

{t('goQuery.title')}

+

{t('goQuery.desc')}

+
+ +
+
+ + )} +
+ ) +}) + +export default AnalysisPage diff --git a/frontend/src/pages/logs/components/LogResultsPanel.tsx b/frontend/src/pages/logs/components/LogResultsPanel.tsx index 0ca6f7f..615790c 100644 --- a/frontend/src/pages/logs/components/LogResultsPanel.tsx +++ b/frontend/src/pages/logs/components/LogResultsPanel.tsx @@ -4,29 +4,30 @@ import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import type { LogEntry } from '@/api/logs' -import { parseIngressAccessLog } from '../ingress-access-log' +import { parseIngressAccessLog } from '@/lib/ingress-access-log' import { applyLabelFilter } from '../query-utils' import { HighlightedLogLine } from './HighlightedLogLine' -import { LogResultViews, type ResultView } from './LogResultViews' import { QueryEmptyState } from './QueryEmptyState' -type Bucket = { timestamp: string; count: number } -const viewIds: ResultView[] = ['raw', 'table', 'line', 'stream', 'pie'] -const viewLabelKeys: Record = { - raw: 'results.viewRaw', - table: 'results.viewTable', - line: 'results.viewLine', - stream: 'results.viewStream', - pie: 'results.viewPie', +function levelBadgeClass(level: string) { + const value = (level || '').toLowerCase() + if (value.includes('error') || value === 'err' || value === 'fatal' || value === 'critical') { + return 'rounded bg-destructive/15 px-1 text-destructive' + } + if (value.includes('warn')) return 'rounded bg-warning/15 px-1 text-warning' + if (value.includes('info')) return 'rounded bg-info/15 px-1 text-info' + 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 }: { - entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean +// 只保留原始日志视图;表格/折线/流图/饼图等可视化已迁至「日志分析」页(LogDetailSection) + +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[] onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void }) { const { t } = useTranslation('logs') - const [view, setView] = useState('raw') const [domainFilter, setDomainFilter] = useState('') const [clock, setClock] = useState(0) useEffect(() => { @@ -34,10 +35,6 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou const timer = window.setInterval(() => setClock(Date.now()), 1000) return () => window.clearInterval(timer) }, [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 visibleEntries = useMemo(() => { const needle = domainFilter.trim().toLowerCase() @@ -46,9 +43,6 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou return

{t('results.title')} {hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}

-
- {views.map(item => )} -
{loading &&
@@ -62,17 +56,17 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou {t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}
} - {view === 'raw' ? entries.length === 0 ? : visibleEntries.length === 0 ?

{t('results.domainNoMatch')}

:
+ {entries.length === 0 ? : visibleEntries.length === 0 ?

{t('results.domainNoMatch')}

:
{visibleEntries.map((row, index) =>
- {row.level} + {row.level} {Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => )}
)} -
: } +
} {searched && entries.length > 0 &&
{t('results.pageOf', { page: pageIndex + 1 })} diff --git a/frontend/src/pages/logs/index.tsx b/frontend/src/pages/logs/index.tsx index 4d97ff9..84e862b 100644 --- a/frontend/src/pages/logs/index.tsx +++ b/frontend/src/pages/logs/index.tsx @@ -1,32 +1,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' +import '@/i18n/domains' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { PageContainer } from '@/components/PageContainer' +import { TimeRangePicker } from '@/components/TimeRangePicker' import { ErrorState } from '@/components/ErrorState' 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 { clusterApi } from '@/api' -import type { Cluster } from '@/types' -import { Play, Radio, Lock, Loader2 } from 'lucide-react' +import { Play, Radio, Loader2 } from 'lucide-react' import { QueryInput } from './components/QueryInput' import { LokiLabelBrowser } from './components/LokiLabelBrowser' import { LogVolumeTimeline } from './components/LogVolumeTimeline' -import { TimeRangePicker } from './components/TimeRangePicker' import { PolicyGroupsPage } from './components/PolicyGroupsPage' +import { SourcesManager } from './components/SourcesManager' import { LogContextDialog, type LogContext } from './components/LogContextDialog' import { LogResultsPanel } from './components/LogResultsPanel' import { literalFilters } from './query-utils' 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 ns = (ms: number) => Math.round(ms).toString() + '000000' @@ -57,12 +50,8 @@ export default function LogsPage() { const [error, setError] = useState('') const [context, setContext] = useState(null) const [tab, setTab] = useState<'query' | 'sources' | 'access'>('query') - const [sourceForm, setSourceForm] = useState & { basicPass?: string; bearerToken?: string }>({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) - const [saving, setSaving] = useState(false) const [tail, setTail] = useState(false) const requestVersion = useRef(0) - const [clusters, setClusters] = useState([]) - useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin]) const reloadSources = useCallback(async () => { try { @@ -74,6 +63,17 @@ export default function LogsPage() { useEffect(() => { void reloadSources() }, [reloadSources]) 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 { 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 }) } 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 applyDomainFilter = (domain: string) => { 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') return ( -
-
} {admin && tab === 'access' && } - {admin && tab === 'sources' &&
-

{t('source.heading')}

-

{t('source.hint')}

- - {/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */} -
- {sources.map(source => { - const managed = source.managed - const cluster = clusters.find(c => c.id === source.clusterID) - return managed ? ( -
- - {source.name} - - {cluster ? t('source.managedCluster', { name: cluster.name }) : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100} - - {t('source.managedBadge')} -
- ) : ( - - ) - })} - -
- - {/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */} -
- -
- - -
- {sourceForm.targetType === 'url' - ? - : <> - - - - - } - - {sourceForm.authType === 'basic' && <>} - {sourceForm.authType === 'bearer' && } - {sourceForm.authType === 'headers' &&