Skip to content

Commit 6171bf6

Browse files
Copilotmo-esmp
andcommitted
Add frontend dashboard component with charts and navigation
Co-authored-by: mo-esmp <[email protected]>
1 parent 65cbd47 commit 6171bf6

File tree

11 files changed

+963
-7
lines changed

11 files changed

+963
-7
lines changed

src/Serilog.Ui.Web/package-lock.json

Lines changed: 372 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Serilog.Ui.Web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"react-dom": "^18.3.1",
2828
"react-hook-form": "^7.55.0",
2929
"react-router": "^7.5.2",
30+
"recharts": "^3.0.2",
3031
"xml-formatter": "^3.6.5"
3132
},
3233
"devDependencies": {
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Box, Card, Grid, Text, Title, Group, Stack } from '@mantine/core';
2+
import { IconActivity, IconAlertTriangle, IconCalendar, IconChartBar } from '@tabler/icons-react';
3+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
4+
import useQueryDashboard from '../hooks/useQueryDashboard';
5+
6+
const LEVEL_COLORS = {
7+
Verbose: '#868e96',
8+
Debug: '#4c6ef5',
9+
Information: '#12b886',
10+
Warning: '#fd7e14',
11+
Error: '#fa5252',
12+
Fatal: '#c92a2a',
13+
Unknown: '#adb5bd',
14+
};
15+
16+
const StatCard = ({ icon, title, value, color }: {
17+
icon: React.ReactNode;
18+
title: string;
19+
value: number;
20+
color: string;
21+
}) => (
22+
<Card shadow="sm" padding="lg" radius="md" withBorder>
23+
<Group justify="space-between">
24+
<Stack gap="xs">
25+
<Text size="sm" c="dimmed">
26+
{title}
27+
</Text>
28+
<Text size="xl" fw={700} c={color}>
29+
{value.toLocaleString()}
30+
</Text>
31+
</Stack>
32+
<Box c={color}>
33+
{icon}
34+
</Box>
35+
</Group>
36+
</Card>
37+
);
38+
39+
export const Dashboard = () => {
40+
const { data: dashboard, isLoading, error } = useQueryDashboard();
41+
42+
if (isLoading) {
43+
return (
44+
<Box p="md">
45+
<Text>Loading dashboard...</Text>
46+
</Box>
47+
);
48+
}
49+
50+
if (error || !dashboard) {
51+
return (
52+
<Box p="md">
53+
<Text c="red">Error loading dashboard data</Text>
54+
</Box>
55+
);
56+
}
57+
58+
// Prepare data for charts
59+
const levelData = Object.entries(dashboard.logsByLevel).map(([level, count]) => ({
60+
level,
61+
count,
62+
color: LEVEL_COLORS[level as keyof typeof LEVEL_COLORS] || LEVEL_COLORS.Unknown,
63+
}));
64+
65+
return (
66+
<Box p="md">
67+
<Title order={2} mb="lg">
68+
Log Dashboard
69+
</Title>
70+
71+
{/* Stats Cards */}
72+
<Grid gutter="md" mb="xl">
73+
<Grid.Col span={{ base: 12, md: 3 }}>
74+
<StatCard
75+
icon={<IconChartBar size={32} />}
76+
title="Total Logs"
77+
value={dashboard.totalLogs}
78+
color="blue"
79+
/>
80+
</Grid.Col>
81+
<Grid.Col span={{ base: 12, md: 3 }}>
82+
<StatCard
83+
icon={<IconCalendar size={32} />}
84+
title="Today's Logs"
85+
value={dashboard.todayLogs}
86+
color="green"
87+
/>
88+
</Grid.Col>
89+
<Grid.Col span={{ base: 12, md: 3 }}>
90+
<StatCard
91+
icon={<IconAlertTriangle size={32} />}
92+
title="Today's Errors"
93+
value={dashboard.todayErrorLogs}
94+
color="red"
95+
/>
96+
</Grid.Col>
97+
<Grid.Col span={{ base: 12, md: 3 }}>
98+
<StatCard
99+
icon={<IconActivity size={32} />}
100+
title="Log Levels"
101+
value={Object.keys(dashboard.logsByLevel).length}
102+
color="grape"
103+
/>
104+
</Grid.Col>
105+
</Grid>
106+
107+
{/* Charts */}
108+
<Grid gutter="md">
109+
<Grid.Col span={{ base: 12, md: 8 }}>
110+
<Card shadow="sm" padding="lg" radius="md" withBorder>
111+
<Title order={4} mb="md">
112+
Logs by Level
113+
</Title>
114+
<Box h={300}>
115+
<ResponsiveContainer width="100%" height="100%">
116+
<BarChart data={levelData}>
117+
<CartesianGrid strokeDasharray="3 3" />
118+
<XAxis dataKey="level" />
119+
<YAxis />
120+
<Tooltip />
121+
<Bar dataKey="count" fill="#4c6ef5" />
122+
</BarChart>
123+
</ResponsiveContainer>
124+
</Box>
125+
</Card>
126+
</Grid.Col>
127+
<Grid.Col span={{ base: 12, md: 4 }}>
128+
<Card shadow="sm" padding="lg" radius="md" withBorder>
129+
<Title order={4} mb="md">
130+
Level Distribution
131+
</Title>
132+
<Box h={300}>
133+
<ResponsiveContainer width="100%" height="100%">
134+
<PieChart>
135+
<Pie
136+
data={levelData}
137+
cx="50%"
138+
cy="50%"
139+
labelLine={false}
140+
label={({ level, percent }) => `${level} ${((percent || 0) * 100).toFixed(0)}%`}
141+
outerRadius={80}
142+
fill="#8884d8"
143+
dataKey="count"
144+
>
145+
{levelData.map((entry, index) => (
146+
<Cell key={`cell-${index}`} fill={entry.color} />
147+
))}
148+
</Pie>
149+
<Tooltip />
150+
</PieChart>
151+
</ResponsiveContainer>
152+
</Box>
153+
</Card>
154+
</Grid.Col>
155+
</Grid>
156+
</Box>
157+
);
158+
};
159+
160+
export default Dashboard;

src/Serilog.Ui.Web/src/app/components/Index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { useSerilogUiProps } from 'app/hooks/useSerilogUiProps';
1010
import { Suspense, lazy } from 'react';
1111
import { Navigate } from 'react-router';
1212

13-
const AppBody = lazy(() => import('./AppBody'));
13+
const AppBody = lazy(() => import('./TabbedAppBody'));
1414
const Head = lazy(() => import('./ShellStructure/Header'));
1515
const Sidebar = lazy(() => import('./ShellStructure/Sidebar'));
1616

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Box, Tabs } from '@mantine/core';
2+
import { IconChartBar, IconTable } from '@tabler/icons-react';
3+
import { useJwtTimeout } from 'app/hooks/useJwtTimeout';
4+
import { useState, Suspense, lazy } from 'react';
5+
import classes from 'style/table.module.css';
6+
7+
const Search = lazy(() => import('./Search/Search'));
8+
const Paging = lazy(() => import('./Search/Paging'));
9+
const SerilogResults = lazy(() => import('./Table/SerilogResults'));
10+
const SerilogResultsMobile = lazy(() => import('./Table/SerilogResultsMobile'));
11+
const Dashboard = lazy(() => import('./Dashboard'));
12+
13+
const TabbedAppBody = ({ hideMobileResults }: { hideMobileResults?: boolean }) => {
14+
useJwtTimeout();
15+
const [activeTab, setActiveTab] = useState<string | null>('logs');
16+
17+
return (
18+
<Box p="md">
19+
<Tabs value={activeTab} onChange={setActiveTab}>
20+
<Tabs.List>
21+
<Tabs.Tab value="logs" leftSection={<IconTable size={16} />}>
22+
Logs
23+
</Tabs.Tab>
24+
<Tabs.Tab value="dashboard" leftSection={<IconChartBar size={16} />}>
25+
Dashboard
26+
</Tabs.Tab>
27+
</Tabs.List>
28+
29+
<Tabs.Panel value="logs">
30+
<Box mt="md">
31+
<Box visibleFrom="lg">
32+
<Suspense>
33+
<Search />
34+
</Suspense>
35+
</Box>
36+
<Box
37+
display={hideMobileResults ? 'none' : 'block'}
38+
hiddenFrom="md"
39+
className={classes.mobileTableWrapper}
40+
>
41+
<Suspense>
42+
<SerilogResultsMobile />
43+
</Suspense>
44+
</Box>
45+
<Box>
46+
<Box visibleFrom="md" m="xl">
47+
<Suspense>
48+
<SerilogResults />
49+
</Suspense>
50+
</Box>
51+
<Suspense>
52+
<Paging />
53+
</Suspense>
54+
</Box>
55+
</Box>
56+
</Tabs.Panel>
57+
58+
<Tabs.Panel value="dashboard">
59+
<Box mt="md">
60+
<Suspense>
61+
<Dashboard />
62+
</Suspense>
63+
</Box>
64+
</Tabs.Panel>
65+
</Tabs>
66+
</Box>
67+
);
68+
};
69+
70+
export default TabbedAppBody;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { useAuthProperties } from './useAuthProperties';
3+
import { fetchDashboard } from '../queries/dashboard';
4+
5+
const useQueryDashboard = () => {
6+
const { fetchInfo, isHeaderReady } = useAuthProperties();
7+
8+
return useQuery({
9+
enabled: isHeaderReady,
10+
queryKey: ['get-dashboard'],
11+
queryFn: async () => {
12+
if (!isHeaderReady) return null;
13+
return await fetchDashboard(fetchInfo.headers, fetchInfo.routePrefix);
14+
},
15+
refetchOnMount: false,
16+
refetchOnWindowFocus: false,
17+
retry: false,
18+
});
19+
};
20+
21+
export default useQueryDashboard;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { determineHost, send403Notification, sendUnexpectedNotification } from '../util/queries';
2+
import { UiApiError } from './errors';
3+
4+
export interface DashboardData {
5+
totalLogs: number;
6+
logsByLevel: Record<string, number>;
7+
todayLogs: number;
8+
todayErrorLogs: number;
9+
}
10+
11+
const defaultDashboard: DashboardData = {
12+
totalLogs: 0,
13+
logsByLevel: {},
14+
todayLogs: 0,
15+
todayErrorLogs: 0,
16+
};
17+
18+
export const fetchDashboard = async (
19+
fetchOptions: RequestInit,
20+
routePrefix?: string,
21+
): Promise<DashboardData> => {
22+
try {
23+
const url = `${determineHost(routePrefix)}/api/dashboard`;
24+
const req = await fetch(url, fetchOptions);
25+
26+
if (req.ok) return await (req.json() as Promise<DashboardData>);
27+
28+
return await Promise.reject(new UiApiError(req.status, 'Failed to fetch dashboard'));
29+
} catch (error: unknown) {
30+
const err = error as UiApiError;
31+
if (err?.code === 403) {
32+
send403Notification();
33+
} else {
34+
sendUnexpectedNotification(err.message);
35+
}
36+
37+
return defaultDashboard;
38+
}
39+
};

0 commit comments

Comments
 (0)