Merge pull request #361 from documenso/feat/admin-ui-metrics

feat: admin ui for metrics
This commit is contained in:
Lucas Smith
2023-09-13 21:55:09 +10:00
committed by GitHub
14 changed files with 274 additions and 15 deletions

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path'); const path = require('path');
const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({ const { parsed: env } = require('dotenv').config({
path: path.join(__dirname, '../../.env.local'), path: path.join(__dirname, '../../.env.local'),
@ -18,7 +19,10 @@ const config = {
'@documenso/ui', '@documenso/ui',
'@documenso/email', '@documenso/email',
], ],
env, env: {
...env,
APP_VERSION: version,
},
modularizeImports: { modularizeImports: {
'lucide-react': { 'lucide-react': {
transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}',

View File

@ -0,0 +1,30 @@
import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';
export type AdminSectionLayoutProps = {
children: React.ReactNode;
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
const user = await getRequiredServerComponentSession();
if (!isAdmin(user)) {
redirect('/documents');
}
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 gap-x-8 md:mt-8">
<AdminNav className="col-span-12 md:col-span-3 md:flex" />
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">{children}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
'use client';
import { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { BarChart3, User2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type AdminNavProps = HTMLAttributes<HTMLDivElement>;
export const AdminNav = ({ className, ...props }: AdminNavProps) => {
const pathname = usePathname();
return (
<div className={cn('flex gap-x-2.5 gap-y-2 md:flex-col', className)} {...props}>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
Stats
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
disabled
>
<User2 className="mr-2 h-5 w-5" />
Users (Coming Soon)
</Button>
</div>
);
};

View File

@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Admin() {
redirect('/admin/stats');
}

View File

@ -0,0 +1,75 @@
import {
File,
FileCheck,
FileClock,
FileEdit,
Mail,
MailOpen,
PenTool,
User as UserIcon,
UserPlus2,
UserSquare2,
} from 'lucide-react';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { CardMetric } from '~/components/(dashboard)/metric-card/metric-card';
export default async function AdminStatsPage() {
const [usersCount, usersWithSubscriptionsCount, docStats, recipientStats] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
]);
return (
<div>
<h2 className="text-4xl font-semibold">Instance Stats</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-4">
<CardMetric icon={UserIcon} title="Total Users" value={usersCount} />
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric
icon={UserPlus2}
title="Active Subscriptions"
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={UserPlus2} title="App Version" value={`v${process.env.APP_VERSION}`} />
</div>
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2">
<div>
<h3 className="text-3xl font-semibold">Document metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric icon={File} title="Total Documents" value={docStats.ALL} />
<CardMetric icon={FileEdit} title="Drafted Documents" value={docStats.DRAFT} />
<CardMetric icon={FileClock} title="Pending Documents" value={docStats.PENDING} />
<CardMetric icon={FileCheck} title="Completed Documents" value={docStats.COMPLETED} />
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">Recipients metrics</h3>
<div className="mt-8 grid flex-1 grid-cols-2 gap-4">
<CardMetric
icon={UserSquare2}
title="Total Recipients"
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric icon={Mail} title="Documents Received" value={recipientStats.SENT} />
<CardMetric icon={MailOpen} title="Documents Viewed" value={recipientStats.OPENED} />
<CardMetric icon={PenTool} title="Signatures Collected" value={recipientStats.SIGNED} />
</div>
</div>
</div>
</div>
);
}

View File

@ -11,10 +11,12 @@ import {
Monitor, Monitor,
Moon, Moon,
Sun, Sun,
UserCog,
} from 'lucide-react'; } from 'lucide-react';
import { signOut } from 'next-auth/react'; import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client'; import { User } from '@documenso/prisma/client';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
@ -36,8 +38,8 @@ export type ProfileDropdownProps = {
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags(); const { getFlag } = useFeatureFlags();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing'); const isBillingEnabled = getFlag('app_billing');
@ -58,6 +60,19 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel>Account</DropdownMenuLabel> <DropdownMenuLabel>Account</DropdownMenuLabel>
{isUserAdmin && (
<>
<DropdownMenuItem asChild>
<Link href="/admin" className="cursor-pointer">
<UserCog className="mr-2 h-4 w-4" />
Admin
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/settings/profile" className="cursor-pointer"> <Link href="/settings/profile" className="cursor-pointer">
<LucideUser className="mr-2 h-4 w-4" /> <LucideUser className="mr-2 h-4 w-4" />

View File

@ -18,10 +18,10 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr
)} )}
> >
<div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4"> <div className="px-4 pb-6 pt-4 sm:px-4 sm:pb-8 sm:pt-4">
<div className="flex items-start"> <div className="flex items-center">
{Icon && <Icon className="mr-2 h-4 w-4 text-slate-500" />} {Icon && <Icon className="text-muted-foreground mr-2 h-4 w-4" />}
<h3 className="flex items-end text-sm font-medium text-slate-500">{title}</h3> <h3 className="text-primary-forground flex items-end text-sm font-medium">{title}</h3>
</div> </div>
<p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8"> <p className="text-foreground mt-6 text-4xl font-semibold leading-8 md:mt-8">

View File

@ -0,0 +1,5 @@
import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View File

@ -0,0 +1,26 @@
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export const getDocumentStats = async () => {
const counts = await prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
});
const stats: Record<Exclude<ExtendedDocumentStatus, 'INBOX'>, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
counts.forEach((stat) => {
stats[stat.status] = stat._count._all;
stats.ALL += stat._count._all;
});
return stats;
};

View File

@ -0,0 +1,29 @@
import { prisma } from '@documenso/prisma';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientsStats = async () => {
const results = await prisma.recipient.groupBy({
by: ['readStatus', 'signingStatus', 'sendStatus'],
_count: true,
});
const stats = {
TOTAL_RECIPIENTS: 0,
[ReadStatus.OPENED]: 0,
[ReadStatus.NOT_OPENED]: 0,
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count;
stats[signingStatus] += _count;
stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count;
});
return stats;
};

View File

@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
};
export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({
where: {
Subscription: {
some: {
status: SubscriptionStatus.ACTIVE,
},
},
},
});
};

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "Role"[] DEFAULT ARRAY['USER']::"Role"[];

View File

@ -13,6 +13,11 @@ enum IdentityProvider {
GOOGLE GOOGLE
} }
enum Role {
ADMIN
USER
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? name String?
@ -21,6 +26,7 @@ model User {
password String? password String?
source String? source String?
signature String? signature String?
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO) identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]

View File

@ -2,13 +2,8 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".next/**", "!.next/cache/**"]
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"lint": {}, "lint": {},
"dev": { "dev": {
@ -16,10 +11,9 @@
"persistent": true "persistent": true
} }
}, },
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION",
"NEXTAUTH_URL", "NEXTAUTH_URL",
"NEXTAUTH_SECRET", "NEXTAUTH_SECRET",
"NEXT_PUBLIC_APP_URL", "NEXT_PUBLIC_APP_URL",