fix(frontend): 补齐 analysis 构建依赖与共享组件搬迁
补 commit hooks/图表组件、路由与 i18n domains、logs metrics API; StatCard/TimeRangePicker/ingress-access-log 抽到共享路径,干净 checkout 可编过。
This commit is contained in:
1 parent
f993d5b420
commit
11d0fff764
22 files changed
+552
-313
No files matched your search
@@ -14,6 +14,11 @@ export interface LogEntry { timestamp: string; timestampNs: string; level: strin
|
|||||||
export interface HistogramBucket { timestamp: string; count: number }
|
export interface HistogramBucket { timestamp: string; count: number }
|
||||||
export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string }
|
export interface LogResult { entries: LogEntry[]; hasMore: boolean; nextCursor: string }
|
||||||
export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string }
|
export interface LogQuery { sourceId: number; query: string; start: number; end: number; limit?: number; direction?: 'backward' | 'forward'; cursor?: string }
|
||||||
|
/** 指标序列采样点(日志分析页) */
|
||||||
|
export interface MetricPoint { timestamp: string; value: number }
|
||||||
|
/** 一条按标签分组的指标序列(如 sum by (app)(...) 中每个 app 一条) */
|
||||||
|
export interface MetricSeries { labels: Record<string, string>; points: MetricPoint[] }
|
||||||
|
export interface MetricQuery { sourceId: number; query: string; start: number; end: number }
|
||||||
export const logsApi = {
|
export const logsApi = {
|
||||||
sources: () => http.get<PageData<LogSource>>('/logs/sources'),
|
sources: () => http.get<PageData<LogSource>>('/logs/sources'),
|
||||||
createSource: (source: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) => http.post<LogSource>('/logs/sources', source),
|
createSource: (source: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) => http.post<LogSource>('/logs/sources', source),
|
||||||
@@ -23,6 +28,8 @@ export const logsApi = {
|
|||||||
query: (data: LogQuery) => http.post<LogResult>('/logs/query', data, { timeout: 125000 }),
|
query: (data: LogQuery) => http.post<LogResult>('/logs/query', data, { timeout: 125000 }),
|
||||||
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
|
context: (data: { sourceId: number; timestampNs: string; labels: Record<string, string> }) => http.post<{ before: LogEntry[]; after: LogEntry[] }>('/logs/context', data),
|
||||||
histogram: (data: LogQuery) => http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 125000 }),
|
histogram: (data: LogQuery) => http.post<Array<Record<string, unknown>>>('/logs/histogram', data, { timeout: 125000 }),
|
||||||
|
/** LogQL 指标查询(admin):query 中的 [$step] 占位符由后端按时间跨度替换 */
|
||||||
|
metrics: (data: MetricQuery, opts?: { signal?: AbortSignal }) => http.post<MetricSeries[]>('/logs/metrics', data, { timeout: 125000, ...opts }),
|
||||||
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
|
labels: (sourceId: number, start?: number, end?: number) => http.get<string[]>(`/logs/labels${buildQuery({ sourceId, start, end } as { page?: number; pageSize?: number })}`),
|
||||||
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
|
labelValues: (sourceId: number, name: string, query = '', start?: number, end?: number) => http.get<string[]>(`/logs/label/${encodeURIComponent(name)}/values${buildQuery({ sourceId, query, start, end } as { page?: number; pageSize?: number })}`),
|
||||||
/** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */
|
/** 当前用户在某数据源上的授权范围(策略 labels;管理员 unrestricted=true) */
|
||||||
|
|||||||
File renamed without changes.
+7
-7
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { getUser } from '@/lib/auth-storage'
|
import { getUser } from '@/lib/auth-storage'
|
||||||
|
|
||||||
// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数
|
// 共享时间范围选择器(原 logs 页私有,日志分析页复用后晋升;词典用 common.range)
|
||||||
const presets = [
|
const presets = [
|
||||||
{ unit: 'minutes', count: 5, ms: 300000 },
|
{ unit: 'minutes', count: 5, ms: 300000 },
|
||||||
{ unit: 'minutes', count: 15, ms: 900000 },
|
{ unit: 'minutes', count: 15, ms: 900000 },
|
||||||
@@ -25,12 +25,11 @@ function readHistory(): SavedRange[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
|
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
|
||||||
const { t } = useTranslation('logs')
|
const { t } = useTranslation('common')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [draftStart, setDraftStart] = useState(start)
|
const [draftStart, setDraftStart] = useState(start)
|
||||||
const [draftEnd, setDraftEnd] = useState(end)
|
const [draftEnd, setDraftEnd] = useState(end)
|
||||||
const [history, setHistory] = useState(readHistory)
|
const [history, setHistory] = useState(readHistory)
|
||||||
const [selection, setSelection] = useState({ start, end, ms: 300000 as number | null })
|
|
||||||
const root = useRef<HTMLDivElement>(null)
|
const root = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,9 +38,8 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
|
|||||||
return () => document.removeEventListener('pointerdown', close)
|
return () => document.removeEventListener('pointerdown', close)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
function choose(nextStart: string, nextEnd: string, nextPreset: number | null = null) {
|
function choose(nextStart: string, nextEnd: string) {
|
||||||
if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return
|
if (!(new Date(nextStart).getTime() < new Date(nextEnd).getTime())) return
|
||||||
setSelection({ start: nextStart, end: nextEnd, ms: nextPreset })
|
|
||||||
onChange(nextStart, nextEnd)
|
onChange(nextStart, nextEnd)
|
||||||
setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false)
|
setDraftStart(nextStart); setDraftEnd(nextEnd); setOpen(false)
|
||||||
const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5)
|
const next = [{ start: nextStart, end: nextEnd }, ...history.filter(item => item.start !== nextStart || item.end !== nextEnd)].slice(0, 5)
|
||||||
@@ -52,7 +50,9 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
|
|||||||
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
|
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
|
||||||
? t('range.lastMinutes', { count: item.count })
|
? t('range.lastMinutes', { count: item.count })
|
||||||
: t('range.lastHours', { count: item.count })
|
: t('range.lastHours', { count: item.count })
|
||||||
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
|
// 高亮预设按 start/end 实际跨度推导:外部传入的初始范围(如分析页默认 1 小时)也能正确标注,
|
||||||
|
// 不再依赖内部记录的 ms(旧逻辑初始误标「最近 5 分钟」)
|
||||||
|
const activePreset = presets.find(item => item.ms === new Date(end).getTime() - new Date(start).getTime())
|
||||||
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
|
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
|
||||||
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
|
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
|
|||||||
</Button>
|
</Button>
|
||||||
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
|
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
|
||||||
<h2 className="text-sm font-medium">{t('range.title')}</h2>
|
<h2 className="text-sm font-medium">{t('range.title')}</h2>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{presetLabel(item)}</Button>)}</div>
|
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now))) }}>{presetLabel(item)}</Button>)}</div>
|
||||||
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
|
||||||
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
|
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
|
||||||
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
|
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
|
||||||
@@ -14,3 +14,5 @@ export { default as PasswordInput } from './PasswordInput';
|
|||||||
export { default as StatusBadge, StatusIcon } from './StatusBadge';
|
export { default as StatusBadge, StatusIcon } from './StatusBadge';
|
||||||
export { getStatusColorClass } from './status-config';
|
export { getStatusColorClass } from './status-config';
|
||||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||||
|
export { TimeRangePicker } from './TimeRangePicker';
|
||||||
|
export { StatCard } from './StatCard';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// 页面域命名空间注册 — dashboard / users / alerts 三域词典在此挂载。
|
// 页面域命名空间注册 — dashboard / users / alerts / analysis / logs 等域词典在此挂载。
|
||||||
// 约束:index.ts 只注册 common(由他人维护,不做改动);域词典通过本模块以
|
// 约束:index.ts 只注册 common(由他人维护,不做改动);域词典通过本模块以
|
||||||
// addResourceBundle 挂载,由各域页面入口副作用 import 引入(页面为懒加载 chunk,
|
// addResourceBundle 挂载,由各域页面入口副作用 import 引入(页面为懒加载 chunk,
|
||||||
// chunk 求值先于组件渲染,因此 useTranslation('<domain>') 时资源必然已就绪)。
|
// chunk 求值先于组件渲染,因此 useTranslation('<domain>') 时资源必然已就绪)。
|
||||||
@@ -11,6 +11,10 @@ import zhUsers from './locales/zh/users'
|
|||||||
import enUsers from './locales/en/users'
|
import enUsers from './locales/en/users'
|
||||||
import zhAlerts from './locales/zh/alerts'
|
import zhAlerts from './locales/zh/alerts'
|
||||||
import enAlerts from './locales/en/alerts'
|
import enAlerts from './locales/en/alerts'
|
||||||
|
import zhAnalysis from './locales/zh/analysis'
|
||||||
|
import enAnalysis from './locales/en/analysis'
|
||||||
|
import zhLogs from './locales/zh/logs'
|
||||||
|
import enLogs from './locales/en/logs'
|
||||||
|
|
||||||
const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
||||||
['zh', 'dashboard', zhDashboard],
|
['zh', 'dashboard', zhDashboard],
|
||||||
@@ -19,6 +23,10 @@ const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
|
|||||||
['en', 'users', enUsers],
|
['en', 'users', enUsers],
|
||||||
['zh', 'alerts', zhAlerts],
|
['zh', 'alerts', zhAlerts],
|
||||||
['en', 'alerts', enAlerts],
|
['en', 'alerts', enAlerts],
|
||||||
|
['zh', 'analysis', zhAnalysis],
|
||||||
|
['en', 'analysis', enAnalysis],
|
||||||
|
['zh', 'logs', zhLogs],
|
||||||
|
['en', 'logs', enLogs],
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const [lng, ns, resources] of DOMAIN_BUNDLES) {
|
for (const [lng, ns, resources] of DOMAIN_BUNDLES) {
|
||||||
|
|||||||
@@ -54,19 +54,7 @@ export default {
|
|||||||
subtitle: 'Top apps by calendar day for the last 7 days — not controlled by the range filter above',
|
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',
|
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: {
|
table: {
|
||||||
title: 'App volume ranking',
|
title: 'App volume ranking',
|
||||||
subtitle: 'Top 10 apps by volume; click "View logs" to jump to the query page',
|
subtitle: 'Top 10 apps by volume; click "View logs" to jump to the query page',
|
||||||
|
|||||||
@@ -54,19 +54,7 @@ export default {
|
|||||||
subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响',
|
subtitle: '固定统计近 7 个自然日的 Top 应用分布,不受上方时间范围影响',
|
||||||
empty: '近 7 天没有应用维度的数据',
|
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: {
|
table: {
|
||||||
title: '应用日志量排行',
|
title: '应用日志量排行',
|
||||||
subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页',
|
subtitle: '按日志量排序的 Top 10 应用,点击「查看日志」跳转查询页',
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,38 @@
|
|||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { AppRow } from '../hooks/useAnalysisMetrics'
|
||||||
|
import { chartColors } from './chart-shared'
|
||||||
|
import { EmptyChart } from './EmptyChart'
|
||||||
|
|
||||||
|
const TOP_N = 10
|
||||||
|
|
||||||
|
/** 应用日志量流图:Top N 应用按时间堆叠面积(即流图/stream graph) */
|
||||||
|
export const AppStreamChart = memo(function AppStreamChart({ apps, appTimes }: { apps: AppRow[]; appTimes: string[] }) {
|
||||||
|
const { t } = useTranslation('analysis')
|
||||||
|
// 未打 app 标签的序列用占位 key(recharts 的 dataKey 不能是空串),图例显示词典文案
|
||||||
|
const topApps = useMemo(() => apps
|
||||||
|
.filter(app => app.total > 0)
|
||||||
|
.slice(0, TOP_N)
|
||||||
|
.map(app => ({ key: app.app || '__untagged__', label: app.app || t('table.untagged'), sparkline: app.sparkline })), [apps, t])
|
||||||
|
const data = useMemo(() => appTimes.map((time, index) => ({
|
||||||
|
time,
|
||||||
|
...Object.fromEntries(topApps.map(app => [app.key, app.sparkline[index] ?? 0])),
|
||||||
|
})), [appTimes, topApps])
|
||||||
|
if (topApps.length === 0 || appTimes.length === 0) return <EmptyChart message={t('stream.empty')} />
|
||||||
|
return <div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={data} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={48} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{topApps.map((app, index) => {
|
||||||
|
const color = chartColors[index % chartColors.length]
|
||||||
|
return <Area key={app.key} type="monotone" dataKey={app.key} name={app.label} stackId="logs" stroke={color} fill={color} fillOpacity={0.65} />
|
||||||
|
})}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { DailyAppRow } from '../hooks/useDailyVolume'
|
||||||
|
import { chartColors } from './chart-shared'
|
||||||
|
import { EmptyChart } from './EmptyChart'
|
||||||
|
|
||||||
|
/** 应用每日日志量堆叠柱状图:x 轴日期,系列为 7 天总量 Top 10 应用 */
|
||||||
|
export const DailyVolumeChart = memo(function DailyVolumeChart({ days, apps }: {
|
||||||
|
days: string[]
|
||||||
|
apps: DailyAppRow[]
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('analysis')
|
||||||
|
// 未打 app 标签的序列用占位 key(recharts dataKey 不能是空串),图例显示词典文案
|
||||||
|
const topApps = useMemo(() => apps
|
||||||
|
.map(app => ({ key: app.app || '__untagged__', label: app.app || t('table.untagged'), totals: app.totals })), [apps, t])
|
||||||
|
const data = useMemo(() => days.map((day, index) => ({
|
||||||
|
day,
|
||||||
|
...Object.fromEntries(topApps.map(app => [app.key, app.totals[index] ?? 0])),
|
||||||
|
})), [days, topApps])
|
||||||
|
if (topApps.length === 0 || days.length === 0) return <EmptyChart message={t('daily.empty')} />
|
||||||
|
return <div className="h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={data} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="day" tick={{ fontSize: 11 }} minTickGap={20} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={56} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{topApps.map((app, index) => {
|
||||||
|
const color = chartColors[index % chartColors.length]
|
||||||
|
return <Bar key={app.key} dataKey={app.key} name={app.label} stackId="daily" fill={color} />
|
||||||
|
})}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function EmptyChart({ message }: { message: string }) {
|
||||||
|
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { memo, useMemo } from 'react'
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { chartColors, compactNumber } from './chart-shared'
|
||||||
|
import { EmptyChart } from './EmptyChart'
|
||||||
|
|
||||||
|
const TOP_SLICES = 8
|
||||||
|
|
||||||
|
/** 命名空间分布环形饼图 + 侧边图例(超过 8 个归并「其他」) */
|
||||||
|
export const NamespacePie = memo(function NamespacePie({ shares }: { shares: Array<{ name: string; count: number }> }) {
|
||||||
|
const { t } = useTranslation('analysis')
|
||||||
|
const pieData = useMemo(() => {
|
||||||
|
const sorted = [...shares].sort((a, b) => b.count - a.count)
|
||||||
|
const top = sorted.slice(0, TOP_SLICES).map(item => ({ name: item.name, count: item.count }))
|
||||||
|
const rest = sorted.slice(TOP_SLICES).reduce((sum, item) => sum + item.count, 0)
|
||||||
|
if (rest > 0) top.push({ name: t('pie.other'), count: rest })
|
||||||
|
return top
|
||||||
|
}, [shares, t])
|
||||||
|
if (pieData.length === 0) return <EmptyChart message={t('pie.empty')} />
|
||||||
|
return <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_200px]">
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={pieData} dataKey="count" nameKey="name" outerRadius="78%" innerRadius="45%">
|
||||||
|
{pieData.map((item, index) => <Cell key={item.name} fill={chartColors[index % chartColors.length]} />)}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-center gap-2 text-xs">
|
||||||
|
{pieData.map((item, index) => (
|
||||||
|
<div key={item.name} className="flex items-center gap-2">
|
||||||
|
<span className="size-2.5 shrink-0 rounded-sm" style={{ background: chartColors[index % chartColors.length] }} />
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span>
|
||||||
|
<strong className="tabular-nums">{compactNumber(item.count)}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { AppRow } from '../hooks/useAnalysisMetrics'
|
||||||
|
|
||||||
|
/** 迷你趋势 sparkline:纯 SVG polyline,避免每行一个 recharts 实例 */
|
||||||
|
function Sparkline({ values }: { values: number[] }) {
|
||||||
|
const max = Math.max(...values, 1)
|
||||||
|
const width = 88
|
||||||
|
const height = 26
|
||||||
|
const points = values
|
||||||
|
.map((value, index) => `${(index / Math.max(values.length - 1, 1)) * width},${height - (value / max) * (height - 4) - 2}`)
|
||||||
|
.join(' ')
|
||||||
|
return <svg width={width} height={height} className="text-primary" aria-hidden>
|
||||||
|
<polyline points={points} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用日志量排行表:哪个 deployment 日志量最大、错误最多一目了然 */
|
||||||
|
export const TopAppsTable = memo(function TopAppsTable({
|
||||||
|
apps, total, appLabel, onViewLogs,
|
||||||
|
}: {
|
||||||
|
apps: AppRow[]
|
||||||
|
total: number
|
||||||
|
appLabel: string
|
||||||
|
onViewLogs: (query: string) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation('analysis')
|
||||||
|
if (apps.length === 0) {
|
||||||
|
return <p className="py-8 text-center text-sm text-muted-foreground">{t('table.empty')}</p>
|
||||||
|
}
|
||||||
|
const appHeader = appLabel === 'app' ? t('table.app') : t('table.appWithKey', { key: appLabel })
|
||||||
|
return <div className="max-h-[26rem] overflow-auto rounded-lg border">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="sticky top-0 bg-muted text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{[t('table.rank'), appHeader, t('table.namespace'), t('table.volume'), t('table.share'), t('table.errors'), t('table.errorRate'), t('table.trend'), t('table.actions')].map(title => (
|
||||||
|
<th key={title} className="whitespace-nowrap border-b px-3 py-2 font-medium">{title}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{apps.map((row, index) => {
|
||||||
|
const selector = `{namespace="${row.namespace}", ${appLabel}="${row.app}"}`
|
||||||
|
return <tr key={`${row.app}-${row.namespace}`} className="hover:bg-muted/40">
|
||||||
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{index + 1}</td>
|
||||||
|
<td className="max-w-56 truncate px-3 py-2 font-medium" title={row.app || undefined}>{row.app || t('table.untagged')}</td>
|
||||||
|
<td className="max-w-40 truncate px-3 py-2 text-muted-foreground" title={row.namespaces.join(', ')}>{row.namespace || '—'}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums">{row.total.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{total > 0 ? `${((row.total / total) * 100).toFixed(1)}%` : '—'}</td>
|
||||||
|
<td className={`px-3 py-2 tabular-nums ${row.errorCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{row.errorCount.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-2 tabular-nums text-muted-foreground">{row.total > 0 ? `${((row.errorCount / row.total) * 100).toFixed(2)}%` : '—'}</td>
|
||||||
|
<td className="px-3 py-2"><Sparkline values={row.sparkline} /></td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onViewLogs(selector)}>{t('table.viewLogs')}</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { EmptyChart } from './EmptyChart'
|
||||||
|
|
||||||
|
interface TrendPoint { time: string; total: number; error: number; warn: number }
|
||||||
|
|
||||||
|
/** 日志量趋势折线图:总量 / 错误 / 告警 三条线 */
|
||||||
|
export const TrendChart = memo(function TrendChart({ trend }: { trend: TrendPoint[] }) {
|
||||||
|
const { t } = useTranslation('analysis')
|
||||||
|
const hasData = trend.some(point => point.total > 0 || point.error > 0 || point.warn > 0)
|
||||||
|
if (!hasData) return <EmptyChart message={t('trend.empty')} />
|
||||||
|
const series = [
|
||||||
|
{ key: 'total', name: t('trend.seriesTotal'), color: 'hsl(var(--info))' },
|
||||||
|
{ key: 'error', name: t('trend.seriesError'), color: 'hsl(var(--destructive))' },
|
||||||
|
{ key: 'warn', name: t('trend.seriesWarn'), color: 'hsl(var(--warning))' },
|
||||||
|
] as const
|
||||||
|
return <div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={trend} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} width={48} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
{series.map(item => (
|
||||||
|
<Line key={item.key} type="monotone" dataKey={item.key} name={item.name} stroke={item.color} strokeWidth={2} dot={false} />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// 分析页图表共享常量(与 LogResultViews 一致的语义 token 配色)
|
||||||
|
export const chartColors = [
|
||||||
|
'hsl(var(--info))',
|
||||||
|
'hsl(var(--success))',
|
||||||
|
'hsl(var(--warning))',
|
||||||
|
'hsl(var(--primary))',
|
||||||
|
'hsl(var(--destructive))',
|
||||||
|
'hsl(var(--muted-foreground))',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 数字缩写:1.2万 / 3.4k(表格与图例用,避免长数字撑爆列宽) */
|
||||||
|
export function compactNumber(value: number): string {
|
||||||
|
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}亿`
|
||||||
|
if (value >= 10000) return `${(value / 10000).toFixed(1)}万`
|
||||||
|
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { logsApi, type MetricSeries } from '@/api/logs'
|
||||||
|
|
||||||
|
/** 表格/流图的一行:一个应用(跨命名空间聚合) */
|
||||||
|
export interface AppRow {
|
||||||
|
app: string
|
||||||
|
namespace: string
|
||||||
|
namespaces: string[]
|
||||||
|
total: number
|
||||||
|
errorCount: number
|
||||||
|
sparkline: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisData {
|
||||||
|
trend: Array<{ time: string; total: number; error: number; warn: number }>
|
||||||
|
namespaceShares: Array<{ name: string; count: number }>
|
||||||
|
apps: AppRow[]
|
||||||
|
/** 流图/趋势缩略的时间桶标签(本地时间 HH:mm) */
|
||||||
|
appTimes: string[]
|
||||||
|
stats: { total: number; errorCount: number; warnCount: number; appCount: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisParams {
|
||||||
|
sourceId: number
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
namespace: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||||
|
const timeLabel = (ts: string) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
||||||
|
function seriesTotal(series: MetricSeries[]): number {
|
||||||
|
return series.reduce((sum, item) => sum + item.points.reduce((acc, point) => acc + point.value, 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把单序列指标(total/error/warn)按时间戳合并成趋势行 */
|
||||||
|
function buildTrend(...seriesList: MetricSeries[][]): AnalysisData['trend'] {
|
||||||
|
const buckets = new Map<string, { total: number; error: number; warn: number }>()
|
||||||
|
for (const [index, series] of seriesList.entries()) {
|
||||||
|
const key = index === 0 ? 'total' : index === 1 ? 'error' : 'warn'
|
||||||
|
for (const item of series) {
|
||||||
|
for (const point of item.points) {
|
||||||
|
const bucket = buckets.get(point.timestamp) ?? { total: 0, error: 0, warn: 0 }
|
||||||
|
bucket[key] += point.value
|
||||||
|
buckets.set(point.timestamp, bucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...buckets.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([timestamp, values]) => ({ time: timeLabel(timestamp), ...values }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志分析数据 — 并行 5 条 LogQL 指标查询([$step] 占位符由后端替换),
|
||||||
|
* AbortController + 版本号令牌防错序(useDashboardStats 同款范式):
|
||||||
|
* 总量 / 错误 / 告警 / 按应用+命名空间 / 按应用错误量。
|
||||||
|
* app 标签沿用 app → service_name → container 逐级回退(与快捷查询一致)。
|
||||||
|
*/
|
||||||
|
export function useAnalysisMetrics({ sourceId, start, end, namespace }: AnalysisParams) {
|
||||||
|
const [appLabel, setAppLabel] = useState('app')
|
||||||
|
const [data, setData] = useState<AnalysisData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const versionRef = useRef(0)
|
||||||
|
const labelVersionRef = useRef(0)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setRefreshTick(tick => tick + 1), [])
|
||||||
|
|
||||||
|
// app 标签探测:每个数据源只做一次
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceId) { setAppLabel('app'); return }
|
||||||
|
const version = ++labelVersionRef.current
|
||||||
|
logsApi.labels(sourceId).then(labels => {
|
||||||
|
if (version !== labelVersionRef.current) return
|
||||||
|
const list = Array.isArray(labels) ? labels : []
|
||||||
|
setAppLabel(['app', 'service_name', 'container'].find(name => list.includes(name)) ?? 'app')
|
||||||
|
}).catch(() => { if (version === labelVersionRef.current) setAppLabel('app') })
|
||||||
|
}, [sourceId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = new Date(start).getTime()
|
||||||
|
const to = new Date(end).getTime()
|
||||||
|
if (!sourceId || !(from < to)) { setData(null); return }
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const version = ++versionRef.current
|
||||||
|
|
||||||
|
const selector = namespace ? `{namespace="${namespace}"}` : '{namespace=~".+"}'
|
||||||
|
const range = { sourceId, start: Number(ns(from)), end: Number(ns(to)) }
|
||||||
|
const fetchMetrics = (query: string) => logsApi.metrics({ ...range, query }, { signal: controller.signal })
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [totalSeries, errorSeries, warnSeries, byAppSeries, errByAppSeries] = await Promise.all([
|
||||||
|
fetchMetrics(`sum(count_over_time(${selector}[$step]))`),
|
||||||
|
fetchMetrics(`sum(count_over_time(${selector} |= "error"[$step]))`),
|
||||||
|
fetchMetrics(`sum(count_over_time(${selector} |= "warn"[$step]))`),
|
||||||
|
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))`),
|
||||||
|
fetchMetrics(`sum by (${appLabel}, namespace) (count_over_time(${selector} |= "error"[$step]))`),
|
||||||
|
])
|
||||||
|
if (version !== versionRef.current) return
|
||||||
|
|
||||||
|
// 应用聚合:统一到最长序列的时间桶上;跨命名空间求和
|
||||||
|
let times: string[] = []
|
||||||
|
for (const item of byAppSeries) if (item.points.length > times.length) times = item.points.map(point => point.timestamp)
|
||||||
|
const timeIndex = new Map(times.map((ts, index) => [ts, index]))
|
||||||
|
const appMap = new Map<string, { row: AppRow; perNs: Map<string, number> }>()
|
||||||
|
for (const item of byAppSeries) {
|
||||||
|
const app = item.labels[appLabel] ?? ''
|
||||||
|
const nsLabel = item.labels.namespace ?? ''
|
||||||
|
const entry = appMap.get(app) ?? { row: { app, namespace: nsLabel, namespaces: [], total: 0, errorCount: 0, sparkline: times.map(() => 0) }, perNs: new Map<string, number>() }
|
||||||
|
for (const point of item.points) {
|
||||||
|
const index = timeIndex.get(point.timestamp)
|
||||||
|
if (index !== undefined) entry.row.sparkline[index] += point.value
|
||||||
|
}
|
||||||
|
entry.perNs.set(nsLabel, (entry.perNs.get(nsLabel) ?? 0) + item.points.reduce((acc, point) => acc + point.value, 0))
|
||||||
|
appMap.set(app, entry)
|
||||||
|
}
|
||||||
|
for (const item of errByAppSeries) {
|
||||||
|
const entry = appMap.get(item.labels[appLabel] ?? '')
|
||||||
|
if (entry) entry.row.errorCount += item.points.reduce((acc, point) => acc + point.value, 0)
|
||||||
|
}
|
||||||
|
const apps = [...appMap.values()].map(({ row, perNs }) => ({
|
||||||
|
...row,
|
||||||
|
total: row.sparkline.reduce((acc, value) => acc + value, 0),
|
||||||
|
namespaces: [...perNs.keys()].filter(Boolean),
|
||||||
|
namespace: [...perNs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? row.namespace,
|
||||||
|
})).sort((a, b) => b.total - a.total)
|
||||||
|
|
||||||
|
// 命名空间分布:各应用的 perNs 二次聚合
|
||||||
|
const nsCounts = new Map<string, number>()
|
||||||
|
for (const { perNs } of appMap.values()) {
|
||||||
|
for (const [nsLabel, count] of perNs) nsCounts.set(nsLabel, (nsCounts.get(nsLabel) ?? 0) + count)
|
||||||
|
}
|
||||||
|
const namespaceShares = [...nsCounts.entries()]
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.filter(item => item.name)
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
setData({
|
||||||
|
trend: buildTrend(totalSeries, errorSeries, warnSeries),
|
||||||
|
namespaceShares,
|
||||||
|
apps,
|
||||||
|
appTimes: times.map(timeLabel),
|
||||||
|
stats: {
|
||||||
|
total: seriesTotal(totalSeries),
|
||||||
|
errorCount: seriesTotal(errorSeries),
|
||||||
|
warnCount: seriesTotal(warnSeries),
|
||||||
|
appCount: apps.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (version !== versionRef.current) return
|
||||||
|
if ((err as Error).name === 'AbortError') return
|
||||||
|
setError(err instanceof Error ? err.message : 'metrics failed')
|
||||||
|
} finally {
|
||||||
|
if (version === versionRef.current) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => { controller.abort() }
|
||||||
|
}, [sourceId, start, end, namespace, appLabel, refreshTick])
|
||||||
|
|
||||||
|
return { data, loading, error, appLabel, refresh }
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { logsApi } from '@/api/logs'
|
||||||
|
|
||||||
|
export interface DailyAppRow {
|
||||||
|
app: string
|
||||||
|
namespace: string
|
||||||
|
/** 每个自然日(本地时区)的日志总量,与 days 一一对应 */
|
||||||
|
totals: number[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyVolumeData {
|
||||||
|
days: string[]
|
||||||
|
apps: DailyAppRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 86400000
|
||||||
|
const RANGE_DAYS = 7
|
||||||
|
const ns = (ms: number) => Math.round(ms).toString() + '000000'
|
||||||
|
const dayLabel = (ts: number) => new Date(ts).toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用每日日志量(近 7 天,Top 10)— 独立于页面时间范围筛选:
|
||||||
|
* 一条 `sum by (app, namespace)(count_over_time(sel[$step]))` 查询(7 天跨度后端自动选 1h 步长),
|
||||||
|
* 客户端把小时桶按本地日历日聚合,避免为每天单独发查询。
|
||||||
|
*/
|
||||||
|
export function useDailyVolume({ sourceId, namespace, appLabel }: {
|
||||||
|
sourceId: number
|
||||||
|
namespace: string
|
||||||
|
appLabel: string
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<DailyVolumeData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const versionRef = useRef(0)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setRefreshTick(tick => tick + 1), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sourceId) return
|
||||||
|
const end = Date.now()
|
||||||
|
// 从今天往前取整 7 个自然日(首尾两天可能是不完整天)
|
||||||
|
const today = Math.floor(end / DAY_MS) * DAY_MS
|
||||||
|
const days = Array.from({ length: RANGE_DAYS }, (_, index) => today - (RANGE_DAYS - 1 - index) * DAY_MS)
|
||||||
|
const start = days[0]
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const version = ++versionRef.current
|
||||||
|
|
||||||
|
const selector = namespace ? `{namespace="${namespace}"}` : '{namespace=~".+"}'
|
||||||
|
void (async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const series = await logsApi.metrics(
|
||||||
|
{ sourceId, start: Number(ns(start)), end: Number(ns(end)), query: `sum by (${appLabel}, namespace) (count_over_time(${selector}[$step]))` },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
if (version !== versionRef.current) return
|
||||||
|
// 小时桶 → 本地日历日分桶(app × 天 累加,并记录各 app 落在哪个命名空间)
|
||||||
|
const perDay = days.map(() => new Map<string, { total: number; perNs: Map<string, number> }>())
|
||||||
|
const dayIndexOf = (timestamp: string) => {
|
||||||
|
const day = Math.floor(new Date(timestamp).getTime() / DAY_MS) * DAY_MS
|
||||||
|
return days.indexOf(day)
|
||||||
|
}
|
||||||
|
for (const item of series) {
|
||||||
|
const app = item.labels[appLabel] ?? ''
|
||||||
|
const nsLabel = item.labels.namespace ?? ''
|
||||||
|
for (const point of item.points) {
|
||||||
|
const index = dayIndexOf(point.timestamp)
|
||||||
|
if (index < 0) continue
|
||||||
|
const bucket = perDay[index]
|
||||||
|
const entry = bucket.get(app) ?? { total: 0, perNs: new Map<string, number>() }
|
||||||
|
entry.total += point.value
|
||||||
|
entry.perNs.set(nsLabel, (entry.perNs.get(nsLabel) ?? 0) + point.value)
|
||||||
|
bucket.set(app, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merged = new Map<string, { totals: number[]; total: number; perNs: Map<string, number> }>()
|
||||||
|
perDay.forEach((bucket, index) => {
|
||||||
|
for (const [app, entry] of bucket) {
|
||||||
|
const row = merged.get(app) ?? { totals: days.map(() => 0), total: 0, perNs: new Map<string, number>() }
|
||||||
|
row.totals[index] += entry.total
|
||||||
|
row.total += entry.total
|
||||||
|
for (const [nsName, count] of entry.perNs) row.perNs.set(nsName, (row.perNs.get(nsName) ?? 0) + count)
|
||||||
|
merged.set(app, row)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const apps: DailyAppRow[] = [...merged.entries()]
|
||||||
|
.map(([app, row]) => ({
|
||||||
|
app,
|
||||||
|
namespace: [...row.perNs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '',
|
||||||
|
totals: row.totals,
|
||||||
|
total: row.total,
|
||||||
|
}))
|
||||||
|
.filter(row => row.total > 0)
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 10)
|
||||||
|
setData({ days: days.map(dayLabel), apps })
|
||||||
|
} catch (err) {
|
||||||
|
if (version !== versionRef.current) return
|
||||||
|
if ((err as Error).name === 'AbortError') return
|
||||||
|
setError(err instanceof Error ? err.message : 'metrics failed')
|
||||||
|
} finally {
|
||||||
|
if (version === versionRef.current) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => { controller.abort() }
|
||||||
|
}, [sourceId, namespace, appLabel, refreshTick])
|
||||||
|
|
||||||
|
return { data, loading, error, refresh }
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { PageContainer } from '@/components/PageContainer'
|
|||||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||||
import { ErrorState } from '@/components/ErrorState'
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
import { useDashboardStats } from './hooks/useDashboardStats'
|
import { useDashboardStats } from './hooks/useDashboardStats'
|
||||||
import { StatCard } from './components/StatCard'
|
import { StatCard } from '@/components/StatCard'
|
||||||
import { RecentDeploymentsCard } from './components/RecentDeploymentsCard'
|
import { RecentDeploymentsCard } from './components/RecentDeploymentsCard'
|
||||||
import { ResourceListCard } from './components/ResourceListCard'
|
import { ResourceListCard } from './components/ResourceListCard'
|
||||||
import type { DashboardStats } from '@/types'
|
import type { DashboardStats } from '@/types'
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
|
|
||||||
import { summarizeIngress } from '../ingress-insights'
|
|
||||||
|
|
||||||
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
|
|
||||||
type ErrorClass = 'all' | '5xx' | '4xx'
|
|
||||||
const modeIds: Mode[] = ['traffic', 'errors', 'slow', 'detail']
|
|
||||||
const modeLabelKeys: Record<Mode, string> = {
|
|
||||||
traffic: 'ingress.modeTraffic',
|
|
||||||
errors: 'ingress.modeErrors',
|
|
||||||
slow: 'ingress.modeSlow',
|
|
||||||
detail: 'ingress.modeDetail',
|
|
||||||
}
|
|
||||||
const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap'
|
|
||||||
const cell = 'px-3 py-2 tabular-nums'
|
|
||||||
const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s`
|
|
||||||
const routeKey = (route: IngressRouteStats) => `${route.domain}\u0000${route.path}`
|
|
||||||
|
|
||||||
function Route({ route }: { route: IngressRouteStats }) {
|
|
||||||
return <><td className={`${cell} max-w-48 truncate`} title={route.domain}>{route.domain}</td>
|
|
||||||
<td className={`${cell} max-w-96 truncate font-mono`} title={route.path}>{route.path}</td></>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
|
|
||||||
const { t } = useTranslation('logs')
|
|
||||||
const [mode, setMode] = useState<Mode>('traffic')
|
|
||||||
const [errorClass, setErrorClass] = useState<ErrorClass>('all')
|
|
||||||
const [slowThreshold, setSlowThreshold] = useState(1)
|
|
||||||
const summary = useMemo(() => summarizeIngress(samples, slowThreshold), [samples, slowThreshold])
|
|
||||||
const traffic = useMemo(() => [...summary.routes].sort((a, b) => b.pv - a.pv || b.error5xx - a.error5xx).slice(0, 10), [summary.routes])
|
|
||||||
const errors = useMemo(() => [...summary.routes]
|
|
||||||
.filter(route => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const count = (route: IngressRouteStats) => errorClass === '5xx' ? route.error5xx : errorClass === '4xx' ? route.error4xx : route.error4xx + route.error5xx
|
|
||||||
return count(b) - count(a) || b.error5xx - a.error5xx
|
|
||||||
}).slice(0, 10), [summary.routes, errorClass])
|
|
||||||
const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals)
|
|
||||||
.sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes])
|
|
||||||
const modes = useMemo(() => modeIds.map(id => ({ id, label: t(modeLabelKeys[id]) })), [t])
|
|
||||||
|
|
||||||
return <div className="space-y-3">
|
|
||||||
<p className="text-xs text-muted-foreground">{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
||||||
{[
|
|
||||||
[t('ingress.statPv'), summary.pv], [t('ingress.statUv'), summary.uv], [t('ingress.statErrors'), summary.errors], [t('ingress.statSlow', { threshold: slowThreshold }), summary.slow],
|
|
||||||
].map(([label, value]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<div role="tablist" aria-label={t('ingress.modesAria')} className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
|
|
||||||
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
|
|
||||||
</div>
|
|
||||||
{mode === 'errors' && <select aria-label={t('ingress.errorClassAria')} value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">{t('ingress.errorAll')}</option><option value="5xx">{t('ingress.error5xx')}</option><option value="4xx">{t('ingress.error4xx')}</option></select>}
|
|
||||||
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">{t('ingress.slowThreshold')} <select aria-label={t('ingress.slowThreshold')} value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>{t('ingress.seconds', { count: 0.5 })}</option><option value={1}>{t('ingress.seconds', { count: 1 })}</option><option value={3}>{t('ingress.seconds', { count: 3 })}</option><option value={5}>{t('ingress.seconds', { count: 5 })}</option><option value={10}>{t('ingress.seconds', { count: 10 })}</option></select></label>}
|
|
||||||
</div>
|
|
||||||
{mode === 'slow' && <p className="text-xs text-muted-foreground">{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}</p>}
|
|
||||||
{mode === 'traffic' && <p className="text-xs text-muted-foreground">{t('ingress.trafficHint')}</p>}
|
|
||||||
<div className="max-h-[60vh] overflow-auto rounded-lg border">
|
|
||||||
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
|
|
||||||
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
|
|
||||||
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
|
|
||||||
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
|
|
||||||
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">{t('ingress.empty')}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import type { LogEntry } from '@/api/logs'
|
|
||||||
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
|
|
||||||
import { IngressInsightsTable } from './IngressInsightsTable'
|
|
||||||
|
|
||||||
export type ResultView = 'raw' | 'table' | 'line' | 'stream' | 'pie'
|
|
||||||
type Bucket = { timestamp: string; count: number }
|
|
||||||
const colors = ['hsl(var(--info))', 'hsl(var(--success))', 'hsl(var(--warning))', 'hsl(var(--primary))', 'hsl(var(--destructive))', 'hsl(var(--muted-foreground))']
|
|
||||||
|
|
||||||
function EmptyChart({ message }: { message: string }) {
|
|
||||||
return <div className="flex h-64 items-center justify-center rounded-lg bg-muted/20 text-sm text-muted-foreground">{message}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LogResultViews({ view, entries, buckets, start, end, domainFiltered = false }: {
|
|
||||||
view: Exclude<ResultView, 'raw'>
|
|
||||||
entries: LogEntry[]
|
|
||||||
buckets: Bucket[]
|
|
||||||
start: string
|
|
||||||
end: string
|
|
||||||
domainFiltered?: boolean
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation('logs')
|
|
||||||
// 聚合占位名(无域名/其他域名等):同时用作数据列名与图例展示,统一从词典取,保证语言切换后聚合与展示一致
|
|
||||||
const noDomain = t('views.noDomain')
|
|
||||||
const otherDomain = t('views.otherDomain')
|
|
||||||
const unknownName = t('views.unknown')
|
|
||||||
const untaggedName = t('views.untagged')
|
|
||||||
const otherName = t('views.other')
|
|
||||||
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
|
|
||||||
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
|
|
||||||
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
|
|
||||||
const parsedRows = useMemo(() => entries.map(entry => parseIngressAccessLog(entry.message)), [entries])
|
|
||||||
const ingressCount = useMemo(() => parsedRows.filter(Boolean).length, [parsedRows])
|
|
||||||
const ingressSamples = useMemo(() => entries.flatMap((entry, index) => {
|
|
||||||
const access = parsedRows[index]
|
|
||||||
return access ? [{ access, timestamp: entry.timestamp }] : []
|
|
||||||
}), [entries, parsedRows])
|
|
||||||
const streamDomains = useMemo(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const row of parsedRows) if (row) {
|
|
||||||
const domain = row.domain || noDomain
|
|
||||||
counts.set(domain, (counts.get(domain) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
|
|
||||||
}, [parsedRows, noDomain])
|
|
||||||
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
|
|
||||||
const lineData = useMemo(() => {
|
|
||||||
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
|
|
||||||
const from = new Date(start).getTime(), to = new Date(end).getTime()
|
|
||||||
if (!(from < to)) return []
|
|
||||||
const steps = 16
|
|
||||||
const counts: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: 0 }))
|
|
||||||
for (const [rowIndex, entry] of entries.entries()) {
|
|
||||||
const index = Math.floor((new Date(entry.timestamp).getTime() - from) / (to - from) * steps)
|
|
||||||
if (index < 0 || index >= steps) continue
|
|
||||||
if (lineBy === 'domain' && ingressCount) {
|
|
||||||
const row = parsedRows[rowIndex]
|
|
||||||
if (!row) continue
|
|
||||||
const domain = row.domain || noDomain
|
|
||||||
const key = streamDomains.includes(domain) ? domain : otherDomain
|
|
||||||
counts[index][key] = Number(counts[index][key] ?? 0) + 1
|
|
||||||
} else counts[index].count = Number(counts[index].count) + 1
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains, noDomain, otherDomain])
|
|
||||||
const streamData = useMemo(() => {
|
|
||||||
const from = new Date(start).getTime(), to = new Date(end).getTime()
|
|
||||||
if (!(from < to)) return []
|
|
||||||
const steps = 16
|
|
||||||
const rows: Record<string, string | number>[] = Array.from({ length: steps }, (_, index) => ({ time: new Date(from + (to - from) * index / steps).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), error: 0, warn: 0, info: 0, other: 0, '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 }))
|
|
||||||
for (const [rowIndex, entry] of entries.entries()) {
|
|
||||||
const time = new Date(entry.timestamp).getTime()
|
|
||||||
const index = Math.floor((time - from) / (to - from) * steps)
|
|
||||||
if (index < 0 || index >= steps) continue
|
|
||||||
const ingress = parsedRows[rowIndex]
|
|
||||||
if (ingressCount) {
|
|
||||||
if (ingress) {
|
|
||||||
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || noDomain) ? ingress.domain || noDomain : otherDomain) : httpStatusClass(ingress.status)
|
|
||||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const level = entry.level.toLowerCase()
|
|
||||||
const key = level === 'error' || level === 'warn' || level === 'info' ? level : 'other'
|
|
||||||
rows[index][key] = Number(rows[index][key] ?? 0) + 1
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end, noDomain, otherDomain])
|
|
||||||
const pieData = useMemo(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const [index, entry] of entries.entries()) {
|
|
||||||
const ingress = parsedRows[index]
|
|
||||||
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
|
|
||||||
const name = effectiveGroupBy === 'domain' ? ingress?.domain || noDomain
|
|
||||||
: effectiveGroupBy === 'status' ? ingress?.status || unknownName
|
|
||||||
: effectiveGroupBy === 'method' ? ingress?.method || unknownName
|
|
||||||
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
|
|
||||||
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || untaggedName
|
|
||||||
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
|
|
||||||
: entry.labels[effectiveGroupBy] || untaggedName
|
|
||||||
counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
||||||
}
|
|
||||||
const sorted = [...counts].sort((a, b) => b[1] - a[1])
|
|
||||||
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
|
|
||||||
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
|
|
||||||
if (other) top.push({ name: otherName, count: other })
|
|
||||||
return top
|
|
||||||
}, [entries, parsedRows, effectiveGroupBy, noDomain, unknownName, untaggedName, otherName])
|
|
||||||
|
|
||||||
if (view === 'table') return ingressCount > 0
|
|
||||||
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
|
|
||||||
: <div>
|
|
||||||
<p className="mb-2 text-xs text-muted-foreground">{t('views.tableLoaded', { count: entries.length })}</p>
|
|
||||||
{entries.length === 0 ? <EmptyChart message={t('views.tableEmpty')} /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('views.thTime'), t('views.thNsPod'), t('views.thLevel'), t('views.thLog')].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if (view === 'line') return <div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? t('views.lineByLoaded') : t('views.lineByRange')}</p>{ingressCount > 0 && <select aria-label={t('views.lineGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="total">{t('views.lineByTotal')}</option></select>}</div>
|
|
||||||
{lineData.length === 0 ? <EmptyChart message={t('views.lineEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, otherDomain].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name={t('views.lineSeriesTotal')} stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
if (view === 'stream') return <div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? t('views.streamParsed', { count: ingressCount }) : t('views.streamByLevel', { count: entries.length })}{t('views.streamSuffix')}</p>{ingressCount > 0 && <select aria-label={t('views.streamGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="status">{t('views.streamByStatus')}</option></select>}</div>
|
|
||||||
{entries.length === 0 ? <EmptyChart message={t('views.streamEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, otherDomain] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
return <div>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? t('views.pieByAccess', { count: ingressCount }) : t('views.pieByLabel', { count: entries.length })}</p><select aria-label={t('views.pieGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">{t('views.pieDomain')}</option><option value="status">{t('views.pieStatus')}</option><option value="method">{t('views.pieMethod')}</option><option value="path">{t('views.piePath')}</option><option value="upstream">{t('views.pieUpstream')}</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">{t('views.pieLevel')}</option></select></div>
|
|
||||||
{pieData.length === 0 ? <EmptyChart message={t('views.pieEmpty')} /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import i18n from '@/i18n'
|
|
||||||
import type { IngressAccessLog } from './ingress-access-log'
|
|
||||||
import { pathWithoutQuery } from './ingress-access-log'
|
|
||||||
|
|
||||||
export interface IngressSample {
|
|
||||||
access: IngressAccessLog
|
|
||||||
timestamp: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IngressRouteStats {
|
|
||||||
domain: string
|
|
||||||
path: string
|
|
||||||
pv: number
|
|
||||||
uv: number
|
|
||||||
success: number
|
|
||||||
error4xx: number
|
|
||||||
error5xx: number
|
|
||||||
slow: number
|
|
||||||
timeoutSignals: number
|
|
||||||
averageSeconds: number | null
|
|
||||||
maxSeconds: number | null
|
|
||||||
statusCodes: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeIngress(samples: IngressSample[], slowThreshold: number): {
|
|
||||||
routes: IngressRouteStats[]
|
|
||||||
pv: number
|
|
||||||
uv: number
|
|
||||||
errors: number
|
|
||||||
slow: number
|
|
||||||
timed: number
|
|
||||||
} {
|
|
||||||
const routes = new Map<string, {
|
|
||||||
domain: string; path: string; pv: number; success: number; error4xx: number; error5xx: number
|
|
||||||
slow: number; timeoutSignals: number; ips: Set<string>; seconds: number[]; statuses: Map<string, number>
|
|
||||||
}>()
|
|
||||||
const allIps = new Set<string>()
|
|
||||||
let errors = 0, slow = 0, timed = 0
|
|
||||||
for (const { access } of samples) {
|
|
||||||
const domain = access.domain || i18n.t('ingress.noDomain', { ns: 'logs' })
|
|
||||||
const path = pathWithoutQuery(access.path)
|
|
||||||
const key = `${domain}\u0000${path}`
|
|
||||||
let route = routes.get(key)
|
|
||||||
if (!route) {
|
|
||||||
route = { domain, path, pv: 0, success: 0, error4xx: 0, error5xx: 0, slow: 0,
|
|
||||||
timeoutSignals: 0, ips: new Set(), seconds: [], statuses: new Map() }
|
|
||||||
routes.set(key, route)
|
|
||||||
}
|
|
||||||
route.pv++
|
|
||||||
if (access.clientIp && access.clientIp !== '-') {
|
|
||||||
route.ips.add(access.clientIp)
|
|
||||||
allIps.add(access.clientIp)
|
|
||||||
}
|
|
||||||
route.statuses.set(access.status, (route.statuses.get(access.status) ?? 0) + 1)
|
|
||||||
const status = Number(access.status)
|
|
||||||
if (status >= 200 && status < 400) route.success++
|
|
||||||
if (status >= 400 && status < 500) { route.error4xx++; errors++ }
|
|
||||||
if (status >= 500 && status < 600) { route.error5xx++; errors++ }
|
|
||||||
if (status === 408 || status === 499 || status === 504) route.timeoutSignals++
|
|
||||||
const seconds = Number(access.requestTime)
|
|
||||||
if (access.requestTime.trim() && Number.isFinite(seconds) && seconds >= 0) {
|
|
||||||
route.seconds.push(seconds)
|
|
||||||
timed++
|
|
||||||
if (seconds >= slowThreshold) { route.slow++; slow++ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
routes: [...routes.values()].map(route => ({
|
|
||||||
domain: route.domain, path: route.path, pv: route.pv, uv: route.ips.size,
|
|
||||||
success: route.success, error4xx: route.error4xx, error5xx: route.error5xx,
|
|
||||||
slow: route.slow, timeoutSignals: route.timeoutSignals,
|
|
||||||
averageSeconds: route.seconds.length ? route.seconds.reduce((sum, value) => sum + value, 0) / route.seconds.length : null,
|
|
||||||
maxSeconds: route.seconds.length ? Math.max(...route.seconds) : null,
|
|
||||||
statusCodes: [...route.statuses].filter(([code]) => Number(code) >= 400).sort((a, b) => Number(a[0]) - Number(b[0])).map(([code, count]) => `${code}×${count}`).join(' · '),
|
|
||||||
})),
|
|
||||||
pv: samples.length, uv: allIps.size, errors, slow, timed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
BellRing,
|
BellRing,
|
||||||
|
BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { AppRoute, MenuItem, RouteMeta } from '@/types/router';
|
import type { AppRoute, MenuItem, RouteMeta } from '@/types/router';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const WizardPage = lazy(() => import('@/pages/wizard'));
|
|||||||
const LogsPage = lazy(() => import('@/pages/logs'));
|
const LogsPage = lazy(() => import('@/pages/logs'));
|
||||||
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
const AlertsPage = lazy(() => import('@/pages/alerts'));
|
||||||
const UsersPage = lazy(() => import('@/pages/users'));
|
const UsersPage = lazy(() => import('@/pages/users'));
|
||||||
|
const AnalysisPage = lazy(() => import('@/pages/analysis'));
|
||||||
|
|
||||||
export const routes: AppRoute[] = [
|
export const routes: AppRoute[] = [
|
||||||
{ path: '/login', component: Login, auth: false, meta: { title: 'menu.login', hideInMenu: true } },
|
{ path: '/login', component: Login, auth: false, meta: { title: 'menu.login', hideInMenu: true } },
|
||||||
@@ -41,7 +43,8 @@ export const routes: AppRoute[] = [
|
|||||||
{ path: '/deployments', component: Deployments, auth: true, meta: { title: 'menu.deployments', icon: Rocket, order: 6, adminOnly: true } },
|
{ path: '/deployments', component: Deployments, auth: true, meta: { title: 'menu.deployments', icon: Rocket, order: 6, adminOnly: true } },
|
||||||
{ path: '/collectors', component: Collectors, auth: true, meta: { title: 'menu.collectors', icon: RadioTower, order: 7, adminOnly: true } },
|
{ path: '/collectors', component: Collectors, auth: true, meta: { title: 'menu.collectors', icon: RadioTower, order: 7, adminOnly: true } },
|
||||||
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 8 } },
|
{ path: '/logs', component: LogsPage, auth: true, meta: { title: 'menu.logs', icon: ScrollText, order: 8 } },
|
||||||
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 9 } },
|
{ path: '/analysis', component: AnalysisPage, auth: true, meta: { title: 'menu.analysis', icon: BarChart3, order: 9, adminOnly: true } },
|
||||||
|
{ path: '/alerts', component: AlertsPage, auth: true, meta: { title: 'menu.alerts', icon: BellRing, order: 10 } },
|
||||||
{ path: '/settings', component: SettingsPage, auth: true, meta: { title: 'menu.settings', icon: Settings, order: 12, adminOnly: true } },
|
{ path: '/settings', component: SettingsPage, auth: true, meta: { title: 'menu.settings', icon: Settings, order: 12, adminOnly: true } },
|
||||||
{ path: '/users', component: UsersPage, auth: true, meta: { title: 'menu.users', icon: UsersRound, order: 11, adminOnly: true } },
|
{ path: '/users', component: UsersPage, auth: true, meta: { title: 'menu.users', icon: UsersRound, order: 11, adminOnly: true } },
|
||||||
{ path: '*', component: NotFound, auth: false, meta: { title: 'menu.notFound', hideInMenu: true } },
|
{ path: '*', component: NotFound, auth: false, meta: { title: 'menu.notFound', hideInMenu: true } },
|
||||||
|
|||||||
Reference in new issue
Block a user