mirror of
https://github.com/diced/zipline.git
synced 2026-06-12 10:51:17 -07:00
fix: optimize stats page
This commit is contained in:
@@ -5,3 +5,7 @@
|
||||
font-weight: 700;
|
||||
font-size: var(--mantine-font-size-xl);
|
||||
}
|
||||
|
||||
.mantine-Table-th {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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]!);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
parseThemeColor,
|
||||
rgba,
|
||||
Select,
|
||||
TableTh,
|
||||
VariantColorsResolver,
|
||||
} from '@mantine/core';
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user