fix: add filters for sessions table

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-16 13:20:59 +01:00
parent eab33d3127
commit 5557db83a6
8 changed files with 846 additions and 142 deletions

View File

@@ -1,11 +1,15 @@
import type { IServiceProfile } from '@openpanel/db';
import type { UseQueryResult } from '@tanstack/react-query';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import type { RouterOutputs } from '@/trpc/client';
import { useNavigate } from '@tanstack/react-router';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo, useCallback } from 'react';
import { useColumns } from './columns';
import { DataTable } from '@/components/ui/data-table/data-table';
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import {
useDataTableColumnVisibility,
useDataTablePagination,
} from '@/components/ui/data-table/data-table-hooks';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
@@ -13,12 +17,8 @@ import {
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import type { RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import type { IServiceProfile } from '@openpanel/db';
import { useNavigate } from '@tanstack/react-router';
import type { PaginationState, Table, Updater } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { memo, useCallback } from 'react';
const PAGE_SIZE = 50;
@@ -40,7 +40,7 @@ export const ProfilesTable = memo(
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/profiles/$profileId' as any,
to: '/$organizationId/$projectId/profiles/$profileId',
params: {
organizationId,
projectId,
@@ -48,7 +48,7 @@ export const ProfilesTable = memo(
},
});
},
[navigate, organizationId, projectId],
[navigate, organizationId, projectId]
);
const { setPage, state: pagination } = useDataTablePagination(pageSize);
@@ -68,7 +68,7 @@ export const ProfilesTable = memo(
columns,
rowCount: data?.meta.count,
pageCount: Math.ceil(
(data?.meta.count || 0) / (pagination.pageSize || 1),
(data?.meta.count || 0) / (pagination.pageSize || 1)
),
filterFns: {
isWithinRange: () => true,
@@ -94,18 +94,18 @@ export const ProfilesTable = memo(
<>
<ProfileTableToolbar table={table} />
<DataTable
table={table}
loading={isLoading}
onRowClick={handleRowClick}
empty={{
title: 'No profiles',
description: "Looks like you haven't identified any profiles yet.",
}}
loading={isLoading}
onRowClick={handleRowClick}
table={table}
/>
</>
);
},
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize'])
);
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
@@ -113,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
return (
<DataTableToolbarContainer>
<AnimatedSearchInput
onChange={setSearch}
placeholder="Search profiles"
value={search}
onChange={setSearch}
/>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>

View File

@@ -1,9 +1,7 @@
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 { useLocalStorage } from 'usehooks-ts';
import { useColumns } from './columns';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
// Custom hook for persistent column visibility
const usePersistentColumnVisibility = (columns: any[]) => {
@@ -21,7 +19,7 @@ const usePersistentColumnVisibility = (columns: any[]) => {
}
return acc;
},
{} as Record<string, boolean>,
{} as Record<string, boolean>
);
}, [columns, savedVisibility]);
@@ -37,27 +35,33 @@ const usePersistentColumnVisibility = (columns: any[]) => {
};
};
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 { useAppParams } from '@/hooks/use-app-params';
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 { useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
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 { Loader2Icon, SlidersHorizontalIcon } from 'lucide-react';
import { last } from 'ramda';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Skeleton } from '@/components/skeleton';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AnimatedSearchInput,
DataTableToolbarContainer,
} from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import type { FilterDefinition } from '@/components/ui/filter-dropdown';
import { FilterDropdown } from '@/components/ui/filter-dropdown';
import { useAppParams } from '@/hooks/use-app-params';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useSessionFilters } from '@/hooks/use-session-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
type Props = {
query: UseInfiniteQueryResult<
@@ -99,20 +103,22 @@ const VirtualRow = memo(
}: VirtualRowProps) {
return (
<div
data-index={virtualRow.index}
ref={virtualRow.measureElement}
className={cn(
'absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row',
onRowClick && 'cursor-pointer',
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
onRowClick && 'cursor-pointer'
)}
data-index={virtualRow.index}
onClick={
onRowClick
? (e) => {
if ((e.target as HTMLElement).closest('a, button')) return;
if ((e.target as HTMLElement).closest('a, button')) {
return;
}
onRowClick(row);
}
: undefined
}
ref={virtualRow.measureElement}
style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid',
@@ -127,8 +133,8 @@ const VirtualRow = memo(
const width = `${cell.column.getSize()}px`;
return (
<div
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
key={cell.id}
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
style={{
width,
overflow: 'hidden',
@@ -161,7 +167,7 @@ const VirtualRow = memo(
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
prevProps.onRowClick === nextProps.onRowClick
);
},
}
);
const VirtualizedSessionsTable = ({
@@ -188,12 +194,12 @@ const VirtualizedSessionsTable = ({
return (
<div
className="w-full overflow-x-auto rounded-md border bg-card"
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"
className="sticky top-0 z-10 border-b bg-card"
style={{
display: 'grid',
gridTemplateColumns: headerColumns
@@ -207,8 +213,8 @@ const VirtualizedSessionsTable = ({
const width = `${column.getSize()}px`;
return (
<div
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{
width,
}}
@@ -221,8 +227,8 @@ const VirtualizedSessionsTable = ({
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No sessions found"
description="Looks like you haven't inserted any events yet."
title="No sessions found"
/>
)}
@@ -237,21 +243,23 @@ const VirtualizedSessionsTable = ({
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null;
if (!row) {
return null;
}
return (
<VirtualRow
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
isLoading={isLoading}
key={row.id}
onRowClick={onRowClick}
row={row}
scrollMargin={rowVirtualizer.options.scrollMargin}
virtualRow={{
...virtualRow,
measureElement: rowVirtualizer.measureElement,
}}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
onRowClick={onRowClick}
/>
);
})}
@@ -269,11 +277,11 @@ export const SessionsTable = ({ query }: Props) => {
const handleRowClick = useCallback(
(row: any) => {
navigate({
to: '/$organizationId/$projectId/sessions/$sessionId' as any,
to: '/$organizationId/$projectId/sessions/$sessionId',
params: { organizationId, projectId, sessionId: row.original.id },
});
},
[navigate, organizationId, projectId],
[navigate, organizationId, projectId]
);
const data = useMemo(() => {
@@ -281,7 +289,7 @@ export const SessionsTable = ({ query }: Props) => {
return LOADING_DATA;
}
return query.data?.pages?.flatMap((p) => p.data) ?? [];
return query.data?.pages?.flatMap((p) => p.items) ?? [];
}, [query.data]);
// const { setPage, state: pagination } = useDataTablePagination();
@@ -322,7 +330,6 @@ export const SessionsTable = ({ query }: Props) => {
enterCount > 0 &&
query.isFetchingNextPage === false
) {
console.log('fetching next page');
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
@@ -331,16 +338,16 @@ export const SessionsTable = ({ query }: Props) => {
<>
<SessionTableToolbar table={table} />
<VirtualizedSessionsTable
table={table}
data={data}
isLoading={isLoading}
onRowClick={handleRowClick}
table={table}
/>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100'
)}
>
<Loader2Icon className="size-4 animate-spin" />
@@ -350,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
);
};
const SESSION_FILTER_KEY_TO_FIELD: Record<string, string> = {
referrer: 'referrer_name',
country: 'country',
os: 'os',
browser: 'browser',
device: 'device',
};
const SESSION_FILTER_DEFINITIONS: FilterDefinition[] = [
{ key: 'referrer', label: 'Referrer', type: 'select' },
{ key: 'country', label: 'Country', type: 'select' },
{ key: 'os', label: 'OS', type: 'select' },
{ key: 'browser', label: 'Browser', type: 'select' },
{ key: 'device', label: 'Device', type: 'select' },
{ key: 'entryPage', label: 'Entry page', type: 'string' },
{ key: 'exitPage', label: 'Exit page', type: 'string' },
{ key: 'minPageViews', label: 'Min page views', type: 'number' },
{ key: 'maxPageViews', label: 'Max page views', type: 'number' },
{ key: 'minEvents', label: 'Min events', type: 'number' },
{ key: 'maxEvents', label: 'Max events', type: 'number' },
];
function SessionTableToolbar({ table }: { table: Table<IServiceSession> }) {
const { projectId } = useAppParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { search, setSearch } = useSearchQueryState();
const { values, setValue, activeCount } = useSessionFilters();
const loadOptions = useCallback(
(key: string) => {
const field = SESSION_FILTER_KEY_TO_FIELD[key];
if (!field) {
return Promise.resolve([]);
}
return queryClient.fetchQuery(
trpc.session.distinctValues.queryOptions({
projectId,
field: field as
| 'referrer_name'
| 'country'
| 'os'
| 'browser'
| 'device',
})
);
},
[trpc, queryClient, projectId]
);
return (
<DataTableToolbarContainer>
<AnimatedSearchInput
placeholder="Search sessions by path, referrer..."
value={search}
onChange={setSearch}
/>
<div className="flex flex-1 flex-wrap items-center gap-2">
<AnimatedSearchInput
onChange={setSearch}
placeholder="Search sessions by path, referrer..."
value={search}
/>
<FilterDropdown
definitions={SESSION_FILTER_DEFINITIONS}
loadOptions={loadOptions}
onChange={setValue}
values={values}
>
<Button
className={cn(
'border-dashed',
activeCount > 0 && 'border-primary border-solid'
)}
size="sm"
variant="outline"
>
<SlidersHorizontalIcon className="mr-2 size-4" />
Filters
{activeCount > 0 && (
<Badge className="ml-2 rounded-full px-1.5 py-0 text-xs">
{activeCount}
</Badge>
)}
</Button>
</FilterDropdown>
</div>
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);

View File

@@ -0,0 +1,328 @@
import { AnimatePresence, motion } from 'framer-motion';
import {
ArrowLeftIcon,
CheckIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from 'lucide-react';
import VirtualList from 'rc-virtual-list';
import { useEffect, useState } from 'react';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/utils/cn';
export type FilterType = 'select' | 'string' | 'number';
export interface FilterDefinition {
key: string;
label: string;
type: FilterType;
/** For 'select' type: show SerieIcon next to options (default true) */
showIcon?: boolean;
}
interface FilterDropdownProps {
definitions: FilterDefinition[];
values: Record<string, string | number | null | undefined>;
onChange: (key: string, value: string | number | null) => void;
loadOptions: (key: string) => Promise<string[]>;
children: React.ReactNode;
}
export function FilterDropdown({
definitions,
values,
onChange,
loadOptions,
children,
}: FilterDropdownProps) {
const [open, setOpen] = useState(false);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
const [search, setSearch] = useState('');
const [options, setOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
useEffect(() => {
if (!open) {
setActiveKey(null);
setSearch('');
setOptions([]);
setInputValue('');
}
}, [open]);
useEffect(() => {
if (!activeKey) {
return;
}
const def = definitions.find((d) => d.key === activeKey);
if (!def || def.type !== 'select') {
return;
}
setIsLoading(true);
loadOptions(activeKey)
.then((opts) => {
setOptions(opts);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}, [activeKey]);
const currentDef = activeKey
? definitions.find((d) => d.key === activeKey)
: null;
const goToFilter = (key: string) => {
setDirection('forward');
setSearch('');
setOptions([]);
const current = values[key];
setInputValue(current != null ? String(current) : '');
setActiveKey(key);
};
const goBack = () => {
setDirection('backward');
setActiveKey(null);
setSearch('');
};
const applyValue = (key: string, value: string | number | null) => {
onChange(key, value);
goBack();
};
const renderIndex = () => (
<div className="min-w-52">
{definitions.map((def) => {
const currentValue = values[def.key];
const isActive = currentValue != null && currentValue !== '';
return (
<button
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent"
key={def.key}
onClick={() => goToFilter(def.key)}
type="button"
>
<span className="font-medium text-sm">{def.label}</span>
<div className="flex shrink-0 items-center gap-1">
{isActive && (
<>
<span className="max-w-24 truncate text-muted-foreground text-xs">
{String(currentValue)}
</span>
<button
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onChange(def.key, null);
}}
type="button"
>
<XIcon className="size-3" />
</button>
</>
)}
<ChevronRightIcon className="size-4 text-muted-foreground" />
</div>
</button>
);
})}
</div>
);
const renderSelectFilter = () => {
const showIcon = currentDef?.showIcon !== false;
const filteredOptions = options.filter((opt) =>
opt.toLowerCase().includes(search.toLowerCase())
);
const currentValue = activeKey ? values[activeKey] : undefined;
return (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<Input
autoFocus
className="h-7 text-sm"
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
value={search}
/>
</div>
<Separator />
{isLoading ? (
<div className="flex items-center justify-center p-6 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
</div>
) : filteredOptions.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm">
No options found
</div>
) : (
<VirtualList
data={filteredOptions}
height={Math.min(filteredOptions.length * 36, 250)}
itemHeight={36}
itemKey={(item) => item}
>
{(option) => (
<button
className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent"
onClick={() => applyValue(activeKey!, option)}
type="button"
>
{showIcon && <SerieIcon name={option} />}
<span className="truncate text-sm">{option || 'Direct'}</span>
<CheckIcon
className={cn(
'ml-auto size-4 shrink-0',
currentValue === option ? 'opacity-100' : 'opacity-0'
)}
/>
</button>
)}
</VirtualList>
)}
</div>
);
};
const renderStringFilter = () => (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
</div>
<Separator />
<div className="flex flex-col gap-2 p-2">
<Input
autoFocus
className="h-8 text-sm"
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
applyValue(activeKey!, inputValue || null);
}
}}
placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
value={inputValue}
/>
<Button
className="w-full"
onClick={() => applyValue(activeKey!, inputValue || null)}
size="sm"
>
Apply
</Button>
</div>
</div>
);
const renderNumberFilter = () => (
<div className="min-w-52">
<div className="flex items-center gap-1 p-1">
<Button
className="size-7 shrink-0"
onClick={goBack}
size="icon"
variant="ghost"
>
<ArrowLeftIcon className="size-4" />
</Button>
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
</div>
<Separator />
<div className="flex flex-col gap-2 p-2">
<Input
autoFocus
className="h-8 text-sm"
inputMode="numeric"
min={0}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
applyValue(
activeKey!,
inputValue === '' ? null : Number(inputValue)
);
}
}}
placeholder="Enter value..."
type="number"
value={inputValue}
/>
<Button
className="w-full"
onClick={() =>
applyValue(
activeKey!,
inputValue === '' ? null : Number(inputValue)
)
}
size="sm"
>
Apply
</Button>
</div>
</div>
);
const renderContent = () => {
if (!(activeKey && currentDef)) {
return renderIndex();
}
switch (currentDef.type) {
case 'select':
return renderSelectFilter();
case 'string':
return renderStringFilter();
case 'number':
return renderNumberFilter();
}
};
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-1">
<AnimatePresence initial={false} mode="wait">
<motion.div
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
key={activeKey ?? 'index'}
transition={{ duration: 0.1 }}
>
{renderContent()}
</motion.div>
</AnimatePresence>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,229 @@
import type { IChartEventFilter } from '@openpanel/validation';
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
import { useCallback, useMemo } from 'react';
const DEBOUNCE_MS = 500;
const debounceOpts = {
clearOnDefault: true,
limitUrlUpdates: { method: 'debounce' as const, timeMs: DEBOUNCE_MS },
};
export function useSessionFilters() {
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [country, setCountry] = useQueryState(
'country',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [os, setOs] = useQueryState(
'os',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [entryPage, setEntryPage] = useQueryState(
'entryPage',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [exitPage, setExitPage] = useQueryState(
'exitPage',
parseAsString.withDefault('').withOptions(debounceOpts),
);
const [minPageViews, setMinPageViews] = useQueryState(
'minPageViews',
parseAsInteger,
);
const [maxPageViews, setMaxPageViews] = useQueryState(
'maxPageViews',
parseAsInteger,
);
const [minEvents, setMinEvents] = useQueryState('minEvents', parseAsInteger);
const [maxEvents, setMaxEvents] = useQueryState('maxEvents', parseAsInteger);
const filters = useMemo<IChartEventFilter[]>(() => {
const result: IChartEventFilter[] = [];
if (referrer) {
result.push({ name: 'referrer_name', operator: 'is', value: [referrer] });
}
if (country) {
result.push({ name: 'country', operator: 'is', value: [country] });
}
if (os) {
result.push({ name: 'os', operator: 'is', value: [os] });
}
if (browser) {
result.push({ name: 'browser', operator: 'is', value: [browser] });
}
if (device) {
result.push({ name: 'device', operator: 'is', value: [device] });
}
if (entryPage) {
result.push({
name: 'entry_path',
operator: 'contains',
value: [entryPage],
});
}
if (exitPage) {
result.push({
name: 'exit_path',
operator: 'contains',
value: [exitPage],
});
}
return result;
}, [referrer, country, os, browser, device, entryPage, exitPage]);
const values = useMemo(
() => ({
referrer,
country,
os,
browser,
device,
entryPage,
exitPage,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
}),
[
referrer,
country,
os,
browser,
device,
entryPage,
exitPage,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
],
);
const setValue = useCallback(
(key: string, value: string | number | null) => {
switch (key) {
case 'referrer':
setReferrer(String(value ?? ''));
break;
case 'country':
setCountry(String(value ?? ''));
break;
case 'os':
setOs(String(value ?? ''));
break;
case 'browser':
setBrowser(String(value ?? ''));
break;
case 'device':
setDevice(String(value ?? ''));
break;
case 'entryPage':
setEntryPage(String(value ?? ''));
break;
case 'exitPage':
setExitPage(String(value ?? ''));
break;
case 'minPageViews':
setMinPageViews(value != null ? Number(value) : null);
break;
case 'maxPageViews':
setMaxPageViews(value != null ? Number(value) : null);
break;
case 'minEvents':
setMinEvents(value != null ? Number(value) : null);
break;
case 'maxEvents':
setMaxEvents(value != null ? Number(value) : null);
break;
}
},
[
setReferrer,
setCountry,
setOs,
setBrowser,
setDevice,
setEntryPage,
setExitPage,
setMinPageViews,
setMaxPageViews,
setMinEvents,
setMaxEvents,
],
);
const activeCount =
filters.length +
(minPageViews != null ? 1 : 0) +
(maxPageViews != null ? 1 : 0) +
(minEvents != null ? 1 : 0) +
(maxEvents != null ? 1 : 0);
const clearAll = useCallback(() => {
setReferrer('');
setCountry('');
setOs('');
setBrowser('');
setDevice('');
setEntryPage('');
setExitPage('');
setMinPageViews(null);
setMaxPageViews(null);
setMinEvents(null);
setMaxEvents(null);
}, [
setReferrer,
setCountry,
setOs,
setBrowser,
setDevice,
setEntryPage,
setExitPage,
setMinPageViews,
setMaxPageViews,
setMinEvents,
setMaxEvents,
]);
return {
referrer,
setReferrer,
country,
setCountry,
os,
setOs,
browser,
setBrowser,
device,
setDevice,
entryPage,
setEntryPage,
exitPage,
setExitPage,
minPageViews,
setMinPageViews,
maxPageViews,
setMaxPageViews,
minEvents,
setMinEvents,
maxEvents,
setMaxEvents,
filters,
values,
setValue,
activeCount,
clearAll,
};
}

View File

@@ -1,19 +1,15 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useSessionFilters } from '@/hooks/use-session-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId/sessions',
'/_app/$organizationId/$projectId/sessions'
)({
component: Component,
head: () => {
@@ -31,6 +27,8 @@ function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
const { filters, minPageViews, maxPageViews, minEvents, maxEvents } =
useSessionFilters();
const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions(
@@ -38,19 +36,24 @@ function Component() {
projectId,
take: 50,
search: debouncedSearch,
filters,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
},
),
}
)
);
return (
<PageContainer>
<PageHeader
title="Sessions"
description="Access all your sessions here"
className="mb-8"
description="Access all your sessions here"
title="Sessions"
/>
<SessionsTable query={query} />
</PageContainer>

View File

@@ -53,7 +53,9 @@
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error"
"noUselessElse": "error",
"noNestedTernary": "off",
"useDefaultSwitchClause": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",

View File

@@ -12,7 +12,6 @@ import {
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
import { getOrganizationByProjectIdCached } from './organization.service';
import { getProfilesCached, type IServiceProfile } from './profile.service';
export interface IClickhouseSession {
@@ -106,7 +105,12 @@ export interface GetSessionListOptions {
startDate?: Date;
endDate?: Date;
search?: string;
cursor?: Cursor | null;
cursor?: Date;
minPageViews?: number | null;
maxPageViews?: number | null;
minEvents?: number | null;
maxEvents?: number | null;
dateIntervalInDays?: number;
}
export function transformSession(session: IClickhouseSession): IServiceSession {
@@ -151,35 +155,51 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
};
}
interface PageInfo {
next?: Cursor; // use last row
}
export async function getSessionList(options: GetSessionListOptions) {
const {
cursor,
take,
projectId,
profileId,
filters,
startDate,
endDate,
search,
minPageViews,
maxPageViews,
minEvents,
maxEvents,
dateIntervalInDays = 0.5,
} = options;
interface Cursor {
createdAt: string; // ISO 8601 with ms
id: string;
}
export async function getSessionList({
cursor,
take,
projectId,
profileId,
filters,
startDate,
endDate,
search,
}: GetSessionListOptions) {
const { sb, getSql } = createSqlBuilder();
sb.from = `${TABLE_NAMES.sessions} FINAL`;
sb.limit = take;
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (startDate && endDate) {
sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
const MAX_DATE_INTERVAL_IN_DAYS = 365;
// Cap the date interval to prevent infinity
const safeDateIntervalInDays = Math.min(
dateIntervalInDays,
MAX_DATE_INTERVAL_IN_DAYS
);
if (cursor instanceof Date) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
}
if (!(cursor || (startDate && endDate))) {
sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
}
if (startDate && endDate) {
sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
}
sb.orderBy.created_at = 'created_at DESC';
if (profileId) {
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
}
@@ -190,27 +210,19 @@ export async function getSessionList({
if (filters?.length) {
Object.assign(sb.where, getEventFiltersWhereClause(filters));
}
const organization = await getOrganizationByProjectIdCached(projectId);
// This will speed up the query quite a lot for big organizations
const dateIntervalInDays =
organization?.subscriptionPeriodEventsLimit &&
organization?.subscriptionPeriodEventsLimit > 1_000_000
? 2
: 360;
if (cursor) {
const cAt = sqlstring.escape(cursor.createdAt);
sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
sb.orderBy.created_at = 'created_at DESC';
} else {
sb.orderBy.created_at = 'created_at DESC';
sb.where.created_at = `created_at > now() - INTERVAL ${dateIntervalInDays} DAY`;
if (minPageViews != null) {
sb.where.minPageViews = `screen_view_count >= ${minPageViews}`;
}
if (maxPageViews != null) {
sb.where.maxPageViews = `screen_view_count <= ${maxPageViews}`;
}
if (minEvents != null) {
sb.where.minEvents = `event_count >= ${minEvents}`;
}
if (maxEvents != null) {
sb.where.maxEvents = `event_count <= ${maxEvents}`;
}
// ==== Select columns (as you had) ====
// sb.select.id = 'id'; sb.select.project_id = 'project_id'; ... etc.
const columns = [
'created_at',
'ended_at',
@@ -249,17 +261,17 @@ export async function getSessionList({
}
>(sql);
// Compute cursors from page edges
const last = data[take - 1];
const meta: PageInfo = {
next: last
? {
createdAt: last.created_at,
id: last.id,
}
: undefined,
};
// If no results and we haven't reached the max window, retry with a larger interval
if (
data.length === 0 &&
sb.where.cursorWindow &&
safeDateIntervalInDays < MAX_DATE_INTERVAL_IN_DAYS
) {
return getSessionList({
...options,
dateIntervalInDays: dateIntervalInDays * 2,
});
}
// Profile hydration (unchanged)
const profileIds = data
@@ -283,6 +295,13 @@ export async function getSessionList({
},
}));
// Compute cursors from page edges
const last = items.at(-1);
const meta = {
next: last ? last.createdAt.toISOString() : undefined,
};
return { items, meta };
}
@@ -370,8 +389,41 @@ export async function getSessionReplayChunksFrom(
};
}
export const SESSION_DISTINCT_FIELDS = [
'referrer_name',
'country',
'os',
'browser',
'device',
] as const;
export type SessionDistinctField = (typeof SESSION_DISTINCT_FIELDS)[number];
export async function getSessionDistinctValues(
projectId: string,
field: SessionDistinctField,
limit = 200
): Promise<string[]> {
const sql = `
SELECT ${field} AS value, count() AS cnt
FROM ${TABLE_NAMES.sessions}
WHERE project_id = ${sqlstring.escape(projectId)}
AND ${field} != ''
AND sign = 1
AND created_at > now() - INTERVAL 90 DAY
GROUP BY value
ORDER BY cnt DESC
LIMIT ${limit}
`;
const results = await chQuery<{ value: string }>(sql);
return results.map((r) => r.value).filter(Boolean);
}
class SessionService {
constructor(private client: typeof ch) {}
private readonly client: typeof ch;
constructor(client: typeof ch) {
this.client = client;
}
async byId(sessionId: string, projectId: string) {
const [sessionRows, hasReplayRows] = await Promise.all([

View File

@@ -1,6 +1,8 @@
import {
getSessionDistinctValues,
getSessionList,
getSessionReplayChunksFrom,
SESSION_DISTINCT_FIELDS,
sessionService,
} from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
@@ -42,20 +44,28 @@ export const sessionRouter = createTRPCRouter({
endDate: z.date().optional(),
search: z.string().optional(),
take: z.number().default(50),
minPageViews: z.number().nullish(),
maxPageViews: z.number().nullish(),
minEvents: z.number().nullish(),
maxEvents: z.number().nullish(),
})
)
.query(async ({ input }) => {
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
const data = await getSessionList({
.query(({ input }) => {
return getSessionList({
...input,
cursor,
cursor: input.cursor ? new Date(input.cursor) : undefined,
});
return {
data: data.items,
meta: {
next: data.meta.next ? encodeCursor(data.meta.next) : undefined,
},
};
}),
distinctValues: protectedProcedure
.input(
z.object({
projectId: z.string(),
field: z.enum(SESSION_DISTINCT_FIELDS),
})
)
.query(({ input }) => {
return getSessionDistinctValues(input.projectId, input.field);
}),
byId: protectedProcedure