diff --git a/apps/dashboard/src/components/events/event-field-value.tsx b/apps/dashboard/src/components/events/event-field-value.tsx new file mode 100644 index 00000000..7ee55d1e --- /dev/null +++ b/apps/dashboard/src/components/events/event-field-value.tsx @@ -0,0 +1,77 @@ +import { fancyMinutes } from '@/hooks/useNumerFormatter'; +import { formatDateTime, formatTime } from '@/utils/date'; +import type { IServiceEvent } from '@openpanel/db'; +import { isToday } from 'date-fns'; +import { SerieIcon } from '../report-chart/common/serie-icon'; + +export function EventFieldValue({ + key, + value, + event, +}: { + key: keyof IServiceEvent; + value: any; + event: IServiceEvent; +}) { + if (!value) { + return null; + } + + if (value instanceof Date) { + return isToday(value) ? formatTime(value) : formatDateTime(value); + } + + switch (key) { + case 'osVersion': + return ( +
+ + {value} +
+ ); + case 'browserVersion': + return ( +
+ + {value} +
+ ); + case 'city': + return ( +
+ + {value} +
+ ); + case 'region': + return ( +
+ + {value} +
+ ); + case 'properties': + return JSON.stringify(value); + case 'country': + case 'browser': + case 'os': + case 'brand': + case 'model': + case 'device': + return ( +
+ + {value} +
+ ); + case 'duration': + return ( +
+ ({value}ms){' '} + {fancyMinutes(value / 1000)} +
+ ); + default: + return value; + } +} diff --git a/apps/dashboard/src/components/events/table/columns.tsx b/apps/dashboard/src/components/events/table/columns.tsx index 2eb48da3..7b26e2e1 100644 --- a/apps/dashboard/src/components/events/table/columns.tsx +++ b/apps/dashboard/src/components/events/table/columns.tsx @@ -1,7 +1,6 @@ import { EventIcon } from '@/components/events/event-icon'; import { ProjectLink } from '@/components/links'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; -import { TooltipComplete } from '@/components/tooltip-complete'; import { useNumber } from '@/hooks/useNumerFormatter'; import { pushModal } from '@/modals'; import { formatDateTime, formatTime } from '@/utils/date'; @@ -11,7 +10,6 @@ import { isToday } from 'date-fns'; import { ScrollArea } from '@/components/ui/scroll-area'; import type { IServiceEvent } from '@openpanel/db'; -import { omit } from 'ramda'; export function useColumns() { const number = useNumber(); @@ -196,6 +194,9 @@ export function useColumns() { accessorKey: 'properties', header: 'Properties', size: 400, + meta: { + className: 'p-0 [&_pre]:p-4', + }, cell({ row }) { const { properties } = row.original; const filteredProperties = Object.fromEntries( diff --git a/apps/dashboard/src/components/events/table/events-data-table.tsx b/apps/dashboard/src/components/events/table/events-data-table.tsx index 21e73b88..9dc2dc3c 100644 --- a/apps/dashboard/src/components/events/table/events-data-table.tsx +++ b/apps/dashboard/src/components/events/table/events-data-table.tsx @@ -133,6 +133,7 @@ export function EventsDataTable({ return ( { ? query.data?.pages[query.data.pages.length - 1]?.meta.next : query.data?.meta.next; + const [eventId, setEventId] = useState(null); + useOnPushModal('EventDetails', (isOpen, props) => { + setEventId(isOpen ? props.id : null); + }); + + useEffect(() => { + return bind(window, { + type: 'keydown', + listener(event) { + if (shouldIgnoreKeypress(event)) { + return; + } + + if (event.key === 'ArrowLeft') { + const index = data.findIndex((p) => p.id === eventId); + if (index !== -1) { + const match = data[index - 1]; + if (match) { + replaceWithModal('EventDetails', match); + } + } + } + if (event.key === 'ArrowRight') { + const index = data.findIndex((p) => p.id === eventId); + if (index !== -1) { + const match = data[index + 1]; + if (match) { + replaceWithModal('EventDetails', match); + } else if ( + hasNextPage && + isInfiniteQuery && + data.length > 0 && + query.isFetchingNextPage === false + ) { + query.fetchNextPage(); + } + } + } + }, + }); + }, [data, eventId]); + useEffect(() => { if ( hasNextPage && diff --git a/apps/dashboard/src/components/profiles/table/columns.tsx b/apps/dashboard/src/components/profiles/table/columns.tsx index 9fe965a5..33bb4271 100644 --- a/apps/dashboard/src/components/profiles/table/columns.tsx +++ b/apps/dashboard/src/components/profiles/table/columns.tsx @@ -29,6 +29,20 @@ export function useColumns(type?: 'profiles' | 'power-users') { ); }, }, + { + accessorKey: 'referrer', + header: 'Referrer', + cell({ row }) { + const { referrer, referrer_name } = row.original.properties; + const ref = referrer_name || referrer; + return ( +
+ + {ref} +
+ ); + }, + }, { accessorKey: 'country', header: 'Country', diff --git a/apps/dashboard/src/components/time-window-picker.tsx b/apps/dashboard/src/components/time-window-picker.tsx index 7abab486..4818125f 100644 --- a/apps/dashboard/src/components/time-window-picker.tsx +++ b/apps/dashboard/src/components/time-window-picker.tsx @@ -15,19 +15,10 @@ import { bind } from 'bind-event-listener'; import { CalendarIcon } from 'lucide-react'; import { useCallback, useEffect, useRef } from 'react'; +import { shouldIgnoreKeypress } from '@/utils/should-ignore-keypress'; import { timeWindows } from '@openpanel/constants'; import type { IChartRange } from '@openpanel/validation'; -function shouldIgnoreKeypress(event: KeyboardEvent) { - const tagName = (event?.target as HTMLElement)?.tagName; - const modifierPressed = - event.ctrlKey || event.metaKey || event.altKey || event.keyCode === 229; - const isTyping = - event.isComposing || tagName === 'INPUT' || tagName === 'TEXTAREA'; - - return modifierPressed || isTyping; -} - type Props = { value: IChartRange; onChange: (value: IChartRange) => void; diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx index 47c02d22..afcabeab 100644 --- a/apps/dashboard/src/components/ui/dialog.tsx +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -40,13 +40,18 @@ const DialogContent = React.forwardRef< ref={ref} className={cn( 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] md:w-full p-2 sm:p-6', - className, 'max-h-screen overflow-y-auto', // Ensure the dialog is scrollable if it exceeds the screen height 'mt-auto', // Add margin-top: auto for all screen sizes + 'focus:outline-none focus:ring-0 transition-none', )} {...props} > -
+
{children}
diff --git a/apps/dashboard/src/modals/event-details.tsx b/apps/dashboard/src/modals/event-details.tsx index 25f025e4..a508de6e 100644 --- a/apps/dashboard/src/modals/event-details.tsx +++ b/apps/dashboard/src/modals/event-details.tsx @@ -1,24 +1,30 @@ import { ReportChartShortcut } from '@/components/report-chart/shortcut'; -import { KeyValue } from '@/components/ui/key-value'; -import { useAppParams } from '@/hooks/useAppParams'; import { useEventQueryFilters, useEventQueryNamesFilter, } from '@/hooks/useEventQueryFilters'; import { api } from '@/trpc/client'; -import { round } from 'mathjs'; -import { SerieName } from '@/components/report-chart/common/serie-name'; +import { EventFieldValue } from '@/components/events/event-field-value'; +import { ProjectLink } from '@/components/links'; +import { + WidgetButtons, + WidgetHead, +} from '@/components/overview/overview-widget'; +import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { Button } from '@/components/ui/button'; +import { Widget, WidgetBody } from '@/components/widget'; import { WidgetTable } from '@/components/widget-table'; -import { useNumber } from '@/hooks/useNumerFormatter'; -import { formatDate, formatDateTime } from '@/utils/date'; -import { FilterIcon } from 'lucide-react'; -import { isNil, omit } from 'ramda'; -import { useMemo, useState } from 'react'; -import { useLocalStorage } from 'usehooks-ts'; +import { fancyMinutes } from '@/hooks/useNumerFormatter'; +import { camelCaseToWords } from '@/utils/casing'; +import { cn } from '@/utils/cn'; +import { getProfileName } from '@/utils/getters'; +import type { IClickhouseEvent, IServiceEvent } from '@openpanel/db'; +import { ArrowLeftIcon, ArrowRightIcon, FilterIcon, XIcon } from 'lucide-react'; +import { omit } from 'ramda'; +import { useState } from 'react'; import { popModal } from '.'; -import { ModalContent, ModalHeader } from './Modal/Container'; +import { ModalContent } from './Modal/Container'; interface Props { id: string; @@ -26,213 +32,390 @@ interface Props { projectId: string; } -const filterable = { - name: 'name', - referrer: 'referrer', - referrerName: 'referrer_name', - referrerType: 'referrer_type', - brand: 'brand', - model: 'model', - browser: 'browser', - browserVersion: 'browser_version', - os: 'os', - osVersion: 'os_version', - city: 'city', - region: 'region', - country: 'country', - device: 'device', - properties: 'properties', -}; +const filterable: Partial> = + { + name: 'name', + referrer: 'referrer', + referrerName: 'referrer_name', + referrerType: 'referrer_type', + brand: 'brand', + model: 'model', + browser: 'browser', + browserVersion: 'browser_version', + os: 'os', + osVersion: 'os_version', + city: 'city', + region: 'region', + country: 'country', + device: 'device', + properties: 'properties', + path: 'path', + origin: 'origin', + }; export default function EventDetails({ id, createdAt, projectId }: Props) { const [, setEvents] = useEventQueryNamesFilter(); const [, setFilter] = useEventQueryFilters(); - const [showNullable, setShowNullable] = useLocalStorage( - '@op:event-details-show-nullable', - false, - ); - const number = useNumber(); - const query = api.event.byId.useQuery({ id, projectId, createdAt }); + const TABS = { + essentials: { + id: 'essentials', + title: 'Essentials', + }, + detailed: { + id: 'detailed', + title: 'Detailed', + }, + }; + const [widget, setWidget] = useState(TABS.essentials); + const [{ event, session }] = api.event.details.useSuspenseQuery({ + id, + projectId, + createdAt, + }); - if (query.isLoading || query.isFetching) { - return null; - } - - if (query.isError || !query.isSuccess) { - return null; - } - - const event = query.data; + const profile = event.profile; const data = (() => { - const data = Object.entries(omit(['properties'], event)).map( - ([name, value]) => ({ - name: [name], - value: value as string | number | undefined, - }), - ); + const data: { name: keyof IServiceEvent; value: any }[] = [ + { + name: 'createdAt', + value: event.createdAt, + }, + { + name: 'name', + value: event.name, + }, + { + name: 'origin', + value: event.origin, + }, + { + name: 'path', + value: event.path, + }, + { + name: 'country', + value: event.country, + }, + { + name: 'region', + value: event.region, + }, + { + name: 'city', + value: event.city, + }, + { + name: 'referrer', + value: event.referrer, + }, + { + name: 'referrerName', + value: event.referrerName, + }, + { + name: 'referrerType', + value: event.referrerType, + }, + { + name: 'brand', + value: event.brand, + }, + { + name: 'model', + value: event.model, + }, + ]; - Object.entries(event.properties).forEach(([name, value]) => { - data.push({ - name: ['properties', ...name.split('.')], - value: value as string | number | undefined, - }); - }); + if (widget.id === TABS.detailed.id) { + data.length = 0; + Object.entries(omit(['properties', 'profile', 'meta'], event)).forEach( + ([name, value]) => { + if (!name.startsWith('__')) { + data.push({ + name: name as keyof IServiceEvent, + value: value as any, + }); + } + }, + ); + } return data.filter((item) => { - if (showNullable) { - return true; + if (widget.id === TABS.essentials.id) { + return !!item.value; } - return !!item.value; + return true; }); })(); + const properties = Object.entries(event.properties) + .filter(([name]) => !name.startsWith('__')) + .map(([name, value]) => ({ + name: name as keyof IServiceEvent, + value: value, + })); + return ( - - -
- item.name.join('.')} - columns={[ - { - name: 'Name', - className: 'text-left', - width: 'auto', - render(item) { - return ( -
- {item.name.map((name, index) => ( -
- {name} -
- ))} + + + +
+
{event.name}
+
+ + + +
+
+ + + {Object.entries(TABS).map(([key, tab]) => ( + + ))} + +
+ + {profile && ( + popModal()} + href={`/profiles/${profile.id}`} + className="card p-4 py-2 col gap-2 hover:bg-def-100" + > +
+
+ {profile.avatar && ( + + )} +
+ {getProfileName(profile, false)}
- ); - }, - }, - { - name: 'Value', - className: 'text-right font-mono font-medium', - width: 'auto', - render(item) { - const render = () => { - if ( - item.name[0] === 'duration' && - typeof item.value === 'number' - ) { +
+
+
+ + + +
+
+ {event.referrerName || event.referrer} +
+
+
+ {!!session && ( +
+ This session has {session.screen_view_count} screen views and{' '} + {session.event_count} events. Visit duration is{' '} + {fancyMinutes(session.duration / 1000)}. +
+ )} +
+ )} + + {properties.length > 0 && ( +
+
+
Properties
+
+ div:first-child]:hidden'} + columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4 h-auto" + data={properties} + keyExtractor={(item) => item.name} + columns={[ + { + name: 'Name', + className: 'text-left', + width: 'auto', + render(item) { + const splitKey = item.name.split('.'); + return ( +
+ {splitKey.map((name, index) => ( +
+ {camelCaseToWords(name)} +
+ ))} +
+ ); + }, + }, + { + name: 'Value', + className: 'text-right font-mono font-medium', + width: 'w-full', + render(item) { + return ( + + ); + }, + }, + ]} + /> +
+ )} +
+
+
Information
+
+ div:first-child]:hidden'} + columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4 h-auto" + data={data} + keyExtractor={(item) => item.name} + columns={[ + { + name: 'Name', + className: 'text-left', + width: 'auto', + render(item) { + const splitKey = item.name.split('.'); return ( -
- - ({item.value}ms) - {' '} - {number.formatWithUnit(item.value / 1000, 'min')} +
+ {splitKey.map((name, index) => ( +
+ {camelCaseToWords(name)} +
+ ))}
); - } + }, + }, + { + name: 'Value', + className: 'text-right font-mono font-medium', + width: 'w-full', + render(item) { + if (item.value && filterable[item.name]) { + return ( + + ); + } - if ( - isNil(item.value) || - item.value === '' || - item.value === '\x00\x00' - ) { - return
-
; - } - - if (typeof item.value === 'string') { - return
{item.value}
; - } - - if ((item.value as unknown) instanceof Date) { return ( -
- {formatDateTime(item.value as unknown as Date)} -
+ ); - } - - return ( -
- {JSON.stringify(item.value)} -
- ); - }; - - if ( - item.name[0] && - item.value && - filterable[item.name[0] as keyof typeof filterable] - ) { - return ( - - ); - } - - return render(); - }, - }, - ]} - /> -
- -
-
-
-
-
Similar events
- -
- -
+ }, + }, + ]} + /> +
+
+
+
All events for {event.name}
+ +
+
+ +
+
+
+
); } diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index 4057463b..f8dc4388 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -87,6 +87,7 @@ const modals = { export const { pushModal, popModal, + replaceWithModal, popAllModals, ModalProvider, useOnPushModal, diff --git a/apps/dashboard/src/utils/casing.ts b/apps/dashboard/src/utils/casing.ts new file mode 100644 index 00000000..d9887a36 --- /dev/null +++ b/apps/dashboard/src/utils/casing.ts @@ -0,0 +1,5 @@ +export const camelCaseToWords = (str: string) => { + return str + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()); +}; diff --git a/apps/dashboard/src/utils/getters.ts b/apps/dashboard/src/utils/getters.ts index be3ac8ad..4a00ce03 100644 --- a/apps/dashboard/src/utils/getters.ts +++ b/apps/dashboard/src/utils/getters.ts @@ -8,6 +8,10 @@ export function getProfileName( return ''; } + if (!profile.isExternal) { + return 'Anonymous'; + } + const name = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || profile.email; diff --git a/apps/dashboard/src/utils/should-ignore-keypress.ts b/apps/dashboard/src/utils/should-ignore-keypress.ts new file mode 100644 index 00000000..9b184e8b --- /dev/null +++ b/apps/dashboard/src/utils/should-ignore-keypress.ts @@ -0,0 +1,9 @@ +export function shouldIgnoreKeypress(event: KeyboardEvent) { + const tagName = (event?.target as HTMLElement)?.tagName; + const modifierPressed = + event.ctrlKey || event.metaKey || event.altKey || event.keyCode === 229; + const isTyping = + event.isComposing || tagName === 'INPUT' || tagName === 'TEXTAREA'; + + return modifierPressed || isTyping; +} diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index bb6193fd..acca5861 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -21,6 +21,7 @@ import { createSqlBuilder } from '../sql-builder'; import { getEventFiltersWhereClause } from './chart.service'; import type { IClickhouseProfile, IServiceProfile } from './profile.service'; import { + getProfileById, getProfiles, transformProfile, upsertProfile, @@ -830,7 +831,7 @@ class EventService { id: string; createdAt?: Date; }) { - return clix(this.client) + const event = await clix(this.client) .select(['*']) .from('events') .where('project_id', '=', projectId) @@ -852,6 +853,15 @@ class EventService { return transformEvent(res[0]); }); + + if (event?.profileId) { + const profile = await getProfileById(event?.profileId, projectId); + if (profile) { + event.profile = profile; + } + } + + return event; } async getList({ diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index 7a708bfd..07caefe3 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -1,3 +1,6 @@ +import { TABLE_NAMES, ch } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; + export type IClickhouseSession = { id: string; profile_id: string; @@ -39,3 +42,19 @@ export type IClickhouseSession = { version: number; properties: Record; }; + +class SessionService { + constructor(private client: typeof ch) {} + + byId(sessionId: string, projectId: string) { + return clix(this.client) + .select(['*']) + .from(TABLE_NAMES.sessions) + .where('id', '=', sessionId) + .where('project_id', '=', projectId) + .execute() + .then((res) => res[0]); + } +} + +export const sessionService = new SessionService(ch); diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index 7c52fdaf..e2ab9ed2 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -12,8 +12,8 @@ import { formatClickhouseDate, getEventList, getEvents, - getTopPages, overviewService, + sessionService, } from '@openpanel/db'; import { zChartEventFilter, @@ -21,7 +21,6 @@ import { zTimeInterval, } from '@openpanel/validation'; -import { addMinutes, subDays, subMinutes } from 'date-fns'; import { clone } from 'ramda'; import { getProjectAccessCached } from '../access'; import { TRPCAccessError } from '../errors'; @@ -79,6 +78,36 @@ export const eventRouter = createTRPCRouter({ return res; }), + details: protectedProcedure + .input( + z.object({ + id: z.string(), + projectId: z.string(), + createdAt: z.date().optional(), + }), + ) + .query(async ({ input: { id, projectId, createdAt } }) => { + const res = await eventService.getById({ + projectId, + id, + createdAt, + }); + + if (!res) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Event not found', + }); + } + + const session = await sessionService.byId(res?.sessionId, projectId); + + return { + event: res, + session, + }; + }), + events: protectedProcedure .input( z.object({