Skip to content

Commit

Permalink
feat: added history page to panel
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Mar 30, 2024
1 parent ee496b5 commit c3daf83
Show file tree
Hide file tree
Showing 18 changed files with 1,060 additions and 21 deletions.
36 changes: 36 additions & 0 deletions core/components/PlayerDatabase/index.ts
Expand Up @@ -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);


Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions core/components/StatisticsManager/statsUtils.ts
Expand Up @@ -57,6 +57,10 @@ export class MultipleCounter {
}
};

toArray(): [string, number][] {
return [...this.#data];
}

toJSON(): MultipleCounterOutput {
return Object.fromEntries(this.#data);
}
Expand Down
4 changes: 4 additions & 0 deletions core/components/WebServer/router.ts
Expand Up @@ -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);
Expand Down
10 changes: 5 additions & 5 deletions core/extras/helpers.ts
Expand Up @@ -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);

Expand All @@ -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)) {
Expand All @@ -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 };
}


Expand Down
152 changes: 152 additions & 0 deletions 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,
});
};
34 changes: 34 additions & 0 deletions 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 });
}
};
3 changes: 3 additions & 0 deletions core/webroutes/index.js
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions core/webroutes/player/search.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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.` });
Expand Down
1 change: 0 additions & 1 deletion core/webroutes/player/stats.ts
Expand Up @@ -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);


Expand Down
2 changes: 1 addition & 1 deletion fxmanifest.lua
Expand Up @@ -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.'
Expand Down
11 changes: 6 additions & 5 deletions panel/src/layout/MainRouter.tsx
Expand Up @@ -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 = {
Expand All @@ -31,11 +32,11 @@ const allRoutes: RouteType[] = [
title: 'Players',
children: <PlayersPage />
},
// {
// path: '/history',
// title: 'History',
// children: <>TODO:</>
// },
{
path: '/history',
title: 'History',
children: <HistoryPage />
},
{
path: '/whitelist',
title: 'Whitelist',
Expand Down
6 changes: 3 additions & 3 deletions panel/src/layout/MainSheets.tsx
Expand Up @@ -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";
Expand Down Expand Up @@ -36,9 +36,9 @@ export function GlobalMenuSheet() {
<MenuNavLink href="/players">
<UsersIcon className="mr-2 h-4 w-4" />Players
</MenuNavLink>
{/* <MenuNavLink href="/history" className="text-accent">
<MenuNavLink href="/history">
<ScrollIcon className="mr-2 h-4 w-4" />History
</MenuNavLink> */}
</MenuNavLink>
<MenuNavLink href="/whitelist">
<ClipboardCheckIcon className="mr-2 h-4 w-4" />Whitelist
</MenuNavLink>
Expand Down

0 comments on commit c3daf83

Please sign in to comment.