feat: insights

* fix: migration for newly created self-hosting instances

* fix: build script

* wip

* wip

* wip

* fix: tailwind css
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-12-19 09:37:15 +01:00
committed by GitHub
parent 1e4f02fb5e
commit 5f38560373
48 changed files with 4072 additions and 25 deletions

View File

@@ -0,0 +1,229 @@
import { countries } from '@/translations/countries';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { InsightPayload } from '@openpanel/validation';
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
import { last } from 'ramda';
import { useState } from 'react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Badge } from '../ui/badge';
function formatWindowKind(windowKind: string): string {
switch (windowKind) {
case 'yesterday':
return 'Yesterday';
case 'rolling_7d':
return '7 Days';
case 'rolling_30d':
return '30 Days';
}
return windowKind;
}
interface InsightCardProps {
insight: RouterOutputs['insight']['list'][number];
className?: string;
onFilter?: () => void;
}
export function InsightCard({
insight,
className,
onFilter,
}: InsightCardProps) {
const payload = insight.payload;
const dimensions = payload?.dimensions;
const availableMetrics = Object.entries(payload?.metrics ?? {});
// Pick what to display: prefer share if available (geo/devices), else primaryMetric
const [metricIndex, setMetricIndex] = useState(
availableMetrics.findIndex(([key]) => key === payload?.primaryMetric),
);
const currentMetricKey = availableMetrics[metricIndex][0];
const currentMetricEntry = availableMetrics[metricIndex][1];
const metricUnit = currentMetricEntry?.unit;
const currentValue = currentMetricEntry?.current ?? null;
const compareValue = currentMetricEntry?.compare ?? null;
const direction = currentMetricEntry?.direction ?? 'flat';
const isIncrease = direction === 'up';
const isDecrease = direction === 'down';
const deltaText =
metricUnit === 'ratio'
? `${Math.abs((currentMetricEntry?.delta ?? 0) * 100).toFixed(1)}pp`
: `${Math.abs((currentMetricEntry?.changePct ?? 0) * 100).toFixed(1)}%`;
// Format metric values
const formatValue = (value: number | null): string => {
if (value == null) return '-';
if (metricUnit === 'ratio') return `${(value * 100).toFixed(1)}%`;
return Math.round(value).toLocaleString();
};
// Get the metric label
const metricKeyToLabel = (key: string) =>
key === 'share' ? 'Share' : key === 'pageviews' ? 'Pageviews' : 'Sessions';
const metricLabel = metricKeyToLabel(currentMetricKey);
const renderTitle = () => {
if (
dimensions[0]?.key === 'country' ||
dimensions[0]?.key === 'referrer_name' ||
dimensions[0]?.key === 'device'
) {
return (
<span className="capitalize flex items-center gap-2">
<SerieIcon name={dimensions[0]?.value} /> {insight.displayName}
</span>
);
}
if (insight.displayName.startsWith('http')) {
return (
<span className="flex items-center gap-2">
<SerieIcon
name={dimensions[0]?.displayName ?? dimensions[0]?.value}
/>
<span className="line-clamp-2">{dimensions[1]?.displayName}</span>
</span>
);
}
return insight.displayName;
};
return (
<div
className={cn(
'card p-4 h-full flex flex-col hover:bg-def-50 transition-colors group/card',
className,
)}
>
<div
className={cn(
'row justify-between h-4 items-center',
onFilter && 'group-hover/card:hidden',
)}
>
<Badge variant="outline" className="-ml-2">
{formatWindowKind(insight.windowKind)}
</Badge>
{/* Severity: subtle dot instead of big pill */}
{insight.severityBand && (
<div className="flex items-center gap-1 shrink-0">
<span
className={cn(
'h-2 w-2 rounded-full',
insight.severityBand === 'severe'
? 'bg-red-500'
: insight.severityBand === 'moderate'
? 'bg-yellow-500'
: 'bg-blue-500',
)}
/>
<span className="text-[11px] text-muted-foreground capitalize">
{insight.severityBand}
</span>
</div>
)}
</div>
{onFilter && (
<div className="row group-hover/card:flex hidden h-4 justify-between gap-2">
{availableMetrics.length > 1 ? (
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={() =>
setMetricIndex((metricIndex + 1) % availableMetrics.length)
}
>
<RotateCcwIcon className="size-2" />
Show{' '}
{metricKeyToLabel(
availableMetrics[
(metricIndex + 1) % availableMetrics.length
][0],
)}
</button>
) : (
<div />
)}
<button
type="button"
className="text-[11px] text-muted-foreground capitalize flex items-center gap-1"
onClick={onFilter}
>
Filter <FilterIcon className="size-2" />
</button>
</div>
)}
<div className="font-semibold text-sm leading-snug line-clamp-2 mt-2">
{renderTitle()}
</div>
{/* Metric row */}
<div className="mt-auto pt-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] text-muted-foreground mb-1">
{metricLabel}
</div>
<div className="col gap-1">
<div className="text-2xl font-semibold tracking-tight">
{formatValue(currentValue)}
</div>
{/* Inline compare, smaller */}
{compareValue != null && (
<div className="text-xs text-muted-foreground">
vs {formatValue(compareValue)}
</div>
)}
</div>
</div>
{/* Delta chip */}
<DeltaChip
isIncrease={isIncrease}
isDecrease={isDecrease}
deltaText={deltaText}
/>
</div>
</div>
</div>
);
}
function DeltaChip({
isIncrease,
isDecrease,
deltaText,
}: {
isIncrease: boolean;
isDecrease: boolean;
deltaText: string;
}) {
return (
<div
className={cn(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
isIncrease
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: isDecrease
? 'bg-red-500/10 text-red-600 dark:text-red-400'
: 'bg-muted text-muted-foreground',
)}
>
{isIncrease ? (
<ArrowUp size={16} className="shrink-0" />
) : isDecrease ? (
<ArrowDown size={16} className="shrink-0" />
) : null}
<span>{deltaText}</span>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { InsightCard } from '../insights/insight-card';
import { Skeleton } from '../skeleton';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '../ui/carousel';
interface OverviewInsightsProps {
projectId: string;
}
export default function OverviewInsights({ projectId }: OverviewInsightsProps) {
const trpc = useTRPC();
const [filters, setFilter] = useEventQueryFilters();
const { data: insights, isLoading } = useQuery(
trpc.insight.list.queryOptions({
projectId,
limit: 20,
}),
);
if (isLoading) {
const keys = Array.from({ length: 4 }, (_, i) => `insight-skeleton-${i}`);
return (
<div className="col-span-6">
<Carousel opts={{ align: 'start' }} className="w-full">
<CarouselContent className="-ml-4">
{keys.map((key) => (
<CarouselItem
key={key}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<Skeleton className="h-36 w-full" />
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
);
}
if (!insights || insights.length === 0) return null;
return (
<div className="col-span-6 -mx-4">
<Carousel opts={{ align: 'start' }} className="w-full group">
<CarouselContent className="mr-4">
{insights.map((insight) => (
<CarouselItem
key={insight.id}
className="pl-4 basis-full sm:basis-1/2 lg:basis-1/4"
>
<InsightCard
insight={insight}
onFilter={() => {
insight.payload.dimensions.forEach((dim) => {
void setFilter(dim.key, dim.value, 'is');
});
}}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
<CarouselNext className="!opacity-0 pointer-events-none transition-opacity group-hover:!opacity-100 group-hover:pointer-events-auto group-focus:opacity-100 group-focus:pointer-events-auto" />
</Carousel>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import {
LayoutPanelTopIcon,
PlusIcon,
SparklesIcon,
TrendingUpDownIcon,
UndoDotIcon,
UsersIcon,
WallpaperIcon,
@@ -39,13 +40,18 @@ export default function SidebarProjectMenu({
}: SidebarProjectMenuProps) {
return (
<>
<div className="mb-2 font-medium text-muted-foreground">Insights</div>
<div className="mb-2 font-medium text-muted-foreground">Analytics</div>
<SidebarLink icon={WallpaperIcon} label="Overview" href={'/'} />
<SidebarLink
icon={LayoutPanelTopIcon}
label="Dashboards"
href={'/dashboards'}
/>
<SidebarLink
icon={TrendingUpDownIcon}
label="Insights"
href={'/insights'}
/>
<SidebarLink icon={LayersIcon} label="Pages" href={'/pages'} />
<SidebarLink icon={Globe2Icon} label="Realtime" href={'/realtime'} />
<SidebarLink icon={GanttChartIcon} label="Events" href={'/events'} />

View File

@@ -123,7 +123,7 @@ export function SidebarContainer({
</div>
<div
className={cn([
'flex flex-grow col gap-1 overflow-auto p-4',
'flex flex-grow col gap-1 overflow-auto p-4 hide-scrollbar',
"[&_a[data-status='active']]:bg-def-200",
])}
>

View File

@@ -208,7 +208,7 @@ const CarouselPrevious = React.forwardRef<
variant={variant}
size={size}
className={cn(
'absolute h-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
'absolute h-10 w-10 rounded-full hover:scale-100 hover:translate-y-[-50%] transition-all duration-200',
orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',