This commit is contained in:
AuroraLS3 2022-08-17 11:07:06 +00:00
parent f686d573da
commit 44b9780057
124 changed files with 23623 additions and 44 deletions

View File

@ -39,20 +39,25 @@ Database:
# -----------------------------------------------------
Webserver:
Port: 8804
Alternative_IP: false
Alternative_IP:
Enabled: false
# %port% is replaced automatically with Webserver.Port
Address: your.domain.here:%port%
# InternalIP usually does not need to be changed, only change it if you know what you're doing!
# 0.0.0.0 allocates Internal (local) IP automatically for the WebServer.
Internal_IP: 0.0.0.0
Cache:
Reduced_refresh_barrier: 15
Reduced_refresh_barrier:
Time: 15
Unit: SECONDS
Invalidate_query_results_on_disk_after: 7
Invalidate_query_results_on_disk_after:
Time: 7
Unit: DAYS
Invalidate_disk_cache_after: 2
Invalidate_disk_cache_after:
Time: 2
Unit: DAYS
Invalidate_memory_cache_after: 5
Invalidate_memory_cache_after:
Time: 5
Unit: MINUTES
Security:
SSL_certificate:
@ -69,12 +74,17 @@ Webserver:
# Allows using the whitelist & brute-force shield with a reverse-proxy.
# ! Make sure non-proxy access is not possible, it would allow IP spoofing !
Use_X-Forwarded-For_Header: false
IP_whitelist: false
Access_log:
Print_to_console: false
Remove_logs_after_days: 30
IP_whitelist:
Enabled: false
Whitelist:
- "192.168.0.0"
- "0:0:0:0:0:0:0:1"
# Does not affect existing cookies
Cookies_expire_after: 2
Cookies_expire_after:
Time: 2
Unit: HOURS
Disable_Webserver: false
External_Webserver_address: "https://www.example.address"
@ -91,37 +101,49 @@ Data_gathering:
# -----------------------------------------------------
Time:
Delays:
Ping_server_enable_delay: 300
Ping_server_enable_delay:
Time: 300
Unit: SECONDS
Ping_player_join_delay: 30
Ping_player_join_delay:
Time: 30
Unit: SECONDS
Wait_for_DB_Transactions_on_disable: 20
Wait_for_DB_Transactions_on_disable:
Time: 20
Unit: SECONDS
Thresholds:
# How long player needs to be idle until Plan considers them AFK
AFK_threshold: 3
AFK_threshold:
Time: 3
Unit: MINUTES
# Activity Index considers last 3 weeks and uses these thresholds in the calculation
# The index is a number from 0 to 5.
# These numbers were calibrated with data of 250 players (Small sample size).
Activity_index:
Playtime_threshold: 30
Playtime_threshold:
Time: 30
Unit: MINUTES
Remove_inactive_player_data_after: 180
Remove_inactive_player_data_after:
Time: 3650
Unit: DAYS
# Includes players online, tps and performance time series
Remove_time_series_data_after: 90
Remove_time_series_data_after:
Time: 90
Unit: DAYS
Remove_ping_data_after: 14
Remove_ping_data_after:
Time: 14
Unit: DAYS
Remove_disabled_extension_data_after: 2
Remove_disabled_extension_data_after:
Time: 2
Unit: DAYS
Periodic_tasks:
Extension_data_refresh_every: 1
Extension_data_refresh_every:
Time: 1
Unit: HOURS
Check_DB_for_server_config_files_every: 1
Check_DB_for_server_config_files_every:
Time: 1
Unit: MINUTES
Clean_Database_every: 1
Clean_Database_every:
Time: 1
Unit: HOURS
# -----------------------------------------------------
Display_options:
@ -171,8 +193,8 @@ Formatting:
Dates:
# Show_recent_day_names replaces day number with Today, Yesterday, Wednesday etc.
Show_recent_day_names: true
# Non-regex pattern to replace
DatePattern: 'MMM d YYYY'
# Non-regex pattern to replace
Show_recent_day_names_date_pattern: 'MMM d YYYY'
Full: 'MMM d YYYY, HH:mm:ss'
NoSeconds: 'MMM d YYYY, HH:mm'
JustClock: 'HH:mm:ss'
@ -209,7 +231,8 @@ Export:
Export_player_on_login_and_logout: false
# If there are multiple servers the period is divided evenly to avoid export of all servers at once
# Also affects Players page export
Server_refresh_period: 20
Server_refresh_period:
Time: 20
Unit: MINUTES
# -----------------------------------------------------
# These settings affect Plugin data integration.

View File

@ -41,20 +41,25 @@ Database:
# -----------------------------------------------------
Webserver:
Port: 8804
Alternative_IP: false
Alternative_IP:
Enabled: false
# %port% is replaced automatically with Webserver.Port
Address: your.domain.here:%port%
# InternalIP usually does not need to be changed, only change it if you know what you're doing!
# 0.0.0.0 allocates Internal (local) IP automatically for the WebServer.
Internal_IP: 0.0.0.0
Cache:
Reduced_refresh_barrier: 15
Reduced_refresh_barrier:
Time: 15
Unit: SECONDS
Invalidate_query_results_on_disk_after: 7
Invalidate_query_results_on_disk_after:
Time: 7
Unit: DAYS
Invalidate_disk_cache_after: 2
Invalidate_disk_cache_after:
Time: 2
Unit: DAYS
Invalidate_memory_cache_after: 5
Invalidate_memory_cache_after:
Time: 5
Unit: MINUTES
Security:
SSL_certificate:
@ -71,12 +76,17 @@ Webserver:
# Allows using the whitelist with a reverse-proxy.
# ! Make sure non-proxy access is not possible, it would allow IP spoofing !
Use_X-Forwarded-For_Header: false
IP_whitelist: false
Access_log:
Print_to_console: false
Remove_logs_after_days: 30
IP_whitelist:
Enabled: false
Whitelist:
- "192.168.0.0"
- "0:0:0:0:0:0:0:1"
# Does not affect existing cookies
Cookies_expire_after: 2
Cookies_expire_after:
Time: 2
Unit: HOURS
Disable_Webserver: false
External_Webserver_address: https://www.example.address
@ -96,37 +106,49 @@ Data_gathering:
# -----------------------------------------------------
Time:
Delays:
Ping_server_enable_delay: 300
Ping_server_enable_delay:
Time: 300
Unit: SECONDS
Ping_player_join_delay: 30
Ping_player_join_delay:
Time: 30
Unit: SECONDS
Wait_for_DB_Transactions_on_disable: 20
Wait_for_DB_Transactions_on_disable:
Time: 20
Unit: SECONDS
Thresholds:
# How long player needs to be idle until Plan considers them AFK
AFK_threshold: 3
AFK_threshold:
Time: 3
Unit: MINUTES
# Activity Index considers last 3 weeks and uses these thresholds in the calculation
# The index is a number from 0 to 5.
# These numbers were calibrated with data of 250 players (Small sample size).
Activity_index:
Playtime_threshold: 30
Playtime_threshold:
Time: 30
Unit: MINUTES
Remove_inactive_player_data_after: 180
Remove_inactive_player_data_after:
Time: 3650
Unit: DAYS
# Includes players online, tps and performance time series
Remove_time_series_data_after: 90
Remove_time_series_data_after:
Time: 90
Unit: DAYS
Remove_ping_data_after: 14
Remove_ping_data_after:
Time: 14
Unit: DAYS
Remove_disabled_extension_data_after: 2
Remove_disabled_extension_data_after:
Time: 2
Unit: DAYS
Periodic_tasks:
Extension_data_refresh_every: 1
Extension_data_refresh_every:
Time: 1
Unit: HOURS
Check_DB_for_server_config_files_every: 1
Check_DB_for_server_config_files_every:
Time: 1
Unit: MINUTES
Clean_Database_every: 1
Clean_Database_every:
Time: 1
Unit: HOURS
# -----------------------------------------------------
Display_options:
@ -176,8 +198,8 @@ Formatting:
Dates:
# Show_recent_day_names replaces day number with Today, Yesterday, Wednesday etc.
Show_recent_day_names: true
# Non-regex pattern to replace
DatePattern: 'MMM d YYYY'
# Non-regex pattern to replace
Show_recent_day_names_date_pattern: 'MMM d YYYY'
Full: 'MMM d YYYY, HH:mm:ss'
NoSeconds: 'MMM d YYYY, HH:mm'
JustClock: HH:mm:ss
@ -215,7 +237,8 @@ Export:
Export_player_on_login_and_logout: false
# If there are multiple servers the period is divided evenly to avoid export of all servers at once
# Also affects Players page export
Server_refresh_period: 20
Server_refresh_period:
Time: 20
Unit: MINUTES
# -----------------------------------------------------
# These settings affect Plugin data integration.

23
react/dashboard/dashboard/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,67 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"proxy": "https://localhost:8804",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.1.2",
"@fortawesome/fontawesome-svg-core": "^6.1.2",
"@fortawesome/free-brands-svg-icons": "^6.1.2",
"@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.1.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fullcalendar/bootstrap": "^5.11.2",
"@fullcalendar/daygrid": "^5.11.2",
"@fullcalendar/react": "^5.11.2",
"@highcharts/map-collection": "^2.0.1",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/user-event": "^14.3.0",
"axios": "^0.27.2",
"bootstrap": "^5.2.0",
"datatables.net": "^1.12.1",
"datatables.net-bs5": "^1.12.1",
"datatables.net-responsive-bs5": "^2.3.0",
"highcharts": "^9.3.2",
"i18next": "^21.8.16",
"i18next-chained-backend": "^3.0.2",
"i18next-http-backend": "^1.4.1",
"i18next-localstorage-backend": "^3.1.3",
"masonry-layout": "^4.2.2",
"react": "^17.0.2",
"react-bootstrap-v5": "^1.4.0",
"react-dom": "^17.0.2",
"react-i18next": "^11.18.3",
"react-router-dom": "6",
"react-scripts": "5.0.1",
"sass": "^1.54.2",
"source-map-explorer": "^2.5.2",
"swagger-ui": "^4.13.2",
"web-vitals": "^2.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"analyze": "source-map-explorer 'build/static/js/*.js'"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
<meta content="Player Analytics, player page that displays more insights about a specific player"
name="description">
<meta content="AuroraLS3" name="author">
<meta content="noindex, nofollow" name="robots">
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link href="%PUBLIC_URL%/manifest.json" rel="manifest"/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Plan | Player Analytics</title>
<link crossorigin="anonymous"
href="https://fonts.googleapis.com/css?family=Nunito:400,700,800,900&display=swap&subset=latin-ext"
rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,15 @@
{
"short_name": "Plan",
"name": "Player Analytics",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,101 @@
import './style/main.sass';
import './style/sb-admin-2.css'
import './style/style.css';
import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom";
import React from "react";
import PlayerPage from "./views/layout/PlayerPage";
import PlayerOverview from "./views/player/PlayerOverview";
import PlayerSessions from "./views/player/PlayerSessions";
import PlayerPvpPve from "./views/player/PlayerPvpPve";
import PlayerServers from "./views/player/PlayerServers";
import PlayerPluginData from "./views/player/PlayerPluginData";
import {ThemeContextProvider} from "./hooks/themeHook";
import axios from "axios";
import ErrorView from "./views/ErrorView";
import {faMapSigns} from "@fortawesome/free-solid-svg-icons";
import {MetadataContextProvider} from "./hooks/metadataHook";
import {AuthenticationContextProvider} from "./hooks/authenticationHook";
import {NavigationContextProvider} from "./hooks/navigationHook";
import ServerPage from "./views/layout/ServerPage";
import ServerOverview from "./views/server/ServerOverview";
import MainPageRedirect from "./components/navigation/MainPageRedirect";
import OnlineActivity from "./views/server/OnlineActivity";
import ServerSessions from "./views/server/ServerSessions";
import ServerPvpPve from "./views/server/ServerPvpPve";
import PlayerbaseOverview from "./views/server/PlayerbaseOverview";
import ServerPlayers from "./views/server/ServerPlayers";
import PlayersPage from "./views/layout/PlayersPage";
import AllPlayers from "./views/players/AllPlayers";
import ServerGeolocations from "./views/server/ServerGeolocations";
const SwaggerView = React.lazy(() => import("./views/SwaggerView"));
const OverviewRedirect = () => {
return (<Navigate to={"overview"} replace={true}/>)
}
const ContextProviders = ({children}) => (
<AuthenticationContextProvider>
<MetadataContextProvider>
<ThemeContextProvider>
<NavigationContextProvider>
{children}
</NavigationContextProvider>
</ThemeContextProvider>
</MetadataContextProvider>
</AuthenticationContextProvider>
)
function App() {
axios.defaults.withCredentials = true;
return (
<div className="App">
<ContextProviders>
<div id="wrapper">
<BrowserRouter>
<Routes>
<Route path="" element={<MainPageRedirect/>}/>
<Route path="/player/:identifier" element={<PlayerPage/>}>
<Route path="" element={<OverviewRedirect/>}/>
<Route path="overview" element={<PlayerOverview/>}/>
<Route path="sessions" element={<PlayerSessions/>}/>
<Route path="pvppve" element={<PlayerPvpPve/>}/>
<Route path="servers" element={<PlayerServers/>}/>
<Route path="plugins/:serverName" element={<PlayerPluginData/>}/>
<Route path="*" element={<ErrorView error={{
message: 'Unknown tab address, please correct the address',
title: 'No such tab',
icon: faMapSigns
}}/>}/>
</Route>
<Route path="/players" element={<PlayersPage/>}>
<Route path="" element={<AllPlayers/>}/>
<Route path="*" element={<AllPlayers/>}/>
</Route>
<Route path="/server/:identifier" element={<ServerPage/>}>
<Route path="" element={<OverviewRedirect/>}/>
<Route path="overview" element={<ServerOverview/>}/>
<Route path="online-activity" element={<OnlineActivity/>}/>
<Route path="sessions" element={<ServerSessions/>}/>
<Route path="pvppve" element={<ServerPvpPve/>}/>
<Route path="playerbase" element={<PlayerbaseOverview/>}/>
<Route path="retention" element={<></>}/>
<Route path="players" element={<ServerPlayers/>}/>
<Route path="geolocations" element={<ServerGeolocations/>}/>
<Route path="performance" element={<></>}/>
<Route path="plugins-overview" element={<></>}/>
</Route>
<Route path="docs" element={<React.Suspense fallback={<></>}>
<SwaggerView/>
</React.Suspense>}/>
</Routes>
</BrowserRouter>
</div>
</ContextProviders>
</div>
);
}
export default App;

View File

@ -0,0 +1,8 @@
import {render, screen} from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App/>);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,51 @@
import React, {useEffect, useState} from "react";
import {useLocation} from "react-router-dom";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
const TabButton = ({name, href, icon, color, active}) => {
return (
<li className="nav-item">
<a className={"nav-link col-black" + (active ? ' active' : '')} aria-selected={active} role="tab"
href={'#' + href}>
<Fa icon={icon} className={'col-' + color}/> {name}
</a>
</li>
)
}
const TabButtons = ({tabs, selectedTab}) => {
return (
<ul className="nav nav-tabs" role="tablist">
{tabs.map((tab, i) => (
<TabButton
key={i}
name={tab.name}
href={tab.href}
icon={tab.icon}
color={tab.color}
active={tab.href === selectedTab}
/>
))}
</ul>
)
}
const CardTabs = ({tabs}) => {
const {hash} = useLocation();
const firstTab = tabs ? tabs[0].href : undefined;
const [selectedTab, setSelectedTab] = useState(firstTab);
useEffect(() => {
setSelectedTab(hash && tabs ? tabs.find(t => t.href === hash.substring(1)).href : firstTab)
}, [hash, tabs, firstTab])
const tabContent = tabs.find(t => t.href === selectedTab).element;
return (
<>
<TabButtons tabs={tabs} selectedTab={selectedTab}/>
{tabContent}
</>
)
}
export default CardTabs

View File

@ -0,0 +1,17 @@
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React from "react";
import End from "./layout/End";
const Datapoint = ({icon, color, name, value, valueLabel, bold, boldTitle, title, trend}) => {
const displayedValue = bold ? <b>{value}</b> : value;
const extraLabel = typeof valueLabel === 'string' ? ` (${valueLabel})` : '';
const colorClass = color && color.startsWith("col-") ? color : "col-" + color;
return (
<p title={title ? title : name + " is " + value}>
{icon && <Fa icon={icon} className={colorClass}/>} {boldTitle ? <b>{name}</b> : name}
{value !== undefined ? <End>{displayedValue} {extraLabel}{trend}</End> : ''}
</p>
);
}
export default Datapoint

View File

@ -0,0 +1,9 @@
import React from "react";
const Scrollable = ({children}) => (
<div className="scrollbar">
{children}
</div>
)
export default Scrollable;

View File

@ -0,0 +1,71 @@
import React, {useState} from "react";
import {useTheme} from "../../hooks/themeHook";
const SliceHeader = ({i, open, onClick, slice}) => {
let style = 'bg-' + slice.color + (slice.outline ? '-outline' : '');
return (
<tr id={"slice_h_" + i} aria-controls={"slice_t_" + i} aria-expanded={open ? "true" : "false"}
className={"clickable collapsed " + style} data-bs-target={"#slice_t_" + i} data-bs-toggle="collapse"
onClick={onClick}
>
{slice.header}
</tr>
)
}
const SliceBody = ({i, open, slice, width}) => {
if (!open) return <tr className={open ? 'open' : 'closed'}/>
return (
<tr className={"collapse" + (open ? ' show' : '')} data-bs-parent="#tableAccordion" id={"slice_t_" + i}>
<td colSpan={width}>
{slice.body}
</td>
</tr>
)
}
const Slice = ({i, slice, open, onClick, width}) => (
<>
<SliceHeader i={i} open={open} onClick={onClick} slice={slice}/>
<SliceBody i={i} open={open} slice={slice} width={width}/>
</>
)
const NoDataRow = ({width}) => {
const nLengthArray = Array.from(Array(width - 1).keys());
return (<tr>
<td>No Data</td>
{nLengthArray.map(i => <td key={i}>-</td>)}
</tr>);
}
const Accordion = ({headers, slices, open}) => {
const [openSlice, setOpenSlice] = useState(open ? 0 : -1);
const {nightModeEnabled} = useTheme();
const toggleSlice = (i) => {
setOpenSlice(openSlice === i ? -1 : i);
}
const width = headers.length;
return (
<table className={"table accordion-striped" + (nightModeEnabled ? " table-dark" : '')} id="tableAccordion">
<thead>
<tr>
{headers.map((header, i) => <th key={i}>{header}</th>)}
</tr>
</thead>
<tbody>
{slices.length ? slices.map((slice, i) => (
<Slice key={'slice-' + i} i={i} slice={slice} width={width}
open={openSlice === i} onClick={() => toggleSlice(i)}
/>
)) : <NoDataRow width={width}/>}
</tbody>
</table>
)
}
export default Accordion;

View File

@ -0,0 +1,111 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCrosshairs, faGavel, faLocationArrow, faServer, faSkull} from "@fortawesome/free-solid-svg-icons";
import {faCalendarCheck, faCalendarPlus, faClock} from "@fortawesome/free-regular-svg-icons";
import Datapoint from "../Datapoint";
import {Col, Row} from "react-bootstrap-v5";
import WorldPie from "../graphs/WorldPie";
import Accordion from "./Accordion";
import {faSuperpowers} from "@fortawesome/free-brands-svg-icons";
import {useTranslation} from "react-i18next";
import {baseAddress} from "../../service/backendConfiguration";
import {useAuth} from "../../hooks/authenticationHook";
const ServerHeader = ({server}) => {
return (
<>
<td>{server.server_name}
{server.operator ? <Fa icon={faSuperpowers} title="Operator"/> : ''}
{server.banned ? <Fa icon={faGavel} title="Banned"/> : ''}
</td>
<td>{server.playtime}</td>
<td>{server.registered}</td>
<td>{server.last_seen}</td>
</>
)
}
const ServerBody = ({i, server}) => {
const {t} = useTranslation();
const {hasPermission} = useAuth();
return (
<Row>
<Col lg={6}>
{server.operator ? <Datapoint icon={faSuperpowers} color="blue" name={t('html.label.operator')}/> : ''}
{server.banned ? <Datapoint icon={faGavel} color="red" name={t('html.label.banned')}/> : ''}
{server.operator || server.banned ? <br/> : ''}
<Datapoint
icon={faCalendarCheck} color={"teal"}
name={t('html.label.sessions')} value={server.session_count} bold
/>
<Datapoint
icon={faClock} color={"green"}
name={t('html.label.playtime')} value={server.playtime} bold
/>
<Datapoint
icon={faClock} color={"grey"}
name={t('html.label.afkTime')} value={server.afk_time} bold
/>
<Datapoint
icon={faClock} color={"teal"}
name={t('html.label.longestSession')} value={server.longest_session_length} bold
/>
<Datapoint
icon={faClock} color={"teal"}
name={t('html.label.sessionMedian')} value={server.session_median} bold
/>
<br/>
<Datapoint
icon={faLocationArrow} color={"amber"}
name={t('html.label.joinAddress')} value={server.join_address}
/>
<br/>
<Datapoint
icon={faCrosshairs} color="red"
name={t('html.label.playerKills')} value={server.player_kills} bold
/>
<Datapoint
icon={faCrosshairs} color="green"
name={t('html.label.mobKills')} value={server.mob_kills} bold
/>
<Datapoint
icon={faSkull} color="black"
name={t('html.label.deaths')} value={server.deaths} bold
/>
<hr/>
</Col>
<div className="col-xs-12 col-sm-12 col-md-12 col-lg-6">
<WorldPie id={"worldpie_server_" + i}
worldSeries={server.world_pie_series}
gmSeries={server.gm_series}/>
{hasPermission('page.server') && <a href={`${baseAddress}/server/${server.server_uuid}`}
className="float-end btn bg-light-green me-2">
<Fa icon={faServer}/> {t('html.label.serverPage')}
</a>}
</div>
</Row>
)
}
const ServerAccordion = ({servers}) => {
const {t} = useTranslation();
return (
<Accordion headers={[
<><Fa icon={faServer}/> {t('html.label.server')}</>,
<><Fa icon={faClock}/> {t('html.label.playtime')}</>,
<><Fa icon={faCalendarPlus}/> {t('html.label.registered')}</>,
<><Fa icon={faCalendarCheck}/> {t('html.label.lastSeen')}</>
]} slices={servers.map(server => {
return {
body: <ServerBody server={server}/>,
header: <ServerHeader server={server}/>,
color: 'light-green',
outline: true
}
})}/>
)
}
export default ServerAccordion

View File

@ -0,0 +1,111 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCrosshairs, faServer, faSignal, faSkull, faUser} from "@fortawesome/free-solid-svg-icons";
import {faCalendarPlus, faClock, faMap} from "@fortawesome/free-regular-svg-icons";
import Datapoint from "../Datapoint";
import {Col, Row} from "react-bootstrap-v5";
import WorldPie from "../graphs/WorldPie";
import KillsTable from "../table/KillsTable";
import Accordion from "./Accordion";
import {useTranslation} from "react-i18next";
import {baseAddress} from "../../service/backendConfiguration";
const SessionHeader = ({session}) => {
return (
<>
<td>{session.name}{session.first_session ?
<Fa icon={faCalendarPlus} title="Registered (First session)"/> : ''}</td>
<td>{session.start}</td>
<td>{session.length}</td>
<td>{session.network_server ? session.network_server : session.most_used_world}</td>
</>
)
}
const SessionBody = ({i, session}) => {
const {t} = useTranslation();
return (
<Row>
<Col lg={6}>
<Datapoint
icon={faClock} color={"teal"}
name={t('html.label.sessionEnded')} value={session.end} bold
/>
<Datapoint
icon={faClock} color={"teal"}
name={t('html.label.length')} value={session.length} bold
/>
<Datapoint
icon={faClock} color={"grey"}
name={t('html.label.afkTime')} value={session.afk_time} bold
/>
<Datapoint
icon={faServer} color={"green"}
name={t('html.label.server')} value={session.server_name} bold
/>
{session.avg_ping ? <Datapoint
icon={faSignal} color={"amber"}
name={t('html.label.averagePing')} value={session.avg_ping} bold
/> : ''}
<br/>
<Datapoint
icon={faCrosshairs} color="red"
name={t('html.label.playerKills')} value={session.player_kills.length} bold
/>
<Datapoint
icon={faCrosshairs} color="green"
name={t('html.label.mobKills')} value={session.mob_kills} bold
/>
<Datapoint
icon={faSkull} color="black"
name={t('html.label.deaths')} value={session.deaths} bold
/>
<hr/>
<KillsTable kills={session.player_kills}/>
</Col>
<div className="col-xs-12 col-sm-12 col-md-12 col-lg-6">
<WorldPie id={"worldpie_" + i}
worldSeries={session.world_series}
gmSeries={session.gm_series}/>
<a href={`${baseAddress}/player/${session.player_uuid}`}
className="float-end btn bg-blue">
<Fa icon={faUser}/> {t('html.label.playerPage')}
</a>
{session.network_server ? <a href={`${baseAddress}/server/${session.server_uuid}`}
className="float-end btn bg-light-green me-2">
<Fa icon={faServer}/> {t('html.label.serverPage')}
</a> : ''}
</div>
</Row>
)
}
const SessionAccordion = (
{
sessions,
isPlayer
}
) => {
const {t} = useTranslation();
const firstColumn = isPlayer ? (<><Fa icon={faUser}/> {t('html.label.player')}</>)
: (<><Fa icon={faServer}/> {t('html.label.server')}</>)
return (
<Accordion headers={[
firstColumn,
<><Fa icon={faClock}/> {t('html.label.sessionStart')}</>,
<><Fa icon={faClock}/> {t('html.label.length')}</>,
<><Fa icon={faMap}/> {t('html.label.mostPlayedWorld')}</>
]} slices={sessions.map(session => {
return {
body: <SessionBody session={session}/>,
header: <SessionHeader session={session}/>,
color: 'teal',
outline: !session.start.includes("Online")
}
})}/>
)
}
export default SessionAccordion

View File

@ -0,0 +1,28 @@
import React from "react";
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
const PlayerSessionCalendar = ({series, firstDay}) => {
return (
<FullCalendar
plugins={[dayGridPlugin]}
timeZone="UTC"
themeSystem='bootstrap'
eventColor='#009688'
dayMaxEventRows={4}
firstDay={firstDay}
initialView='dayGridMonth'
navLinks={true}
height={560}
contentHeight={560}
headerToolbar={{
left: 'title',
center: '',
right: 'dayGridMonth dayGridWeek dayGridDay today prev next'
}}
events={(_fetchInfo, successCallback) => successCallback(series)}
/>
)
}
export default PlayerSessionCalendar

View File

@ -0,0 +1,28 @@
import React from "react";
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
const ServerCalendar = ({series, firstDay}) => {
return (
<FullCalendar
plugins={[dayGridPlugin]}
timeZone="UTC"
themeSystem='bootstrap'
eventColor='#2196F3'
// dayMaxEventRows={4}
firstDay={firstDay}
initialView='dayGridMonth'
navLinks={true}
height={800}
contentHeight={800}
headerToolbar={{
left: 'title',
center: '',
right: 'dayGridMonth dayGridWeek dayGridDay today prev next'
}}
events={(_fetchInfo, successCallback) => successCallback(series)}
/>
)
}
export default ServerCalendar

View File

@ -0,0 +1,9 @@
.fc-button
background-color: #368F17 !important
.fc-header-toolbar
margin-bottom: 0 !important
padding: 0.5rem
.fc-button
border-color: transparent !important

View File

@ -0,0 +1,42 @@
import {useTranslation} from "react-i18next";
import {Card, Col, Row} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React from "react";
import {faExclamationTriangle, faGlobe} from "@fortawesome/free-solid-svg-icons";
import GeolocationBarGraph from "../../graphs/GeolocationBarGraph";
import GeolocationWorldMap from "../../graphs/GeolocationWorldMap";
const GeolocationsCard = ({data}) => {
const {t} = useTranslation();
if (!data?.geolocations_enabled) {
return (
<div className="alert alert-warning mb-0" id="geolocation-warning">
<Fa icon={faExclamationTriangle}/>{' '}
{t('html.description.noGeolocations')}
</div>
)
}
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faGlobe} className="col-green"/> {t('html.label.geolocations')}
</h6>
</Card.Header>
<Card.Body className="chart-area" style={{height: "100%"}}>
<Row>
<Col md={3}>
<GeolocationBarGraph series={data.geolocation_bar_series} color={data.colors.bars}/>
</Col>
<Col md={9}>
<GeolocationWorldMap series={data.geolocation_series} colors={data.colors}/>
</Col>
</Row>
</Card.Body>
</Card>
)
}
export default GeolocationsCard;

View File

@ -0,0 +1,23 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React from "react";
import {faLifeRing} from "@fortawesome/free-regular-svg-icons";
const InsightsFor30DaysCard = ({children}) => {
const {t} = useTranslation();
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faLifeRing} className="col-red"/> {t('html.label.insights30days')}
</h6>
</Card.Header>
<Card.Body>
{children}
</Card.Body>
</Card>
)
}
export default InsightsFor30DaysCard;

View File

@ -0,0 +1,41 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React, {useEffect, useState} from "react";
import {faUsers} from "@fortawesome/free-solid-svg-icons";
import DataTablesTable from "../../table/DataTablesTable";
import {CardLoader} from "../../navigation/Loader";
const PlayerListCard = ({data}) => {
const {t} = useTranslation();
const [options, setOptions] = useState(undefined);
useEffect(() => {
for (const row of data.data) {
row.name = row.name.replace('../player/', '../../player/');
}
setOptions({
responsive: true,
deferRender: true,
columns: data.columns,
data: data.data,
order: [[5, "desc"]]
});
}, [data])
if (!options) return <CardLoader/>
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faUsers} className="col-black"/> {t('html.label.playerList')}
</h6>
</Card.Header>
<DataTablesTable id={"players-table"} options={options}/>
</Card>
)
}
export default PlayerListCard;

View File

@ -0,0 +1,25 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCrosshairs} from "@fortawesome/free-solid-svg-icons";
import KillsTable from "../../table/KillsTable";
import React from "react";
const PvpKillsTableCard = ({player_kills}) => {
const {t} = useTranslation();
if (!player_kills) return <></>;
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faCrosshairs} className="col-red"/> {t('html.label.recentPvpKills')}
</h6>
</Card.Header>
<KillsTable kills={player_kills}/>
</Card>
)
}
export default PvpKillsTableCard;

View File

@ -0,0 +1,28 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCalendar, faHandPointer} from "@fortawesome/free-regular-svg-icons";
import Scrollable from "../../Scrollable";
import SessionAccordion from "../../accordion/SessionAccordion";
import React from "react";
const RecentSessionsCard = ({sessions, isPlayer}) => {
const {t} = useTranslation();
return (
<Card>
<Card.Header>
<h6 className="col-black" style={{width: '100%'}}>
<Fa icon={faCalendar} className="col-teal"/> {t('html.label.recentSessions')}
<span className="float-end">
<Fa icon={faHandPointer}/> <small>{t('html.text.clickToExpand')}</small>
</span>
</h6>
</Card.Header>
<Scrollable>
<SessionAccordion sessions={sessions} isPlayer={isPlayer}/>
</Scrollable>
</Card>
)
}
export default RecentSessionsCard;

View File

@ -0,0 +1,21 @@
import {Card} from "react-bootstrap-v5";
import Datapoint from "../../Datapoint";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faLongArrowAltRight} from "@fortawesome/free-solid-svg-icons";
import BigTrend from "../../trend/BigTrend";
import React from "react";
const TrendCard = ({icon, color, name, previous, next, trend}) => {
return <Card>
<Card.Body>
<Datapoint icon={icon} name={name} color={color}
value={
<>
{previous} <Fa icon={faLongArrowAltRight}/> {next} <BigTrend trend={trend}/>
</>
}/>
</Card.Body>
</Card>
}
export default TrendCard;

View File

@ -0,0 +1,26 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faClock} from "@fortawesome/free-regular-svg-icons";
import WorldPie from "../../graphs/WorldPie";
import React from "react";
const WorldPieCard = ({worldSeries, gmSeries}) => {
const {t} = useTranslation();
return (
<Card>
<Card.Header>
<h6 className="col-black" style={{width: '100%'}}>
<Fa icon={faClock} className="col-teal"/> {t('html.label.worldPlaytime')}
</h6>
</Card.Header>
<WorldPie
id="world-pie"
worldSeries={worldSeries}
gmSeries={gmSeries}
/>
</Card>
)
}
export default WorldPieCard;

View File

@ -0,0 +1,42 @@
import {useTranslation} from "react-i18next";
import {useTheme} from "../../../hooks/themeHook";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faServer, faSignature} from "@fortawesome/free-solid-svg-icons";
import Scrollable from "../../Scrollable";
import {faClock} from "@fortawesome/free-regular-svg-icons";
import React from "react";
const NicknamesCard = ({player}) => {
const {t} = useTranslation();
const {nightModeEnabled} = useTheme();
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faSignature}/> {t('html.label.seenNicknames')}
</h6>
</Card.Header>
<Scrollable>
<table className={"table table-striped mb-0" + (nightModeEnabled ? " table-dark" : '')}>
<thead className="bg-purple">
<tr>
<th><Fa icon={faSignature}/> {t('html.label.nickname')}</th>
<th><Fa icon={faServer}/> {t('html.label.server')}</th>
<th><Fa icon={faClock}/> {t('html.label.lastSeen')}</th>
</tr>
</thead>
<tbody>
{player.nicknames.map((nickname, i) => (<tr key={'nick-' + i}>
<td dangerouslySetInnerHTML={{__html: nickname.nickname}}/>
<td>{nickname.server}</td>
<td>{nickname.date}</td>
</tr>))}
</tbody>
</table>
</Scrollable>
</Card>
);
}
export default NicknamesCard;

View File

@ -0,0 +1,10 @@
import React from "react";
import RecentSessionsCard from "../common/RecentSessionsCard";
const PlayerRecentSessionsCard = ({player}) => {
return (
<RecentSessionsCard sessions={player.sessions}/>
)
}
export default PlayerRecentSessionsCard;

View File

@ -0,0 +1,13 @@
import React from "react";
import WorldPieCard from "../common/WorldPieCard";
const PlayerWorldPieCard = ({player}) => {
return (
<WorldPieCard
worldSeries={player.world_pie_series}
gmSeries={player.gm_series}
/>
)
}
export default PlayerWorldPieCard;

View File

@ -0,0 +1,22 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCampground} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import PlayerPvpPveAsNumbersTable from "../../table/PlayerPvpPveAsNumbersTable";
const PvpPveAsNumbersCard = ({player}) => {
const {t} = useTranslation();
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faCampground} className="col-red"/> {t('html.label.pvpPveAsNumbers')}
</h6>
</Card.Header>
<PlayerPvpPveAsNumbersTable killData={player.kill_data}/>
</Card>
)
}
export default PvpPveAsNumbersCard;

View File

@ -0,0 +1,33 @@
import React from "react";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchPlayerbaseDevelopmentGraph} from "../../../../service/serverService";
import {ErrorViewBody} from "../../../../views/ErrorView";
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faUsers} from "@fortawesome/free-solid-svg-icons";
import PlayerbasePie from "../../../graphs/PlayerbasePie";
const CurrentPlayerbaseCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchPlayerbaseDevelopmentGraph, [identifier]);
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <></>;
return (
<Card>
<Card.Header>
<h6 className="col-black" style={{width: '100%'}}>
<Fa icon={faUsers} className="col-amber"/> {t('html.label.currentPlayerbase')}
</h6>
</Card.Header>
<PlayerbasePie series={data.activity_pie_series}/>
</Card>
)
}
export default CurrentPlayerbaseCard;

View File

@ -0,0 +1,36 @@
import {useTranslation} from "react-i18next";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchPlayersOnlineGraph} from "../../../../service/serverService";
import {ErrorViewCard} from "../../../../views/ErrorView";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faChartArea} from "@fortawesome/free-solid-svg-icons";
import PlayersOnlineGraph from "../../../graphs/PlayersOnlineGraph";
import React from "react";
import {CardLoader} from "../../../navigation/Loader";
const OnlineActivityCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(
fetchPlayersOnlineGraph,
[identifier])
if (loadingError) return <ErrorViewCard error={loadingError}/>
if (!data) return <CardLoader/>;
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa className="col-blue" icon={faChartArea}/> {t('html.label.onlineActivity')}
</h6>
</Card.Header>
<PlayersOnlineGraph data={data}/>
</Card>
)
}
export default OnlineActivityCard;

View File

@ -0,0 +1,86 @@
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {
fetchDayByDayGraph,
fetchHourByHourGraph,
fetchPunchCardGraph,
fetchServerCalendarGraph
} from "../../../../service/serverService";
import {ErrorViewBody} from "../../../../views/ErrorView";
import PunchCard from "../../../graphs/PunchCard";
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import CardTabs from "../../../CardTabs";
import {faBraille, faChartArea} from "@fortawesome/free-solid-svg-icons";
import {faCalendar} from "@fortawesome/free-regular-svg-icons";
import React from "react";
import TimeByTimeGraph from "../../../graphs/TimeByTimeGraph";
import ServerCalendar from "../../../calendar/ServerCalendar";
import {ChartLoader} from "../../../navigation/Loader";
const DayByDayTab = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchDayByDayGraph, [identifier])
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <ChartLoader/>;
return <TimeByTimeGraph data={data}/>
}
const HourByHourTab = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchHourByHourGraph, [identifier])
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <ChartLoader/>;
return <TimeByTimeGraph data={data}/>
}
const ServerCalendarTab = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchServerCalendarGraph, [identifier])
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <ChartLoader/>;
return <ServerCalendar series={data.data} firstDay={data.firstDay}/>
}
const PunchCardTab = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchPunchCardGraph, [identifier])
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <ChartLoader/>;
return <PunchCard series={data.punchCard}/>
}
const OnlineActivityGraphsCard = () => {
const {t} = useTranslation();
return <Card>
<CardTabs tabs={[
{
name: t('html.label.dayByDay'), icon: faChartArea, color: 'blue', href: 'day-by-day',
element: <DayByDayTab/>
}, {
name: t('html.label.hourByHour'), icon: faChartArea, color: 'blue', href: 'hour-by-hour',
element: <HourByHourTab/>
}, {
name: t('html.label.serverCalendar'), icon: faCalendar, color: 'teal', href: 'server-calendar',
element: <ServerCalendarTab/>
}, {
name: t('html.label.punchcard30days'), icon: faBraille, color: 'black', href: 'punchcard',
element: <PunchCardTab/>
},
]}/>
</Card>
}
export default OnlineActivityGraphsCard;

View File

@ -0,0 +1,35 @@
import {useTranslation} from "react-i18next";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchPlayerbaseDevelopmentGraph} from "../../../../service/serverService";
import {ErrorViewCard} from "../../../../views/ErrorView";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faChartLine} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import PlayerbaseGraph from "../../../graphs/PlayerbaseGraph";
const PlayerbaseDevelopmentCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(
fetchPlayerbaseDevelopmentGraph,
[identifier])
if (!data) return <></>;
if (loadingError) return <ErrorViewCard error={loadingError}/>
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa className="col-amber" icon={faChartLine}/> {t('html.label.playerbaseDevelopment')}
</h6>
</Card.Header>
<PlayerbaseGraph data={data}/>
</Card>
)
}
export default PlayerbaseDevelopmentCard;

View File

@ -0,0 +1,25 @@
import React from "react";
import WorldPieCard from "../../common/WorldPieCard";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchWorldPie} from "../../../../service/serverService";
import {ErrorViewBody} from "../../../../views/ErrorView";
import {CardLoader} from "../../../navigation/Loader";
const ServerWorldPieCard = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchWorldPie, [identifier]);
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <CardLoader/>;
return (
<WorldPieCard
worldSeries={data.world_series}
gmSeries={data.gm_series}
/>
)
}
export default ServerWorldPieCard;

View File

@ -0,0 +1,39 @@
import React from "react";
import InsightsFor30DaysCard from "../../common/InsightsFor30DaysCard";
import {useTranslation} from "react-i18next";
import Datapoint from "../../../Datapoint";
import {faUserClock, faUserGroup} from "@fortawesome/free-solid-svg-icons";
import SmallTrend from "../../../trend/SmallTrend";
import {faCalendar, faCalendarPlus} from "@fortawesome/free-regular-svg-icons";
import ComparingLabel from "../../../trend/ComparingLabel";
import End from "../../../layout/End";
const OnlineActivityInsightsCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>
return (
<InsightsFor30DaysCard>
<Datapoint name={t('html.label.onlineOnFirstJoin')} icon={faUserGroup} color="light-green"
value={data.players_first_join_avg}
trend={<SmallTrend trend={data.players_first_join_avg_trend}/>}/>
<Datapoint name={t('html.label.firstSessionLength.average')} icon={faUserClock} color="light-green"
value={data.first_session_length_avg}
trend={<SmallTrend trend={data.first_session_length_avg_trend}/>}/>
<Datapoint name={t('html.label.firstSessionLength.median')} icon={faUserClock} color="light-green"
value={data.first_session_length_median}
trend={<SmallTrend trend={data.first_session_length_median_trend}/>}/>
<Datapoint name={t('html.label.loneJoins')} icon={faCalendar} color="teal"
value={data.lone_joins}
trend={<SmallTrend trend={data.lone_joins_trend}/>}/>
<Datapoint name={t('html.label.loneNewbieJoins')} icon={faCalendarPlus} color="teal"
value={data.lone_new_joins}
trend={<SmallTrend trend={data.lone_new_joins_trend}/>}/>
<End>
<ComparingLabel>{t('html.text.comparing15days')}</ComparingLabel>
</End>
</InsightsFor30DaysCard>
)
}
export default OnlineActivityInsightsCard;

View File

@ -0,0 +1,45 @@
import React from "react";
import InsightsFor30DaysCard from "../../common/InsightsFor30DaysCard";
import {useTranslation} from "react-i18next";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import Datapoint from "../../../Datapoint";
import {faLongArrowAltRight, faUser} from "@fortawesome/free-solid-svg-icons";
import SmallTrend from "../../../trend/SmallTrend";
import End from "../../../layout/End";
import ComparingLabel from "../../../trend/ComparingLabel";
const TwoPlayerChange = ({colorBefore, labelBefore, colorAfter, labelAfter}) => {
return (
<>
<Fa icon={faUser} className={`col-${colorBefore}`}/>{' '}{labelBefore}
{' '}<Fa icon={faLongArrowAltRight}/>{' '}
<Fa icon={faUser} className={`col-${colorAfter}`}/>{' '}{labelAfter}
</>
)
}
const PlayerbaseInsightsCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>;
return (
<InsightsFor30DaysCard>
<Datapoint name={<TwoPlayerChange colorBefore={'light-green'}
labelBefore={t('html.label.new')}
colorAfter={'lime'}
labelAfter={t('html.label.regular')}/>}
value={data.new_to_regular}
trend={<SmallTrend trend={data.new_to_regular_trend}/>}
/>
<Datapoint name={<TwoPlayerChange colorBefore={'lime'}
labelBefore={t('html.label.regular')}
colorAfter={'bluegray'}
labelAfter={t('html.label.inactive')}/>}
value={data.regular_to_inactive}
trend={<SmallTrend trend={data.regular_to_inactive_trend}/>}
/>
<End><ComparingLabel>{t('html.text.comparing30daysAgo')}</ComparingLabel></End>
</InsightsFor30DaysCard>
)
}
export default PlayerbaseInsightsCard;

View File

@ -0,0 +1,22 @@
import React from "react";
import InsightsFor30DaysCard from "../../common/InsightsFor30DaysCard";
import Datapoint from "../../../Datapoint";
import {useTranslation} from "react-i18next";
import {faKhanda} from "@fortawesome/free-solid-svg-icons";
const PvpPveInsightsCard = ({data}) => {
const {t} = useTranslation();
return (
<InsightsFor30DaysCard>
<Datapoint name={t('html.label.deadliestWeapon')} icon={faKhanda} color="amber"
value={data.weapon_1st}/>
<Datapoint name={t('html.label.secondDeadliestWeapon')} icon={faKhanda} color="gray"
value={data.weapon_2nd}/>
<Datapoint name={t('html.label.thirdDeadliestWeapon')} icon={faKhanda} color="brown"
value={data.weapon_3rd}/>
</InsightsFor30DaysCard>
)
}
export default PvpPveInsightsCard;

View File

@ -0,0 +1,39 @@
import React from "react";
import InsightsFor30DaysCard from "../../common/InsightsFor30DaysCard";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchSessionOverview} from "../../../../service/serverService";
import ErrorView from "../../../../views/ErrorView";
import Datapoint from "../../../Datapoint";
import {useTranslation} from "react-i18next";
import {faGamepad, faUsers} from "@fortawesome/free-solid-svg-icons";
import {faClock} from "@fortawesome/free-regular-svg-icons";
const SessionInsightsCard = () => {
const {t} = useTranslation();
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchSessionOverview, [identifier]);
if (!data) return <></>;
if (loadingError) return <ErrorView error={loadingError}/>
return (
<InsightsFor30DaysCard>
<Datapoint name={t('html.label.mostActiveGamemode')} icon={faGamepad} color="teal" bold
value={data.insights.most_active_gamemode} valueLabel={data.insights.most_active_gamemode_perc}
/>
<Datapoint name={t('html.label.serverOccupied')} icon={faUsers} color="teal"
value={'~' + data.insights.server_occupied} valueLabel={data.insights.server_occupied_perc}
/>
<Datapoint name={t('html.label.playtime')} icon={faClock} color="green"
value={data.insights.total_playtime}
/>
<Datapoint name={t('html.label.afkTime')} icon={faClock} color="grey"
value={data.insights.afk_time} valueLabel={data.insights.afk_time_perc}
/>
</InsightsFor30DaysCard>
)
}
export default SessionInsightsCard;

View File

@ -0,0 +1,24 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faBookOpen} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import OnlineActivityAsNumbersTable from "../../../table/OnlineActivityAsNumbersTable";
const OnlineActivityAsNumbersCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faBookOpen} className="col-light-blue"/> {t('html.label.onlineActivityAsNumbers')}
</h6>
</Card.Header>
<OnlineActivityAsNumbersTable data={data}/>
</Card>
)
}
export default OnlineActivityAsNumbersCard;

View File

@ -0,0 +1,52 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faExchangeAlt, faUsers} from "@fortawesome/free-solid-svg-icons";
import ComparisonTable from "../../../table/ComparisonTable";
import BigTrend from "../../../trend/BigTrend";
import React from "react";
import {faClock} from "@fortawesome/free-regular-svg-icons";
import {TableRow} from "../../../table/TableRow";
const PlayerbaseTrendsCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>;
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faExchangeAlt} className="col-amber"/> {t('html.label.trends30days')}
</h6>
</Card.Header>
<ComparisonTable comparisonHeader={t('html.text.comparing30daysAgo')}
headers={[t('html.label.thirtyDaysAgo'), t('html.label.now'), t('html.label.trend')]}>
<TableRow icon={faUsers} color="black" text={t('html.label.totalPlayers')}
values={[data.total_players_now, data.total_players_then,
<BigTrend trend={data.total_players_trend}/>]}/>
<TableRow icon={faUsers} color="lime" text={t('html.label.regularPlayers')}
values={[data.regular_players_now, data.regular_players_then,
<BigTrend trend={data.regular_players_trend}/>]}/>
<TableRow icon={faClock} color="green"
text={t('html.label.averagePlaytime') + ' ' + t('html.label.perPlayer')}
values={[data.playtime_avg_now, data.playtime_avg_then,
<BigTrend trend={data.playtime_avg_trend}/>]}/>
<TableRow icon={faClock} color="gray" text={t('html.label.afk') + ' ' + t('html.label.perPlayer')}
values={[data.afk_now, data.afk_then, <BigTrend trend={data.afk_trend}/>]}/>
<TableRow icon={faClock} color="green"
text={t('html.label.averagePlaytime') + ' ' + t('html.label.perRegularPlayer')}
values={[data.regular_playtime_avg_now, data.regular_playtime_avg_then,
<BigTrend trend={data.regular_playtime_avg_trend}/>]}/>
<TableRow icon={faClock} color="teal"
text={t('html.label.averageSessionLength') + ' ' + t('html.label.perRegularPlayer')}
values={[data.regular_session_avg_now, data.regular_session_avg_then,
<BigTrend trend={data.regular_session_avg_trend}/>]}/>
<TableRow icon={faClock} color="gray"
text={t('html.label.afk') + ' ' + t('html.label.perRegularPlayer')}
values={[data.regular_afk_avg_now, data.regular_afk_avg_then,
<BigTrend trend={data.regular_afk_avg_trend}/>]}/>
</ComparisonTable>
</Card>
)
}
export default PlayerbaseTrendsCard

View File

@ -0,0 +1,22 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCampground} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import ServerPvpPveAsNumbersTable from "../../../table/ServerPvpPveAsNumbersTable";
const PvpPveAsNumbersCard = ({kill_data}) => {
const {t} = useTranslation();
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faCampground} className="col-red"/> {t('html.label.pvpPveAsNumbers')}
</h6>
</Card.Header>
<ServerPvpPveAsNumbersTable killData={kill_data}/>
</Card>
)
}
export default PvpPveAsNumbersCard;

View File

@ -0,0 +1,22 @@
import React from "react";
import {useParams} from "react-router-dom";
import {useDataRequest} from "../../../../hooks/dataFetchHook";
import {fetchSessions} from "../../../../service/serverService";
import {ErrorViewBody} from "../../../../views/ErrorView";
import RecentSessionsCard from "../../common/RecentSessionsCard";
const ServerRecentSessionsCard = () => {
const {identifier} = useParams();
const {data, loadingError} = useDataRequest(fetchSessions, [identifier])
if (loadingError) return <ErrorViewBody error={loadingError}/>
if (!data) return <></>;
return (
<RecentSessionsCard sessions={data.sessions} isPlayer={true}/>
)
}
export default ServerRecentSessionsCard;

View File

@ -0,0 +1,49 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCrosshairs, faExchangeAlt, faSkull, faUsers} from "@fortawesome/free-solid-svg-icons";
import ComparisonTable from "../../../table/ComparisonTable";
import BigTrend from "../../../trend/BigTrend";
import {faCalendarCheck, faClock} from "@fortawesome/free-regular-svg-icons";
import React from "react";
import {TableRow} from "../../../table/TableRow";
const ServerWeekComparisonCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>;
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faExchangeAlt}/> {t('html.label.weekComparison')}
</h6>
</Card.Header>
<ComparisonTable comparisonHeader={t('html.label.comparing7days')}
headers={[data.start + ' - ' + data.midpoint, data.midpoint + ' - ' + data.end, t('html.label.trend')]}>
<TableRow icon={faUsers} color="blue" text={t('html.label.uniquePlayers')}
values={[data.unique_before, data.unique_after, <BigTrend trend={data.unique_trend}/>]}/>
<TableRow icon={faUsers} color="light-green" text={t('html.label.newPlayers')}
values={[data.new_before, data.new_after, <BigTrend trend={data.new_trend}/>]}/>
<TableRow icon={faUsers} color="lime" text={t('html.label.regularPlayers')}
values={[data.regular_before, data.regular_after, <BigTrend trend={data.regular_trend}/>]}/>
<TableRow icon={faClock} color="green"
text={t('html.label.averagePlaytime') + ' ' + t('html.label.perPlayer')}
values={[data.average_playtime_before, data.average_playtime_after,
<BigTrend trend={data.average_playtime_trend}/>]}/>
<TableRow icon={faCalendarCheck} color="teal" text={t('html.label.sessions')}
values={[data.sessions_before, data.sessions_after,
<BigTrend trend={data.sessions_trend}/>]}/>
<TableRow icon={faCrosshairs} color="red" text={t('html.label.playerKills')}
values={[data.player_kills_before, data.player_kills_after,
<BigTrend trend={data.player_kills_trend}/>]}/>
<TableRow icon={faCrosshairs} color="green" text={t('html.label.mobKills')}
values={[data.mob_kills_before, data.mob_kills_after,
<BigTrend trend={data.mob_kills_trend}/>]}/>
<TableRow icon={faSkull} color="black" text={t('html.label.deaths')}
values={[data.deaths_before, data.deaths_after, <BigTrend trend={data.deaths_trend}/>]}/>
</ComparisonTable>
</Card>
)
}
export default ServerWeekComparisonCard

View File

@ -0,0 +1,75 @@
import {useTranslation} from "react-i18next";
import {Card} from "react-bootstrap-v5";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {
faBookOpen,
faChartLine,
faCrosshairs,
faPowerOff,
faSkull,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import Datapoint from "../../../Datapoint";
import {faCalendarCheck, faClock} from "@fortawesome/free-regular-svg-icons";
import React from "react";
const ServerAsNumbersCard = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>;
return (
<Card>
<Card.Header>
<h6 className="col-black">
<Fa icon={faBookOpen}/> {t('html.label.serverAsNumberse')}
</h6>
</Card.Header>
<Card.Body>
<Datapoint name={t('html.label.currentUptime')}
color={'light-green'} icon={faPowerOff}
value={data.current_uptime}/>
<hr/>
<Datapoint name={t('html.label.totalPlayers')}
color={'black'} icon={faUsers}
value={data.total_players} bold/>
<Datapoint name={t('html.label.regularPlayers')}
color={'lime'} icon={faUsers}
value={data.regular_players} bold/>
<Datapoint name={t('html.label.playersOnline')}
color={'blue'} icon={faUser}
value={data.online_players} bold/>
<hr/>
<Datapoint name={t('html.label.lastPeak') + ' (' + data.last_peak_date + ')'}
color={'blue'} icon={faChartLine}
value={data.last_peak_players} valueLabel={t('html.unit.players')} bold/>
<Datapoint name={t('html.label.bestPeak') + ' (' + data.best_peak_date + ')'}
color={'light-green'} icon={faChartLine}
value={data.best_peak_players} valueLabel={t('html.unit.players')} bold/>
<hr/>
<Datapoint name={t('html.label.totalPlaytime')}
color={'green'} icon={faClock}
value={data.playtime}/>
<Datapoint name={t('html.label.averagePlaytime') + ' ' + t('html.label.perPlayer')}
color={'green'} icon={faClock}
value={data.player_playtime}/>
<Datapoint name={t('html.label.sessions')}
color={'teal'} icon={faCalendarCheck}
value={data.sessions} bold/>
<hr/>
<Datapoint name={t('html.label.playerKills')}
color={'red'} icon={faCrosshairs}
value={data.player_kills} bold/>
<Datapoint name={t('html.label.mobKills')}
color={'green'} icon={faCrosshairs}
value={data.mob_kills} bold/>
<Datapoint name={t('html.label.deaths')}
color={'black'} icon={faSkull}
value={data.deaths} bold/>
</Card.Body>
</Card>
)
}
export default ServerAsNumbersCard;

View File

@ -0,0 +1,126 @@
import React, {useEffect, useState} from "react";
import {Card, Col} from "react-bootstrap-v5";
import ExtensionIcon from "./ExtensionIcon";
import Datapoint from "../Datapoint";
import Masonry from 'masonry-layout'
import {useTheme} from "../../hooks/themeHook";
export const ExtensionCardWrapper = ({extension, children}) => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const onResize = () => {
setWindowWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
}
}, [])
const wide = extension.wide;
const baseWidth = windowWidth < 1000 ? 6 : 4;
const width = (wide ? 2 : 1) * baseWidth;
return <Col lg={width} md={width} className="extension-wrapper">{children}</Col>
}
const ExtensionTab = ({tab}) => {
return (<>
{tab.tabInformation.elementOrder.map((type, i) => {
switch (type) {
case "VALUES":
return <ExtensionValues key={i} tab={tab}/>
case "TABLE":
return <ExtensionTables key={i} tab={tab}/>
default:
return ''
}
})}
</>);
}
const ExtensionValue = ({data}) => {
return (<Datapoint name={data.description.text}
title={data.description.description}
icon={[data.description.icon.familyClass, data.description.icon.iconName]}
color={data.description.icon.colorClass}
value={data.value}
/>);
}
const ExtensionValues = ({tab}) => (
<Card.Body>
{tab.values.map((data, i) => {
return (<ExtensionValue key={i} data={data}/>);
}
)}
</Card.Body>
)
const ExtensionTable = ({table}) => {
const {nightModeEnabled} = useTheme();
return (
<table className={"table table-striped" + (nightModeEnabled ? " table-dark" : '')}>
<thead className={table.tableColorClass}>
<tr>
{table.table.columns.map((column, i) => <th key={i}><ExtensionIcon
icon={table.table.icons[i]}/> {column}
</th>)}
</tr>
</thead>
<tbody>
{table.table.rows.map((row, i) => <tr key={i}>{row.map((value, j) => <td
key={i + '' + j}>{value}</td>)}</tr>)}
</tbody>
</table>
);
}
const ExtensionTables = ({tab}) => {
return (<>
{tab.tableData.map((table, i) => (
<ExtensionTable key={i} table={table}/>
))}
</>);
}
const ExtensionCard = ({extension}) => {
const [openTabIndex, setOpenTabIndex] = useState(0);
const toggleTabIndex = index => {
setOpenTabIndex(index);
requestAnimationFrame(() => {
const masonryRow = document.getElementById('extension-masonry-row');
const masonry = Masonry.data(masonryRow);
if (masonry) {
masonry.layout();
}
});
}
return <Card>
<Card.Header>
<h6>
<ExtensionIcon
icon={extension.extensionInformation.icon}/> {extension.extensionInformation.pluginName}
</h6>
</Card.Header>
<ul className="nav nav-tabs tab-nav-right" role="tablist">
{extension.onlyGenericTab ? '' :
extension.tabs.map((tab, i) => <li key={i} role="presentation" className="nav-item col-black">
<button className={"nav-link col-black"
+ (openTabIndex === i ? ' active' : '')} onClick={() => toggleTabIndex(i)}>
<ExtensionIcon icon={tab.tabInformation.icon}/> {tab.tabInformation.tabName}
</button>
</li>)
}
</ul>
{extension.tabs.map((tab, i) => openTabIndex === i ? <ExtensionTab tab={tab} key={i}/> : '')}
</Card>
}
export default ExtensionCard

View File

@ -0,0 +1,14 @@
import {FontAwesomeIcon as Fa} from '@fortawesome/react-fontawesome'
import {iconTypeToFontAwesomeClass} from "../../util/icons";
import React from "react";
const ExtensionIcon = ({icon}) => (
<Fa icon={[
iconTypeToFontAwesomeClass(icon.family),
icon.iconName
]}
className={icon.colorClass}
/>
)
export default ExtensionIcon;

View File

@ -0,0 +1,46 @@
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import Highcharts from "highcharts";
const GeolocationBarGraph = ({series, color}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const bars = series.map(bar => bar.value);
const categories = series.map(bar => bar.label);
const geolocationBarSeries = {
color: nightModeEnabled ? withReducedSaturation(color) : color,
name: t('html.label.players'),
data: bars
};
Highcharts.setOptions(graphTheming);
Highcharts.chart("countryBarChart", {
chart: {type: 'bar'},
title: {text: ''},
xAxis: {
categories: categories,
title: {text: ''}
},
yAxis: {
min: 0,
title: {text: t('html.label.players'), align: 'high'},
labels: {overflow: 'justify'}
},
legend: {enabled: false},
plotOptions: {
bar: {
dataLabels: {enabled: true}
}
},
series: [geolocationBarSeries]
})
}, [color, series, graphTheming, nightModeEnabled, t]);
return (<div id="countryBarChart"/>);
};
export default GeolocationBarGraph

View File

@ -0,0 +1,46 @@
import React, {useEffect} from 'react';
import {useTranslation} from "react-i18next";
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import Highcharts from 'highcharts/highmaps.js';
import map from '@highcharts/map-collection/custom/world.geo.json';
const GeolocationWorldMap = ({series, colors}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const mapSeries = {
name: t('html.label.players'),
type: 'map',
mapData: map,
data: series,
joinBy: ['iso-a3', 'code']
};
Highcharts.setOptions(graphTheming);
Highcharts.mapChart('countryWorldMap', {
chart: {
animation: true
},
title: {text: ''},
mapNavigation: {
enabled: true,
enableDoubleClickZoomTo: true
},
colorAxis: {
min: 1,
type: 'logarithmic',
minColor: nightModeEnabled ? withReducedSaturation(colors.low) : colors.low,
maxColor: nightModeEnabled ? withReducedSaturation(colors.high) : colors.high
},
series: [mapSeries]
})
}, [colors, series, graphTheming, nightModeEnabled, t]);
return (<div id="countryWorldMap"/>);
};
export default GeolocationWorldMap

View File

@ -0,0 +1,42 @@
import {useTheme} from "../../hooks/themeHook";
import React, {useEffect} from "react";
import {linegraphButtons} from "../../util/graphs";
import Highcharts from "highcharts/highstock";
import NoDataDisplay from "highcharts/modules/no-data-to-display"
import {useTranslation} from "react-i18next";
const LineGraph = ({id, series}) => {
const {t} = useTranslation()
const {graphTheming} = useTheme();
useEffect(() => {
NoDataDisplay(Highcharts);
Highcharts.setOptions({lang: {noData: t('html.label.noDataToDisplay')}})
Highcharts.setOptions(graphTheming);
Highcharts.stockChart(id, {
rangeSelector: {
selected: 2,
buttons: linegraphButtons
},
yAxis: {
softMax: 2,
softMin: 0
},
title: {text: ''},
plotOptions: {
areaspline: {
fillOpacity: 0.4
}
},
series: series
})
}, [series, graphTheming, id, t])
return (
<div className="chart-area" id={id}>
<span className="loader"/>
</div>
)
}
export default LineGraph

View File

@ -0,0 +1,40 @@
import React, {useEffect, useState} from "react";
import {useTranslation} from "react-i18next";
import {tooltip} from "../../util/graphs";
import LineGraph from "./LineGraph";
const PingGraph = ({data}) => {
const {t} = useTranslation();
const [series, setSeries] = useState([]);
useEffect(() => {
const avgPingSeries = {
name: t('html.label.averagePing'),
type: 'spline',
tooltip: tooltip.twoDecimals,
data: data.avg_ping_series,
color: data.colors.avg
}
const maxPingSeries = {
name: t('html.label.worstPing'),
type: 'spline',
tooltip: tooltip.twoDecimals,
data: data.max_ping_series,
color: data.colors.max
}
const minPingSeries = {
name: t('html.label.bestPing'),
type: 'spline',
tooltip: tooltip.twoDecimals,
data: data.min_ping_series,
color: data.colors.min
}
setSeries([avgPingSeries, maxPingSeries, minPingSeries]);
}, [data, t])
return (
<LineGraph id="ping-graph" series={series}/>
)
}
export default PingGraph

View File

@ -0,0 +1,60 @@
import {useTranslation} from "react-i18next";
import React, {useEffect} from "react";
import {useTheme} from "../../hooks/themeHook";
import NoDataDisplay from "highcharts/modules/no-data-to-display";
import Highcharts from "highcharts/highstock";
import {withReducedSaturation} from "../../util/colors";
const PlayerbaseGraph = ({data}) => {
const {t} = useTranslation()
const {nightModeEnabled, graphTheming} = useTheme();
const id = 'playerbase-graph';
useEffect(() => {
const reduceColors = (lines) => lines.map(line => {
return {...line, color: withReducedSaturation(line.color)}
});
NoDataDisplay(Highcharts);
Highcharts.setOptions({lang: {noData: t('html.label.noDataToDisplay')}})
Highcharts.setOptions(graphTheming);
const labels = data?.activity_labels;
const series = data?.activity_series;
Highcharts.chart(id, {
chart: {
type: "area"
},
xAxis: {
categories: labels,
tickmarkPlacement: 'on',
title: {
enabled: false
},
ordinal: false
},
yAxis: {
softMax: 2,
softMin: 0
},
title: {text: ''},
plotOptions: {
area: {
stacking: 'normal',
lineWidth: 1
}
},
series: nightModeEnabled ? reduceColors(series) : series
})
}, [data, graphTheming, id, t, nightModeEnabled])
return (
<div className="chart-area" id={id}>
<span className="loader"/>
</div>
)
}
export default PlayerbaseGraph;

View File

@ -0,0 +1,48 @@
import React, {useEffect} from "react";
import Highcharts from 'highcharts';
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import {useTranslation} from "react-i18next";
const PlayerbasePie = ({series}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const reduceColors = (slices) => slices.map(slice => {
return {...slice, color: withReducedSaturation(slice.color)}
});
const pieSeries = {
name: t('html.label.players'),
colorByPoint: true,
data: nightModeEnabled ? reduceColors(series) : series
};
Highcharts.setOptions(graphTheming);
Highcharts.chart('playerbase-pie', {
chart: {
backgroundColor: 'transparent',
plotBorderWidth: null,
plotShadow: false,
type: 'pie'
},
title: {text: ''},
plotOptions: {
pie: {
allowPointSelect: true,
cursor: 'pointer',
dataLabels: {
enabled: false
},
showInLegend: true
}
},
series: [pieSeries]
});
}, [series, graphTheming, nightModeEnabled, t]);
return (<div className="chart-area" id="playerbase-pie"/>);
}
export default PlayerbasePie;

View File

@ -0,0 +1,30 @@
import React, {useEffect, useState} from "react";
import {useTranslation} from "react-i18next";
import {tooltip} from "../../util/graphs";
import LineGraph from "./LineGraph";
import {ChartLoader} from "../navigation/Loader";
const PlayersOnlineGraph = ({data}) => {
const {t} = useTranslation();
const [series, setSeries] = useState([]);
useEffect(() => {
const playersOnlineSeries = {
name: t('html.label.playersOnline'),
type: 'areaspline',
tooltip: tooltip.zeroDecimals,
data: data.playersOnline,
color: data.color,
yAxis: 0
}
setSeries([playersOnlineSeries]);
}, [data, t])
if (!data) return <ChartLoader/>;
return (
<LineGraph id="players-online-graph" series={series}/>
)
}
export default PlayersOnlineGraph

View File

@ -0,0 +1,56 @@
import React, {useEffect} from "react";
import Highcharts from 'highcharts';
import {useTheme} from "../../hooks/themeHook";
import {useTranslation} from "react-i18next";
const PunchCard = ({series}) => {
const {t} = useTranslation();
const {graphTheming} = useTheme();
useEffect(() => {
const punchCard = {
name: t('html.label.relativeJoinActivity'),
color: '#222',
data: series
};
Highcharts.setOptions(graphTheming);
setTimeout(() => Highcharts.chart('punchcard', {
chart: {
backgroundColor: 'transparent',
plotBackgroundColor: 'transparent',
defaultSeriesType: 'scatter'
},
title: {text: ''},
xAxis: {
type: 'datetime',
dateTimeLabelFormats: {
// https://www.php.net/manual/en/function.strftime.php
hour: '%I %P',
day: '%I %P'
},
tickInterval: 3600000
},
time: {
timezoneOffset: 0
},
yAxis: {
title: {
text: t('html.label.dayOfweek')
},
reversed: true,
categories: t('html.label.weekdays').replaceAll("'", '').split(', ')
},
tooltip: {
pointFormat: t('html.label.active') + ': {point.z}'
},
series: [punchCard]
}), 25)
}, [series, graphTheming, t])
return (
<div className="chart-area" id="punchcard">
<span className="loader"/>
</div>
)
}
export default PunchCard

View File

@ -0,0 +1,54 @@
import React, {useEffect} from "react";
import Highcharts from 'highcharts';
import {formatTimeAmount} from '../../util/formatters'
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import {useTranslation} from "react-i18next";
const ServerPie = ({colors, series}) => {
const {t} = useTranslation();
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const reduceColors = (colorsToReduce) => colorsToReduce.map(color => withReducedSaturation(color));
const pieSeries = {
name: t('html.label.serverPlaytime'),
colorByPoint: true,
colors: nightModeEnabled ? reduceColors(colors) : colors,
data: series
};
Highcharts.setOptions(graphTheming);
Highcharts.chart('server-pie', {
chart: {
backgroundColor: 'transparent',
plotBorderWidth: null,
plotShadow: false,
type: 'pie'
},
title: {text: ''},
plotOptions: {
pie: {
allowPointSelect: true,
cursor: 'pointer',
dataLabels: {
enabled: false
},
showInLegend: true
}
},
tooltip: {
formatter: function () {
return '<b>' + this.point.name + ':</b> ' + formatTimeAmount(this.y) + ' (' + this.percentage.toFixed(2) + '%)';
}
},
series: [pieSeries]
});
}, [colors, series, graphTheming, nightModeEnabled, t]);
return (<div className="chart-pie" id="server-pie"/>);
}
export default ServerPie;

View File

@ -0,0 +1,33 @@
import {useTranslation} from "react-i18next";
import React, {useEffect, useState} from "react";
import {tooltip} from "../../util/graphs";
import LineGraph from "./LineGraph";
const TimeByTimeGraph = ({data}) => {
const {t} = useTranslation();
const [series, setSeries] = useState([]);
useEffect(() => {
const uniquePlayers = {
name: t('html.label.uniquePlayers'),
type: 'spline',
tooltip: tooltip.zeroDecimals,
data: data.uniquePlayers,
color: data.colors.playersOnline
};
const newPlayers = {
name: t('html.label.newPlayers'),
type: 'spline',
tooltip: tooltip.zeroDecimals,
data: data.newPlayers,
color: data.colors.newPlayers
};
setSeries([uniquePlayers, newPlayers]);
}, [data, t])
return (
<LineGraph id="day-by-day-graph" series={series}/>
)
}
export default TimeByTimeGraph

View File

@ -0,0 +1,86 @@
import React, {useEffect} from "react";
import Highcharts from 'highcharts';
import factory from 'highcharts/modules/drilldown';
import {formatTimeAmount} from '../../util/formatters'
import {useTheme} from "../../hooks/themeHook";
import {withReducedSaturation} from "../../util/colors";
import {useMetadata} from "../../hooks/metadataHook";
import {useTranslation} from "react-i18next";
const WorldPie = ({id, worldSeries, gmSeries}) => {
const {t} = useTranslation();
const {gmPieColors} = useMetadata();
useEffect(() => {
factory(Highcharts)
}, []);
const {nightModeEnabled, graphTheming} = useTheme();
useEffect(() => {
const reduceColors = (series) => {
return series.map(slice => {
return {...slice, color: withReducedSaturation(slice.color)};
})
}
const pieSeries = {
name: t('html.label.worldPlaytime'),
colorByPoint: true,
data: nightModeEnabled ? reduceColors(worldSeries) : worldSeries
};
const defaultTitle = '';
const defaultSubtitle = t('html.text.clickToExpand');
Highcharts.setOptions(graphTheming);
setTimeout(() => {
const chart = Highcharts.chart(id, {
chart: {
backgroundColor: 'transparent',
plotBackgroundColor: 'transparent',
plotBorderWidth: null,
plotShadow: false,
type: 'pie',
events: {
drilldown: function (e) {
chart.setTitle({text: '' + e.point.name}, {text: ''});
},
drillup: function () {
chart.setTitle({text: defaultTitle}, {text: defaultSubtitle});
}
}
},
title: {text: defaultTitle},
subtitle: {
text: defaultSubtitle
},
plotOptions: {
pie: {
allowPointSelect: true,
cursor: 'pointer',
dataLabels: {
enabled: false
},
showInLegend: true
}
},
tooltip: {
formatter: function () {
return '<b>' + this.point.name + ':</b> ' + formatTimeAmount(this.y) + ' (' + this.percentage.toFixed(2) + '%)';
}
},
series: [pieSeries],
drilldown: {
series: gmSeries.map(function (d) {
return {name: d.name, id: d.id, colors: gmPieColors, data: d.data}
})
}
});
}, 25)
}, [worldSeries, gmSeries, graphTheming, nightModeEnabled, id, gmPieColors, t]);
return (<div className="chart-pie" id={id}/>)
}
export default WorldPie;

View File

@ -0,0 +1,7 @@
import React from "react";
const End = ({children}) => (
<span className="float-end">{children}</span>
)
export default End;

View File

@ -0,0 +1,52 @@
import React from "react";
import {useTheme} from "../../hooks/themeHook";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCloudMoon, faPalette} from "@fortawesome/free-solid-svg-icons";
import {colorEnumToBgClass} from "../../util/colors";
import {Modal} from "react-bootstrap-v5";
import {useTranslation} from "react-i18next";
const ColorSelectorButton = ({color, setColor, disabled}) =>
<button className={"btn color-chooser " + colorEnumToBgClass(color)}
id={"choose-" + color}
disabled={disabled}
onClick={() => setColor(color)}
>
<Fa icon={faPalette}/>
</button>
const ColorSelectorModal = () => {
const {t} = useTranslation();
const theme = useTheme();
return (
<Modal id="colorChooserModal"
aria-labelledby="colorChooserModalLabel"
show={theme.colorChooserOpen}
onHide={theme.toggleColorChooser}>
<Modal.Header>
<Modal.Title id="colorChooserModalLabel">
<Fa icon={faPalette}/> {t('html.label.themeSelect')}
</Modal.Title>
<button aria-label="Close" className="btn-close" onClick={theme.toggleColorChooser}/>
</Modal.Header>
<Modal.Body style={{padding: "0.5rem 0 0.5rem 0.5rem"}}>
{theme.themeColors.map((color, i) =>
<ColorSelectorButton
key={i}
color={color.name}
setColor={theme.setColor}
disabled={theme.nightModeEnabled}
/>)}
</Modal.Body>
<Modal.Footer>
<button className="btn" id="night-mode-toggle" type="button" onClick={theme.toggleNightMode}>
<Fa icon={faCloudMoon}/> {t('html.button.nightMode')}
</button>
<button className="btn bg-plan" type="button" onClick={theme.toggleColorChooser}>OK</button>
</Modal.Footer>
</Modal>
)
}
export default ColorSelectorModal;

View File

@ -0,0 +1,147 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {Modal} from "react-bootstrap-v5";
import {
faBug,
faChartArea,
faCode,
faGraduationCap,
faLanguage,
faQuestionCircle,
faStar
} from "@fortawesome/free-solid-svg-icons";
import {faDiscord} from "@fortawesome/free-brands-svg-icons";
import {useMetadata} from "../../hooks/metadataHook";
import {useTranslation} from "react-i18next";
const LicenseSection = () => {
const {t} = useTranslation();
return (
<p>{t('html.modal.info.license')}{' '}
<a href="https://opensource.org/licenses/LGPL-3.0"
rel="noopener noreferrer"
target="_blank">
Lesser General Public License v3.0
</a>
</p>
)
}
const Links = () => {
const {t} = useTranslation();
return (<>
<a className="btn col-plan" href="https://github.com/plan-player-analytics/Plan/wiki"
rel="noopener noreferrer" target="_blank">
<Fa icon={faGraduationCap}/> {t('html.modal.info.wiki')}
</a>
<a className="btn col-plan" href="https://github.com/plan-player-analytics/Plan/issues"
rel="noopener noreferrer" target="_blank">
<Fa icon={faBug}/> {t('html.modal.info.bugs')}</a>
<a className="btn col-plan" href="https://discord.gg/yXKmjzT" rel="noopener noreferrer"
target="_blank">
<Fa icon={faDiscord}/> {t('html.modal.info.discord')}
</a>
</>
)
}
const getContributionIcon = (type) => {
switch (type) {
case "LANG":
return 'language';
case "CODE":
return 'code';
default:
return "exclamation-triangle";
}
}
const Contributor = ({contributor}) => {
const icons = contributor.contributed.map(
(type, i) => <Fa key={i} icon={["fa", getContributionIcon(type)]}/>);
return (
<li className="col-4">{contributor.name} {icons} </li>
)
}
const Contributions = () => {
const {t} = useTranslation();
const metadata = useMetadata();
const contributors = metadata.contributors ? metadata.contributors : [{
name: '(Error getting contributors)',
contributed: ['exclamation-triangle']
}];
// TODO Translate
return (<>
<p>Player Analytics {t('html.modal.info.developer')} AuroraLS3.</p>
<p>In addition following <span className="col-plan">awesome people</span> have
contributed:</p>
<ul className="row contributors">
{contributors.map((contributor, i) => <Contributor key={i} contributor={contributor}/>)}
<li>{t('html.modal.info.contributors.bugreporters')}</li>
</ul>
<small>
<Fa icon={faCode}/> {t('html.modal.info.contributors.code')} <Fa
icon={faLanguage}/> {t('html.modal.info.contributors.translator')}
</small>
<hr/>
<p className="col-plan">
{t('html.modal.info.contributors.donate')}
<Fa icon={faStar} className={"col-amber"}/>
</p>
</>)
}
const MetricsLinks = () => {
const {t} = useTranslation();
return (
<>
<h6>{t('html.modal.info.metrics')}</h6>
<a className="btn col-plan" href="https://bstats.org/plugin/bukkit/Plan"
rel="noopener noreferrer" target="_blank">
<Fa icon={faChartArea}/> Bukkit
</a>
<a className="btn col-plan" href="https://bstats.org/plugin/bungeecord/Plan"
rel="noopener noreferrer" target="_blank">
<Fa icon={faChartArea}/> BungeeCord
</a>
<a className="btn col-plan" href="https://bstats.org/plugin/sponge/plan"
rel="noopener noreferrer" target="_blank">
<Fa icon={faChartArea}/> Sponge
</a>
<a className="btn col-plan" href="https://bstats.org/plugin/velocity/Plan/10326"
rel="noopener noreferrer" target="_blank">
<Fa icon={faChartArea}/> Velocity
</a>
</>
);
}
const PluginInformationModal = ({open, toggle}) => {
const {t} = useTranslation();
return (
<Modal id="informationModal" aria-labelledby="informationModalLabel" show={open} onHide={toggle} size="lg">
<Modal.Header>
<Modal.Title id="informationModalLabel">
<Fa icon={faQuestionCircle}/> {t('html.modal.info.text')}
</Modal.Title>
<button aria-label="Close" className="btn-close" type="button" onClick={toggle}/>
</Modal.Header>
<Modal.Body>
<LicenseSection/>
<hr/>
<Links/>
<hr/>
<Contributions/>
<hr/>
<MetricsLinks/>
</Modal.Body>
<Modal.Footer>
<button className="btn bg-plan" onClick={toggle}>OK</button>
</Modal.Footer>
</Modal>
)
}
export default PluginInformationModal;

View File

@ -0,0 +1,62 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {Modal} from "react-bootstrap-v5";
import {faCheckCircle, faDownload} from "@fortawesome/free-solid-svg-icons";
import {useTranslation} from "react-i18next";
// TODO translate
const UpdateAvailableModal = ({open, toggle, versionInfo}) => {
const {t} = useTranslation();
return (
<Modal id="versionModal" aria-labelledby="versionModalLabel" show={open} onHide={toggle}>
<Modal.Header>
<Modal.Title id="versionModalLabel">
<Fa icon={faDownload}/> {t('html.modal.version.title')} {versionInfo.newVersion} {t('html.modal.version.available')}
</Modal.Title>
<button aria-label="Close" className="btn-close" type="button" onClick={toggle}/>
</Modal.Header>
<Modal.Body>
<p>You have version {versionInfo.currentVersion}.</p>
<p>New
release: {versionInfo.newVersion}{versionInfo.isRelease ? '' : " (" + t('html.modal.version.dev') + ")"}</p>
<a className="btn col-plan" href={versionInfo.changelogUrl} rel="noopener noreferrer" target="_blank">
{t('html.modal.version.changelog')}
</a>
<a className="btn col-plan" href={versionInfo.downloadUrl} rel="noopener noreferrer" target="_blank">
{t('html.modal.version.download')} Plan-{versionInfo.newVersion}.jar
</a>
</Modal.Body>
<Modal.Footer>
<button className="btn bg-plan" onClick={toggle}>OK</button>
</Modal.Footer>
</Modal>
)
}
const NewestVersionModal = ({open, toggle, versionInfo}) => {
return (
<Modal id="versionModal" aria-labelledby="versionModalLabel" show={open} onHide={toggle}>
<Modal.Header>
<Modal.Title id="versionModalLabel">
<Fa icon={faCheckCircle}/> You have version {versionInfo.currentVersion}.
</Modal.Title>
<button aria-label="Close" className="btn-close" type="button" onClick={toggle}/>
</Modal.Header>
<Modal.Body>
You're using the latest version {versionInfo.currentVersion}. (No updates available)
</Modal.Body>
<Modal.Footer>
<button className="btn bg-plan" onClick={toggle}>OK</button>
</Modal.Footer>
</Modal>
);
}
const VersionInformationModal = ({open, toggle, versionInfo}) => {
return versionInfo.updateAvailable
? <UpdateAvailableModal open={open} toggle={toggle} versionInfo={versionInfo}/>
: <NewestVersionModal open={open} toggle={toggle} versionInfo={versionInfo}/>
}
export default VersionInformationModal;

View File

@ -0,0 +1,96 @@
import {useMetadata} from "../../hooks/metadataHook";
import {useAuth} from "../../hooks/authenticationHook";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faBars, faCog, faDoorOpen, faPalette, faSyncAlt} from "@fortawesome/free-solid-svg-icons";
import DropdownMenu from "react-bootstrap-v5/lib/esm/DropdownMenu";
import DropdownItem from "react-bootstrap-v5/lib/esm/DropdownItem";
import {useTheme} from "../../hooks/themeHook";
import {Dropdown} from "react-bootstrap-v5";
import DropdownToggle from "react-bootstrap-v5/lib/esm/DropdownToggle";
import {localeService} from "../../service/localeService";
import {useTranslation} from "react-i18next";
import {useNavigation} from "../../hooks/navigationHook";
const LanguageSelector = () => {
const languages = localeService.getLanguages();
const onSelect = ({target}) => {
localeService.loadLocale(target.value)
}
return (
<select onChange={onSelect}
aria-label="Language selector"
className="form-select form-select-sm"
id="langSelector"
defaultValue={localeService.clientLocale}>
{languages.map((lang, i) =>
<option key={i} value={lang.name}>{lang.displayName}</option>)}
</select>
)
}
const Header = ({page, tab}) => {
const {authRequired, user} = useAuth();
const {toggleColorChooser} = useTheme();
const {t} = useTranslation();
const {requestUpdate, updating, lastUpdate, toggleSidebar} = useNavigation();
const {getPlayerHeadImageUrl} = useMetadata();
const headImageUrl = user ? getPlayerHeadImageUrl(user.playerName, user.linkedToUuid) : undefined
// <!-- <li><a className="dropdown-item" href="#"><i className="fas fa-users-cog"></i> Web users</a></li>-->
// <!-- <li><a className="dropdown-item" href="#"><i className="fas fa-code"></i> API access</a></li>-->
// <!-- <li>-->
// <!-- <hr className="dropdown-divider">-->
// <!-- </li>-->
return (
<nav className="nav mt-3 align-items-center justify-content-between container-fluid">
<div className="d-sm-flex">
<h1 className="h3 mb-0 text-gray-800">
<button onClick={toggleSidebar}>
<Fa icon={faBars} className={"sidebar-toggler"}/>
</button>
{page}
{tab ? <>{' '}&middot; {t(tab)}</> : ''}</h1>
</div>
<span className="topbar-divider"/>
<div className="refresh-element">
<button onClick={requestUpdate}>
<Fa icon={faSyncAlt} spin={updating}/>
</button>
{' '}
<span className="refresh-time">{lastUpdate.formatted}</span>
</div>
<div className="ms-auto">
<LanguageSelector/>
</div>
<div className="topbar-divider"/>
<Dropdown className="nav-item">
<DropdownToggle variant=''>
{authRequired && user ? <>
<span className="me-1 login-username">{user.username} </span>
<img alt="user img" className="rounded-circle" src={headImageUrl} style={{height: "2rem"}}/>
</> : <>
<Fa icon={faCog} className="me-2"/>
</>}
</DropdownToggle>
<DropdownMenu>
<DropdownItem onClick={toggleColorChooser}>
<Fa icon={faPalette}/> {t('html.label.themeSelect')}
</DropdownItem>
{authRequired ? <DropdownItem href="./auth/logout">
<Fa icon={faDoorOpen}/> {t('html.login.logout')}
</DropdownItem> : ''}
</DropdownMenu>
</Dropdown>
</nav>
)
}
export default Header;

View File

@ -0,0 +1,29 @@
import React from "react";
import {Card} from "react-bootstrap-v5";
export const CardLoader = () => {
return (
<Card className="loading">
<Card.Header>
<h6 className="col-black">
...
</h6>
</Card.Header>
<ChartLoader/>
</Card>
)
}
export const ChartLoader = () => {
return <div className="chart-area loading">
<Loader/>
</div>
}
const Loader = () => {
return (
<span className="loader"/>
)
}
export default Loader;

View File

@ -0,0 +1,76 @@
import {useAuth} from "../../hooks/authenticationHook";
import {useMetadata} from "../../hooks/metadataHook";
import {Navigate} from "react-router-dom";
import React, {useEffect, useState} from "react";
const RedirectPlaceholder = () => {
const [redirectStart] = useState(Date.now())
const [dateDiff, setDateDiff] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
if (dateDiff <= 50) {
setDateDiff(Date.now() - redirectStart);
} else {
clearInterval(interval);
}
}, 500);
return () => clearInterval(interval);
}, [redirectStart, dateDiff])
if (dateDiff > 50) {
return <>
<p className="m-4">Redirecting..</p>
<div style={{maxWidth: "500px"}}>
<p className="m-4">
This is taking longer than expected.
</p>
<p className="m-4">
Make sure the Plan webserver is enabled.<br/>(This page can show up if the Plan webserver goes
offline.)
</p>
<p className="m-4">
If you are trying to set up a development environment,
change package.json "proxy" to your Plan webserver address.
</p>
<p className="m-4">
<button className="btn bg-plan" onClick={() => window.location.reload()}>Click to Refresh the
page & try again.
</button>
</p>
</div>
</>
} else {
return <p className="m-4">Redirecting..</p>
}
}
const MainPageRedirect = () => {
const {authLoaded, authRequired, loggedIn, user} = useAuth();
const {isProxy, serverName} = useMetadata();
console.log(authLoaded, authRequired, loggedIn, user)
if (!authLoaded || !serverName) {
return <RedirectPlaceholder/>
}
if (authRequired && !loggedIn) {
return (<Navigate to={"login"} replace={true}/>)
} else if (authRequired && loggedIn) {
if (isProxy && user.permissions.includes('page.network')) {
return (<Navigate to={"network/overview"} replace={true}/>)
} else if (user.permissions.includes('page.server')) {
return (<Navigate to={"server/overview"} replace={true}/>)
} else if (user.permissions.includes('page.player.other')) {
return (<Navigate to={"players"} replace={true}/>)
} else if (user.permissions.includes('page.player.self')) {
return (<Navigate to={"player/" + user.linkedToUuid} replace={true}/>)
}
} else {
return (<Navigate to={isProxy ? "network/overview" : "server/" + encodeURIComponent(serverName) + "/overview"}
replace={true}/>)
}
}
export default MainPageRedirect

View File

@ -0,0 +1,240 @@
import React, {useCallback, useEffect, useState} from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import logo from '../../Flaticon_circle.png';
import {faArrowLeft, faDoorOpen, faDownload, faPalette, faQuestionCircle} from "@fortawesome/free-solid-svg-icons";
import {NavLink, useLocation} from "react-router-dom";
import {useTheme} from "../../hooks/themeHook";
import PluginInformationModal from "../modal/PluginInformationModal";
import VersionInformationModal from "../modal/VersionInformationModal";
import {fetchPlanVersion} from "../../service/metadataService";
import {useAuth} from "../../hooks/authenticationHook";
import {useNavigation} from "../../hooks/navigationHook";
import {useTranslation} from "react-i18next";
import {Collapse} from "react-bootstrap-v5";
import {baseAddress} from "../../service/backendConfiguration";
const Logo = () => (
<a className="sidebar-brand d-flex align-items-center justify-content-center" href="/">
<img alt="logo" className="w-22" src={logo}/>
</a>
)
const Divider = ({showMargin}) => (
<hr className={"sidebar-divider" + (showMargin ? '' : " my-0")}/>
)
const InnerItem = ({href, icon, name, nameShort}) => {
if (href.startsWith('/')) {
return (
<a href={href} className="collapse-item nav-button">
<Fa icon={icon}/> <span>{nameShort ? nameShort : name}</span>
</a>
)
}
return <NavLink to={href} className={({isActive}) => {
return isActive ? "collapse-item nav-button active" : "collapse-item nav-button"
}}>
<Fa icon={icon}/> <span>{nameShort ? nameShort : name}</span>
</NavLink>
}
const Item = ({href, icon, name, nameShort, inner}) => {
const {setCurrentTab} = useNavigation();
const {pathname} = useLocation();
const {t} = useTranslation();
useEffect(() => {
if ('/' !== href && pathname.includes(href)) setCurrentTab(name);
}, [pathname, href, setCurrentTab, name])
if (inner) {
return (<InnerItem href={href} icon={icon} name={t(name)} nameShort={t(nameShort)}/>)
}
if (href.startsWith('/')) {
return (
<li className={"nav-item nav-button"}>
<a href={baseAddress + href} className="nav-link">
<Fa icon={icon}/> <span>{t(nameShort ? nameShort : name)}</span>
</a>
</li>
)
}
return (
<li className={"nav-item nav-button"}>
<NavLink to={href} className={({isActive}) => {
return isActive ? "nav-link active" : "nav-link"
}}>
<Fa icon={icon}/> <span>{t(name)}</span>
</NavLink>
</li>
);
}
const VersionButton = ({toggleVersionModal, versionInfo}) => {
if (versionInfo.updateAvailable) {
return <button className="btn bg-white col-plan" onClick={toggleVersionModal}>
<Fa icon={faDownload}/> Update Available!
</button>;
}
return <button className="btn bg-transparent-light" onClick={toggleVersionModal}>
{versionInfo.currentVersion}
</button>;
}
const FooterButtons = () => {
const {t} = useTranslation();
const {toggleColorChooser} = useTheme();
const {authRequired} = useAuth();
const [infoModalOpen, setInfoModalOpen] = useState(false);
const toggleInfoModal = () => setInfoModalOpen(!infoModalOpen);
const [versionModalOpen, setVersionModalOpen] = useState(false);
const toggleVersionModal = () => setVersionModalOpen(!versionModalOpen);
const [versionInfo, setVersionInfo] = useState({currentVersion: 'Loading..', updateAvailable: false});
const loadVersion = async () => {
const {data, error} = await fetchPlanVersion();
if (data) {
setVersionInfo(data);
} else if (error) {
setVersionInfo({currentVersion: "Error getting version", updateAvailable: false})
}
}
useEffect(() => {
loadVersion();
}, [])
return (
<>
<div className="mt-2 ms-md-3 text-center text-md-start">
<button className="btn bg-transparent-light" onClick={toggleColorChooser}
title={t('html.label.themeSelect')}>
<Fa icon={faPalette}/>
</button>
<button className="btn bg-transparent-light" onClick={toggleInfoModal}
title={t('html.modal.info.text')}>
<Fa icon={faQuestionCircle}/>
</button>
{authRequired ?
<a className="btn bg-transparent-light" href={baseAddress + "/auth/logout"} id="logout-button">
<Fa icon={faDoorOpen}/> Logout
</a> : ''}
</div>
<div className="ms-md-3 text-center text-md-start">
<VersionButton toggleVersionModal={toggleVersionModal} versionInfo={versionInfo}/>
</div>
<PluginInformationModal open={infoModalOpen} toggle={toggleInfoModal}/>
<VersionInformationModal open={versionModalOpen} toggle={toggleVersionModal} versionInfo={versionInfo}/>
</>
)
}
const SidebarCollapse = ({item, open, setOpen}) => {
const {t} = useTranslation();
const toggle = event => {
event.preventDefault();
setOpen(!open);
}
return (
<li className="nav-item">
<button className="nav-link"
onClick={toggle}
aria-controls={item.name + "-collapse"}
aria-expanded={open}
data-bs-toggle="collapse"
>
<Fa icon={item.icon}/> <span>{t(item.name)}</span>
</button>
<Collapse in={open}>
<div id={item.name + "-collapse"}>
<div className="bg-white py-2 collapse-inner rounded">
{item.contents.map((content, i) =>
<Item key={i}
inner
active={false}
href={content.href}
icon={content.icon}
name={content.name}
nameShort={content.nameShort}
/>)}
</div>
</div>
</Collapse>
</li>
)
}
const renderItem = (item, i, openCollapse, setOpenCollapse, t) => {
if (item.contents) {
return <SidebarCollapse key={i}
item={item}
open={openCollapse && openCollapse === i}
setOpen={() => setOpenCollapse(i)}/>
}
if (item.href !== undefined) {
return <Item key={i}
active={false}
href={item.href}
icon={item.icon}
name={item.name}
nameShort={item.nameShort}
/>
}
if (item.name) {
return <div key={i} className="sidebar-heading">{t(item.name)}</div>
}
return <hr key={i} className="sidebar-divider"/>
}
const Sidebar = ({items, showBackButton}) => {
const {t} = useTranslation();
const {color} = useTheme();
const {currentTab, sidebarExpanded, setSidebarExpanded} = useNavigation();
const [openCollapse, setOpenCollapse] = useState(undefined);
const toggleCollapse = collapse => {
setOpenCollapse(openCollapse === collapse ? undefined : collapse);
}
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const updateWidth = useCallback(() => setWindowWidth(window.innerWidth), []);
useEffect(() => {
window.addEventListener('resize', updateWidth);
return () => window.removeEventListener('resize', updateWidth);
}, [updateWidth]);
const collapseSidebar = () => setSidebarExpanded(windowWidth > 1350);
useEffect(collapseSidebar, [windowWidth, currentTab, setSidebarExpanded]);
if (!items.length) return <></>
return (
<>
{sidebarExpanded &&
<ul className={"navbar-nav sidebar sidebar-dark accordion bg-" + color} id="accordionSidebar">
<Logo/>
<Divider/>
{showBackButton && <>
<Item active={false} href="/" icon={faArrowLeft} name={t('html.label.toMainPage')}/>
<Divider showMargin={!items[0].contents && items[0].href === undefined}/>
</>}
{items.map((item, i) => renderItem(item, i, openCollapse, toggleCollapse, t))}
<Divider/>
<FooterButtons/>
</ul>}
</>
)
}
export default Sidebar

View File

@ -0,0 +1,20 @@
import React from "react";
import {useTheme} from "../../hooks/themeHook";
const AsNumbersTable = ({headers, children}) => {
const {nightModeEnabled} = useTheme();
return <table className={"table table-striped" + (nightModeEnabled ? " table-dark" : '')}>
<thead>
<tr>
<th/>
{headers.map((header, i) => <th key={i}>{header}</th>)}
</tr>
</thead>
<tbody>
{children}
</tbody>
</table>
}
export default AsNumbersTable

View File

@ -0,0 +1,23 @@
import React from "react";
import {useTheme} from "../../hooks/themeHook";
import ComparingLabel from "../trend/ComparingLabel";
const ComparisonTable = ({headers, children, comparisonHeader}) => {
const {nightModeEnabled} = useTheme();
return <table className={"table table-striped" + (nightModeEnabled ? " table-dark" : '')}>
<thead>
<tr>
<th>
<ComparingLabel>{comparisonHeader}</ComparingLabel>
</th>
{headers.map((header, i) => <th key={i}>{header}</th>)}
</tr>
</thead>
<tbody>
{children}
</tbody>
</table>
}
export default ComparisonTable

View File

@ -0,0 +1,31 @@
import React, {useEffect, useRef} from 'react';
import DataTable from 'datatables.net'
import 'datatables.net-bs5'
import 'datatables.net-responsive-bs5'
import 'datatables.net-bs5/css/dataTables.bootstrap5.min.css';
import 'datatables.net-responsive-bs5/css/responsive.bootstrap5.min.css';
const DataTablesTable = ({id, options}) => {
const dataTableRef = useRef(null);
useEffect(() => {
const idSelector = `#${id}`;
if (dataTableRef.current && DataTable.isDataTable(idSelector)) {
dataTableRef.current.destroy();
}
dataTableRef.current = new DataTable(idSelector, options);
return () => {
if (dataTableRef.current) {
dataTableRef.current.destroy();
}
};
}, [id, options, dataTableRef]);
return (
<table id={id} className="table table-bordered table-striped" style={{width: "100%"}}/>
)
};
export default DataTablesTable

View File

@ -0,0 +1,39 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faAngleRight, faSkullCrossbones} from "@fortawesome/free-solid-svg-icons";
import {useTheme} from "../../hooks/themeHook";
import {useTranslation} from "react-i18next";
const KillRow = ({kill}) => {
const killSeparator = <Fa
icon={kill.killerUUID === kill.victimUUID ? faSkullCrossbones : faAngleRight}
className={"col-red"}/>;
return (
<tr>
<td>{kill.date}</td>
<td>{kill.killerName} {killSeparator} {kill.victimName}</td>
<td>{kill.weapon}</td>
<td>{kill.serverName}</td>
</tr>
);
}
const KillsTable = ({kills}) => {
const {t} = useTranslation();
const {nightModeEnabled} = useTheme();
return (
<table className={"table mb-0" + (nightModeEnabled ? " table-dark" : '')}>
<tbody>
{kills.length ? kills.map((kill, i) => <KillRow key={i} kill={kill}/>) : <tr>
<td>{t('html.generic.none')}</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>}
</tbody>
</table>
)
};
export default KillsTable;

View File

@ -0,0 +1,86 @@
import {useTranslation} from "react-i18next";
import {faUser, faUserCircle, faUserPlus, faUsers} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {TableRow} from "./TableRow";
import ComparisonTable from "./ComparisonTable";
import SmallTrend from "../trend/SmallTrend";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCalendarCheck, faClock, faEye} from "@fortawesome/free-regular-svg-icons";
const OnlineActivityAsNumbersTable = ({data}) => {
const {t} = useTranslation();
if (!data) return <></>;
return (
<ComparisonTable
headers={[t('html.label.last30days'), t('html.label.last7days'), t('html.label.last24hours')]}
comparisonHeader={t('html.text.comparing15days')}
>
<TableRow icon={faUsers} color="light-blue" text={t('html.label.uniquePlayers')}
values={[
<>{data.unique_players_30d}{' '}<SmallTrend trend={data.unique_players_30d_trend}/></>,
data.unique_players_7d,
data.unique_players_24h
]}/>
<TableRow icon={faUser} color="light-blue"
text={t('html.label.uniquePlayers') + ' ' + t('html.label.perDay')}
values={[
<>{data.unique_players_30d_avg}{' '}<SmallTrend
trend={data.unique_players_30d_avg_trend}/></>,
data.unique_players_7d_avg,
data.unique_players_24h_avg
]}/>
<TableRow icon={faUsers} color="light-green" text={t('html.label.newPlayers')}
values={[
<>{data.new_players_30d}{' '}<SmallTrend trend={data.new_players_30d_trend}/></>,
data.new_players_7d,
data.new_players_24h
]}/>
<TableRow icon={faUserPlus} color="light-green"
text={t('html.label.newPlayers') + ' ' + t('html.label.perDay')}
values={[
<>{data.new_players_30d_avg}{' '}<SmallTrend trend={data.new_players_30d_avg_trend}/></>,
data.new_players_7d_avg,
data.new_players_24h_avg
]}/>
<TableRow icon={faUserCircle} color="light-green" text={t('html.label.newPlayerRetention')}
values={[
`(${data.new_players_retention_30d}/${data.new_players_30d}) ${data.new_players_retention_30d_perc}`,
`(${data.new_players_retention_7d}/${data.new_players_7d}) ${data.new_players_retention_7d_perc}`,
<>{`(${data.new_players_retention_24h}/${data.new_players_24h}) ${data.new_players_retention_24h_perc}`}<Fa
icon={faEye} title={t('html.description.newPlayerRetention')}/></>
]}/>
<TableRow icon={faClock} color="green"
text={t('html.label.playtime')}
values={[
<>{data.playtime_30d}{' '}<SmallTrend trend={data.playtime_30d_trend}/></>,
data.playtime_7d,
data.playtime_24h
]}/>
<TableRow icon={faClock} color="green"
text={t('html.label.averagePlaytime') + ' ' + t('html.label.perDay')}
values={[
<>{data.playtime_30d_avg}{' '}<SmallTrend trend={data.playtime_30d_avg_trend}/></>,
data.playtime_7d_avg,
data.playtime_24h_avg
]}/>
<TableRow icon={faClock} color="teal"
text={t('html.label.averageSessionLength')}
values={[
<>{data.session_length_30d_avg}{' '}<SmallTrend
trend={data.session_length_30d_avg_trend}/></>,
data.session_length_7d_avg,
data.session_length_24h_avg
]}/>
<TableRow icon={faCalendarCheck} color="teal"
text={t('html.label.sessions')}
values={[
<>{data.sessions_30d}{' '}<SmallTrend trend={data.sessions_30d_trend}/></>,
data.sessions_7d,
data.sessions_24h
]}/>
</ComparisonTable>
)
}
export default OnlineActivityAsNumbersTable;

View File

@ -0,0 +1,45 @@
import {useTranslation} from "react-i18next";
import AsNumbersTable from "./AsNumbersTable";
import {faCrosshairs, faSkull} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {TableRow} from "./TableRow";
const PlayerPvpPveAsNumbersTable = ({killData}) => {
const {t} = useTranslation();
return (
<AsNumbersTable
headers={[t('html.label.allTime'), t('html.label.last30days'), t('html.label.last7days')]}
>
<TableRow icon={faCrosshairs} color="red" text={t('html.label.kdr')} bold
values={[killData.player_kdr_total,
killData.player_kdr_30d,
killData.player_kdr_7d]}/>
<TableRow icon={faCrosshairs} color="red" text={t('html.label.playerKills')}
values={[killData.player_kills_total,
killData.player_kills_30d,
killData.player_kills_7d]}/>
<TableRow icon={faSkull} color="red" text={t('html.label.playerDeaths')}
values={[killData.player_deaths_total,
killData.player_deaths_30d,
killData.player_deaths_7d]}/>
<TableRow icon={faCrosshairs} color="green" text={t('html.label.mobKdr')} bold
values={[killData.mob_kdr_total,
killData.mob_kdr_30d,
killData.mob_kdr_7d]}/>
<TableRow icon={faCrosshairs} color="green" text={t('html.label.mobKills')}
values={[killData.mob_kills_total,
killData.mob_kills_30d,
killData.mob_kills_7d]}/>
<TableRow icon={faSkull} color="green" text={t('html.label.mobDeaths')}
values={[killData.mob_deaths_total,
killData.mob_deaths_30d,
killData.mob_deaths_7d]}/>
<TableRow icon={faSkull} color="black" text={t('html.label.deaths')}
values={[killData.deaths_total,
killData.deaths_30d,
killData.deaths_7d]}/>
</AsNumbersTable>
)
}
export default PlayerPvpPveAsNumbersTable;

View File

@ -0,0 +1,41 @@
import {useTranslation} from "react-i18next";
import AsNumbersTable from "./AsNumbersTable";
import {faCrosshairs, faSkull} from "@fortawesome/free-solid-svg-icons";
import React from "react";
import {TableRow} from "./TableRow";
const ServerPvpPveAsNumbersTable = ({killData}) => {
const {t} = useTranslation();
return (
<AsNumbersTable
headers={[t('html.label.allTime'), t('html.label.last30days'), t('html.label.last7days')]}
>
<TableRow icon={faCrosshairs} color="red" text={t('html.label.averageKdr')}
values={[killData.player_kdr_avg,
killData.player_kdr_avg_30d,
killData.player_kdr_avg_7d]}/>
<TableRow icon={faCrosshairs} color="red" text={t('html.label.playerKills')}
values={[killData.player_kills_total,
killData.player_kills_30d,
killData.player_kills_7d]}/>
<TableRow icon={faCrosshairs} color="green" text={t('html.label.averageMobKdr')}
values={[killData.mob_kdr_total,
killData.mob_kdr_30d,
killData.mob_kdr_7d]}/>
<TableRow icon={faCrosshairs} color="green" text={t('html.label.mobKills')}
values={[killData.mob_kills_total,
killData.mob_kills_30d,
killData.mob_kills_7d]}/>
<TableRow icon={faSkull} color="green" text={t('html.label.mobDeaths')}
values={[killData.mob_deaths_total,
killData.mob_deaths_30d,
killData.mob_deaths_7d]}/>
<TableRow icon={faSkull} color="black" text={t('html.label.deaths')}
values={[killData.deaths_total,
killData.deaths_30d,
killData.deaths_7d]}/>
</AsNumbersTable>
)
}
export default ServerPvpPveAsNumbersTable;

View File

@ -0,0 +1,12 @@
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import React from "react";
export const TableRow = ({icon, text, color, values, bold}) => {
const label = (<><Fa icon={icon} className={'col-' + color}/> {text}</>);
return (
<tr>
<td>{bold ? <b>{label}</b> : label}</td>
{values.map((value, j) => <td key={j}>{value}</td>)}
</tr>
)
}

View File

@ -0,0 +1,26 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCaretDown, faCaretRight, faCaretUp} from "@fortawesome/free-solid-svg-icons";
const TrendUpGood = ({value}) => <span className="badge bg-success"><Fa icon={faCaretUp}/>{value}</span>;
const TrendUpBad = ({value}) => <span className="badge bg-danger"><Fa icon={faCaretUp}/>{value}</span>;
const TrendDownBad = ({value}) => <span className="badge bg-danger"><Fa icon={faCaretDown}/>{value}</span>;
const TrendDownGood = ({value}) => <span className="badge bg-success"><Fa icon={faCaretDown}/>{value}</span>;
const TrendSame = ({value}) => <span className="badge bg-warning"><Fa icon={faCaretRight}/>{value}</span>;
const BigTrend = ({trend}) => {
if (!trend) {
return <TrendSame value={'?'}/>;
}
switch (trend.direction) {
case '+':
return (trend.reversed ? <TrendUpBad value={trend.text}/> : <TrendUpGood value={trend.text}/>);
case '-':
return (trend.reversed ? <TrendDownGood value={trend.text}/> : <TrendDownBad value={trend.text}/>);
default:
return <TrendSame value={trend.text}/>;
}
}
export default BigTrend

View File

@ -0,0 +1,13 @@
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCaretDown, faCaretUp} from "@fortawesome/free-solid-svg-icons";
import React from "react";
const ComparingLabel = ({children}) => {
return (<>
<Fa icon={faCaretUp} className="comparing text-success"/>
<Fa icon={faCaretDown} className="comparing text-danger"/>
{' '}<small>{children}</small>
</>);
}
export default ComparingLabel;

View File

@ -0,0 +1,26 @@
import React from "react";
import {FontAwesomeIcon as Fa} from "@fortawesome/react-fontawesome";
import {faCaretDown, faCaretRight, faCaretUp} from "@fortawesome/free-solid-svg-icons";
const TrendUpGood = ({value}) => <Fa icon={faCaretUp} className="trend text-success" title={value}/>;
const TrendUpBad = ({value}) => <Fa icon={faCaretUp} className="trend text-danger" title={value}/>;
const TrendDownBad = ({value}) => <Fa icon={faCaretDown} className="trend text-danger" title={value}/>;
const TrendDownGood = ({value}) => <Fa icon={faCaretDown} className="trend text-success" title={value}/>;
const TrendSame = ({value}) => <Fa icon={faCaretRight} className="trend text-warning" title={value}/>;
const SmallTrend = ({trend}) => {
if (!trend) {
return <TrendSame value={'?'}/>;
}
switch (trend.direction) {
case '+':
return (trend.reversed ? <TrendUpBad value={trend.text}/> : <TrendUpGood value={trend.text}/>);
case '-':
return (trend.reversed ? <TrendDownGood value={trend.text}/> : <TrendDownBad value={trend.text}/>);
default:
return <TrendSame value={trend.text}/>;
}
}
export default SmallTrend

View File

@ -0,0 +1,66 @@
import {createContext, useCallback, useContext, useEffect, useState} from "react";
import {fetchWhoAmI} from "../service/authenticationService";
const AuthenticationContext = createContext({});
export const AuthenticationContextProvider = ({children}) => {
const [loginError, setLoginError] = useState(undefined);
const [authLoaded, setAuthLoaded] = useState(false)
const [authRequired, setAuthRequired] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [user, setUser] = useState(undefined);
const updateLoginDetails = useCallback(async () => {
const {data: whoAmI, error} = await fetchWhoAmI();
if (whoAmI) {
setAuthRequired(whoAmI.authRequired);
if (whoAmI.loggedIn) setUser(whoAmI.user);
setLoggedIn(whoAmI.loggedIn);
setAuthLoaded(true)
} else if (error) {
setLoginError(error);
}
}, [])
const login = useCallback(async (username, password) => {
// TODO implement later when login page is done with React
await updateLoginDetails();
}, [updateLoginDetails]);
const logout = useCallback(() => {
// TODO implement later when login page is done with React
}, []);
const hasPermission = useCallback(permission => {
return !authRequired || (loggedIn && user && user.permissions.filter(perm => perm === permission).length);
}, [authRequired, loggedIn, user]);
const hasPermissionOtherThan = useCallback(permission => {
return !authRequired || (loggedIn && user && user.permissions.filter(perm => perm !== permission).length);
}, [authRequired, loggedIn, user]);
useEffect(() => {
updateLoginDetails();
}, [updateLoginDetails]);
const sharedState = {
authLoaded,
authRequired,
loggedIn,
user,
login,
logout,
loginError,
hasPermission,
hasPermissionOtherThan
}
return (<AuthenticationContext.Provider value={sharedState}>
{children}
</AuthenticationContext.Provider>
)
}
export const useAuth = () => {
return useContext(AuthenticationContext);
}

View File

@ -0,0 +1,23 @@
import {useEffect, useState} from "react";
import {useNavigation} from "./navigationHook";
export const useDataRequest = (fetchMethod, parameters) => {
const [data, setData] = useState(undefined);
const [loadingError, setLoadingError] = useState(undefined);
const {updateRequested, finishUpdate} = useNavigation();
/*eslint-disable react-hooks/exhaustive-deps */
useEffect(() => {
fetchMethod(...parameters, updateRequested).then(({data: json, error}) => {
if (json) {
setData(json);
finishUpdate(json.timestamp, json.timestamp_f);
} else if (error) {
setLoadingError(error);
}
});
}, [fetchMethod, ...parameters, updateRequested])
/* eslint-enable react-hooks/exhaustive-deps */
return {data, loadingError};
}

View File

@ -0,0 +1,46 @@
import {createContext, useCallback, useContext, useEffect, useState} from "react";
import {fetchPlanMetadata} from "../service/metadataService";
import terminal from '../Terminal-icon.png'
const MetadataContext = createContext({});
export const MetadataContextProvider = ({children}) => {
const [metadata, setMetadata] = useState({});
const updateMetadata = useCallback(async () => {
const {data, error} = await fetchPlanMetadata();
if (data) {
setMetadata(data);
} else if (error) {
setMetadata({metadataError: error})
}
}, [])
const getPlayerHeadImageUrl = useCallback((name, uuid) => {
if (!uuid && name === 'console') {
return terminal;
}
/* eslint-disable no-template-curly-in-string */
return (metadata.playerHeadImageUrl ? metadata.playerHeadImageUrl : "https://cravatar.eu/helmavatar/${playerUUID}/120.png")
.replace('${playerUUID}', uuid)
.replace('${playerUUIDNoDash}', uuid ? uuid.split('-').join('') : undefined)
.replace('${playerName}', name)
/* eslint-enable no-template-curly-in-string */
}, [metadata.playerHeadImageUrl]);
useEffect(() => {
updateMetadata();
}, [updateMetadata]);
const sharedState = {...metadata, getPlayerHeadImageUrl}
return (<MetadataContext.Provider value={sharedState}>
{children}
</MetadataContext.Provider>
)
}
export const useMetadata = () => {
return useContext(MetadataContext);
}

View File

@ -0,0 +1,43 @@
import {createContext, useCallback, useContext, useState} from "react";
const NavigationContext = createContext({});
export const NavigationContextProvider = ({children}) => {
const [currentTab, setCurrentTab] = useState(undefined);
const [updateRequested, setUpdateRequested] = useState(Date.now());
const [updating, setUpdating] = useState(false);
const [lastUpdate, setLastUpdate] = useState({date: 0, formatted: ""});
const [sidebarExpanded, setSidebarExpanded] = useState(window.innerWidth > 1350);
const requestUpdate = useCallback(() => {
if (!updating) {
setUpdateRequested(Date.now());
setUpdating(true);
}
}, [updating, setUpdateRequested, setUpdating]);
const finishUpdate = useCallback((date, formatted) => {
// TODO Logic to retry if received data is too old
setLastUpdate({date, formatted});
setUpdating(false);
}, [setLastUpdate, setUpdating]);
const toggleSidebar = useCallback(() => {
setSidebarExpanded(!sidebarExpanded);
}, [setSidebarExpanded, sidebarExpanded])
const sharedState = {
currentTab, setCurrentTab,
lastUpdate, updateRequested, updating, requestUpdate, finishUpdate,
sidebarExpanded, setSidebarExpanded, toggleSidebar
}
return (<NavigationContext.Provider value={sharedState}>
{children}
</NavigationContext.Provider>
)
}
export const useNavigation = () => {
return useContext(NavigationContext);
}

View File

@ -0,0 +1,120 @@
import {createContext, useContext, useState} from "react";
import {createNightModeCss, getColors} from "../util/colors";
import {getLightModeChartTheming, getNightModeChartTheming} from "../util/graphColors";
import {useMetadata} from "./metadataHook";
const themeColors = getColors();
themeColors.splice(themeColors.length - 4, 4);
const getDefaultTheme = (metadata) => {
const defaultTheme = metadata.defaultTheme;
// Use 'plan' if default or if default is undefined.
// Avoid night mode staying on if default theme is night mode
const invalidColor = !defaultTheme
|| defaultTheme === 'default'
|| defaultTheme === 'black'
|| defaultTheme === 'white'
|| !themeColors.map(color => color.name).includes(defaultTheme)
return invalidColor ? 'plan' : defaultTheme;
}
const getStoredTheme = (defaultTheme) => {
const stored = window.localStorage.getItem('themeColor');
return stored && stored !== 'undefined' ? stored : defaultTheme;
}
const setStoredTheme = themeColor => {
if (themeColor) {
window.localStorage.setItem('themeColor', themeColor);
}
}
const ThemeContext = createContext({});
export const ThemeContextProvider = ({children}) => {
const metadata = useMetadata();
const [colorChooserOpen, setColorChooserOpen] = useState(false);
const [selectedColor, setSelectedColor] = useState(getStoredTheme(getDefaultTheme(metadata)));
const [previousColor, setPreviousColor] = useState(undefined);
const sharedState = {
selectedColor,
setSelectedColor,
previousColor,
setPreviousColor,
colorChooserOpen,
setColorChooserOpen
}
return (<ThemeContext.Provider value={sharedState}>
{children}
</ThemeContext.Provider>
)
}
const lightModeChartTheming = getLightModeChartTheming();
const nightModeChartTheming = getNightModeChartTheming();
export const useTheme = () => {
const {
selectedColor,
setSelectedColor,
previousColor,
setPreviousColor,
colorChooserOpen,
setColorChooserOpen
} = useContext(ThemeContext);
const metadata = useMetadata();
const setTheme = color => {
setStoredTheme(color);
setSelectedColor(color);
}
if (!selectedColor) setTheme(selectedColor);
const toggleColorChooser = () => {
setColorChooserOpen(!colorChooserOpen);
}
const isNightModeEnabled = () => {
return selectedColor === 'night';
}
const toggleNightMode = () => {
if (isNightModeEnabled()) {
setTheme(previousColor ? previousColor : getDefaultTheme(metadata));
} else {
setPreviousColor(selectedColor);
setTheme('night');
}
}
const nightModeEnabled = isNightModeEnabled();
return {
color: selectedColor,
setColor: setTheme,
nightModeEnabled: nightModeEnabled,
colorChooserOpen: colorChooserOpen,
nightModeCss: nightModeEnabled ? createNightModeCss() : undefined,
toggleNightMode: toggleNightMode,
toggleColorChooser: toggleColorChooser,
themeColors: themeColors,
graphTheming: nightModeEnabled ? nightModeChartTheming : lightModeChartTheming
};
}
export const NightModeCss = () => {
const theme = useTheme();
return (
<>
{theme.nightModeEnabled ? <style>
{theme.nightModeCss}
</style> : ''}
</>
);
}

View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import '@fortawesome/fontawesome-free/css/all.min.css'
import {library} from '@fortawesome/fontawesome-svg-core';
import {fas} from '@fortawesome/free-solid-svg-icons';
import {far} from '@fortawesome/free-regular-svg-icons';
import {fab} from '@fortawesome/free-brands-svg-icons';
import {localeService} from "./service/localeService";
library.add(fab);
library.add(fas);
library.add(far);
localeService.init().then(() => ReactDOM.render(
<React.StrictMode>
<App/>
</React.StrictMode>,
document.getElementById('root')
));

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({getCLS, getFID, getFCP, getLCP, getTTFB}) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@ -0,0 +1,6 @@
import {doGetRequest} from "./backendConfiguration";
export const fetchWhoAmI = async () => {
const url = '/v1/whoami';
return doGetRequest(url);
}

View File

@ -0,0 +1,60 @@
import axios from "axios";
const toBeReplaced = "PLAN_BASE_ADDRESS";
const isCurrentAddress = (address) => {
const is = window.location.href.startsWith(address);
if (!is) console.warn(`Configured address ${address} did not match start of ${window.location.href}, falling back to relative address. Configure 'Webserver.Alternative_IP' settings to point to your address.`)
return is;
}
export const baseAddress = "PLAN_BASE_ADDRESS" === toBeReplaced || !isCurrentAddress(toBeReplaced) ? "" : toBeReplaced;
export const doSomeGetRequest = async (url, statusOptions) => {
let response = undefined;
try {
response = await axios.get(baseAddress + url);
for (const statusOption of statusOptions) {
if (response.status === statusOption.status) {
return {
data: statusOption.get(response),
error: undefined
};
}
}
} catch (e) {
console.error(e);
if (e.response !== undefined) {
for (const statusOption of statusOptions) {
if (e.response.status === statusOption.status) {
return {
data: undefined,
error: statusOption.get(response, e)
};
}
}
return {
data: undefined,
error: {
message: e.message,
url,
data: e.response.data
}
};
}
return {
data: undefined,
error: {
message: e.message,
url
}
};
}
}
export const standard200option = {status: 200, get: response => response.data}
export const doGetRequest = async url => {
return doSomeGetRequest(url, [standard200option])
}

View File

@ -0,0 +1,115 @@
import i18next from "i18next";
import I18NextChainedBackend from "i18next-chained-backend";
import I18NextLocalStorageBackend from "i18next-localstorage-backend";
import I18NextHttpBackend from 'i18next-http-backend';
import {initReactI18next} from 'react-i18next';
import {fetchAvailableLocales} from "./metadataService";
/**
* A locale system for localizing the website.
*/
export const localeService = {
/**
* @function
* Localizes an element.
* @param {Element} element Element to localize
* @param {Object} [options] Options
*/
localize: {},
/**
* The current default language reported by the server.
* @type {string}
* @readonly
*/
defaultLanguage: "",
/**
* The current available languages reported by the server.
* @type {Object.<string, string>}
* @readonly
*/
availableLanguages: {},
clientLocale: "",
/**
* Initializes the locale system. Gets the default & available languages from `/v1/locale`, and initializes i18next.
*/
init: async function () {
try {
const {data} = await fetchAvailableLocales();
if (data !== undefined) {
this.defaultLanguage = data.defaultLanguage;
this.availableLanguages = data.languages;
this.languageVersions = data.languageVersions;
} else {
this.defaultLanguage = 'en'
this.availableLanguages = {};
this.languageVersions = [];
}
this.clientLocale = window.localStorage.getItem("locale");
if (!this.clientLocale) {
this.clientLocale = this.defaultLanguage;
}
await i18next
.use(I18NextChainedBackend)
.use(initReactI18next)
.init({
debug: false,
lng: this.clientLocale,
fallbackLng: false,
supportedLngs: Object.keys(this.availableLanguages),
backend: {
backends: [
I18NextLocalStorageBackend,
I18NextHttpBackend
],
backendOptions: [{
expirationTime: 7 * 24 * 60 * 60 * 1000, // 7 days
versions: this.languageVersions
}, {
loadPath: '/v1/locale/{{lng}}'
}]
},
}, () => {/* No need to initialize anything */
});
} catch (e) {
console.error(e);
}
},
/**
* Loads a locale and translates the page.
*
* @param {string} langCode The two-character code for the language to be loaded, e.g. EN
* @throws Error if an invalid langCode is given
* @see /v1/language endpoint for available language codes
*/
loadLocale: async function (langCode) {
if (i18next.language === langCode) {
return;
}
if (!(langCode in this.availableLanguages)) {
throw Error(`The locale ${langCode} isn't available!`);
}
window.localStorage.setItem("locale", langCode);
await i18next.changeLanguage(langCode)
},
getLanguages: function () {
let languages = Object.fromEntries(Object.entries(this.availableLanguages).sort());
if ('CUSTOM' in languages) {
// Move "Custom" to first in list
delete languages["CUSTOM"]
languages = Object.assign({"CUSTOM": "Custom"}, languages);
}
return Object.entries(languages)
.map(entry => {
return {name: entry[0], displayName: entry[1]}
});
}
}

View File

@ -0,0 +1,16 @@
import {doGetRequest} from "./backendConfiguration";
export const fetchPlanMetadata = async () => {
const url = '/v1/metadata';
return doGetRequest(url);
}
export const fetchPlanVersion = async () => {
const url = '/v1/version';
return doGetRequest(url);
}
export const fetchAvailableLocales = async () => {
const url = '/v1/locale';
return doGetRequest(url);
}

View File

@ -0,0 +1,17 @@
import {faMapSigns} from "@fortawesome/free-solid-svg-icons";
import {doSomeGetRequest, standard200option} from "./backendConfiguration";
export const fetchPlayer = async (uuid, timestamp) => {
const url = `/v1/player?player=${uuid}&timestamp=${timestamp}`;
return doSomeGetRequest(url, [
standard200option,
{
status: 400,
get: () => ({
message: 'Player not found: ' + uuid + ', try another player',
title: '404 Player not found',
icon: faMapSigns
})
}
])
}

View File

@ -0,0 +1,97 @@
import {doGetRequest} from "./backendConfiguration";
export const fetchServerOverview = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/serverOverview?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchOnlineActivityOverview = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/onlineOverview?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPlayerbaseOverview = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/playerbaseOverview?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchSessionOverview = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/sessionsOverview?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPvpPve = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/playerVersus?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchSessions = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/sessions?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchKills = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/kills?server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPlayers = async (identifier) => {
const timestamp = Date.now();
const url = identifier ? `/v1/players?server=${identifier}&timestamp=${timestamp}` : `/v1/players?timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPlayersOnlineGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=playersOnline&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPlayerbaseDevelopmentGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=activity&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchDayByDayGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=uniqueAndNew&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchHourByHourGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=hourlyUniqueAndNew&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchServerCalendarGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=serverCalendar&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchPunchCardGraph = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=punchCard&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchWorldPie = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=worldPie&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}
export const fetchGeolocations = async (identifier) => {
const timestamp = Date.now();
const url = `/v1/graph?type=geolocation&server=${identifier}&timestamp=${timestamp}`;
return doGetRequest(url);
}

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,24 @@
@import "../../node_modules/bootstrap/scss/bootstrap"
@import "src/components/calendar/calendar-fixes"
p, span, td, .h3, a, button
.svg-inline--fa, .fa, .far, .fas, .fab
&:not(.comparing):not(.trend)
text-align: center !important
width: 1.25em !important
.card
@extend .shadow
@extend .mb-4
.card-header
@extend .py-3
@extend .d-flex
@extend .flex-row
@extend .align-items-center
@extend .justify-content-between
h6
@extend .m-0
@extend .fw-bold

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,203 @@
const colorMap = {
PLAN: {
name: "plan",
hex: "#468F17"
},
RED: {
name: "red",
hex: "#F44336"
},
PINK: {
name: "pink",
hex: "#E91E63"
},
PURPLE: {
name: "purple",
hex: "#9C27B0"
},
DEEP_PURPLE: {
name: "deep-purple",
hex: "#673AB7"
},
INDIGO: {
name: "indigo",
hex: "#3F61B5"
},
BLUE: {
name: "blue",
hex: "#2196F3"
},
LIGHT_BLUE: {
name: "light-blue",
hex: "#03A9F4"
},
CYAN: {
name: "cyan",
hex: "#00BCD4"
},
TEAL: {
name: "teal",
hex: "#009688"
},
GREEN: {
name: "green",
hex: "#4CAF50"
},
LIGHT_GREEN: {
name: "light-green",
hex: "#8BC34A"
},
LIME: {
name: "lime",
hex: "#CDDC39"
},
YELLOW: {
name: "yellow",
hex: "#FFE821"
},
AMBER: {
name: "amber",
hex: "#FFC107"
},
ORANGE: {
name: "orange",
hex: "#FF9800"
},
DEEP_ORANGE: {
name: "deep-orange",
hex: "#FF5722"
},
BROWN: {
name: "brown",
hex: "#795548"
},
GREY: {
name: "grey",
hex: "#9E9E9E"
},
BLUE_GREY: {
name: "blue-grey",
hex: "#607D8B"
},
BLACK: {
name: "black",
hex: "#555555"
},
SUCCESS: {
name: "success",
hex: "#1CC88A"
},
WARNING: {
name: "warning",
hex: "#F6C23E"
},
DANGER: {
name: "danger",
hex: "#e74A3B"
},
NONE: ""
};
export const getColors = () => {
return Object.values(colorMap).filter(color => color);
}
export const colorEnumToColorClass = color => {
const mapped = "col-" + colorMap[color].name;
return mapped ? mapped : "";
}
export const bgClassToColorClass = bgClass => {
return "col-" + bgClass.substring(3);
}
export const colorClassToColorName = (colorClass) => {
return colorClass.substring(4);
}
export const colorEnumToBgClass = color => {
return "bg-" + color;
}
export const colorClassToBgClass = colorClass => {
return "bg-" + colorClassToColorName(colorClass);
}
// From https://stackoverflow.com/a/3732187
export const withReducedSaturation = hex => {
const saturationReduction = 0.70;
// To RGB
let r = parseInt(hex.substr(1, 2), 16); // Grab the hex representation of red (chars 1-2) and convert to decimal (base 10).
let g = parseInt(hex.substr(3, 2), 16);
let b = parseInt(hex.substr(5, 2), 16);
// To HSL
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
break;
}
h /= 6;
}
// To css property
return 'hsl(' + h * 360 + ',' + s * 100 * saturationReduction + '%,' + l * 95 + '%)';
}
const nightColors = {
yellow: "#eee8d5",
black: "#282a36",
darkBlue: "#44475a",
blue: "#6272a4",
greyBlue: "#646e8c",
darkGreyBlue: "#606270"
}
const createNightModeColorCss = () => {
return getColors()
.filter(color => color.name !== 'white' && color.name !== 'black' && color.name !== 'plan')
.map(color => {
const desaturatedColor = withReducedSaturation(color.hex);
return `.bg-${color.name}{background-color: ${desaturatedColor} !important;color: ${nightColors.yellow};}` +
`.bg-${color.name}-outline{outline-color: ${desaturatedColor};border-color: ${desaturatedColor};}` +
`.col-${color.name}{color: ${desaturatedColor} !important;}`
}).join();
}
export const createNightModeCss = () => {
return `#content-wrapper {background-color:${nightColors.black}!important;}` +
`body,.btn,.bg-transparent-light {color: ${nightColors.yellow};}` +
`.card,.bg-white,.modal-content,.page-loader,.nav-tabs .nav-link:hover,.nav-tabs,hr,form .btn, .btn-outline-secondary{background-color:${nightColors.darkBlue}!important;border-color:${nightColors.blue}!important;}` +
`.bg-white.collapse-inner {border:1px solid;}` +
`.card-header {background-color:${nightColors.darkBlue};border-color:${nightColors.blue};}` +
`#content,.col-black,.text-gray-900,.text-gray-800,.collapse-item,.modal-title,.modal-body,.page-loader,.fc-title,.fc-time,pre,.table-dark,input::placeholder{color:${nightColors.yellow} !important;}` +
`.collapse-item:hover,.nav-link.active {background-color: ${nightColors.darkGreyBlue} !important;}` +
`.nav-tabs .nav-link.active {background-color: ${nightColors.darkBlue} !important;border-color:${nightColors.blue} ${nightColors.blue} ${nightColors.darkBlue} !important;}` +
`.fc-today {background:${nightColors.greyBlue} !important}` +
`.fc-popover-body,.fc-popover-header{background-color: ${nightColors.darkBlue} !important;color: ${nightColors.yellow} !important;}` +
`select,input,.dataTables_paginate .page-item:not(.active) a,.input-group-text,.input-group-text > * {background-color:${nightColors.darkBlue} !important;border-color:${nightColors.blue} !important;color: ${nightColors.yellow} !important;}` +
`.topbar-divider,.fc td,.fc tr,.fc th, .fc table, .modal-header,.modal-body,.modal-footer{border-color:${nightColors.blue} !important;}` +
`.fc a{color:${nightColors.yellow} !important;}` +
`.fc-button{ background-color: ${withReducedSaturation(colorMap.PLAN.hex)} !important;}` +
createNightModeColorCss()
}

Some files were not shown because too many files have changed in this diff Show More