diff --git a/website/package-lock.json b/website/package-lock.json index a785fe29..57a0660f 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12332,11 +12332,11 @@ } }, "node_modules/@tanstack/react-table": { - "version": "8.7.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz", - "integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==", + "version": "8.7.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.9.tgz", + "integrity": "sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==", "dependencies": { - "@tanstack/table-core": "8.7.6" + "@tanstack/table-core": "8.7.9" }, "engines": { "node": ">=12" @@ -12351,9 +12351,9 @@ } }, "node_modules/@tanstack/table-core": { - "version": "8.7.6", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz", - "integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA==", + "version": "8.7.9", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.9.tgz", + "integrity": "sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==", "engines": { "node": ">=12" }, @@ -47665,17 +47665,17 @@ } }, "@tanstack/react-table": { - "version": "8.7.6", - "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.6.tgz", - "integrity": "sha512-/QijmMFeP7wDLBnr0MQ/5MlbXePbIL/1nOtkxBC9zvmBu4gDKJEDBqipUyM7Wc/iBpSd0IFyqBlvZvTPD9FYDA==", + "version": "8.7.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.7.9.tgz", + "integrity": "sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==", "requires": { - "@tanstack/table-core": "8.7.6" + "@tanstack/table-core": "8.7.9" } }, "@tanstack/table-core": { - "version": "8.7.6", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.6.tgz", - "integrity": "sha512-sqiNTMzB6cpyL8DFH6/VqW48SwiflLqxQqYpo2wNock7rdVGvlm0BLNI8vZUJbr1+fmmWmHwBvi5OMgZw8n1DA==" + "version": "8.7.9", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.7.9.tgz", + "integrity": "sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==" }, "@testing-library/dom": { "version": "8.19.1", diff --git a/website/public/locales/en/side_menu.json b/website/public/locales/en/side_menu.json index a2275647..cbbb3fad 100644 --- a/website/public/locales/en/side_menu.json +++ b/website/public/locales/en/side_menu.json @@ -8,5 +8,6 @@ "users": "Users", "users_dashboard": "Users Dashboard", "status": "Status", - "status_dashboard": "Status Dashboard" + "status_dashboard": "Status Dashboard", + "trollboard": "Trollboard" } diff --git a/website/src/components/Layout.tsx b/website/src/components/Layout.tsx index 70e2abdb..d08e36e6 100644 --- a/website/src/components/Layout.tsx +++ b/website/src/components/Layout.tsx @@ -74,6 +74,11 @@ export const getAdminLayout = (page: React.ReactElement) => ( pathname: "/admin", icon: Users, }, + { + labelID: "trollboard", + pathname: "/admin/trollboard", + icon: BarChart2, + }, { labelID: "status", pathname: "/admin/status", diff --git a/website/src/components/LeaderboardTable/LeaderboardTable.tsx b/website/src/components/LeaderboardTable/LeaderboardTable.tsx index 8451be9e..d59fc902 100644 --- a/website/src/components/LeaderboardTable/LeaderboardTable.tsx +++ b/website/src/components/LeaderboardTable/LeaderboardTable.tsx @@ -1,15 +1,16 @@ -import { Box, CircularProgress, Flex, useColorModeValue, useToken } from "@chakra-ui/react"; +import { Box, CircularProgress, Flex, Link, useColorModeValue } from "@chakra-ui/react"; import { createColumnHelper } from "@tanstack/react-table"; import { MoreHorizontal } from "lucide-react"; +import NextLink from "next/link"; +import { useSession } from "next-auth/react"; import { useTranslation } from "next-i18next"; -import React, { useCallback, useMemo, useState } from "react"; -import { get } from "src/lib/api"; -import { colors } from "src/styles/Theme/colors"; +import React, { useMemo } from "react"; import { LeaderboardEntity, LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard"; -import useSWRImmutable from "swr/immutable"; - -import { DataTable, DataTableColumnDef, DataTableRowPropsCallback } from "../DataTable"; +import { DataTable, DataTableColumnDef } from "../DataTable"; +import { useBoardPagination } from "./useBoardPagination"; +import { useBoardRowProps } from "./useBoardRowProps"; +import { useFetchBoard } from "./useFetchBoard"; type WindowLeaderboardEntity = LeaderboardEntity & { isSpaceRow?: boolean }; const columnHelper = createColumnHelper(); @@ -34,10 +35,13 @@ export const LeaderboardTable = ({ data: reply, isLoading, error, - } = useSWRImmutable( - `/api/leaderboard?time_frame=${timeFrame}&limit=${limit}&includeUserStats=${!hideCurrentUserRanking}`, - get + lastUpdated, + } = useFetchBoard( + `/api/leaderboard?time_frame=${timeFrame}&limit=${limit}&includeUserStats=${!hideCurrentUserRanking}` ); + const { data: session } = useSession(); + + const isAdmin = session?.user?.role === "admin"; const columns: DataTableColumnDef[] = useMemo( () => [ { @@ -49,6 +53,14 @@ export const LeaderboardTable = ({ }, columnHelper.accessor("display_name", { header: t("user"), + cell: ({ getValue, row }) => + isAdmin ? ( + + {getValue()} + + ) : ( + getValue() + ), }), columnHelper.accessor("leader_score", { header: t("score"), @@ -63,40 +75,32 @@ export const LeaderboardTable = ({ header: t("label"), }), ], - [t] + [isAdmin, t] ); - const lastUpdated = useMemo(() => { - const val = new Date(reply?.last_updated); - return t("last_updated_at", { val, formatParams: { val: { dateStyle: "full", timeStyle: "short" } } }); - }, [t, reply?.last_updated]); - - const [page, setPage] = useState(1); + const { + data: paginatedData, + end, + ...pagnationProps + } = useBoardPagination({ rowPerPage, data: reply?.leaderboard, limit }); const data: WindowLeaderboardEntity[] = useMemo(() => { - if (!reply) { - return []; - } - const start = (page - 1) * rowPerPage; - const end = start + rowPerPage; - const leaderBoardEntities = reply.leaderboard.slice(start, end); - if (hideCurrentUserRanking || !reply.user_stats_window) { - return leaderBoardEntities; + if (hideCurrentUserRanking || !reply?.user_stats_window) { + return paginatedData; } const userStatsWindow: WindowLeaderboardEntity[] = reply.user_stats_window; const userStats = userStatsWindow.find((stats) => stats.highlighted); - if (userStats.rank > end) { - leaderBoardEntities.push( + if (userStats && userStats.rank > end) { + paginatedData.push( { isSpaceRow: true } as WindowLeaderboardEntity, ...reply.user_stats_window.filter( - (stats) => - leaderBoardEntities.findIndex((leaderBoardEntity) => leaderBoardEntity.user_id === stats.user_id) === -1 + (stats) => paginatedData.findIndex((leaderBoardEntity) => leaderBoardEntity.user_id === stats.user_id) === -1 ) // filter to avoid duplicated row ); } - return leaderBoardEntities; - }, [page, rowPerPage, reply, hideCurrentUserRanking]); + return paginatedData; + }, [hideCurrentUserRanking, reply?.user_stats_window, end, paginatedData]); - const rowProps = useLeaderboardRowProps(); + const rowProps = useBoardRowProps(); if (isLoading) { return ; @@ -106,19 +110,13 @@ export const LeaderboardTable = ({ return Unable to load leaderboard; } - const maxPage = Math.ceil(reply.leaderboard.length / rowPerPage); - return ( - data={data} columns={columns} caption={lastUpdated} - disablePagination={limit <= rowPerPage} - disableNext={page >= maxPage} - disablePrevious={page === 1} - onNextClick={() => setPage((p) => p + 1)} - onPreviousClick={() => setPage((p) => p - 1)} rowProps={rowProps} + {...pagnationProps} > ); }; @@ -131,32 +129,3 @@ const SpaceRow = () => { ); }; - -const useLeaderboardRowProps = () => { - const borderColor = useToken("colors", useColorModeValue(colors.light.active, colors.dark.active)); - return useCallback>( - (row) => { - const rowData = row.original; - return rowData.highlighted - ? { - sx: { - // https://stackoverflow.com/questions/37963524/how-to-apply-border-radius-to-tr-in-bootstrap - position: "relative", - "td:first-of-type:before": { - borderLeft: `6px solid ${borderColor}`, - content: `""`, - display: "block", - width: "10px", - height: "100%", - left: 0, - top: 0, - borderRadius: "6px 0 0 6px", - position: "absolute", - }, - }, - } - : {}; - }, - [borderColor] - ); -}; diff --git a/website/src/components/LeaderboardTable/TrollboardTable.tsx b/website/src/components/LeaderboardTable/TrollboardTable.tsx new file mode 100644 index 00000000..1b1ea118 --- /dev/null +++ b/website/src/components/LeaderboardTable/TrollboardTable.tsx @@ -0,0 +1,124 @@ +import { Box, CircularProgress, Flex, Link } from "@chakra-ui/react"; +import { createColumnHelper } from "@tanstack/react-table"; +import { ThumbsDown, ThumbsUp } from "lucide-react"; +import NextLink from "next/link"; +import { FetchTrollBoardResponse, TrollboardEntity, TrollboardTimeFrame } from "src/types/Trollboard"; + +import { DataTable } from "../DataTable"; +import { useBoardPagination } from "./useBoardPagination"; +import { useBoardRowProps } from "./useBoardRowProps"; +import { useFetchBoard } from "./useFetchBoard"; +const columnHelper = createColumnHelper(); + +const toPercentage = (num: number) => `${Math.round(num * 100)}%`; + +const columns = [ + columnHelper.accessor("rank", {}), + columnHelper.accessor("display_name", { + header: "Display name", + cell: ({ getValue, row }) => ( + + {getValue()} + + ), + }), + columnHelper.accessor("troll_score", { + header: "Troll score", + }), + columnHelper.accessor("red_flags", { + header: "Red flags", + }), + columnHelper.accessor((row) => [row.upvotes, row.downvotes] as const, { + id: "vote", + cell: ({ getValue }) => { + const [up, down] = getValue(); + return ( + + + {up} + + {down} + + ); + }, + }), + columnHelper.accessor((row) => row.spam + row.spam_prompts, { + header: "Spam", + }), + columnHelper.accessor("lang_mismach", { + header: "Lang mismach", + }), + columnHelper.accessor("not_appropriate", { + header: "Not appropriate", + }), + columnHelper.accessor("pii", {}), + columnHelper.accessor("hate_speech", { + header: "Hate speech", + }), + columnHelper.accessor("sexual_content", { + header: "Sexual Content", + }), + columnHelper.accessor("political_content", { + header: "Political Content", + }), + columnHelper.accessor("quality", { + cell: ({ getValue }) => toPercentage(getValue()), + }), + columnHelper.accessor("helpfulness", { + cell: ({ getValue }) => toPercentage(getValue()), + }), + columnHelper.accessor("humor", { + cell: ({ getValue }) => toPercentage(getValue()), + }), + columnHelper.accessor("violence", { + cell: ({ getValue }) => toPercentage(getValue()), + }), + columnHelper.accessor("toxicity", { + cell: ({ getValue }) => toPercentage(getValue()), + }), +]; + +export const TrollboardTable = ({ + limit, + rowPerPage, + timeFrame, +}: { + timeFrame: TrollboardTimeFrame; + limit: number; + rowPerPage: number; +}) => { + const { + data: trollboardRes, + isLoading, + error, + lastUpdated, + } = useFetchBoard(`/api/admin/trollboard?time_frame=${timeFrame}&limit=${limit}`); + + const { data, ...paginationProps } = useBoardPagination({ rowPerPage, data: trollboardRes?.trollboard, limit }); + const rowProps = useBoardRowProps(); + if (isLoading) { + return ; + } + + if (error) { + return Unable to load leaderboard; + } + + return ( + + + data={data} + columns={columns} + caption={lastUpdated} + rowProps={rowProps} + {...paginationProps} + > + + ); +}; diff --git a/website/src/components/LeaderboardTable/useBoardPagination.ts b/website/src/components/LeaderboardTable/useBoardPagination.ts new file mode 100644 index 00000000..78e52dba --- /dev/null +++ b/website/src/components/LeaderboardTable/useBoardPagination.ts @@ -0,0 +1,34 @@ +import { useState } from "react"; + +export const useBoardPagination = ({ + rowPerPage, + limit, + ...res +}: { + rowPerPage: number; + data?: T[]; + limit: number; +}) => { + const data = res.data || []; + const [page, setPage] = useState(1); + const maxPage = data ? Math.ceil(data.length / rowPerPage) : 0; + const disablePagination = limit <= rowPerPage; + const disableNext = page >= maxPage; + const disablePrevious = page === 1; + const onNextClick = () => setPage((p) => p + 1); + const onPreviousClick = () => setPage((p) => p - 1); + const start = (page - 1) * rowPerPage; + const end = start + rowPerPage; + const entities = data.slice(start, end); + + return { + page, + data: entities, + end, + disablePrevious, + disableNext, + disablePagination, + onNextClick, + onPreviousClick, + }; +}; diff --git a/website/src/components/LeaderboardTable/useBoardRowProps.ts b/website/src/components/LeaderboardTable/useBoardRowProps.ts new file mode 100644 index 00000000..32f3fc56 --- /dev/null +++ b/website/src/components/LeaderboardTable/useBoardRowProps.ts @@ -0,0 +1,34 @@ +import { useColorModeValue, useToken } from "@chakra-ui/react"; +import { useCallback } from "react"; +import { colors } from "src/styles/Theme/colors"; + +import { DataTableRowPropsCallback } from "../DataTable"; + +export const useBoardRowProps = () => { + const borderColor = useToken("colors", useColorModeValue(colors.light.active, colors.dark.active)); + return useCallback>( + (row) => { + const rowData = row.original; + return rowData.highlighted + ? { + sx: { + // https://stackoverflow.com/questions/37963524/how-to-apply-border-radius-to-tr-in-bootstrap + position: "relative", + "td:first-of-type:before": { + borderLeft: `6px solid ${borderColor}`, + content: `""`, + display: "block", + width: "10px", + height: "100%", + left: 0, + top: 0, + borderRadius: "6px 0 0 6px", + position: "absolute", + }, + }, + } + : {}; + }, + [borderColor] + ); +}; diff --git a/website/src/components/LeaderboardTable/useFetchBoard.ts b/website/src/components/LeaderboardTable/useFetchBoard.ts new file mode 100644 index 00000000..abb99f9f --- /dev/null +++ b/website/src/components/LeaderboardTable/useFetchBoard.ts @@ -0,0 +1,19 @@ +import { useTranslation } from "next-i18next"; +import { useMemo } from "react"; +import { get } from "src/lib/api"; +import useSWRImmutable from "swr/immutable"; + +export const useFetchBoard = (url: string) => { + const { t } = useTranslation("leaderboard"); + const res = useSWRImmutable(url, get); + + const lastUpdated = useMemo(() => { + const val = res.data ? new Date(res.data.last_updated) : new Date(); + return t("last_updated_at", { val, formatParams: { val: { dateStyle: "full", timeStyle: "short" } } }); + }, [res.data, t]); + + return { + ...res, + lastUpdated, + }; +}; diff --git a/website/src/lib/oasst_api_client.ts b/website/src/lib/oasst_api_client.ts index abd1173f..edf4bfd8 100644 --- a/website/src/lib/oasst_api_client.ts +++ b/website/src/lib/oasst_api_client.ts @@ -1,6 +1,7 @@ import type { EmojiOp, Message } from "src/types/Conversation"; import { LeaderboardReply, LeaderboardTimeFrame } from "src/types/Leaderboard"; import type { AvailableTasks } from "src/types/Task"; +import { FetchTrollBoardResponse, TrollboardTimeFrame } from "src/types/Trollboard"; import type { BackendUser, BackendUserCore, FetchUsersParams, FetchUsersResponse } from "src/types/Users"; export class OasstError { @@ -350,4 +351,10 @@ export class OasstApiClient { fetch_frontend_user(user: BackendUserCore) { return this.get(`/api/v1/frontend_users/${user.auth_method}/${user.id}`); } + + fetch_trollboard(time_frame: TrollboardTimeFrame, { limit }: { limit?: number }) { + return this.get(`/api/v1/trollboards/${time_frame}`, { + max_count: limit, + }); + } } diff --git a/website/src/pages/admin/trollboard.tsx b/website/src/pages/admin/trollboard.tsx new file mode 100644 index 00000000..aaef80f3 --- /dev/null +++ b/website/src/pages/admin/trollboard.tsx @@ -0,0 +1,57 @@ +import { Box, Card, CardBody, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; +import Head from "next/head"; +import { useTranslation } from "next-i18next"; +export { getDefaultStaticProps as getStaticProps } from "src/lib/default_static_props"; +import { AdminArea } from "src/components/AdminArea"; +import { getAdminLayout } from "src/components/Layout"; +import { TrollboardTable } from "src/components/LeaderboardTable/TrollboardTable"; +import { TrollboardTimeFrame } from "src/types/Trollboard"; + +const Leaderboard = () => { + const { t } = useTranslation(["leaderboard", "common"]); + return ( + <> + + {`Trollboard - ${t("common:title")}`} + + + + + + {t("leaderboard")} + + + + + + {t("daily")} + {t("weekly")} + {t("monthly")} + {t("overall")} + + + + + + + + + + + + + + + + + + + + + + ); +}; + +Leaderboard.getLayout = getAdminLayout; + +export default Leaderboard; diff --git a/website/src/pages/api/admin/trollboard.ts b/website/src/pages/api/admin/trollboard.ts new file mode 100644 index 00000000..9016f42b --- /dev/null +++ b/website/src/pages/api/admin/trollboard.ts @@ -0,0 +1,13 @@ +import { withRole } from "src/lib/auth"; +import { createApiClient } from "src/lib/oasst_client_factory"; +import { TrollboardTimeFrame } from "src/types/Trollboard"; + +export default withRole("admin", async (req, res, token) => { + const client = await createApiClient(token); + + const trollboard = await client.fetch_trollboard(req.query.time_frame as TrollboardTimeFrame, { + limit: req.query.limit as unknown as number, + }); + + return res.status(200).json(trollboard); +}); diff --git a/website/src/types/Leaderboard.ts b/website/src/types/Leaderboard.ts index b806ac95..28f2edb8 100644 --- a/website/src/types/Leaderboard.ts +++ b/website/src/types/Leaderboard.ts @@ -4,7 +4,7 @@ export interface LeaderboardEntry { score: number; } -export const enum LeaderboardTimeFrame { +export enum LeaderboardTimeFrame { day = "day", week = "week", month = "month", diff --git a/website/src/types/Trollboard.ts b/website/src/types/Trollboard.ts new file mode 100644 index 00000000..cd7f1094 --- /dev/null +++ b/website/src/types/Trollboard.ts @@ -0,0 +1,41 @@ +export enum TrollboardTimeFrame { + day = "day", + week = "week", + month = "month", + total = "total", +} + +export type FetchTrollBoardResponse = { + time_frame: TrollboardTimeFrame; + last_updated: string; + trollboard: TrollboardEntity[]; +}; + +export type TrollboardEntity = { + rank: number; + user_id: string; + highlighted: boolean; + username: string; + auth_method: string; + display_name: string; + last_activity_date: string | null; + troll_score: number; + base_date: string; + modified_date: string; + red_flags: number; + upvotes: number; + downvotes: number; + spam_prompts: 0; + quality: number | null; + humor: number | null; + toxicity: number | null; + violence: number | null; + helpfulness: number | null; + spam: number; + lang_mismach: number; + not_appropriate: number; + pii: number; + hate_speech: number; + sexual_content: number; + political_content: number; +};