feat(dashboard): added new Pages

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-09-04 11:19:10 +02:00
parent 4e5adbedff
commit 7e941080dc
26 changed files with 635 additions and 132 deletions

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-portal": "^1.1.1",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",

View File

@@ -17,7 +17,7 @@ const ListDashboardsServer = async ({ projectId }: Props) => {
return (
<Padding>
<HeaderDashboards />
<ListDashboards dashboards={dashboards} />;
<ListDashboards dashboards={dashboards} />
</Padding>
);
};

View File

@@ -9,6 +9,7 @@ import { useUser } from '@clerk/nextjs';
import {
GanttChartIcon,
Globe2Icon,
LayersIcon,
LayoutPanelTopIcon,
PlusIcon,
ScanEyeIcon,
@@ -89,6 +90,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
label="Dashboards"
href={`/${params.organizationSlug}/${projectId}/dashboards`}
/>
<LinkWithIcon
icon={LayersIcon}
label="Pages"
href={`/${params.organizationSlug}/${projectId}/pages`}
/>
<LinkWithIcon
icon={Globe2Icon}
label="Realtime"

View File

@@ -0,0 +1,34 @@
import { PageTabs, PageTabsLink } from '@/components/page-tabs';
import { Padding } from '@/components/ui/padding';
import { parseAsStringEnum } from 'nuqs';
import { Pages } from './pages';
interface PageProps {
params: {
projectId: string;
};
searchParams: {
tab: string;
};
}
export default function Page({
params: { projectId },
searchParams,
}: PageProps) {
const tab = parseAsStringEnum(['pages', 'trends'])
.withDefault('pages')
.parseServerSide(searchParams.tab);
return (
<Padding>
<PageTabs className="mb-4">
<PageTabsLink href="?tab=pages" isActive={tab === 'pages'}>
Pages
</PageTabsLink>
</PageTabs>
{tab === 'pages' && <Pages projectId={projectId} />}
</Padding>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon } from 'lucide-react';
import type { IServicePage } from '@openpanel/db';
export function PagesTable({ data }: { data: IServicePage[] }) {
const number = useNumber();
const cell =
'flex min-h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border';
return (
<div className="overflow-x-auto rounded-md border bg-background">
<div className={cn('min-w-[800px]')}>
<div className="grid grid-cols-[0.2fr_auto_1fr] overflow-hidden rounded-t-none border-b">
<div className="center-center h-10 rounded-tl-md bg-def-100 p-4 font-semibold text-muted-foreground">
Views
</div>
<div className="flex h-10 w-80 items-center bg-def-100 p-4 font-semibold text-muted-foreground">
Path
</div>
<div className="flex h-10 items-center rounded-tr-md bg-def-100 p-4 font-semibold text-muted-foreground">
Chart
</div>
</div>
{data.map((item, index) => {
return (
<div
key={item.path + item.origin + item.title}
className="grid grid-cols-[0.2fr_auto_1fr] border-b transition-colors last:border-b-0 hover:bg-muted/50 data-[state=selected]:bg-muted"
>
<div
className={cn(
cell,
'center-center font-mono text-lg font-semibold',
index === data.length - 1 && 'rounded-bl-md'
)}
>
{number.short(item.count)}
</div>
<div
className={cn(
cell,
'flex w-80 flex-col justify-center gap-2 text-left'
)}
>
<span className="truncate font-medium">{item.title}</span>
{item.origin ? (
<a
href={item.origin + item.path}
className="truncate font-mono text-sm text-muted-foreground underline"
>
<ExternalLinkIcon className="mr-2 inline-block size-3" />
{item.path}
</a>
) : (
<span className="truncate font-mono text-sm text-muted-foreground">
{item.path}
</span>
)}
</div>
<div
className={cn(
cell,
'p-1',
index === data.length - 1 && 'rounded-br-md'
)}
>
<LazyChart
hideYAxis
hideXAxis
className="w-full"
lineType="linear"
breakdowns={[]}
name="screen_view"
metric="sum"
range="30d"
interval="day"
previous
aspectRatio={0.15}
chartType="linear"
projectId={item.project_id}
events={[
{
id: 'A',
name: 'screen_view',
segment: 'event',
filters: [
{
id: 'path',
name: 'path',
value: [item.path],
operator: 'is',
},
{
id: 'origin',
name: 'origin',
value: [item.origin],
operator: 'is',
},
],
},
]}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { TableButtons } from '@/components/data-table';
import { Pagination } from '@/components/pagination';
import { Input } from '@/components/ui/input';
import { TableSkeleton } from '@/components/ui/table';
import { useDebounceValue } from '@/hooks/useDebounceValue';
import { api } from '@/trpc/client';
import { Loader2Icon } from 'lucide-react';
import { parseAsInteger, useQueryState } from 'nuqs';
import { PagesTable } from './pages-table';
export function Pages({ projectId }: { projectId: string }) {
const take = 20;
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(0)
);
const [search, setSearch] = useQueryState('search', {
defaultValue: '',
shallow: true,
});
const debouncedSearch = useDebounceValue(search, 500);
const query = api.event.pages.useQuery(
{
projectId,
cursor,
take,
search: debouncedSearch,
},
{
keepPreviousData: true,
}
);
const data = query.data ?? [];
return (
<>
<TableButtons>
<Input
placeholder="Serch path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(0);
}}
/>
</TableButtons>
{query.isLoading ? (
<TableSkeleton cols={3} />
) : (
<PagesTable data={data} />
)}
<Pagination
className="mt-2"
setCursor={setCursor}
cursor={cursor}
count={Infinity}
take={take}
loading={query.isFetching}
/>
</>
);
}

View File

@@ -3,8 +3,10 @@ import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
import { column } from 'mathjs';
import type { IServiceEvent } from '@openpanel/db';
@@ -25,15 +27,7 @@ export const EventsTable = ({ query, ...props }: Props) => {
const { data, isFetching, isLoading } = query;
if (isLoading) {
return (
<div className="flex flex-col gap-2">
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
</div>
);
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {

View File

@@ -60,7 +60,7 @@ export const GridCell: React.FC<
}) => (
<Component
className={cn(
'flex h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
'flex min-h-12 items-center whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
isHeader && 'h-10 bg-def-100 font-semibold text-muted-foreground',
colSpan && `col-span-${colSpan}`,
className

View File

@@ -9,7 +9,7 @@ export function PageTabs({
className?: string;
}) {
return (
<div className={cn('overflow-x-auto', className)}>
<div className={cn('h-7 overflow-x-auto', className)}>
<div className="flex gap-4 whitespace-nowrap text-3xl font-semibold">
{children}
</div>

View File

@@ -3,6 +3,7 @@ import { DataTable } from '@/components/data-table';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { Button } from '@/components/ui/button';
import { TableSkeleton } from '@/components/ui/table';
import type { UseQueryResult } from '@tanstack/react-query';
import { GanttChartIcon } from 'lucide-react';
@@ -26,15 +27,7 @@ export const ProfilesTable = ({ type, query, ...props }: Props) => {
const { data, isFetching, isLoading } = query;
if (isLoading) {
return (
<div className="flex flex-col gap-2">
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
<div className="card h-[74px] w-full animate-pulse items-center justify-between rounded-lg p-4"></div>
</div>
);
return <TableSkeleton cols={columns.length} />;
}
if (data?.length === 0) {

View File

@@ -1,8 +1,8 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCardEmpty } from './MetricCard';
import { ResponsiveContainer } from './ResponsiveContainer';
export function ChartEmpty() {
const { editMode, chartType } = useChartContext();
@@ -20,12 +20,10 @@ export function ChartEmpty() {
}
return (
<div
className={
'flex aspect-video max-h-[300px] min-h-[200px] w-full items-center justify-center'
}
>
No data
</div>
<ResponsiveContainer>
<div className={'flex h-full w-full items-center justify-center'}>
No data
</div>
</ResponsiveContainer>
);
}

View File

@@ -1,15 +1,20 @@
import { cn } from '@/utils/cn';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ChartLoadingProps {
className?: string;
aspectRatio?: number;
}
export function ChartLoading({ className }: ChartLoadingProps) {
export function ChartLoading({ className, aspectRatio }: ChartLoadingProps) {
return (
<div
className={cn(
'bg-def-200 aspect-video max-h-[300px] min-h-[200px] w-full animate-pulse rounded',
className
)}
/>
<ResponsiveContainer aspectRatio={aspectRatio}>
<div
className={cn(
'h-full w-full animate-pulse rounded bg-def-200',
className
)}
/>
</ResponsiveContainer>
);
}

View File

@@ -6,6 +6,9 @@ import type { LucideIcon } from 'lucide-react';
import type { IChartProps, IChartSerie } from '@openpanel/validation';
export interface IChartContextType extends IChartProps {
hideXAxis?: boolean;
hideYAxis?: boolean;
aspectRatio?: number;
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;

View File

@@ -1,13 +1,17 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useInViewport } from 'react-in-viewport';
import type { IChartRoot } from '.';
import { ChartRoot } from '.';
import { ChartLoading } from './ChartLoading';
export function LazyChart(props: IChartRoot) {
export function LazyChart({
className,
...props
}: IChartRoot & { className?: string }) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
@@ -21,11 +25,11 @@ export function LazyChart(props: IChartRoot) {
}, [inViewport]);
return (
<div ref={ref}>
<div ref={ref} className={cn('w-full', className)}>
{once.current || inViewport ? (
<ChartRoot {...props} editMode={false} />
) : (
<ChartLoading />
<ChartLoading aspectRatio={props.aspectRatio} />
)}
</div>
);

View File

@@ -1,9 +1,11 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
@@ -24,11 +26,35 @@ export function ReportChartTooltip({
const { unit, interval } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!active || !payload) {
return null;
}
const [position, setPosition] = useState<{ x: number; y: number } | null>(
null
);
if (!payload.length) {
const inactive = !active || !payload?.length;
useEffect(() => {
const setPositionThrottled = throttle(setPosition, 50);
const unsubMouseMove = bind(window, {
type: 'mousemove',
listener(event) {
if (!inactive) {
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
}
},
});
const unsubDragEnter = bind(window, {
type: 'pointerdown',
listener() {
setPosition(null);
},
});
return () => {
unsubMouseMove();
unsubDragEnter();
};
}, [inactive]);
if (inactive) {
return null;
}
@@ -41,55 +67,81 @@ export function ReportChartTooltip({
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const correctXPosition = (x: number | undefined) => {
if (!x) {
return undefined;
}
const tooltipWidth = 300;
const screenWidth = window.innerWidth;
const newX = x;
if (newX + tooltipWidth > screenWidth) {
return screenWidth - tooltipWidth;
}
return newX;
};
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
<Portal.Portal
style={{
position: 'fixed',
top: position?.y,
left: correctXPosition(position?.x),
zIndex: 1000,
}}
>
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
return (
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
<div className="font-mono flex justify-between gap-8 font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count, unit)}
{!!data.previous && (
<span className="text-muted-foreground">
({number.formatWithUnit(data.previous.value, unit)})
</span>
)}
</div>
<PreviousDiffIndicator {...data.previous} />
<PreviousDiffIndicator {...data.previous} />
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">
and {hidden.length} more...
</div>
)}
</div>
</Portal.Portal>
);
}

View File

@@ -6,10 +6,9 @@ import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { useTheme } from 'next-themes';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
@@ -21,7 +20,11 @@ interface ReportHistogramChartProps {
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const bg = theme?.colors?.slate?.['200'] as string;
const themeMode = useTheme();
const bg =
themeMode?.theme === 'dark'
? theme.colors['def-100']
: theme.colors['def-300'];
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
@@ -33,7 +36,7 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
}
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
const { editMode, previous, interval } = useChartContext();
const { editMode, previous, interval, aspectRatio } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
@@ -41,10 +44,15 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
return (
<>
<div className={cn(editMode && 'card p-4')}>
<ResponsiveContainer>
<div className={cn('w-full', editMode && 'card p-4')}>
<ResponsiveContainer aspectRatio={aspectRatio}>
{({ width, height }) => (
<BarChart width={width} height={height} data={rechartData}>
<BarChart
width={width}
height={height}
data={rechartData}
barCategoryGap={10}
>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
@@ -70,22 +78,45 @@ export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
{series.map((serie) => {
return (
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id="colorGradient"
x1="0"
y1="1"
x2="0"
y2="0"
>
<stop
offset="0%"
stopColor={getChartColor(serie.index)}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={getChartColor(serie.index)}
stopOpacity={1}
/>
</linearGradient>
</defs>
{previous && (
<Bar
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
fillOpacity={0.1}
radius={3}
barSize={20} // Adjust the bar width here
/>
)}
<Bar
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
fill="url(#colorGradient)"
radius={3}
fillOpacity={1}
barSize={20} // Adjust the bar width here
/>
</React.Fragment>
);

View File

@@ -45,7 +45,11 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
endDate,
range,
lineType,
aspectRatio,
hideXAxis,
hideYAxis,
} = useChartContext();
const dataLength = data.series[0]?.data?.length || 0;
const references = api.reference.getChartReferences.useQuery(
{
projectId,
@@ -120,11 +124,10 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
}, [series]);
const isAreaStyle = series.length === 1;
return (
<>
<div className={cn(editMode && 'card p-4')}>
<ResponsiveContainer>
<div className={cn('w-full', editMode && 'card p-4')}>
<ResponsiveContainer aspectRatio={aspectRatio}>
{({ width, height }) => (
<ComposedChart width={width} height={height} data={rechartData}>
<CartesianGrid
@@ -149,7 +152,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
/>
))}
<YAxis
width={getYAxisWidth(data.metrics.max)}
width={hideYAxis ? 0 : getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
@@ -164,6 +167,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
)}
<Tooltip content={<ReportChartTooltip />} />
<XAxis
height={hideXAxis ? 0 : undefined}
axisLine={false}
fontSize={12}
dataKey="timestamp"
@@ -212,7 +216,7 @@ export function ReportLineChart({ data }: ReportLineChartProps) {
)}
</defs>
<Line
dot={isAreaStyle}
dot={isAreaStyle && dataLength <= 8}
type={lineType}
name={serie.id}
isAnimationActive={false}

View File

@@ -1,28 +1,45 @@
'use client';
import AutoSizer from 'react-virtualized-auto-sizer';
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
interface ResponsiveContainerProps {
children: (props: { width: number; height: number }) => React.ReactNode;
aspectRatio?: number;
children:
| ((props: { width: number; height: number }) => React.ReactNode)
| React.ReactNode;
}
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
export function ResponsiveContainer({
children,
aspectRatio = 0.5625,
}: ResponsiveContainerProps) {
const maxHeight = 300;
const minHeight = 200;
return (
<div
className="w-full"
style={{
aspectRatio: 1 / (aspectRatio || DEFAULT_ASPECT_RATIO),
maxHeight,
minHeight,
}}
className={'aspect-video w-full max-sm:-mx-3'}
>
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(maxHeight, width * 0.5625),
})
}
</AutoSizer>
{typeof children === 'function' ? (
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(
maxHeight,
width * aspectRatio || DEFAULT_ASPECT_RATIO
),
})
}
</AutoSizer>
) : (
children
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import * as Portal from '@radix-ui/react-portal';
import type { IChartProps } from '@openpanel/validation';
@@ -24,14 +25,18 @@ export function ChartRoot(props: IChartContextType) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
<ChartLoading aspectRatio={props.aspectRatio} />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? <MetricCardLoading /> : <ChartLoading />
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading aspectRatio={props.aspectRatio} />
)
}
>
<ChartProvider {...props}>
@@ -49,9 +54,13 @@ interface ChartRootShortcutProps {
interval?: IChartProps['interval'];
events: IChartProps['events'];
breakdowns?: IChartProps['breakdowns'];
lineType?: IChartProps['lineType'];
hideXAxis?: boolean;
aspectRatio?: number;
}
export const ChartRootShortcut = ({
hideXAxis,
projectId,
range = '7d',
previous = false,
@@ -59,19 +68,25 @@ export const ChartRootShortcut = ({
interval = 'day',
events,
breakdowns,
aspectRatio,
lineType = 'monotone',
}: ChartRootShortcutProps) => {
return (
<ChartRoot
projectId={projectId}
range={range}
breakdowns={breakdowns ?? []}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType="bump"
metric="sum"
events={events}
/>
<Portal.Root>
<ChartRoot
projectId={projectId}
range={range}
breakdowns={breakdowns ?? []}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType={lineType}
metric="sum"
events={events}
aspectRatio={aspectRatio}
hideXAxis={hideXAxis}
/>
</Portal.Root>
);
};

View File

@@ -110,6 +110,39 @@ const TableCaption = React.forwardRef<
));
TableCaption.displayName = 'TableCaption';
export function TableSkeleton({
rows = 10,
cols = 2,
}: {
rows?: number;
cols?: number;
}) {
return (
<Table>
<TableHeader>
<TableRow>
{Array.from({ length: cols }).map((_, j) => (
<TableHead key={j}>
<div className="h-4 w-1/4 animate-pulse rounded-full bg-def-300" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: cols }).map((_, j) => (
<TableCell key={j}>
<div className="h-4 w-1/4 min-w-20 animate-pulse rounded-full bg-def-300" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
);
}
export {
Table,
TableHeader,

View File

@@ -37,7 +37,7 @@ export function WidgetTitle({
)}
>
{Icon && (
<div className="bg-def-200 absolute left-0 rounded-lg p-2">
<div className="absolute left-0 rounded-lg bg-def-200 p-2">
<Icon size={18} />
</div>
)}

View File

@@ -1,5 +1,6 @@
import { isSameDay, isSameMonth } from 'date-fns';
export const DEFAULT_ASPECT_RATIO = 0.5625;
export const NOT_SET_VALUE = '(not set)';
export const timeWindows = {

View File

@@ -28,6 +28,15 @@ export type IImportedEvent = Omit<
properties: Record<string, unknown>;
};
export type IServicePage = {
path: string;
count: number;
project_id: string;
first_seen: string;
title: string;
origin: string;
};
export interface IClickhouseEvent {
id: string;
name: string;
@@ -493,3 +502,30 @@ export async function getLastScreenViewFromProfileId({
return eventInDb || null;
}
export async function getTopPages({
projectId,
cursor,
take,
search,
}: {
projectId: string;
cursor?: number;
take: number;
search?: string;
}) {
const res = await chQuery<IServicePage>(`
SELECT path, count(*) as count, project_id, first_value(created_at) as first_seen, max(properties['__title']) as title, origin
FROM events_v2
WHERE name = 'screen_view'
AND project_id = ${escape(projectId)}
AND created_at > now() - INTERVAL 30 DAY
${search ? `AND path LIKE '%${search}%'` : ''}
GROUP BY path, project_id, origin
ORDER BY count desc
LIMIT ${take}
OFFSET ${Math.max(0, (cursor ?? 0) * take)}
`);
return res;
}

View File

@@ -8,6 +8,7 @@ import {
db,
getEventList,
getEvents,
getTopPages,
TABLE_NAMES,
} from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
@@ -69,7 +70,6 @@ export const eventRouter = createTRPCRouter({
z.object({
projectId: z.string(),
cursor: z.number().optional(),
limit: z.number().default(8),
profileId: z.string().optional(),
take: z.number().default(50),
events: z.array(z.string()).optional(),
@@ -165,4 +165,17 @@ export const eventRouter = createTRPCRouter({
count: counts[0]?.count ?? 0,
};
}),
pages: protectedProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.number().optional(),
take: z.number().default(20),
search: z.string().optional(),
})
)
.query(async ({ input }) => {
return getTopPages(input);
}),
});

View File

@@ -5,7 +5,7 @@ import { z } from 'zod';
import { db } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
import { getOrganizationAccess, getOrganizationAccessCached } from '../access';
import { getOrganizationAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';

84
pnpm-lock.yaml generated
View File

@@ -233,6 +233,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-portal':
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
@@ -5574,6 +5577,19 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-compose-refs@1.1.0(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/react-context@1.0.0(react@18.2.0):
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
peerDependencies:
@@ -5982,6 +5998,27 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-portal@1.1.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
peerDependencies:
@@ -6050,6 +6087,26 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-primitive@2.0.0(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@radix-ui/react-slot': 1.1.0(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==}
peerDependencies:
@@ -6185,6 +6242,20 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-slot@1.1.0(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@radix-ui/react-compose-refs': 1.1.0(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==}
peerDependencies:
@@ -6450,6 +6521,19 @@ packages:
react: 18.2.0
dev: false
/@radix-ui/react-use-layout-effect@1.1.0(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.56
react: 18.2.0
dev: false
/@radix-ui/react-use-previous@1.0.1(@types/react@18.2.56)(react@18.2.0):
resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
peerDependencies: