feat(analyticsDashboard): added trend view and error view
This commit is contained in:
parent
eb634c7b98
commit
f1f6b600b0
|
@ -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"
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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[]
|
||||
}
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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 */
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 */
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
|
30
yarn.lock
30
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue