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
271
apps/start/src/components/sessions/table/columns.tsx
Normal file
271
apps/start/src/components/sessions/table/columns.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { getProfileName } from '@/utils/getters';
|
||||
import { round } from '@openpanel/common';
|
||||
import type { IServiceSession } from '@openpanel/db';
|
||||
|
||||
function formatDuration(milliseconds: number): string {
|
||||
const seconds = milliseconds / 1000;
|
||||
if (seconds < 60) {
|
||||
return `${round(seconds, 1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (minutes < 60) {
|
||||
return remainingSeconds > 0
|
||||
? `${minutes}m ${round(remainingSeconds, 0)}s`
|
||||
: `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${hours}h ${round(remainingMinutes, 0)}m`;
|
||||
}
|
||||
|
||||
export function useColumns() {
|
||||
const columns: ColumnDef<IServiceSession>[] = [
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Started',
|
||||
size: 140,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
|
||||
{formatDateTime(session.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'Session ID',
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/sessions/${session.id}`}
|
||||
className="font-medium"
|
||||
title={session.id}
|
||||
>
|
||||
{session.id.slice(0, 8)}...
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'profileId',
|
||||
header: 'Profile',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
if (session.profile) {
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${session.profile.id}`}
|
||||
className="font-medium"
|
||||
>
|
||||
{getProfileName(session.profile)}
|
||||
</ProjectLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ProjectLink
|
||||
href={`/profiles/${session.profileId}`}
|
||||
className="font-mono font-medium"
|
||||
>
|
||||
{session.profileId}
|
||||
</ProjectLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'entryPath',
|
||||
header: 'Entry Page',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono">{session.entryPath || '/'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'exitPath',
|
||||
header: 'Exit Page',
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<span className="font-mono">
|
||||
{session.exitPath || session.entryPath || '/'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'duration',
|
||||
header: 'Duration',
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="font-medium">{formatDuration(session.duration)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'isBounce',
|
||||
header: 'Bounce',
|
||||
size: 80,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="text-center">
|
||||
{session.isBounce ? (
|
||||
<span className="text-orange-600">Yes</span>
|
||||
) : (
|
||||
<span className="text-green-600">No</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'referrerName',
|
||||
header: 'Referrer',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
const ref = session.referrerName || session.referrer || 'Direct';
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={ref} />
|
||||
<span className="truncate">{ref}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'country',
|
||||
header: 'Location',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={session.country} />
|
||||
<span className="truncate">{session.city || session.country}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'os',
|
||||
header: 'OS',
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={session.os} />
|
||||
<span className="truncate">{session.os}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'browser',
|
||||
header: 'Browser',
|
||||
size: 120,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={session.browser} />
|
||||
<span className="truncate">{session.browser}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'device',
|
||||
header: 'Device',
|
||||
size: 150,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
let deviceInfo =
|
||||
session.brand || session.model
|
||||
? [session.brand, session.model].filter(Boolean).join(' / ')
|
||||
: session.device;
|
||||
if (deviceInfo === 'K') {
|
||||
deviceInfo = session.device;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<SerieIcon name={session.device} />
|
||||
<span className="truncate">{deviceInfo}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'screenViewCount',
|
||||
header: 'Page views',
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="text-center font-medium">
|
||||
{session.screenViewCount}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'eventCount',
|
||||
header: 'Events',
|
||||
size: 90,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="text-center font-medium">{session.eventCount}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'revenue',
|
||||
header: 'Revenue',
|
||||
size: 100,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return session.revenue > 0 ? (
|
||||
<div className="font-medium text-green-600">
|
||||
${session.revenue.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">-</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'deviceId',
|
||||
header: 'Device ID',
|
||||
size: 120,
|
||||
},
|
||||
];
|
||||
|
||||
return columns;
|
||||
}
|
||||
304
apps/start/src/components/sessions/table/index.tsx
Normal file
304
apps/start/src/components/sessions/table/index.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
||||
|
||||
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||
import { useColumns } from './columns';
|
||||
|
||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||
import { Skeleton } from '@/components/skeleton';
|
||||
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 { cn } from '@/utils/cn';
|
||||
import type { IServiceSession } from '@openpanel/db';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { memo, useEffect, useMemo, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
type Props = {
|
||||
query: UseInfiniteQueryResult<
|
||||
TRPCInfiniteData<
|
||||
RouterInputs['session']['list'],
|
||||
RouterOutputs['session']['list']
|
||||
>,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
|
||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceSession[];
|
||||
const ROW_HEIGHT = 40;
|
||||
|
||||
interface VirtualizedSessionsTableProps {
|
||||
table: Table<IServiceSession>;
|
||||
data: IServiceSession[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface VirtualRowProps {
|
||||
row: any;
|
||||
virtualRow: any;
|
||||
headerColumns: any[];
|
||||
scrollMargin: number;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(
|
||||
function VirtualRow({
|
||||
row,
|
||||
virtualRow,
|
||||
headerColumns,
|
||||
scrollMargin,
|
||||
isLoading,
|
||||
}: VirtualRowProps) {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualRow.measureElement}
|
||||
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
minHeight: ROW_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const width = `${cell.column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
) : cell.column.columnDef.cell ? (
|
||||
typeof cell.column.columnDef.cell === 'function' ? (
|
||||
cell.column.columnDef.cell(cell.getContext())
|
||||
) : (
|
||||
cell.column.columnDef.cell
|
||||
)
|
||||
) : (
|
||||
(cell.getValue() as React.ReactNode)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.row.id === nextProps.row.id &&
|
||||
prevProps.virtualRow.index === nextProps.virtualRow.index &&
|
||||
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||
prevProps.isLoading === nextProps.isLoading
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const VirtualizedSessionsTable = memo(
|
||||
function VirtualizedSessionsTable({
|
||||
table,
|
||||
data,
|
||||
isLoading,
|
||||
}: VirtualizedSessionsTableProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const headerColumns = useMemo(
|
||||
() =>
|
||||
table.getAllLeafColumns().filter((col) => {
|
||||
return table.getState().columnVisibility[col.id] !== false;
|
||||
}),
|
||||
[table],
|
||||
);
|
||||
|
||||
const rowVirtualizer = useWindowVirtualizer({
|
||||
count: data.length,
|
||||
estimateSize: () => ROW_HEIGHT, // Estimated row height
|
||||
overscan: 10,
|
||||
scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{headerColumns.map((column) => {
|
||||
const header = column.columnDef.header;
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{typeof header === 'function' ? header({} as any) : header}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No sessions found"
|
||||
description="Looks like you haven't inserted any events yet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Body */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minHeight: 'fit-content',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={virtualRow.key}
|
||||
row={row}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['data', 'isLoading']),
|
||||
);
|
||||
|
||||
export const SessionsTable = ({ query }: Props) => {
|
||||
const { isLoading } = query;
|
||||
const columns = useColumns();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (isLoading) {
|
||||
return LOADING_DATA;
|
||||
}
|
||||
|
||||
return query.data?.pages?.flatMap((p) => p.data) ?? [];
|
||||
}, [query.data]);
|
||||
|
||||
// const { setPage, state: pagination } = useDataTablePagination();
|
||||
const { columnVisibility, setColumnVisibility } =
|
||||
useDataTableColumnVisibility(columns);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
manualSorting: true,
|
||||
columns,
|
||||
rowCount: 50,
|
||||
pageCount: 1,
|
||||
filterFns: {
|
||||
isWithinRange: () => true,
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
});
|
||||
|
||||
const inViewportRef = useRef<HTMLDivElement>(null);
|
||||
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
|
||||
disconnectOnLeave: true,
|
||||
});
|
||||
|
||||
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
data.length > 0 &&
|
||||
inViewport &&
|
||||
enterCount > 0 &&
|
||||
query.isFetchingNextPage === false
|
||||
) {
|
||||
console.log('fetching next page');
|
||||
query.fetchNextPage();
|
||||
}
|
||||
}, [inViewport, enterCount, hasNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionTableToolbar table={table} />
|
||||
<VirtualizedSessionsTable
|
||||
table={table}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
|
||||
const { search, setSearch } = useSearchQueryState();
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search sessions by path, referrer..."
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user