mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-06 01:15:42 +01:00
Session details screen.
This commit is contained in:
parent
c3c3b46ef6
commit
f32bf0a2e0
@ -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;
|
||||
|
@ -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;
|
||||
}
|
@ -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 <Loading position="page" />;
|
||||
}
|
||||
|
||||
let lastDay = null;
|
||||
|
||||
return (
|
||||
<div className={styles.timeline}>
|
||||
<h1>Activity log</h1>
|
||||
{data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => {
|
||||
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
||||
lastDay = createdAt;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader && (
|
||||
<div className={styles.header}>
|
||||
{formatDate(new Date(createdAt), 'EEEE, PPP', locale)}
|
||||
</div>
|
||||
)}
|
||||
<div key={eventId} className={styles.row}>
|
||||
<div className={styles.time}>
|
||||
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
||||
{formatDate(new Date(createdAt), 'h:mm:ss aaa', locale)}
|
||||
</StatusLight>
|
||||
</div>
|
||||
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||
<div>{eventName || urlPath}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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 <Loading position="page" />;
|
||||
@ -27,7 +29,12 @@ export default function SessionDetailsPage({
|
||||
<Profile seed={data?.id} />
|
||||
<SessionInfo data={data} />
|
||||
</div>
|
||||
<div className={styles.content}>oh hi.</div>
|
||||
<div className={styles.content}>
|
||||
<SessionActivity websiteId={websiteId} sessionId={sessionId} />
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<SessionStats data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -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 }) {
|
||||
<dd>
|
||||
{data?.id} <CopyIcon value={data?.id} />
|
||||
</dd>
|
||||
<dt>{formatMessage(labels.firstSeen)}</dt>
|
||||
<dd>{format(new Date(data?.firstAt), 'PPPpp')}</dd>
|
||||
|
||||
<dt>{formatMessage(labels.lastSeen)}</dt>
|
||||
<dd>{format(new Date(data?.lastAt), 'PPPpp')}</dd>
|
||||
<dd>{formatDate(new Date(data?.lastAt), 'EEEE, PPPpp', locale)}</dd>
|
||||
|
||||
<dt>{formatMessage(labels.firstSeen)}</dt>
|
||||
<dd>{formatDate(new Date(data?.firstAt), 'EEEE, PPPpp', locale)}</dd>
|
||||
|
||||
<dt>{formatMessage(labels.country)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="country" value={data?.country} />
|
||||
{formatValue(data?.country, 'country')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.region)}</dt>
|
||||
<dd>
|
||||
<Icon>
|
||||
@ -34,6 +38,7 @@ export default function SessionInfo({ data }) {
|
||||
</Icon>
|
||||
{getRegionName(data?.subdivision1)}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.city)}</dt>
|
||||
<dd>
|
||||
<Icon>
|
||||
@ -41,16 +46,19 @@ export default function SessionInfo({ data }) {
|
||||
</Icon>
|
||||
{data?.city}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.os)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />
|
||||
{formatValue(data?.os, 'os')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.device)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="device" value={data?.device} />
|
||||
{formatValue(data?.device, 'device')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.browser)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="browser" value={data?.browser} />
|
||||
|
@ -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 (
|
||||
<>
|
||||
<MetricCard label={formatMessage(labels.visits)} value={data?.visits} />
|
||||
<MetricCard label={formatMessage(labels.views)} value={data?.views} />
|
||||
<MetricCard label={formatMessage(labels.events)} value={data?.events} />
|
||||
</>
|
||||
);
|
||||
}
|
1
src/assets/location.svg
Normal file
1
src/assets/location.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Pin"><path d="m32 0a24.0319 24.0319 0 0 0 -24 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0c.95-.91 23.31-22.49 23.31-39.72a24.0319 24.0319 0 0 0 -24-24zm0 35a11 11 0 1 1 11-11 11.0066 11.0066 0 0 1 -11 11z"/></g></svg>
|
After Width: | Height: | Size: 320 B |
@ -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';
|
||||
|
12
src/components/hooks/queries/useSessionActivity.ts
Normal file
12
src/components/hooks/queries/useSessionActivity.ts
Normal file
@ -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`);
|
||||
},
|
||||
});
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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<Session> {
|
||||
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) {
|
||||
|
@ -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<SessionActivityRequestQuery, any>,
|
||||
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);
|
||||
};
|
@ -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<ReportsRequestQuery, any>,
|
||||
req: NextApiRequestQueryBody<WesiteSessionRequestQuery, any>,
|
||||
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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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),
|
||||
]);
|
||||
|
44
src/queries/analytics/sessions/getSessionActivity.ts
Normal file
44
src/queries/analytics/sessions/getSessionActivity.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
@ -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}
|
@ -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({
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user