Sessions page.

This commit is contained in:
Mike Cao 2024-07-28 19:51:14 -07:00
parent cd0f185f77
commit ac60d08ee5
17 changed files with 414 additions and 16 deletions

View file

@ -45,11 +45,11 @@ export function WebsiteHeader({
icon: <Icons.Reports />,
path: '/reports',
},
// {
// label: formatMessage(labels.sessions),
// icon: <Icons.User />,
// path: '/sessions',
// },
{
label: formatMessage(labels.sessions),
icon: <Icons.User />,
path: '/sessions',
},
{
label: formatMessage(labels.events),
icon: <Icons.Nodes />,
@ -69,7 +69,7 @@ export function WebsiteHeader({
<div className={styles.links}>
{links.map(({ label, icon, path }) => {
const selected = path
? pathname.endsWith(path)
? pathname.includes(path)
: pathname.match(/^\/websites\/[\w-]+$/);
return (

View file

@ -1,6 +1,8 @@
import Link from 'next/link';
import { GridColumn, GridTable, useBreakpoint } from 'react-basics';
import { useFormat, useMessages } from 'components/hooks';
import { formatDistanceToNow } from 'date-fns';
import Profile from 'components/common/Profile';
export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean }) {
const { formatMessage, labels } = useMessages();
@ -9,7 +11,16 @@ export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean
return (
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
<GridColumn name="id" label="ID" />
<GridColumn name="pic" label="" width="90px">
{row => <Profile seed={row.id} size={64} />}
</GridColumn>
<GridColumn name="id" label="ID">
{row => (
<Link href={`sessions/${row.id}`}>
{row.id} ({row.firstAt !== row.lastAt ? 'YES' : 'NO'})
</Link>
)}
</GridColumn>
<GridColumn name="country" label={formatMessage(labels.country)}>
{row => formatValue(row.country, 'country')}
</GridColumn>

View file

@ -0,0 +1,3 @@
.page {
display: grid;
}

View file

@ -0,0 +1,27 @@
'use client';
import WebsiteHeader from '../../WebsiteHeader';
import SessionInfo from './SessionInfo';
import { useSession } from 'components/hooks';
import { Loading } from 'react-basics';
import styles from './SessionDetailsPage.module.css';
export default function SessionDetailsPage({
websiteId,
sessionId,
}: {
websiteId: string;
sessionId: string;
}) {
const { data, isLoading } = useSession(websiteId, sessionId);
if (isLoading) {
return <Loading position="page" />;
}
return (
<div className={styles.page}>
<WebsiteHeader websiteId={websiteId} />
<SessionInfo data={data} />
</div>
);
}

View file

@ -0,0 +1,17 @@
import Profile from 'components/common/Profile';
export default function SessionInfo({ data }) {
return (
<h1>
<Profile seed={data?.id} />
<dl>
<dt>ID</dt>
<dd>{data?.id}</dd>
<dt>Country</dt>
<dd>{data?.country}</dd>
<dt>City</dt>
<dd>{data?.city}</dd>
</dl>
</h1>
);
}

View file

@ -0,0 +1,10 @@
import SessionDetailsPage from './SessionDetailsPage';
import { Metadata } from 'next';
export default function WebsitePage({ params: { websiteId, sessionId } }) {
return <SessionDetailsPage websiteId={websiteId} sessionId={sessionId} />;
}
export const metadata: Metadata = {
title: 'Websites',
};

View file

@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { lorelei } from '@dicebear/collection';
import md5 from 'md5';
const lib = lorelei;
function convertToPastel(hexColor: string, pastelFactor: number = 0.5) {
// Remove the # if present
hexColor = hexColor.replace(/^#/, '');
// Convert hex to RGB
let r = parseInt(hexColor.substr(0, 2), 16);
let g = parseInt(hexColor.substr(2, 2), 16);
let b = parseInt(hexColor.substr(4, 2), 16);
// Calculate pastel version (mix with white)
//const pastelFactor = 0.5; // Adjust this value to control pastel intensity
r = Math.floor((r + 255 * pastelFactor) / (1 + pastelFactor));
g = Math.floor((g + 255 * pastelFactor) / (1 + pastelFactor));
b = Math.floor((b + 255 * pastelFactor) / (1 + pastelFactor));
// Convert back to hex
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
}
function Profile({ seed, size = 128, ...props }: { seed: string; size?: number }) {
const avatar = useMemo(() => {
return createAvatar(lib, {
...props,
seed,
size,
backgroundColor: [convertToPastel(md5(seed).substring(0, 6), 2).replace(/^#/, '')],
}).toDataUri();
}, []);
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%' }} />;
}
export default Profile;

View file

@ -5,6 +5,7 @@ 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/useShareToken';
export * from './queries/useTeam';

View file

@ -0,0 +1,14 @@
import { useApi } from './useApi';
export function useSession(websiteId: string, sessionId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['session', { websiteId, sessionId }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}`);
},
});
}
export default useSession;

View file

@ -4,7 +4,7 @@ import useModified from '../useModified';
export function useSessions(websiteId: string, params?: { [key: string]: string | number }) {
const { get } = useApi();
const { modified } = useModified(`websites`);
const { modified } = useModified(`sessions`);
return useFilterQuery({
queryKey: ['sessions', { websiteId, modified, ...params }],

View file

@ -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 { getSession } from 'queries';
export interface ReportsRequestQuery 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<ReportsRequestQuery, 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 getSession(websiteId, sessionId);
return ok(res, data);
}
return methodNotAllowed(res);
};

View file

@ -1,9 +1,46 @@
import prisma from 'lib/prisma';
import clickhouse from 'lib/clickhouse';
import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db';
export async function getSession(id: string) {
export async function getSession(...args: [websiteId: string, sessionId: string]) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(websiteId: string, sessionId: string) {
return prisma.client.session.findUnique({
where: {
id,
id: sessionId,
},
});
}
async function clickhouseQuery(websiteId: string, sessionId: string) {
const { rawQuery } = clickhouse;
return rawQuery(
`
select
session_id as id,
website_id as websiteId,
min(created_at) as firstAt,
max(created_at) as lastAt,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
city
from website_event
where website_id = {websiteId:UUID}
and session_id = {sessionId:UUID}
group by session_id, website_id, hostname, browser, os, device, screen, language, country, subdivision1, city
`,
{ websiteId, sessionId },
).then(result => result?.[0]);
}

View file

@ -24,7 +24,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
}
async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
const { pagedQuery, parseFilters, getDateStringSQL } = clickhouse;
const { pagedQuery, parseFilters } = clickhouse;
const { params, dateQuery, filterQuery } = await parseFilters(websiteId, filters);
return pagedQuery(
@ -32,7 +32,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
select
session_id as id,
website_id as websiteId,
${getDateStringSQL('created_at', 'second', filters.timezone)} as createdAt,
min(created_at) as createdAt,
hostname,
browser,
os,
@ -41,13 +41,13 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
language,
country,
subdivision1,
subdivision2,
city
from website_event
where website_id = {websiteId:UUID}
${dateQuery}
${filterQuery}
order by created_at desc
group by session_id, website_id, hostname, browser, os, device, screen, language, country, subdivision1, city
order by createdAt desc
`,
params,
pageParams,