diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx index 9e9f97e9..d69cad12 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx @@ -1,4 +1,4 @@ -import { useSessions } from 'components/hooks'; +import { useWebsiteSessions } from 'components/hooks'; import SessionsTable from './SessionsTable'; import DataTable from 'components/common/DataTable'; import { ReactNode } from 'react'; @@ -11,7 +11,7 @@ export default function SessionsDataTable({ teamId?: string; children?: ReactNode; }) { - const queryResult = useSessions(websiteId); + const queryResult = useWebsiteSessions(websiteId); if (queryResult?.result?.data?.length === 0) { return children; diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.module.css b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.module.css new file mode 100644 index 00000000..bcc6868d --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.module.css @@ -0,0 +1,20 @@ +.timeline { + display: flex; + flex-direction: column; + gap: 20px; +} + +.row { + display: flex; + align-items: center; + gap: 20px; +} + +.time { + color: var(--font-color200); + width: 120px; +} + +.header { + font-weight: bold; +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx new file mode 100644 index 00000000..c644fb89 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionActivity.tsx @@ -0,0 +1,52 @@ +import { formatDate } from 'lib/date'; +import { isSameDay } from 'date-fns'; +import { Loading, Icon, StatusLight } from 'react-basics'; +import Icons from 'components/icons'; +import { useLocale, useSessionActivity } from 'components/hooks'; +import styles from './SessionActivity.module.css'; + +export function SessionActivity({ + websiteId, + sessionId, +}: { + websiteId: string; + sessionId: string; +}) { + const { locale } = useLocale(); + const { data, isLoading } = useSessionActivity(websiteId, sessionId); + + if (isLoading) { + return ; + } + + let lastDay = null; + + return ( +
+

Activity log

+ {data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => { + const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); + lastDay = createdAt; + + return ( + <> + {showHeader && ( +
+ {formatDate(new Date(createdAt), 'EEEE, PPP', locale)} +
+ )} +
+
+ + {formatDate(new Date(createdAt), 'h:mm:ss aaa', locale)} + +
+ {eventName ? : } +
{eventName || urlPath}
+
+ + ); + })} +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css index 21bb011a..af5ed201 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.module.css @@ -1,18 +1,27 @@ .page { display: grid; - grid-template-columns: 300px 1fr; + grid-template-columns: 300px 1fr max-content; + margin-bottom: 40px; } .sidebar { display: flex; flex-direction: column; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 20px; padding-right: 20px; border-right: 1px solid var(--base300); } .content { + padding: 0 20px; +} + +.stats { + display: flex; + flex-direction: column; + gap: 20px; + border-left: 1px solid var(--base300); padding-left: 20px; } diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx index 5aaac952..723e6ff7 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionDetailsPage.tsx @@ -1,10 +1,12 @@ 'use client'; import WebsiteHeader from '../../WebsiteHeader'; import SessionInfo from './SessionInfo'; -import { useSession } from 'components/hooks'; +import { useWebsiteSession } from 'components/hooks'; import { Loading } from 'react-basics'; import Profile from 'components/common/Profile'; import styles from './SessionDetailsPage.module.css'; +import { SessionActivity } from './SessionActivity'; +import SessionStats from './SessionStats'; export default function SessionDetailsPage({ websiteId, @@ -13,7 +15,7 @@ export default function SessionDetailsPage({ websiteId: string; sessionId: string; }) { - const { data, isLoading } = useSession(websiteId, sessionId); + const { data, isLoading } = useWebsiteSession(websiteId, sessionId); if (isLoading) { return ; @@ -27,7 +29,12 @@ export default function SessionDetailsPage({ -
oh hi.
+
+ +
+
+ +
); diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx index cae200ab..4b3e8a06 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionInfo.tsx @@ -1,4 +1,4 @@ -import { format } from 'date-fns'; +import { formatDate } from 'lib/date'; import { useFormat, useLocale, useMessages, useRegionNames } from 'components/hooks'; import TypeIcon from 'components/common/TypeIcon'; import { Icon, CopyIcon } from 'react-basics'; @@ -18,15 +18,19 @@ export default function SessionInfo({ data }) {
{data?.id}
-
{formatMessage(labels.firstSeen)}
-
{format(new Date(data?.firstAt), 'PPPpp')}
+
{formatMessage(labels.lastSeen)}
-
{format(new Date(data?.lastAt), 'PPPpp')}
+
{formatDate(new Date(data?.lastAt), 'EEEE, PPPpp', locale)}
+ +
{formatMessage(labels.firstSeen)}
+
{formatDate(new Date(data?.firstAt), 'EEEE, PPPpp', locale)}
+
{formatMessage(labels.country)}
{formatValue(data?.country, 'country')}
+
{formatMessage(labels.region)}
@@ -34,6 +38,7 @@ export default function SessionInfo({ data }) { {getRegionName(data?.subdivision1)}
+
{formatMessage(labels.city)}
@@ -41,16 +46,19 @@ export default function SessionInfo({ data }) { {data?.city}
+
{formatMessage(labels.os)}
{formatValue(data?.os, 'os')}
+
{formatMessage(labels.device)}
{formatValue(data?.device, 'device')}
+
{formatMessage(labels.browser)}
diff --git a/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx new file mode 100644 index 00000000..574152ec --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/[sessionId]/SessionStats.tsx @@ -0,0 +1,14 @@ +import MetricCard from 'components/metrics/MetricCard'; +import { useMessages } from 'components/hooks'; + +export default function SessionStats({ data }) { + const { formatMessage, labels } = useMessages(); + + return ( + <> + + + + + ); +} diff --git a/src/assets/location.svg b/src/assets/location.svg new file mode 100644 index 00000000..b0f9af47 --- /dev/null +++ b/src/assets/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 515255e6..4ef89251 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -5,8 +5,9 @@ export * from './queries/useLogin'; export * from './queries/useRealtime'; export * from './queries/useReport'; export * from './queries/useReports'; -export * from './queries/useSession'; -export * from './queries/useSessions'; +export * from './queries/useSessionActivity'; +export * from './queries/useWebsiteSession'; +export * from './queries/useWebsiteSessions'; export * from './queries/useShareToken'; export * from './queries/useTeam'; export * from './queries/useTeams'; diff --git a/src/components/hooks/queries/useSessionActivity.ts b/src/components/hooks/queries/useSessionActivity.ts new file mode 100644 index 00000000..e6d7ffa0 --- /dev/null +++ b/src/components/hooks/queries/useSessionActivity.ts @@ -0,0 +1,12 @@ +import { useApi } from './useApi'; + +export function useSessionActivity(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:activity', { websiteId, sessionId }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}/activity`); + }, + }); +} diff --git a/src/components/hooks/queries/useSession.ts b/src/components/hooks/queries/useWebsiteSession.ts similarity index 69% rename from src/components/hooks/queries/useSession.ts rename to src/components/hooks/queries/useWebsiteSession.ts index f65efcf4..64c7be58 100644 --- a/src/components/hooks/queries/useSession.ts +++ b/src/components/hooks/queries/useWebsiteSession.ts @@ -1,6 +1,6 @@ import { useApi } from './useApi'; -export function useSession(websiteId: string, sessionId: string) { +export function useWebsiteSession(websiteId: string, sessionId: string) { const { get, useQuery } = useApi(); return useQuery({ @@ -11,4 +11,4 @@ export function useSession(websiteId: string, sessionId: string) { }); } -export default useSession; +export default useWebsiteSession; diff --git a/src/components/hooks/queries/useSessions.ts b/src/components/hooks/queries/useWebsiteSessions.ts similarity index 76% rename from src/components/hooks/queries/useSessions.ts rename to src/components/hooks/queries/useWebsiteSessions.ts index 91f4ea4c..9430786b 100644 --- a/src/components/hooks/queries/useSessions.ts +++ b/src/components/hooks/queries/useWebsiteSessions.ts @@ -2,7 +2,7 @@ import { useApi } from './useApi'; import { useFilterQuery } from './useFilterQuery'; import useModified from '../useModified'; -export function useSessions(websiteId: string, params?: { [key: string]: string | number }) { +export function useWebsiteSessions(websiteId: string, params?: { [key: string]: string | number }) { const { get } = useApi(); const { modified } = useModified(`sessions`); @@ -17,4 +17,4 @@ export function useSessions(websiteId: string, params?: { [key: string]: string }); } -export default useSessions; +export default useWebsiteSessions; diff --git a/src/lib/load.ts b/src/lib/load.ts index 7812ea0d..970247dd 100644 --- a/src/lib/load.ts +++ b/src/lib/load.ts @@ -1,4 +1,4 @@ -import { getSession, getWebsite } from 'queries'; +import { getWebsiteSession, getWebsite } from 'queries'; import { Website, Session } from '@prisma/client'; import redis from '@umami/redis-client'; @@ -22,9 +22,13 @@ export async function fetchSession(sessionId: string): Promise { let session = null; if (redis.enabled) { - session = await redis.client.fetch(`session:${sessionId}`, () => getSession(sessionId), 86400); + session = await redis.client.fetch( + `session:${sessionId}`, + () => getWebsiteSession(sessionId), + 86400, + ); } else { - session = await getSession(sessionId); + session = await getWebsiteSession(sessionId); } if (!session) { diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts new file mode 100644 index 00000000..8d1b2346 --- /dev/null +++ b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts @@ -0,0 +1,42 @@ +import * as yup from 'yup'; +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody, PageParams } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getSessionActivity } from 'queries'; + +export interface SessionActivityRequestQuery extends PageParams { + websiteId: string; + sessionId: string; +} + +const schema = { + GET: yup.object().shape({ + websiteId: yup.string().uuid().required(), + sessionId: yup.string().uuid().required(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + const { websiteId, sessionId } = req.query; + + if (req.method === 'GET') { + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const data = await getSessionActivity(websiteId, sessionId); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId].ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts similarity index 78% rename from src/pages/api/websites/[websiteId]/sessions/[sessionId].ts rename to src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts index 57e99ea3..f627a208 100644 --- a/src/pages/api/websites/[websiteId]/sessions/[sessionId].ts +++ b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/index.ts @@ -4,9 +4,9 @@ import { useAuth, useCors, useValidate } from 'lib/middleware'; import { NextApiRequestQueryBody, PageParams } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getSession } from 'queries'; +import { getWebsiteSession } from 'queries'; -export interface ReportsRequestQuery extends PageParams { +export interface WesiteSessionRequestQuery extends PageParams { websiteId: string; sessionId: string; } @@ -19,7 +19,7 @@ const schema = { }; export default async ( - req: NextApiRequestQueryBody, + req: NextApiRequestQueryBody, res: NextApiResponse, ) => { await useCors(req, res); @@ -33,7 +33,7 @@ export default async ( return unauthorized(res); } - const data = await getSession(websiteId, sessionId); + const data = await getWebsiteSession(websiteId, sessionId); return ok(res, data); } diff --git a/src/pages/api/websites/[websiteId]/sessions/index.ts b/src/pages/api/websites/[websiteId]/sessions/index.ts index 21cacb1c..4f242882 100644 --- a/src/pages/api/websites/[websiteId]/sessions/index.ts +++ b/src/pages/api/websites/[websiteId]/sessions/index.ts @@ -5,7 +5,7 @@ import { NextApiRequestQueryBody, PageParams } from 'lib/types'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; import { pageInfo } from 'lib/schema'; -import { getSessions } from 'queries'; +import { getWebsiteSessions } from 'queries'; export interface ReportsRequestQuery extends PageParams { websiteId: string; @@ -33,7 +33,7 @@ export default async ( return unauthorized(res); } - const data = await getSessions(websiteId, {}, req.query); + const data = await getWebsiteSessions(websiteId, {}, req.query); return ok(res, data); } diff --git a/src/queries/analytics/getRealtimeData.ts b/src/queries/analytics/getRealtimeData.ts index 5a9c5a36..e25cc866 100644 --- a/src/queries/analytics/getRealtimeData.ts +++ b/src/queries/analytics/getRealtimeData.ts @@ -1,4 +1,4 @@ -import { getSessions, getEvents, getPageviewStats, getSessionStats } from 'queries/index'; +import { getWebsiteSessions, getEvents, getPageviewStats, getSessionStats } from 'queries/index'; const MAX_SIZE = 50; @@ -20,7 +20,7 @@ export async function getRealtimeData( const filters = { startDate, endDate: new Date(), unit: 'minute', timezone }; const [events, sessions, pageviews, sessionviews] = await Promise.all([ getEvents(websiteId, { startDate, timezone }, { pageSize: 10000 }), - getSessions(websiteId, { startDate, timezone }, { pageSize: 10000 }), + getWebsiteSessions(websiteId, { startDate, timezone }, { pageSize: 10000 }), getPageviewStats(websiteId, filters), getSessionStats(websiteId, filters), ]); diff --git a/src/queries/analytics/sessions/getSessionActivity.ts b/src/queries/analytics/sessions/getSessionActivity.ts new file mode 100644 index 00000000..bc1f9aca --- /dev/null +++ b/src/queries/analytics/sessions/getSessionActivity.ts @@ -0,0 +1,44 @@ +import prisma from 'lib/prisma'; +import clickhouse from 'lib/clickhouse'; +import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db'; + +export async function getSessionActivity(...args: [websiteId: string, sessionId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, sessionId: string) { + return prisma.client.websiteEvent.findMany({ + where: { + id: sessionId, + websiteId, + }, + }); +} + +async function clickhouseQuery(websiteId: string, sessionId: string) { + const { rawQuery } = clickhouse; + + return rawQuery( + ` + select + session_id as id, + website_id as websiteId, + created_at as createdAt, + url_path as urlPath, + url_query as urlQuery, + referrer_domain as referrerDomain, + event_id as eventId, + event_type as eventType, + event_name as eventName, + visit_id as visitId + from website_event + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + order by created_at desc + `, + { websiteId, sessionId }, + ); +} diff --git a/src/queries/analytics/sessions/getSession.ts b/src/queries/analytics/sessions/getWebsiteSession.ts similarity index 80% rename from src/queries/analytics/sessions/getSession.ts rename to src/queries/analytics/sessions/getWebsiteSession.ts index 9cdc45d6..2e6e8eca 100644 --- a/src/queries/analytics/sessions/getSession.ts +++ b/src/queries/analytics/sessions/getWebsiteSession.ts @@ -2,7 +2,7 @@ import prisma from 'lib/prisma'; import clickhouse from 'lib/clickhouse'; import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db'; -export async function getSession(...args: [websiteId: string, sessionId: string]) { +export async function getWebsiteSession(...args: [websiteId: string, sessionId: string]) { return runQuery({ [PRISMA]: () => relationalQuery(...args), [CLICKHOUSE]: () => clickhouseQuery(...args), @@ -13,6 +13,7 @@ async function relationalQuery(websiteId: string, sessionId: string) { return prisma.client.session.findUnique({ where: { id: sessionId, + websiteId, }, }); } @@ -35,7 +36,10 @@ async function clickhouseQuery(websiteId: string, sessionId: string) { subdivision1, city, min(created_at) as firstAt, - max(created_at) as lastAt + max(created_at) as lastAt, + uniq(visit_id) as "visits", + sumIf(1, event_type = 1) as "views", + sumIf(1, event_type = 2) as "events" from website_event where website_id = {websiteId:UUID} and session_id = {sessionId:UUID} diff --git a/src/queries/analytics/sessions/getSessions.ts b/src/queries/analytics/sessions/getWebsiteSessions.ts similarity index 97% rename from src/queries/analytics/sessions/getSessions.ts rename to src/queries/analytics/sessions/getWebsiteSessions.ts index 4e632430..2094c8a4 100644 --- a/src/queries/analytics/sessions/getSessions.ts +++ b/src/queries/analytics/sessions/getWebsiteSessions.ts @@ -3,7 +3,7 @@ import clickhouse from 'lib/clickhouse'; import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db'; import { PageParams, QueryFilters } from 'lib/types'; -export async function getSessions( +export async function getWebsiteSessions( ...args: [websiteId: string, filters?: QueryFilters, pageParams?: PageParams] ) { return runQuery({ diff --git a/src/queries/index.ts b/src/queries/index.ts index 796adea6..cd7ea280 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -19,9 +19,10 @@ export * from './analytics/reports/getUTM'; export * from './analytics/pageviews/getPageviewMetrics'; export * from './analytics/pageviews/getPageviewStats'; export * from './analytics/sessions/createSession'; -export * from './analytics/sessions/getSession'; +export * from './analytics/sessions/getWebsiteSession'; export * from './analytics/sessions/getSessionMetrics'; -export * from './analytics/sessions/getSessions'; +export * from './analytics/sessions/getWebsiteSessions'; +export * from './analytics/sessions/getSessionActivity'; export * from './analytics/sessions/getSessionStats'; export * from './analytics/sessions/saveSessionData'; export * from './analytics/getActiveVisitors';