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,74 @@
'use client';
import { Button } from '@/components/ui/button';
import { Widget } from '@/components/widget';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { ActivityIcon } from 'lucide-react';
import { EventsViewOptions, useEventsViewOptions } from '../events/table';
import { EventItem } from '../events/table/item';
import {
WidgetAbsoluteButtons,
WidgetHead,
WidgetTitle,
} from '../overview/overview-widget';
import { ScrollArea } from '../ui/scroll-area';
type Props = {
profileId: string;
projectId: string;
organizationId: string;
};
export const LatestEvents = ({
profileId,
projectId,
organizationId,
}: Props) => {
const [viewOptions] = useEventsViewOptions();
const router = useRouter();
const trpc = useTRPC();
const query = useQuery(
trpc.event.events.queryOptions({
projectId,
profileId,
}),
);
const handleShowMore = () => {
router.navigate({
to: '/$organizationId/$projectId/profiles/$profileId/events',
params: {
organizationId,
projectId,
profileId,
},
});
};
return (
<Widget className="w-full overflow-hidden">
<WidgetHead>
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
<WidgetAbsoluteButtons>
<Button variant="outline" size="sm" onClick={handleShowMore}>
All
</Button>
<EventsViewOptions />
</WidgetAbsoluteButtons>
</WidgetHead>
<ScrollArea className="h-72">
{query.data?.data?.map((event) => (
<EventItem
className="border-0 rounded-none border-b last:border-b-0 [&_[data-slot='inner']]:px-4"
key={event.id}
event={event}
viewOptions={viewOptions}
/>
))}
</ScrollArea>
</Widget>
);
};

View File

@@ -0,0 +1,33 @@
import { Widget } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = {
data: { count: number; name: string }[];
};
export const MostEvents = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Popular events</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.name} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.name}</div>
<div>{item.count}</div>
</div>
</div>
))}
</div>
</Widget>
);
};

View File

@@ -0,0 +1,33 @@
import { Widget } from '@/components/widget';
import { WidgetHead, WidgetTitle } from '../overview/overview-widget';
type Props = {
data: { count: number; path: string }[];
};
export const PopularRoutes = ({ data }: Props) => {
const max = Math.max(...data.map((item) => item.count));
return (
<Widget className="w-full">
<WidgetHead>
<WidgetTitle>Most visted pages</WidgetTitle>
</WidgetHead>
<div className="flex flex-col gap-1 p-1">
{data.slice(0, 5).map((item) => (
<div key={item.path} className="relative px-3 py-2">
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(item.count / max) * 100}%`,
}}
/>
<div className="relative flex justify-between ">
<div>{item.path}</div>
<div>{item.count}</div>
</div>
</div>
))}
</div>
</Widget>
);
};

View File

@@ -0,0 +1,92 @@
import { Widget, WidgetBody } from '@/components/widget';
import { cn } from '@/utils/cn';
import {
addMonths,
eachDayOfInterval,
endOfMonth,
format,
formatISO,
isSameMonth,
startOfMonth,
subMonths,
} from 'date-fns';
import { ActivityIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { useState } from 'react';
import {
WidgetAbsoluteButtons,
WidgetHead,
WidgetTitle,
} from '../overview/overview-widget';
import { Button } from '../ui/button';
type Props = {
data: { count: number; date: string }[];
};
const MonthCalendar = ({
month,
data,
}: { month: Date; data: Props['data'] }) => (
<div>
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
{eachDayOfInterval({
start: startOfMonth(month),
end: endOfMonth(month),
}).map((date) => {
const hit = data.find((item) =>
item.date.includes(formatISO(date, { representation: 'date' })),
);
return (
<div
key={date.toISOString()}
className={cn(
'aspect-square w-full rounded',
hit ? 'bg-highlight' : 'bg-def-200',
)}
/>
);
})}
</div>
</div>
);
export const ProfileActivity = ({ data }: Props) => {
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
return (
<Widget className="w-full">
<WidgetHead className="row justify-between relative">
<WidgetTitle icon={ActivityIcon}>Activity</WidgetTitle>
<WidgetAbsoluteButtons>
<Button
variant="outline"
size="icon"
onClick={() => setStartDate(subMonths(startDate, 1))}
>
<ChevronLeftIcon size={14} />
</Button>
<Button
variant="outline"
size="icon"
disabled={isSameMonth(startDate, new Date())}
onClick={() => setStartDate(addMonths(startDate, 1))}
>
<ChevronRightIcon size={14} />
</Button>
</WidgetAbsoluteButtons>
</WidgetHead>
<WidgetBody>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{[3, 2, 1, 0].map((offset) => (
<MonthCalendar
key={offset}
month={subMonths(startDate, offset)}
data={data}
/>
))}
</div>
</WidgetBody>
</Widget>
);
};

View File

@@ -0,0 +1,59 @@
import { cn } from '@/utils/cn';
import { AvatarImage } from '@radix-ui/react-avatar';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { type GetProfileNameProps, getProfileName } from '@/utils/getters';
import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
extends VariantProps<typeof variants>,
GetProfileNameProps {
className?: string;
avatar?: string;
}
const variants = cva('shrink-0', {
variants: {
size: {
lg: 'h-14 w-14 rounded [&>span]:rounded',
default: 'h-8 w-8 rounded [&>span]:rounded',
sm: 'h-6 w-6 rounded [&>span]:rounded',
xs: 'h-4 w-4 rounded [&>span]:rounded',
},
},
defaultVariants: {
size: 'default',
},
});
export function ProfileAvatar({
avatar,
className,
size,
...profile
}: ProfileAvatarProps) {
const name = getProfileName(profile);
const isValidAvatar = avatar?.startsWith('http');
return (
<Avatar className={cn(variants({ className, size }), className)}>
{isValidAvatar && <AvatarImage src={avatar} className="rounded-full" />}
<AvatarFallback
className={cn(
'rounded-full',
size === 'lg'
? 'text-lg'
: size === 'sm'
? 'text-sm'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
'bg-def-200 text-muted-foreground',
)}
>
{name?.at(0)?.toUpperCase() ?? '🧔‍♂️'}
</AvatarFallback>
</Avatar>
);
}

View File

@@ -0,0 +1,105 @@
import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody } from '@/components/widget';
import { memo } from 'react';
import type { IChartProps } from '@openpanel/validation';
import { WidgetHead } from '../overview/overview-widget';
type Props = {
profileId: string;
projectId: string;
};
export const ProfileCharts = memo(
({ profileId, projectId }: Props) => {
const pageViewsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: 'screen_view',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'path',
name: 'path',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
const eventsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
],
breakdowns: [
{
id: 'name',
name: 'name',
},
],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '30d',
previous: false,
metric: 'sum',
};
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Page views</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={pageViewsChart} />
</WidgetBody>
</Widget>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ReportChart report={eventsChart} />
</WidgetBody>
</Widget>
</>
);
},
(a, b) => {
return a.profileId === b.profileId && a.projectId === b.projectId;
},
);

View File

@@ -0,0 +1,110 @@
import { OverviewMetricCard } from '@/components/overview/overview-metric-card';
import type { IProfileMetrics } from '@openpanel/db';
type Props = {
data: IProfileMetrics;
};
const PROFILE_METRICS = [
{
title: 'Total Events',
key: 'totalEvents',
unit: '',
inverted: false,
},
{
title: 'Sessions',
key: 'sessions',
unit: '',
inverted: false,
},
{
title: 'Page Views',
key: 'screenViews',
unit: '',
inverted: false,
},
{
title: 'Avg Events/Session',
key: 'avgEventsPerSession',
unit: '',
inverted: false,
},
{
title: 'Bounce Rate',
key: 'bounceRate',
unit: '%',
inverted: true,
},
{
title: 'Session Duration (Avg)',
key: 'durationAvg',
unit: 'min',
inverted: false,
},
{
title: 'Session Duration (P90)',
key: 'durationP90',
unit: 'min',
inverted: false,
},
{
title: 'First seen',
key: 'firstSeen',
unit: 'timeAgo',
inverted: false,
},
{
title: 'Last seen',
key: 'lastSeen',
unit: 'timeAgo',
inverted: false,
},
{
title: 'Days Active',
key: 'uniqueDaysActive',
unit: '',
inverted: false,
},
{
title: 'Conversion Events',
key: 'conversionEvents',
unit: '',
inverted: false,
},
{
title: 'Avg Time Between Sessions (h)',
key: 'avgTimeBetweenSessions',
unit: 'min',
inverted: false,
},
] as const;
export const ProfileMetrics = ({ data }: Props) => {
return (
<div className="relative col-span-6 -m-4 mb-0 mt-0 md:m-0">
<div className="card grid grid-cols-2 overflow-hidden rounded-md md:grid-cols-4 lg:grid-cols-6">
{PROFILE_METRICS.map((metric) => (
<OverviewMetricCard
key={metric.key}
id={metric.key}
label={metric.title}
metric={{
current:
metric.unit === 'timeAgo' &&
typeof data[metric.key] === 'string'
? new Date(data[metric.key] as string).getTime()
: (data[metric.key] as number) || 0,
previous: null, // Profile metrics don't have previous period comparison
}}
unit={metric.unit}
data={[]}
inverted={metric.inverted}
isLoading={false}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,114 @@
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import { Widget } from '@/components/widget';
import { cn } from '@/utils/cn';
import { formatDateTime } from '@/utils/date';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import type { IServiceEvent, IServiceProfile } from '@openpanel/db';
import { WidgetButtons, WidgetHead } from '../overview/overview-widget';
type Props = {
profile: IServiceProfile;
};
export const ProfileProperties = ({ profile }: Props) => {
const [tab, setTab] = useQueryState(
'tab',
parseAsStringEnum(['profile', 'properties']).withDefault('profile'),
);
return (
<Widget className="w-full">
<WidgetHead>
<div className="title">Profile Information</div>
<WidgetButtons>
{[
{
key: 'profile',
btn: 'Profile',
},
{
key: 'properties',
btn: 'Properties',
},
].map((w) => (
<button
type="button"
key={w.key}
onClick={() => setTab(w.key as 'profile' | 'properties')}
className={cn(w.key === tab && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
{tab === 'profile' && (
<KeyValueGrid
copyable
className="border-0"
columns={3}
data={[
{ name: 'id', value: profile.id },
{ name: 'firstName', value: profile.firstName },
{ name: 'lastName', value: profile.lastName },
{ name: 'email', value: profile.email },
{ name: 'isExternal', value: profile.isExternal ? 'Yes' : 'No' },
{
name: 'createdAt',
value: formatDateTime(new Date(profile.createdAt)),
},
...(profile.properties.country
? [{ name: 'country', value: profile.properties.country }]
: []),
...(profile.properties.city
? [{ name: 'city', value: profile.properties.city }]
: []),
...(profile.properties.os
? [{ name: 'os', value: profile.properties.os }]
: []),
...(profile.properties.browser
? [{ name: 'browser', value: profile.properties.browser }]
: []),
...(profile.properties.device
? [{ name: 'device', value: profile.properties.device }]
: []),
...(profile.properties.referrer_name
? [
{
name: 'referrerName',
value: profile.properties.referrer_name,
},
]
: []),
].map((item) => ({
...item,
event: {
...profile,
...profile.properties,
} as unknown as IServiceEvent,
}))}
/>
)}
{tab === 'properties' && (
<KeyValueGrid
copyable
className="border-0"
columns={3}
data={Object.entries(profile.properties)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => ({
name: key,
value: value,
event: {
...profile,
...profile.properties,
} as unknown as IServiceEvent,
}))}
/>
)}
</Widget>
);
};

View File

@@ -0,0 +1,131 @@
import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Tooltiper } from '@/components/ui/tooltip';
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 { IServiceProfile } from '@openpanel/db';
import { ProfileAvatar } from '../profile-avatar';
export function useColumns(type: 'profiles' | 'power-users') {
const columns: ColumnDef<IServiceProfile>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => {
const profile = row.original;
return (
<ProjectLink
href={`/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium"
title={getProfileName(profile, false)}
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</ProjectLink>
);
},
},
{
accessorKey: 'referrer',
header: 'Referrer',
cell({ row }) {
const { referrer, referrer_name } = row.original.properties;
const ref = referrer_name || referrer;
return (
<div className="flex min-w-0 items-center gap-2">
<SerieIcon name={ref} />
<span className="truncate">{ref}</span>
</div>
);
},
},
{
accessorKey: 'country',
header: 'Country',
cell({ row }) {
const { country, city } = row.original.properties;
return (
<div className="flex min-w-0 items-center gap-2">
<SerieIcon name={country} />
<span className="truncate">{city}</span>
</div>
);
},
},
{
accessorKey: 'os',
header: 'OS',
cell({ row }) {
const { os } = row.original.properties;
return (
<div className="flex min-w-0 items-center gap-2">
<SerieIcon name={os} />
<span className="truncate">{os}</span>
</div>
);
},
},
{
accessorKey: 'browser',
header: 'Browser',
cell({ row }) {
const { browser } = row.original.properties;
return (
<div className="flex min-w-0 items-center gap-2">
<SerieIcon name={browser} />
<span className="truncate">{browser}</span>
</div>
);
},
},
{
accessorKey: 'model',
header: 'Model',
cell({ row }) {
const { model, brand } = row.original.properties;
return (
<div className="flex min-w-0 items-center gap-2">
<SerieIcon name={brand} />
<span className="truncate">
{brand} / {model}
</span>
</div>
);
},
},
{
accessorKey: 'createdAt',
header: 'Last seen',
cell: ({ row }) => {
const profile = row.original;
return (
<Tooltiper asChild content={formatDateTime(profile.createdAt)}>
<div className="text-muted-foreground">
{isToday(profile.createdAt)
? formatTime(profile.createdAt)
: formatDateTime(profile.createdAt)}
</div>
</Tooltiper>
);
},
},
];
if (type === 'power-users') {
columns.unshift({
accessorKey: 'count',
header: 'Events',
cell: ({ row }) => {
const profile = row.original;
// @ts-expect-error
return <div>{profile.count}</div>;
},
});
}
return columns;
}

View File

@@ -0,0 +1,94 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import type { RouterOutputs } from '@/trpc/client';
import { useColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { arePropsEqual } from '@/utils/are-props-equal';
import type { IServiceProfile } from '@openpanel/db';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo } from 'react';
type Props = {
query: UseQueryResult<RouterOutputs['profile']['list'], unknown>;
type: 'profiles' | 'power-users';
};
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceProfile[];
export const ProfilesTable = memo(
({ type, query }: Props) => {
const { data, isLoading } = query;
const columns = useColumns(type);
const { setPage, state: pagination } = useDataTablePagination();
const { columnVisibility, setColumnVisibility } =
useDataTableColumnVisibility(columns);
const table = useReactTable({
data: isLoading ? LOADING_DATA : (data?.data ?? []),
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
columns,
rowCount: data?.meta.count,
pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1),
),
filterFns: {
isWithinRange: () => true,
},
state: {
pagination,
columnVisibility,
},
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'
? updaterOrValue(pagination)
: updaterOrValue;
setPage(nextPagination.pageIndex + 1);
},
});
return (
<>
<ProfileTableToolbar table={table} />
<DataTable
table={table}
loading={isLoading}
empty={{
title: 'No profiles',
description: "Looks like you haven't identified any profiles yet.",
}}
/>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'type']),
);
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
const { search, setSearch } = useSearchQueryState();
return (
<DataTableToolbarContainer>
<AnimatedSearchInput
placeholder="Search profiles"
value={search}
onChange={setSearch}
/>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);
}