fix(dashboard): share overview (all widgets didnt work)

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-05 21:24:37 +00:00
parent 2d0478d626
commit 7a88b262c0
15 changed files with 820 additions and 136 deletions

View File

@@ -10,11 +10,12 @@ import { AnimatedNumber } from '../animated-number';
export interface LiveCounterProps { export interface LiveCounterProps {
projectId: string; projectId: string;
shareId?: string;
} }
const FIFTEEN_SECONDS = 1000 * 30; const FIFTEEN_SECONDS = 1000 * 30;
export function LiveCounter({ projectId }: LiveCounterProps) { export function LiveCounter({ projectId, shareId }: LiveCounterProps) {
const trpc = useTRPC(); const trpc = useTRPC();
const client = useQueryClient(); const client = useQueryClient();
const counter = useDebounceState(0, 1000); const counter = useDebounceState(0, 1000);
@@ -22,6 +23,7 @@ export function LiveCounter({ projectId }: LiveCounterProps) {
const query = useQuery( const query = useQuery(
trpc.overview.liveVisitors.queryOptions({ trpc.overview.liveVisitors.queryOptions({
projectId, projectId,
shareId,
}), }),
); );

View File

@@ -18,16 +18,18 @@ import {
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps { interface OverviewLiveHistogramProps {
projectId: string; projectId: string;
shareId?: string;
} }
export function OverviewLiveHistogram({ export function OverviewLiveHistogram({
projectId, projectId,
shareId,
}: OverviewLiveHistogramProps) { }: OverviewLiveHistogramProps) {
const trpc = useTRPC(); const trpc = useTRPC();
// Use the new liveData endpoint instead of chart props // Use the new liveData endpoint instead of chart props
const { data: liveData, isLoading } = useQuery( const { data: liveData, isLoading } = useQuery(
trpc.overview.liveData.queryOptions({ projectId }), trpc.overview.liveData.queryOptions({ projectId, shareId }),
); );
const totalSessions = liveData?.totalSessions ?? 0; const totalSessions = liveData?.totalSessions ?? 0;

View File

@@ -0,0 +1,90 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import WorldMap from 'react-svg-worldmap';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewMapProps {
projectId: string;
shareId?: string;
}
export function OverviewMap({ projectId, shareId }: OverviewMapProps) {
const { range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const trpc = useTRPC();
const query = useQuery(
trpc.overview.map.queryOptions({
projectId,
shareId,
range,
filters,
startDate,
endDate,
}),
);
const mapData = useMemo(() => {
if (!query.data) return [];
// Aggregate by country (sum counts for same country)
const countryMap = new Map<string, number>();
query.data.forEach((item) => {
const country = item.country.toLowerCase();
const current = countryMap.get(country) ?? 0;
countryMap.set(country, current + item.count);
});
return Array.from(countryMap.entries()).map(([country, value]) => ({
country,
value,
}));
}, [query.data]);
if (query.isLoading) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Loading map...</div>
</div>
);
}
if (query.isError) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">Error loading map</div>
</div>
);
}
if (!query.data || mapData.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center">
<div className="text-muted-foreground">No data available</div>
</div>
);
}
return (
<div className="h-full w-full" style={{ minHeight: 300 }}>
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
onClickFunction={(event) => {
if (event.countryCode) {
setFilter('country', event.countryCode);
}
}}
size={width}
data={mapData}
color={'var(--chart-0)'}
borderColor={'var(--foreground)'}
/>
)}
</AutoSizer>
</div>
);
}

View File

@@ -36,6 +36,7 @@ import { OverviewMetricCard } from './overview-metric-card';
interface OverviewMetricsProps { interface OverviewMetricsProps {
projectId: string; projectId: string;
shareId?: string;
} }
const TITLES = [ const TITLES = [
@@ -83,7 +84,10 @@ const TITLES = [
}, },
] as const; ] as const;
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { export default function OverviewMetrics({
projectId,
shareId,
}: OverviewMetricsProps) {
const { range, interval, metric, setMetric, startDate, endDate } = const { range, interval, metric, setMetric, startDate, endDate } =
useOverviewOptions(); useOverviewOptions();
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
@@ -93,6 +97,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const overviewQuery = useQuery( const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({ trpc.overview.stats.queryOptions({
projectId, projectId,
shareId,
range, range,
interval, interval,
filters, filters,
@@ -138,7 +143,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1', 'col-span-4 min-h-16 flex-1 p-4 pb-0 shadow-[0_0_0_0.5px] shadow-border max-md:row-start-1 md:col-span-1',
)} )}
> >
<OverviewLiveHistogram projectId={projectId} /> <OverviewLiveHistogram projectId={projectId} shareId={shareId} />
</div> </div>
</div> </div>
@@ -344,7 +349,7 @@ function Chart({
onAnimationEnd={handleAnimationEnd} onAnimationEnd={handleAnimationEnd}
/> />
<Tooltip /> <Tooltip />
<YAxis {...yAxisProps} domain={[0, 'dataMax']} width={25} /> <YAxis {...yAxisProps} width={25} />
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -471,7 +476,12 @@ function Chart({
<Tooltip /> <Tooltip />
<YAxis <YAxis
{...yAxisProps} {...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']} domain={[
0,
activeMetric.key === 'bounce_rate'
? 100
: (dataMax: number) => Math.max(dataMax, 1),
]}
width={25} width={25}
/> />
<YAxis <YAxis
@@ -480,14 +490,18 @@ function Chart({
orientation="right" orientation="right"
domain={[ domain={[
0, 0,
data.reduce( Math.max(
(max, item) => Math.max(max, item.total_revenue ?? 0), data.reduce(
0, (max, item) => Math.max(max, item.total_revenue ?? 0),
) * 2, 0,
) * 1.2,
1,
),
]} ]}
width={30} width={30}
allowDataOverflow={false}
/> />
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} padding={{ left: 10, right: 10 }} />
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -523,19 +537,11 @@ function Chart({
stroke={'oklch(from var(--foreground) l c h / 0.1)'} stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2} strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
dot={ dot={false}
data.length > 90
? false
: {
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
fill: 'transparent',
strokeWidth: 1.5,
r: 2,
}
}
activeDot={{ activeDot={{
stroke: 'oklch(from var(--foreground) l c h / 0.2)', stroke: 'oklch(from var(--foreground) l c h / 0.2)',
fill: 'transparent', fill: 'var(--def-100)',
fillOpacity: 1,
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
}} }}
@@ -581,7 +587,8 @@ function Chart({
? false ? false
: { : {
stroke: getChartColor(0), stroke: getChartColor(0),
fill: 'transparent', fill: 'var(--def-100)',
fillOpacity: 1,
strokeWidth: 1.5, strokeWidth: 1.5,
r: 3, r: 3,
} }

View File

@@ -26,9 +26,11 @@ import { useOverviewWidget } from './useOverviewWidget';
interface OverviewTopDevicesProps { interface OverviewTopDevicesProps {
projectId: string; projectId: string;
shareId?: string;
} }
export default function OverviewTopDevices({ export default function OverviewTopDevices({
projectId, projectId,
shareId,
}: OverviewTopDevicesProps) { }: OverviewTopDevicesProps) {
const { interval, range, previous, startDate, endDate } = const { interval, range, previous, startDate, endDate } =
useOverviewOptions(); useOverviewOptions();
@@ -325,6 +327,7 @@ export default function OverviewTopDevices({
const query = useQuery( const query = useQuery(
trpc.overview.topGeneric.queryOptions({ trpc.overview.topGeneric.queryOptions({
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,
@@ -337,6 +340,7 @@ export default function OverviewTopDevices({
trpc.overview.topGenericSeries.queryOptions( trpc.overview.topGenericSeries.queryOptions(
{ {
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,

View File

@@ -1,8 +1,6 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { IReportInput } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget'; import { Widget, WidgetBody } from '../widget';
@@ -17,17 +15,18 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
export interface OverviewTopEventsProps { export interface OverviewTopEventsProps {
projectId: string; projectId: string;
shareId?: string;
} }
export default function OverviewTopEvents({ export default function OverviewTopEvents({
projectId, projectId,
shareId,
}: OverviewTopEventsProps) { }: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } = const { range, startDate, endDate } = useOverviewOptions();
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(); const [filters, setFilter] = useEventQueryFilters();
const trpc = useTRPC(); const trpc = useTRPC();
const { data: conversions } = useQuery( const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }), trpc.overview.topConversions.queryOptions({ projectId, shareId }),
); );
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -36,15 +35,7 @@ export default function OverviewTopEvents({
title: 'Events', title: 'Events',
btn: 'Events', btn: 'Events',
meta: { meta: {
filters: [ type: 'events' as const,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
eventName: '*',
}, },
}, },
conversions: { conversions: {
@@ -52,69 +43,84 @@ export default function OverviewTopEvents({
btn: 'Conversions', btn: 'Conversions',
hide: !conversions || conversions.length === 0, hide: !conversions || conversions.length === 0,
meta: { meta: {
filters: [ type: 'conversions' as const,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
eventName: '*',
}, },
}, },
link_out: { link_out: {
title: 'Link out', title: 'Link out',
btn: 'Link out', btn: 'Link out',
meta: { meta: {
filters: [], type: 'linkOut' as const,
eventName: 'link_out',
breakdownProperty: 'properties.href',
}, },
}, },
}); });
const report: IReportInput = useMemo( // Use different endpoints based on widget type
() => ({ const eventsQuery = useQuery(
limit: 1000, trpc.overview.topEvents.queryOptions({
projectId, projectId,
shareId,
range,
startDate, startDate,
endDate, endDate,
series: [ filters,
{ excludeEvents:
type: 'event' as const, widget.meta?.type === 'events'
segment: 'event' as const, ? ['session_start', 'session_end', 'screen_view']
filters: [...filters, ...(widget.meta?.filters ?? [])], : undefined,
id: 'A',
name: widget.meta?.eventName ?? '*',
},
],
breakdowns: [
{
id: 'A',
name: widget.meta?.breakdownProperty ?? 'name',
},
],
chartType: 'bar' as const,
interval,
range,
previous,
metric: 'sum' as const,
}), }),
[projectId, startDate, endDate, filters, widget, interval, range, previous],
); );
const query = useQuery(trpc.chart.aggregate.queryOptions(report)); const linkOutQuery = useQuery(
trpc.overview.topLinkOut.queryOptions({
projectId,
shareId,
range,
startDate,
endDate,
filters,
}),
);
const tableData: EventTableItem[] = useMemo(() => { const tableData: EventTableItem[] = useMemo(() => {
if (!query.data?.series) return []; // For link out, use href as name
if (widget.meta?.type === 'linkOut') {
if (!linkOutQuery.data) return [];
return linkOutQuery.data.map((item) => ({
id: item.href,
name: item.href,
count: item.count,
}));
}
return query.data.series.map((serie) => ({ // For events and conversions
id: serie.id, if (!eventsQuery.data) return [];
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
count: serie.metrics.sum, // For conversions, filter events by conversion names (client-side filtering)
if (widget.meta?.type === 'conversions' && conversions) {
const conversionNames = new Set(conversions.map((c) => c.name));
return eventsQuery.data
.filter((item) => conversionNames.has(item.name))
.map((item) => ({
id: item.name,
name: item.name,
count: item.count,
}));
}
// For regular events
return eventsQuery.data.map((item) => ({
id: item.name,
name: item.name,
count: item.count,
})); }));
}, [query.data]); }, [eventsQuery.data, linkOutQuery.data, widget.meta?.type, conversions]);
// Determine which query's loading state to use
const isLoading =
widget.meta?.type === 'linkOut'
? linkOutQuery.isLoading
: eventsQuery.isLoading;
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
@@ -150,14 +156,14 @@ export default function OverviewTopEvents({
className="border-b-0 pb-2" className="border-b-0 pb-2"
/> />
<WidgetBody className="p-0"> <WidgetBody className="p-0">
{query.isLoading ? ( {isLoading ? (
<OverviewWidgetTableLoading /> <OverviewWidgetTableLoading />
) : ( ) : (
<OverviewWidgetTableEvents <OverviewWidgetTableEvents
data={filteredData} data={filteredData}
onItemClick={(name) => { onItemClick={(name) => {
if (widget.meta?.breakdownProperty) { if (widget.meta?.type === 'linkOut') {
setFilter(widget.meta.breakdownProperty, name); setFilter('properties.href', name);
} else { } else {
setFilter('name', name); setFilter('name', name);
} }

View File

@@ -9,9 +9,7 @@ import { countries } from '@/translations/countries';
import { NOT_SET_VALUE } from '@openpanel/constants'; import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon'; import { SerieIcon } from '../report-chart/common/serie-icon';
import { ReportChartShortcut } from '../report-chart/shortcut';
import { Widget, WidgetBody } from '../widget'; import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants'; import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button'; import OverviewDetailsButton from './overview-details-button';
@@ -19,6 +17,7 @@ import {
OverviewLineChart, OverviewLineChart,
OverviewLineChartLoading, OverviewLineChartLoading,
} from './overview-line-chart'; } from './overview-line-chart';
import { OverviewMap } from './overview-map';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle'; import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { import {
WidgetFooter, WidgetFooter,
@@ -34,8 +33,12 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopGeoProps { interface OverviewTopGeoProps {
projectId: string; projectId: string;
shareId?: string;
} }
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { export default function OverviewTopGeo({
projectId,
shareId,
}: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } = const { interval, range, previous, startDate, endDate } =
useOverviewOptions(); useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar'); const [chartType, setChartType] = useState<IChartType>('bar');
@@ -63,6 +66,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const query = useQuery( const query = useQuery(
trpc.overview.topGeneric.queryOptions({ trpc.overview.topGeneric.queryOptions({
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,
@@ -75,6 +79,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
trpc.overview.topGenericSeries.queryOptions( trpc.overview.topGenericSeries.queryOptions(
{ {
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,
@@ -211,32 +216,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div> <div className="title">Map</div>
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<ReportChartShortcut <OverviewMap projectId={projectId} shareId={shareId} />
{...{
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters,
id: 'A',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
interval: interval,
range: range,
previous: previous,
}}
/>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
</> </>

View File

@@ -20,9 +20,13 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopPagesProps { interface OverviewTopPagesProps {
projectId: string; projectId: string;
shareId?: string;
} }
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { export default function OverviewTopPages({
projectId,
shareId,
}: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions(); const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean); const [domain, setDomain] = useQueryState('d', parseAsBoolean);
@@ -56,6 +60,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const query = useQuery( const query = useQuery(
trpc.overview.topPages.queryOptions({ trpc.overview.topPages.queryOptions({
projectId, projectId,
shareId,
filters, filters,
startDate, startDate,
endDate, endDate,

View File

@@ -24,9 +24,11 @@ import { useOverviewWidgetV2 } from './useOverviewWidget';
interface OverviewTopSourcesProps { interface OverviewTopSourcesProps {
projectId: string; projectId: string;
shareId?: string;
} }
export default function OverviewTopSources({ export default function OverviewTopSources({
projectId, projectId,
shareId,
}: OverviewTopSourcesProps) { }: OverviewTopSourcesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions(); const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters(); const [filters, setFilter] = useEventQueryFilters();
@@ -71,6 +73,7 @@ export default function OverviewTopSources({
const query = useQuery( const query = useQuery(
trpc.overview.topGeneric.queryOptions({ trpc.overview.topGeneric.queryOptions({
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,
@@ -83,6 +86,7 @@ export default function OverviewTopSources({
trpc.overview.topGenericSeries.queryOptions( trpc.overview.topGenericSeries.queryOptions(
{ {
projectId, projectId,
shareId,
range, range,
filters, filters,
column: widget.key, column: widget.key,

View File

@@ -29,6 +29,7 @@ import { useOverviewOptions } from './useOverviewOptions';
interface OverviewUserJourneyProps { interface OverviewUserJourneyProps {
projectId: string; projectId: string;
shareId?: string;
} }
type PortalTooltipPosition = { left: number; top: number; ready: boolean }; type PortalTooltipPosition = { left: number; top: number; ready: boolean };
@@ -159,6 +160,7 @@ function SankeyPortalTooltip({
export default function OverviewUserJourney({ export default function OverviewUserJourney({
projectId, projectId,
shareId,
}: OverviewUserJourneyProps) { }: OverviewUserJourneyProps) {
const { range, startDate, endDate } = useOverviewOptions(); const { range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
@@ -177,6 +179,7 @@ export default function OverviewUserJourney({
endDate, endDate,
range, range,
steps: steps ?? 5, steps: steps ?? 5,
shareId,
}), }),
); );

View File

@@ -38,7 +38,7 @@ export default function OverviewFilters({
const { projectId } = useAppParams(); const { projectId } = useAppParams();
const [filters, setFilter] = useEventQueryFilters(nuqsOptions); const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions); const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames({ projectId }); const eventNames = useEventNames({ projectId, anyEvents: false });
const selectedFilters = filters.filter((filter) => filter.value[0] !== null); const selectedFilters = filters.filter((filter) => filter.value[0] !== null);
return ( return (
<SheetContent className="[&>button.absolute]:hidden"> <SheetContent className="[&>button.absolute]:hidden">

View File

@@ -1,6 +1,7 @@
import { ShareEnterPassword } from '@/components/auth/share-enter-password'; import { ShareEnterPassword } from '@/components/auth/share-enter-password';
import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state'; import FullPageLoadingState from '@/components/full-page-loading-state';
import { LazyComponent } from '@/components/lazy-component';
import { LoginNavbar } from '@/components/login-navbar'; import { LoginNavbar } from '@/components/login-navbar';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { LiveCounter } from '@/components/overview/live-counter'; import { LiveCounter } from '@/components/overview/live-counter';
@@ -11,6 +12,7 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo'; import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages'; import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources'; import OverviewTopSources from '@/components/overview/overview-top-sources';
import OverviewUserJourney from '@/components/overview/overview-user-journey';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router'; import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
@@ -110,19 +112,22 @@ function RouteComponent() {
<OverviewRange /> <OverviewRange />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<LiveCounter projectId={projectId} /> <LiveCounter projectId={projectId} shareId={shareId} />
</div> </div>
</div> </div>
<OverviewFiltersButtons /> <OverviewFiltersButtons />
</div> </div>
</div> </div>
<div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4"> <div className="mx-auto grid max-w-7xl grid-cols-6 gap-4 p-4">
<OverviewMetrics projectId={projectId} /> <OverviewMetrics projectId={projectId} shareId={shareId} />
<OverviewTopSources projectId={projectId} /> <OverviewTopSources projectId={projectId} shareId={shareId} />
<OverviewTopPages projectId={projectId} /> <OverviewTopPages projectId={projectId} shareId={shareId} />
<OverviewTopDevices projectId={projectId} /> <OverviewTopDevices projectId={projectId} shareId={shareId} />
<OverviewTopEvents projectId={projectId} /> <OverviewTopEvents projectId={projectId} shareId={shareId} />
<OverviewTopGeo projectId={projectId} /> <OverviewTopGeo projectId={projectId} shareId={shareId} />
<LazyComponent className="col-span-6">
<OverviewUserJourney projectId={projectId} shareId={shareId} />
</LazyComponent>
</div> </div>
</div> </div>
); );

View File

@@ -3,10 +3,11 @@ import { chartColors } from '@openpanel/constants';
import { getCache } from '@openpanel/redis'; import { getCache } from '@openpanel/redis';
import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation'; import { type IChartEventFilter, zTimeInterval } from '@openpanel/validation';
import { omit } from 'ramda'; import { omit } from 'ramda';
import sqlstring from 'sqlstring';
import { z } from 'zod'; import { z } from 'zod';
import { TABLE_NAMES, ch } from '../clickhouse/client'; import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder'; import { clix } from '../clickhouse/query-builder';
import { getEventFiltersWhereClause } from './chart.service'; import { getEventFiltersWhereClause, getSelectPropertyKey } from './chart.service';
// Constants // Constants
const ROLLUP_DATE_PREFIX = '1970-01-01'; const ROLLUP_DATE_PREFIX = '1970-01-01';
@@ -127,12 +128,53 @@ export type IGetUserJourneyInput = z.infer<typeof zGetUserJourneyInput> & {
timezone: string; timezone: string;
}; };
export const zGetTopEventsInput = z.object({
projectId: z.string(),
filters: z.array(z.any()),
startDate: z.string(),
endDate: z.string(),
excludeEvents: z.array(z.string()).optional(),
});
export type IGetTopEventsInput = z.infer<typeof zGetTopEventsInput> & {
timezone: string;
};
export const zGetTopLinkOutInput = z.object({
projectId: z.string(),
filters: z.array(z.any()),
startDate: z.string(),
endDate: z.string(),
});
export type IGetTopLinkOutInput = z.infer<typeof zGetTopLinkOutInput> & {
timezone: string;
};
export const zGetMapDataInput = z.object({
projectId: z.string(),
filters: z.array(z.any()),
startDate: z.string(),
endDate: z.string(),
});
export type IGetMapDataInput = z.infer<typeof zGetMapDataInput> & {
timezone: string;
};
export class OverviewService { export class OverviewService {
constructor(private client: typeof ch) {} constructor(private client: typeof ch) {}
// Helper methods // Helper methods
private isRollupRow(date: string): boolean { private isRollupRow(date: string): boolean {
return date.startsWith(ROLLUP_DATE_PREFIX); // The rollup row has date 1970-01-01 00:00:00 (epoch) from ClickHouse.
// After transform with `new Date().toISOString()`, this becomes an ISO string.
// Due to timezone handling in JavaScript's Date constructor (which interprets
// the input as local time), the UTC date might become:
// - 1969-12-31T... for positive UTC offsets (e.g., UTC+8)
// - 1970-01-01T... for UTC or negative offsets
// We check for both year prefixes to handle all server timezones.
return date.startsWith(ROLLUP_DATE_PREFIX) || date.startsWith('1969-12-31');
} }
private getFillConfig(interval: string, startDate: string, endDate: string) { private getFillConfig(interval: string, startDate: string, endDate: string) {
@@ -1223,6 +1265,150 @@ export class OverviewService {
links: filteredLinks, links: filteredLinks,
}; };
} }
async getTopEvents({
projectId,
filters,
startDate,
endDate,
timezone,
excludeEvents = ['session_start', 'session_end', 'screen_view'],
}: {
projectId: string;
filters: IChartEventFilter[];
startDate: string;
endDate: string;
timezone: string;
excludeEvents?: string[];
}): Promise<Array<{ name: string; count: number }>> {
const where = this.getRawWhereClause('events', filters);
const excludeWhere =
excludeEvents.length > 0
? `name NOT IN (${excludeEvents.map((e) => sqlstring.escape(e)).join(',')})`
: '';
const query = clix(this.client, timezone)
.select<{ name: string; count: number }>([
'name',
'count() as count',
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(where)
.rawWhere(excludeWhere)
.groupBy(['name'])
.orderBy('count', 'DESC')
.limit(MAX_RECORDS_LIMIT);
return query.execute();
}
async getTopLinkOut({
projectId,
filters,
startDate,
endDate,
timezone,
}: {
projectId: string;
filters: IChartEventFilter[];
startDate: string;
endDate: string;
timezone: string;
}): Promise<Array<{ href: string; count: number }>> {
const where = this.getRawWhereClause('events', filters);
const hrefKey = getSelectPropertyKey('properties.href');
const query = clix(this.client, timezone)
.select<{ href: string; count: number }>([
`${hrefKey} as href`,
'count() as count',
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('name', '=', 'link_out')
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(where)
.rawWhere(`${hrefKey} IS NOT NULL AND ${hrefKey} != ''`)
.groupBy(['href'])
.orderBy('count', 'DESC')
.limit(MAX_RECORDS_LIMIT);
return query.execute();
}
async getMapData({
projectId,
filters,
startDate,
endDate,
timezone,
}: {
projectId: string;
filters: IChartEventFilter[];
startDate: string;
endDate: string;
timezone: string;
}): Promise<
Array<{
country: string;
region?: string;
city?: string;
lat: number;
lng: number;
count: number;
}>
> {
const where = this.getRawWhereClause('events', filters);
// Note: ClickHouse doesn't have built-in lat/lng for countries/regions
// This would typically require a lookup table or external service
// For now, we'll return the data structure but lat/lng would need to be
// resolved on the frontend or via a separate lookup
const query = clix(this.client, timezone)
.select<{
country: string;
region: string | null;
city: string | null;
count: number;
}>([
'nullIf(country, \'\') as country',
'nullIf(region, \'\') as region',
'nullIf(city, \'\') as city',
'uniq(session_id) as count',
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(where)
.rawWhere('country IS NOT NULL AND country != \'\'')
.groupBy(['country', 'region', 'city'])
.orderBy('count', 'DESC')
.limit(MAX_RECORDS_LIMIT);
const results = await query.execute();
// Return with placeholder lat/lng - these should be resolved via geocoding
// or a lookup table on the frontend/backend
return results.map((row) => ({
country: row.country,
region: row.region ?? undefined,
city: row.city ?? undefined,
lat: 0, // Placeholder - needs geocoding
lng: 0, // Placeholder - needs geocoding
count: row.count,
}));
}
} }
export const overviewService = new OverviewService(ch); export const overviewService = new OverviewService(ch);

View File

@@ -213,3 +213,67 @@ export async function validateShareAccess(
throw new Error('Share not found'); throw new Error('Share not found');
} }
// Validation for overview share access
export async function validateOverviewShareAccess(
shareId: string | undefined,
projectId: string,
ctx: {
cookies: Record<string, string | undefined>;
session?: { userId?: string | null };
},
): Promise<{ isValid: boolean }> {
// If shareId is provided, validate share access
if (shareId) {
const share = await db.shareOverview.findUnique({
where: { id: shareId },
});
if (!share || !share.public) {
throw new Error('Share not found or not public');
}
// Verify the share is for the correct project
if (share.projectId !== projectId) {
throw new Error('Project ID mismatch');
}
// If no password is set, share is public and accessible
if (!share.password) {
return {
isValid: true,
};
}
// If password is set, require cookie OR member access
const hasCookie = !!ctx.cookies[`shared-overview-${shareId}`];
const hasMemberAccess =
ctx.session?.userId &&
(await getProjectAccess({
userId: ctx.session.userId,
projectId,
}));
return {
isValid: hasCookie || !!hasMemberAccess,
};
}
// If no shareId, require authenticated user with project access
if (!ctx.session?.userId) {
throw new Error('Authentication required');
}
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw new Error('You do not have access to this project');
}
return {
isValid: true,
};
}

View File

@@ -5,18 +5,25 @@ import {
eventBuffer, eventBuffer,
getChartPrevStartEndDate, getChartPrevStartEndDate,
getChartStartEndDate, getChartStartEndDate,
getConversionEventNames,
getOrganizationSubscriptionChartEndDate, getOrganizationSubscriptionChartEndDate,
getSettingsForProject, getSettingsForProject,
overviewService, overviewService,
validateOverviewShareAccess,
zGetMapDataInput,
zGetMetricsInput, zGetMetricsInput,
zGetTopEventsInput,
zGetTopGenericInput, zGetTopGenericInput,
zGetTopGenericSeriesInput, zGetTopGenericSeriesInput,
zGetTopLinkOutInput,
zGetTopPagesInput, zGetTopPagesInput,
zGetUserJourneyInput, zGetUserJourneyInput,
} from '@openpanel/db'; } from '@openpanel/db';
import { type IChartRange, zRange } from '@openpanel/validation'; import { type IChartRange, zRange } from '@openpanel/validation';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { z } from 'zod'; import { z } from 'zod';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc'; import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
const cacher = cacheMiddleware((input, opts) => { const cacher = cacheMiddleware((input, opts) => {
@@ -87,15 +94,58 @@ function getCurrentAndPrevious<
export const overviewRouter = createTRPCRouter({ export const overviewRouter = createTRPCRouter({
liveVisitors: publicProcedure liveVisitors: publicProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
return eventBuffer.getActiveVisitorCount(input.projectId); return eventBuffer.getActiveVisitorCount(input.projectId);
}), }),
liveData: publicProcedure liveData: publicProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string(), shareId: z.string().optional() }))
.use(cacher) .use(cacher)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
// Get total unique sessions in the last 30 minutes // Get total unique sessions in the last 30 minutes
@@ -212,10 +262,32 @@ export const overviewRouter = createTRPCRouter({
startDate: z.string().nullish(), startDate: z.string().nullish(),
endDate: z.string().nullish(), endDate: z.string().nullish(),
range: zRange, range: zRange,
shareId: z.string().optional(),
}), }),
) )
.use(cacher) .use(cacher)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { current, previous } = await getCurrentAndPrevious( const { current, previous } = await getCurrentAndPrevious(
{ ...input, timezone }, { ...input, timezone },
@@ -258,10 +330,32 @@ export const overviewRouter = createTRPCRouter({
endDate: z.string().nullish(), endDate: z.string().nullish(),
range: zRange, range: zRange,
mode: z.enum(['page', 'entry', 'exit', 'bot']), mode: z.enum(['page', 'entry', 'exit', 'bot']),
shareId: z.string().optional(),
}), }),
) )
.use(cacher) .use(cacher)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious( const { current } = await getCurrentAndPrevious(
{ ...input }, { ...input },
@@ -292,10 +386,34 @@ export const overviewRouter = createTRPCRouter({
startDate: z.string().nullish(), startDate: z.string().nullish(),
endDate: z.string().nullish(), endDate: z.string().nullish(),
range: zRange, range: zRange,
shareId: z.string().optional(),
}), }),
) )
.use(cacher) .use(cacher)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
console.log('input', input);
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious( const { current } = await getCurrentAndPrevious(
{ ...input, timezone }, { ...input, timezone },
@@ -308,14 +426,38 @@ export const overviewRouter = createTRPCRouter({
topGenericSeries: publicProcedure topGenericSeries: publicProcedure
.input( .input(
zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({ zGetTopGenericSeriesInput
startDate: z.string().nullish(), .omit({ startDate: true, endDate: true })
endDate: z.string().nullish(), .extend({
range: zRange, startDate: z.string().nullish(),
}), endDate: z.string().nullish(),
range: zRange,
shareId: z.string().optional(),
}),
) )
.use(cacher) .use(cacher)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious( const { current } = await getCurrentAndPrevious(
{ ...input, timezone }, { ...input, timezone },
@@ -333,10 +475,32 @@ export const overviewRouter = createTRPCRouter({
endDate: z.string().nullish(), endDate: z.string().nullish(),
range: zRange, range: zRange,
steps: z.number().min(2).max(10).default(5).optional(), steps: z.number().min(2).max(10).default(5).optional(),
shareId: z.string().optional(),
}), }),
) )
.use(cacher) .use(cacher)
.query(async ({ input }) => { .query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious( const { current } = await getCurrentAndPrevious(
{ ...input, timezone }, { ...input, timezone },
@@ -350,6 +514,168 @@ export const overviewRouter = createTRPCRouter({
}); });
}); });
return current;
}),
topEvents: publicProcedure
.input(
zGetTopEventsInput.omit({ startDate: true, endDate: true }).extend({
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange,
shareId: z.string().optional(),
}),
)
.use(cacher)
.query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
{ ...input, timezone },
false,
timezone,
)(overviewService.getTopEvents.bind(overviewService));
return current;
}),
topConversions: publicProcedure
.input(
z.object({
projectId: z.string(),
shareId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
return getConversionEventNames(input.projectId);
}),
topLinkOut: publicProcedure
.input(
zGetTopLinkOutInput.omit({ startDate: true, endDate: true }).extend({
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange,
shareId: z.string().optional(),
}),
)
.use(cacher)
.query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
{ ...input, timezone },
false,
timezone,
)(overviewService.getTopLinkOut.bind(overviewService));
return current;
}),
map: publicProcedure
.input(
zGetMapDataInput.omit({ startDate: true, endDate: true }).extend({
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange,
shareId: z.string().optional(),
}),
)
.use(cacher)
.query(async ({ input, ctx }) => {
// Validate share access if shareId provided
if (input.shareId) {
await validateOverviewShareAccess(input.shareId, input.projectId, {
cookies: ctx.cookies,
session: ctx.session?.userId
? { userId: ctx.session.userId }
: undefined,
});
} else {
// Regular member access check
if (!ctx.session?.userId) {
throw TRPCAccessError('Authentication required');
}
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
{ ...input, timezone },
false,
timezone,
)(overviewService.getMapData.bind(overviewService));
return current; return current;
}), }),
}); });