feat(analyticsDashboard): added trend view and error view

This commit is contained in:
Valentin Kolb 2024-11-13 21:59:37 +01:00
parent eb634c7b98
commit f1f6b600b0
25 changed files with 779 additions and 456 deletions

View File

@ -10,7 +10,7 @@ export const PB_STORAGE_KEY = "stuve-it-login-record"
// general
export const APP_NAME = "StuVe IT"
export const APP_VERSION = "0.9.8 (beta)"
export const APP_VERSION = "0.9.89 (beta)"
export const APP_URL = "https://it.stuve.uni-ulm.de"
export const LOCAL_DEV_MODE = process?.env?.NODE_ENV === "development" || window?.location?.hostname === "localhost"

View File

@ -21,8 +21,9 @@
"@mantine/hooks": "^7.10.0",
"@mantine/modals": "^7.10.0",
"@mantine/notifications": "^7.10.0",
"@mantine/nprogress": "^7.14.0",
"@mantine/tiptap": "^7.10.0",
"@tabler/icons-react": "^3.2.0",
"@tabler/icons-react": "^3.21.0",
"@tanstack/react-query": "^5.0.5",
"@tanstack/react-query-devtools": "^5.31.0",
"@tiptap/extension-collaboration": "^2.3.0",

View File

@ -4,6 +4,7 @@ import {Fragment} from "react";
import {
IconBug,
IconConfetti,
IconDatabaseShare,
IconDatabaseSmile,
IconHome,
IconList,
@ -11,6 +12,7 @@ import {
IconQrcode
} from "@tabler/icons-react";
import {usePB} from "@/lib/pocketbase.tsx";
import {PB_BASE_URL} from "../../../../config.ts";
const NavItems = [
@ -78,6 +80,14 @@ const AdminMenuItems = [
description: "Dashboard für Admins",
link: "/admin",
color: "blue"
},
{
title: "Pocketbase",
icon: IconDatabaseShare,
description: "Pocketbase Datenbank",
link: `${PB_BASE_URL}/_`,
color: "brown",
"_blank": true
}
]
}
@ -108,6 +118,7 @@ export default function MenuItems() {
component={NavLink}
to={item.link}
aria-label={item.description}
target={"_blank" in item ? "_blank" : undefined}
color={'color' in item ? item.color as string : undefined}
>
{item.title}

View File

@ -6,6 +6,9 @@ import MenuItems from "./MenuItems.tsx";
import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts";
import {useShowDebug} from "@/components/ShowDebug.tsx";
import {Link} from "react-router-dom";
import {useIsFetching} from "@tanstack/react-query";
import {NavigationProgress, nprogress} from "@mantine/nprogress";
import {useEffect} from "react";
export default function NavBar() {
@ -16,74 +19,87 @@ export default function NavBar() {
const {handler: userMenuHandler} = useUserMenu()
const {handler: loginHandler} = useLogin()
return <nav className={classes.navbar}>
<Menu
trigger="hover"
closeDelay={400}
position="bottom-start"
shadow="md"
width={200}
>
<Menu.Target>
<div className={classes.logo}>
<Image
h={30}
w={30}
src={showDebug ? "/stuve-logo-debug.svg" : "/stuve-logo.svg"}
alt={"StuVe IT Logo"}
/>
const isFetching = useIsFetching()
useEffect(() => {
if (isFetching > 0) {
nprogress.start()
} else {
nprogress.complete()
}
}, [isFetching])
return <>
<NavigationProgress/>
<nav className={classes.navbar}>
<Menu
trigger="hover"
closeDelay={400}
position="bottom-start"
shadow="md"
width={200}
>
<Menu.Target>
<div className={classes.logo}>
<Image
h={30}
w={30}
src={showDebug ? "/stuve-logo-debug.svg" : "/stuve-logo.svg"}
alt={"StuVe IT Logo"}
/>
<div className={classes.title}>
StuVe IT
</div>
<ThemeIcon variant={"transparent"} size={"sm"}>
<IconChevronDown/>
</ThemeIcon>
<div className={classes.title}>
StuVe IT
</div>
</Menu.Target>
<ThemeIcon variant={"transparent"} size={"sm"}>
<IconChevronDown/>
</ThemeIcon>
<Menu.Dropdown>
<MenuItems/>
</Menu.Dropdown>
</Menu>
</div>
</Menu.Target>
<div className={classes.actionIcons}>
<Menu.Dropdown>
<MenuItems/>
</Menu.Dropdown>
</Menu>
<div className={classes.actionIcons}>
{showDebug && (
<Badge
className={"cursor-pointer"}
color={"orange"}
component={Link}
to={"/debug"}
leftSection={<IconBug size={12}/>}
>
DEBUG MODUS
</Badge>
)}
{user ?
<>
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"User Menu"}
onClick={userMenuHandler.open}
{showDebug && (
<Badge
className={"cursor-pointer"}
color={"orange"}
component={Link}
to={"/debug"}
leftSection={<IconBug size={12}/>}
>
<IconUserStar/>
</ActionIcon>
</>
: (
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"Login"}
onClick={loginHandler.open}
>
<IconLogin/>
</ActionIcon>
DEBUG MODUS
</Badge>
)}
</div>
</nav>
{user ?
<>
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"User Menu"}
onClick={userMenuHandler.open}
>
<IconUserStar/>
</ActionIcon>
</>
: (
<ActionIcon
variant={"transparent"}
color={"gray"}
aria-label={"Login"}
onClick={loginHandler.open}
>
<IconLogin/>
</ActionIcon>
)}
</div>
</nav>
</>
}

View File

@ -111,7 +111,7 @@ export default function LoginModal() {
}
placeholder={
formValues.values.authMethod === "ldap" ?
"vorname.nachname" : "Anmeldename"
"vorname.nachname" : "Anmeldename oder E-Mail"
}
{...formValues.getInputProps("username")}
/>

View File

@ -77,6 +77,14 @@ export const useQRCodeScannerSettings = (): [QRCodeScannerSettings, (s: Partial<
return [value, setValue]
}
/**
* A reducer function that flattens nested arrays.
*
* @template T - The type of elements in the array.
* @param {T[]} acc - The accumulator array.
* @param {T | T[]} val - The current value, which can be an element or an array of elements.
* @returns {T[]} The flattened array.
*/
export const flattenReducer = <T>(acc: T[], val: T | T[]): T[] => {
if (Array.isArray(val)) {
return [...acc, ...val]
@ -84,6 +92,17 @@ export const flattenReducer = <T>(acc: T[], val: T | T[]): T[] => {
return [...acc, val]
}
/**
* Maps the values of an object using a provided function.
*
* @template T - The type of the input object.
* @template T1 - The type of the keys in the input object.
* @template T2 - The type of the values in the input object.
* @template R - The type of the values in the resulting object.
* @param {T} obj - The input object to map.
* @param {(k: T1, v: T[T1], i: number) => R} fn - The function to apply to each key-value pair.
* @returns {Record<T1, R>} The resulting object with mapped values.
*/
export const objectMap = <T extends { [key in T1]: T2 }, T1 extends string | number | symbol, T2, R>(
obj: T,
fn: (k: T1, v: T[T1], i: number) => R
@ -94,10 +113,56 @@ export const objectMap = <T extends { [key in T1]: T2 }, T1 extends string | num
)
)
/**
* Iterates over all keys of an object and provides a tuple for each entry
* containing the key, value, and index.
*
* @param obj - The object to iterate over
* @returns An array of tuples, each tuple containing:
* - key: The key of the current entry
* - value: The value of the current entry
* - index: The index of the current entry
*
* @example
* const obj = { a: 1, b: 2, c: 3 };
* const result = objectIterate(obj);
* // Result: [
* // ['a', 1, 0],
* // ['b', 2, 1],
* // ['c', 3, 2]
* // ]
*/
export const objectIterate = <T extends object>(
obj: T
): Array<[key: keyof T, value: T[keyof T], index: number]> => {
return Object.keys(obj).map((key, index) => [
key as keyof T,
obj[key as keyof T],
index
]);
}
/**
* This functions filters out all duplicate values from an array.
* @example [1, 2, 3, 1, 2, 4].filter(onlyUnique) // [1, 2, 3, 4]
*/
export function onlyUnique<T>(value: T, index: number, array: T[]) {
return array.indexOf(value) === index;
}
/**
* Converts a string to title case and replaces all underscores with spaces.
* Each word's first letter is capitalized, and all underscores are removed.
*
* @param str - The input string
* @returns A new string in title case with underscores replaced by spaces
*
* @example
* const result = toTitleCase("hello_world_this_is_test");
* // Result: "Hello World This Is Test"
*/
export function toTitleCase(str: string): string {
return str
.toLowerCase() // Convert the string to lowercase
.replace(/\b\w/g, char => char.toUpperCase()); // Capitalize each word's first letter
}

View File

@ -7,6 +7,7 @@ import '@mantine/dates/styles.layer.css';
import '@mantine/tiptap/styles.layer.css';
import '@mantine/notifications/styles.layer.css';
import '@mantine/charts/styles.css';
import '@mantine/nprogress/styles.css';
import {Alert, createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {PocketBaseProvider} from "@/lib/pocketbase.tsx";
@ -28,6 +29,7 @@ const themeOverride = createTheme({
headings: {
fontFamily: 'Overpass, sans-serif'
},
defaultRadius: 'sm',
components: {
Alert: Alert.extend({
defaultProps: {

View File

@ -37,6 +37,28 @@ export type AnalyticsPageViewModel = {
}
} & RecordModel
export type AnalyticsPageViewsWithSessionDetailModel = {
path_error_count: number
} & AnalyticsPageViewModel & Pick<AnalyticsSessionModel,
"visitor"
| "device_type"
| "browser_name"
| "browser_version"
| "operating_system"
| "operating_system_version"
| "ip_address"
| "user_agent"
| "geo_country_code"
| "preferred_language"
>
export type AnalyticsDailyAggregateAPIResponse = {
date: string; // Format: ISO 8601 date string
error_count: number;
page_view_count: number;
unique_visitor_count: number;
}[]
export type AnalyticsIpApiResult = {
ip: string
country_code: string
@ -44,4 +66,35 @@ export type AnalyticsIpApiResult = {
asn: string
as_desc: string
user_agent: string
}
interface Data {
value: string;
count: number;
}
export interface AggregateCountApiResponse {
device_type: Data[];
browser_name: Data[];
operating_system: Data[];
geo_country_code: Data[];
preferred_language: Data[];
}
export type PageViewCountItem = {
id: number;
path: string;
count: number;
last_30_days_data: {
date: string; // ISO 8601 formatted date
count: number;
}[]
}
export interface PageViewCountResponse {
page: number,
perPage: number,
totalItems: number,
totalPages: number,
items: PageViewCountItem[]
}

View File

@ -10,7 +10,12 @@ import {
} from "./EventTypes.ts";
import {MessagesModel} from "@/models/MessageTypes.ts"
import {EmailModel} from "@/models/EmailTypes.ts"
import {AnalyticsPageViewModel, AnalyticsSessionModel, AnalyticsVisitorsModel} from "@/models/AnalyticsTypes.ts"
import {
AnalyticsPageViewModel,
AnalyticsPageViewsWithSessionDetailModel,
AnalyticsSessionModel,
AnalyticsVisitorsModel
} from "@/models/AnalyticsTypes.ts"
export type SettingsModel = {
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
@ -60,4 +65,6 @@ export interface TypedPocketBase extends PocketBase {
collection(idOrName: 'analyticsSessions'): RecordService<AnalyticsSessionModel>
collection(idOrName: 'analyticsPageViews'): RecordService<AnalyticsPageViewModel>
}
collection(idOrName: 'analyticsPageViewsWithSessionDetail'): RecordService<AnalyticsPageViewsWithSessionDetailModel>
}

View File

@ -0,0 +1,35 @@
import {AnalyticsPageViewsWithSessionDetailModel} from "@/models/AnalyticsTypes.ts";
import {Alert, Code} from "@mantine/core";
import {IconDeviceLaptop, IconGraph, IconHash, IconInfoCircle, IconStack3, IconUser} from "@tabler/icons-react";
import {CodeHighlight} from "@mantine/code-highlight";
import TextWithIcon from "@/components/layout/TextWithIcon";
export default function AnalyticsErrorDetail({pageView}: { pageView: AnalyticsPageViewsWithSessionDetailModel }) {
return <div className={"stack"}>
<Alert icon={<IconInfoCircle/>} title={"Informationen"}>
<div className={"stack"}>
<TextWithIcon icon={<IconUser/>}>
Visitor Id <Code>{pageView.visitor}</Code>
</TextWithIcon>
<TextWithIcon icon={<IconHash/>}>
Session Id <Code>{pageView.id}</Code>
</TextWithIcon>
<TextWithIcon icon={<IconGraph/>}>
Fehler-Anzahl für Pfad <Code>{pageView.path_error_count}</Code>
</TextWithIcon>
<TextWithIcon icon={<IconDeviceLaptop/>}>
User Agent <Code>{pageView.user_agent}</Code>
</TextWithIcon>
</div>
</Alert>
<Alert icon={<IconStack3/>} title={" Stack Trace"} color={"red"}>
<div className={"stack"}>
<Code>{pageView.error?.error_type}</Code>
<CodeHighlight code={pageView.error?.stack_trace ?? ""} language="js"/>
</div>
</Alert>
</div>
}

View File

@ -13,7 +13,6 @@ import {
IconBug,
IconCalendarClock,
IconCar,
IconClick,
IconDeviceGamepad2,
IconDeviceIpad,
IconDeviceLaptop,
@ -23,8 +22,7 @@ import {
IconDeviceWatch,
IconEye,
IconEyeOff,
IconGlobe,
IconLanguage,
IconNotebook,
IconStereoGlasses
} from "@tabler/icons-react";
import TextWithIcon from "@/components/layout/TextWithIcon";
@ -32,8 +30,9 @@ import TextWithIcon from "@/components/layout/TextWithIcon";
import {useDisclosure} from "@mantine/hooks";
import {pprintDateTime} from "@/lib/datetime.ts";
import {AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts";
import AnalyticsSessionDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail";
import {AnalyticsPageViewsWithSessionDetailModel} from "@/models/AnalyticsTypes.ts";
import AnalyticsErrorDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx";
import {Link} from "react-router-dom";
export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => {
if (deviceType === "mobile") {
@ -91,64 +90,43 @@ export const OsIcon = ({os}: { os: string }) => {
return <IconDevices2/>
}
export default function AnalyticsSessionRow({session}: {
session: AnalyticsSessionModel,
export default function AnalyticsErrorRow({pageView}: {
pageView: AnalyticsPageViewsWithSessionDetailModel,
}) {
const [expanded, expandedHandler] = useDisclosure(false)
const errorCount = session.expand?.analyticsPageViews_via_session?.filter(pv => pv.error).length
const pageViewCount = session.expand?.analyticsPageViews_via_session?.length
return <>
<div className={classes.subgrid} data-error={!!errorCount}>
<div className={classes.subgrid}>
<TextWithIcon
className={classes.child}
icon={<IconCalendarClock/>}
>
{pprintDateTime(session.created)}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<BrowserIcon browser={session.browser_name ?? ""}/>}
>
{session.browser_name} {session.browser_version}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<OsIcon os={session.operating_system ?? ""}/>}
>
{session.operating_system} {session.operating_system_version}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<IconGlobe/>}
>
{session.geo_country_code || "--"}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<IconLanguage/>}
>
{session.preferred_language || "--"}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<IconClick/>}
>
{pageViewCount ?? '--'}
{pprintDateTime(pageView.created)}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<IconBug/>}
themeIconProps={{color: "red"}}
>
{errorCount ?? '--'}
{pageView.error?.error_type || ""}
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<IconNotebook/>}
>
<Link to={pageView.path} target={"_blank"}>
{pageView.path}
</Link>
</TextWithIcon>
<TextWithIcon
className={classes.child}
icon={<BrowserIcon browser={pageView.browser_name ?? ""}/>}
>
{pageView.browser_name} {pageView.browser_version}
</TextWithIcon>
<div className={`${classes.child} ${classes.alignEnd}`}>
@ -161,7 +139,7 @@ export default function AnalyticsSessionRow({session}: {
</div>
<Collapse in={expanded} className={classes.detailsContainer}>
<AnalyticsSessionDetail session={session}/>
<AnalyticsErrorDetail pageView={pageView}/>
</Collapse>
</>
}

View File

@ -5,14 +5,14 @@
.mainGrid {
display: grid;
grid-template-columns: auto auto auto auto auto auto auto 0.1fr;
grid-template-columns: auto auto auto auto 0.1fr;
gap: var(--gap);
}
.subgrid {
display: grid;
grid-template-columns: subgrid;
grid-column: span 8;
grid-column: span 5;
background-color: var(--mantine-color-body);
border: var(--border);
@ -53,6 +53,6 @@
}
.detailsContainer {
grid-column: span 8; /* Spans all columns of the main grid */
grid-column: span 5; /* Spans all columns of the main grid */
}

View File

@ -0,0 +1,68 @@
import {useInfiniteQuery} from "@tanstack/react-query";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import dayjs from "dayjs";
import {Button, Center, Loader} from "@mantine/core";
import {IconArrowDown} from "@tabler/icons-react";
import classes from "./index.module.css";
import AnalyticsErrorRow from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx";
export default function AnalyticsErrors({lastNDays}: { lastNDays: number }) {
const {pb} = usePB()
const query = useInfiniteQuery({
queryKey: ["sessions", {lastNDays}],
queryFn: async ({pageParam}) => {
const filter = ['visitor.meta.localDevMode!=true'] as string[]
filter.push(`created>='${dayjs().startOf("days").subtract(lastNDays, "days").toISOString()}'`)
filter.push(`error!=null`)
return await pb.collection("analyticsPageViewsWithSessionDetail").getList(pageParam, 100, {
filter: filter.join("&&"),
sort: '-created'
})
},
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
})
const pageViews = query.data?.pages.flatMap(p => p.items) ?? []
if (query.error) return (
<div className={"section-transparent"}>
<PocketBaseErrorAlert error={query.error}/>
</div>
)
if (!query.data) return (
<Center>
<Loader size="sm"/>
</Center>
)
return <>
<div className={"section-transparent"}>
<div className={classes.mainGrid}>
{pageViews.map(pageView => <AnalyticsErrorRow pageView={pageView} key={pageView.id}/>)}
</div>
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
</>
}

View File

@ -0,0 +1,75 @@
import {Center, Loader, SimpleGrid, Stack, Title} from "@mantine/core";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {useQuery} from "@tanstack/react-query";
import {AggregateCountApiResponse} from "@/models/AnalyticsTypes.ts";
import {BarChart} from "@mantine/charts";
import dayjs from "dayjs";
import {objectIterate, toTitleCase} from "@/lib/util.ts";
import classes from "../index.module.css";
function Graph({
title,
data
}: {
title: string,
data: object[],
}) {
return <Stack>
<Title order={4}>
{title}
</Title>
<BarChart
mih={300}
orientation="vertical"
withXAxis={false}
data={data ?? []}
dataKey="value"
type="stacked"
series={[
{name: 'success_count', color: 'blue.6', label: 'Anzahl Fehlerfrei'},
{name: 'error_count', color: 'orange.6', label: 'Anzahl Fehler'},
]}
/>
</Stack>
}
export default function AnalyticsGraphs({lastNDays}: { lastNDays: number }) {
const {pb} = usePB()
const query = useQuery({
queryKey: ["analyticsSessionCounts", {lastNDays}],
queryFn: async () => {
return await pb.send<AggregateCountApiResponse>("/api/analytics/aggregateCount", {
method: "GET",
headers: {
"Content-Type": "application/json"
},
query: {
startDate: dayjs().subtract(lastNDays, "days").toISOString()
},
requestKey: null,
})
}
})
if (query.error) return (
<div className={"section-transparent"}>
<PocketBaseErrorAlert error={query.error}/>
</div>
)
if (!query.data) return (
<Center>
<Loader size="sm"/>
</Center>
)
return (
<SimpleGrid className={classes.graphsGrid} cols={{base: 2, sm: 3, md: 5}}>
{query.data && objectIterate(query.data).map(([key, values, index]) => (
<Graph title={toTitleCase(key.replace(/_/g, ' '))} data={values} key={index}/>
))}
</SimpleGrid>
)
}

View File

@ -1,6 +1,6 @@
.mainGrid {
display: grid;
grid-template-columns: auto auto auto auto auto auto;
grid-template-columns: auto auto auto;
background-color: var(--mantine-color-body);
border: var(--border);
border-radius: var(--border-radius);
@ -9,7 +9,7 @@
.subGrid {
display: grid;
grid-template-columns: subgrid;
grid-column: span 6;
grid-column: span 3;
gap: var(--gap);
font-size: var(--mantine-font-size-sm);
@ -44,5 +44,5 @@
}
.debugContainer {
grid-column: span 6; /* Spans all columns of the main grid */
grid-column: span 3; /* Spans all columns of the main grid */
}

View File

@ -0,0 +1,160 @@
import {useInfiniteQuery} from "@tanstack/react-query";
import dayjs from "dayjs";
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {Button, Center, Loader, Tooltip} from "@mantine/core";
import classes from "./index.module.css"
import {PageViewCountItem, PageViewCountResponse} from "@/models/AnalyticsTypes.ts";
import {IconArrowDown, IconEye, IconNotebook} from "@tabler/icons-react";
import TextWithIcon from "@/components/layout/TextWithIcon";
import {Sparkline} from "@mantine/charts";
import {Link} from "react-router-dom";
interface DailyCount {
date: string; // Format: 'YYYY-MM-DD'
count: number;
}
/**
* Fills an array with daily counts for the last 30 days, adding missing days with a count of 0.
*
* @param data - An array of objects representing days with counts, in the format:
* [{ date: 'YYYY-MM-DD', count: number }, ...]
* @returns A new array with exactly 30 entries, each day from 29 days ago to today.
*/
const fillLast30Days = (data: DailyCount[]): DailyCount[] => {
// Initialize the result array with the last 30 days, each day with a count of 0
const last30Days: DailyCount[] = Array.from({length: 30}, (_, i) => ({
date: dayjs().subtract(29 - i, 'day').format('YYYY-MM-DD'),
count: 0
}))
// Convert input data into a map for quick lookup
const dataMap = new Map(data.map(item => [item.date, item.count]))
// Fill in counts from the input data, if available
return last30Days.map(day => ({
date: day.date,
count: dataMap.get(day.date) ?? 0
}))
}
/**
* Calculates the trend of the counts over the last 30 days.
*
* @param data - An array of daily counts for the last 30 days.
* @returns A string indicating the trend ('rising', 'falling', 'stable').
*/
const calculateTrend = (data: DailyCount[]): string => {
let totalChange = 0
for (let i = 1; i < data.length; i++) {
totalChange += data[i].count - data[i - 1].count
}
const averageChange = totalChange / (data.length - 1)
return averageChange > 0 ? 'rising' : averageChange < 0 ? 'falling' : 'stable'
};
const PageCountRow = ({pageView}: { pageView: PageViewCountItem }) => {
const data = fillLast30Days(pageView.last_30_days_data)
const trend = calculateTrend(data)
return (
<div className={classes.subGrid}>
<TextWithIcon
className={classes.gridCell}
icon={<IconEye/>}
>
{pageView.count}
</TextWithIcon>
<TextWithIcon
className={classes.gridCell}
icon={<IconNotebook/>}
>
<Link to={pageView.path} target={"_blank"}>
{pageView.path}
</Link>
</TextWithIcon>
{
data.length > 1 && (
<Tooltip label={`Besuchendezahlen der letzten 30 Tage • ${trend}`} position={"top"}>
<div className={classes.gridCell}>
<Sparkline
w={"100%"}
h={30}
data={data.map((d) => d.count)}
curveType="linear"
color={trend === "stable" ? "blue" : trend === "rising" ? "green" : "orange"}
fillOpacity={0.6}
strokeWidth={2}
/>
</div>
</Tooltip>
)}
</div>
)
}
export default function AnalyticsPages({lastNDays}: { lastNDays: number }) {
const {pb} = usePB()
const query = useInfiniteQuery({
queryKey: ["pageCounts", {lastNDays}],
queryFn: async ({pageParam}) => {
return await pb.send<PageViewCountResponse>("/api/analytics/pageViewCount", {
query: {
page: pageParam,
perPage: 100,
startDate: dayjs().startOf("days").subtract(lastNDays, "days").toISOString(),
sort: 'DESC'
}
})
},
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
})
const pageView = query.data?.pages.flatMap(p => p.items) ?? []
if (query.error) return (
<div className={"section-transparent"}>
<PocketBaseErrorAlert error={query.error}/>
</div>
)
if (!query.data) return (
<Center>
<Loader size="sm"/>
</Center>
)
return <>
<div className={"section-transparent"}>
<div className={classes.mainGrid}>
{pageView.map((pageView, index) => (
<PageCountRow pageView={pageView} key={index}/>
))}
</div>
</div>
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</>
}

View File

@ -1,104 +0,0 @@
import {AnalyticsPageViewModel, AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts";
import classes from "./index.module.css";
import {formatDuration, pprintDateTime, pprintTime} from "@/lib/datetime.ts";
import ShowDebug from "@/components/ShowDebug.tsx";
import {Code} from "@mantine/core";
import TextWithIcon from "@/components/layout/TextWithIcon";
import {IconBug, IconClick, IconClock, IconHourglass, IconInfoCircle, IconStack3Filled} from "@tabler/icons-react";
export default function AnalyticsSessionDetail({session}: { session: AnalyticsSessionModel }) {
return <div className={classes.mainGrid}>
<ShowDebug className={classes.debugContainer}>
Session Id - <Code>{session.id}</Code>
<br/>
created - <Code>{pprintDateTime(session.created)}</Code>
<br/>
updated - <Code>{pprintDateTime(session.updated)}</Code>
<br/>
<br/>
IP - <Code>{session.ip_address}</Code>
<br/>
User Agent - <Code>{session.user_agent}</Code>
<br/>
Visitor Id - <Code>{session.visitor}</Code>
</ShowDebug>
{
session.expand?.analyticsPageViews_via_session?.sort((a, b) => {
return a.created < b.created ? -1 : 1
}).map((pageView, index, array) => {
return (
<PageViewRow
key={index}
pageView={pageView}
nextPageView={array[index + 1]}
/>
)
})
}
</div>
}
function PageViewRow({pageView, nextPageView}: {
pageView: AnalyticsPageViewModel,
nextPageView?: AnalyticsPageViewModel
}) {
return (
<div className={classes.subGrid}>
<TextWithIcon
className={classes.gridCell}
icon={<IconClock/>}
>
{pprintTime(pageView.created)}
</TextWithIcon>
<TextWithIcon
className={classes.gridCell}
icon={<IconHourglass/>}
>
{nextPageView ?
formatDuration(pageView.created, nextPageView.created, true)
:
"End"
}
</TextWithIcon>
<TextWithIcon
className={classes.gridCell}
icon={<IconClick/>}
>
{pageView.path}
</TextWithIcon>
{
pageView.error && <>
<TextWithIcon
className={classes.gridCell}
themeIconProps={{color: "red"}}
icon={<IconBug/>}
>
{pageView.error.error_type}
</TextWithIcon>
<TextWithIcon
className={classes.gridCell}
themeIconProps={{color: "red"}}
icon={<IconInfoCircle/>}
>
{pageView.error.error_message}
</TextWithIcon>
<TextWithIcon
className={classes.gridCell}
themeIconProps={{color: "red"}}
icon={<IconStack3Filled/>}
>
<Code>
{pageView.error.stack_trace}
</Code>
</TextWithIcon>
</>
}
</div>
)
}

View File

@ -1,199 +0,0 @@
import {useInfiniteQuery} from "@tanstack/react-query";
import {usePB} from "@/lib/pocketbase.tsx";
import dayjs from "dayjs";
import {ActionIcon, Anchor, Button, Center, SimpleGrid, Switch, Text, TextInput, Tooltip} from "@mantine/core";
import {IconArrowDown, IconBug, IconZoomReset} from "@tabler/icons-react";
import {countByAllKeys} from "@/lib/datasience.ts";
import {useMemo} from "react";
import {RadarChart} from "@mantine/charts";
import classes from "./index.module.css";
import AnalyticsSessionRow from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx";
import {useForm} from "@mantine/form";
export default function AnalyticsSessions({lastNDays}: { lastNDays?: number }) {
const {pb} = usePB()
const formValues = useForm({
mode: "uncontrolled",
initialValues: {
browser: '',
os: '',
country: '',
language: '',
hasErrors: false
}
})
const query = useInfiniteQuery({
queryKey: ["sessions", {lastNDays}],
queryFn: async ({pageParam}) => {
const filter = ['visitor.meta.localDevMode!=true'] as string[]
if (lastNDays !== undefined) {
filter.push(`created>='${dayjs().startOf("days").subtract(lastNDays, "days").toISOString()}'`)
}
const filterValues = formValues.getValues()
if (filterValues.browser) {
filter.push(`browser_name~'${filterValues.browser}'`)
}
if (filterValues.os) {
filter.push(`operating_system~'${filterValues.os}'`)
}
if (filterValues.country) {
filter.push(`geo_country_code~'${filterValues.country}'`)
}
if (filterValues.language) {
filter.push(`preferred_language~'${filterValues.language}'`)
}
if (filterValues.hasErrors) {
filter.push('analyticsPageViews_via_session.session=id&&analyticsPageViews_via_session.error?!=null')
}
return await pb.collection("analyticsSessions").getList(pageParam, 100, {
filter: filter.join("&&"),
expand: 'analyticsErrors_via_session,analyticsPageViews_via_session',
sort: '-created'
})
},
getNextPageParam: (lastPage) =>
lastPage.page >= lastPage.totalPages ? undefined : lastPage.page + 1,
initialPageParam: 1,
})
const sessions = query.data?.pages.flatMap(p => p.items) ?? []
const sessionAnalytics = useMemo(() => {
return countByAllKeys(sessions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query.data])
return <>
<SimpleGrid className={classes.charts} cols={{base: 2, xs: 4}}>
<RadarChart
h={{base: 120, md: 200}}
data={sessionAnalytics["browser_name"]}
dataKey="key"
series={[{name: 'count', color: 'blue.4'}]}
/>
<RadarChart
h={{base: 120, md: 200}}
data={sessionAnalytics["device_type"]}
dataKey="key"
series={[{name: 'count', color: 'blue.4'}]}
/>
<RadarChart
h={{base: 120, md: 200}}
data={sessionAnalytics["operating_system"]}
dataKey="key"
series={[{name: 'count', color: 'blue.4'}]}
/>
<RadarChart
h={{base: 120, md: 200}}
data={sessionAnalytics["preferred_language"]}
dataKey="key"
series={[{name: 'count', color: 'blue.4'}]}
/>
</SimpleGrid>
<div className={"section-transparent center"}>
<Text size={"xs"} c={"dimmed"}>
Datenvisualisierung der neuesten {sessions.length} Sessions
({query.data?.pages[0].totalItems} insgesamt)
{query.hasNextPage && <>
{" • "}
<Anchor
variant={"transparent"}
size={"xs"}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Anchor>
</>}
</Text>
</div>
<div className={"section-transparent"}>
<div className={classes.mainGrid}>
<div className={classes.subgrid}>
<div className={classes.child}/>
<div className={classes.child}>
<TextInput
variant={"unstyled"}
placeholder={"Browser"}
{...formValues.getInputProps("browser")}
/>
</div>
<div className={classes.child}>
<TextInput
variant={"unstyled"}
placeholder={"OS"}
{...formValues.getInputProps("os")}
/>
</div>
<div className={classes.child}>
<TextInput
variant={"unstyled"}
placeholder={"Land"}
{...formValues.getInputProps("country")}
/>
</div>
<div className={classes.child}>
<TextInput
variant={"unstyled"}
placeholder={"Sprache"}
{...formValues.getInputProps("language")}
/>
</div>
<div className={classes.child}/>
<div className={classes.child}>
<Switch
size="sm"
color="gray"
onLabel={<IconBug size={16}/>}
{...formValues.getInputProps("hasErrors", {type: "checkbox"})}
/>
</div>
<div className={`${classes.child} ${classes.alignEnd}`}>
<Tooltip label={"Suchen & Daten neu laden"}>
<ActionIcon
aria-label={"search"}
variant={"transparent"}
onClick={() => query.refetch()}
disabled={query.isPending}
>
<IconZoomReset/>
</ActionIcon>
</Tooltip>
</div>
</div>
{sessions.map(session => <AnalyticsSessionRow session={session} key={session.id}/>)}
</div>
{query.hasNextPage && (
<Center p={"xs"}>
<Button
disabled={query.isFetchingNextPage || !query.hasNextPage}
loading={query.isFetchingNextPage}
variant={"transparent"}
size={"compact-xs"}
leftSection={<IconArrowDown/>}
onClick={() => query.fetchNextPage()}
>
Mehr laden
</Button>
</Center>
)}
</div>
</>
}

View File

@ -0,0 +1,100 @@
import {PocketBaseErrorAlert, usePB} from "@/lib/pocketbase.tsx";
import {useQuery} from "@tanstack/react-query";
import {AnalyticsDailyAggregateAPIResponse} from "@/models/AnalyticsTypes.ts";
import dayjs from "dayjs";
import {Center, Loader} from "@mantine/core";
import {useMemo} from "react";
import {CompositeChart} from "@mantine/charts";
import {pprintDate} from "@/lib/datetime.ts";
interface DailyCount {
date: string; // Format: ISO 8601 date string
error_count: number;
page_view_count: number;
unique_visitor_count: number;
}
/**
* Fills an array with daily counts for the lastNDays, adding missing days with a count of 0.
*
* @param data - An array of objects representing days with counts, in the format:
* @param lastNDays - The number of days to fill in the array.
* @returns A new array with exactly lastNDays entries, each day from lastNDays-1 days ago to today.
*/
const fillLastNDays = (data: DailyCount[], lastNDays: number): DailyCount[] => {
// Convert input data into a map for fast lookup
const dataMap = new Map(data.map(item => [item.date, item]));
// Initialize the result array with the last N days, filling in counts from dataMap if available
return Array.from({length: lastNDays}, (_, i) => {
const date = dayjs().subtract(lastNDays - 1 - i, 'day').format('YYYY-MM-DD');
const entry = dataMap.get(date);
return {
date,
error_count: entry?.error_count ?? 0,
page_view_count: entry?.page_view_count ?? 0,
unique_visitor_count: entry?.unique_visitor_count ?? 0
};
});
}
export default function AnalyticsTrend({lastNDays}: { lastNDays: number }) {
const {pb} = usePB()
const query = useQuery({
queryKey: ["analyticsDailyAggregates", {lastNDays}],
queryFn: async () => {
return await pb.send<AnalyticsDailyAggregateAPIResponse>("/api/analytics/dailyAggregates", {
method: "GET",
headers: {
"Content-Type": "application/json"
},
query: {
startDate: dayjs().subtract(lastNDays, "days").toISOString()
},
requestKey: null,
})
}
})
const data = useMemo(() =>
fillLastNDays(query.data ?? [], lastNDays).map(item => ({
...item,
date: pprintDate(item.date)
}))
, [lastNDays, query.data])
if (query.error) return (
<div className={"section-transparent"}>
<PocketBaseErrorAlert error={query.error}/>
</div>
)
if (!query.data) return (
<Center>
<Loader size="sm"/>
</Center>
)
return (
<div className={"section-transparent"}>
<CompositeChart
mt={"md"}
h={300}
data={data}
dataKey="date"
maxBarWidth={10}
series={[
{name: 'page_view_count', label: "Seitenaufrufe", color: 'blue.6', type: 'line'},
{name: 'unique_visitor_count', label: "Einzigartige Besuchende", color: 'cyan.6', type: 'area'},
{name: 'error_count', label: "Fehler", color: 'orange.6', type: 'bar'},
]}
curveType="linear"
tickLine="none"
gridAxis="xy"
/>
</div>
)
}

View File

@ -72,7 +72,6 @@ export default function CountStat({lastNDays, collection, label, icon, filter}:
const count = countQuery.data ?? 0
const reachedPercentage = (count / expectedCount) * 100
console.log({collection, days, dailyAverage, expectedCount, reachedPercentage})
return {
expectedCount,
count,

View File

@ -7,4 +7,9 @@
border: var(--border);
padding: var(--padding);
background-color: var(--mantine-color-body);
}
}
.graphsGrid {
max-width: var(--max-content-width);
padding: var(--padding) 0;
}

View File

@ -1,19 +1,34 @@
import {Anchor, Breadcrumbs, Button, Group, Select, SimpleGrid, Title} from "@mantine/core";
import {Link, Navigate, NavLink, Outlet, Route, Routes} from "react-router-dom";
import {Link, NavLink, Outlet, Route, Routes} from "react-router-dom";
import classes from './index.module.css'
import CountStat from "@/pages/admin/AnalyticsDashboard/CountStat.tsx";
import {IconBug, IconCalendarStats, IconEye, IconTimeline, IconUsers, IconZoomExclamation} from "@tabler/icons-react";
import {useMemo} from "react";
import {
IconBug,
IconCalendarStats,
IconChartPie4,
IconEye,
IconGraph,
IconTimeline,
IconUsers,
IconZoomExclamation
} from "@tabler/icons-react";
import {useForm} from "@mantine/form";
import AnalyticsGraphs from "@/pages/admin/AnalyticsDashboard/AnalyticsGraphs";
import NotFound from "@/pages/error-pages/NotFound.tsx";
import Sessions from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions";
import AnalyticsPages from "@/pages/admin/AnalyticsDashboard/AnalyticsPages";
import AnalyticsErrors from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors";
import AnalyticsTrend from "@/pages/admin/AnalyticsDashboard/AnalyticsTrend";
export default function AnalyticsDashboard() {
const dateSelect = useMemo(() => ([
const dateSelect = [
{
label: "letztes Jahr",
value: '365'
value: '360'
},
{
label: "letztes halbes Jahr",
value: '180'
},
{
label: "letzte 30 Tage",
@ -27,15 +42,15 @@ export default function AnalyticsDashboard() {
label: "heute",
value: '1'
}
]), [])
]
const formValues = useForm({
initialValues: {
dateFilter: undefined as string | undefined
dateFilter: dateSelect[1].value // default to last 30 days
}
})
const dateFilter = formValues.values.dateFilter ? parseInt(formValues.values.dateFilter) : undefined
const dateFilter = parseInt(formValues.values.dateFilter)
return <>
<div className={"section-transparent stack"}>
@ -59,17 +74,27 @@ export default function AnalyticsDashboard() {
{
[
{
label: "Sessions",
path: "/admin/analytics/sessions",
label: "Trend",
path: "/admin/analytics",
icon: <IconGraph/>
},
{
label: "Seiten",
path: "/admin/analytics/pages",
icon: <IconTimeline/>
},
{
label: "Graphen",
path: "/admin/analytics/graphs",
icon: <IconChartPie4/>
},
{
label: "Fehler",
path: "/admin/analytics/error",
icon: <IconBug/>
}
].map((e, i) => (
<NavLink key={i} to={e.path} replace>
<NavLink key={i} to={e.path} replace end={true}>
{({isActive}) => (
<Button
variant={isActive ? "filled" : "light"}
@ -82,11 +107,10 @@ export default function AnalyticsDashboard() {
</NavLink>
))
}
<Select
leftSection={<IconCalendarStats/>}
placeholder="Zeitraum"
clearable
leftSection={<IconCalendarStats/>}
allowDeselect={false}
data={dateSelect}
{...formValues.getInputProps("dateFilter")}
/>
@ -99,7 +123,7 @@ export default function AnalyticsDashboard() {
lastNDays={dateFilter}
collection={"analyticsVisitors"}
filter={'meta.localDevMode!=true'}
label={"Besucher"}
label={"Besuchende"}
icon={<IconUsers/>}
/>
@ -120,9 +144,12 @@ export default function AnalyticsDashboard() {
/>
</SimpleGrid>
<Routes>
<Route index element={<Navigate to={"sessions"}/>}/>
<Route path={"sessions/*"} element={<Sessions lastNDays={dateFilter}/>}/>
<Route index element={<AnalyticsTrend lastNDays={dateFilter}/>}/>
<Route path={"graphs"} element={<AnalyticsGraphs lastNDays={dateFilter}/>}/>
<Route path={"pages"} element={<AnalyticsPages lastNDays={dateFilter}/>}/>
<Route path={"error"} element={<AnalyticsErrors lastNDays={dateFilter}/>}/>
<Route path={"*"} element={<NotFound/>}/>
</Routes>
<Outlet/>

View File

@ -4,7 +4,7 @@ import {
BrowserIcon,
DeviceTypeIcon,
OsIcon
} from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx";
} from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx";
import DataItem from "@/pages/util/whatWeKnowAboutYou/DataItem.tsx";
import {useLocation} from "react-router-dom";
import {useCookies} from "react-cookie";

View File

@ -4,6 +4,18 @@
font-size: calc(var(--mantine-font-size-xs) * 0.8);
}
:root[data-mantine-color-scheme='dark'] {
--mantine-color-body: var(--mantine-color-dark-9);
}
:root[data-mantine-color-scheme='light'] {
--mantine-color-body: var(--mantine-color-white);
}
body {
background-color: var(--mantine-color-body);
}
* {
--gap: var(--mantine-spacing-sm);
--padding: var(--mantine-spacing-md);

View File

@ -531,11 +531,23 @@
"@mantine/store" "7.10.0"
react-transition-group "4.4.5"
"@mantine/nprogress@^7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@mantine/nprogress/-/nprogress-7.14.0.tgz#208a13043ff29c0cf78ad573ab61f9f1bb466b88"
integrity sha512-ku8iE3f7VcZpFRV4MRru7NXc5f145Yz7bqLqcr5Tcw06GsMB17oyThM+CuUWY+eljAI+wuTxvIYmL9ck1kinLQ==
dependencies:
"@mantine/store" "7.14.0"
"@mantine/store@7.10.0":
version "7.10.0"
resolved "https://registry.yarnpkg.com/@mantine/store/-/store-7.10.0.tgz#68368c6ca5b75cfb331220e06a3235be753df055"
integrity sha512-B6AyUX0cA97/hI9v0att7eJJnQTcUG7zBlTdWhOsptBV5UoDNrzdv3DDWIFxrA8h+nhNKGBh6Dif5HWh1+QLeA==
"@mantine/store@7.14.0":
version "7.14.0"
resolved "https://registry.yarnpkg.com/@mantine/store/-/store-7.14.0.tgz#c985275c6b396d282a233ec26b25692b07842851"
integrity sha512-qI0XnQZkHuWYbe9Mn6kFObka4x26RINnDpyJGSiK6on+VwDWGJ3gn1dfFlQa2zboVtA6OUXHyxDlwALHNJwiZw==
"@mantine/tiptap@^7.10.0":
version "7.10.0"
resolved "https://registry.yarnpkg.com/@mantine/tiptap/-/tiptap-7.10.0.tgz#77926f0a2d81c05e3f4084e65786778d5227fa56"
@ -845,17 +857,17 @@
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a"
integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==
"@tabler/icons-react@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.2.0.tgz#1b8d4059672ec2999f29f9aea03c44f00084ce66"
integrity sha512-b1mZT1XpZrzvbM+eFe1YbYbxkzgJ18tM4knZKqXh0gnHDZ6XVLIH3TzJZ3HZ7PTkUqZLZ7XcGae3qQVGburlBw==
"@tabler/icons-react@^3.21.0":
version "3.21.0"
resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.21.0.tgz#195aa60c16a5c3cf20e03adca6144ea340c573de"
integrity sha512-Qq0GnZzzccbv/zuMyXAUUPlogNAqx9KsF8cr/ev3bxs+GMObqNEjXv1eZl9GFzxyQTS435siJNU8A1BaIYhX8g==
dependencies:
"@tabler/icons" "3.2.0"
"@tabler/icons" "3.21.0"
"@tabler/icons@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.2.0.tgz#4adcde021943d50f2c228dab6145fd9c9b639f85"
integrity sha512-h8GQ2rtxgiSjltrVz4vcopAxTPSpUSUi5nBfJ09H3Bk4fJk6wZ/dVUjzhv/BHfDwGTkAxZBiYe/Q/T95cPeg5Q==
"@tabler/icons@3.21.0":
version "3.21.0"
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.21.0.tgz#d48b5d96d56736d4bd7d32b4a77b1c3e8df12908"
integrity sha512-5+GkkmWCr1wgMor5cOF1/YYflTQdc15y10FUikJ3HW8hDiFjfbuoAHJi17FT1vwsr1sA78rkJMn+fDoOOjnnPA==
"@tanstack/query-core@5.0.5":
version "5.0.5"