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),
+ });
},
);
},