feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,241 @@
import { cn } from '@/utils/cn';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { LucideIcon } from 'lucide-react';
import * as Icons from 'lucide-react';
import type { EventMeta } from '@openpanel/db';
const variants = cva('flex shrink-0 items-center justify-center rounded-full', {
variants: {
size: {
xs: 'h-5 w-5',
sm: 'h-6 w-6',
default: 'h-10 w-10',
},
},
defaultVariants: {
size: 'default',
},
});
type EventIconProps = VariantProps<typeof variants> & {
name: string;
meta?: EventMeta;
className?: string;
};
export const EventIconRecords: Record<
string,
{
icon: string;
color: string;
}
> = {
default: {
icon: 'BotIcon',
color: 'slate',
},
screen_view: {
icon: 'MonitorPlayIcon',
color: 'blue',
},
session_start: {
icon: 'ActivityIcon',
color: 'teal',
},
link_out: {
icon: 'ExternalLinkIcon',
color: 'indigo',
},
};
export const EventIconMapper: Record<string, LucideIcon> = {
DownloadIcon: Icons.DownloadIcon,
BotIcon: Icons.BotIcon,
BoxIcon: Icons.BoxIcon,
AccessibilityIcon: Icons.AccessibilityIcon,
ActivityIcon: Icons.ActivityIcon,
AirplayIcon: Icons.AirplayIcon,
AlarmCheckIcon: Icons.AlarmCheckIcon,
AlertTriangleIcon: Icons.AlertTriangleIcon,
BellIcon: Icons.BellIcon,
BoltIcon: Icons.BoltIcon,
CandyIcon: Icons.CandyIcon,
ConeIcon: Icons.ConeIcon,
MonitorPlayIcon: Icons.MonitorPlayIcon,
PizzaIcon: Icons.PizzaIcon,
SearchIcon: Icons.SearchIcon,
HomeIcon: Icons.HomeIcon,
MailIcon: Icons.MailIcon,
AngryIcon: Icons.AngryIcon,
AnnoyedIcon: Icons.AnnoyedIcon,
ArchiveIcon: Icons.ArchiveIcon,
AwardIcon: Icons.AwardIcon,
BadgeCheckIcon: Icons.BadgeCheckIcon,
BeerIcon: Icons.BeerIcon,
BluetoothIcon: Icons.BluetoothIcon,
BookIcon: Icons.BookIcon,
BookmarkIcon: Icons.BookmarkIcon,
BookCheckIcon: Icons.BookCheckIcon,
BookMinusIcon: Icons.BookMinusIcon,
BookPlusIcon: Icons.BookPlusIcon,
CalendarIcon: Icons.CalendarIcon,
ClockIcon: Icons.ClockIcon,
CogIcon: Icons.CogIcon,
LoaderIcon: Icons.LoaderIcon,
CrownIcon: Icons.CrownIcon,
FileIcon: Icons.FileIcon,
KeyRoundIcon: Icons.KeyRoundIcon,
GemIcon: Icons.GemIcon,
GlobeIcon: Icons.GlobeIcon,
LightbulbIcon: Icons.LightbulbIcon,
LightbulbOffIcon: Icons.LightbulbOffIcon,
LockIcon: Icons.LockIcon,
MessageCircleIcon: Icons.MessageCircleIcon,
RadioIcon: Icons.RadioIcon,
RepeatIcon: Icons.RepeatIcon,
ShareIcon: Icons.ShareIcon,
ExternalLinkIcon: Icons.ExternalLinkIcon,
UserIcon: Icons.UserIcon,
UsersIcon: Icons.UsersIcon,
UserPlusIcon: Icons.UserPlusIcon,
UserMinusIcon: Icons.UserMinusIcon,
UserCheckIcon: Icons.UserCheckIcon,
UserXIcon: Icons.UserXIcon,
PlayIcon: Icons.PlayIcon,
PauseIcon: Icons.PauseIcon,
SkipForwardIcon: Icons.SkipForwardIcon,
SkipBackIcon: Icons.SkipBackIcon,
VolumeIcon: Icons.VolumeIcon,
VolumeOffIcon: Icons.VolumeOffIcon,
ImageIcon: Icons.ImageIcon,
VideoIcon: Icons.VideoIcon,
MusicIcon: Icons.MusicIcon,
CameraIcon: Icons.CameraIcon,
ClickIcon: Icons.MousePointerClickIcon,
ChevronDownIcon: Icons.ChevronDownIcon,
ChevronUpIcon: Icons.ChevronUpIcon,
ChevronLeftIcon: Icons.ChevronLeftIcon,
ChevronRightIcon: Icons.ChevronRightIcon,
ArrowUpIcon: Icons.ArrowUpIcon,
ArrowDownIcon: Icons.ArrowDownIcon,
ArrowLeftIcon: Icons.ArrowLeftIcon,
ArrowRightIcon: Icons.ArrowRightIcon,
PhoneIcon: Icons.PhoneIcon,
MessageSquareIcon: Icons.MessageSquareIcon,
SendIcon: Icons.SendIcon,
ShoppingCartIcon: Icons.ShoppingCartIcon,
ShoppingBagIcon: Icons.ShoppingBagIcon,
CreditCardIcon: Icons.CreditCardIcon,
DollarSignIcon: Icons.DollarSignIcon,
EuroIcon: Icons.EuroIcon,
HeartIcon: Icons.HeartIcon,
StarIcon: Icons.StarIcon,
ThumbsUpIcon: Icons.ThumbsUpIcon,
ThumbsDownIcon: Icons.ThumbsDownIcon,
SmileIcon: Icons.SmileIcon,
FrownIcon: Icons.FrownIcon,
BarChartIcon: Icons.BarChartIcon,
LineChartIcon: Icons.LineChartIcon,
PieChartIcon: Icons.PieChartIcon,
TrendingUpIcon: Icons.TrendingUpIcon,
TrendingDownIcon: Icons.TrendingDownIcon,
TargetIcon: Icons.TargetIcon,
ShieldIcon: Icons.ShieldIcon,
EyeIcon: Icons.EyeIcon,
EyeOffIcon: Icons.EyeOffIcon,
KeyIcon: Icons.KeyIcon,
UnlockIcon: Icons.UnlockIcon,
SettingsIcon: Icons.SettingsIcon,
RefreshCwIcon: Icons.RefreshCwIcon,
TrashIcon: Icons.TrashIcon,
EditIcon: Icons.EditIcon,
PlusIcon: Icons.PlusIcon,
MinusIcon: Icons.MinusIcon,
XIcon: Icons.XIcon,
CheckIcon: Icons.CheckIcon,
SaveIcon: Icons.SaveIcon,
UploadIcon: Icons.UploadIcon,
SmartphoneIcon: Icons.SmartphoneIcon,
TabletIcon: Icons.TabletIcon,
LaptopIcon: Icons.LaptopIcon,
MonitorIcon: Icons.MonitorIcon,
WifiIcon: Icons.WifiIcon,
MapPinIcon: Icons.MapPinIcon,
NavigationIcon: Icons.NavigationIcon,
CompassIcon: Icons.CompassIcon,
FolderIcon: Icons.FolderIcon,
FileTextIcon: Icons.FileTextIcon,
FilePlusIcon: Icons.FilePlusIcon,
FileMinusIcon: Icons.FileMinusIcon,
DatabaseIcon: Icons.DatabaseIcon,
AlertCircleIcon: Icons.AlertCircleIcon,
InfoIcon: Icons.InfoIcon,
HelpCircleIcon: Icons.HelpCircleIcon,
CheckCircleIcon: Icons.CheckCircleIcon,
XCircleIcon: Icons.XCircleIcon,
CalendarDaysIcon: Icons.CalendarDaysIcon,
CalendarPlusIcon: Icons.CalendarPlusIcon,
TimerIcon: Icons.TimerIcon,
FilterIcon: Icons.FilterIcon,
SortAscIcon: Icons.ArrowUpAZIcon,
SortDescIcon: Icons.ArrowDownZAIcon,
CopyIcon: Icons.CopyIcon,
LinkIcon: Icons.LinkIcon,
QrCodeIcon: Icons.QrCodeIcon,
ScanIcon: Icons.ScanIcon,
ZapIcon: Icons.ZapIcon,
FlameIcon: Icons.FlameIcon,
RocketIcon: Icons.RocketIcon,
TrophyIcon: Icons.TrophyIcon,
};
export const EventIconColors = [
'rose',
'pink',
'fuchsia',
'purple',
'violet',
'indigo',
'blue',
'sky',
'cyan',
'teal',
'emerald',
'green',
'lime',
'yellow',
'amber',
'orange',
'red',
'stone',
'neutral',
'zinc',
'grey',
'slate',
];
export function EventIcon({ className, name, size, meta }: EventIconProps) {
const Icon =
EventIconMapper[
meta?.icon ??
EventIconRecords[name]?.icon ??
EventIconRecords.default?.icon ??
''
]!;
const color =
meta?.color ??
EventIconRecords[name]?.color ??
EventIconRecords.default?.color ??
'';
return (
<div className={cn(`bg-${color}-200`, variants({ size }), className)}>
<Icon
size={size === 'xs' ? 12 : size === 'sm' ? 14 : 20}
className={`text-${color}-700`}
/>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Tooltiper } from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { Link } from '@tanstack/react-router';
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { EventIcon } from './event-icon';
type EventListItemProps = IServiceEventMinimal | IServiceEvent;
export function EventListItem(props: EventListItemProps) {
const { organizationId, projectId } = useAppParams();
const { createdAt, name, path, duration, meta } = props;
const profile = 'profile' in props ? props.profile : null;
const number = useNumber();
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return path;
}
return `Route: ${path}`;
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
const isMinimal = 'minimal' in props;
return (
<>
<button
type="button"
onClick={() => {
if (!isMinimal) {
pushModal('EventDetails', {
id: props.id,
projectId,
createdAt,
});
}
}}
className={cn(
'card hover:bg-light-background flex w-full items-center justify-between rounded-lg p-4 transition-colors',
meta?.conversion &&
`bg-${meta.color}-50 dark:bg-${meta.color}-900 hover:bg-${meta.color}-100 dark:hover:bg-${meta.color}-700`,
)}
>
<div>
<div className="flex items-center gap-4 text-left ">
<EventIcon size="sm" name={name} meta={meta} />
<span>
<span className="font-medium">{renderName()}</span>
{' '}
{renderDuration()}
</span>
</div>
<div className="pl-10">
<div className="flex origin-left scale-75 gap-1">
<SerieIcon name={props.country} />
<SerieIcon name={props.os} />
<SerieIcon name={props.browser} />
</div>
</div>
</div>
<div className="flex gap-4">
{profile && (
<Tooltiper asChild content={getProfileName(profile)}>
<Link
onClick={(e) => {
e.stopPropagation();
}}
to={'/$organizationId/$projectId/profiles/$profileId'}
params={{
organizationId,
projectId,
profileId: profile.id,
}}
className="max-w-[80px] overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground hover:underline"
>
{getProfileName(profile)}
</Link>
</Tooltiper>
)}
<Tooltiper asChild content={createdAt.toLocaleString()}>
<div className=" text-muted-foreground">
{createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
</div>
</button>
</>
);
}

View File

@@ -0,0 +1,74 @@
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceState } from '@/hooks/use-debounce-state';
import useWS from '@/hooks/use-ws';
import { cn } from '@/utils/cn';
import type { IServiceEventMinimal } from '@openpanel/db';
import { AnimatedNumber } from '../animated-number';
export default function EventListener({
onRefresh,
}: {
onRefresh: () => void;
}) {
const { projectId } = useAppParams();
const counter = useDebounceState(0, 1000);
useWS<IServiceEventMinimal>(
`/live/events/${projectId}`,
(event) => {
if (event?.name) {
counter.set((prev) => prev + 1);
}
},
{
debounce: {
delay: 1000,
maxWait: 5000,
},
},
);
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
counter.set(0);
onRefresh();
}}
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
>
<div className="relative">
<div
className={cn(
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
)}
/>
<div
className={cn(
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
)}
/>
</div>
{counter.debounced === 0 ? (
'Listening'
) : (
<AnimatedNumber value={counter.debounced} suffix=" new events" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter.debounced === 0
? 'Listening to new events'
: 'Click to refresh'}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,49 @@
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
interface Props {
country?: string;
city?: string;
os?: string;
os_version?: string;
browser?: string;
browser_version?: string;
referrer_name?: string;
referrer_type?: string;
}
export function ListPropertiesIcon({
country,
city,
os,
os_version,
browser,
browser_version,
referrer_name,
referrer_type,
}: Props) {
return (
<div className="flex gap-1.5">
{country && (
<Tooltiper content={[country, city].filter(Boolean).join(', ')}>
<SerieIcon name={country} />
</Tooltiper>
)}
{os && (
<Tooltiper content={`${os} (${os_version})`}>
<SerieIcon name={os} />
</Tooltiper>
)}
{browser && (
<Tooltiper content={`${browser} (${browser_version})`}>
<SerieIcon name={browser} />
</Tooltiper>
)}
{referrer_name && (
<Tooltiper content={`${referrer_name} (${referrer_type})`}>
<SerieIcon name={referrer_name} />
</Tooltiper>
)}
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { EventIcon } from '@/components/events/event-icon';
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
size: 300,
accessorKey: 'name',
header: 'Name',
cell({ row }) {
const { name, path, duration } = row.original;
const renderName = () => {
if (name === 'screen_view') {
if (path.includes('/')) {
return <span className="max-w-md truncate">{path}</span>;
}
return (
<>
<span className="text-muted-foreground">Screen: </span>
<span className="max-w-md truncate">{path}</span>
</>
);
}
return name.replace(/_/g, ' ');
};
const renderDuration = () => {
if (name === 'screen_view') {
return (
<span className="text-muted-foreground">
{number.shortWithUnit(duration / 1000, 'min')}
</span>
);
}
return null;
};
return (
<div className="flex items-center gap-2">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={() => {
pushModal('EditEvent', {
id: row.original.id,
});
}}
>
<EventIcon
size="sm"
name={row.original.name}
meta={row.original.meta}
/>
</button>
<span className="flex gap-2">
<button
type="button"
onClick={() => {
pushModal('EventDetails', {
id: row.original.id,
createdAt: row.original.createdAt,
projectId: row.original.projectId,
});
}}
className="font-medium"
>
{renderName()}
</button>
{renderDuration()}
</span>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
size: 170,
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
accessorKey: 'profileId',
header: 'Profile',
cell({ row }) {
const { profile, profileId, deviceId } = row.original;
if (profile) {
return (
<ProjectLink
href={`/profiles/${profile.id}`}
className="whitespace-nowrap font-medium hover:underline"
>
{getProfileName(profile)}
</ProjectLink>
);
}
if (profileId && profileId !== deviceId) {
return (
<ProjectLink
href={`/profiles/${profileId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Unknown
</ProjectLink>
);
}
if (deviceId) {
return (
<ProjectLink
href={`/profiles/${deviceId}`}
className="whitespace-nowrap font-medium hover:underline"
>
Anonymous
</ProjectLink>
);
}
return null;
},
},
{
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
},
{
accessorKey: 'deviceId',
header: 'Device ID',
size: 320,
},
{
accessorKey: 'country',
header: 'Country',
size: 150,
cell({ row }) {
const { country, city } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={country} />
<span className="truncate">{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
size: 130,
cell({ row }) {
const { os } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={os} />
<span className="truncate">{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
size: 110,
cell({ row }) {
const { browser } = row.original;
return (
<div className="row items-center gap-2 min-w-0">
<SerieIcon name={browser} />
<span className="truncate">{browser}</span>
</div>
);
},
},
{
accessorKey: 'properties',
header: 'Properties',
size: 400,
cell({ row }) {
const { properties } = row.original;
const filteredProperties = Object.fromEntries(
Object.entries(properties || {}).filter(
([key]) => !key.startsWith('__'),
),
);
const items = Object.entries(filteredProperties);
return (
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
{items.slice(0, 4).map(([key, value]) => (
<div key={key} className="row items-center gap-1 min-w-0">
<span className="text-muted-foreground">{key}</span>
<span className="truncate font-medium">{String(value)}</span>
</div>
))}
{items.length > 5 && (
<span className="truncate">{items.length - 5} more</span>
)}
</div>
);
},
},
];
return columns;
}

View File

@@ -0,0 +1,298 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { Button } from '@/components/ui/button';
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
import throttle from 'lodash.throttle';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
import { useLocalStorage } from 'usehooks-ts';
import EventListener from '../event-listener';
import { EventItem, EventItemSkeleton } from './item';
export const useEventsViewOptions = () => {
return useLocalStorage<Record<string, boolean | undefined>>(
'@op:events-table-view-options',
{
properties: false,
},
);
};
type Props = {
query: UseInfiniteQueryResult<
TRPCInfiniteData<
RouterInputs['event']['events'],
RouterOutputs['event']['events']
>,
unknown
>;
};
export const EventsTable = memo(
({ query }: Props) => {
const [viewOptions] = useEventsViewOptions();
const { isLoading } = query;
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const inViewportRef = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
disconnectOnLeave: true,
});
const data = query.data?.pages?.flatMap((p) => p.data) ?? [];
const virtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => 55,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for resize events
window.addEventListener('resize', updateScrollMargin);
return () => {
window.removeEventListener('resize', updateScrollMargin);
};
}, []);
useEffect(() => {
virtualizer.measure();
}, [viewOptions, virtualizer]);
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
useEffect(() => {
if (
hasNextPage &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
const visibleItems = virtualizer.getVirtualItems();
return (
<>
<EventsTableToolbar query={query} />
<div ref={parentRef} className="w-full">
{isLoading && (
<div className="w-full gap-2 col">
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
</div>
)}
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
/>
)}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleItems.map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
paddingBottom: '8px', // Gap between items
}}
>
<EventItem
event={data[virtualRow.index]!}
viewOptions={viewOptions}
/>
</div>
))}
</div>
</div>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']),
);
function EventsTableToolbar({
query,
}: {
query: Props['query'];
}) {
const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState(
'startDate',
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return (
<DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2">
<EventListener onRefresh={() => query.refetch()} />
<Button
variant="outline"
size="sm"
icon={CalendarIcon}
onClick={() => {
pushModal('DateRangerPicker', {
onChange: ({ startDate, endDate }) => {
setStartDate(startDate);
setEndDate(endDate);
},
startDate: startDate || undefined,
endDate: endDate || undefined,
});
}}
>
{startDate && endDate
? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`
: 'Date range'}
</Button>
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
enableEventsFilter
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<EventsViewOptions />
</DataTableToolbarContainer>
);
}
export function EventsViewOptions() {
const [viewOptions, setViewOptions] = useEventsViewOptions();
const columns = {
origin: 'Show origin',
queryString: 'Show query string',
referrer: 'Referrer',
country: 'Country',
os: 'OS',
browser: 'Browser',
profileId: 'Profile',
createdAt: 'Created at',
properties: 'Properties',
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2Icon className="size-4 mr-2" />
View
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent align="end" className="w-44 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{Object.entries(columns).map(([column, label]) => (
<CommandItem
key={column}
onSelect={() =>
setViewOptions({
...viewOptions,
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
[column]: viewOptions[column] === false ? true : false,
})
}
>
<span className="truncate">{label}</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
viewOptions[column] !== false
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -0,0 +1,178 @@
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { formatTimeAgoOrDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { IServiceEvent } from '@openpanel/db';
import { memo } from 'react';
import { Skeleton } from '../../skeleton';
import { EventIcon } from '../event-icon';
interface EventItemProps {
event: IServiceEvent | Record<string, never>;
viewOptions: Record<string, boolean | undefined>;
className?: string;
}
export const EventItem = memo<EventItemProps>(
({ event, viewOptions, className }) => {
let url: string | null = '';
if (event.path && event.origin) {
if (viewOptions.origin !== false && event.origin) {
url += event.origin;
}
url += event.path;
const query = Object.entries(event.properties || {})
.filter(([key]) => key.startsWith('__query'))
.map(([key, value]) => [key.replace('__query.', ''), value]);
if (viewOptions.queryString !== false && query.length) {
query.forEach(([key, value], index) => {
url += `${index === 0 ? '?' : '&'}${key}=${value}`;
});
}
}
return (
<div className={cn('group card @container overflow-hidden', className)}>
<div
onClick={() => {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
pushModal('EventDetails', {
id: event.id,
projectId: event.projectId,
createdAt: event.createdAt,
});
}
}}
data-slot="inner"
className={cn(
'col gap-2 flex-1 p-2',
// Desktop
'@lg:row @lg:items-center',
'cursor-pointer',
event.meta?.color
? `hover:bg-${event.meta.color}-50 dark:hover:bg-${event.meta.color}-900`
: 'hover:bg-def-200',
)}
>
<div className="min-w-0 flex-1 row items-center gap-4">
<button
type="button"
className="transition-transform hover:scale-105"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
pushModal('EditEvent', {
id: event.id,
});
}}
>
<EventIcon name={event.name} size="sm" meta={event.meta} />
</button>
<span className="min-w-0 whitespace-break-spaces wrap-break-word break-all">
{event.name === 'screen_view' ? (
<>
<span className="text-muted-foreground mr-2">Visit:</span>
<span className="font-medium min-w-0">
{url ? url : event.path}
</span>
</>
) : (
<>
<span className="text-muted-foreground mr-2">Event:</span>
<span className="font-medium">{event.name}</span>
</>
)}
</span>
</div>
<div className="row gap-2 items-center @max-lg:pl-10">
{event.referrerName && viewOptions.referrerName !== false && (
<Pill
icon={<SerieIcon className="mr-2" name={event.referrerName} />}
>
<span>{event.referrerName}</span>
</Pill>
)}
{event.os && viewOptions.os !== false && (
<Pill icon={<SerieIcon name={event.os} />}>{event.os}</Pill>
)}
{event.browser && viewOptions.browser !== false && (
<Pill icon={<SerieIcon name={event.browser} />}>
{event.browser}
</Pill>
)}
{event.country && viewOptions.country !== false && (
<Pill icon={<SerieIcon name={event.country} />}>
{event.country}
</Pill>
)}
{viewOptions.profileId !== false && (
<Pill
className="@max-xl:ml-auto @max-lg:[&>span]:inline mx-4"
icon={<ProfileAvatar size="xs" {...event.profile} />}
>
{getProfileName(event.profile)}
</Pill>
)}
{viewOptions.createdAt !== false && (
<span className="text-sm text-neutral-500">
{formatTimeAgoOrDateTime(event.createdAt)}
</span>
)}
</div>
</div>
{viewOptions.properties !== false && (
<div
data-slot="extra"
className="border-t border-neutral-200 p-4 py-2 bg-def-100"
>
<pre className="text-sm leading-tight">
{JSON.stringify(event.properties, null, 2)}
</pre>
</div>
)}
</div>
);
},
);
export const EventItemSkeleton = () => {
return (
<div className="card h-10 p-2 gap-4 row items-center">
<Skeleton className="size-6 rounded-full" />
<Skeleton className="w-1/2 h-3" />
<div className="row gap-2 ml-auto">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-4 w-14" />
</div>
</div>
);
};
function Pill({
children,
icon,
className,
}: { children: React.ReactNode; icon?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
'shrink-0 whitespace-nowrap inline-flex gap-2 items-center rounded-full @3xl:text-muted-foreground h-6 text-xs font-mono',
className,
)}
>
{icon && <div className="size-4 center-center">{icon}</div>}
<div className="hidden @3xl:inline">{children}</div>
</div>
);
}