diff --git a/src/client/styles/global.css b/src/client/styles/global.css index 225d7c16..7b45bcd0 100644 --- a/src/client/styles/global.css +++ b/src/client/styles/global.css @@ -5,3 +5,7 @@ font-weight: 700; font-size: var(--mantine-font-size-xl); } + +.mantine-Table-th { + font-weight: 800; +} diff --git a/src/components/pages/metrics/index.tsx b/src/components/pages/metrics/index.tsx index bcb65b47..791ebed2 100644 --- a/src/components/pages/metrics/index.tsx +++ b/src/components/pages/metrics/index.tsx @@ -3,11 +3,11 @@ import { DatePicker } from '@mantine/dates'; import { IconCalendarSearch, IconCalendarTime } from '@tabler/icons-react'; import dayjs from 'dayjs'; import { lazy, useState } from 'react'; -import FilesUrlsCountGraph from './parts/FilesUrlsCountGraph'; import { StatsCardsSkeleton } from './parts/StatsCards'; import { StatsTablesSkeleton } from './parts/StatsTables'; import { useApiStats } from './useStats'; +const FilesUrlsCountGraph = lazy(() => import('./parts/FilesUrlsCountGraph')); const StorageGraph = lazy(() => import('./parts/StorageGraph')); const ViewsGraph = lazy(() => import('./parts/ViewsGraph')); const StatsCards = lazy(() => import('./parts/StatsCards')); @@ -133,16 +133,16 @@ export default function DashboardMetrics() { - ) : data?.length ? ( + ) : data?.points.length ? (
- - + + - - + +
- +
) : ( diff --git a/src/components/pages/metrics/parts/FilesUrlsCountGraph.tsx b/src/components/pages/metrics/parts/FilesUrlsCountGraph.tsx index 4c712662..95a51842 100644 --- a/src/components/pages/metrics/parts/FilesUrlsCountGraph.tsx +++ b/src/components/pages/metrics/parts/FilesUrlsCountGraph.tsx @@ -1,11 +1,20 @@ -import { Metric } from '@/lib/db/models/metric'; +import { MetricsPoint } from '@/lib/metrics'; import { ChartTooltip, LineChart } from '@mantine/charts'; import { Paper, Title } from '@mantine/core'; +import { useMemo } from 'react'; import { defaultChartProps } from '../statsHelpers'; -export default function FilesUrlsCountGraph({ metrics }: { metrics: Metric[] }) { - const sortedMetrics = metrics.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), +export default function FilesUrlsCountGraph({ points }: { points: MetricsPoint[] }) { + const data = useMemo( + () => + points + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((point) => ({ + date: new Date(point.createdAt).getTime(), + files: point.files, + urls: point.urls, + })), + [points], ); return ( @@ -13,11 +22,7 @@ export default function FilesUrlsCountGraph({ metrics }: { metrics: Metric[] }) Count ({ - date: new Date(metric.createdAt).getTime(), - files: metric.data.files, - urls: metric.data.urls, - }))} + data={data} series={[ { name: 'files', diff --git a/src/components/pages/metrics/parts/StatsCards.tsx b/src/components/pages/metrics/parts/StatsCards.tsx index 03679c96..7d7d07f8 100644 --- a/src/components/pages/metrics/parts/StatsCards.tsx +++ b/src/components/pages/metrics/parts/StatsCards.tsx @@ -1,5 +1,5 @@ import { bytes } from '@/lib/bytes'; -import { Metric } from '@/lib/db/models/metric'; +import { MetricsPoint } from '@/lib/metrics'; import { Group, Paper, rgba, SimpleGrid, Skeleton, Text } from '@mantine/core'; import { IconArrowDown, @@ -21,8 +21,8 @@ function StatCard({ Icon, }: { title: string; - first: number; - last: number; + first: number | bigint; + last: number | bigint; Icon: TablerIcon; formatter?: (value: number) => string; }) { @@ -37,7 +37,7 @@ function StatCard({ return ( - + {title} @@ -45,8 +45,8 @@ function StatCard({ - - {formatter ? formatter(first) : first} + + {formatter ? formatter(Number(first)) : first} new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); +export default function StatsCards({ points }: { points: MetricsPoint[] }) { + if (!points.length) return null; - const recent = sortedMetrics[0]; - const last = sortedMetrics[sortedMetrics.length - 1]; + const recent = points[0]; + const last = points[points.length - 1]; return ( - - + + - - - + + + ); } diff --git a/src/components/pages/metrics/parts/StatsTables.tsx b/src/components/pages/metrics/parts/StatsTables.tsx index eaa89d86..dd647bd0 100644 --- a/src/components/pages/metrics/parts/StatsTables.tsx +++ b/src/components/pages/metrics/parts/StatsTables.tsx @@ -94,10 +94,10 @@ export function StatsTablesSkeleton() { ); } -export default function StatsTables({ data }: { data: Metric[] }) { - if (!data.length) return null; +export default function StatsTables({ latest }: { latest: Metric | null }) { + if (!latest) return null; - const recent = data[0]; // it is sorted by desc so 0 is the first one. + const recent = latest; if (recent.data.filesUsers.length === 0 || recent.data.urlsUsers.length === 0) return null; diff --git a/src/components/pages/metrics/parts/StorageGraph.tsx b/src/components/pages/metrics/parts/StorageGraph.tsx index bd65ae78..ae85d799 100644 --- a/src/components/pages/metrics/parts/StorageGraph.tsx +++ b/src/components/pages/metrics/parts/StorageGraph.tsx @@ -1,12 +1,20 @@ import { bytes } from '@/lib/bytes'; -import { Metric } from '@/lib/db/models/metric'; -import { LineChart, ChartTooltip } from '@mantine/charts'; +import { MetricsPoint } from '@/lib/metrics'; +import { ChartTooltip, LineChart } from '@mantine/charts'; import { Paper, Title } from '@mantine/core'; +import { useMemo } from 'react'; import { defaultChartProps } from '../statsHelpers'; -export default function StorageGraph({ metrics }: { metrics: Metric[] }) { - const sortedMetrics = metrics.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), +export default function StorageGraph({ points }: { points: MetricsPoint[] }) { + const data = useMemo( + () => + points + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((point) => ({ + date: new Date(point.createdAt).getTime(), + storage: point.storage, + })), + [points], ); return ( @@ -16,10 +24,7 @@ export default function StorageGraph({ metrics }: { metrics: Metric[] }) { ({ - date: new Date(metric.createdAt).getTime(), - storage: metric.data.storage, - }))} + data={data} series={[ { name: 'storage', diff --git a/src/components/pages/metrics/parts/ViewsGraph.tsx b/src/components/pages/metrics/parts/ViewsGraph.tsx index e8988c2e..41b2e850 100644 --- a/src/components/pages/metrics/parts/ViewsGraph.tsx +++ b/src/components/pages/metrics/parts/ViewsGraph.tsx @@ -1,22 +1,27 @@ -import { Metric } from '@/lib/db/models/metric'; +import { MetricsPoint } from '@/lib/metrics'; import { ChartTooltip, LineChart } from '@mantine/charts'; import { Paper, Title } from '@mantine/core'; +import { useMemo } from 'react'; import { defaultChartProps } from '../statsHelpers'; -export default function ViewsGraph({ metrics }: { metrics: Metric[] }) { - const sortedMetrics = metrics.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), +export default function ViewsGraph({ points }: { points: MetricsPoint[] }) { + const data = useMemo( + () => + points + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map((point) => ({ + date: new Date(point.createdAt).getTime(), + files: point.fileViews, + urls: point.urlViews, + })), + [points], ); return ( Views ({ - date: new Date(metric.createdAt).getTime(), - files: metric.data.fileViews, - urls: metric.data.urlViews, - }))} + data={data} series={[ { name: 'files', diff --git a/src/components/pages/metrics/statsHelpers.ts b/src/components/pages/metrics/statsHelpers.ts index ac29eb2f..cc029551 100644 --- a/src/components/pages/metrics/statsHelpers.ts +++ b/src/components/pages/metrics/statsHelpers.ts @@ -13,7 +13,10 @@ export const defaultChartProps: Partial & { dataKey: string } = dataKey: 'date', }; -export function percentChange(a: number, b: number): [string, string] { +export function percentChange(a: number | bigint, b: number | bigint): [string, string] { + if (typeof a === 'bigint') a = Number(a); + if (typeof b === 'bigint') b = Number(b); + const change = Math.round(((b - a) / a) * 100); const color = change > 0 ? 'green' : change < 0 ? 'red' : 'gray'; diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts new file mode 100644 index 00000000..b82143dc --- /dev/null +++ b/src/lib/metrics.ts @@ -0,0 +1,72 @@ +import z from 'zod'; +import { prisma } from './db'; +import { Metric } from './db/models/metric'; + +export const metricsPointSchema = z.object({ + id: z.string(), + createdAt: z.date(), + users: z.number(), + files: z.number(), + fileViews: z.number(), + urls: z.number(), + urlViews: z.number(), + storage: z.bigint(), +}); + +export type MetricsPoint = z.infer; + +export function getMetricsPoints(from?: Date, to?: Date): Promise { + if (from && to) { + return prisma.$queryRaw` + SELECT + id, + "createdAt", + (data->>'users')::int AS users, + (data->>'files')::int AS files, + (data->>'fileViews')::int AS "fileViews", + (data->>'urls')::int AS urls, + (data->>'urlViews')::int AS "urlViews", + (data->>'storage')::bigint AS storage + FROM "Metric" + WHERE "createdAt" >= ${from} AND "createdAt" <= ${to} + ORDER BY "createdAt" DESC + `; + } + + return prisma.$queryRaw` + SELECT + id, + "createdAt", + (data->>'users')::int AS users, + (data->>'files')::int AS files, + (data->>'fileViews')::int AS "fileViews", + (data->>'urls')::int AS urls, + (data->>'urlViews')::int AS "urlViews", + (data->>'storage')::bigint AS storage + FROM "Metric" + ORDER BY "createdAt" DESC + `; +} + +export function getLatestMetricsPoint(from?: Date, to?: Date): Promise { + return prisma.metric.findFirst({ + where: from && to ? { createdAt: { gte: from, lte: to } } : undefined, + orderBy: { createdAt: 'desc' }, + }); +} + +export function downsample(points: MetricsPoint[], max: number = 500): MetricsPoint[] { + if (points.length <= max) return points; + + const indices = new Set(); + indices.add(0); + indices.add(points.length - 1); + + const middle = max - 2; + const step = (points.length - 1) / (middle + 1); + for (let i = 1; i <= middle; i++) { + indices.add(Math.round(i * step)); + } + + return [...indices].sort((a, b) => a - b).map((i) => points[i]!); +} diff --git a/src/lib/theme/index.ts b/src/lib/theme/index.ts index 6ba5cb85..62fa81d7 100644 --- a/src/lib/theme/index.ts +++ b/src/lib/theme/index.ts @@ -9,6 +9,7 @@ import { parseThemeColor, rgba, Select, + TableTh, VariantColorsResolver, } from '@mantine/core'; diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 51f16c90..90106759 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -1,16 +1,22 @@ import { ApiError } from '@/lib/api/errors'; import { config } from '@/lib/config'; -import { prisma } from '@/lib/db'; -import { Metric, metricSchema } from '@/lib/db/models/metric'; +import { metricSchema } from '@/lib/db/models/metric'; +import { downsample, getLatestMetricsPoint, getMetricsPoints, metricsPointSchema } from '@/lib/metrics'; import { isAdministrator } from '@/lib/role'; import { zQsBoolean } from '@/lib/validation'; import { userMiddleware } from '@/server/middleware/user'; import typedPlugin from '@/server/typedPlugin'; import z from 'zod'; -export type ApiStatsResponse = Metric[]; +export const apiStatsResponseSchema = z.object({ + latest: metricSchema.nullable(), + points: z.array(metricsPointSchema), +}); + +export type ApiStatsResponse = z.infer; export const PATH = '/api/stats'; + export default typedPlugin( async (server) => { server.get( @@ -39,7 +45,7 @@ export default typedPlugin( all: zQsBoolean.default(false), }), response: { - 200: z.array(metricSchema), + 200: apiStatsResponseSchema, }, tags: ['auth'], }, @@ -60,30 +66,20 @@ export default typedPlugin( if (fromDate > new Date()) throw new ApiError(1059); } - const stats = await prisma.metric.findMany({ - where: { - ...(!all && { - createdAt: { - gte: fromDate, - lte: toDate, - }, - }), - }, - orderBy: { - createdAt: 'desc', - }, - }); + const [latest, points] = await Promise.all([ + getLatestMetricsPoint(!all ? fromDate : undefined, !all ? toDate : undefined), + all ? getMetricsPoints() : getMetricsPoints(fromDate, toDate), + ]); - if (!config.features.metrics.showUserSpecific) { - for (let i = 0; i !== stats.length; ++i) { - const stat = stats[i].data; - - stat.filesUsers = []; - stat.urlsUsers = []; - } + if (latest && !config.features.metrics.showUserSpecific) { + latest.data.filesUsers = []; + latest.data.urlsUsers = []; } - return res.send(stats); + return res.send({ + latest, + points: downsample(points), + }); }, ); },