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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
241
apps/start/src/components/events/event-icon.tsx
Normal file
241
apps/start/src/components/events/event-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/start/src/components/events/event-list-item.tsx
Normal file
114
apps/start/src/components/events/event-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/events/event-listener.tsx
Normal file
74
apps/start/src/components/events/event-listener.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
apps/start/src/components/events/list-properties-icon.tsx
Normal file
49
apps/start/src/components/events/list-properties-icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
apps/start/src/components/events/table/columns.tsx
Normal file
222
apps/start/src/components/events/table/columns.tsx
Normal 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;
|
||||
}
|
||||
298
apps/start/src/components/events/table/index.tsx
Normal file
298
apps/start/src/components/events/table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/start/src/components/events/table/item.tsx
Normal file
178
apps/start/src/components/events/table/item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user