diff options
Diffstat (limited to 'apps/web/components/dashboard')
| -rw-r--r-- | apps/web/components/dashboard/admin/AdminActions.tsx | 79 | ||||
| -rw-r--r-- | apps/web/components/dashboard/admin/ServerStats.tsx | 130 | ||||
| -rw-r--r-- | apps/web/components/dashboard/admin/UserList.tsx | 75 |
3 files changed, 284 insertions, 0 deletions
diff --git a/apps/web/components/dashboard/admin/AdminActions.tsx b/apps/web/components/dashboard/admin/AdminActions.tsx new file mode 100644 index 00000000..783f7e76 --- /dev/null +++ b/apps/web/components/dashboard/admin/AdminActions.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; + +export default function AdminActions() { + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlLinks.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 ( + <div> + <div className="mb-2 mt-8 text-xl font-medium">Actions</div> + <div className="flex flex-col gap-2 sm:w-1/2"> + <ActionButton + variant="destructive" + loading={isRecrawlPending} + onClick={() => + recrawlLinks({ crawlStatus: "failure", runInference: true }) + } + > + Recrawl Failed Links Only + </ActionButton> + <ActionButton + variant="destructive" + loading={isRecrawlPending} + onClick={() => + recrawlLinks({ crawlStatus: "all", runInference: true }) + } + > + Recrawl All Links + </ActionButton> + <ActionButton + variant="destructive" + loading={isRecrawlPending} + onClick={() => + recrawlLinks({ crawlStatus: "all", runInference: false }) + } + > + Recrawl All Links (Without Inference) + </ActionButton> + <ActionButton + variant="destructive" + loading={isReindexPending} + onClick={() => reindexBookmarks()} + > + Reindex All Bookmarks + </ActionButton> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/admin/ServerStats.tsx b/apps/web/components/dashboard/admin/ServerStats.tsx new file mode 100644 index 00000000..06e3421f --- /dev/null +++ b/apps/web/components/dashboard/admin/ServerStats.tsx @@ -0,0 +1,130 @@ +"use client"; + +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useClientConfig } from "@/lib/clientConfig"; +import { api } from "@/lib/trpc"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; + +const REPO_LATEST_RELEASE_API = + "https://api.github.com/repos/hoarder-app/hoarder/releases/latest"; +const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases"; + +function useLatestRelease() { + const { data } = useQuery({ + queryKey: ["latest-release"], + queryFn: async () => { + const res = await fetch(REPO_LATEST_RELEASE_API); + if (!res.ok) { + return undefined; + } + const data = (await res.json()) as { name: string }; + return data.name; + }, + staleTime: 60 * 60 * 1000, + enabled: !useClientConfig().disableNewReleaseCheck, + }); + return data; +} + +function ReleaseInfo() { + const currentRelease = useClientConfig().serverVersion ?? "NA"; + const latestRelease = useLatestRelease(); + + let newRelease; + if (latestRelease && currentRelease != latestRelease) { + newRelease = ( + <a + href={REPO_RELEASE_PAGE} + target="_blank" + className="text-blue-500" + rel="noreferrer" + title="Update available" + > + ({latestRelease} ⬆️) + </a> + ); + } + return ( + <div className="text-nowrap"> + <span className="text-3xl font-semibold">{currentRelease}</span> + <span className="ml-1 text-sm">{newRelease}</span> + </div> + ); +} + +export default function ServerStats() { + const { data: serverStats } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + if (!serverStats) { + return <LoadingSpinner />; + } + + return ( + <> + <div className="mb-2 text-xl font-medium">Server Stats</div> + <div className="flex flex-col gap-4 sm:flex-row"> + <div className="rounded-md border bg-background p-4 sm:w-1/4"> + <div className="text-sm font-medium text-gray-400">Total Users</div> + <div className="text-3xl font-semibold">{serverStats.numUsers}</div> + </div> + <div className="rounded-md border bg-background p-4 sm:w-1/4"> + <div className="text-sm font-medium text-gray-400"> + Total Bookmarks + </div> + <div className="text-3xl font-semibold"> + {serverStats.numBookmarks} + </div> + </div> + <div className="rounded-md border bg-background p-4 sm:w-1/4"> + <div className="text-sm font-medium text-gray-400"> + Server Version + </div> + <ReleaseInfo /> + </div> + </div> + + <div className="sm:w-1/2"> + <div className="mb-2 mt-8 text-xl font-medium">Background Jobs</div> + <Table className="rounded-md border"> + <TableHeader className="bg-gray-200"> + <TableHead>Job</TableHead> + <TableHead>Queued</TableHead> + <TableHead>Pending</TableHead> + <TableHead>Failed</TableHead> + </TableHeader> + <TableBody> + <TableRow> + <TableCell className="lg:w-2/3">Crawling Jobs</TableCell> + <TableCell>{serverStats.crawlStats.queuedInRedis}</TableCell> + <TableCell>{serverStats.crawlStats.pending}</TableCell> + <TableCell>{serverStats.crawlStats.failed}</TableCell> + </TableRow> + <TableRow> + <TableCell>Indexing Jobs</TableCell> + <TableCell>{serverStats.indexingStats.queuedInRedis}</TableCell> + <TableCell>-</TableCell> + <TableCell>-</TableCell> + </TableRow> + <TableRow> + <TableCell>Inference Jobs</TableCell> + <TableCell>{serverStats.inferenceStats.queuedInRedis}</TableCell> + <TableCell>{serverStats.inferenceStats.pending}</TableCell> + <TableCell>{serverStats.inferenceStats.failed}</TableCell> + </TableRow> + </TableBody> + </Table> + </div> + </> + ); +} diff --git a/apps/web/components/dashboard/admin/UserList.tsx b/apps/web/components/dashboard/admin/UserList.tsx new file mode 100644 index 00000000..024325a3 --- /dev/null +++ b/apps/web/components/dashboard/admin/UserList.tsx @@ -0,0 +1,75 @@ +"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 { Trash } from "lucide-react"; +import { useSession } from "next-auth/react"; + +export default function UsersSection() { + const { data: session } = useSession(); + const invalidateUserList = api.useUtils().users.list.invalidate; + const { data: users } = api.users.list.useQuery(); + const { mutate: deleteUser, isPending: isDeletionPending } = + api.users.delete.useMutation({ + onSuccess: () => { + toast({ + description: "User deleted", + }); + invalidateUserList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Something went wrong: ${e.message}`, + }); + }, + }); + + if (!users) { + return <LoadingSpinner />; + } + + return ( + <> + <div className="mb-2 text-xl font-medium">Users List</div> + + <Table> + <TableHeader className="bg-gray-200"> + <TableHead>Name</TableHead> + <TableHead>Email</TableHead> + <TableHead>Role</TableHead> + <TableHead>Action</TableHead> + </TableHeader> + <TableBody> + {users.users.map((u) => ( + <TableRow key={u.id}> + <TableCell className="py-1">{u.name}</TableCell> + <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1 capitalize">{u.role}</TableCell> + <TableCell className="py-1"> + <ActionButton + variant="outline" + onClick={() => deleteUser({ userId: u.id })} + loading={isDeletionPending} + disabled={session!.user.id == u.id} + > + <Trash size={16} color="red" /> + </ActionButton> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </> + ); +} |
