feat(analyticsDashboard): finished sessions view dashboard
Build and Push Docker image / build-and-push (push) Failing after 3m41s Details

This commit is contained in:
Valentin Kolb 2024-11-08 18:35:55 +01:00
parent d605d17d06
commit 8a7dc4869b
14 changed files with 273 additions and 219 deletions

View File

@ -1,4 +1,4 @@
import {Alert, Tooltip} from "@mantine/core";
import {Alert, AlertProps, Tooltip} from "@mantine/core";
import {IconBug} from "@tabler/icons-react";
import {useLocalStorage} from "@mantine/hooks";
import {ReactNode} from "react";
@ -34,8 +34,9 @@ export const useShowDebug = (): {
* Shows a help dialog if the user has not disabled it
* @see useShowHelp
* @param children - the content of the help dialog
* @param props - the props of the alert component
*/
export default function ShowDebug({children}: { children: ReactNode }) {
export default function ShowDebug({children, ...props}: { children: ReactNode } & Omit<AlertProps, "children">) {
const {showDebug} = useShowDebug()
@ -54,7 +55,9 @@ export default function ShowDebug({children}: { children: ReactNode }) {
>
<IconBug/>
</Tooltip>
}>
}
{...props}
>
{children}
</Alert>
}

View File

@ -1,12 +1,22 @@
import classes from "./index.module.css";
import {ReactNode} from "react";
import {ThemeIcon, ThemeIconProps} from "@mantine/core";
export default function TextWithIcon({icon, children}: {
export default function TextWithIcon({icon, children, className, themeIconProps}: {
icon: ReactNode,
children: ReactNode
children: ReactNode,
className?: string,
themeIconProps?: ThemeIconProps
}) {
return <div className={classes.container}>
return <div className={`${classes.container} ${className}`}>
<ThemeIcon
variant={"transparent"}
size={"xs"}
color={"gray"}
{...themeIconProps}
>
{icon}
</ThemeIcon>
<span>{children}</span>
</div>
}

View File

@ -134,12 +134,18 @@ export function formatDateForExcel(date: string | Date | Dayjs): string {
* Example: "3 Wo, 1 Tag, 2 Std, 53 Min"
* @param date1
* @param date2
* @param includeSeconds - If true, include seconds in the output.
*/
export function formatDuration(date1: string | Date | Dayjs, date2: string | Date | Dayjs) {
export function formatDuration(date1: string | Date | Dayjs, date2: string | Date | Dayjs, includeSeconds = false) {
if (!includeSeconds) {
// ignore seconds and milliseconds
date1 = dayjs(date1).startOf('minute');
date2 = dayjs(date2).startOf('minute');
} else {
date1 = dayjs(date1);
date2 = dayjs(date2);
}
// get the difference in milliseconds
const diff = dayjs(date2).diff(dayjs(date1));
@ -150,6 +156,7 @@ export function formatDuration(date1: string | Date | Dayjs, date2: string | Dat
const days = durationObj.days();
const hours = durationObj.hours();
const minutes = durationObj.minutes();
const seconds = durationObj.seconds();
// create a string array with the duration parts
const result: string[] = [];
@ -157,6 +164,8 @@ export function formatDuration(date1: string | Date | Dayjs, date2: string | Dat
if (days > 0) result.push(`${days} Tag${days > 1 ? 'e' : ''}`);
if (hours > 0) result.push(`${hours} Std`);
if (minutes > 0) result.push(`${minutes} Min`);
if (includeSeconds && seconds > 0) result.push(`${seconds} Sek`);
return result.join(', ');
// join the parts with a comma and space
return result.length ? result.join(', ') : `0 ${includeSeconds ? 'Sek' : 'Min'}`;
}

View File

@ -17,7 +17,6 @@ export type AnalyticsSessionModel = {
preferred_language?: string
expand?: {
visitor?: AnalyticsVisitorsModel,
analyticsErrors_via_session?: AnalyticsErrorModel[],
analyticsPageViews_via_session?: AnalyticsPageViewModel[]
}
} & RecordModel
@ -38,18 +37,6 @@ export type AnalyticsPageViewModel = {
}
} & RecordModel
export type AnalyticsErrorModel = {
session: string
error_message?: string
error_type?: string
stack_trace?: string
path?: string
expand?: {
session?: AnalyticsSessionModel
}
} & RecordModel
export type AnalyticsIpApiResult = {
ip: string
country_code: string

View File

@ -0,0 +1,48 @@
.mainGrid {
display: grid;
grid-template-columns: auto auto auto auto auto auto;
background-color: var(--mantine-color-body);
border: var(--border);
border-radius: var(--border-radius);
}
.subGrid {
display: grid;
grid-template-columns: subgrid;
grid-column: span 6;
gap: var(--gap);
font-size: var(--mantine-font-size-sm);
padding: var(--padding);
border-bottom: var(--border);
&:last-of-type {
border-bottom: none;
}
@media (max-width: $mantine-breakpoint-sm) {
font-size: var(--mantine-font-size-sm);
display: flex;
flex-direction: column;
gap: var(--gap);
}
}
.gridCell {
display: flex;
justify-content: start;
align-items: center;
text-align: start;
gap: var(--gap);
word-wrap: break-word; /* Ensures text wraps within the cell */
overflow-wrap: break-word; /* Ensures text wraps within the cell */
hyphens: auto;
@media (max-width: $mantine-breakpoint-sm) {
}
}
.debugContainer {
grid-column: span 6; /* Spans all columns of the main grid */
}

View File

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

View File

@ -1,5 +1,5 @@
import classes from "./index.module.css";
import {ActionIcon, Code, Collapse, ThemeIcon, Tooltip} from "@mantine/core";
import {ActionIcon, Collapse, Tooltip} from "@mantine/core";
import {
IconBrandAndroid,
IconBrandApple,
@ -31,9 +31,9 @@ import TextWithIcon from "@/components/layout/TextWithIcon";
import {useDisclosure} from "@mantine/hooks";
import ShowDebug from "@/components/ShowDebug.tsx";
import {pprintDateTime} from "@/lib/datetime.ts";
import {AnalyticsSessionModel} from "@/models/AnalyticsTypes.ts";
import AnalyticsSessionDetail from "@/pages/admin/AnalyticsDashboard/AnalyticsSessions/AnalyticsSessionDetail";
export const DeviceTypeIcon = ({deviceType}: { deviceType: string }) => {
if (deviceType === "mobile") {
@ -102,75 +102,54 @@ export default function AnalyticsSessionRow({session}: {
return <>
<div className={classes.subgrid} data-error={!!errorCount}>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendarClock/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconCalendarClock/>}
>
{pprintDateTime(session.created)}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<BrowserIcon browser={session.browser_name ?? ""}/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<BrowserIcon browser={session.browser_name ?? ""}/>}
>
{session.browser_name} {session.browser_version}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<OsIcon os={session.operating_system ?? ""}/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<OsIcon os={session.operating_system ?? ""}/>}
>
{session.operating_system} {session.operating_system_version}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconGlobe/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconGlobe/>}
>
{session.geo_country_code || "--"}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconLanguage/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconLanguage/>}
>
{session.preferred_language || "--"}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconClick/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconClick/>}
>
{pageViewCount ?? '--'}
</TextWithIcon>
</div>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={errorCount ? "red" : "gray"}>
<IconBug/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconBug/>}
>
{errorCount ?? '--'}
</TextWithIcon>
</div>
<div className={`${classes.child} ${classes.alignEnd}`}>
<Tooltip label={expanded ? "Details verbergen" : "Details anzeigen"} withArrow>
@ -182,21 +161,7 @@ export default function AnalyticsSessionRow({session}: {
</div>
<Collapse in={expanded} className={classes.detailsContainer}>
<ShowDebug>
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>
{/*<EntryQuestionAndStatusData entry={entry}/>*/}
<AnalyticsSessionDetail session={session}/>
</Collapse>
</>
}

View File

@ -52,7 +52,7 @@ export default function AnalyticsSessions({lastNDays}: { lastNDays?: number }) {
filter.push('analyticsPageViews_via_session.session=id&&analyticsPageViews_via_session.error?!=null')
}
return await pb.collection("analyticsSessions").getList(pageParam, 500, {
return await pb.collection("analyticsSessions").getList(pageParam, 100, {
filter: filter.join("&&"),
expand: 'analyticsErrors_via_session,analyticsPageViews_via_session',
sort: '-created'
@ -67,6 +67,7 @@ export default function AnalyticsSessions({lastNDays}: { lastNDays?: number }) {
const sessionAnalytics = useMemo(() => {
return countByAllKeys(sessions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query.data])
return <>
@ -102,7 +103,8 @@ export default function AnalyticsSessions({lastNDays}: { lastNDays?: number }) {
<div className={"section-transparent center"}>
<Text size={"xs"} c={"dimmed"}>
Datenvisualisierung der letzten {sessions.length} Sessions ({query.data?.pages[0].totalItems} insgesamt)
Datenvisualisierung der neuesten {sessions.length} Sessions
({query.data?.pages[0].totalItems} insgesamt)
{query.hasNextPage && <>
{" • "}

View File

@ -100,16 +100,16 @@ export default function EventListRouter({event}: { event: EventModel }) {
</ShowDebug>
<Alert color={list.open ? "green" : "red"}>
<TextWithIcon icon={list.open ? <IconLockOpen size={16}/> : <IconLock size={16}/>}>
<TextWithIcon icon={list.open ? <IconLockOpen/> : <IconLock/>}>
Liste ist für Anmeldungen <b>{list.open ? "geöffnet" : "geschlossen"}</b>
</TextWithIcon>
<br/>
<TextWithIcon icon={<IconUserCog size={16}/>}>
<TextWithIcon icon={<IconUserCog/>}>
Anmeldung für Personen mit {list.onlyStuVeAccounts ?
<><b>StuVe</b> Account</> : <>StuVe <b>und</b> Gast Account</>}
</TextWithIcon>
<br/>
<TextWithIcon icon={<IconClockCog size={16}/>}>
<TextWithIcon icon={<IconClockCog/>}>
Überlappende Einträge sind {!list.allowOverlappingEntries && <b>nicht</b>} erlaubt
</TextWithIcon>
</Alert>

View File

@ -1,7 +1,7 @@
import {areDatesSame, formatDuration} from "@/lib/datetime.ts";
import {Group, ThemeIcon} from "@mantine/core";
import {Group} from "@mantine/core";
import {IconCalendar, IconCalendarClock, IconClock, IconHourglass} from "@tabler/icons-react";
import dayjs from "dayjs";
import dayjs, {Dayjs} from "dayjs";
import TextWithIcon from "@/components/layout/TextWithIcon";
/**
@ -11,68 +11,47 @@ import TextWithIcon from "@/components/layout/TextWithIcon";
* @param start - start date
* @param end - end date
*/
export const RenderDateRange = ({start, end}: { start: Date, end: Date }) => {
export const RenderDateRange = ({start, end}: { start: string | Date | Dayjs, end: string | Date | Dayjs }) => {
const duration = formatDuration(start, end)
start = dayjs(start)
end = dayjs(end)
// case for same date
if (areDatesSame(start, end)) {
return <Group gap={"xs"}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconClock/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconClock/>}>
{dayjs(start).format("HH:mm")}
{"-"}
{dayjs(end).format("HH:mm")}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendar/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconCalendar/>}>
{dayjs(start).format("DD.MM.YY")}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconHourglass/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconHourglass/>}>
{duration}
</TextWithIcon>
</Group>
}
// case both dates start at 00:00:00
if (start.getHours() === 0 && start.getMinutes() === 0 && start.getSeconds() === 0 &&
end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0) {
if (start.hour() === 0 && start.minute() === 0 && start.second() === 0 &&
end.hour() === 0 && end.minute() === 0 && end.second() === 0) {
return <Group gap={'xs'}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendar/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconCalendar/>}>
{dayjs(start).format("DD.MM.YY")}
</TextWithIcon>
{"bis"}
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendar/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconCalendar/>}>
{dayjs(end).format("DD.MM.YY")}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconHourglass/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconHourglass/>}>
{duration}
</TextWithIcon>
</Group>
@ -80,29 +59,17 @@ export const RenderDateRange = ({start, end}: { start: Date, end: Date }) => {
// case different dates and times
return <Group gap={'xs'}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendarClock/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconCalendarClock/>}>
{dayjs(start).format("HH:mm DD.MM.YY")}
</TextWithIcon>
{"bis"}
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconCalendarClock/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconCalendarClock/>}>
{dayjs(end).format("HH:mm DD.MM.YY")}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconHourglass/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconHourglass/>}>
{duration}
</TextWithIcon>
</Group>

View File

@ -1,6 +1,6 @@
import {EventListSlotEntriesWithUserModel, EventModel} from "@/models/EventTypes.ts";
import classes from "./EventEntries.module.css";
import {ActionIcon, Code, Collapse, ThemeIcon, Tooltip} from "@mantine/core";
import {ActionIcon, Code, Collapse, Tooltip} from "@mantine/core";
import {IconEye, IconEyeOff, IconList, IconUser} from "@tabler/icons-react";
import TextWithIcon from "@/components/layout/TextWithIcon";
@ -26,22 +26,15 @@ function EventEntry({entry, refetch, event}: {
return <>
<div className={classes.subgrid}>
<div className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconUser/>
</ThemeIcon>
}>
<TextWithIcon
className={classes.child}
icon={<IconUser/>}
>
<RenderUserName user={entry.expand?.user}/>
</TextWithIcon>
</div>
<Link to={`/events/e/${entry.event}/lists/overview/${entry.eventList}`} className={classes.child}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconList/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconList/>}>
{entry.listName}
</TextWithIcon>
</Link>

View File

@ -1,15 +1,5 @@
import {EventListSlotEntriesWithUserModel} from "@/models/EventTypes.ts";
import {
ActionIcon,
Collapse,
Group,
Modal,
Text,
ThemeIcon,
Tooltip,
useMantineColorScheme,
useMantineTheme
} from "@mantine/core";
import {ActionIcon, Collapse, Group, Modal, Text, Tooltip, useMantineColorScheme, useMantineTheme} from "@mantine/core";
import {
IconChevronDown,
IconChevronRight,
@ -107,19 +97,11 @@ export default function UserEntryRow({entry, refetch}: {
</Tooltip>
<div className={classes.entryInfo}>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconConfetti/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconConfetti/>}>
{entry.eventName}
</TextWithIcon>
<TextWithIcon icon={
<ThemeIcon variant={"transparent"} size={"xs"} color={"gray"}>
<IconList/>
</ThemeIcon>
}>
<TextWithIcon icon={<IconList/>}>
<Link to={`/events/s/${entry.event}?lists=${entry.eventList}`}>
{entry.listName}
</Link>

View File

@ -1,24 +1,4 @@
.wrapper {
padding-top: calc(var(--mantine-spacing-xl) * 4);
padding-bottom: calc(var(--mantine-spacing-xl) * 4);
}
.title {
font-family: var(--mantine-font-family), sans-serif;
font-weight: 900;
margin-bottom: var(--mantine-spacing-md);
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
font-size: var(--mantine-font-size-lg);
text-align: left;
}
}
.description {
text-align: center;
@media (max-width: $mantine-breakpoint-sm) {
text-align: left;
}
.svg {
max-width: 70vw;
max-height: 20vh;
}

View File

@ -3,6 +3,8 @@ import {Link} from "react-router-dom";
import CollectedAnalyticsData from "@/pages/util/whatWeKnowAboutYou/CollectedAnalyticsData.tsx";
import CollectedUserData from "@/pages/util/whatWeKnowAboutYou/CollectedUserData.tsx";
import SVG from "@/illustrations/chart-circle.svg?react"
import classes from "./index.module.css";
export default function WhatWeKnowAboutYou() {
@ -10,6 +12,8 @@ export default function WhatWeKnowAboutYou() {
<div className={"section-transparent stack"}>
<Center mt={"xl"} className={"stack"}>
<SVG className={classes.svg} aria-label={"data analysis image"}/>
<Title order={1} c={"blue"}>Welche Daten werden gesammelt?</Title>
<Anchor