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
|
// general
|
||||||
export const APP_NAME = "StuVe IT"
|
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 APP_URL = "https://it.stuve.uni-ulm.de"
|
||||||
export const LOCAL_DEV_MODE = process?.env?.NODE_ENV === "development" || window?.location?.hostname === "localhost"
|
export const LOCAL_DEV_MODE = process?.env?.NODE_ENV === "development" || window?.location?.hostname === "localhost"
|
||||||
|
|
||||||
|
|
|
@ -21,8 +21,9 @@
|
||||||
"@mantine/hooks": "^7.10.0",
|
"@mantine/hooks": "^7.10.0",
|
||||||
"@mantine/modals": "^7.10.0",
|
"@mantine/modals": "^7.10.0",
|
||||||
"@mantine/notifications": "^7.10.0",
|
"@mantine/notifications": "^7.10.0",
|
||||||
|
"@mantine/nprogress": "^7.14.0",
|
||||||
"@mantine/tiptap": "^7.10.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": "^5.0.5",
|
||||||
"@tanstack/react-query-devtools": "^5.31.0",
|
"@tanstack/react-query-devtools": "^5.31.0",
|
||||||
"@tiptap/extension-collaboration": "^2.3.0",
|
"@tiptap/extension-collaboration": "^2.3.0",
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {Fragment} from "react";
|
||||||
import {
|
import {
|
||||||
IconBug,
|
IconBug,
|
||||||
IconConfetti,
|
IconConfetti,
|
||||||
|
IconDatabaseShare,
|
||||||
IconDatabaseSmile,
|
IconDatabaseSmile,
|
||||||
IconHome,
|
IconHome,
|
||||||
IconList,
|
IconList,
|
||||||
|
@ -11,6 +12,7 @@ import {
|
||||||
IconQrcode
|
IconQrcode
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {usePB} from "@/lib/pocketbase.tsx";
|
import {usePB} from "@/lib/pocketbase.tsx";
|
||||||
|
import {PB_BASE_URL} from "../../../../config.ts";
|
||||||
|
|
||||||
|
|
||||||
const NavItems = [
|
const NavItems = [
|
||||||
|
@ -78,6 +80,14 @@ const AdminMenuItems = [
|
||||||
description: "Dashboard für Admins",
|
description: "Dashboard für Admins",
|
||||||
link: "/admin",
|
link: "/admin",
|
||||||
color: "blue"
|
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}
|
component={NavLink}
|
||||||
to={item.link}
|
to={item.link}
|
||||||
aria-label={item.description}
|
aria-label={item.description}
|
||||||
|
target={"_blank" in item ? "_blank" : undefined}
|
||||||
color={'color' in item ? item.color as string : undefined}
|
color={'color' in item ? item.color as string : undefined}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|
|
@ -6,6 +6,9 @@ import MenuItems from "./MenuItems.tsx";
|
||||||
import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts";
|
import {useLogin, useUserMenu} from "@/components/users/modals/hooks.ts";
|
||||||
import {useShowDebug} from "@/components/ShowDebug.tsx";
|
import {useShowDebug} from "@/components/ShowDebug.tsx";
|
||||||
import {Link} from "react-router-dom";
|
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() {
|
export default function NavBar() {
|
||||||
|
|
||||||
|
@ -16,7 +19,19 @@ export default function NavBar() {
|
||||||
const {handler: userMenuHandler} = useUserMenu()
|
const {handler: userMenuHandler} = useUserMenu()
|
||||||
const {handler: loginHandler} = useLogin()
|
const {handler: loginHandler} = useLogin()
|
||||||
|
|
||||||
return <nav className={classes.navbar}>
|
const isFetching = useIsFetching()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFetching > 0) {
|
||||||
|
nprogress.start()
|
||||||
|
} else {
|
||||||
|
nprogress.complete()
|
||||||
|
}
|
||||||
|
}, [isFetching])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<NavigationProgress/>
|
||||||
|
<nav className={classes.navbar}>
|
||||||
<Menu
|
<Menu
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
closeDelay={400}
|
closeDelay={400}
|
||||||
|
@ -86,4 +101,5 @@ export default function NavBar() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</>
|
||||||
}
|
}
|
|
@ -111,7 +111,7 @@ export default function LoginModal() {
|
||||||
}
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
formValues.values.authMethod === "ldap" ?
|
formValues.values.authMethod === "ldap" ?
|
||||||
"vorname.nachname" : "Anmeldename"
|
"vorname.nachname" : "Anmeldename oder E-Mail"
|
||||||
}
|
}
|
||||||
{...formValues.getInputProps("username")}
|
{...formValues.getInputProps("username")}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -77,6 +77,14 @@ export const useQRCodeScannerSettings = (): [QRCodeScannerSettings, (s: Partial<
|
||||||
return [value, setValue]
|
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[] => {
|
export const flattenReducer = <T>(acc: T[], val: T | T[]): T[] => {
|
||||||
if (Array.isArray(val)) {
|
if (Array.isArray(val)) {
|
||||||
return [...acc, ...val]
|
return [...acc, ...val]
|
||||||
|
@ -84,6 +92,17 @@ export const flattenReducer = <T>(acc: T[], val: T | T[]): T[] => {
|
||||||
return [...acc, val]
|
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>(
|
export const objectMap = <T extends { [key in T1]: T2 }, T1 extends string | number | symbol, T2, R>(
|
||||||
obj: T,
|
obj: T,
|
||||||
fn: (k: T1, v: T[T1], i: number) => R
|
fn: (k: T1, v: T[T1], i: number) => R
|
||||||
|
@ -94,6 +113,35 @@ 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.
|
* This functions filters out all duplicate values from an array.
|
||||||
* @example [1, 2, 3, 1, 2, 4].filter(onlyUnique) // [1, 2, 3, 4]
|
* @example [1, 2, 3, 1, 2, 4].filter(onlyUnique) // [1, 2, 3, 4]
|
||||||
|
@ -101,3 +149,20 @@ export const objectMap = <T extends { [key in T1]: T2 }, T1 extends string | num
|
||||||
export function onlyUnique<T>(value: T, index: number, array: T[]) {
|
export function onlyUnique<T>(value: T, index: number, array: T[]) {
|
||||||
return array.indexOf(value) === index;
|
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/tiptap/styles.layer.css';
|
||||||
import '@mantine/notifications/styles.layer.css';
|
import '@mantine/notifications/styles.layer.css';
|
||||||
import '@mantine/charts/styles.css';
|
import '@mantine/charts/styles.css';
|
||||||
|
import '@mantine/nprogress/styles.css';
|
||||||
import {Alert, createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
|
import {Alert, createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
|
||||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||||
import {PocketBaseProvider} from "@/lib/pocketbase.tsx";
|
import {PocketBaseProvider} from "@/lib/pocketbase.tsx";
|
||||||
|
@ -28,6 +29,7 @@ const themeOverride = createTheme({
|
||||||
headings: {
|
headings: {
|
||||||
fontFamily: 'Overpass, sans-serif'
|
fontFamily: 'Overpass, sans-serif'
|
||||||
},
|
},
|
||||||
|
defaultRadius: 'sm',
|
||||||
components: {
|
components: {
|
||||||
Alert: Alert.extend({
|
Alert: Alert.extend({
|
||||||
defaultProps: {
|
defaultProps: {
|
||||||
|
|
|
@ -37,6 +37,28 @@ export type AnalyticsPageViewModel = {
|
||||||
}
|
}
|
||||||
} & RecordModel
|
} & 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 = {
|
export type AnalyticsIpApiResult = {
|
||||||
ip: string
|
ip: string
|
||||||
country_code: string
|
country_code: string
|
||||||
|
@ -45,3 +67,34 @@ export type AnalyticsIpApiResult = {
|
||||||
as_desc: string
|
as_desc: string
|
||||||
user_agent: 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";
|
} from "./EventTypes.ts";
|
||||||
import {MessagesModel} from "@/models/MessageTypes.ts"
|
import {MessagesModel} from "@/models/MessageTypes.ts"
|
||||||
import {EmailModel} from "@/models/EmailTypes.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 = {
|
export type SettingsModel = {
|
||||||
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
|
key: ['privacyPolicy', 'agb', 'stexGroup', 'stuveEventQuestions']
|
||||||
|
@ -60,4 +65,6 @@ export interface TypedPocketBase extends PocketBase {
|
||||||
collection(idOrName: 'analyticsSessions'): RecordService<AnalyticsSessionModel>
|
collection(idOrName: 'analyticsSessions'): RecordService<AnalyticsSessionModel>
|
||||||
|
|
||||||
collection(idOrName: 'analyticsPageViews'): RecordService<AnalyticsPageViewModel>
|
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,
|
IconBug,
|
||||||
IconCalendarClock,
|
IconCalendarClock,
|
||||||
IconCar,
|
IconCar,
|
||||||
IconClick,
|
|
||||||
IconDeviceGamepad2,
|
IconDeviceGamepad2,
|
||||||
IconDeviceIpad,
|
IconDeviceIpad,
|
||||||
IconDeviceLaptop,
|
IconDeviceLaptop,
|
||||||
|
@ -23,8 +22,7 @@ import {
|
||||||
IconDeviceWatch,
|
IconDeviceWatch,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconGlobe,
|
IconNotebook,
|
||||||
IconLanguage,
|
|
||||||
IconStereoGlasses
|
IconStereoGlasses
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import TextWithIcon from "@/components/layout/TextWithIcon";
|
import TextWithIcon from "@/components/layout/TextWithIcon";
|
||||||
|
@ -32,8 +30,9 @@ import TextWithIcon from "@/components/layout/TextWithIcon";
|
||||||
|
|
||||||
import {useDisclosure} from "@mantine/hooks";
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
import {pprintDateTime} from "@/lib/datetime.ts";
|
import {pprintDateTime} from "@/lib/datetime.ts";
|
||||||
import {AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts";
|
import {AnalyticsPageViewsWithSessionDetailModel} from "@/models/AnalyticsTypes.ts";
|
||||||
import AnalyticsSessionDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail";
|
import AnalyticsErrorDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorDetail.tsx";
|
||||||
|
import {Link} from "react-router-dom";
|
||||||
|
|
||||||
export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => {
|
export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => {
|
||||||
if (deviceType === "mobile") {
|
if (deviceType === "mobile") {
|
||||||
|
@ -91,64 +90,43 @@ export const OsIcon = ({os}: { os: string }) => {
|
||||||
return <IconDevices2/>
|
return <IconDevices2/>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalyticsSessionRow({session}: {
|
export default function AnalyticsErrorRow({pageView}: {
|
||||||
session: AnalyticsSessionModel,
|
pageView: AnalyticsPageViewsWithSessionDetailModel,
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const [expanded, expandedHandler] = useDisclosure(false)
|
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 <>
|
return <>
|
||||||
<div className={classes.subgrid} data-error={!!errorCount}>
|
<div className={classes.subgrid}>
|
||||||
<TextWithIcon
|
<TextWithIcon
|
||||||
className={classes.child}
|
className={classes.child}
|
||||||
icon={<IconCalendarClock/>}
|
icon={<IconCalendarClock/>}
|
||||||
>
|
>
|
||||||
{pprintDateTime(session.created)}
|
{pprintDateTime(pageView.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 ?? '--'}
|
|
||||||
</TextWithIcon>
|
</TextWithIcon>
|
||||||
|
|
||||||
<TextWithIcon
|
<TextWithIcon
|
||||||
className={classes.child}
|
className={classes.child}
|
||||||
icon={<IconBug/>}
|
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>
|
</TextWithIcon>
|
||||||
|
|
||||||
<div className={`${classes.child} ${classes.alignEnd}`}>
|
<div className={`${classes.child} ${classes.alignEnd}`}>
|
||||||
|
@ -161,7 +139,7 @@ export default function AnalyticsSessionRow({session}: {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapse in={expanded} className={classes.detailsContainer}>
|
<Collapse in={expanded} className={classes.detailsContainer}>
|
||||||
<AnalyticsSessionDetail session={session}/>
|
<AnalyticsErrorDetail pageView={pageView}/>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
|
@ -5,14 +5,14 @@
|
||||||
|
|
||||||
.mainGrid {
|
.mainGrid {
|
||||||
display: grid;
|
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);
|
gap: var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subgrid {
|
.subgrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
grid-column: span 8;
|
grid-column: span 5;
|
||||||
|
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
|
@ -53,6 +53,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailsContainer {
|
.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 {
|
.mainGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto auto auto auto auto auto;
|
grid-template-columns: auto auto auto;
|
||||||
background-color: var(--mantine-color-body);
|
background-color: var(--mantine-color-body);
|
||||||
border: var(--border);
|
border: var(--border);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
|
@ -9,7 +9,7 @@
|
||||||
.subGrid {
|
.subGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
grid-column: span 6;
|
grid-column: span 3;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
|
|
||||||
font-size: var(--mantine-font-size-sm);
|
font-size: var(--mantine-font-size-sm);
|
||||||
|
@ -44,5 +44,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.debugContainer {
|
.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 count = countQuery.data ?? 0
|
||||||
const reachedPercentage = (count / expectedCount) * 100
|
const reachedPercentage = (count / expectedCount) * 100
|
||||||
|
|
||||||
console.log({collection, days, dailyAverage, expectedCount, reachedPercentage})
|
|
||||||
return {
|
return {
|
||||||
expectedCount,
|
expectedCount,
|
||||||
count,
|
count,
|
||||||
|
|
|
@ -8,3 +8,8 @@
|
||||||
padding: var(--padding);
|
padding: var(--padding);
|
||||||
background-color: var(--mantine-color-body);
|
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 {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 classes from './index.module.css'
|
||||||
import CountStat from "@/pages/admin/AnalyticsDashboard/CountStat.tsx";
|
import CountStat from "@/pages/admin/AnalyticsDashboard/CountStat.tsx";
|
||||||
import {IconBug, IconCalendarStats, IconEye, IconTimeline, IconUsers, IconZoomExclamation} from "@tabler/icons-react";
|
import {
|
||||||
import {useMemo} from "react";
|
IconBug,
|
||||||
|
IconCalendarStats,
|
||||||
|
IconChartPie4,
|
||||||
|
IconEye,
|
||||||
|
IconGraph,
|
||||||
|
IconTimeline,
|
||||||
|
IconUsers,
|
||||||
|
IconZoomExclamation
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import {useForm} from "@mantine/form";
|
import {useForm} from "@mantine/form";
|
||||||
|
import AnalyticsGraphs from "@/pages/admin/AnalyticsDashboard/AnalyticsGraphs";
|
||||||
import NotFound from "@/pages/error-pages/NotFound.tsx";
|
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() {
|
export default function AnalyticsDashboard() {
|
||||||
|
|
||||||
const dateSelect = useMemo(() => ([
|
const dateSelect = [
|
||||||
{
|
{
|
||||||
label: "letztes Jahr",
|
label: "letztes Jahr",
|
||||||
value: '365'
|
value: '360'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "letztes halbes Jahr",
|
||||||
|
value: '180'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "letzte 30 Tage",
|
label: "letzte 30 Tage",
|
||||||
|
@ -27,15 +42,15 @@ export default function AnalyticsDashboard() {
|
||||||
label: "heute",
|
label: "heute",
|
||||||
value: '1'
|
value: '1'
|
||||||
}
|
}
|
||||||
]), [])
|
]
|
||||||
|
|
||||||
const formValues = useForm({
|
const formValues = useForm({
|
||||||
initialValues: {
|
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 <>
|
return <>
|
||||||
<div className={"section-transparent stack"}>
|
<div className={"section-transparent stack"}>
|
||||||
|
@ -59,17 +74,27 @@ export default function AnalyticsDashboard() {
|
||||||
{
|
{
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: "Sessions",
|
label: "Trend",
|
||||||
path: "/admin/analytics/sessions",
|
path: "/admin/analytics",
|
||||||
|
icon: <IconGraph/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Seiten",
|
||||||
|
path: "/admin/analytics/pages",
|
||||||
icon: <IconTimeline/>
|
icon: <IconTimeline/>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Graphen",
|
||||||
|
path: "/admin/analytics/graphs",
|
||||||
|
icon: <IconChartPie4/>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Fehler",
|
label: "Fehler",
|
||||||
path: "/admin/analytics/error",
|
path: "/admin/analytics/error",
|
||||||
icon: <IconBug/>
|
icon: <IconBug/>
|
||||||
}
|
}
|
||||||
].map((e, i) => (
|
].map((e, i) => (
|
||||||
<NavLink key={i} to={e.path} replace>
|
<NavLink key={i} to={e.path} replace end={true}>
|
||||||
{({isActive}) => (
|
{({isActive}) => (
|
||||||
<Button
|
<Button
|
||||||
variant={isActive ? "filled" : "light"}
|
variant={isActive ? "filled" : "light"}
|
||||||
|
@ -82,11 +107,10 @@ export default function AnalyticsDashboard() {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
leftSection={<IconCalendarStats/>}
|
|
||||||
placeholder="Zeitraum"
|
placeholder="Zeitraum"
|
||||||
clearable
|
leftSection={<IconCalendarStats/>}
|
||||||
|
allowDeselect={false}
|
||||||
data={dateSelect}
|
data={dateSelect}
|
||||||
{...formValues.getInputProps("dateFilter")}
|
{...formValues.getInputProps("dateFilter")}
|
||||||
/>
|
/>
|
||||||
|
@ -99,7 +123,7 @@ export default function AnalyticsDashboard() {
|
||||||
lastNDays={dateFilter}
|
lastNDays={dateFilter}
|
||||||
collection={"analyticsVisitors"}
|
collection={"analyticsVisitors"}
|
||||||
filter={'meta.localDevMode!=true'}
|
filter={'meta.localDevMode!=true'}
|
||||||
label={"Besucher"}
|
label={"Besuchende"}
|
||||||
icon={<IconUsers/>}
|
icon={<IconUsers/>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -120,9 +144,12 @@ export default function AnalyticsDashboard() {
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to={"sessions"}/>}/>
|
<Route index element={<AnalyticsTrend lastNDays={dateFilter}/>}/>
|
||||||
<Route path={"sessions/*"} element={<Sessions 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/>}/>
|
<Route path={"*"} element={<NotFound/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {
|
||||||
BrowserIcon,
|
BrowserIcon,
|
||||||
DeviceTypeIcon,
|
DeviceTypeIcon,
|
||||||
OsIcon
|
OsIcon
|
||||||
} from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionRow.tsx";
|
} from "@/pages/admin/AnalyticsDashboard/AnalyticsErrors/AnalyticsErrorRow.tsx";
|
||||||
import DataItem from "@/pages/util/whatWeKnowAboutYou/DataItem.tsx";
|
import DataItem from "@/pages/util/whatWeKnowAboutYou/DataItem.tsx";
|
||||||
import {useLocation} from "react-router-dom";
|
import {useLocation} from "react-router-dom";
|
||||||
import {useCookies} from "react-cookie";
|
import {useCookies} from "react-cookie";
|
||||||
|
|
|
@ -4,6 +4,18 @@
|
||||||
font-size: calc(var(--mantine-font-size-xs) * 0.8);
|
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);
|
--gap: var(--mantine-spacing-sm);
|
||||||
--padding: var(--mantine-spacing-md);
|
--padding: var(--mantine-spacing-md);
|
||||||
|
|
30
yarn.lock
30
yarn.lock
|
@ -531,11 +531,23 @@
|
||||||
"@mantine/store" "7.10.0"
|
"@mantine/store" "7.10.0"
|
||||||
react-transition-group "4.4.5"
|
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":
|
"@mantine/store@7.10.0":
|
||||||
version "7.10.0"
|
version "7.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/@mantine/store/-/store-7.10.0.tgz#68368c6ca5b75cfb331220e06a3235be753df055"
|
resolved "https://registry.yarnpkg.com/@mantine/store/-/store-7.10.0.tgz#68368c6ca5b75cfb331220e06a3235be753df055"
|
||||||
integrity sha512-B6AyUX0cA97/hI9v0att7eJJnQTcUG7zBlTdWhOsptBV5UoDNrzdv3DDWIFxrA8h+nhNKGBh6Dif5HWh1+QLeA==
|
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":
|
"@mantine/tiptap@^7.10.0":
|
||||||
version "7.10.0"
|
version "7.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/@mantine/tiptap/-/tiptap-7.10.0.tgz#77926f0a2d81c05e3f4084e65786778d5227fa56"
|
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"
|
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a"
|
||||||
integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==
|
integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==
|
||||||
|
|
||||||
"@tabler/icons-react@^3.2.0":
|
"@tabler/icons-react@^3.21.0":
|
||||||
version "3.2.0"
|
version "3.21.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.2.0.tgz#1b8d4059672ec2999f29f9aea03c44f00084ce66"
|
resolved "https://registry.yarnpkg.com/@tabler/icons-react/-/icons-react-3.21.0.tgz#195aa60c16a5c3cf20e03adca6144ea340c573de"
|
||||||
integrity sha512-b1mZT1XpZrzvbM+eFe1YbYbxkzgJ18tM4knZKqXh0gnHDZ6XVLIH3TzJZ3HZ7PTkUqZLZ7XcGae3qQVGburlBw==
|
integrity sha512-Qq0GnZzzccbv/zuMyXAUUPlogNAqx9KsF8cr/ev3bxs+GMObqNEjXv1eZl9GFzxyQTS435siJNU8A1BaIYhX8g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tabler/icons" "3.2.0"
|
"@tabler/icons" "3.21.0"
|
||||||
|
|
||||||
"@tabler/icons@3.2.0":
|
"@tabler/icons@3.21.0":
|
||||||
version "3.2.0"
|
version "3.21.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.2.0.tgz#4adcde021943d50f2c228dab6145fd9c9b639f85"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.21.0.tgz#d48b5d96d56736d4bd7d32b4a77b1c3e8df12908"
|
||||||
integrity sha512-h8GQ2rtxgiSjltrVz4vcopAxTPSpUSUi5nBfJ09H3Bk4fJk6wZ/dVUjzhv/BHfDwGTkAxZBiYe/Q/T95cPeg5Q==
|
integrity sha512-5+GkkmWCr1wgMor5cOF1/YYflTQdc15y10FUikJ3HW8hDiFjfbuoAHJi17FT1vwsr1sA78rkJMn+fDoOOjnnPA==
|
||||||
|
|
||||||
"@tanstack/query-core@5.0.5":
|
"@tanstack/query-core@5.0.5":
|
||||||
version "5.0.5"
|
version "5.0.5"
|
||||||
|
|
Loading…
Reference in New Issue