From 484a6b1d41ca527cd12944d0348edfa3e8d8f4d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 9 Feb 2024 15:05:59 +0100 Subject: [PATCH] server side events and ui improvemnt --- .env.example | 4 +- apps/public/src/components/ui/use-toast.ts | 190 -------------- apps/public/src/utils/clipboard.ts | 2 +- apps/sdk-api/package.json | 2 + .../src/controllers/live.controller.ts | 96 +++++++ apps/sdk-api/src/index.ts | 24 +- apps/sdk-api/src/routes/event.router.ts | 14 ++ apps/sdk-api/src/routes/live.router.ts | 19 ++ apps/sdk-api/src/sse/combine.ts | 33 +++ .../sdk-api/src/sse/redis-message-iterator.ts | 49 ++++ apps/web/Dockerfile | 3 - apps/web/package.json | 4 + .../dashboards/[dashboardId]/list-reports.tsx | 2 +- .../dashboards/list-dashboards.tsx | 2 +- .../[projectId]/layout-menu.tsx | 9 +- .../layout-organization-selector.tsx | 24 +- .../[projectId]/layout-project-selector.tsx | 1 + .../[projectId]/live-counter.tsx | 93 +++++++ .../[projectId]/overview-metrics.tsx | 18 +- .../[projectId]/reports/report-editor.tsx | 11 +- .../organization/edit-organization.tsx | 2 +- .../settings/organization/invite-user.tsx | 2 +- .../settings/profile/edit-profile.tsx | 2 +- apps/web/src/app/_trpc/client.tsx | 2 +- apps/web/src/app/layout.tsx | 4 +- .../web/src/components/FullPageEmptyState.tsx | 5 +- apps/web/src/components/Widget.tsx | 2 +- .../overview/overview-top-devices.tsx | 44 ++-- .../overview/overview-top-events.tsx | 6 +- .../components/overview/overview-top-geo.tsx | 44 ++-- .../overview/overview-top-pages.tsx | 20 +- .../overview/overview-top-sources.tsx | 59 ++--- .../components/overview/overview-widget.tsx | 2 + .../components/report/ReportSaveButton.tsx | 2 +- .../components/report/chart/ChartEmpty.tsx | 31 +++ .../components/report/chart/ChartLoading.tsx | 15 ++ .../src/components/report/chart/LazyChart.tsx | 15 +- .../components/report/chart/MetricCard.tsx | 21 +- .../report/chart/ReportAreaChart.tsx | 48 +++- .../report/chart/ReportBarChart.tsx | 235 ++++++++---------- .../report/chart/ReportChartTooltip.tsx | 7 +- .../report/chart/ReportHistogramChart.tsx | 2 +- .../report/chart/ReportLineChart.tsx | 6 +- .../report/chart/ReportPieChart.tsx | 9 +- .../components/report/chart/chart-utils.ts | 6 +- .../web/src/components/report/chart/index.tsx | 86 +------ .../src/components/ui/combobox-advanced.tsx | 2 + apps/web/src/components/ui/combobox.tsx | 4 +- apps/web/src/components/ui/progress.tsx | 30 +++ apps/web/src/components/ui/sonner.tsx | 29 +++ apps/web/src/components/ui/toaster.tsx | 54 ++-- apps/web/src/env.mjs | 13 - apps/web/src/modals/AddClient.tsx | 2 +- apps/web/src/modals/AddDashboard.tsx | 2 +- apps/web/src/modals/AddProject.tsx | 2 +- apps/web/src/modals/EditClient.tsx | 2 +- apps/web/src/modals/EditDashboard.tsx | 2 +- apps/web/src/modals/EditProject.tsx | 2 +- apps/web/src/modals/SaveReport.tsx | 2 +- apps/web/src/server/api/routers/chart.ts | 7 +- apps/web/src/utils/clipboard.ts | 2 +- apps/web/src/utils/math.ts | 7 +- apps/web/tailwind.config.js | 6 +- apps/worker/package.json | 1 + docker/Dockerfile-composed | 3 - docker/Dockerfile-web | 3 - docker/build-composed | 1 - packages/common/src/object.ts | 8 + packages/db/package.json | 1 + packages/db/src/services/event.service.ts | 57 +++-- packages/db/src/sql-builder.ts | 14 +- packages/redis/index.ts | 2 + pnpm-lock.yaml | 210 +++++++++++++++- 73 files changed, 1095 insertions(+), 650 deletions(-) delete mode 100644 apps/public/src/components/ui/use-toast.ts create mode 100644 apps/sdk-api/src/controllers/live.controller.ts create mode 100644 apps/sdk-api/src/routes/live.router.ts create mode 100644 apps/sdk-api/src/sse/combine.ts create mode 100644 apps/sdk-api/src/sse/redis-message-iterator.ts create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/live-counter.tsx create mode 100644 apps/web/src/components/report/chart/ChartEmpty.tsx create mode 100644 apps/web/src/components/report/chart/ChartLoading.tsx create mode 100644 apps/web/src/components/ui/progress.tsx create mode 100644 apps/web/src/components/ui/sonner.tsx diff --git a/.env.example b/.env.example index 715f62fd..b55b157b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,3 @@ # Ready for docker-compose REDIS_URL="redis://127.0.0.1:6379" -DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable" -NEXTAUTH_SECRET="secret_sauce" -NEXTAUTH_URL="http://localhost:3000" \ No newline at end of file +DATABASE_URL="postgres://username:password@127.0.0.1:5435/postgres?sslmode=disable" \ No newline at end of file diff --git a/apps/public/src/components/ui/use-toast.ts b/apps/public/src/components/ui/use-toast.ts deleted file mode 100644 index e6807d07..00000000 --- a/apps/public/src/components/ui/use-toast.ts +++ /dev/null @@ -1,190 +0,0 @@ -'use client'; - -// Inspired by react-hot-toast library -import * as React from 'react'; -import type { ToastActionElement, ToastProps } from '@/components/ui/toast'; - -const TOAST_LIMIT = 1; -const TOAST_REMOVE_DELAY = 1000000; - -type ToasterToast = ToastProps & { - id: string; - title?: React.ReactNode; - description?: React.ReactNode; - action?: ToastActionElement; -}; - -const actionTypes = { - ADD_TOAST: 'ADD_TOAST', - UPDATE_TOAST: 'UPDATE_TOAST', - DISMISS_TOAST: 'DISMISS_TOAST', - REMOVE_TOAST: 'REMOVE_TOAST', -} as const; - -let count = 0; - -function genId() { - count = (count + 1) % Number.MAX_VALUE; - return count.toString(); -} - -type ActionType = typeof actionTypes; - -type Action = - | { - type: ActionType['ADD_TOAST']; - toast: ToasterToast; - } - | { - type: ActionType['UPDATE_TOAST']; - toast: Partial; - } - | { - type: ActionType['DISMISS_TOAST']; - toastId?: ToasterToast['id']; - } - | { - type: ActionType['REMOVE_TOAST']; - toastId?: ToasterToast['id']; - }; - -interface State { - toasts: ToasterToast[]; -} - -const toastTimeouts = new Map>(); - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return; - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId); - dispatch({ - type: 'REMOVE_TOAST', - toastId: toastId, - }); - }, TOAST_REMOVE_DELAY); - - toastTimeouts.set(toastId, timeout); -}; - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'ADD_TOAST': - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - }; - - case 'UPDATE_TOAST': - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - }; - - case 'DISMISS_TOAST': { - const { toastId } = action; - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId); - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id); - }); - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - }; - } - case 'REMOVE_TOAST': - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - }; - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - }; - } -}; - -const listeners: ((state: State) => void)[] = []; - -let memoryState: State = { toasts: [] }; - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action); - listeners.forEach((listener) => { - listener(memoryState); - }); -} - -export type Toast = Omit; - -function toast({ ...props }: Toast) { - const id = genId(); - - const update = (props: ToasterToast) => - dispatch({ - type: 'UPDATE_TOAST', - toast: { ...props, id }, - }); - const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id }); - - dispatch({ - type: 'ADD_TOAST', - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); - }, - }, - }); - - return { - id: id, - dismiss, - update, - }; -} - -function useToast() { - const [state, setState] = React.useState(memoryState); - - React.useEffect(() => { - listeners.push(setState); - return () => { - const index = listeners.indexOf(setState); - if (index > -1) { - listeners.splice(index, 1); - } - }; - }, [state]); - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }), - }; -} - -export { useToast, toast }; diff --git a/apps/public/src/utils/clipboard.ts b/apps/public/src/utils/clipboard.ts index d2b4d29e..171fc623 100644 --- a/apps/public/src/utils/clipboard.ts +++ b/apps/public/src/utils/clipboard.ts @@ -1,4 +1,4 @@ -import { toast } from '@/components/ui/use-toast'; +import { toast } from 'sonner'; export function clipboard(value: string | number) { navigator.clipboard.writeText(value.toString()); diff --git a/apps/sdk-api/package.json b/apps/sdk-api/package.json index 302bfa7a..11579c20 100644 --- a/apps/sdk-api/package.json +++ b/apps/sdk-api/package.json @@ -14,7 +14,9 @@ "@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", "request-ip": "^3.3.0", diff --git a/apps/sdk-api/src/controllers/live.controller.ts b/apps/sdk-api/src/controllers/live.controller.ts new file mode 100644 index 00000000..019cbdd9 --- /dev/null +++ b/apps/sdk-api/src/controllers/live.controller.ts @@ -0,0 +1,96 @@ +import { combine } from '@/sse/combine'; +import { redisMessageIterator } from '@/sse/redis-message-iterator'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import { getSafeJson } from '@mixan/common'; +import type { IServiceCreateEventPayload } from '@mixan/db'; +import { chQuery, getEvents } 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) { + return key.split(':').slice(2) as [string, string]; +} + +export async function test(request: FastifyRequest, reply: FastifyReply) { + const [event] = await getEvents( + `SELECT * FROM events LIMIT 1 OFFSET ${Math.floor(Math.random() * 1000)}` + ); + 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'); +} + +export function events( + request: FastifyRequest<{ + Params: { projectId: string }; + }>, + reply: FastifyReply +) { + const reqProjectId = request.params.projectId; + + // Subscribe + redisSub.subscribe('event'); + redisSub.psubscribe('__key*:*'); + const listeners: ((...args: any[]) => void)[] = []; + + const incomingEvents = redisMessageIterator({ + listenOn: 'message', + async transformer(message) { + const event = getSafeJson(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), + }; + } + } + } + + reply.sse(consumeMessages()); + + reply.raw.on('close', () => { + redisSub.unsubscribe('event'); + redisSub.punsubscribe('__key*:expired'); + listeners.forEach((listener) => redisSub.off('message', listener)); + }); +} diff --git a/apps/sdk-api/src/index.ts b/apps/sdk-api/src/index.ts index 1aacc645..3d1f0643 100644 --- a/apps/sdk-api/src/index.ts +++ b/apps/sdk-api/src/index.ts @@ -1,9 +1,12 @@ import cors from '@fastify/cors'; import Fastify from 'fastify'; +import { FastifySSEPlugin } from 'fastify-sse-v2'; import pino from 'pino'; +import { redisPub } from '@mixan/redis'; + import eventRouter from './routes/event.router'; -import { validateSdkRequest } from './utils/auth'; +import liveRouter from './routes/live.router'; declare module 'fastify' { interface FastifyRequest { @@ -23,22 +26,10 @@ const startServer = async () => { origin: '*', }); + fastify.register(FastifySSEPlugin); fastify.decorateRequest('projectId', ''); - - fastify.addHook('preHandler', (req, reply, done) => { - validateSdkRequest(req.headers) - .then((projectId) => { - req.projectId = projectId; - done(); - }) - .catch((e) => { - console.log(e); - - reply.status(401).send(); - }); - }); - fastify.register(eventRouter, { prefix: '/event' }); + fastify.register(liveRouter, { prefix: '/live' }); fastify.setErrorHandler((error, request, reply) => { fastify.log.error(error); }); @@ -65,6 +56,9 @@ const startServer = async () => { } await fastify.listen({ host: '0.0.0.0', port }); + + // Notify when keys expires + redisPub.config('SET', 'notify-keyspace-events', 'Ex'); } catch (e) { console.error(e); } diff --git a/apps/sdk-api/src/routes/event.router.ts b/apps/sdk-api/src/routes/event.router.ts index 661f3084..0e820dee 100644 --- a/apps/sdk-api/src/routes/event.router.ts +++ b/apps/sdk-api/src/routes/event.router.ts @@ -1,7 +1,21 @@ import * as controller from '@/controllers/event.controller'; +import { validateSdkRequest } from '@/utils/auth'; import type { FastifyPluginCallback } from 'fastify'; const eventRouter: FastifyPluginCallback = (fastify, opts, done) => { + fastify.addHook('preHandler', (req, reply, done) => { + validateSdkRequest(req.headers) + .then((projectId) => { + req.projectId = projectId; + done(); + }) + .catch((e) => { + console.log(e); + + reply.status(401).send(); + }); + }); + fastify.route({ method: 'POST', url: '/', diff --git a/apps/sdk-api/src/routes/live.router.ts b/apps/sdk-api/src/routes/live.router.ts new file mode 100644 index 00000000..c1fc1144 --- /dev/null +++ b/apps/sdk-api/src/routes/live.router.ts @@ -0,0 +1,19 @@ +import * as controller from '@/controllers/live.controller'; +import type { FastifyPluginCallback } from 'fastify'; + +const liveRouter: FastifyPluginCallback = (fastify, opts, done) => { + fastify.route({ + method: 'GET', + url: '/events/test', + handler: controller.test, + }); + + fastify.route({ + method: 'GET', + url: '/events/:projectId', + handler: controller.events, + }); + done(); +}; + +export default liveRouter; diff --git a/apps/sdk-api/src/sse/combine.ts b/apps/sdk-api/src/sse/combine.ts new file mode 100644 index 00000000..27fecea3 --- /dev/null +++ b/apps/sdk-api/src/sse/combine.ts @@ -0,0 +1,33 @@ +// @ts-nocheck + +export async function* combine(iterable: AsyncGenerator[]): 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; +} diff --git a/apps/sdk-api/src/sse/redis-message-iterator.ts b/apps/sdk-api/src/sse/redis-message-iterator.ts new file mode 100644 index 00000000..cb551b86 --- /dev/null +++ b/apps/sdk-api/src/sse/redis-message-iterator.ts @@ -0,0 +1,49 @@ +import { redisSub } from '@mixan/redis'; + +export async function* redisMessageIterator(opts: { + transformer: (payload: string) => Promise; + 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 = new Promise((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 }; + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 6fbb3c5a..1f28c880 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -24,9 +24,6 @@ ENV REDIS_URL=$REDIS_URL ARG NEXTAUTH_SECRET ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET -ARG NEXTAUTH_URL -ENV NEXTAUTH_URL=$NEXTAUTH_URL - ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" diff --git a/apps/web/package.json b/apps/web/package.json index ff507081..8f63f1d6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", @@ -51,12 +52,14 @@ "mitt": "^3.0.1", "next": "~14.0.4", "next-auth": "^4.23.0", + "next-themes": "^0.2.1", "nuqs": "^1.15.2", "prisma-error-enum": "^0.1.3", "ramda": "^0.29.1", "random-animal-name": "^0.1.1", "react": "18.2.0", "react-animate-height": "^3.2.3", + "react-animated-numbers": "^0.18.0", "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-in-viewport": "1.0.0-alpha.30", @@ -68,6 +71,7 @@ "recharts": "^2.8.0", "request-ip": "^3.3.0", "slugify": "^1.6.6", + "sonner": "^1.4.0", "superjson": "^1.13.1", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx index 12fa2c41..51ba9ce5 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/[dashboardId]/list-reports.tsx @@ -109,7 +109,7 @@ export function ListReports({ reports }: ListReportsProps) {
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/list-dashboards.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/list-dashboards.tsx index 1ed860c6..eecc84cf 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/list-dashboards.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/dashboards/list-dashboards.tsx @@ -5,13 +5,13 @@ import { Card, CardActions, CardActionsItem } from '@/components/Card'; import { FullPageEmptyState } from '@/components/FullPageEmptyState'; import { Button } from '@/components/ui/button'; import { ToastAction } from '@/components/ui/toast'; -import { toast } from '@/components/ui/use-toast'; import { useAppParams } from '@/hooks/useAppParams'; import { pushModal } from '@/modals'; import type { IServiceDashboards } from '@/server/services/dashboard.service'; import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; interface ListDashboardsProps { dashboards: IServiceDashboards; diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx index 2d37b9fc..6143410e 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx @@ -39,7 +39,7 @@ function LinkWithIcon({ return ( {dashboards.map((item) => ( +
{item.name} - {item.project.name} + + {item.project.name} +
} href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx index a6b914a6..5312afc0 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-organization-selector.tsx @@ -1,8 +1,10 @@ 'use client'; +import { Combobox } from '@/components/ui/combobox'; import { useAppParams } from '@/hooks/useAppParams'; import type { IServiceOrganization } from '@/server/services/organization.service'; import { Building } from 'lucide-react'; +import { useRouter } from 'next/navigation'; interface LayoutOrganizationSelectorProps { organizations: IServiceOrganization[]; @@ -12,6 +14,7 @@ export default function LayoutOrganizationSelector({ organizations, }: LayoutOrganizationSelectorProps) { const params = useAppParams(); + const router = useRouter(); const organization = organizations.find( (item) => item.slug === params.organizationId @@ -22,9 +25,22 @@ export default function LayoutOrganizationSelector({ } return ( -
- - {organization.name} -
+ item.slug) + .map((item) => ({ + label: item.name, + value: item.slug!, + })) ?? [] + } + onChange={(value) => { + router.push(`/${value}`); + }} + /> ); } diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx index 2e1c00ec..0f5f0452 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-project-selector.tsx @@ -18,6 +18,7 @@ export default function LayoutProjectSelector({ return (
{ diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/live-counter.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/live-counter.tsx new file mode 100644 index 00000000..3bd62b96 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/live-counter.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useEffect, 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 { toast } from 'sonner'; + +import { getSafeJson } from '@mixan/common'; +import type { IServiceCreateEventPayload } from '@mixan/db'; + +interface LiveCounterProps { + initialCount: number; +} + +export function LiveCounter({ initialCount = 0 }: LiveCounterProps) { + 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}`) + ); + + useEffect(() => { + if (!es) { + return () => {}; + } + + function handler(event: MessageEvent) { + const parsed = getSafeJson<{ + visitors: number; + event: IServiceCreateEventPayload | null; + }>(event.data); + + if (parsed) { + setCounter(parsed.visitors); + if (parsed.event) { + client.refetchQueries({ + type: 'active', + }); + toast('New event', { + description: `${parsed.event.name}`, + duration: 2000, + }); + } + } + } + es.addEventListener('message', handler); + return () => es.removeEventListener('message', handler); + }, []); + + return ( + + +
+
+
+
+
+ ({ + type: 'spring', + duration: index + 0.3, + })} + animateToNumber={counter} + locale="en" + /> +
+
+ + {counter} unique visitors last 5 minutes + +
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/overview-metrics.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/overview-metrics.tsx index c7689872..6e3b5d90 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/overview-metrics.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/overview-metrics.tsx @@ -1,5 +1,6 @@ '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'; @@ -10,6 +11,8 @@ 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 { @@ -27,6 +30,7 @@ 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'; export default function OverviewMetrics() { const { previous, range, setRange, interval, metric, setMetric, filters } = @@ -200,6 +204,7 @@ export default function OverviewMetrics() { onChange={(value) => setRange(value)} />
+
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx index e4daf7e6..8e600c7f 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { Suspense, useEffect } from 'react'; import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header'; import { Chart } from '@/components/report/chart'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { ReportChartType } from '@/components/report/ReportChartType'; import { ReportInterval } from '@/components/report/ReportInterval'; import { ReportLineType } from '@/components/report/ReportLineType'; @@ -16,11 +17,9 @@ import { } from '@/components/report/reportSlice'; import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; import { Button } from '@/components/ui/button'; -import { Combobox } from '@/components/ui/combobox'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { useDispatch, useSelector } from '@/redux'; import type { IServiceReport } from '@/server/services/reports.service'; -import { timeRanges } from '@/utils/constants'; import { GanttChartSquareIcon } from 'lucide-react'; interface ReportEditorProps { @@ -73,7 +72,11 @@ export default function ReportEditor({
- {report.ready && } + {report.ready && ( + }> + + + )}
diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/edit-organization.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/edit-organization.tsx index 2e0e06c6..1e43609a 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/edit-organization.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/edit-organization.tsx @@ -3,11 +3,11 @@ import { api, handleError } from '@/app/_trpc/client'; import { InputWithLabel } from '@/components/forms/InputWithLabel'; import { Button } from '@/components/ui/button'; -import { toast } from '@/components/ui/use-toast'; import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; import type { getOrganizationBySlug } from '@/server/services/organization.service'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; const validator = z.object({ diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx index befa6b54..2bae018f 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/organization/invite-user.tsx @@ -1,13 +1,13 @@ import { api } from '@/app/_trpc/client'; import { InputWithLabel } from '@/components/forms/InputWithLabel'; import { Button } from '@/components/ui/button'; -import { toast } from '@/components/ui/use-toast'; import { useAppParams } from '@/hooks/useAppParams'; import { zInviteUser } from '@/utils/validation'; import { zodResolver } from '@hookform/resolvers/zod'; import { SendIcon } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import type { z } from 'zod'; type IForm = z.infer; diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx index a3b7ded3..292175cc 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/profile/edit-profile.tsx @@ -3,12 +3,12 @@ import { api, handleError } from '@/app/_trpc/client'; import { InputWithLabel } from '@/components/forms/InputWithLabel'; import { Button } from '@/components/ui/button'; -import { toast } from '@/components/ui/use-toast'; import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; import type { getUserById } from '@/server/services/user.service'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; const validator = z.object({ diff --git a/apps/web/src/app/_trpc/client.tsx b/apps/web/src/app/_trpc/client.tsx index c43a80f6..7e664017 100644 --- a/apps/web/src/app/_trpc/client.tsx +++ b/apps/web/src/app/_trpc/client.tsx @@ -1,9 +1,9 @@ import type { Toast } from '@/components/ui/use-toast'; -import { toast } from '@/components/ui/use-toast'; import type { AppRouter } from '@/server/api/root'; import type { TRPCClientErrorBase } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query'; import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; +import { toast } from 'sonner'; export const api = createTRPCReact({}); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c5df0a0a..c6b17cc8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -4,7 +4,9 @@ import Providers from './providers'; import '@/styles/globals.css'; -export const metadata = {}; +export const metadata = { + title: 'Overview - Openpanel.dev', +}; export const viewport = { width: 'device-width', diff --git a/apps/web/src/components/FullPageEmptyState.tsx b/apps/web/src/components/FullPageEmptyState.tsx index a998d330..092e9582 100644 --- a/apps/web/src/components/FullPageEmptyState.tsx +++ b/apps/web/src/components/FullPageEmptyState.tsx @@ -1,13 +1,14 @@ +import { BoxSelectIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; interface FullPageEmptyStateProps { - icon: LucideIcon; + icon?: LucideIcon; title: string; children: React.ReactNode; } export function FullPageEmptyState({ - icon: Icon, + icon: Icon = BoxSelectIcon, title, children, }: FullPageEmptyStateProps) { diff --git a/apps/web/src/components/Widget.tsx b/apps/web/src/components/Widget.tsx index 57c50154..926d6366 100644 --- a/apps/web/src/components/Widget.tsx +++ b/apps/web/src/components/Widget.tsx @@ -8,7 +8,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) { return (
diff --git a/apps/web/src/components/overview/overview-top-devices.tsx b/apps/web/src/components/overview/overview-top-devices.tsx index 73516c56..675a6a25 100644 --- a/apps/web/src/components/overview/overview-top-devices.tsx +++ b/apps/web/src/components/overview/overview-top-devices.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Suspense } from 'react'; import { Chart } from '@/components/report/chart'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; @@ -172,26 +174,28 @@ export default function OverviewTopDevices() { - { - // switch (widget.key) { - // case 'browser': - // setWidget('browser_version'); - // // setCountry(item.name); - // break; - // case 'regions': - // setWidget('cities'); - // setRegion(item.name); - // break; - // case 'cities': - // setCity(item.name); - // break; - // } - }} - /> + }> + { + // switch (widget.key) { + // case 'browser': + // setWidget('browser_version'); + // // setCountry(item.name); + // break; + // case 'regions': + // setWidget('cities'); + // setRegion(item.name); + // break; + // case 'cities': + // setCity(item.name); + // break; + // } + }} + /> + diff --git a/apps/web/src/components/overview/overview-top-events.tsx b/apps/web/src/components/overview/overview-top-events.tsx index 1454ea58..a850156e 100644 --- a/apps/web/src/components/overview/overview-top-events.tsx +++ b/apps/web/src/components/overview/overview-top-events.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Suspense } from 'react'; import { Chart } from '@/components/report/chart'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; @@ -67,7 +69,9 @@ export default function OverviewTopEvents() { - + }> + + diff --git a/apps/web/src/components/overview/overview-top-geo.tsx b/apps/web/src/components/overview/overview-top-geo.tsx index c5364901..c4430652 100644 --- a/apps/web/src/components/overview/overview-top-geo.tsx +++ b/apps/web/src/components/overview/overview-top-geo.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Suspense } from 'react'; import { Chart } from '@/components/report/chart'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; @@ -144,26 +146,28 @@ export default function OverviewTopGeo() { - { - switch (widget.key) { - case 'countries': - setWidget('regions'); - setCountry(item.name); - break; - case 'regions': - setWidget('cities'); - setRegion(item.name); - break; - case 'cities': - setCity(item.name); - break; - } - }} - /> + }> + { + switch (widget.key) { + case 'countries': + setWidget('regions'); + setCountry(item.name); + break; + case 'regions': + setWidget('cities'); + setRegion(item.name); + break; + case 'cities': + setCity(item.name); + break; + } + }} + /> + diff --git a/apps/web/src/components/overview/overview-top-pages.tsx b/apps/web/src/components/overview/overview-top-pages.tsx index a970c60e..b91855ea 100644 --- a/apps/web/src/components/overview/overview-top-pages.tsx +++ b/apps/web/src/components/overview/overview-top-pages.tsx @@ -1,6 +1,8 @@ 'use client'; +import { Suspense } from 'react'; import { Chart } from '@/components/report/chart'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; @@ -115,14 +117,16 @@ export default function OverviewTopPages() { - { - setPage(item.name); - }} - /> + }> + { + setPage(item.name); + }} + /> + diff --git a/apps/web/src/components/overview/overview-top-sources.tsx b/apps/web/src/components/overview/overview-top-sources.tsx index d7fd6ebf..fc9b36eb 100644 --- a/apps/web/src/components/overview/overview-top-sources.tsx +++ b/apps/web/src/components/overview/overview-top-sources.tsx @@ -1,7 +1,8 @@ 'use client'; +import { Suspense } from 'react'; import { Chart } from '@/components/report/chart'; -import type { IChartInput } from '@/types'; +import { ChartLoading } from '@/components/report/chart/ChartLoading'; import { cn } from '@/utils/cn'; import { Widget, WidgetBody } from '../Widget'; @@ -211,33 +212,35 @@ export default function OverviewTopSources() { - { - switch (widget.key) { - case 'all': - setReferrer(item.name); - break; - case 'utm_source': - setUtmSource(item.name); - break; - case 'utm_medium': - setUtmMedium(item.name); - break; - case 'utm_campaign': - setUtmCampaign(item.name); - break; - case 'utm_term': - setUtmTerm(item.name); - break; - case 'utm_content': - setUtmContent(item.name); - break; - } - }} - /> + }> + { + switch (widget.key) { + case 'all': + setReferrer(item.name); + break; + case 'utm_source': + setUtmSource(item.name); + break; + case 'utm_medium': + setUtmMedium(item.name); + break; + case 'utm_campaign': + setUtmCampaign(item.name); + break; + case 'utm_term': + setUtmTerm(item.name); + break; + case 'utm_content': + setUtmContent(item.name); + break; + } + }} + /> + diff --git a/apps/web/src/components/overview/overview-widget.tsx b/apps/web/src/components/overview/overview-widget.tsx index 9881fc38..21e712dd 100644 --- a/apps/web/src/components/overview/overview-widget.tsx +++ b/apps/web/src/components/overview/overview-widget.tsx @@ -1,3 +1,5 @@ +'use client'; + import { cn } from '@/utils/cn'; import type { WidgetHeadProps } from '../Widget'; diff --git a/apps/web/src/components/report/ReportSaveButton.tsx b/apps/web/src/components/report/ReportSaveButton.tsx index bcf98235..fd7ce3bd 100644 --- a/apps/web/src/components/report/ReportSaveButton.tsx +++ b/apps/web/src/components/report/ReportSaveButton.tsx @@ -2,11 +2,11 @@ import { api, handleError } from '@/app/_trpc/client'; import { Button } from '@/components/ui/button'; -import { toast } from '@/components/ui/use-toast'; import { useAppParams } from '@/hooks/useAppParams'; import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; import { SaveIcon } from 'lucide-react'; +import { toast } from 'sonner'; import { resetDirty } from './reportSlice'; diff --git a/apps/web/src/components/report/chart/ChartEmpty.tsx b/apps/web/src/components/report/chart/ChartEmpty.tsx new file mode 100644 index 00000000..7570922d --- /dev/null +++ b/apps/web/src/components/report/chart/ChartEmpty.tsx @@ -0,0 +1,31 @@ +import { FullPageEmptyState } from '@/components/FullPageEmptyState'; +import { cn } from '@/utils/cn'; + +import { useChartContext } from './ChartProvider'; +import { MetricCardEmpty } from './MetricCard'; + +export function ChartEmpty() { + const { editMode, chartType } = useChartContext(); + + if (editMode) { + return ( + + We could not find any data for selected events and filter. + + ); + } + + if (chartType === 'metric') { + return ; + } + + return ( +
+ No data +
+ ); +} diff --git a/apps/web/src/components/report/chart/ChartLoading.tsx b/apps/web/src/components/report/chart/ChartLoading.tsx new file mode 100644 index 00000000..b843d126 --- /dev/null +++ b/apps/web/src/components/report/chart/ChartLoading.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/utils/cn'; + +interface ChartLoadingProps { + className?: string; +} +export function ChartLoading({ className }: ChartLoadingProps) { + return ( +
+ ); +} diff --git a/apps/web/src/components/report/chart/LazyChart.tsx b/apps/web/src/components/report/chart/LazyChart.tsx index 9a9a3066..5aa2672c 100644 --- a/apps/web/src/components/report/chart/LazyChart.tsx +++ b/apps/web/src/components/report/chart/LazyChart.tsx @@ -1,10 +1,11 @@ 'use client'; -import React, { useEffect, useRef } from 'react'; +import React, { Suspense, useEffect, useRef } from 'react'; import { useInViewport } from 'react-in-viewport'; import type { ReportChartProps } from '.'; import { Chart } from '.'; +import { ChartLoading } from './ChartLoading'; import type { ChartContextType } from './ChartProvider'; export function LazyChart(props: ReportChartProps & ChartContextType) { @@ -22,11 +23,13 @@ export function LazyChart(props: ReportChartProps & ChartContextType) { return (
- {once.current || inViewport ? ( - - ) : ( -
- )} + }> + {once.current || inViewport ? ( + + ) : ( + + )} +
); } diff --git a/apps/web/src/components/report/chart/MetricCard.tsx b/apps/web/src/components/report/chart/MetricCard.tsx index 6945c3bc..9db79e2c 100644 --- a/apps/web/src/components/report/chart/MetricCard.tsx +++ b/apps/web/src/components/report/chart/MetricCard.tsx @@ -25,7 +25,7 @@ export function MetricCard({ const number = useNumber(); return (
@@ -79,3 +79,22 @@ export function MetricCard({
); } + +export function MetricCardEmpty() { + return ( +
+
+ No data +
+
+ ); +} + +export function MetricCardLoading() { + return ( +
+
+
+
+ ); +} diff --git a/apps/web/src/components/report/chart/ReportAreaChart.tsx b/apps/web/src/components/report/chart/ReportAreaChart.tsx index dc1b8c95..1a47f641 100644 --- a/apps/web/src/components/report/chart/ReportAreaChart.tsx +++ b/apps/web/src/components/report/chart/ReportAreaChart.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import type { IChartData } from '@/app/_trpc/client'; import { AutoSizer } from '@/components/AutoSizer'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; @@ -48,7 +49,7 @@ export function ReportAreaChart({ {({ width }) => ( } /> @@ -69,18 +70,41 @@ export function ReportAreaChart({ /> {series.map((serie) => { + const color = getChartColor(serie.index); return ( - + + + + + + + + + ); })} (); - interface ReportBarChartProps { data: IChartData; } export function ReportBarChart({ data }: ReportBarChartProps) { const { editMode, metric, unit, onClick } = useChartContext(); - const [sorting, setSorting] = useState([]); - const maxCount = Math.max( - ...data.series.map((serie) => serie.metrics[metric]) - ); const number = useNumber(); - const table = useReactTable({ - data: useMemo( - () => (editMode ? data.series : data.series.slice(0, 20)), - [editMode, data] - ), - columns: useMemo(() => { - return [ - columnHelper.accessor((row) => row.name, { - id: 'label', - header: () => 'Label', - cell(info) { - return ( -
- {info.row.original.event.id} - - -
- {info.getValue()} -
-
- {info.getValue()} -
-
- ); - }, - }), - columnHelper.accessor((row) => row.metrics[metric], { - id: 'totalCount', - cell: (info) => ( -
-
-
-
-
- {number.format(info.getValue())} - {unit} -
- -
- ), - header: () => 'Count', - enableSorting: true, - }), - ]; - }, [maxCount, number]), - state: { - sorting, - }, - getCoreRowModel: getCoreRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - }); + const series = useMemo( + () => (editMode ? data.series : data.series.slice(0, 20)), + [data] + ); + const maxCount = Math.max(...series.map((serie) => serie.metrics[metric])); return ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
- {flexRender( - header.column.columnDef.header, - header.getContext() - )} - {{ - asc: , - desc: , - }[header.column.getIsSorted() as string] ?? null} -
-
- ))} -
- ))} -
- - {table.getRowModel().rows.map((row) => ( - +
Event
+
Count
+ + )} + {series.map((serie, index) => { + return ( +
- {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
+
{serie.name}
+
+ +
+ {number.format(serie.metrics.sum)} +
+ +
+
+ ); + })} +
); + + // return ( + // + // + // {table.getHeaderGroups().map((headerGroup) => ( + // + // {headerGroup.headers.map((header) => ( + // + //
+ // {flexRender( + // header.column.columnDef.header, + // header.getContext() + // )} + // {{ + // asc: , + // desc: , + // }[header.column.getIsSorted() as string] ?? null} + //
+ //
+ // ))} + //
+ // ))} + //
+ // + // {table.getRowModel().rows.map((row) => ( + // + // {row.getVisibleCells().map((cell) => ( + // + // {flexRender(cell.column.columnDef.cell, cell.getContext())} + // + // ))} + // + // ))} + // + //
+ // ); } diff --git a/apps/web/src/components/report/chart/ReportChartTooltip.tsx b/apps/web/src/components/report/chart/ReportChartTooltip.tsx index 8b904bc1..b9e867ff 100644 --- a/apps/web/src/components/report/chart/ReportChartTooltip.tsx +++ b/apps/web/src/components/report/chart/ReportChartTooltip.tsx @@ -19,7 +19,7 @@ export function ReportChartTooltip({ active, payload, }: ReportLineChartTooltipProps) { - const { previous, unit } = useChartContext(); + const { unit } = useChartContext(); const getLabel = useMappings(); const interval = useSelector((state) => state.report.interval); const formatDate = useFormatDateInterval(interval); @@ -57,11 +57,6 @@ export function ReportChartTooltip({ {index === 0 && data.date && (
{formatDate(new Date(data.date))}
- {/* {previous && data.previous?.date && ( -
- {formatDate(new Date(data.previous.date))} -
- )} */}
)}
diff --git a/apps/web/src/components/report/chart/ReportHistogramChart.tsx b/apps/web/src/components/report/chart/ReportHistogramChart.tsx index cfa15bb1..64041009 100644 --- a/apps/web/src/components/report/chart/ReportHistogramChart.tsx +++ b/apps/web/src/components/report/chart/ReportHistogramChart.tsx @@ -46,7 +46,7 @@ export function ReportHistogramChart({ {({ width }) => ( diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index a4206ab5..6735a4c1 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -49,7 +49,7 @@ export function ReportLineChart({ {({ width }) => ( {({ width }) => { - const height = Math.min(Math.max(width * 0.5, 250), 400); + const height = Math.min(Math.max(width * 0.5625, 250), 400); return ( - + } /> {pieData.map((item) => { diff --git a/apps/web/src/components/report/chart/chart-utils.ts b/apps/web/src/components/report/chart/chart-utils.ts index cce56acf..90105777 100644 --- a/apps/web/src/components/report/chart/chart-utils.ts +++ b/apps/web/src/components/report/chart/chart-utils.ts @@ -1,5 +1,9 @@ import { round } from '@/utils/math'; export function getYAxisWidth(value: number) { - return round(value, 0).toString().length * 7.5 + 7.5; + if (!isFinite(value)) { + return 7.8 + 7.8; + } + + return round(value, 0).toString().length * 7.8 + 7.8; } diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx index b19221ef..ad52b288 100644 --- a/apps/web/src/components/report/chart/index.tsx +++ b/apps/web/src/components/report/chart/index.tsx @@ -7,6 +7,7 @@ import { useAppParams } from '@/hooks/useAppParams'; import type { IChartInput } from '@/types'; import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation'; +import { ChartEmpty } from './ChartEmpty'; import { withChartProivder } from './ChartProvider'; import { ReportAreaChart } from './ReportAreaChart'; import { ReportBarChart } from './ReportBarChart'; @@ -36,12 +37,7 @@ export const Chart = memo( initialData, }: ReportChartProps) { const params = useAppParams(); - const hasEmptyFilters = events.some((event) => - event.filters.some((filter) => filter.value.length === 0) - ); - const enabled = events.length > 0 && !hasEmptyFilters; - - const chart = api.chart.chart.useQuery( + const [data] = api.chart.chart.useSuspenseQuery( { // dont send lineType since it does not need to be sent lineType: 'monotone', @@ -61,104 +57,46 @@ export const Chart = memo( }, { keepPreviousData: true, - enabled, initialData, } ); - const anyData = Boolean(chart.data?.series?.[0]?.data); - - if (!enabled) { - return ( - - -

- Please select at least one event to see the chart. -

-
- ); - } - - if (chart.isLoading) { - return ( - - {/* */} -

Loading...

-
- ); - } - - if (chart.isError) { - return ( - - -

Something went wrong...

-
- ); - } - - if (!chart.isSuccess) { - return ( - - ); - } - - if (!anyData) { - return ( - - -

No data

-
- ); + if (data.series.length === 0) { + return ; } if (chartType === 'map') { - return ; + return ; } if (chartType === 'histogram') { - return ; + return ; } if (chartType === 'bar') { - return ; + return ; } if (chartType === 'metric') { - return ; + return ; } if (chartType === 'pie') { - return ; + return ; } if (chartType === 'linear') { return ( - + ); } if (chartType === 'area') { return ( - + ); } - return ( - - -

- Chart type "{chartType}" is not supported yet. -

-
- ); + return

Unknown chart type

; }) ); diff --git a/apps/web/src/components/ui/combobox-advanced.tsx b/apps/web/src/components/ui/combobox-advanced.tsx index c23af75f..f8f6e9d5 100644 --- a/apps/web/src/components/ui/combobox-advanced.tsx +++ b/apps/web/src/components/ui/combobox-advanced.tsx @@ -9,6 +9,7 @@ import { CommandInput, CommandItem, } from '@/components/ui/command'; +import { ChevronsUpDownIcon } from 'lucide-react'; import { useOnClickOutside } from 'usehooks-ts'; import { Button } from './button'; @@ -92,6 +93,7 @@ export function ComboboxAdvanced({ })} {value.length > 2 && +{value.length - 2} more}
+ diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index dde972e8..a45304a6 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -35,6 +35,7 @@ export interface ComboboxProps { icon?: LucideIcon; size?: ButtonProps['size']; label?: string; + align?: 'start' | 'end' | 'center'; } export type ExtendedComboboxProps = Omit< @@ -55,6 +56,7 @@ export function Combobox({ searchable, icon: Icon, size, + align = 'start', }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); @@ -85,7 +87,7 @@ export function Combobox({ )} - + {searchable === true && ( , + React.ComponentPropsWithoutRef & { + color: string; + } +>(({ className, value, color, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx new file mode 100644 index 00000000..1128edfc --- /dev/null +++ b/apps/web/src/components/ui/sonner.tsx @@ -0,0 +1,29 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/apps/web/src/components/ui/toaster.tsx b/apps/web/src/components/ui/toaster.tsx index beb7f25c..b38ad1e0 100644 --- a/apps/web/src/components/ui/toaster.tsx +++ b/apps/web/src/components/ui/toaster.tsx @@ -1,35 +1,31 @@ 'use client'; -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport, -} from '@/components/ui/toast'; -import { useToast } from '@/components/ui/use-toast'; +import { useTheme } from 'next-themes'; +import { Toaster as Sonner } from 'sonner'; -export function Toaster() { - const { toasts } = useToast(); +type ToasterProps = React.ComponentProps; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); return ( - - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ); - })} - -
+ ); -} +}; + +export { Toaster }; diff --git a/apps/web/src/env.mjs b/apps/web/src/env.mjs index 32ac1455..df287104 100644 --- a/apps/web/src/env.mjs +++ b/apps/web/src/env.mjs @@ -17,17 +17,6 @@ export const env = createEnv({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), - NEXTAUTH_SECRET: - process.env.NODE_ENV === 'production' - ? z.string() - : z.string().optional(), - NEXTAUTH_URL: z.preprocess( - // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL - // Since NextAuth.js automatically uses the VERCEL_URL if present. - (str) => process.env.VERCEL_URL ?? str, - // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url() - ), }, /** @@ -46,8 +35,6 @@ export const env = createEnv({ runtimeEnv: { DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/apps/web/src/modals/AddClient.tsx b/apps/web/src/modals/AddClient.tsx index 2de4a1db..fa56d455 100644 --- a/apps/web/src/modals/AddClient.tsx +++ b/apps/web/src/modals/AddClient.tsx @@ -8,12 +8,12 @@ import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Combobox } from '@/components/ui/combobox'; import { Label } from '@/components/ui/label'; -import { toast } from '@/components/ui/use-toast'; import { clipboard } from '@/utils/clipboard'; import { zodResolver } from '@hookform/resolvers/zod'; import { Copy } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { Controller, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/AddDashboard.tsx b/apps/web/src/modals/AddDashboard.tsx index 082ffea6..af5412e6 100644 --- a/apps/web/src/modals/AddDashboard.tsx +++ b/apps/web/src/modals/AddDashboard.tsx @@ -4,11 +4,11 @@ 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 { toast } from '@/components/ui/use-toast'; import { useAppParams } from '@/hooks/useAppParams'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/AddProject.tsx b/apps/web/src/modals/AddProject.tsx index 604edf93..d6f3c658 100644 --- a/apps/web/src/modals/AddProject.tsx +++ b/apps/web/src/modals/AddProject.tsx @@ -4,10 +4,10 @@ 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 { toast } from '@/components/ui/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/EditClient.tsx b/apps/web/src/modals/EditClient.tsx index 2cac3767..37435372 100644 --- a/apps/web/src/modals/EditClient.tsx +++ b/apps/web/src/modals/EditClient.tsx @@ -4,11 +4,11 @@ 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 { toast } from '@/components/ui/use-toast'; import type { IClientWithProject } from '@/types'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/EditDashboard.tsx b/apps/web/src/modals/EditDashboard.tsx index ee8862ee..b90634f5 100644 --- a/apps/web/src/modals/EditDashboard.tsx +++ b/apps/web/src/modals/EditDashboard.tsx @@ -4,11 +4,11 @@ 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 { toast } from '@/components/ui/use-toast'; import type { IServiceDashboardWithProject } from '@/server/services/dashboard.service'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/EditProject.tsx b/apps/web/src/modals/EditProject.tsx index 934875f2..d25abfd7 100644 --- a/apps/web/src/modals/EditProject.tsx +++ b/apps/web/src/modals/EditProject.tsx @@ -4,11 +4,11 @@ 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 { toast } from '@/components/ui/use-toast'; import type { IProject } from '@/types'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/modals/SaveReport.tsx b/apps/web/src/modals/SaveReport.tsx index f8959077..0941a37e 100644 --- a/apps/web/src/modals/SaveReport.tsx +++ b/apps/web/src/modals/SaveReport.tsx @@ -6,12 +6,12 @@ import { InputWithLabel } from '@/components/forms/InputWithLabel'; import { Button } from '@/components/ui/button'; import { Combobox } from '@/components/ui/combobox'; import { Label } from '@/components/ui/label'; -import { toast } from '@/components/ui/use-toast'; import { useAppParams } from '@/hooks/useAppParams'; import type { IChartInput } from '@/types'; import { zodResolver } from '@hookform/resolvers/zod'; import { useRouter, useSearchParams } from 'next/navigation'; import { Controller, useForm } from 'react-hook-form'; +import { toast } from 'sonner'; import { z } from 'zod'; import { popModal } from '.'; diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index 66bc7134..67b99f38 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -411,7 +411,12 @@ function getDatesFromRange(range: IChartRange) { let days = 1; if (range === '24h') { - days = 1; + const startDate = getDaysOldDate(days); + const endDate = new Date(); + return { + startDate: startDate.toUTCString(), + endDate: endDate.toUTCString(), + }; } else if (range === '7d') { days = 7; } else if (range === '14d') { diff --git a/apps/web/src/utils/clipboard.ts b/apps/web/src/utils/clipboard.ts index d2b4d29e..171fc623 100644 --- a/apps/web/src/utils/clipboard.ts +++ b/apps/web/src/utils/clipboard.ts @@ -1,4 +1,4 @@ -import { toast } from '@/components/ui/use-toast'; +import { toast } from 'sonner'; export function clipboard(value: string | number) { navigator.clipboard.writeText(value.toString()); diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts index 9360880c..e7a77151 100644 --- a/apps/web/src/utils/math.ts +++ b/apps/web/src/utils/math.ts @@ -6,8 +6,11 @@ export const round = (num: number, decimals = 2) => { }; export const average = (arr: (number | null)[]) => { - const filtered = arr.filter(isNumber); - return filtered.reduce((p, c) => p + c, 0) / filtered.length; + const filtered = arr.filter( + (n): n is number => isNumber(n) && !Number.isNaN(n) && Number.isFinite(n) + ); + const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length; + return Number.isNaN(avg) ? 0 : avg; }; export const sum = (arr: (number | null)[]): number => diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index fba13589..6895c32f 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -16,7 +16,11 @@ const colors = [ /** @type {import('tailwindcss').Config} */ const config = { - safelist: [...colors.map((color) => `chart-${color}`)], + safelist: [ + ...colors.flatMap((color) => + ['text', 'bg'].map((prefix) => `${prefix}-chart-${color}`) + ), + ], content: [ './pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', diff --git a/apps/worker/package.json b/apps/worker/package.json index 3e5aeca3..eda22e82 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -15,6 +15,7 @@ "@mixan/db": "workspace:*", "@mixan/queue": "workspace:*", "@mixan/common": "workspace:*", + "@mixan/redis": "workspace:*", "bullmq": "^5.1.1", "express": "^4.18.2", "ramda": "^0.29.1" diff --git a/docker/Dockerfile-composed b/docker/Dockerfile-composed index fcfe8d2c..dcada1a5 100644 --- a/docker/Dockerfile-composed +++ b/docker/Dockerfile-composed @@ -12,9 +12,6 @@ ENV DATABASE_URL=$DATABASE_URL ARG NEXTAUTH_SECRET="secret_sauce" ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET -ARG NEXTAUTH_URL="http://localhost:3300" -ENV NEXTAUTH_URL=$NEXTAUTH_URL - ARG REDIS_URL="redis://127.0.0.1:6379" ENV REDIS_URL=$REDIS_URL diff --git a/docker/Dockerfile-web b/docker/Dockerfile-web index e70cadb2..15a2b962 100644 --- a/docker/Dockerfile-web +++ b/docker/Dockerfile-web @@ -8,9 +8,6 @@ ENV DATABASE_URL=$DATABASE_URL ARG NEXTAUTH_SECRET="secret_sauce" ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET -ARG NEXTAUTH_URL="http://localhost:3300" -ENV NEXTAUTH_URL=$NEXTAUTH_URL - ARG REDIS_URL="redis://127.0.0.1:6379" ENV REDIS_URL=$REDIS_URL diff --git a/docker/build-composed b/docker/build-composed index e7a127a4..9626e5e8 100755 --- a/docker/build-composed +++ b/docker/build-composed @@ -3,7 +3,6 @@ docker build \ --build-arg DATABASE_URL="postgresql://local@host.docker.internal:5432/mixan?schema=public" \ --build-arg NEXTAUTH_SECRET="secret_sauce" \ - --build-arg NEXTAUTH_URL="http://localhost:3300" \ --build-arg REDIS_URL="redis://127.0.0.1:6379" \ -t mixan/composed:latest \ -t mixan/composed:1.0 \ diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts index 7f23ec22..11c62e51 100644 --- a/packages/common/src/object.ts +++ b/packages/common/src/object.ts @@ -20,3 +20,11 @@ export function toDots( } export const strip = reject(anyPass([isEmpty, isNil])); + +export function getSafeJson(str: string): T | null { + try { + return JSON.parse(str); + } catch (e) { + return null; + } +} diff --git a/packages/db/package.json b/packages/db/package.json index 47f31626..3296fdac 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@mixan/common": "workspace:*", + "@mixan/redis": "workspace:*", "@clickhouse/client": "^0.2.9", "@prisma/client": "^5.1.1", "ramda": "^0.29.1" diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 64614261..0c1b3891 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1,6 +1,7 @@ import { omit } from 'ramda'; import { toDots } from '@mixan/common'; +import { redisPub } from '@mixan/redis'; import { ch, chQuery, formatClickhouseDate } from '../clickhouse-client'; @@ -12,7 +13,7 @@ export interface IClickhouseEvent { referrer: string; referrer_name: string; duration: number; - properties: Record; + properties: Record; created_at: string; country: string; city: string; @@ -91,30 +92,38 @@ export async function createEvent(payload: IServiceCreateEventPayload) { delete payload.properties.hash; } - return ch.insert({ + const event: IClickhouseEvent = { + name: payload.name, + profile_id: payload.profileId, + project_id: payload.projectId, + properties: toDots(omit(['_path'], payload.properties)), + path: payload.path ?? '', + created_at: formatClickhouseDate(payload.createdAt), + country: payload.country ?? '', + city: payload.city ?? '', + region: payload.region ?? '', + os: payload.os ?? '', + os_version: payload.osVersion ?? '', + browser: payload.browser ?? '', + browser_version: payload.browserVersion ?? '', + device: payload.device ?? '', + brand: payload.brand ?? '', + model: payload.model ?? '', + duration: payload.duration, + referrer: payload.referrer ?? '', + referrer_name: payload.referrerName ?? '', + }; + + const res = await ch.insert({ table: 'events', - values: [ - { - name: payload.name, - profile_id: payload.profileId, - project_id: payload.projectId, - properties: toDots(omit(['_path'], payload.properties)), - path: payload.path ?? '', - created_at: formatClickhouseDate(payload.createdAt), - country: payload.country ?? '', - city: payload.city ?? '', - region: payload.region ?? '', - os: payload.os ?? '', - os_version: payload.osVersion ?? '', - browser: payload.browser ?? '', - browser_version: payload.browserVersion ?? '', - device: payload.device ?? '', - brand: payload.brand ?? '', - model: payload.model ?? '', - duration: payload.duration, - referrer: payload.referrer ?? '', - }, - ], + values: [event], format: 'JSONEachRow', }); + + redisPub.publish('event', JSON.stringify(transformEvent(event))); + + return { + ...res, + document: event, + }; } diff --git a/packages/db/src/sql-builder.ts b/packages/db/src/sql-builder.ts index c8805175..ed79ff8b 100644 --- a/packages/db/src/sql-builder.ts +++ b/packages/db/src/sql-builder.ts @@ -12,7 +12,7 @@ export function createSqlBuilder() { offset: number | undefined; } = { where: {}, - from: 'events', + from: 'openpanel.events', select: {}, groupBy: {}, orderBy: {}, @@ -40,8 +40,8 @@ export function createSqlBuilder() { getSelect, getGroupBy, getOrderBy, - getSql: () => - [ + getSql: () => { + const sql = [ getSelect(), getFrom(), getWhere(), @@ -51,6 +51,12 @@ export function createSqlBuilder() { getOffset(), ] .filter(Boolean) - .join(' '), + .join(' '); + console.log('---'); + console.log(sql); + console.log('---'); + + return sql; + }, }; } diff --git a/packages/redis/index.ts b/packages/redis/index.ts index 3b42fdd7..bfc82ac6 100644 --- a/packages/redis/index.ts +++ b/packages/redis/index.ts @@ -1,3 +1,5 @@ import Redis from 'ioredis'; export const redis = new Redis(process.env.REDIS_URL!); +export const redisSub = new Redis(process.env.REDIS_URL!); +export const redisPub = new Redis(process.env.REDIS_URL!); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a377d767..a029f46b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,9 +201,15 @@ importers: '@mixan/queue': specifier: workspace:* version: link:../../packages/queue + '@mixan/redis': + specifier: workspace:* + version: link:../../packages/redis fastify: specifier: ^4.25.2 version: 4.25.2 + fastify-sse-v2: + specifier: ^3.1.2 + version: 3.1.2(fastify@4.25.2) pino: specifier: ^8.17.2 version: 8.17.2 @@ -362,6 +368,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) @@ -434,6 +443,9 @@ importers: next-auth: specifier: ^4.23.0 version: 4.24.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) + next-themes: + specifier: ^0.2.1 + version: 0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) nuqs: specifier: ^1.15.2 version: 1.15.2(next@14.0.4) @@ -452,6 +464,9 @@ importers: react-animate-height: specifier: ^3.2.3 version: 3.2.3(react-dom@18.2.0)(react@18.2.0) + react-animated-numbers: + specifier: ^0.18.0 + version: 0.18.0(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -485,6 +500,9 @@ importers: slugify: specifier: ^1.6.6 version: 1.6.6 + sonner: + specifier: ^1.4.0 + version: 1.4.0(react-dom@18.2.0)(react@18.2.0) superjson: specifier: ^1.13.1 version: 1.13.3 @@ -579,6 +597,9 @@ importers: '@mixan/queue': specifier: workspace:* version: link:../../packages/queue + '@mixan/redis': + specifier: workspace:* + version: link:../../packages/redis bullmq: specifier: ^5.1.1 version: 5.1.1 @@ -665,6 +686,9 @@ importers: '@mixan/common': specifier: workspace:* version: link:../common + '@mixan/redis': + specifier: workspace:* + version: link:../redis '@prisma/client': specifier: ^5.1.1 version: 5.5.2(prisma@5.5.2) @@ -1348,6 +1372,20 @@ packages: dev: false optional: true + /@emotion/is-prop-valid@0.8.8: + resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} + requiresBuild: true + dependencies: + '@emotion/memoize': 0.7.4 + dev: false + optional: true + + /@emotion/memoize@0.7.4: + resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true + dev: false + optional: true + /@esbuild/android-arm64@0.18.20: resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -2871,6 +2909,28 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.34)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.34 + '@types/react-dom': 18.2.14 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.14)(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} peerDependencies: @@ -3185,6 +3245,54 @@ packages: '@babel/runtime': 7.23.9 dev: false + /@react-spring/animated@9.7.3(react@18.2.0): + resolution: {integrity: sha512-5CWeNJt9pNgyvuSzQH+uy2pvTg8Y4/OisoscZIR8/ZNLIOI+CatFBhGZpDGTF/OzdNFsAoGk3wiUYTwoJ0YIvw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/core@9.7.3(react@18.2.0): + resolution: {integrity: sha512-IqFdPVf3ZOC1Cx7+M0cXf4odNLxDC+n7IN3MDcVCTIOSBfqEcBebSv+vlY5AhM0zw05PDbjKrNmBpzv/AqpjnQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/animated': 9.7.3(react@18.2.0) + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/shared@9.7.3(react@18.2.0): + resolution: {integrity: sha512-NEopD+9S5xYyQ0pGtioacLhL2luflh6HACSSDUZOwLHoxA5eku1UPuqcJqjwSD6luKjjLfiLOspxo43FUHKKSA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/types': 9.7.3 + react: 18.2.0 + dev: false + + /@react-spring/types@9.7.3: + resolution: {integrity: sha512-Kpx/fQ/ZFX31OtlqVEFfgaD1ACzul4NksrvIgYfIFq9JpDHFwQkMVZ10tbo0FU/grje4rcL4EIrjekl3kYwgWw==} + dev: false + + /@react-spring/web@9.7.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BXt6BpS9aJL/QdVqEIX9YoUy8CE6TJrU0mNCqSoxdXlIeNcEBWOfIyE6B14ENNsyQKS3wOWkiJfco0tCr/9tUg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@react-spring/animated': 9.7.3(react@18.2.0) + '@react-spring/core': 9.7.3(react@18.2.0) + '@react-spring/shared': 9.7.3(react@18.2.0) + '@react-spring/types': 9.7.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@reduxjs/toolkit@1.9.7(react-redux@8.1.3)(react@18.2.0): resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==} peerDependencies: @@ -4575,7 +4683,7 @@ packages: /dom-helpers@3.4.0: resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} dependencies: - '@babel/runtime': 7.23.2 + '@babel/runtime': 7.23.9 dev: false /dot-case@3.0.4: @@ -5203,6 +5311,10 @@ packages: engines: {node: '>=6.0.0'} dev: false + /fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: false + /fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -5250,6 +5362,17 @@ packages: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false + /fastify-sse-v2@3.1.2(fastify@4.25.2): + resolution: {integrity: sha512-eEgxBv04wWtrIupDKw65DtdI8Q4XJA3IGstefkSU8qgDlUlexc7+cixxowxlSGj1pz/HC3QsKGLhna+fb7snOQ==} + peerDependencies: + fastify: '>=4' + dependencies: + fastify: 4.25.2 + fastify-plugin: 4.5.1 + it-pushable: 1.4.2 + it-to-stream: 1.0.0 + dev: false + /fastify@4.25.2: resolution: {integrity: sha512-SywRouGleDHvRh054onj+lEZnbC1sBCLkR0UY3oyJwjD4BdZJUrxBqfkfCaqn74pVCwBaRHGuL3nEWeHbHzAfw==} dependencies: @@ -5377,6 +5500,24 @@ packages: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true + /framer-motion@10.18.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + dev: false + /fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -5445,6 +5586,10 @@ packages: hasown: 2.0.0 dev: false + /get-iterator@1.0.2: + resolution: {integrity: sha512-v+dm9bNVfOYsY1OrhaCrmyOcYoSeVvbt+hHZ0Au+T+p1y+0Uyj9aMaGIeUTT6xdpRbWzDeYKvfOslPhggQMcsg==} + dev: false + /get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -5962,6 +6107,23 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + /it-pushable@1.4.2: + resolution: {integrity: sha512-vVPu0CGRsTI8eCfhMknA7KIBqqGFolbRx+1mbQ6XuZ7YCz995Qj7L4XUviwClFunisDq96FdxzF5FnAbw15afg==} + dependencies: + fast-fifo: 1.3.2 + dev: false + + /it-to-stream@1.0.0: + resolution: {integrity: sha512-pLULMZMAB/+vbdvbZtebC0nWBTbG581lk6w8P7DfIIIKUfa8FbY7Oi0FxZcFPbxvISs7A9E+cMpLDBc1XhpAOA==} + dependencies: + buffer: 6.0.3 + fast-fifo: 1.3.2 + get-iterator: 1.0.2 + p-defer: 3.0.0 + p-fifo: 1.0.0 + readable-stream: 3.6.2 + dev: false + /iterator.prototype@1.1.2: resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} dependencies: @@ -6411,6 +6573,18 @@ packages: uuid: 8.3.2 dev: false + /next-themes@0.2.1(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + dependencies: + next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /next@13.4.19(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-HuPSzzAbJ1T4BD8e0bs6B9C1kWQ6gv8ykZoRWs5AQoiIuqbGHHdQO7Ljuvg05Q0Z24E2ABozHe6FxDvI6HfyAw==} engines: {node: '>=16.8.0'} @@ -6706,6 +6880,18 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 + /p-defer@3.0.0: + resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} + engines: {node: '>=8'} + dev: false + + /p-fifo@1.0.0: + resolution: {integrity: sha512-IjoCxXW48tqdtDFz6fqo5q1UfFVjjVZe8TC1QRflvNUJtNfCUhxOUw6MOVZhDPjqhSzc26xKdugsO17gmzd5+A==} + dependencies: + fast-fifo: 1.3.2 + p-defer: 3.0.0 + dev: false + /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -7089,6 +7275,18 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-animated-numbers@0.18.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-o/4ho2p5B3FfXpjDpwjYwNq6fTCbHvuQbrHRRJ+SZ3g0+OVssFVIrQBTJ16pVbWXOkjtw61wgCcbJN2VZmuKzg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@react-spring/web': 9.7.3(react-dom@18.2.0)(react@18.2.0) + framer-motion: 10.18.0(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -7782,6 +7980,16 @@ packages: atomic-sleep: 1.0.0 dev: false + /sonner@1.4.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-nvkTsIuOmi9e5Wz5If8ldasJjZNVfwiXYijBi2dbijvTQnQppvMcXTFNxL/NUFWlI2yJ1JX7TREDsg+gYm9WyA==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'}