wip
This commit is contained in:
@@ -1,16 +1,13 @@
|
|||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import superjson from 'superjson';
|
|
||||||
|
|
||||||
import type { WebSocket } from '@fastify/websocket';
|
import type { WebSocket } from '@fastify/websocket';
|
||||||
import {
|
import { eventBuffer } from '@openpanel/db';
|
||||||
eventBuffer,
|
|
||||||
getProfileById,
|
|
||||||
transformMinimalEvent,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
import { setSuperJson } from '@openpanel/json';
|
import { setSuperJson } from '@openpanel/json';
|
||||||
import { subscribeToPublishedEvent } from '@openpanel/redis';
|
import {
|
||||||
|
psubscribeToPublishedEvent,
|
||||||
|
subscribeToPublishedEvent,
|
||||||
|
} from '@openpanel/redis';
|
||||||
import { getProjectAccess } from '@openpanel/trpc';
|
import { getProjectAccess } from '@openpanel/trpc';
|
||||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
export function wsVisitors(
|
export function wsVisitors(
|
||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
@@ -18,19 +15,38 @@ export function wsVisitors(
|
|||||||
Params: {
|
Params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>
|
||||||
) {
|
) {
|
||||||
const { params } = req;
|
const { params } = req;
|
||||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
const sendCount = () => {
|
||||||
if (event?.projectId === params.projectId) {
|
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
socket.send(String(count));
|
||||||
socket.send(String(count));
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const unsubscribe = subscribeToPublishedEvent(
|
||||||
|
'events',
|
||||||
|
'batch',
|
||||||
|
({ projectId }) => {
|
||||||
|
if (projectId === params.projectId) {
|
||||||
|
sendCount();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
const punsubscribe = psubscribeToPublishedEvent(
|
||||||
|
'__keyevent@0__:expired',
|
||||||
|
(key) => {
|
||||||
|
const [, , projectId] = key.split(':');
|
||||||
|
if (projectId === params.projectId) {
|
||||||
|
sendCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
punsubscribe();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,18 +58,10 @@ export async function wsProjectEvents(
|
|||||||
};
|
};
|
||||||
Querystring: {
|
Querystring: {
|
||||||
token?: string;
|
token?: string;
|
||||||
type?: 'saved' | 'received';
|
|
||||||
};
|
};
|
||||||
}>,
|
}>
|
||||||
) {
|
) {
|
||||||
const { params, query } = req;
|
const { params } = req;
|
||||||
const type = query.type || 'saved';
|
|
||||||
|
|
||||||
if (!['saved', 'received'].includes(type)) {
|
|
||||||
socket.send('Invalid type');
|
|
||||||
socket.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = req.session?.userId;
|
const userId = req.session?.userId;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -67,24 +75,20 @@ export async function wsProjectEvents(
|
|||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
socket.send('No access');
|
||||||
|
socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const unsubscribe = subscribeToPublishedEvent(
|
const unsubscribe = subscribeToPublishedEvent(
|
||||||
'events',
|
'events',
|
||||||
type,
|
'batch',
|
||||||
async (event) => {
|
({ projectId, count }) => {
|
||||||
if (event.projectId === params.projectId) {
|
if (projectId === params.projectId) {
|
||||||
const profile = await getProfileById(event.profileId, event.projectId);
|
socket.send(setSuperJson({ count }));
|
||||||
socket.send(
|
|
||||||
superjson.stringify(
|
|
||||||
access
|
|
||||||
? {
|
|
||||||
...event,
|
|
||||||
profile,
|
|
||||||
}
|
|
||||||
: transformMinimalEvent(event),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on('close', () => unsubscribe());
|
socket.on('close', () => unsubscribe());
|
||||||
@@ -96,7 +100,7 @@ export async function wsProjectNotifications(
|
|||||||
Params: {
|
Params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>
|
||||||
) {
|
) {
|
||||||
const { params } = req;
|
const { params } = req;
|
||||||
const userId = req.session?.userId;
|
const userId = req.session?.userId;
|
||||||
@@ -123,9 +127,9 @@ export async function wsProjectNotifications(
|
|||||||
'created',
|
'created',
|
||||||
(notification) => {
|
(notification) => {
|
||||||
if (notification.projectId === params.projectId) {
|
if (notification.projectId === params.projectId) {
|
||||||
socket.send(superjson.stringify(notification));
|
socket.send(setSuperJson(notification));
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on('close', () => unsubscribe());
|
socket.on('close', () => unsubscribe());
|
||||||
@@ -137,7 +141,7 @@ export async function wsOrganizationEvents(
|
|||||||
Params: {
|
Params: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
}>,
|
}>
|
||||||
) {
|
) {
|
||||||
const { params } = req;
|
const { params } = req;
|
||||||
const userId = req.session?.userId;
|
const userId = req.session?.userId;
|
||||||
@@ -164,7 +168,7 @@ export async function wsOrganizationEvents(
|
|||||||
'subscription_updated',
|
'subscription_updated',
|
||||||
(message) => {
|
(message) => {
|
||||||
socket.send(setSuperJson(message));
|
socket.send(setSuperJson(message));
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
socket.on('close', () => unsubscribe());
|
socket.on('close', () => unsubscribe());
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AnimatedNumber } from '../animated-number';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -8,71 +9,53 @@ import { useDebounceState } from '@/hooks/use-debounce-state';
|
|||||||
import useWS from '@/hooks/use-ws';
|
import useWS from '@/hooks/use-ws';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
import type { IServiceEvent, IServiceEventMinimal } from '@openpanel/db';
|
|
||||||
import { useParams } from '@tanstack/react-router';
|
|
||||||
import { AnimatedNumber } from '../animated-number';
|
|
||||||
|
|
||||||
export default function EventListener({
|
export default function EventListener({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
const params = useParams({
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const counter = useDebounceState(0, 1000);
|
const counter = useDebounceState(0, 1000);
|
||||||
useWS<IServiceEventMinimal | IServiceEvent>(
|
useWS<{ count: number }>(
|
||||||
`/live/events/${projectId}`,
|
`/live/events/${projectId}`,
|
||||||
(event) => {
|
({ count }) => {
|
||||||
if (event) {
|
counter.set((prev) => prev + count);
|
||||||
const isProfilePage = !!params?.profileId;
|
|
||||||
if (isProfilePage) {
|
|
||||||
const profile = 'profile' in event ? event.profile : null;
|
|
||||||
if (profile?.id === params?.profileId) {
|
|
||||||
counter.set((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
counter.set((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
debounce: {
|
debounce: {
|
||||||
delay: 1000,
|
delay: 1000,
|
||||||
maxWait: 5000,
|
maxWait: 5000,
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
counter.set(0);
|
counter.set(0);
|
||||||
onRefresh();
|
onRefresh();
|
||||||
}}
|
}}
|
||||||
className="flex h-8 items-center gap-2 rounded-md border border-border bg-card px-3 font-medium leading-none"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all',
|
'h-3 w-3 animate-ping rounded-full bg-emerald-500 opacity-100 transition-all'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0 top-0 h-3 w-3 rounded-full bg-emerald-500 transition-all',
|
'absolute top-0 left-0 h-3 w-3 rounded-full bg-emerald-500 transition-all'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{counter.debounced === 0 ? (
|
{counter.debounced === 0 ? (
|
||||||
'Listening'
|
'Listening'
|
||||||
) : (
|
) : (
|
||||||
<AnimatedNumber value={counter.debounced} suffix=" new events" />
|
<AnimatedNumber suffix=" new events" value={counter.debounced} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Props = {
|
|||||||
>,
|
>,
|
||||||
unknown
|
unknown
|
||||||
>;
|
>;
|
||||||
|
showEventListener?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
||||||
@@ -215,7 +216,7 @@ const VirtualizedEventsTable = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventsTable = ({ query }: Props) => {
|
export const EventsTable = ({ query, showEventListener = false }: Props) => {
|
||||||
const { isLoading } = query;
|
const { isLoading } = query;
|
||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
|
|
||||||
@@ -272,7 +273,7 @@ export const EventsTable = ({ query }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EventsTableToolbar query={query} table={table} />
|
<EventsTableToolbar query={query} table={table} showEventListener={showEventListener} />
|
||||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||||
<div
|
<div
|
||||||
@@ -291,9 +292,11 @@ export const EventsTable = ({ query }: Props) => {
|
|||||||
function EventsTableToolbar({
|
function EventsTableToolbar({
|
||||||
query,
|
query,
|
||||||
table,
|
table,
|
||||||
|
showEventListener,
|
||||||
}: {
|
}: {
|
||||||
query: Props['query'];
|
query: Props['query'];
|
||||||
table: Table<IServiceEvent>;
|
table: Table<IServiceEvent>;
|
||||||
|
showEventListener: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const [startDate, setStartDate] = useQueryState(
|
const [startDate, setStartDate] = useQueryState(
|
||||||
@@ -305,7 +308,7 @@ function EventsTableToolbar({
|
|||||||
return (
|
return (
|
||||||
<DataTableToolbarContainer>
|
<DataTableToolbarContainer>
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
<EventListener onRefresh={() => query.refetch()} />
|
{showEventListener && <EventListener onRefresh={() => query.refetch()} />}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
import type {
|
import type { IServiceEvent } from '@openpanel/db';
|
||||||
IServiceClient,
|
|
||||||
IServiceEvent,
|
|
||||||
IServiceProject,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
|
||||||
import useWS from '@/hooks/use-ws';
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { timeAgo } from '@/utils/date';
|
import { timeAgo } from '@/utils/date';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: IServiceProject;
|
|
||||||
client: IServiceClient | null;
|
|
||||||
events: IServiceEvent[];
|
events: IServiceEvent[];
|
||||||
onVerified: (verified: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
const VerifyListener = ({ events }: Props) => {
|
||||||
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
|
|
||||||
useWS<IServiceEvent>(
|
|
||||||
`/live/events/${client?.projectId}?type=received`,
|
|
||||||
(data) => {
|
|
||||||
setEvents((prev) => [...prev, data]);
|
|
||||||
onVerified(true);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const isConnected = events.length > 0;
|
const isConnected = events.length > 0;
|
||||||
|
|
||||||
const renderIcon = () => {
|
const renderIcon = () => {
|
||||||
@@ -49,16 +31,18 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-6 rounded-xl p-4 md:p-6',
|
'flex gap-6 rounded-xl p-4 md:p-6',
|
||||||
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10'
|
isConnected
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-700/10'
|
||||||
|
: 'bg-blue-500/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
||||||
{isConnected ? 'Success' : 'Waiting for events'}
|
{isConnected ? 'Successfully connected' : 'Waiting for events'}
|
||||||
</div>
|
</div>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<div className="flex flex-col-reverse">
|
<div className="mt-2 flex flex-col-reverse gap-1">
|
||||||
{events.length > 5 && (
|
{events.length > 5 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckIcon size={14} />{' '}
|
<CheckIcon size={14} />{' '}
|
||||||
@@ -69,7 +53,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
<div className="flex items-center gap-2" key={event.id}>
|
<div className="flex items-center gap-2" key={event.id}>
|
||||||
<CheckIcon size={14} />{' '}
|
<CheckIcon size={14} />{' '}
|
||||||
<span className="font-medium">{event.name}</span>{' '}
|
<span className="font-medium">{event.name}</span>{' '}
|
||||||
<span className="ml-auto text-emerald-800">
|
<span className="ml-auto text-foreground/50 text-sm">
|
||||||
{timeAgo(event.createdAt, 'round')}
|
{timeAgo(event.createdAt, 'round')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { useEffect, useState } from 'react';
|
import { ProjectLink } from '../links';
|
||||||
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import useWS from '@/hooks/use-ws';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import type { IServiceEvent } from '@openpanel/db';
|
import { formatTimeAgoOrDateTime } from '@/utils/date';
|
||||||
import { EventItem } from '../events/table/item';
|
|
||||||
|
|
||||||
interface RealtimeActiveSessionsProps {
|
interface RealtimeActiveSessionsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -17,64 +15,52 @@ export function RealtimeActiveSessions({
|
|||||||
limit = 10,
|
limit = 10,
|
||||||
}: RealtimeActiveSessionsProps) {
|
}: RealtimeActiveSessionsProps) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const activeSessionsQuery = useQuery(
|
const { data: sessions = [] } = useQuery(
|
||||||
trpc.realtime.activeSessions.queryOptions({
|
trpc.realtime.activeSessions.queryOptions(
|
||||||
projectId,
|
{ projectId },
|
||||||
}),
|
{ refetchInterval: 5000 }
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<IServiceEvent[]>([]);
|
|
||||||
|
|
||||||
// Update state when initial data loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeSessionsQuery.data && state.length === 0) {
|
|
||||||
setState(activeSessionsQuery.data);
|
|
||||||
}
|
|
||||||
}, [activeSessionsQuery.data, state]);
|
|
||||||
|
|
||||||
// Set up WebSocket connection for real-time updates
|
|
||||||
useWS<IServiceEvent>(
|
|
||||||
`/live/events/${projectId}`,
|
|
||||||
(session) => {
|
|
||||||
setState((prev) => {
|
|
||||||
// Add new session and remove duplicates, keeping most recent
|
|
||||||
const filtered = prev.filter((s) => s.id !== session.id);
|
|
||||||
return [session, ...filtered].slice(0, limit);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
debounce: {
|
|
||||||
delay: 1000,
|
|
||||||
maxWait: 5000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sessions = state.length > 0 ? state : (activeSessionsQuery.data ?? []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col h-full max-md:hidden">
|
<div className="col card h-full max-md:hidden">
|
||||||
<div className="hide-scrollbar h-full overflow-y-auto pb-10">
|
<div className="hide-scrollbar h-full overflow-y-auto">
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence initial={false} mode="popLayout">
|
||||||
<div className="col gap-4">
|
<div className="col divide-y">
|
||||||
{sessions.map((session) => (
|
{sessions.slice(0, limit).map((session) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={session.id}
|
|
||||||
layout
|
|
||||||
// initial={{ opacity: 0, x: -200, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
exit={{ opacity: 0, x: 200, scale: 0.8 }}
|
||||||
|
key={session.id}
|
||||||
|
layout
|
||||||
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
|
transition={{ duration: 0.4, type: 'spring', stiffness: 300 }}
|
||||||
>
|
>
|
||||||
<EventItem
|
<ProjectLink
|
||||||
event={session}
|
className="relative block p-4 py-3 pr-14"
|
||||||
viewOptions={{
|
href={`/sessions/${session.sessionId}`}
|
||||||
properties: false,
|
>
|
||||||
origin: false,
|
<div className="col flex-1 gap-1">
|
||||||
queryString: false,
|
{session.name === 'screen_view' && (
|
||||||
}}
|
<span className="text-muted-foreground text-xs leading-normal/80">
|
||||||
className="w-full"
|
{session.origin}
|
||||||
/>
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-sm leading-normal">
|
||||||
|
{session.name === 'screen_view'
|
||||||
|
? session.path
|
||||||
|
: session.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row absolute top-1/2 right-4 origin-right -translate-y-1/2 scale-50 gap-2">
|
||||||
|
<SerieIcon name={session.referrerName} />
|
||||||
|
<SerieIcon name={session.os} />
|
||||||
|
<SerieIcon name={session.browser} />
|
||||||
|
<SerieIcon name={session.device} />
|
||||||
|
</div>
|
||||||
|
</ProjectLink>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,5 +42,5 @@ function Component() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return <EventsTable query={query} />;
|
return <EventsTable query={query} showEventListener />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
|
import { Fullscreen, FullscreenClose } from '@/components/fullscreen-toggle';
|
||||||
import RealtimeMap from '@/components/realtime/map';
|
import RealtimeMap from '@/components/realtime/map';
|
||||||
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
|
import { RealtimeActiveSessions } from '@/components/realtime/realtime-active-sessions';
|
||||||
@@ -7,12 +9,10 @@ import { RealtimePaths } from '@/components/realtime/realtime-paths';
|
|||||||
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
|
import { RealtimeReferrals } from '@/components/realtime/realtime-referrals';
|
||||||
import RealtimeReloader from '@/components/realtime/realtime-reloader';
|
import RealtimeReloader from '@/components/realtime/realtime-reloader';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
|
import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/realtime',
|
'/_app/$organizationId/$projectId/realtime'
|
||||||
)({
|
)({
|
||||||
component: Component,
|
component: Component,
|
||||||
head: () => {
|
head: () => {
|
||||||
@@ -36,8 +36,8 @@ function Component() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
},
|
}
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +47,7 @@ function Component() {
|
|||||||
<RealtimeReloader projectId={projectId} />
|
<RealtimeReloader projectId={projectId} />
|
||||||
|
|
||||||
<div className="row relative">
|
<div className="row relative">
|
||||||
<div className="overflow-hidden aspect-[4/2] w-full">
|
<div className="aspect-[4/2] w-full overflow-hidden">
|
||||||
<RealtimeMap
|
<RealtimeMap
|
||||||
markers={coordinatesQuery.data ?? []}
|
markers={coordinatesQuery.data ?? []}
|
||||||
sidebarConfig={{
|
sidebarConfig={{
|
||||||
@@ -56,18 +56,17 @@ function Component() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-8 left-8 bottom-0 col gap-4">
|
<div className="col absolute top-8 bottom-4 left-8 gap-4">
|
||||||
<div className="card p-4 w-72 bg-background/90">
|
<div className="card w-72 bg-background/90 p-4">
|
||||||
<RealtimeLiveHistogram projectId={projectId} />
|
<RealtimeLiveHistogram projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-72 flex-1 min-h-0 relative">
|
<div className="relative min-h-0 w-72 flex-1">
|
||||||
<RealtimeActiveSessions projectId={projectId} />
|
<RealtimeActiveSessions projectId={projectId} />
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-def-100 to-transparent" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-4 pt-4 md:p-8 md:pt-0">
|
<div className="grid grid-cols-1 gap-4 p-4 pt-4 md:grid-cols-2 md:p-8 md:pt-0 xl:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<RealtimeGeo projectId={projectId} />
|
<RealtimeGeo projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
|
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
|
||||||
import { BoxSelectIcon } from 'lucide-react';
|
import { BoxSelectIcon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { ButtonContainer } from '@/components/button-container';
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
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';
|
||||||
@@ -33,22 +32,21 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Component() {
|
function Component() {
|
||||||
const [isVerified, setIsVerified] = useState(false);
|
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { data: events, refetch } = useQuery(
|
const { data: events } = useQuery(
|
||||||
trpc.event.events.queryOptions({ projectId })
|
trpc.event.events.queryOptions(
|
||||||
|
{ projectId },
|
||||||
|
{
|
||||||
|
refetchInterval: 2500,
|
||||||
|
}
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const isVerified = events?.data && events.data.length > 0;
|
||||||
const { data: project } = useQuery(
|
const { data: project } = useQuery(
|
||||||
trpc.project.getProjectWithClients.queryOptions({ projectId })
|
trpc.project.getProjectWithClients.queryOptions({ projectId })
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (events && events.data.length > 0) {
|
|
||||||
setIsVerified(true);
|
|
||||||
}
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
<FullPageEmptyState icon={BoxSelectIcon} title="Project not found" />
|
<FullPageEmptyState icon={BoxSelectIcon} title="Project not found" />
|
||||||
@@ -64,15 +62,7 @@ function Component() {
|
|||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||||
<div className="col gap-8 p-4">
|
<div className="col gap-8 p-4">
|
||||||
<VerifyListener
|
<VerifyListener events={events?.data ?? []} />
|
||||||
client={client}
|
|
||||||
events={events?.data ?? []}
|
|
||||||
onVerified={() => {
|
|
||||||
refetch();
|
|
||||||
setIsVerified(true);
|
|
||||||
}}
|
|
||||||
project={project}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VerifyFaq project={project} />
|
<VerifyFaq project={project} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { getSafeJson } from '@openpanel/json';
|
|||||||
import {
|
import {
|
||||||
type Redis,
|
type Redis,
|
||||||
getRedisCache,
|
getRedisCache,
|
||||||
getRedisPub,
|
|
||||||
publishEvent,
|
publishEvent,
|
||||||
} from '@openpanel/redis';
|
} from '@openpanel/redis';
|
||||||
import { ch } from '../clickhouse/client';
|
import { ch } from '../clickhouse/client';
|
||||||
@@ -53,14 +52,10 @@ export class EventBuffer extends BaseBuffer {
|
|||||||
/** Tracks consecutive flush failures for observability; reset on success. */
|
/** Tracks consecutive flush failures for observability; reset on success. */
|
||||||
private flushRetryCount = 0;
|
private flushRetryCount = 0;
|
||||||
|
|
||||||
private publishThrottleMs = process.env.EVENT_BUFFER_PUBLISH_THROTTLE_MS
|
|
||||||
? Number.parseInt(process.env.EVENT_BUFFER_PUBLISH_THROTTLE_MS, 10)
|
|
||||||
: 1000;
|
|
||||||
private lastPublishTime = 0;
|
|
||||||
private pendingPublishEvent: IClickhouseEvent | null = null;
|
|
||||||
private publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
private activeVisitorsExpiration = 60 * 5; // 5 minutes
|
private activeVisitorsExpiration = 60 * 5; // 5 minutes
|
||||||
|
/** How often (ms) we refresh the heartbeat key + zadd per visitor. */
|
||||||
|
private heartbeatRefreshMs = 60_000; // 1 minute
|
||||||
|
private lastHeartbeat = new Map<string, number>();
|
||||||
private queueKey = 'event_buffer:queue';
|
private queueKey = 'event_buffer:queue';
|
||||||
protected bufferCounterKey = 'event_buffer:total_count';
|
protected bufferCounterKey = 'event_buffer:total_count';
|
||||||
|
|
||||||
@@ -194,7 +189,7 @@ return added
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add(event: IClickhouseEvent, _multi?: ReturnType<Redis['multi']>) {
|
add(event: IClickhouseEvent) {
|
||||||
const eventJson = JSON.stringify(event);
|
const eventJson = JSON.stringify(event);
|
||||||
|
|
||||||
let type: PendingEvent['type'] = 'regular';
|
let type: PendingEvent['type'] = 'regular';
|
||||||
@@ -218,11 +213,6 @@ return added
|
|||||||
type,
|
type,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (_multi) {
|
|
||||||
this.addToMulti(_multi, pendingEvent);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingEvents.push(pendingEvent);
|
this.pendingEvents.push(pendingEvent);
|
||||||
|
|
||||||
if (this.pendingEvents.length >= this.microBatchMaxSize) {
|
if (this.pendingEvents.length >= this.microBatchMaxSize) {
|
||||||
@@ -318,11 +308,7 @@ return added
|
|||||||
await multi.exec();
|
await multi.exec();
|
||||||
|
|
||||||
this.flushRetryCount = 0;
|
this.flushRetryCount = 0;
|
||||||
|
this.pruneHeartbeatMap();
|
||||||
const lastEvent = eventsToFlush[eventsToFlush.length - 1];
|
|
||||||
if (lastEvent) {
|
|
||||||
this.scheduleThrottledPublish(lastEvent.event);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Re-queue failed events at the front to preserve order and avoid data loss
|
// Re-queue failed events at the front to preserve order and avoid data loss
|
||||||
this.pendingEvents = eventsToFlush.concat(this.pendingEvents);
|
this.pendingEvents = eventsToFlush.concat(this.pendingEvents);
|
||||||
@@ -335,41 +321,13 @@ return added
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.isFlushing = false;
|
this.isFlushing = false;
|
||||||
}
|
// Events may have accumulated while we were flushing; schedule another flush if needed
|
||||||
}
|
if (this.pendingEvents.length > 0 && !this.flushTimer) {
|
||||||
|
this.flushTimer = setTimeout(() => {
|
||||||
private scheduleThrottledPublish(event: IClickhouseEvent) {
|
this.flushTimer = null;
|
||||||
this.pendingPublishEvent = event;
|
this.flushLocalBuffer();
|
||||||
|
}, this.microBatchIntervalMs);
|
||||||
const now = Date.now();
|
}
|
||||||
const timeSinceLastPublish = now - this.lastPublishTime;
|
|
||||||
|
|
||||||
if (timeSinceLastPublish >= this.publishThrottleMs) {
|
|
||||||
this.executeThrottledPublish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.publishTimer) {
|
|
||||||
const delay = this.publishThrottleMs - timeSinceLastPublish;
|
|
||||||
this.publishTimer = setTimeout(() => {
|
|
||||||
this.publishTimer = null;
|
|
||||||
this.executeThrottledPublish();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private executeThrottledPublish() {
|
|
||||||
if (!this.pendingPublishEvent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = this.pendingPublishEvent;
|
|
||||||
this.pendingPublishEvent = null;
|
|
||||||
this.lastPublishTime = Date.now();
|
|
||||||
|
|
||||||
const result = publishEvent('events', 'received', transformEvent(event));
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
result.catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,11 +396,13 @@ return added
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubMulti = getRedisPub().multi();
|
const countByProject = new Map<string, number>();
|
||||||
for (const event of eventsToClickhouse) {
|
for (const event of eventsToClickhouse) {
|
||||||
await publishEvent('events', 'saved', transformEvent(event), pubMulti);
|
countByProject.set(event.project_id, (countByProject.get(event.project_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
for (const [projectId, count] of countByProject) {
|
||||||
|
publishEvent('events', 'batch', { projectId, count });
|
||||||
}
|
}
|
||||||
await pubMulti.exec();
|
|
||||||
|
|
||||||
await redis
|
await redis
|
||||||
.multi()
|
.multi()
|
||||||
@@ -502,14 +462,34 @@ return added
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pruneHeartbeatMap() {
|
||||||
|
const cutoff = Date.now() - this.activeVisitorsExpiration * 1000;
|
||||||
|
for (const [key, ts] of this.lastHeartbeat) {
|
||||||
|
if (ts < cutoff) {
|
||||||
|
this.lastHeartbeat.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private incrementActiveVisitorCount(
|
private incrementActiveVisitorCount(
|
||||||
multi: ReturnType<Redis['multi']>,
|
multi: ReturnType<Redis['multi']>,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
) {
|
) {
|
||||||
|
const key = `${projectId}:${profileId}`;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const last = this.lastHeartbeat.get(key) ?? 0;
|
||||||
|
|
||||||
|
if (now - last < this.heartbeatRefreshMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastHeartbeat.set(key, now);
|
||||||
const zsetKey = `live:visitors:${projectId}`;
|
const zsetKey = `live:visitors:${projectId}`;
|
||||||
return multi.zadd(zsetKey, now, profileId);
|
const heartbeatKey = `live:visitor:${projectId}:${profileId}`;
|
||||||
|
multi
|
||||||
|
.zadd(zsetKey, now, profileId)
|
||||||
|
.set(heartbeatKey, '1', 'EX', this.activeVisitorsExpiration);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getActiveVisitorCount(projectId: string): Promise<number> {
|
public async getActiveVisitorCount(projectId: string): Promise<number> {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ export type IPublishChannels = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
received: IServiceEvent;
|
batch: { projectId: string; count: number };
|
||||||
saved: IServiceEvent;
|
|
||||||
};
|
};
|
||||||
notification: {
|
notification: {
|
||||||
created: Prisma.NotificationUncheckedCreateInput;
|
created: Prisma.NotificationUncheckedCreateInput;
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type EventMeta,
|
|
||||||
TABLE_NAMES,
|
|
||||||
ch,
|
ch,
|
||||||
chQuery,
|
chQuery,
|
||||||
clix,
|
clix,
|
||||||
db,
|
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
getEventList,
|
type IClickhouseEvent,
|
||||||
|
TABLE_NAMES,
|
||||||
|
transformEvent,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
|
||||||
import { subMinutes } from 'date-fns';
|
import { subMinutes } from 'date-fns';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
|
import { z } from 'zod';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
export const realtimeRouter = createTRPCRouter({
|
export const realtimeRouter = createTRPCRouter({
|
||||||
@@ -25,7 +22,7 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
long: number;
|
long: number;
|
||||||
lat: number;
|
lat: number;
|
||||||
}>(
|
}>(
|
||||||
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`,
|
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`
|
||||||
);
|
);
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@@ -33,25 +30,18 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
activeSessions: protectedProcedure
|
activeSessions: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return getEventList({
|
const rows = await chQuery<IClickhouseEvent>(
|
||||||
projectId: input.projectId,
|
`SELECT
|
||||||
take: 30,
|
name, session_id, created_at, path, origin, referrer, referrer_name,
|
||||||
select: {
|
country, city, region, os, os_version, browser, browser_version,
|
||||||
name: true,
|
device
|
||||||
path: true,
|
FROM ${TABLE_NAMES.events}
|
||||||
origin: true,
|
WHERE project_id = ${sqlstring.escape(input.projectId)}
|
||||||
referrer: true,
|
AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}'
|
||||||
referrerName: true,
|
ORDER BY created_at DESC
|
||||||
referrerType: true,
|
LIMIT 50`
|
||||||
country: true,
|
);
|
||||||
device: true,
|
return rows.map(transformEvent);
|
||||||
os: true,
|
|
||||||
browser: true,
|
|
||||||
createdAt: true,
|
|
||||||
profile: true,
|
|
||||||
meta: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
paths: protectedProcedure
|
paths: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
@@ -76,7 +66,7 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
'created_at',
|
'created_at',
|
||||||
'>=',
|
'>=',
|
||||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||||
)
|
)
|
||||||
.groupBy(['path', 'origin'])
|
.groupBy(['path', 'origin'])
|
||||||
.orderBy('count', 'DESC')
|
.orderBy('count', 'DESC')
|
||||||
@@ -106,7 +96,7 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
'created_at',
|
'created_at',
|
||||||
'>=',
|
'>=',
|
||||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||||
)
|
)
|
||||||
.groupBy(['referrer_name'])
|
.groupBy(['referrer_name'])
|
||||||
.orderBy('count', 'DESC')
|
.orderBy('count', 'DESC')
|
||||||
@@ -137,7 +127,7 @@ export const realtimeRouter = createTRPCRouter({
|
|||||||
.where(
|
.where(
|
||||||
'created_at',
|
'created_at',
|
||||||
'>=',
|
'>=',
|
||||||
formatClickhouseDate(subMinutes(new Date(), 30)),
|
formatClickhouseDate(subMinutes(new Date(), 30))
|
||||||
)
|
)
|
||||||
.groupBy(['country', 'city'])
|
.groupBy(['country', 'city'])
|
||||||
.orderBy('count', 'DESC')
|
.orderBy('count', 'DESC')
|
||||||
|
|||||||
Reference in New Issue
Block a user