This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-09 14:20:15 +01:00
parent 0f9e5f6e93
commit df0258f532
12 changed files with 186 additions and 76 deletions

View File

@@ -4,6 +4,22 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod'; import { z } from 'zod';
import { LogError } from '@/utils/errors'; import { LogError } from '@/utils/errors';
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
function sanitizeOAuthQuery(
query: Record<string, unknown> | null | undefined
): Record<string, string> {
if (!query || typeof query !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(query).map(([k, v]) => [
k,
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
])
);
}
export async function gscGoogleCallback( export async function gscGoogleCallback(
req: FastifyRequest, req: FastifyRequest,
reply: FastifyReply reply: FastifyReply
@@ -16,10 +32,10 @@ export async function gscGoogleCallback(
const query = schema.safeParse(req.query); const query = schema.safeParse(req.query);
if (!query.success) { if (!query.success) {
throw new LogError('Invalid GSC callback query params', { throw new LogError(
error: query.error, 'Invalid GSC callback query params',
query: req.query, sanitizeOAuthQuery(req.query as Record<string, unknown>)
}); );
} }
const { code, state } = query.data; const { code, state } = query.data;
@@ -27,16 +43,24 @@ export async function gscGoogleCallback(
const codeVerifier = req.cookies.gsc_code_verifier ?? null; const codeVerifier = req.cookies.gsc_code_verifier ?? null;
const projectId = req.cookies.gsc_project_id ?? null; const projectId = req.cookies.gsc_project_id ?? null;
if (!storedState || !codeVerifier || !projectId) { const hasStoredState = storedState !== null;
const hasCodeVerifier = codeVerifier !== null;
const hasProjectId = projectId !== null;
const hasAllCookies = hasStoredState && hasCodeVerifier && hasProjectId;
if (!hasAllCookies) {
throw new LogError('Missing GSC OAuth cookies', { throw new LogError('Missing GSC OAuth cookies', {
storedState: storedState === null, storedState: !hasStoredState,
codeVerifier: codeVerifier === null, codeVerifier: !hasCodeVerifier,
projectId: projectId === null, projectId: !hasProjectId,
}); });
} }
if (state !== storedState) { if (state !== storedState) {
throw new LogError('GSC OAuth state mismatch', { state, storedState }); throw new LogError('GSC OAuth state mismatch', {
hasState: true,
hasStoredState: true,
stateMismatch: true,
});
} }
const tokens = await googleGsc.validateAuthorizationCode( const tokens = await googleGsc.validateAuthorizationCode(
@@ -91,6 +115,9 @@ export async function gscGoogleCallback(
return reply.redirect(redirectUrl); return reply.redirect(redirectUrl);
} catch (error) { } catch (error) {
req.log.error(error); req.log.error(error);
reply.clearCookie('gsc_oauth_state');
reply.clearCookie('gsc_code_verifier');
reply.clearCookie('gsc_project_id');
return redirectWithError(reply, error); return redirectWithError(reply, error);
} }
} }

View File

@@ -43,6 +43,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
const breakdownKey = type === 'page' ? 'query' : 'page'; const breakdownKey = type === 'page' ? 'query' : 'page';
const breakdownLabel = type === 'page' ? 'Query' : 'Page'; const breakdownLabel = type === 'page' ? 'Query' : 'Page';
const pluralLabel = type === 'page' ? 'queries' : 'pages';
const maxClicks = Math.max( const maxClicks = Math.max(
...(breakdownRows as { clicks: number }[]).map((r) => r.clicks), ...(breakdownRows as { clicks: number }[]).map((r) => r.clicks),
@@ -52,7 +53,7 @@ export function GscBreakdownTable({ projectId, value, type }: GscBreakdownTableP
return ( return (
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="border-b p-4"> <div className="border-b p-4">
<h3 className="font-medium text-sm">Top {breakdownLabel.toLowerCase()}s</h3> <h3 className="font-medium text-sm">Top {pluralLabel}</h3>
</div> </div>
{isLoading ? ( {isLoading ? (
<OverviewWidgetTable <OverviewWidgetTable

View File

@@ -1,6 +1,7 @@
import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react'; import { AlertCircleIcon, ChevronsUpDownIcon } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Pagination } from '@/components/pagination'; import { Pagination } from '@/components/pagination';
import { useAppContext } from '@/hooks/use-app-context'; import { useAppContext } from '@/hooks/use-app-context';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
@@ -9,8 +10,8 @@ import { cn } from '@/utils/cn';
interface GscCannibalizationProps { interface GscCannibalizationProps {
projectId: string; projectId: string;
range: string; range: IChartRange;
interval: string; interval: IInterval;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
} }
@@ -30,7 +31,13 @@ export function GscCannibalization({
const query = useQuery( const query = useQuery(
trpc.gsc.getCannibalization.queryOptions( trpc.gsc.getCannibalization.queryOptions(
{ projectId, range: range as any, interval: interval as any }, {
projectId,
range,
interval,
startDate,
endDate,
},
{ placeholderData: keepPreviousData } { placeholderData: keepPreviousData }
) )
); );
@@ -50,6 +57,9 @@ export function GscCannibalization({
const items = query.data ?? []; const items = query.data ?? [];
const pageCount = Math.ceil(items.length / pageSize) || 1; const pageCount = Math.ceil(items.length / pageSize) || 1;
useEffect(() => {
setPage((p) => Math.max(0, Math.min(p, pageCount - 1)));
}, [items, pageSize, pageCount]);
const paginatedItems = useMemo( const paginatedItems = useMemo(
() => items.slice(page * pageSize, (page + 1) * pageSize), () => items.slice(page * pageSize, (page + 1) * pageSize),
[items, page, pageSize] [items, page, pageSize]
@@ -99,7 +109,6 @@ export function GscCannibalization({
))} ))}
{paginatedItems.map((item) => { {paginatedItems.map((item) => {
const isOpen = expanded.has(item.query); const isOpen = expanded.has(item.query);
const winner = item.pages[0];
const avgCtr = const avgCtr =
item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length; item.pages.reduce((s, p) => s + p.ctr, 0) / item.pages.length;

View File

@@ -8,7 +8,7 @@ interface SparklineBarsProps {
data: { date: string; pageviews: number }[]; data: { date: string; pageviews: number }[];
} }
const gap = 1; const defaultGap = 1;
const height = 24; const height = 24;
const width = 100; const width = 100;
@@ -38,7 +38,16 @@ function SparklineBars({ data }: SparklineBarsProps) {
} }
const max = Math.max(...data.map((d) => d.pageviews), 1); const max = Math.max(...data.map((d) => d.pageviews), 1);
const total = data.length; const total = data.length;
const barW = Math.max(2, Math.floor((width - gap * (total - 1)) / total)); // Compute bar width to fit SVG width; reduce gap if needed so barW >= 1 when possible
let gap = defaultGap;
let barW = Math.floor((width - gap * (total - 1)) / total);
if (barW < 1 && total > 1) {
gap = 0;
barW = Math.floor((width - gap * (total - 1)) / total);
}
if (barW < 1) {
barW = 1;
}
const trend = getTrendDirection(data); const trend = getTrendDirection(data);
const trendColor = const trendColor =
trend === '↑' trend === '↑'
@@ -71,9 +80,9 @@ function SparklineBars({ data }: SparklineBarsProps) {
<Tooltiper <Tooltiper
content={ content={
trend === '↑' trend === '↑'
? 'Upgoing trend' ? 'Upward trend'
: trend === '↓' : trend === '↓'
? 'Downgoing trend' ? 'Downward trend'
: 'Stable trend' : 'Stable trend'
} }
> >

View File

@@ -103,6 +103,16 @@ export function useColumns({
if (prev == null) { if (prev == null) {
return <span className="text-muted-foreground"></span>; return <span className="text-muted-foreground"></span>;
} }
if (prev === 0) {
return (
<div className="flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)}
</span>
<span className="text-muted-foreground">new</span>
</div>
);
}
const pct = ((row.original.sessions - prev) / prev) * 100; const pct = ((row.original.sessions - prev) / prev) * 100;
const isPos = pct >= 0; const isPos = pct >= 0;
@@ -112,15 +122,12 @@ export function useColumns({
<span className="font-mono text-sm tabular-nums"> <span className="font-mono text-sm tabular-nums">
{number.short(row.original.sessions)} {number.short(row.original.sessions)}
</span> </span>
{prev === 0 && <span className="text-muted-foreground">new</span>}
{prev > 0 && (
<span <span
className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`} className={`font-mono text-sm tabular-nums ${isPos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}
> >
{isPos ? '+' : ''} {isPos ? '+' : ''}
{pct.toFixed(1)}% {pct.toFixed(1)}%
</span> </span>
)}
</div> </div>
); );
}, },

View File

@@ -26,7 +26,12 @@ export function PagesTable({ projectId }: PagesTableProps) {
const pagesQuery = useQuery( const pagesQuery = useQuery(
trpc.event.pages.queryOptions( trpc.event.pages.queryOptions(
{ projectId, cursor: 1, take: 1000, search: undefined, range, interval }, {
projectId,
search: debouncedSearch ?? undefined,
range,
interval,
},
{ placeholderData: keepPreviousData }, { placeholderData: keepPreviousData },
), ),
); );
@@ -44,7 +49,7 @@ export function PagesTable({ projectId }: PagesTableProps) {
range, range,
startDate: startDate ?? undefined, startDate: startDate ?? undefined,
endDate: endDate ?? undefined, endDate: endDate ?? undefined,
limit: 1000, limit: 10_000,
}, },
{ enabled: isGscConnected }, { enabled: isGscConnected },
), ),
@@ -88,22 +93,11 @@ export function PagesTable({ projectId }: PagesTableProps) {
})); }));
}, [pagesQuery.data, gscMap]); }, [pagesQuery.data, gscMap]);
const filteredData = useMemo(() => {
if (!debouncedSearch) return rawData;
const q = debouncedSearch.toLowerCase();
return rawData.filter(
(p) =>
p.path.toLowerCase().includes(q) ||
p.origin.toLowerCase().includes(q) ||
(p.title ?? '').toLowerCase().includes(q),
);
}, [rawData, debouncedSearch]);
const columns = useColumns({ projectId, isGscConnected, previousMap }); const columns = useColumns({ projectId, isGscConnected, previousMap });
const { table } = useTable({ const { table } = useTable({
columns, columns,
data: filteredData, data: rawData,
loading: pagesQuery.isLoading, loading: pagesQuery.isLoading,
pageSize: 50, pageSize: 50,
name: 'pages', name: 'pages',
@@ -133,7 +127,9 @@ export function PagesTable({ projectId }: PagesTableProps) {
: 'Integrate our web SDK to your site to get pages here.', : 'Integrate our web SDK to your site to get pages here.',
}} }}
onRowClick={(row) => { onRowClick={(row) => {
if (!isGscConnected) return; if (!isGscConnected) {
return;
}
const page = row.original; const page = row.original;
pushModal('PageDetails', { pushModal('PageDetails', {
type: 'page', type: 'page',

View File

@@ -23,16 +23,28 @@ import { SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
type GscChartData = { date: string; clicks: number; impressions: number }; interface GscChartData {
date: string;
clicks: number;
impressions: number;
}
interface GscViewsChartData {
date: string;
views: number;
}
const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip< const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
GscChartData, GscChartData | GscViewsChartData,
Record<string, unknown> Record<string, unknown>
>(({ data }) => { >(({ data }) => {
const item = data[0]; const item = data[0];
if (!item) { if (!item) {
return null; return null;
} }
if (!('date' in item)) {
return null;
}
if ('views' in item && item.views != null) {
return ( return (
<> <>
<ChartTooltipHeader> <ChartTooltipHeader>
@@ -40,16 +52,36 @@ const { TooltipProvider, Tooltip: GscTooltip } = createChartTooltip<
</ChartTooltipHeader> </ChartTooltipHeader>
<ChartTooltipItem color={getChartColor(0)}> <ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono"> <div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span> <span>Views</span>
<span>{item.clicks.toLocaleString()}</span> <span>{item.views.toLocaleString()}</span>
</div> </div>
</ChartTooltipItem> </ChartTooltipItem>
</>
);
}
const clicks = 'clicks' in item ? item.clicks : undefined;
const impressions = 'impressions' in item ? item.impressions : undefined;
return (
<>
<ChartTooltipHeader>
<div>{item.date}</div>
</ChartTooltipHeader>
{clicks != null && (
<ChartTooltipItem color={getChartColor(0)}>
<div className="flex justify-between gap-8 font-medium font-mono">
<span>Clicks</span>
<span>{clicks.toLocaleString()}</span>
</div>
</ChartTooltipItem>
)}
{impressions != null && (
<ChartTooltipItem color={getChartColor(1)}> <ChartTooltipItem color={getChartColor(1)}>
<div className="flex justify-between gap-8 font-medium font-mono"> <div className="flex justify-between gap-8 font-medium font-mono">
<span>Impressions</span> <span>Impressions</span>
<span>{item.impressions.toLocaleString()}</span> <span>{impressions.toLocaleString()}</span>
</div> </div>
</ChartTooltipItem> </ChartTooltipItem>
)}
</> </>
); );
}); });
@@ -93,10 +125,25 @@ export default function GscDetails(props: Props) {
) )
); );
const pagesTimeseriesQuery = useQuery( const { origin: pageOrigin, path: pagePath } =
trpc.event.pagesTimeseries.queryOptions( type === 'page'
{ projectId, ...dateInput }, ? (() => {
{ enabled: type === 'page' } try {
const url = new URL(value);
return { origin: url.origin, path: url.pathname + url.search };
} catch {
return {
origin: typeof window !== 'undefined' ? window.location.origin : '',
path: value,
};
}
})()
: { origin: '', path: '' };
const pageTimeseriesQuery = useQuery(
trpc.event.pageTimeseries.queryOptions(
{ projectId, ...dateInput, origin: pageOrigin, path: pagePath },
{ enabled: type === 'page' && !!pagePath }
) )
); );
@@ -105,7 +152,7 @@ export default function GscDetails(props: Props) {
type === 'page' ? pageQuery.isLoading : queryQuery.isLoading; type === 'page' ? pageQuery.isLoading : queryQuery.isLoading;
const timeseries = data?.timeseries ?? []; const timeseries = data?.timeseries ?? [];
const pagesTimeseries = pagesTimeseriesQuery.data ?? []; const pageTimeseries = pageTimeseriesQuery.data ?? [];
const breakdownRows = const breakdownRows =
type === 'page' type === 'page'
? ((data as { queries?: unknown[] } | undefined)?.queries ?? []) ? ((data as { queries?: unknown[] } | undefined)?.queries ?? [])
@@ -131,13 +178,14 @@ export default function GscDetails(props: Props) {
{type === 'page' && ( {type === 'page' && (
<div className="card p-4"> <div className="card p-4">
<h3 className="mb-4 font-medium text-sm">Views & Sessions</h3> <h3 className="mb-4 font-medium text-sm">Views & Sessions</h3>
{isLoading ? ( {isLoading || pageTimeseriesQuery.isLoading ? (
<Skeleton className="h-40 w-full" /> <Skeleton className="h-40 w-full" />
) : ( ) : (
<GscViewsChart <GscViewsChart
data={pagesTimeseries data={pageTimeseries.map((r) => ({
.filter((r) => r.origin + r.path === value) date: r.date,
.map((r) => ({ date: r.date, views: r.pageviews }))} views: r.pageviews,
}))}
/> />
)} )}
</div> </div>

View File

@@ -21,14 +21,17 @@ export default function PageDetails({ type, projectId, value }: Props) {
<div className="col gap-6"> <div className="col gap-6">
{type === 'page' && {type === 'page' &&
(() => { (() => {
let origin = value; let origin: string;
let path = '/'; let path: string;
try { try {
const url = new URL(value); const url = new URL(value);
origin = url.origin; origin = url.origin;
path = url.pathname + url.search; path = url.pathname + url.search;
} catch { } catch {
// value might already be just a path // value is path-only (e.g. "/docs/foo")
origin =
typeof window !== 'undefined' ? window.location.origin : '';
path = value;
} }
return ( return (
<PageViewsChart <PageViewsChart

View File

@@ -407,7 +407,8 @@ export const getGscCannibalization = cacheable(
if (existing) { if (existing) {
existing.clicks += row.clicks; existing.clicks += row.clicks;
existing.impressions += row.impressions; existing.impressions += row.impressions;
existing.ctr = (existing.ctr + row.ctr) / 2; existing.ctr =
existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
existing.position = Math.min(existing.position, row.position); existing.position = Math.min(existing.position, row.position);
} else { } else {
entry.pages.push({ entry.pages.push({

View File

@@ -8,6 +8,7 @@ export interface IGetPagesInput {
endDate: string; endDate: string;
timezone: string; timezone: string;
search?: string; search?: string;
limit?: number;
} }
export interface IPageTimeseriesRow { export interface IPageTimeseriesRow {
@@ -37,6 +38,7 @@ export class PagesService {
endDate, endDate,
timezone, timezone,
search, search,
limit,
}: IGetPagesInput): Promise<ITopPage[]> { }: IGetPagesInput): Promise<ITopPage[]> {
// CTE: Get titles from the last 30 days for faster retrieval // CTE: Get titles from the last 30 days for faster retrieval
const titlesCte = clix(this.client, timezone) const titlesCte = clix(this.client, timezone)
@@ -92,12 +94,18 @@ export class PagesService {
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.when(!!search, (q) => { .when(!!search, (q) => {
q.where('e.path', 'LIKE', `%${search}%`); const term = `%${search}%`;
q.whereGroup()
.where('e.path', 'LIKE', term)
.orWhere('e.origin', 'LIKE', term)
.orWhere('pt.title', 'LIKE', term)
.end();
}) })
.groupBy(['e.origin', 'e.path', 'pt.title']) .groupBy(['e.origin', 'e.path', 'pt.title'])
.orderBy('sessions', 'DESC') .orderBy('sessions', 'DESC');
.limit(1000); if (limit !== undefined) {
query.limit(limit);
}
return query.execute(); return query.execute();
} }

View File

@@ -320,7 +320,7 @@ export const eventRouter = createTRPCRouter({
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
cursor: z.number().optional(), cursor: z.number().optional(),
take: z.number().default(20), take: z.number().min(1).optional(),
search: z.string().optional(), search: z.string().optional(),
range: zRange, range: zRange,
interval: zTimeInterval, interval: zTimeInterval,
@@ -335,6 +335,7 @@ export const eventRouter = createTRPCRouter({
endDate, endDate,
timezone, timezone,
search: input.search, search: input.search,
limit: input.take,
}); });
}), }),

View File

@@ -185,7 +185,7 @@ export const gscRouter = createTRPCRouter({
getPages: protectedProcedure getPages: protectedProcedure
.input( .input(
zGscDateInput.extend({ zGscDateInput.extend({
limit: z.number().min(1).max(1000).optional().default(100), limit: z.number().min(1).max(10_000).optional().default(100),
}) })
) )
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {