mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 20:57:17 +01:00
Sessions page.
This commit is contained in:
parent
cd0f185f77
commit
ac60d08ee5
17 changed files with 414 additions and 16 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
.page {
|
||||
display: grid;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
41
src/components/common/Profile.tsx
Normal file
41
src/components/common/Profile.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
14
src/components/hooks/queries/useSession.ts
Normal file
14
src/components/hooks/queries/useSession.ts
Normal 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;
|
||||
|
|
@ -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 }],
|
||||
|
|
|
|||
42
src/pages/api/websites/[websiteId]/sessions/[sessionId].ts
Normal file
42
src/pages/api/websites/[websiteId]/sessions/[sessionId].ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue