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';