fix: optimize stats page

This commit is contained in:
diced
2026-06-01 18:46:18 -07:00
parent e6382b3881
commit 833f8a30cc
11 changed files with 171 additions and 93 deletions
+4
View File
@@ -5,3 +5,7 @@
font-weight: 700;
font-size: var(--mantine-font-size-xl);
}
.mantine-Table-th {
font-weight: 800;
}
+7 -7
View File
@@ -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() {
<StatsCardsSkeleton />
<StatsTablesSkeleton />
</div>
) : data?.length ? (
) : data?.points.length ? (
<div>
<StatsCards data={data} />
<StatsTables data={data} />
<StatsCards points={data.points} />
<StatsTables latest={data.latest} />
<SimpleGrid mt='md' cols={{ base: 1, md: 2 }}>
<FilesUrlsCountGraph metrics={data} />
<ViewsGraph metrics={data} />
<FilesUrlsCountGraph points={data.points} />
<ViewsGraph points={data.points} />
</SimpleGrid>
<div>
<StorageGraph metrics={data} />
<StorageGraph points={data.points} />
</div>
</div>
) : (
@@ -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[] })
<Title order={3}>Count</Title>
<LineChart
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
files: metric.data.files,
urls: metric.data.urls,
}))}
data={data}
series={[
{
name: 'files',
@@ -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 (
<Paper radius='sm' withBorder p='sm'>
<Group justify='space-between'>
<Text size='xl' fw='bolder'>
<Text size='xl' fw={900}>
{title}
</Text>
@@ -45,8 +45,8 @@ function StatCard({
</Group>
<Group justify='flex-start' gap='xs'>
<Text size='xl' fw='bolder'>
{formatter ? formatter(first) : first}
<Text size='lg' fw={600}>
{formatter ? formatter(Number(first)) : first}
</Text>
<Paper
@@ -87,14 +87,11 @@ export function StatsCardsSkeleton() {
);
}
export default function StatsCards({ data }: { data: Metric[] }) {
if (!data.length) return null;
const sortedMetrics = data.sort(
(a, b) => 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 (
<SimpleGrid
@@ -105,28 +102,18 @@ export default function StatsCards({ data }: { data: Metric[] }) {
}}
mb='sm'
>
<StatCard title='Files' first={recent.data.files} last={last.data.files} Icon={IconFiles} />
<StatCard title='URLs' first={recent.data.urls} last={last.data.urls} Icon={IconLink} />
<StatCard title='Files' first={recent.files} last={last.files} Icon={IconFiles} />
<StatCard title='URLs' first={recent.urls} last={last.urls} Icon={IconLink} />
<StatCard
title='Storage Used'
first={recent.data.storage}
last={last.data.storage}
first={recent.storage}
last={last.storage}
formatter={bytes}
Icon={IconDatabase}
/>
<StatCard title='Users' first={recent.data.users} last={last.data.users} Icon={IconUsers} />
<StatCard
title='File Views'
first={recent.data.fileViews}
last={last.data.fileViews}
Icon={IconEyeFilled}
/>
<StatCard
title='URL Views'
first={recent.data.urlViews}
last={last.data.urlViews}
Icon={IconEyeFilled}
/>
<StatCard title='Users' first={recent.users} last={last.users} Icon={IconUsers} />
<StatCard title='File Views' first={recent.fileViews} last={last.fileViews} Icon={IconEyeFilled} />
<StatCard title='URL Views' first={recent.urlViews} last={last.urlViews} Icon={IconEyeFilled} />
</SimpleGrid>
);
}
@@ -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;
@@ -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[] }) {
</Title>
<LineChart
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
storage: metric.data.storage,
}))}
data={data}
series={[
{
name: 'storage',
@@ -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 (
<Paper radius='sm' withBorder p='sm'>
<Title order={3}>Views</Title>
<LineChart
data={sortedMetrics.map((metric) => ({
date: new Date(metric.createdAt).getTime(),
files: metric.data.fileViews,
urls: metric.data.urlViews,
}))}
data={data}
series={[
{
name: 'files',
+4 -1
View File
@@ -13,7 +13,10 @@ export const defaultChartProps: Partial<LineChartProps> & { 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';
+72
View File
@@ -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<typeof metricsPointSchema>;
export function getMetricsPoints(from?: Date, to?: Date): Promise<MetricsPoint[]> {
if (from && to) {
return prisma.$queryRaw<MetricsPoint[]>`
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<MetricsPoint[]>`
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<Metric | null> {
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<number>();
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]!);
}
+1
View File
@@ -9,6 +9,7 @@ import {
parseThemeColor,
rgba,
Select,
TableTh,
VariantColorsResolver,
} from '@mantine/core';
+21 -25
View File
@@ -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<typeof apiStatsResponseSchema>;
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),
});
},
);
},