From 7ddcfb630d3dec3d9fecbfd6a498ca7c572809ec Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Fri, 1 Mar 2024 23:17:27 +0000 Subject: feature: Add an admin page showing server stats and actions --- packages/web/app/dashboard/admin/page.tsx | 123 ++++++++++++++++++++++ packages/web/app/dashboard/components/Sidebar.tsx | 8 ++ packages/web/app/layout.tsx | 6 +- packages/web/lib/providers.tsx | 20 +++- packages/web/lib/testUtils.ts | 2 +- packages/web/server/api/routers/_app.ts | 2 + packages/web/server/api/routers/admin.ts | 86 +++++++++++++++ packages/web/server/auth.ts | 7 ++ 8 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 packages/web/app/dashboard/admin/page.tsx create mode 100644 packages/web/server/api/routers/admin.ts (limited to 'packages/web') diff --git a/packages/web/app/dashboard/admin/page.tsx b/packages/web/app/dashboard/admin/page.tsx new file mode 100644 index 00000000..26b6447b --- /dev/null +++ b/packages/web/app/dashboard/admin/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return ; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + const { data } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlAllLinks.useMutation({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutate: reindexBookmarks, isPending: isReindexPending } = + api.admin.reindexAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + return ( +
+

Admin

+
+ {data ? ( + + + + Stats + Value + + + + + Num Users + {data.numUsers} + + + Num Bookmarks + {data.numBookmarks} + + + Pending Crawling Jobs + {data.pendingCrawls} + + + Pending Indexing Jobs + {data.pendingIndexing} + + + Pending OpenAI Jobs + {data.pendingOpenai} + + +
+ ) : ( + + )} +
+

Actions

+ recrawlLinks()} + > + Recrawl All Links + + reindexBookmarks()} + > + Reindex All Bookmarks + +
+ ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index 010ee103..29e5baed 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { PackageOpen, Settings, Search, + Shield, } from "lucide-react"; import { redirect } from "next/navigation"; import SidebarItem from "./SidebarItem"; @@ -61,6 +62,13 @@ export default async function Sidebar() { name="Settings" path="/dashboard/settings" /> + {session.user.role == "admin" && ( + } + name="Admin" + path="/dashboard/admin" + /> + )} diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index d597063b..a8423031 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -5,6 +5,7 @@ import React from "react"; import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { getServerAuthSession } from "@/server/auth"; const inter = Inter({ subsets: ["latin"] }); @@ -13,15 +14,16 @@ export const metadata: Metadata = { description: "Your AI powered second brain", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerAuthSession(); return ( - + {children} diff --git a/packages/web/lib/providers.tsx b/packages/web/lib/providers.tsx index d14d4d96..0c721c1e 100644 --- a/packages/web/lib/providers.tsx +++ b/packages/web/lib/providers.tsx @@ -6,8 +6,16 @@ import { api } from "./trpc"; import { loggerLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; +import { SessionProvider } from "next-auth/react"; +import { Session } from "next-auth"; -export default function Providers({ children }: { children: React.ReactNode }) { +export default function Providers({ + children, + session, +}: { + children: React.ReactNode; + session: Session | null; +}) { const [queryClient] = React.useState(() => new QueryClient()); const [trpcClient] = useState(() => @@ -28,8 +36,12 @@ export default function Providers({ children }: { children: React.ReactNode }) { ); return ( - - {children} - + + + + {children} + + + ); } diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts index ca9a6474..142ad844 100644 --- a/packages/web/lib/testUtils.ts +++ b/packages/web/lib/testUtils.ts @@ -2,7 +2,6 @@ import { users } from "@hoarder/db/schema"; import { getInMemoryDB } from "@hoarder/db/drizzle"; import { appRouter } from "@/server/api/routers/_app"; import { createCallerFactory } from "@/server/api/trpc"; -import { beforeEach } from "vitest"; export function getTestDB() { return getInMemoryDB(true); @@ -31,6 +30,7 @@ export function getApiCaller(db: TestDB, userId: string) { return createCaller({ user: { id: userId, + role: "user", }, db, }); diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index 6a1b05e9..43ab6f5d 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,4 +1,5 @@ import { router } from "../trpc"; +import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; import { listsAppRouter } from "./lists"; @@ -8,6 +9,7 @@ export const appRouter = router({ apiKeys: apiKeysAppRouter, users: usersAppRouter, lists: listsAppRouter, + admin: adminAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts new file mode 100644 index 00000000..92769151 --- /dev/null +++ b/packages/web/server/api/routers/admin.ts @@ -0,0 +1,86 @@ +import { authedProcedure, router } from "../trpc"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { count } from "drizzle-orm"; +import { bookmarks, users } from "@hoarder/db/schema"; +import { + LinkCrawlerQueue, + OpenAIQueue, + SearchIndexingQueue, +} from "@hoarder/shared/queues"; + +const adminProcedure = authedProcedure.use(function isAdmin(opts) { + const user = opts.ctx.user; + if (user.role != "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return opts.next(opts); +}); + +export const adminAppRouter = router({ + stats: adminProcedure + .output( + z.object({ + numUsers: z.number(), + numBookmarks: z.number(), + pendingCrawls: z.number(), + pendingIndexing: z.number(), + pendingOpenai: z.number(), + }), + ) + .query(async ({ ctx }) => { + const [ + [{ value: numUsers }], + [{ value: numBookmarks }], + pendingCrawls, + pendingIndexing, + pendingOpenai, + ] = await Promise.all([ + ctx.db.select({ value: count() }).from(users), + ctx.db.select({ value: count() }).from(bookmarks), + LinkCrawlerQueue.getWaitingCount(), + SearchIndexingQueue.getWaitingCount(), + OpenAIQueue.getWaitingCount(), + ]); + + return { + numUsers, + numBookmarks, + pendingCrawls, + pendingIndexing, + pendingOpenai, + }; + }), + recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + LinkCrawlerQueue.add("crawl", { + bookmarkId: b.id, + }), + ), + ); + }), + + reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + SearchIndexingQueue.add("search_indexing", { + bookmarkId: b.id, + type: "index", + }), + ), + ); + }), +}); diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index f2c78190..1810c87d 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -16,6 +16,7 @@ declare module "next-auth/jwt" { export interface JWT { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } } @@ -27,8 +28,13 @@ declare module "next-auth" { export interface Session { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } + + export interface DefaultUser { + role: "admin" | "user" | null; + } } const providers: Provider[] = [ @@ -75,6 +81,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, + role: user.role || "user", }; } return token; -- cgit v1.2.3-70-g09d2