diff --git a/core/components/PlayerDatabase/index.ts b/core/components/PlayerDatabase/index.ts index a563f5db3..2cafe4084 100644 --- a/core/components/PlayerDatabase/index.ts +++ b/core/components/PlayerDatabase/index.ts @@ -7,6 +7,7 @@ import { DatabaseActionType, DatabaseDataType, DatabasePlayerType, DatabaseWhite import { cloneDeep } from 'lodash-es'; import { now } from '@core/extras/helpers'; import consoleFactory from '@extras/console'; +import { MultipleCounter } from '../StatisticsManager/statsUtils'; const console = consoleFactory(modulename); @@ -453,6 +454,41 @@ export default class PlayerDatabase { } + /** + * Returns players stats for the database (for Players page callouts) + */ + getActionStats() { + if (!this.#db.obj || !this.#db.obj.data) throw new Error(`database not ready yet`); + + const sevenDaysAgo = now() - (7 * 24 * 60 * 60); + const startingValue = { + totalWarns: 0, + warnsLast7d: 0, + totalBans: 0, + bansLast7d: 0, + groupedByAdmins: new MultipleCounter(), + }; + const actionStats = this.#db.obj.chain.get('actions') + .reduce((acc, action, ind) => { + if (action.type == 'ban') { + acc.totalBans++; + if (action.timestamp > sevenDaysAgo) acc.bansLast7d++; + } else if (action.type == 'warn') { + acc.totalWarns++; + if (action.timestamp > sevenDaysAgo) acc.warnsLast7d++; + } + acc.groupedByAdmins.count(action.author); + return acc; + }, startingValue) + .value(); + + return { + ...actionStats, + groupedByAdmins: actionStats.groupedByAdmins.toJSON(), + }; + } + + /** * Returns actions/players stats for the database * FIXME: deprecate, used by the old players page diff --git a/core/components/StatisticsManager/statsUtils.ts b/core/components/StatisticsManager/statsUtils.ts index 4aa63274d..053005ca6 100644 --- a/core/components/StatisticsManager/statsUtils.ts +++ b/core/components/StatisticsManager/statsUtils.ts @@ -57,6 +57,10 @@ export class MultipleCounter { } }; + toArray(): [string, number][] { + return [...this.#data]; + } + toJSON(): MultipleCounterOutput { return Object.fromEntries(this.#data); } diff --git a/core/components/WebServer/router.ts b/core/components/WebServer/router.ts index 01930e8da..39c71e8f8 100644 --- a/core/components/WebServer/router.ts +++ b/core/components/WebServer/router.ts @@ -110,6 +110,10 @@ export default (config: WebServerConfigType) => { /logs/:log/download - WEB */ + //History routes + router.get('/history/stats', apiAuthMw, webRoutes.history_stats); + router.get('/history/search', apiAuthMw, webRoutes.history_search); + //Player routes router.get('/player', apiAuthMw, webRoutes.player_modal); router.get('/player/stats', apiAuthMw, webRoutes.player_stats); diff --git a/core/extras/helpers.ts b/core/extras/helpers.ts index 4bc0c2721..38fbf2a5a 100644 --- a/core/extras/helpers.ts +++ b/core/extras/helpers.ts @@ -189,10 +189,10 @@ export const filterPlayerHwids = (hwids: string[]) => { export const parseLaxIdsArrayInput = (fullInput: string) => { const validIds: string[] = []; const validHwids: string[] = []; - const invalidIds: string[] = []; + const invalids: string[] = []; if (typeof fullInput !== 'string') { - return { validIds, validHwids, invalidIds }; + return { validIds, validHwids, invalids }; } const inputs = fullInput.toLowerCase().split(/[,;\s]+/g).filter(Boolean); @@ -207,7 +207,7 @@ export const parseLaxIdsArrayInput = (fullInput: string) => { if (consts.validIdentifierParts[type as keyof typeof consts.validIdentifierParts]?.test(value)) { validIds.push(input); } else { - invalidIds.push(input); + invalids.push(input); } } } else if (consts.validIdentifierParts.discord.test(input)) { @@ -219,11 +219,11 @@ export const parseLaxIdsArrayInput = (fullInput: string) => { } else if (consts.validIdentifierParts.steam.test(input)) { validIds.push(`steam:${input}`); } else { - invalidIds.push(input); + invalids.push(input); } } - return { validIds, validHwids, invalidIds }; + return { validIds, validHwids, invalids }; } diff --git a/core/webroutes/history/search.ts b/core/webroutes/history/search.ts new file mode 100644 index 000000000..17ac6ccc9 --- /dev/null +++ b/core/webroutes/history/search.ts @@ -0,0 +1,152 @@ +const modulename = 'WebServer:HistorySearch'; +import { DatabaseActionType } from '@core/components/PlayerDatabase/databaseTypes'; +import consoleFactory from '@extras/console'; +import { AuthedCtx } from '@core/components/WebServer/ctxTypes'; +import cleanPlayerName from '@shared/cleanPlayerName'; +import { chain as createChain } from 'lodash-es'; +import Fuse from 'fuse.js'; +import { now, parseLaxIdsArrayInput } from '@extras/helpers'; +import { HistoryTableActionType, HistoryTableSearchResp } from '@shared/historyApiTypes'; +const console = consoleFactory(modulename); + +//Helpers +const DEFAULT_LIMIT = 100; //cant override it for now +const ALLOWED_SORTINGS = ['timestamp']; + + +/** + * Returns the players stats for the Players page table + */ +export default async function HistorySearch(ctx: AuthedCtx) { + //Sanity check + if (typeof ctx.query === 'undefined') { + return ctx.utils.error(400, 'Invalid Request'); + } + const { + searchValue, + searchType, + filterbyType, + filterbyAdmin, + sortingKey, + sortingDesc, + offsetParam, + offsetActionId + } = ctx.query; + const sendTypedResp = (data: HistoryTableSearchResp) => ctx.send(data); + const dbo = ctx.txAdmin.playerDatabase.getDb(); + let chain = dbo.chain.get('actions'); + + //sort the actions by the sortingKey/sortingDesc + const parsedSortingDesc = sortingDesc === 'true'; + if (typeof sortingKey !== 'string' || !ALLOWED_SORTINGS.includes(sortingKey)) { + return sendTypedResp({ error: 'Invalid sorting key' }); + } + chain = chain.sort((a, b) => { + // @ts-ignore + return parsedSortingDesc ? b[sortingKey] - a[sortingKey] : a[sortingKey] - b[sortingKey]; + }); + + //offset the actions by the offsetParam/offsetActionId + if (offsetParam !== undefined && offsetActionId !== undefined) { + const parsedOffsetParam = parseInt(offsetParam as string); + if (isNaN(parsedOffsetParam) || typeof offsetActionId !== 'string' || !offsetActionId.length) { + return sendTypedResp({ error: 'Invalid offsetParam or offsetActionId' }); + } + chain = chain.takeRightWhile((a) => { + return a.id !== offsetActionId && parsedSortingDesc + ? a[sortingKey as keyof DatabaseActionType] as number <= parsedOffsetParam + : a[sortingKey as keyof DatabaseActionType] as number >= parsedOffsetParam + }); + } + + //filter the actions by the simple filters (lightweight) + const effectiveTypeFilter = typeof filterbyType === 'string' && filterbyType.length ? filterbyType : undefined; + const effectiveAdminFilter = typeof filterbyAdmin === 'string' && filterbyAdmin.length ? filterbyAdmin : undefined; + console.dir({ effectiveTypeFilter, effectiveAdminFilter }); + if (effectiveTypeFilter || effectiveAdminFilter) { + chain = chain.filter((a) => { + if (effectiveTypeFilter && a.type !== effectiveTypeFilter) { + return false; + } + if (effectiveAdminFilter && a.author !== effectiveAdminFilter) { + return false; + } + return true; + }); + } + + // filter the actions by the searchValue/searchType (VERY HEAVY!) + if (typeof searchType === 'string') { + if (typeof searchValue !== 'string' || !searchValue.length) { + return sendTypedResp({ error: 'Invalid searchValue' }); + } + + if (searchType === 'actionId') { + //Searching by action ID + const cleanId = searchValue.toUpperCase().trim(); + if (!cleanId.length) { + return sendTypedResp({ error: 'This action ID is unsearchable (empty?).' }); + } + const actions = chain.value(); + const fuse = new Fuse(actions, { + isCaseSensitive: true, //maybe that's an optimization?! + keys: ['id'], + threshold: 0.3 + }); + const filtered = fuse.search(cleanId).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'reason') { + //Searching by player notes + const actions = chain.value(); + const fuse = new Fuse(actions, { + keys: ['reason'], + threshold: 0.3 + }); + const filtered = fuse.search(searchValue).map(x => x.item); + chain = createChain(filtered); + } else if (searchType === 'identifiers') { + //Searching by target identifiers + const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue); + if (invalids.length) { + return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` }); + } + if (!validIds.length && !validHwids.length) { + return sendTypedResp({ error: `No valid identifiers found.` }); + } + chain = chain.filter((a) => { + if (validIds.length && !validIds.some((id) => a.ids.includes(id))) { + return false; + } + if (validHwids.length && a.hwids !== undefined && !validHwids.some((hwid) => a.hwids!.includes(hwid))) { + return false; + } + return true; + }); + } else { + return sendTypedResp({ error: 'Unknown searchType' }); + } + } + + //filter players by the limit - taking 1 more to check if we reached the end + chain = chain.take(DEFAULT_LIMIT + 1); + const actions = chain.value(); + const hasReachedEnd = actions.length <= DEFAULT_LIMIT; + const currTs = now(); + const processedActions: HistoryTableActionType[] = actions.slice(0, DEFAULT_LIMIT).map((a) => { + return { + id: a.id, + type: a.type, + playerName: a.playerName, + author: a.author, + reason: a.reason, + timestamp: a.timestamp, + isExpired: typeof a.expiration === 'number' && a.expiration < currTs, + isRevoked: !!a.revocation.timestamp, + }; + }); + + return sendTypedResp({ + history: processedActions, + hasReachedEnd, + }); +}; diff --git a/core/webroutes/history/stats.ts b/core/webroutes/history/stats.ts new file mode 100644 index 000000000..9d4fb470c --- /dev/null +++ b/core/webroutes/history/stats.ts @@ -0,0 +1,34 @@ +const modulename = 'WebServer:HistoryStats'; +import consoleFactory from '@extras/console'; +import { AuthedCtx } from '@core/components/WebServer/ctxTypes'; +import { HistoryStatsResp } from '@shared/historyApiTypes'; +import { union } from 'lodash-es'; +const console = consoleFactory(modulename); + + +/** + * Returns the players stats for the Players page callouts + */ +export default async function HistoryStats(ctx: AuthedCtx) { + const sendTypedResp = (data: HistoryStatsResp) => ctx.send(data); + try { + const dbStats = ctx.txAdmin.playerDatabase.getActionStats(); + const dbAdmins = Object.keys(dbStats.groupedByAdmins); + // @ts-ignore i don't wanna type this + const vaultAdmins = ctx.txAdmin.adminVault.getAdminsList().map(a => a.name); + const adminStats = union(dbAdmins, vaultAdmins) + .sort((a, b) => a.localeCompare(b)) + .map(admin => ({ + name: admin, + actions: dbStats.groupedByAdmins[admin] ?? 0 + })); + return sendTypedResp({ + ...dbStats, + groupedByAdmins: adminStats, + }); + } catch (error) { + const msg = `getStats failed with error: ${(error as Error).message}`; + console.verbose.error(msg); + return sendTypedResp({ error: msg }); + } +}; diff --git a/core/webroutes/index.js b/core/webroutes/index.js index 3a15fd12f..9732baaf6 100644 --- a/core/webroutes/index.js +++ b/core/webroutes/index.js @@ -46,6 +46,9 @@ export { default as fxserver_controls } from './fxserver/controls'; export { default as fxserver_downloadLog } from './fxserver/downloadLog'; export { default as fxserver_schedule } from './fxserver/schedule'; +export { default as history_stats } from './history/stats'; +export { default as history_search } from './history/search'; + export { default as player_stats } from './player/stats'; export { default as player_search } from './player/search'; export { default as player_pageOld } from './player/pageOld.js'; //FIXME: DEPRECATED diff --git a/core/webroutes/player/search.ts b/core/webroutes/player/search.ts index 7e61a6450..fd4146957 100644 --- a/core/webroutes/player/search.ts +++ b/core/webroutes/player/search.ts @@ -38,7 +38,6 @@ export default async function PlayerSearch(ctx: AuthedCtx) { const onlinePlayersLicenses = ctx.txAdmin.playerlistManager.getOnlinePlayersLicenses(); const dbo = ctx.txAdmin.playerDatabase.getDb(); let chain = dbo.chain.get('players'); - /* In order: - [X] sort the players by the sortingKey/sortingDesc @@ -131,9 +130,9 @@ export default async function PlayerSearch(ctx: AuthedCtx) { chain = createChain(filtered); } else if (searchType === 'playerIds') { //Searching by player identifiers - const { validIds, validHwids, invalidIds } = parseLaxIdsArrayInput(searchValue); - if (invalidIds.length) { - return sendTypedResp({ error: `Invalid identifiers (${invalidIds.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` }); + const { validIds, validHwids, invalids } = parseLaxIdsArrayInput(searchValue); + if (invalids.length) { + return sendTypedResp({ error: `Invalid identifiers (${invalids.join(',')}). Prefix any identifier with their type, like 'fivem:123456' instead of just '123456'.` }); } if (!validIds.length && !validHwids.length) { return sendTypedResp({ error: `No valid identifiers found.` }); diff --git a/core/webroutes/player/stats.ts b/core/webroutes/player/stats.ts index aab0ffa9f..beeb5a33e 100644 --- a/core/webroutes/player/stats.ts +++ b/core/webroutes/player/stats.ts @@ -2,7 +2,6 @@ const modulename = 'WebServer:PlayersStats'; import consoleFactory from '@extras/console'; import { AuthedCtx } from '@core/components/WebServer/ctxTypes'; import { PlayersStatsResp } from '@shared/playerApiTypes'; -import { GenericApiErrorResp } from '@shared/genericApiTypes'; const console = consoleFactory(modulename); diff --git a/fxmanifest.lua b/fxmanifest.lua index 01fdbf888..e20936ab0 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -5,7 +5,7 @@ author 'Tabarra' description 'The official FiveM/RedM server web/in-game management platform.' repository 'https://github.com/tabarra/txAdmin' -version '7.1.0-beta1' +version '7.1.0-tbd' ui_label 'txAdmin' rdr3_warning 'I acknowledge that this is a prerelease build of RedM, and I am aware my resources *will* become incompatible once RedM ships.' diff --git a/panel/src/layout/MainRouter.tsx b/panel/src/layout/MainRouter.tsx index fc90c4a1d..d144596b8 100644 --- a/panel/src/layout/MainRouter.tsx +++ b/panel/src/layout/MainRouter.tsx @@ -10,6 +10,7 @@ import NotFound from "@/pages/NotFound"; import TestingPage from "@/pages/TestingPage/TestingPage"; import LiveConsole from "@/pages/LiveConsole/LiveConsole"; import PlayersPage from "@/pages/Players/PlayersPage"; +import HistoryPage from "@/pages/History/HistoryPage"; type RouteType = { @@ -31,11 +32,11 @@ const allRoutes: RouteType[] = [ title: 'Players', children: }, - // { - // path: '/history', - // title: 'History', - // children: <>TODO: - // }, + { + path: '/history', + title: 'History', + children: + }, { path: '/whitelist', title: 'Whitelist', diff --git a/panel/src/layout/MainSheets.tsx b/panel/src/layout/MainSheets.tsx index 7df5566b9..1c03133df 100644 --- a/panel/src/layout/MainSheets.tsx +++ b/panel/src/layout/MainSheets.tsx @@ -3,7 +3,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sh import { ServerSidebar } from "./ServerSidebar/ServerSidebar"; import { useGlobalMenuSheet, usePlayerlistSheet, useServerSheet } from "@/hooks/sheets"; import { MenuNavLink, NavLink } from "@/components/MainPageLink"; -import { ClipboardCheckIcon, ListIcon, PieChartIcon, SettingsIcon, UserSquare2Icon, UsersIcon, ZapIcon } from 'lucide-react'; +import { ClipboardCheckIcon, ListIcon, PieChartIcon, ScrollIcon, SettingsIcon, UserSquare2Icon, UsersIcon, ZapIcon } from 'lucide-react'; import { PlayerlistSidebar } from "./PlayerlistSidebar/PlayerlistSidebar"; import { useAdminPerms } from "@/hooks/auth"; import { LogoFullSquareGreen } from "@/components/Logos"; @@ -36,9 +36,9 @@ export function GlobalMenuSheet() { Players - {/* + History - */} + Whitelist diff --git a/panel/src/pages/History/HistoryPage.tsx b/panel/src/pages/History/HistoryPage.tsx new file mode 100644 index 000000000..5c071529e --- /dev/null +++ b/panel/src/pages/History/HistoryPage.tsx @@ -0,0 +1,106 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangleIcon, GavelIcon } from 'lucide-react'; +import PageCalloutRow from '@/components/PageCalloutRow'; +import { HistorySearchBox, HistorySearchBoxReturnStateType } from './HistorySearchBox'; +import HistoryTable from './HistoryTable'; +import { HistoryStatsResp, HistoryTableSearchType } from '@shared/historyApiTypes'; +import { useBackendApi } from '@/hooks/fetch'; +import { createRandomHslColor } from '@/lib/utils'; + + +//Memoized components +const HistorySearchBoxMemo = memo(HistorySearchBox); +const HistoryTableMemo = memo(HistoryTable); +const PageCalloutRowMemo = memo(PageCalloutRow); + + +export default function HistoryPage() { + const [calloutData, setCalloutData] = useState(undefined); + const [searchBoxReturn, setSearchBoxReturn] = useState(undefined); + const statsApi = useBackendApi({ + method: 'GET', + path: '/history/stats', + abortOnUnmount: true, + }); + + //Callout data + useEffect(() => { + statsApi({ + success(data, toastId) { + setCalloutData(data); + }, + }) + }, []); + + //HistorySearchBox handlers + const doSearch = useCallback(( + search: HistoryTableSearchType, + filterbyType: string | undefined, + filterbyAdmin: string | undefined + ) => { + setSearchBoxReturn({ search, filterbyType, filterbyAdmin }); + }, []); + const initialState = useMemo(() => { + return { + search: null, + filterbyType: undefined, + filterbyAdmin: undefined, + } satisfies HistorySearchBoxReturnStateType; + }, []); + + + const hasCalloutData = calloutData && !('error' in calloutData); + return (
+ {/*
{JSON.stringify(searchBoxReturn)}
*/} + + , + }, + { + label: 'New Warns Last 7d', + value: hasCalloutData ? calloutData.warnsLast7d : false, + icon: , + prefix: '+' + }, + { + label: 'Total Bans', + value: hasCalloutData ? calloutData.totalBans : false, + icon: , + }, + { + label: 'New Bans Last 7d', + value: hasCalloutData ? calloutData.bansLast7d : false, + icon: , + prefix: '+' + } + ]} + /> + + {hasCalloutData ? ( + + ) : null} + + {searchBoxReturn ? ( + + ) : null} +
); +} diff --git a/panel/src/pages/History/HistorySearchBox.tsx b/panel/src/pages/History/HistorySearchBox.tsx new file mode 100644 index 000000000..fbfbdf010 --- /dev/null +++ b/panel/src/pages/History/HistorySearchBox.tsx @@ -0,0 +1,289 @@ +import { throttle } from "throttle-debounce"; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ChevronsUpDownIcon, XIcon, ChevronDownIcon, ExternalLinkIcon } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import InlineCode from '@/components/InlineCode'; +import { useEventListener } from "usehooks-ts"; +import { Link } from "wouter"; +import { useAuth } from "@/hooks/auth" +import { + Select, + SelectContent, + SelectItem, + SelectSeparator, + SelectTrigger, + SelectValue +} from "@/components/ui/select" +import { HistoryTableSearchType } from "@shared/historyApiTypes"; + + +/** + * Helpers + */ +const availableSearchTypes = [ + { + value: 'actionId', + label: 'Action ID', + placeholder: 'XXXX-XXXX', + description: 'Search actions by their ID.' + }, + { + value: 'reason', + label: 'Reason', + placeholder: 'Enter part of the reason to search for', + description: 'Search actions by their reason contents.' + }, + { + value: 'identifiers', + label: 'Player IDs', + placeholder: 'License, Discord, Steam, etc.', + description: 'Search actions by their player IDs separated by a comma.' + }, +] as const; + +const SEARCH_ANY_STRING = '!any'; + +//FIXME: this doesn't require exporting, but HMR doesn't work without it +// eslint-disable-next-line @typescript-eslint/no-explicit-any, react-refresh/only-export-components +export const throttleFunc = throttle(1250, (func: any) => { + func(); +}, { noLeading: true }); + + + +/** + * Component + */ +export type HistorySearchBoxReturnStateType = { + search: HistoryTableSearchType; + filterbyType?: string; + filterbyAdmin?: string; +} + +type HistorySearchBoxProps = { + doSearch: (search: HistoryTableSearchType, filterbyType: string | undefined, filterbyAdmin: string | undefined) => void; + initialState: HistorySearchBoxReturnStateType; + adminStats: { + name: string; + actions: number; + }[]; +}; + +export function HistorySearchBox({ doSearch, initialState, adminStats }: HistorySearchBoxProps) { + const { authData } = useAuth(); + const inputRef = useRef(null); + const [isSearchTypeDropdownOpen, setSearchTypeDropdownOpen] = useState(false); + const [currSearchType, setCurrSearchType] = useState(initialState.search?.type || 'actionId'); + const [hasSearchText, setHasSearchText] = useState(!!initialState.search?.value); + const [typeFilter, setTypeFilter] = useState(initialState.filterbyType ?? SEARCH_ANY_STRING); + const [adminNameFilter, setAdminNameFilter] = useState(initialState.filterbyAdmin ?? SEARCH_ANY_STRING); + + const updateSearch = () => { + if (!inputRef.current) return; + const searchValue = inputRef.current.value.trim(); + const effectiveTypeFilter = typeFilter !== SEARCH_ANY_STRING ? typeFilter : undefined; + const effectiveAdminNameFilter = adminNameFilter !== SEARCH_ANY_STRING ? adminNameFilter : undefined; + if (searchValue.length) { + doSearch( + { value: searchValue, type: currSearchType }, + effectiveTypeFilter, + effectiveAdminNameFilter, + ); + } else { + doSearch( + null, + effectiveTypeFilter, + effectiveAdminNameFilter, + ); + } + } + + //Call onSearch when params change + useEffect(() => { + updateSearch(); + }, [currSearchType, typeFilter, adminNameFilter]); + + //Input handlers + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + throttleFunc.cancel({ upcomingOnly: true }); + updateSearch(); + } else if (e.key === 'Escape') { + inputRef.current!.value = ''; + throttleFunc(updateSearch); + setHasSearchText(false); + } else { + throttleFunc(updateSearch); + setHasSearchText(true); + } + }; + + const clearSearchBtn = () => { + inputRef.current!.value = ''; + throttleFunc.cancel({ upcomingOnly: true }); + updateSearch(); + setHasSearchText(false); + }; + + //Search hotkey + useEventListener('keydown', (e: KeyboardEvent) => { + if (e.code === 'KeyF' && (e.ctrlKey || e.metaKey)) { + inputRef.current?.focus(); + e.preventDefault(); + } + }); + + //It's render time! 🎉 + const selectedSearchType = availableSearchTypes.find((type) => type.value === currSearchType); + if (!selectedSearchType) throw new Error(`Invalid search type: ${currSearchType}`); + if (!authData) throw new Error(`authData is not available`); + const filteredAdmins = useMemo(() => { + return adminStats.filter((admin) => admin.name !== authData.name) + }, [adminStats, authData.name]); + const selfActionCount = useMemo(() => { + return adminStats.find((admin) => admin.name === authData.name)?.actions || 0; + }, [adminStats, authData.name]); + return ( +
+
+
+ + {hasSearchText ? ( + + ) : ( +
+ ctrl+f +
+ )} +
+ +
+ + + + + + Search Type + + + {availableSearchTypes.map((searchType) => ( + + {searchType.label} + + ))} + + + + + + + + + +
+ + + + + + + + + Old Page + + + + + + Bulk Remove + + + + +
+
+
+
+ {selectedSearchType.description} +
+
+ ) +} diff --git a/panel/src/pages/History/HistoryTable.tsx b/panel/src/pages/History/HistoryTable.tsx new file mode 100644 index 000000000..7d2871565 --- /dev/null +++ b/panel/src/pages/History/HistoryTable.tsx @@ -0,0 +1,359 @@ +import { useEffect, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import TxAnchor from '@/components/TxAnchor'; +import { cn, tsToLocaleDateTime } from '@/lib/utils'; +import { TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table"; +import { Loader2Icon, GavelIcon, AlertTriangleIcon, Undo2Icon, AlarmClockCheckIcon } from 'lucide-react'; +import { useOpenPlayerModal } from "@/hooks/playerModal"; +import { useBackendApi } from '@/hooks/fetch'; +import { HistoryTableActionType, HistoryTableSearchResp, HistoryTableSearchType, HistoryTableSortingType } from '@shared/historyApiTypes'; + + +/** + * Action row + */ +const convertRowDateTime = (ts: number) => { + return tsToLocaleDateTime(ts, 'medium', 'medium'); +} +type HistoryRowProps = { + action: HistoryTableActionType; + modalOpener: ReturnType; +} + +function HistoryRow({ action, modalOpener }: HistoryRowProps) { + const openModal = () => { + //FIXME: modalOpener(action.id); + } + + // Type indicator + let rowPrefix: React.ReactNode; + let rowId: React.ReactNode; + if (action.type === 'warn') { + rowPrefix =
+ +
+ rowId = {action.id} + } else if (action.type === 'ban') { + rowPrefix =
+ +
+ rowId = {action.id} + } else { + throw new Error(`Invalid action type: ${action.type}`); + } + + //Status indicator + let statusIcon: React.ReactNode; + if (action.isRevoked) { + statusIcon = ; + } else if (action.isExpired) { + statusIcon = ; + } + + return ( + + +
+ {rowPrefix} +
+ {rowId} +
+
+ {statusIcon} +
+
+
+ + + {action.playerName ? action.playerName : ( + unknown + )} + + + + + {action.reason} + + + + + {action.author} + + + + + {convertRowDateTime(action.timestamp)} + + +
+ ) +} + + +/** + * Last row + */ +type LastRowProps = { + playersCount: number; + hasReachedEnd: boolean; + loadError: string | null; + isFetching: boolean; + retryFetch: (_reset?: boolean) => Promise; +} + +function LastRow({ playersCount, hasReachedEnd, isFetching, loadError, retryFetch }: LastRowProps) { + let content: React.ReactNode; + if (isFetching) { + content = + } else if (loadError) { + content = <> + Error: {loadError}
+ + + } else if (hasReachedEnd) { + content = + {playersCount ? 'You have reached the end of the list.' : 'No actions found.'} + + } else { + content = + You've found the end of the rainbow, but there's no pot of gold here.
+ (this is a bug, please report it in discord.gg/txAdmin) +
+ } + + return ( + + + {content} + + + ) +} + + +/** + * Sortable table header + */ +type SortableTableHeaderProps = { + label: string; + sortKey: 'timestamp'; + sortingState: HistoryTableSortingType; + setSorting: (newState: HistoryTableSortingType) => void; + className?: string; +} + +function SortableTableHeader({ label, sortKey, sortingState, setSorting, className }: SortableTableHeaderProps) { + const isSorted = sortingState.key === sortKey; + const isDesc = sortingState.desc; + const sortIcon = isSorted ? (isDesc ? 'â–¼' : 'â–²') : <>; + const onClick = (e: React.MouseEvent) => { + e.preventDefault(); + setSorting({ + key: sortKey, + desc: isSorted ? (!isDesc) : true + }); + } + return ( + + {label} +
{sortIcon}
+ + ) +} + +function NonSortableTableHeader({ label, className }: { label: string, className?: string }) { + return ( + + {label} + + ) +} + + +/** + * History table + */ +type HistoryTableProps = { + search: HistoryTableSearchType; + filterbyType: string | undefined, + filterbyAdmin: string | undefined, +} + +export default function HistoryTable({ search, filterbyType, filterbyAdmin }: HistoryTableProps) { + const scrollRef = useRef(null); + const [history, setHistory] = useState([]); + const [hasReachedEnd, setHasReachedEnd] = useState(false); + const [isFetching, setIsFetching] = useState(false); + const [loadError, setLoadError] = useState(null); + const [sorting, setSorting] = useState({ key: 'timestamp', desc: true }); + const [isResetting, setIsResetting] = useState(false); + + const historyListingApi = useBackendApi({ + method: 'GET', + path: '/history/search', + abortOnUnmount: true, + }); + + const fetchNextPage = async (resetOffset?: boolean) => { + setIsFetching(true); + setLoadError(null); + if (resetOffset) { + setIsResetting(true); + } + const handleError = (error: string) => { + setLoadError(error); + if (resetOffset) { + setHistory([]); + } + } + try { + const queryParams: { [key: string]: string | number | boolean } = { + sortingKey: sorting.key, + sortingDesc: sorting.desc, + }; + if (search) { + queryParams.searchValue = search.value; + queryParams.searchType = search.type; + } + if (filterbyType && filterbyType !== '!any') { + queryParams.filterbyType = filterbyType; + } + if (filterbyAdmin && filterbyAdmin !== '!any') { + queryParams.filterbyAdmin = filterbyAdmin; + } + if (!resetOffset && history.length) { + queryParams.offsetParam = history[history.length - 1][sorting.key]; + queryParams.offsetActionId = history[history.length - 1].id; + } + const resp = await historyListingApi({ queryParams }); + + //Dealing with errors + if (resp === undefined) { + return handleError(`Request failed.`); + } else if ('error' in resp) { + return handleError(`Request failed: ${resp.error}`); + } + + //Setting the states + setLoadError(null); + setHasReachedEnd(resp.hasReachedEnd); + setIsResetting(false); + if (resp.history.length) { + setHistory((prev) => resetOffset ? resp.history : [...prev, ...resp.history]); + } else { + setHistory([]); + } + } catch (error) { + handleError(`Failed to fetch more data: ${(error as Error).message}`); + } finally { + setIsFetching(false); + setIsResetting(false); + } + }; + + // The virtualizer + const rowVirtualizer = useVirtualizer({ + scrollingDelay: 0, + count: history.length + 1, + getScrollElement: () => (scrollRef.current as HTMLDivElement)?.getElementsByTagName('div')[0], + estimateSize: () => 38, // border-b + overscan: 25, + }); + const virtualItems = rowVirtualizer.getVirtualItems(); + const virtualizerTotalSize = rowVirtualizer.getTotalSize(); + + //NOTE: This is required due to how css works on tables + //ref: https://github.com/TanStack/virtual/issues/585 + let TopRowPad: React.ReactNode = null; + let BottomRowPad: React.ReactNode = null; + if (virtualItems.length > 0) { + const padStart = virtualItems[0].start - rowVirtualizer.options.scrollMargin; + if (padStart > 0) { + TopRowPad = ; + } + const padEnd = virtualizerTotalSize - virtualItems[virtualItems.length - 1].end; + if (padEnd > 0) { + BottomRowPad = ; + } + } + + // Automagically fetch next page when reaching the end + useEffect(() => { + if (!history.length || !virtualItems.length) return; + const lastVirtualItemIndex = virtualItems[virtualItems.length - 1].index; + if (history.length <= lastVirtualItemIndex && !hasReachedEnd && !isFetching) { + fetchNextPage() + } + }, [history, virtualItems, hasReachedEnd, isFetching]); + + //on state change, reset the list + useEffect(() => { + rowVirtualizer.scrollToIndex(0); + fetchNextPage(true); + }, [search, filterbyType, filterbyAdmin, sorting]); + + + return ( +
+ {/*
{JSON.stringify({ search, filters, sorting })}
*/} + + + + + + + + + + + + + {TopRowPad} + {virtualItems.map((virtualItem) => { + const isLastRow = virtualItem.index > history.length - 1; + return isLastRow ? ( + + ) : ( + { /* FIXME: write cooooode */ }} + /> + ) + })} + {BottomRowPad} + +
+
+
+ ); +} diff --git a/panel/src/pages/Players/PlayersTable.tsx b/panel/src/pages/Players/PlayersTable.tsx index b5801a19a..c5355d0ea 100644 --- a/panel/src/pages/Players/PlayersTable.tsx +++ b/panel/src/pages/Players/PlayersTable.tsx @@ -66,7 +66,7 @@ function LastRow({ playersCount, hasReachedEnd, isFetching, loadError, retryFetc content = } else if (loadError) { content = <> - Error: {loadError}.
+ Error: {loadError}
} else if (hasReachedEnd) { @@ -190,6 +190,7 @@ export default function PlayersTable({ search, filters }: PlayersTableProps) { } //Setting the states + setLoadError(null); setHasReachedEnd(resp.hasReachedEnd); setIsResetting(false); if (resp.players.length) { diff --git a/shared/historyApiTypes.ts b/shared/historyApiTypes.ts new file mode 100644 index 000000000..7c6b9aed9 --- /dev/null +++ b/shared/historyApiTypes.ts @@ -0,0 +1,52 @@ +import { GenericApiErrorResp } from "genericApiTypes"; + +export type HistoryStatsResp = { + totalWarns: number; + warnsLast7d: number; + totalBans: number; + bansLast7d: number; + groupedByAdmins: { + name: string; + actions: number; + }[]; +} | GenericApiErrorResp; + + +export type HistoryTableSearchType = null | { + value: string; + type: string; +} + +export type HistoryTableSortingType = { + key: 'timestamp'; + desc: boolean; +}; + +export type HistoryTableReqType = { + search: HistoryTableSearchType; + filterbyType: string | undefined; + filterbyAdmin: string | undefined; + sorting: HistoryTableSortingType; + //NOTE: the query needs to be offset.param inclusive, but offset.actionId exclusive + // therefore, optimistically always limit to x + 1 + offset?: { + param: number; + actionId: string; + } +}; + +export type HistoryTableActionType = { + id: string; + type: "ban" | "warn"; + playerName: string | false; + author: string; + reason: string; + timestamp: number; + isExpired: boolean; + isRevoked: boolean; +} + +export type HistoryTableSearchResp = { + history: HistoryTableActionType[]; + hasReachedEnd: boolean; +} | GenericApiErrorResp; diff --git a/shared/playerApiTypes.ts b/shared/playerApiTypes.ts index c2549cb5f..844d812c6 100644 --- a/shared/playerApiTypes.ts +++ b/shared/playerApiTypes.ts @@ -72,7 +72,7 @@ export type PlayersTableReqType = { search: PlayersTableSearchType; filters: PlayersTableFiltersType; sorting: PlayersTableSortingType; - //NOTE: the query needs to be prevOffset inclusive, but ignore prevLicense + //NOTE: the query needs to be offset.param inclusive, but ignore offset.license // therefore, optimistically always limit to x + 1 offset?: { param: number;