a lot
This commit is contained in:
1798
apps/public/src/components/TimezoneSelector.tsx
Normal file
1798
apps/public/src/components/TimezoneSelector.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,12 +11,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@mixan/common": "workspace:*",
|
||||
"@mixan/db": "workspace:*",
|
||||
"@mixan/queue": "workspace:*",
|
||||
"@mixan/redis": "workspace:*",
|
||||
"fastify": "^4.25.2",
|
||||
"fastify-sse-v2": "^3.1.2",
|
||||
"pino": "^8.17.2",
|
||||
"ramda": "^0.29.1",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
@@ -28,6 +28,7 @@
|
||||
"@mixan/types": "workspace:*",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"@types/ws": "^8.5.10",
|
||||
"eslint": "^8.48.0",
|
||||
"prettier": "^3.0.3",
|
||||
"tsup": "^7.2.0",
|
||||
|
||||
@@ -1,96 +1,80 @@
|
||||
import { combine } from '@/sse/combine';
|
||||
import { redisMessageIterator } from '@/sse/redis-message-iterator';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type * as WebSocket from 'ws';
|
||||
|
||||
import { getSafeJson } from '@mixan/common';
|
||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||
import { chQuery, getEvents } from '@mixan/db';
|
||||
import { getEvents, getLiveVisitors } from '@mixan/db';
|
||||
import { redis, redisPub, redisSub } from '@mixan/redis';
|
||||
|
||||
async function getLiveCount(projectId: string) {
|
||||
const keys = await redis.keys(`live:event:${projectId}:*`);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
function getLiveEventInfo(key: string) {
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
|
||||
export async function test(request: FastifyRequest, reply: FastifyReply) {
|
||||
export async function test(
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const [event] = await getEvents(
|
||||
`SELECT * FROM events LIMIT 1 OFFSET ${Math.floor(Math.random() * 1000)}`
|
||||
`SELECT * FROM events WHERE project_id = '${req.params.projectId}' AND name = 'screen_view' LIMIT 1`
|
||||
);
|
||||
if (!event) {
|
||||
return reply.status(404).send('No event found');
|
||||
}
|
||||
redisPub.publish('event', JSON.stringify(event));
|
||||
redis.set(`live:event:${event.projectId}:${event.profileId}`, '', 'EX', 10);
|
||||
reply.status(202).send('OK');
|
||||
redis.set(
|
||||
`live:event:${event.projectId}:${Math.random() * 1000}`,
|
||||
'',
|
||||
'EX',
|
||||
10
|
||||
);
|
||||
reply.status(202).send(event);
|
||||
}
|
||||
|
||||
export function events(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
export function wsVisitors(
|
||||
connection: {
|
||||
socket: WebSocket;
|
||||
},
|
||||
req: FastifyRequest<{
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>
|
||||
) {
|
||||
const reqProjectId = request.params.projectId;
|
||||
const { params } = req;
|
||||
|
||||
// Subscribe
|
||||
redisSub.subscribe('event');
|
||||
redisSub.psubscribe('__key*:*');
|
||||
const listeners: ((...args: any[]) => void)[] = [];
|
||||
redisSub.psubscribe('__key*:expired');
|
||||
|
||||
const incomingEvents = redisMessageIterator({
|
||||
listenOn: 'message',
|
||||
async transformer(message) {
|
||||
const message = (channel: string, message: string) => {
|
||||
if (channel === 'event') {
|
||||
const event = getSafeJson<IServiceCreateEventPayload>(message);
|
||||
if (event && event.projectId === reqProjectId) {
|
||||
return {
|
||||
visitors: await getLiveCount(event.projectId),
|
||||
event,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
registerListener(fn) {
|
||||
listeners.push(fn);
|
||||
},
|
||||
});
|
||||
|
||||
const expiredEvents = redisMessageIterator({
|
||||
listenOn: 'pmessage',
|
||||
async transformer(message) {
|
||||
// message = live:event:${projectId}:${profileId}
|
||||
const [projectId] = getLiveEventInfo(message);
|
||||
if (projectId && projectId === reqProjectId) {
|
||||
return {
|
||||
visitors: await getLiveCount(projectId),
|
||||
event: null as null | IServiceCreateEventPayload,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
registerListener(fn) {
|
||||
listeners.push(fn);
|
||||
},
|
||||
});
|
||||
|
||||
async function* consumeMessages() {
|
||||
for await (const result of combine([incomingEvents, expiredEvents])) {
|
||||
if (result.data) {
|
||||
yield {
|
||||
data: JSON.stringify(result.data),
|
||||
};
|
||||
if (event?.projectId === params.projectId) {
|
||||
getLiveVisitors(params.projectId).then((count) => {
|
||||
connection.socket.send(String(count));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const pmessage = (pattern: string, channel: string, message: string) => {
|
||||
const [projectId] = getLiveEventInfo(message);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
getLiveVisitors(params.projectId).then((count) => {
|
||||
connection.socket.send(String(count));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reply.sse(consumeMessages());
|
||||
redisSub.on('message', message);
|
||||
redisSub.on('pmessage', pmessage);
|
||||
|
||||
reply.raw.on('close', () => {
|
||||
connection.socket.on('close', () => {
|
||||
redisSub.unsubscribe('event');
|
||||
redisSub.punsubscribe('__key*:expired');
|
||||
listeners.forEach((listener) => redisSub.off('message', listener));
|
||||
redisSub.off('message', message);
|
||||
redisSub.off('pmessage', pmessage);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify from 'fastify';
|
||||
import { FastifySSEPlugin } from 'fastify-sse-v2';
|
||||
import pino from 'pino';
|
||||
|
||||
import { redisPub } from '@mixan/redis';
|
||||
@@ -29,7 +28,6 @@ const startServer = async () => {
|
||||
origin: '*',
|
||||
});
|
||||
|
||||
fastify.register(FastifySSEPlugin);
|
||||
fastify.decorateRequest('projectId', '');
|
||||
fastify.register(eventRouter, { prefix: '/event' });
|
||||
fastify.register(profileRouter, { prefix: '/profile' });
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import * as controller from '@/controllers/live.controller';
|
||||
import fastifyWS from '@fastify/websocket';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
const liveRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/events/test',
|
||||
url: '/events/test/:projectId',
|
||||
handler: controller.test,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/events/:projectId',
|
||||
handler: controller.events,
|
||||
fastify.register(fastifyWS);
|
||||
|
||||
fastify.register((fastify, _, done) => {
|
||||
fastify.get(
|
||||
'/visitors/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsVisitors
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// @ts-nocheck
|
||||
|
||||
export async function* combine<T>(iterable: AsyncGenerator<T>[]): T[] {
|
||||
const asyncIterators = Array.from(iterable, (o) => o[Symbol.asyncIterator]());
|
||||
const results = [];
|
||||
let count = asyncIterators.length;
|
||||
const never = new Promise(() => {});
|
||||
function getNext(asyncIterator: AsyncGenerator, index: number) {
|
||||
return asyncIterator.next().then((result) => ({
|
||||
index,
|
||||
result,
|
||||
}));
|
||||
}
|
||||
const nextPromises = asyncIterators.map(getNext);
|
||||
try {
|
||||
while (count) {
|
||||
const { index, result } = await Promise.race(nextPromises);
|
||||
if (result.done) {
|
||||
nextPromises[index] = never;
|
||||
results[index] = result.value;
|
||||
count--;
|
||||
} else {
|
||||
nextPromises[index] = getNext(asyncIterators[index], index);
|
||||
yield result.value;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const [index, iterator] of asyncIterators.entries())
|
||||
if (nextPromises[index] != never && iterator.return != null)
|
||||
iterator.return();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { redisSub } from '@mixan/redis';
|
||||
|
||||
export async function* redisMessageIterator<T>(opts: {
|
||||
transformer: (payload: string) => Promise<T>;
|
||||
listenOn: string;
|
||||
registerListener: (listener: (...args: any[]) => void) => void;
|
||||
}) {
|
||||
// Subscribe to a channel
|
||||
interface Payload {
|
||||
data: T;
|
||||
}
|
||||
// Promise resolver to signal new messages
|
||||
let messageNotifier: null | ((payload: Payload) => void) = null;
|
||||
|
||||
// Promise to wait for new messages
|
||||
let waitForMessage: Promise<Payload> = new Promise<Payload>((resolve) => {
|
||||
messageNotifier = resolve;
|
||||
});
|
||||
|
||||
async function listener(pattern: string, channel: string, message: string) {
|
||||
const data = await opts.transformer(
|
||||
pattern && channel && message ? message : channel
|
||||
);
|
||||
|
||||
// Resolve the waiting promise to notify the generator of new message arrival
|
||||
if (typeof messageNotifier === 'function') {
|
||||
messageNotifier({ data });
|
||||
}
|
||||
// Clear the notifier to avoid multiple resolutions for a single message
|
||||
messageNotifier = null;
|
||||
}
|
||||
|
||||
// Event listener for messages on the subscribed channel
|
||||
redisSub.on(opts.listenOn, listener);
|
||||
opts.registerListener(listener);
|
||||
|
||||
while (true) {
|
||||
// Wait for a new message
|
||||
const { data } = await waitForMessage;
|
||||
|
||||
// Reset the waiting promise for the next message
|
||||
waitForMessage = new Promise((resolve) => {
|
||||
messageNotifier = resolve;
|
||||
});
|
||||
|
||||
// Yield the received message
|
||||
yield { data };
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { stripTrailingSlash } from '@mixan/common';
|
||||
|
||||
import referrers from '../referrers';
|
||||
|
||||
function getHostname(url: string | undefined) {
|
||||
@@ -18,7 +20,7 @@ export function parseReferrer(url: string | undefined) {
|
||||
return {
|
||||
name: match?.name ?? '',
|
||||
type: match?.type ?? 'unknown',
|
||||
url: url ?? '',
|
||||
url: stripTrailingSlash(url ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
6
apps/web/TOOODOO.md
Normal file
6
apps/web/TOOODOO.md
Normal file
@@ -0,0 +1,6 @@
|
||||
- new org
|
||||
- create project
|
||||
- all trpc mutations seems to break in prod
|
||||
- top event convertions
|
||||
- create events_meta (name, color, icon)
|
||||
- edit event convertion
|
||||
@@ -12,9 +12,9 @@
|
||||
"with-env": "dotenv -e ../../.env -c --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^4.29.6",
|
||||
"@clerk/nextjs": "^4.29.7",
|
||||
"@clickhouse/client": "^0.2.9",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@mixan/common": "workspace:^",
|
||||
"@mixan/db": "workspace:^",
|
||||
"@mixan/queue": "workspace:^",
|
||||
@@ -33,28 +33,28 @@
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@t3-oss/env-nextjs": "^0.7.0",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@trpc/client": "^10.37.1",
|
||||
"@trpc/next": "^10.37.1",
|
||||
"@trpc/react-query": "^10.37.1",
|
||||
"@trpc/server": "^10.37.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.3",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.11.8",
|
||||
"@trpc/client": "^10.45.1",
|
||||
"@trpc/next": "^10.45.1",
|
||||
"@trpc/react-query": "^10.45.1",
|
||||
"@trpc/server": "^10.45.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.1",
|
||||
"hamburger-react": "^2.5.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.323.0",
|
||||
"mathjs": "^12.3.0",
|
||||
"mathjs": "^12.3.2",
|
||||
"mitt": "^3.0.1",
|
||||
"next": "~14.0.4",
|
||||
"next-auth": "^4.23.0",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-themes": "^0.2.1",
|
||||
"nuqs": "^1.15.2",
|
||||
"nuqs": "^1.16.1",
|
||||
"prisma-error-enum": "^0.1.3",
|
||||
"ramda": "^0.29.1",
|
||||
"random-animal-name": "^0.1.1",
|
||||
@@ -62,45 +62,48 @@
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-animated-numbers": "^0.18.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-hook-form": "^7.50.1",
|
||||
"react-in-viewport": "1.0.0-alpha.30",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-social-icons": "^6.12.0",
|
||||
"react-svg-worldmap": "2.0.0-alpha.16",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.20",
|
||||
"recharts": "^2.8.0",
|
||||
"react-use-websocket": "^4.7.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.22",
|
||||
"recharts": "^2.12.0",
|
||||
"request-ip": "^3.3.0",
|
||||
"short-unique-id": "^5.0.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"superjson": "^1.13.3",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"usehooks-ts": "^2.14.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mixan/eslint-config": "workspace:*",
|
||||
"@mixan/prettier-config": "workspace:*",
|
||||
"@mixan/tsconfig": "workspace:*",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/ramda": "^0.29.6",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/react-syntax-highlighter": "^15.5.9",
|
||||
"@types/node": "^18.19.15",
|
||||
"@types/ramda": "^0.29.10",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/request-ip": "^0.0.41",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^8.48.0",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.2.2"
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.21.0"
|
||||
|
||||
@@ -1,45 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { OverviewFilters } from '@/components/overview/overview-filters';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { WidgetHead } from '@/components/overview/overview-widget';
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { MetricCardLoading } from '@/components/report/chart/MetricCard';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { Widget, WidgetBody } from '@/components/Widget';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||
import { LiveCounter } from './live-counter';
|
||||
interface OverviewMetricsProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export default function OverviewMetrics() {
|
||||
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
||||
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const { previous, range, interval, metric, setMetric, filters } =
|
||||
useOverviewOptions();
|
||||
|
||||
const reports = [
|
||||
{
|
||||
id: 'Unique visitors',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -60,7 +42,7 @@ export default function OverviewMetrics() {
|
||||
},
|
||||
{
|
||||
id: 'Total sessions',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -81,7 +63,7 @@ export default function OverviewMetrics() {
|
||||
},
|
||||
{
|
||||
id: 'Total pageviews',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -102,7 +84,7 @@ export default function OverviewMetrics() {
|
||||
},
|
||||
{
|
||||
id: 'Views per session',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user_average',
|
||||
@@ -123,7 +105,7 @@ export default function OverviewMetrics() {
|
||||
},
|
||||
{
|
||||
id: 'Bounce rate',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -161,7 +143,7 @@ export default function OverviewMetrics() {
|
||||
},
|
||||
{
|
||||
id: 'Visit duration',
|
||||
projectId: '', // TODO: Remove
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'property_average',
|
||||
@@ -196,90 +178,37 @@ export default function OverviewMetrics() {
|
||||
const selectedMetric = reports[metric]!;
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<ReportRange value={range} onChange={(value) => setRange(value)} />
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" responsive icon={FilterIcon}>
|
||||
Filters
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<LiveCounter initialCount={0} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={Globe2Icon} responsive>
|
||||
Public
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/project/4e2798cb-e255-4e9d-960d-c9ad095aabd7`}
|
||||
>
|
||||
<Eye size={16} className="mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(event) => {}}>
|
||||
<LockIcon size={16} className="mr-2" />
|
||||
Make private
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
|
||||
<div className="p-4 flex gap-2 flex-wrap">
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
{reports.map((report, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative col-span-6 md:col-span-3 lg:col-span-2 group"
|
||||
onClick={() => {
|
||||
setMetric(index);
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<MetricCardLoading />}>
|
||||
<Chart hideID {...report} />
|
||||
</Suspense>
|
||||
{/* add active border */}
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
|
||||
metric === index ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<Widget className="col-span-6">
|
||||
<WidgetHead>
|
||||
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<Chart hideID {...selectedMetric} chartType="linear" />
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
<OverviewTopSources />
|
||||
<OverviewTopPages />
|
||||
<OverviewTopDevices />
|
||||
<OverviewTopEvents />
|
||||
<div className="col-span-6">
|
||||
<OverviewTopGeo />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetContent className="!max-w-lg w-full" side="right">
|
||||
<OverviewFilters />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<>
|
||||
{reports.map((report, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="relative col-span-6 md:col-span-3 lg:col-span-2 group"
|
||||
onClick={() => {
|
||||
setMetric(index);
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={<MetricCardLoading />}>
|
||||
<Chart hideID {...report} />
|
||||
</Suspense>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
|
||||
metric === index ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{/* add active border */}
|
||||
</button>
|
||||
))}
|
||||
<Widget className="col-span-6">
|
||||
<WidgetHead>
|
||||
<div className="title">{selectedMetric.events[0]?.displayName}</div>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<Chart hideID {...selectedMetric} chartType="linear" />
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { ReportRange } from '@/components/report/ReportRange';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SheetTrigger } from '@/components/ui/sheet';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
|
||||
export function OverviewReportRange() {
|
||||
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
||||
useOverviewOptions();
|
||||
|
||||
return <ReportRange value={range} onChange={(value) => setRange(value)} />;
|
||||
}
|
||||
|
||||
export function OverviewFilterSheetTrigger() {
|
||||
const { previous, range, setRange, interval, metric, setMetric, filters } =
|
||||
useOverviewOptions();
|
||||
|
||||
return (
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" responsive icon={FilterIcon}>
|
||||
Filters
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,24 @@
|
||||
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
import { OverviewFilters } from '@/components/overview/overview-filters';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
|
||||
import { OverviewShare } from '@/components/overview/overview-share';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { getExists } from '@/server/pageExists';
|
||||
|
||||
import { db } from '@mixan/db';
|
||||
|
||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||
import OverviewMetrics from './overview-metrics';
|
||||
import {
|
||||
OverviewFilterSheetTrigger,
|
||||
OverviewReportRange,
|
||||
} from './overview-sticky-header';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
@@ -9,14 +26,49 @@ interface PageProps {
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
await getExists(organizationId, projectId);
|
||||
const [share] = await Promise.all([
|
||||
db.shareOverview.findUnique({
|
||||
where: {
|
||||
project_id: projectId,
|
||||
},
|
||||
}),
|
||||
getExists(organizationId, projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageLayout title="Overview" organizationSlug={organizationId}>
|
||||
<OverviewMetrics />
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFilterSheetTrigger />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
<OverviewShare data={share} />
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
<div className="col-span-6 flex flex-wrap gap-2">
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<div className="col-span-6">
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg w-full" side="right">
|
||||
<OverviewFilters projectId={projectId} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||
import { getProjectById } from '@/server/services/project.service';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params: { organizationId, projectId },
|
||||
}: PageProps) {
|
||||
const project = await getProjectById(projectId);
|
||||
const organization = await getOrganizationBySlug(organizationId);
|
||||
return (
|
||||
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div className="leading-none">
|
||||
<span className="text-white mb-4">{organization?.name}</span>
|
||||
<h1 className="text-white text-xl font-medium">{project?.name}</h1>
|
||||
</div>
|
||||
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
|
||||
<Logo className="text-white" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<OverviewMetrics />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
apps/web/src/app/(public)/share/overview/[id]/page.tsx
Normal file
82
apps/web/src/app/(public)/share/overview/[id]/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
|
||||
import {
|
||||
OverviewFilterSheetTrigger,
|
||||
OverviewReportRange,
|
||||
} from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||
import { OverviewFilters } from '@/components/overview/overview-filters';
|
||||
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
|
||||
import OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { getOrganizationBySlug } from '@/server/services/organization.service';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getShareOverviewById } from '@mixan/db';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({ params: { id } }: PageProps) {
|
||||
const share = await getShareOverviewById(id);
|
||||
if (!share) {
|
||||
return notFound();
|
||||
}
|
||||
const projectId = share.project_id;
|
||||
const organization = await getOrganizationBySlug(share.organization_slug);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-end mb-4">
|
||||
<div className="leading-none">
|
||||
<span className="text-white mb-4">{organization?.name}</span>
|
||||
<h1 className="text-white text-xl font-medium">
|
||||
{share.project?.name}
|
||||
</h1>
|
||||
</div>
|
||||
<a href="https://openpanel.dev?utm_source=openpanel.dev&utm_medium=share">
|
||||
<Logo className="text-white" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50">
|
||||
<Sheet>
|
||||
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
|
||||
<div className="flex gap-2">
|
||||
<OverviewReportRange />
|
||||
<OverviewFilterSheetTrigger />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ServerLiveCounter projectId={projectId} />
|
||||
</div>
|
||||
</StickyBelowHeader>
|
||||
<div className="p-4 grid gap-4 grid-cols-6">
|
||||
<div className="col-span-6 flex flex-wrap gap-2">
|
||||
<OverviewFiltersButtons />
|
||||
</div>
|
||||
<OverviewMetrics projectId={projectId} />
|
||||
<OverviewTopSources projectId={projectId} />
|
||||
<OverviewTopPages projectId={projectId} />
|
||||
<OverviewTopDevices projectId={projectId} />
|
||||
<OverviewTopEvents projectId={projectId} />
|
||||
<div className="col-span-6">
|
||||
<OverviewTopGeo projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg w-full" side="right">
|
||||
<OverviewFilters projectId={projectId} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ClerkProvider } from '@clerk/nextjs';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpLink } from '@trpc/client';
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { Toaster } from 'sonner';
|
||||
import superjson from 'superjson';
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
@@ -49,6 +50,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{children}
|
||||
<Toaster />
|
||||
<ModalProvider />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
|
||||
11
apps/web/src/components/overview/live-counter/index.tsx
Normal file
11
apps/web/src/components/overview/live-counter/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getLiveVisitors } from '@mixan/db';
|
||||
|
||||
import type { LiveCounterProps } from './live-counter';
|
||||
import LiveCounter from './live-counter';
|
||||
|
||||
export default async function ServerLiveCounter(
|
||||
props: Omit<LiveCounterProps, 'data'>
|
||||
) {
|
||||
const count = await getLiveVisitors(props.projectId);
|
||||
return <LiveCounter data={count} {...props} />;
|
||||
}
|
||||
@@ -1,60 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import AnimatedNumbers from 'react-animated-numbers';
|
||||
import dynamic from 'next/dynamic';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getSafeJson } from '@mixan/common';
|
||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||
|
||||
interface LiveCounterProps {
|
||||
initialCount: number;
|
||||
export interface LiveCounterProps {
|
||||
data: number;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
|
||||
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
ssr: false,
|
||||
loading: () => <div>0</div>,
|
||||
});
|
||||
|
||||
const FIFTEEN_SECONDS = 1000 * 15;
|
||||
|
||||
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
|
||||
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
||||
.replace(/^https/, 'wss')
|
||||
.replace(/^http/, 'ws');
|
||||
const client = useQueryClient();
|
||||
const [counter, setCounter] = useState(initialCount);
|
||||
const { projectId } = useAppParams();
|
||||
const [es] = useState(
|
||||
typeof window != 'undefined' &&
|
||||
new EventSource(`http://localhost:3333/live/events/${projectId}`)
|
||||
);
|
||||
const [counter, setCounter] = useState(data);
|
||||
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
|
||||
const lastRefresh = useRef(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!es) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function handler(event: MessageEvent<string>) {
|
||||
const parsed = getSafeJson<{
|
||||
visitors: number;
|
||||
event: IServiceCreateEventPayload | null;
|
||||
}>(event.data);
|
||||
|
||||
if (parsed) {
|
||||
setCounter(parsed.visitors);
|
||||
if (parsed.event) {
|
||||
useWebSocket(socketUrl, {
|
||||
shouldReconnect: () => true,
|
||||
onMessage(event) {
|
||||
const value = parseInt(event.data, 10);
|
||||
if (!isNaN(value)) {
|
||||
setCounter(value);
|
||||
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
|
||||
lastRefresh.current = Date.now();
|
||||
toast('Refreshed data');
|
||||
client.refetchQueries({
|
||||
type: 'active',
|
||||
});
|
||||
toast('New event', {
|
||||
description: `${parsed.event.name}`,
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
es.addEventListener('message', handler);
|
||||
return () => es.removeEventListener('message', handler);
|
||||
}, []);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -79,6 +73,9 @@ export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
|
||||
transitions={(index) => ({
|
||||
type: 'spring',
|
||||
duration: index + 0.3,
|
||||
|
||||
damping: 10,
|
||||
stiffness: 200,
|
||||
})}
|
||||
animateToNumber={counter}
|
||||
locale="en"
|
||||
@@ -20,6 +20,28 @@ export function OverviewFiltersButtons() {
|
||||
<strong>{options.referrer}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.referrerName && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setReferrerName(null)}
|
||||
>
|
||||
<span className="mr-1">Referrer name is</span>
|
||||
<strong>{options.referrerName}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.referrerType && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setReferrerType(null)}
|
||||
>
|
||||
<span className="mr-1">Referrer type is</span>
|
||||
<strong>{options.referrerType}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.device && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Label } from '../ui/label';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
export function OverviewFilters() {
|
||||
const { projectId } = useAppParams();
|
||||
interface OverviewFiltersProps {
|
||||
projectId: string;
|
||||
}
|
||||
export function OverviewFilters({ projectId }: OverviewFiltersProps) {
|
||||
const options = useOverviewOptions();
|
||||
|
||||
const { data: referrers } = api.chart.values.useQuery({
|
||||
|
||||
76
apps/web/src/components/overview/overview-share.tsx
Normal file
76
apps/web/src/components/overview/overview-share.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { pushModal } from '@/modals';
|
||||
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { ShareOverview } from '@mixan/db';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
|
||||
interface OverviewShareProps {
|
||||
data: ShareOverview | null;
|
||||
}
|
||||
|
||||
export function OverviewShare({ data }: OverviewShareProps) {
|
||||
const router = useRouter();
|
||||
const mutation = api.share.shareOverview.useMutation({
|
||||
onSuccess() {
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={data && data.public ? Globe2Icon : LockIcon} responsive>
|
||||
{data && data.public ? 'Public' : 'Private'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
{(!data || data.public === false) && (
|
||||
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
|
||||
<Globe2Icon size={16} className="mr-2" />
|
||||
Make public
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data?.public && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/overview/${data.id}`}
|
||||
>
|
||||
<EyeIcon size={16} className="mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data?.public && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
mutation.mutate({
|
||||
public: false,
|
||||
projectId: data?.project_id,
|
||||
organizationId: data?.organization_slug,
|
||||
password: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LockIcon size={16} className="mr-2" />
|
||||
Make private
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopDevices() {
|
||||
interface OverviewTopDevicesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopDevices({
|
||||
projectId,
|
||||
}: OverviewTopDevicesProps) {
|
||||
const {
|
||||
filters,
|
||||
interval,
|
||||
@@ -18,19 +23,15 @@ export default function OverviewTopDevices() {
|
||||
previous,
|
||||
setBrowser,
|
||||
setBrowserVersion,
|
||||
browser,
|
||||
browserVersion,
|
||||
setOS,
|
||||
setOSVersion,
|
||||
os,
|
||||
osVersion,
|
||||
} = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||
devices: {
|
||||
title: 'Top devices',
|
||||
btn: 'Devices',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -58,7 +59,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top browser',
|
||||
btn: 'Browser',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -86,7 +87,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top Browser Version',
|
||||
btn: 'Browser Version',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -114,7 +115,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top OS',
|
||||
btn: 'OS',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -142,7 +143,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top OS version',
|
||||
btn: 'OS Version',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
|
||||
@@ -10,14 +10,19 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopEvents() {
|
||||
interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopEvents({
|
||||
projectId,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { filters, interval, range, previous } = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
|
||||
all: {
|
||||
title: 'Top events',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,7 +10,10 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopGeo() {
|
||||
interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
|
||||
useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
|
||||
@@ -18,7 +21,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Map',
|
||||
btn: 'Map',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -46,7 +49,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top countries',
|
||||
btn: 'Countries',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -74,7 +77,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top regions',
|
||||
btn: 'Regions',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -102,7 +105,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top cities',
|
||||
btn: 'Cities',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,14 +10,17 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopPages() {
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const { filters, interval, range, previous, setPage } = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
|
||||
top: {
|
||||
title: 'Top pages',
|
||||
btn: 'Top pages',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -45,7 +48,7 @@ export default function OverviewTopPages() {
|
||||
title: 'Entry Pages',
|
||||
btn: 'Entries',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -73,7 +76,7 @@ export default function OverviewTopPages() {
|
||||
title: 'Exit Pages',
|
||||
btn: 'Exits',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopSources() {
|
||||
interface OverviewTopSourcesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const {
|
||||
filters,
|
||||
interval,
|
||||
@@ -22,13 +27,43 @@ export default function OverviewTopSources() {
|
||||
setUtmCampaign,
|
||||
setUtmTerm,
|
||||
setUtmContent,
|
||||
setReferrerName,
|
||||
setReferrerType,
|
||||
} = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
|
||||
all: {
|
||||
title: 'Top sources',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top groups',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
domain: {
|
||||
title: 'Top urls',
|
||||
btn: 'URLs',
|
||||
chart: {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -52,11 +87,39 @@ export default function OverviewTopSources() {
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
type: {
|
||||
title: 'Top types',
|
||||
btn: 'Types',
|
||||
chart: {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_type',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top types',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
utm_source: {
|
||||
title: 'UTM Source',
|
||||
btn: 'Source',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -84,7 +147,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Medium',
|
||||
btn: 'Medium',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -112,7 +175,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Campaign',
|
||||
btn: 'Campaign',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -140,7 +203,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Term',
|
||||
btn: 'Term',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -168,7 +231,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Content',
|
||||
btn: 'Content',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -220,8 +283,16 @@ export default function OverviewTopSources() {
|
||||
onClick={(item) => {
|
||||
switch (widget.key) {
|
||||
case 'all':
|
||||
setReferrerName(item.name);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'domain':
|
||||
setReferrer(item.name);
|
||||
break;
|
||||
case 'type':
|
||||
setReferrerType(item.name);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'utm_source':
|
||||
setUtmSource(item.name);
|
||||
break;
|
||||
|
||||
@@ -33,7 +33,7 @@ export function WidgetButtons({
|
||||
}: WidgetHeadProps) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const sizes = useRef<number[]>([]);
|
||||
const [slice, setSlice] = useState(Children.count(children) - 1);
|
||||
const [slice, setSlice] = useState(-1);
|
||||
const gap = 8;
|
||||
|
||||
const handleResize = useThrottle(() => {
|
||||
|
||||
@@ -30,12 +30,22 @@ export function useOverviewOptions() {
|
||||
);
|
||||
|
||||
// Filters
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
// Referrer
|
||||
const [referrer, setReferrer] = useQueryState(
|
||||
'referrer',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
const [referrerName, setReferrerName] = useQueryState(
|
||||
'referrer_name',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [referrerType, setReferrerType] = useQueryState(
|
||||
'referrer_type',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
@@ -99,14 +109,6 @@ export function useOverviewOptions() {
|
||||
|
||||
const filters = useMemo(() => {
|
||||
const filters: IChartInput['events'][number]['filters'] = [];
|
||||
if (referrer) {
|
||||
filters.push({
|
||||
id: 'referrer',
|
||||
operator: 'is',
|
||||
name: 'referrer',
|
||||
value: [referrer],
|
||||
});
|
||||
}
|
||||
|
||||
if (page) {
|
||||
filters.push({
|
||||
@@ -126,6 +128,33 @@ export function useOverviewOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
if (referrer) {
|
||||
filters.push({
|
||||
id: 'referrer',
|
||||
operator: 'is',
|
||||
name: 'referrer',
|
||||
value: [referrer],
|
||||
});
|
||||
}
|
||||
|
||||
if (referrerName) {
|
||||
filters.push({
|
||||
id: 'referrer_name',
|
||||
operator: 'is',
|
||||
name: 'referrer_name',
|
||||
value: [referrerName],
|
||||
});
|
||||
}
|
||||
|
||||
if (referrerType) {
|
||||
filters.push({
|
||||
id: 'referrer_type',
|
||||
operator: 'is',
|
||||
name: 'referrer_type',
|
||||
value: [referrerType],
|
||||
});
|
||||
}
|
||||
|
||||
if (utmSource) {
|
||||
filters.push({
|
||||
id: 'utm_source',
|
||||
@@ -236,9 +265,11 @@ export function useOverviewOptions() {
|
||||
|
||||
return filters;
|
||||
}, [
|
||||
referrer,
|
||||
page,
|
||||
device,
|
||||
referrer,
|
||||
referrerName,
|
||||
referrerType,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
@@ -260,8 +291,6 @@ export function useOverviewOptions() {
|
||||
setRange,
|
||||
metric,
|
||||
setMetric,
|
||||
referrer,
|
||||
setReferrer,
|
||||
page,
|
||||
setPage,
|
||||
|
||||
@@ -269,6 +298,14 @@ export function useOverviewOptions() {
|
||||
interval,
|
||||
filters,
|
||||
|
||||
// Refs
|
||||
referrer,
|
||||
setReferrer,
|
||||
referrerName,
|
||||
setReferrerName,
|
||||
referrerType,
|
||||
setReferrerType,
|
||||
|
||||
// UTM
|
||||
utmSource,
|
||||
setUtmSource,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ChartEmpty() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'aspect-video w-full max-h-[400px] flex justify-center items-center'
|
||||
'aspect-video w-full max-h-[400px] min-h-[200px] flex justify-center items-center'
|
||||
}
|
||||
>
|
||||
No data
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { IChartSerie } from '@/server/api/routers/chart';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
@@ -53,6 +64,16 @@ export function withChartProivder<ComponentProps>(
|
||||
WrappedComponent: React.FC<ComponentProps>
|
||||
) {
|
||||
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
|
||||
const [mounted, setMounted] = useState(props.chartType === 'metric');
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <ChartLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartProvider {...props}>
|
||||
<WrappedComponent {...props} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ReportAreaChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { NOT_SET_VALUE } from '@/utils/constants';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
|
||||
interface ReportBarChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode, metric, unit, onClick } = useChartContext();
|
||||
const { editMode, metric, onClick } = useChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
@@ -62,7 +50,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
)}
|
||||
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
|
||||
>
|
||||
<div className="flex-1 break-all">{serie.name}</div>
|
||||
<div className="flex-1 break-all flex items-center gap-2">
|
||||
<SerieIcon name={serie.name} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
|
||||
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
||||
<div className="font-bold">
|
||||
|
||||
@@ -19,9 +19,16 @@ interface ReportHistogramChartProps {
|
||||
interval: IInterval;
|
||||
}
|
||||
|
||||
function BarHover(props: any) {
|
||||
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
const bg = theme?.colors?.slate?.['200'] as string;
|
||||
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
|
||||
return (
|
||||
<rect
|
||||
{...{ x, y, width, height, top, left, right, bottom }}
|
||||
rx="8"
|
||||
fill={bg}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({
|
||||
@@ -38,7 +45,7 @@ export function ReportHistogramChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
@@ -41,7 +43,7 @@ export function ReportLineChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal file
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NOT_SET_VALUE } from '@/utils/constants';
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
import {
|
||||
ActivityIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircleIcon,
|
||||
MonitorIcon,
|
||||
MonitorPlayIcon,
|
||||
PhoneIcon,
|
||||
SmartphoneIcon,
|
||||
SquareAsteriskIcon,
|
||||
TabletIcon,
|
||||
TabletSmartphoneIcon,
|
||||
TwitterIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getKeys,
|
||||
getNetworks,
|
||||
networkFor,
|
||||
register,
|
||||
SocialIcon,
|
||||
} from 'react-social-icons';
|
||||
|
||||
interface SerieIconProps extends LucideProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mapper: Record<string, LucideIcon> = {
|
||||
screen_view: MonitorPlayIcon,
|
||||
session_start: ActivityIcon,
|
||||
link_out: ExternalLinkIcon,
|
||||
mobile: SmartphoneIcon,
|
||||
desktop: MonitorIcon,
|
||||
tablet: TabletIcon,
|
||||
[NOT_SET_VALUE]: HelpCircleIcon,
|
||||
};
|
||||
|
||||
const networks = getNetworks();
|
||||
|
||||
register('duckduckgo', {
|
||||
color: 'red',
|
||||
path: 'https://duckduckgo.com/favicon.ico',
|
||||
});
|
||||
|
||||
export function SerieIcon({ name, ...props }: SerieIconProps) {
|
||||
let Icon = mapper[name] ?? null;
|
||||
|
||||
if (name.includes('http')) {
|
||||
Icon = ((_props) => (
|
||||
<SocialIcon network={networkFor(name)} />
|
||||
)) as LucideIcon;
|
||||
}
|
||||
|
||||
if (Icon === null && networks.includes(name.toLowerCase())) {
|
||||
Icon = ((_props) => (
|
||||
<SocialIcon network={name.toLowerCase()} />
|
||||
)) as LucideIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
|
||||
{Icon ? <Icon size={16} {...props} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import type { RouterOutputs } from '@/app/_trpc/client';
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
@@ -33,9 +33,8 @@ export const Chart = memo(
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
initialData,
|
||||
projectId,
|
||||
}: ReportChartProps) {
|
||||
const params = useAppParams();
|
||||
const [data] = api.chart.chart.useSuspenseQuery(
|
||||
{
|
||||
// dont send lineType since it does not need to be sent
|
||||
@@ -48,7 +47,7 @@ export const Chart = memo(
|
||||
range,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
@@ -56,7 +55,6 @@ export const Chart = memo(
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
initialData,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon, FilterIcon } from 'lucide-react';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface EventPropertiesComboboxProps {
|
||||
@@ -16,7 +16,7 @@ export function EventPropertiesCombobox({
|
||||
event,
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartBreakdown } from '@/types';
|
||||
import { SplitIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
|
||||
import { ReportBreakdownMore } from './ReportBreakdownMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const params = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery({
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
});
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
|
||||
@@ -6,12 +6,12 @@ import { Dropdown } from '@/components/Dropdown';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDebounceFn } from '@/hooks/useDebounceFn';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
@@ -28,9 +28,9 @@ export function ReportEvents() {
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const params = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const eventsQuery = api.chart.events.useQuery({
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
});
|
||||
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type {
|
||||
@@ -14,8 +13,8 @@ import type {
|
||||
} from '@/types';
|
||||
import { operators } from '@/utils/constants';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { useChartContext } from '../../chart/ChartProvider';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
@@ -24,7 +23,7 @@ interface FilterProps {
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type { IChartEvent } from '@/types';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../../chart/ChartProvider';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FiltersComboboxProps {
|
||||
@@ -13,7 +13,7 @@ interface FiltersComboboxProps {
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
|
||||
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
|
||||
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
|
||||
const max = limit ?? 5;
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ref.current && data) {
|
||||
setVisibleSeries(
|
||||
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
|
||||
);
|
||||
// ref.current = true;
|
||||
}
|
||||
}, [data, max]);
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>(
|
||||
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { authMiddleware } from '@clerk/nextjs';
|
||||
// Please edit this to allow other routes to be public as needed.
|
||||
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
|
||||
export default authMiddleware({
|
||||
publicRoutes: [],
|
||||
publicRoutes: ['/share/overview/:id', '/api/trpc/chart.chart'],
|
||||
});
|
||||
|
||||
export const config = {
|
||||
|
||||
96
apps/web/src/modals/ShareOverviewModal.tsx
Normal file
96
apps/web/src/modals/ShareOverviewModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { zShareOverview } from '@/utils/validation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { popModal } from '.';
|
||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||
|
||||
const validator = zShareOverview;
|
||||
|
||||
type IForm = z.infer<typeof validator>;
|
||||
|
||||
export default function ShareOverviewModal() {
|
||||
const { projectId, organizationId: organizationSlug } = useAppParams();
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState, control } = useForm<IForm>({
|
||||
resolver: zodResolver(validator),
|
||||
defaultValues: {
|
||||
public: true,
|
||||
password: '',
|
||||
projectId,
|
||||
organizationId: organizationSlug,
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = api.share.shareOverview.useMutation({
|
||||
onError: handleError,
|
||||
onSuccess(res) {
|
||||
router.refresh();
|
||||
toast('Success', {
|
||||
description: `Your overview is now ${
|
||||
res.public ? 'public' : 'private'
|
||||
}`,
|
||||
});
|
||||
popModal();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalContent>
|
||||
<ModalHeader title="Overview access" />
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<Controller
|
||||
name="public"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<label
|
||||
htmlFor="public"
|
||||
className="flex items-center gap-2 text-sm font-medium leading-none mb-4"
|
||||
>
|
||||
<Checkbox
|
||||
id="public"
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
Make it public!
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<InputWithLabel
|
||||
label="Password"
|
||||
placeholder="Make your overview accessable with password"
|
||||
{...register('password')}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={mutation.isLoading}>
|
||||
Update
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</form>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,9 @@ const modals = {
|
||||
EditReport: dynamic(() => import('./EditReport'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
};
|
||||
|
||||
const emitter = mitt<{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { organizationRouter } from './routers/organization';
|
||||
import { profileRouter } from './routers/profile';
|
||||
import { projectRouter } from './routers/project';
|
||||
import { reportRouter } from './routers/report';
|
||||
import { shareRouter } from './routers/share';
|
||||
import { uiRouter } from './routers/ui';
|
||||
import { userRouter } from './routers/user';
|
||||
|
||||
@@ -27,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
event: eventRouter,
|
||||
profile: profileRouter,
|
||||
ui: uiRouter,
|
||||
share: shareRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -226,10 +226,9 @@ export async function getChartData(payload: IGetChartDataInput) {
|
||||
return Object.keys(series).map((key) => {
|
||||
// If we have breakdowns, we want to use the breakdown key as the legend
|
||||
// But only if it successfully broke it down, otherwise we use the getEventLabel
|
||||
const serieName =
|
||||
payload.breakdowns.length && !alphabetIds.includes(key as 'A')
|
||||
? key
|
||||
: getEventLegend(payload.event);
|
||||
const isBreakdown =
|
||||
payload.breakdowns.length && !alphabetIds.includes(key as 'A');
|
||||
const serieName = isBreakdown ? key : getEventLegend(payload.event);
|
||||
const data =
|
||||
payload.chartType === 'area' ||
|
||||
payload.chartType === 'linear' ||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from '@/server/api/trpc';
|
||||
import type { IChartEvent, IChartInput, IChartRange } from '@/types';
|
||||
import { getDaysOldDate } from '@/utils/date';
|
||||
import { average, max, min, round, sum } from '@/utils/math';
|
||||
@@ -103,7 +107,8 @@ export const chartRouter = createTRPCRouter({
|
||||
)(properties);
|
||||
}),
|
||||
|
||||
values: protectedProcedure
|
||||
// TODO: Make this private
|
||||
values: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
event: z.string(),
|
||||
@@ -135,7 +140,8 @@ export const chartRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
chart: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
// TODO: Make this private
|
||||
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const current = getDatesFromRange(input.range);
|
||||
let diff = 0;
|
||||
|
||||
@@ -313,6 +319,11 @@ export const chartRouter = createTRPCRouter({
|
||||
}
|
||||
});
|
||||
|
||||
// await new Promise((res) => {
|
||||
// setTimeout(() => {
|
||||
// res();
|
||||
// }, 100);
|
||||
// });
|
||||
return final;
|
||||
}),
|
||||
});
|
||||
@@ -329,8 +340,8 @@ function getPreviousMetric(
|
||||
((current > previous
|
||||
? current / previous
|
||||
: current < previous
|
||||
? previous / current
|
||||
: 0) -
|
||||
? previous / current
|
||||
: 0) -
|
||||
1) *
|
||||
100,
|
||||
1
|
||||
@@ -345,8 +356,8 @@ function getPreviousMetric(
|
||||
current > previous
|
||||
? 'positive'
|
||||
: current < previous
|
||||
? 'negative'
|
||||
: 'neutral',
|
||||
? 'negative'
|
||||
: 'neutral',
|
||||
value: previous,
|
||||
};
|
||||
}
|
||||
|
||||
29
apps/web/src/server/api/routers/share.ts
Normal file
29
apps/web/src/server/api/routers/share.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||
import { db } from '@/server/db';
|
||||
import { zShareOverview } from '@/utils/validation';
|
||||
import ShortUniqueId from 'short-unique-id';
|
||||
|
||||
const uid = new ShortUniqueId({ length: 6 });
|
||||
|
||||
export const shareRouter = createTRPCRouter({
|
||||
shareOverview: protectedProcedure
|
||||
.input(zShareOverview)
|
||||
.mutation(({ input }) => {
|
||||
return db.shareOverview.upsert({
|
||||
where: {
|
||||
project_id: input.projectId,
|
||||
},
|
||||
create: {
|
||||
id: uid.rnd(),
|
||||
organization_slug: input.organizationId,
|
||||
project_id: input.projectId,
|
||||
public: input.public,
|
||||
password: input.password || null,
|
||||
},
|
||||
update: {
|
||||
public: input.public,
|
||||
password: input.password,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
21
apps/web/src/utils/meta.ts
Normal file
21
apps/web/src/utils/meta.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
const title = 'Openpanel.dev | An open-source alternative to Mixpanel';
|
||||
const description =
|
||||
'Unlock actionable insights effortlessly with Insightful, the open-source analytics library that combines the power of Mixpanel with the simplicity of Plausible. Enjoy a unified overview, predictable pricing, and a vibrant community. Join us in democratizing analytics today!';
|
||||
|
||||
export const defaultMeta: Metadata = {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
images: [
|
||||
{
|
||||
url: 'https://openpanel.dev/ogimage.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -79,3 +79,10 @@ export const zInviteUser = z.object({
|
||||
organizationSlug: z.string(),
|
||||
role: z.enum(['admin', 'org:member']),
|
||||
});
|
||||
|
||||
export const zShareOverview = z.object({
|
||||
organizationId: z.string(),
|
||||
projectId: z.string(),
|
||||
password: z.string().nullable(),
|
||||
public: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './src/profileId';
|
||||
export * from './src/date';
|
||||
export * from './src/object';
|
||||
export * from './src/names';
|
||||
export * from './src/string';
|
||||
|
||||
3
packages/common/src/string.ts
Normal file
3
packages/common/src/string.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function stripTrailingSlash(url: string) {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './src/clickhouse-client';
|
||||
export * from './src/sql-builder';
|
||||
export * from './src/services/salt';
|
||||
export * from './src/services/event.service';
|
||||
export * from './src/services/share.service';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "shares" (
|
||||
"id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"organization_slug" TEXT NOT NULL,
|
||||
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||
"password" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "shares_id_key" ON "shares"("id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "shares_project_id_key" ON "shares"("project_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "shares" ADD CONSTRAINT "shares_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "shares" ALTER COLUMN "password" DROP NOT NULL;
|
||||
@@ -19,10 +19,11 @@ model Project {
|
||||
profiles Profile[]
|
||||
clients Client[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
reports Report[]
|
||||
dashboards Dashboard[]
|
||||
share ShareOverview?
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -151,3 +152,16 @@ model Waitlist {
|
||||
|
||||
@@map("waitlist")
|
||||
}
|
||||
|
||||
model ShareOverview {
|
||||
id String @unique
|
||||
project_id String @unique
|
||||
project Project @relation(fields: [project_id], references: [id])
|
||||
organization_slug String
|
||||
public Boolean @default(false)
|
||||
password String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@map("shares")
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ interface GetEventsOptions {
|
||||
profile?: boolean | Prisma.ProfileSelect;
|
||||
}
|
||||
|
||||
export async function getLiveVisitors(projectId: string) {
|
||||
const keys = await redis.keys(`live:event:${projectId}:*`);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
export async function getEvents(sql: string, options: GetEventsOptions = {}) {
|
||||
const events = await chQuery<IClickhouseEvent>(sql);
|
||||
if (options.profile) {
|
||||
@@ -186,7 +191,12 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
|
||||
});
|
||||
|
||||
redisPub.publish('event', JSON.stringify(transformEvent(event)));
|
||||
redis.set(`live:event:${event.project_id}:${event.profile_id}`, '', 'EX', 10);
|
||||
redis.set(
|
||||
`live:event:${event.project_id}:${event.profile_id}`,
|
||||
'',
|
||||
'EX',
|
||||
60 * 5
|
||||
);
|
||||
|
||||
return {
|
||||
...res,
|
||||
|
||||
12
packages/db/src/services/share.service.ts
Normal file
12
packages/db/src/services/share.service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { db } from '../prisma-client';
|
||||
|
||||
export function getShareOverviewById(id: string) {
|
||||
return db.shareOverview.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
3671
pnpm-lock.yaml
generated
3671
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user