Skip to content

Commit 794bb08

Browse files
committed
feat: Implement initial dashboard, log explorer, and metrics explorer with OTLP ingestion.
1 parent 38b9580 commit 794bb08

13 files changed

Lines changed: 587 additions & 694 deletions

File tree

internal/ingest/otlp.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,19 @@ func (s *TraceServer) Export(ctx context.Context, req *coltracepb.ExportTraceSer
326326
}
327327
}
328328

329+
if len(synthesizedLogs) > 0 {
330+
if err := s.repo.BatchCreateLogs(synthesizedLogs); err != nil {
331+
slog.Error("❌ Failed to insert synthesized logs", "error", err)
332+
// Continue, don't fail the whole trace request
333+
}
334+
335+
if s.logCallback != nil {
336+
for _, l := range synthesizedLogs {
337+
s.logCallback(l)
338+
}
339+
}
340+
}
341+
329342
return &coltracepb.ExportTraceServiceResponse{}, nil
330343
}
331344

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Paper, Group, Text, Box, LoadingOverlay } from '@mantine/core'
2+
import type { LucideIcon } from 'lucide-react'
3+
import React, { type ReactNode } from 'react'
4+
import ReactEChartsCore, { echarts } from '../../lib/echarts'
5+
6+
interface ChartCardProps {
7+
title: string
8+
icon: LucideIcon
9+
iconColor: string
10+
height?: number | string
11+
loading?: boolean
12+
option: any
13+
children?: ReactNode
14+
}
15+
16+
export const ChartCard = React.memo(({
17+
title,
18+
icon: Icon,
19+
iconColor,
20+
height = 300,
21+
loading = false,
22+
option,
23+
children
24+
}: ChartCardProps) => {
25+
return (
26+
<Paper shadow="xs" p="md" radius="md" withBorder style={{ position: 'relative', height: '100%' }}>
27+
<LoadingOverlay visible={loading} zIndex={100} overlayProps={{ radius: 'sm', blur: 2 }} />
28+
29+
<Group gap="xs" mb="sm">
30+
<Icon size={16} color={iconColor} />
31+
<Text fw={600}>{title}</Text>
32+
</Group>
33+
34+
<Box style={{ height }}>
35+
<ReactEChartsCore
36+
echarts={echarts}
37+
option={option}
38+
style={{ height: '100%', width: '100%' }}
39+
notMerge={true}
40+
lazyUpdate={true}
41+
/>
42+
</Box>
43+
44+
{children}
45+
</Paper>
46+
)
47+
})
48+
49+
ChartCard.displayName = 'ChartCard'
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Paper, Group, Text, Title, ThemeIcon, Box } from '@mantine/core'
2+
import type { LucideIcon } from 'lucide-react'
3+
import React from 'react'
4+
5+
interface StatsCardProps {
6+
label: string
7+
value: string | number
8+
icon: LucideIcon
9+
color: string
10+
}
11+
12+
export const StatsCard = React.memo(({ label, value, icon: Icon, color }: StatsCardProps) => {
13+
return (
14+
<Paper shadow="xs" p="md" radius="md" withBorder>
15+
<Group justify="space-between" align="flex-start">
16+
<Box>
17+
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
18+
<Title order={3} mt={4}>
19+
{typeof value === 'number' ? value.toLocaleString() : value}
20+
</Title>
21+
</Box>
22+
<ThemeIcon variant="light" color={color} size="lg" radius="md">
23+
<Icon size={18} />
24+
</ThemeIcon>
25+
</Group>
26+
</Paper>
27+
)
28+
})
29+
30+
StatsCard.displayName = 'StatsCard'

web/src/features/dashboard/Dashboard.tsx

Lines changed: 11 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,14 @@
1-
import { useEffect, useMemo } from 'react'
2-
import {
3-
Paper,
4-
Group,
5-
Title,
6-
Stack,
7-
SimpleGrid,
8-
Text,
9-
Badge,
10-
ThemeIcon,
11-
Box,
12-
LoadingOverlay,
13-
} from '@mantine/core'
14-
import { useQuery } from '@tanstack/react-query'
15-
import ReactEChartsCore from 'echarts-for-react/lib/core'
16-
import * as echarts from 'echarts/core'
17-
import { LineChart, BarChart, HeatmapChart, ScatterChart, PieChart } from 'echarts/charts'
18-
import {
19-
GridComponent,
20-
TooltipComponent,
21-
LegendComponent,
22-
VisualMapComponent,
23-
TitleComponent,
24-
} from 'echarts/components'
25-
import { CanvasRenderer } from 'echarts/renderers'
26-
import { Activity, AlertTriangle, Clock, Layers, Zap, BarChart3, TrendingUp } from 'lucide-react'
27-
import type { TrafficPoint, DashboardStats, LatencyHeatmapPoint } from '../../types'
28-
import { useFilterParam } from '../../hooks/useFilterParams'
1+
import { Group, Title, Stack, Badge, Box } from '@mantine/core'
292
import { useLiveMode } from '../../contexts/LiveModeContext'
303
import { GlobalControls } from '../../components/GlobalControls'
31-
import { useTimeRange } from '../../components/TimeRangeSelector'
32-
33-
echarts.use([
34-
LineChart, BarChart, HeatmapChart, ScatterChart, PieChart,
35-
GridComponent, TooltipComponent, LegendComponent, VisualMapComponent, TitleComponent,
36-
CanvasRenderer,
37-
])
4+
import { DashboardStatsGrid } from './components/DashboardStatsGrid'
5+
import { TrafficVolumeChart } from './components/TrafficVolumeChart'
6+
import { LatencyScatterChart } from './components/LatencyScatterChart'
7+
import { ThroughputPieChart } from './components/ThroughputPieChart'
8+
import { SimpleGrid } from '@mantine/core'
389

3910
export function Dashboard() {
40-
const tr = useTimeRange('15m')
41-
const [selectedService] = useFilterParam('service', null)
42-
const { isLive, isConnected, setServiceFilter } = useLiveMode()
43-
44-
// Sync local filter param to global live mode filter
45-
useEffect(() => {
46-
if (isLive) {
47-
setServiceFilter(selectedService || '')
48-
}
49-
}, [isLive, selectedService, setServiceFilter])
50-
51-
const serviceParams = selectedService ? `&service_name=${encodeURIComponent(selectedService)}` : ''
52-
53-
// Traffic data
54-
const trafficQueryKey = ['traffic', tr.start, tr.end, selectedService, isLive]
55-
const { data: traffic, isFetching: isFetchingTraffic } = useQuery<TrafficPoint[]>({
56-
queryKey: trafficQueryKey,
57-
queryFn: () => fetch(`/api/metrics/traffic?start=${tr.start}&end=${tr.end}${serviceParams}`).then(r => r.json()),
58-
refetchInterval: isLive ? 10000 : false,
59-
staleTime: 30000,
60-
refetchOnWindowFocus: false,
61-
})
62-
63-
// Dashboard Stats
64-
const statsQueryKey = ['dashboardStats', tr.start, tr.end, selectedService, isLive]
65-
const { data: stats, isFetching: isFetchingStats } = useQuery<DashboardStats>({
66-
queryKey: statsQueryKey,
67-
queryFn: () => fetch(`/api/metrics/dashboard?start=${tr.start}&end=${tr.end}${serviceParams}`).then(r => r.json()),
68-
refetchInterval: isLive ? 10000 : false,
69-
staleTime: 30000,
70-
refetchOnWindowFocus: false,
71-
})
72-
73-
// Heatmap data
74-
const heatmapQueryKey = ['heatmap', tr.start, tr.end, selectedService, isLive]
75-
const { data: heatmap, isFetching: isFetchingHeatmap } = useQuery<LatencyHeatmapPoint[]>({
76-
queryKey: heatmapQueryKey,
77-
queryFn: () => fetch(`/api/metrics/latency_heatmap?start=${tr.start}&end=${tr.end}${serviceParams}`).then(r => r.json()),
78-
refetchInterval: isLive ? 10000 : false,
79-
})
80-
81-
const topFailing = stats?.top_failing_services || []
82-
83-
const trafficChartOption = useMemo(() => ({
84-
tooltip: { trigger: 'axis' },
85-
grid: { left: 50, right: 20, top: 20, bottom: 30 },
86-
xAxis: {
87-
type: 'time',
88-
axisLabel: { formatter: (val: number) => new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) },
89-
},
90-
yAxis: { type: 'value', name: 'Requests' },
91-
series: [
92-
{
93-
name: 'Total',
94-
type: 'line',
95-
smooth: true,
96-
data: (traffic || []).map((p: TrafficPoint) => [new Date(p.timestamp).getTime(), p.count]),
97-
areaStyle: { opacity: 0.1, color: '#4c6ef5' },
98-
lineStyle: { color: '#4c6ef5', width: 2 },
99-
itemStyle: { color: '#4c6ef5' },
100-
},
101-
{
102-
name: 'Errors',
103-
type: 'line',
104-
smooth: true,
105-
data: (traffic || []).map((p: TrafficPoint) => [new Date(p.timestamp).getTime(), p.error_count]),
106-
areaStyle: { opacity: 0.1, color: '#fa5252' },
107-
lineStyle: { color: '#fa5252', width: 2 },
108-
itemStyle: { color: '#fa5252' },
109-
},
110-
],
111-
}), [traffic])
112-
113-
const heatmapChartOption = useMemo(() => ({
114-
tooltip: {
115-
trigger: 'item',
116-
formatter: (p: any) => {
117-
const durMs = p.value[1] / 1000;
118-
return `Time: ${new Date(p.value[0]).toLocaleTimeString()}<br/>Latency: <b>${durMs.toFixed(2)}ms</b>`;
119-
}
120-
},
121-
grid: { left: 60, right: 20, top: 20, bottom: 30 },
122-
xAxis: { type: 'time', axisLabel: { color: '#909296' } },
123-
yAxis: {
124-
type: 'value',
125-
name: 'µs',
126-
axisLabel: { color: '#909296' },
127-
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } }
128-
},
129-
series: [{
130-
type: 'scatter',
131-
symbolSize: 8,
132-
data: (heatmap || []).map(p => [new Date(p.timestamp).getTime(), p.duration]),
133-
itemStyle: { color: 'rgba(76, 110, 245, 0.6)' },
134-
large: true,
135-
largeThreshold: 500
136-
}]
137-
}), [heatmap])
138-
139-
const throughputPieOption = useMemo(() => ({
140-
tooltip: { trigger: 'item' },
141-
legend: { bottom: '5%', left: 'center', textStyle: { color: '#909296' } },
142-
series: [{
143-
name: 'Throughput',
144-
type: 'pie',
145-
radius: ['40%', '70%'],
146-
avoidLabelOverlap: false,
147-
itemStyle: { borderRadius: 10, borderColor: '#ffffffff', borderWidth: 4 },
148-
label: { show: false, position: 'center' },
149-
emphasis: { label: { show: true, fontSize: 16, fontWeight: 'bold' } },
150-
labelLine: { show: false },
151-
data: (topFailing || []).map(s => ({ value: s.total_count, name: s.service_name }))
152-
}]
153-
}), [topFailing])
154-
155-
const statCards = [
156-
{ label: 'Total Traces', value: stats?.total_traces ?? 0, icon: Layers, color: 'indigo' },
157-
{ label: 'Total Logs', value: stats?.total_logs ?? 0, icon: Activity, color: 'cyan' },
158-
{ label: 'Total Errors', value: stats?.total_errors ?? 0, icon: AlertTriangle, color: 'red' },
159-
{ label: 'Avg Latency', value: `${(stats?.avg_latency_ms ?? 0).toFixed(1)}ms`, icon: Clock, color: 'orange' },
160-
]
11+
const { isLive, isConnected } = useLiveMode()
16112

16213
return (
16314
<Stack gap="md">
@@ -176,59 +27,14 @@ export function Dashboard() {
17627
</Group>
17728

17829
<Box style={{ position: 'relative' }}>
179-
<LoadingOverlay visible={(isFetchingTraffic || isFetchingStats || isFetchingHeatmap) && !isLive} zIndex={1000} overlayProps={{ radius: 'sm', blur: 2 }} />
180-
181-
<SimpleGrid cols={{ base: 2, md: 4 }} mb="md">
182-
{statCards.map((s) => (
183-
<Paper key={s.label} shadow="xs" p="md" radius="md" withBorder>
184-
<Group justify="space-between" align="flex-start">
185-
<Box>
186-
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{s.label}</Text>
187-
<Title order={3} mt={4}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</Title>
188-
</Box>
189-
<ThemeIcon variant="light" color={s.color} size="lg" radius="md">
190-
<s.icon size={18} />
191-
</ThemeIcon>
192-
</Group>
193-
</Paper>
194-
))}
195-
</SimpleGrid>
30+
<DashboardStatsGrid />
19631

19732
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md" mb="md">
198-
<Paper shadow="xs" p="md" radius="md" withBorder>
199-
<Group gap="xs" mb="sm">
200-
<BarChart3 size={16} color="var(--mantine-color-cyan-4)" />
201-
<Text fw={600}>Throughput Distribution</Text>
202-
</Group>
203-
<Box style={{ height: 350 }}>
204-
<ReactEChartsCore echarts={echarts} option={throughputPieOption} style={{ height: '100%' }} />
205-
</Box>
206-
</Paper>
207-
208-
<Paper shadow="xs" p="md" radius="md" withBorder>
209-
<Group gap="xs" mb="sm">
210-
<Zap size={16} color="var(--mantine-color-yellow-4)" />
211-
<Text fw={600}>Latency Distribution (Scatter)</Text>
212-
</Group>
213-
<Box style={{ height: 260 }}>
214-
<ReactEChartsCore echarts={echarts} option={heatmapChartOption} style={{ height: '100%' }} />
215-
</Box>
216-
</Paper>
33+
<ThroughputPieChart />
34+
<LatencyScatterChart />
21735
</SimpleGrid>
21836

219-
<Paper shadow="xs" p="md" radius="md" withBorder>
220-
<Group gap="xs" mb="sm">
221-
<TrendingUp size={16} color="var(--mantine-color-indigo-4)" />
222-
<Text fw={600}>Traffic Request Volume</Text>
223-
</Group>
224-
<Box style={{ height: 260 }}>
225-
<ReactEChartsCore echarts={echarts} option={trafficChartOption} style={{ height: '100%' }} />
226-
</Box>
227-
228-
229-
230-
231-
</Paper>
37+
<TrafficVolumeChart />
23238
</Box>
23339
</Stack>
23440
)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { SimpleGrid } from '@mantine/core'
2+
import { Activity, AlertTriangle, Clock, Layers } from 'lucide-react'
3+
import React from 'react'
4+
import { StatsCard } from '../../../components/common/StatsCard'
5+
import { useArgusQuery } from '../../../hooks/useArgusQuery'
6+
import type { DashboardStats } from '../../../types'
7+
8+
export const DashboardStatsGrid = React.memo(() => {
9+
const { data: stats } = useArgusQuery<DashboardStats>({
10+
queryKey: ['dashboardStats'],
11+
path: '/api/metrics/dashboard',
12+
liveKey: 'dashboardStats',
13+
})
14+
15+
const statCards = [
16+
{ label: 'Total Traces', value: stats?.total_traces ?? 0, icon: Layers, color: 'indigo' },
17+
{ label: 'Total Logs', value: stats?.total_logs ?? 0, icon: Activity, color: 'cyan' },
18+
{ label: 'Total Errors', value: stats?.total_errors ?? 0, icon: AlertTriangle, color: 'red' },
19+
{ label: 'Avg Latency', value: `${(stats?.avg_latency_ms ?? 0).toFixed(1)}ms`, icon: Clock, color: 'orange' },
20+
]
21+
22+
return (
23+
<SimpleGrid cols={{ base: 2, md: 4 }} mb="md">
24+
{statCards.map((s) => (
25+
<StatsCard
26+
key={s.label}
27+
label={s.label}
28+
value={s.value}
29+
icon={s.icon}
30+
color={s.color}
31+
/>
32+
))}
33+
</SimpleGrid>
34+
)
35+
})
36+
37+
DashboardStatsGrid.displayName = 'DashboardStatsGrid'

0 commit comments

Comments
 (0)