mirror of
https://github.com/plan-player-analytics/Plan.git
synced 2024-11-14 14:45:51 +01:00
Fixed some issues with DataTables and unmounting extensions
When using ServerWidePluginData component for two different plugins that had a DataTablesTable React reused the component, which lead to issues of DataTables trying to remount on the same component with a different ID, with both jQuery and React trying to remove elements from DOM. - Added div around table in DataTablesTable to allow React properly unmount the element after jQuery already removed the table element - Added extra layer in ServerWidePluginData that re-renders the tab when plugin changes - Sorted extensions in the endpoints - Added /v1/networkMetadata endpoint for implementing server list navigation later - Added network page to App.js with plugins and players tabs already implemented
This commit is contained in:
parent
813abd040a
commit
38ee84af67
@ -33,7 +33,7 @@ public class ExtensionsDto {
|
||||
this.playerUUID = playerUUID;
|
||||
this.serverUUID = serverUUID;
|
||||
this.serverName = serverName;
|
||||
this.extensionData = extensionData.stream().map(ExtensionDataDto::new).collect(Collectors.toList());
|
||||
this.extensionData = extensionData.stream().sorted().map(ExtensionDataDto::new).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public String getServerUUID() {
|
||||
|
@ -116,6 +116,7 @@ public class ExtensionJSONResolver implements Resolver {
|
||||
List<ExtensionData> extensionData = dbSystem.getDatabase().query(new ExtensionServerDataQuery(serverUUID));
|
||||
return Map.of(
|
||||
"extensions", extensionData.stream()
|
||||
.sorted()
|
||||
.map(ExtensionDataDto::new)
|
||||
.collect(Collectors.toList())
|
||||
);
|
||||
|
@ -82,6 +82,7 @@ public class MetadataJSONResolver implements NoAuthResolver {
|
||||
.put("playerHeadImageUrl", config.get(DisplaySettings.PLAYER_HEAD_IMG_URL))
|
||||
.put("isProxy", serverInfo.getServer().isProxy())
|
||||
.put("serverName", serverInfo.getServer().getIdentifiableName())
|
||||
.put("serverUUID", serverInfo.getServer().getUuid().toString())
|
||||
.put("networkName", serverInfo.getServer().isProxy() ? config.get(ProxySettings.NETWORK_NAME) : null)
|
||||
.put("mainCommand", mainCommand)
|
||||
.build())
|
||||
|
@ -0,0 +1,82 @@
|
||||
/*
|
||||
* This file is part of Player Analytics (Plan).
|
||||
*
|
||||
* Plan is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Lesser General Public License v3 as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Plan is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Lesser General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Lesser General Public License
|
||||
* along with Plan. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package com.djrapitops.plan.delivery.webserver.resolver.json;
|
||||
|
||||
import com.djrapitops.plan.delivery.domain.datatransfer.ServerDto;
|
||||
import com.djrapitops.plan.delivery.web.resolver.Resolver;
|
||||
import com.djrapitops.plan.delivery.web.resolver.Response;
|
||||
import com.djrapitops.plan.delivery.web.resolver.request.Request;
|
||||
import com.djrapitops.plan.delivery.web.resolver.request.WebUser;
|
||||
import com.djrapitops.plan.identification.ServerInfo;
|
||||
import com.djrapitops.plan.storage.database.DBSystem;
|
||||
import com.djrapitops.plan.storage.database.queries.objects.ServerQueries;
|
||||
import com.djrapitops.plan.utilities.java.Maps;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author AuroraLS3
|
||||
*/
|
||||
@Singleton
|
||||
@Path("/v1/networkMetadata")
|
||||
public class NetworkMetadataJSONResolver implements Resolver {
|
||||
|
||||
private final ServerInfo serverInfo;
|
||||
private final DBSystem dbSystem;
|
||||
|
||||
@Inject
|
||||
public NetworkMetadataJSONResolver(ServerInfo serverInfo, DBSystem dbSystem) {
|
||||
this.serverInfo = serverInfo;
|
||||
this.dbSystem = dbSystem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canAccess(Request request) {
|
||||
return request.getUser().orElse(new WebUser("")).hasPermission("page.network");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Operation(
|
||||
description = "Get metadata about the network such as list of servers.",
|
||||
requestBody = @RequestBody(content = @Content(examples = @ExampleObject()))
|
||||
)
|
||||
@Override
|
||||
public Optional<Response> resolve(Request request) {
|
||||
return Optional.of(getResponse());
|
||||
}
|
||||
|
||||
private Response getResponse() {
|
||||
return Response.builder()
|
||||
.setJSONContent(Maps.builder(String.class, Object.class)
|
||||
.put("servers", dbSystem.getDatabase().query(ServerQueries.fetchPlanServerInformationCollection())
|
||||
.stream().map(ServerDto::fromServer)
|
||||
.sorted()
|
||||
.collect(Collectors.toList()))
|
||||
.put("currentServer", ServerDto.fromServer(serverInfo.getServer()))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
}
|
@ -62,6 +62,7 @@ public class RootJSONResolver {
|
||||
QueryJSONResolver queryJSONResolver,
|
||||
VersionJSONResolver versionJSONResolver,
|
||||
MetadataJSONResolver metadataJSONResolver,
|
||||
NetworkMetadataJSONResolver networkMetadataJSONResolver,
|
||||
WhoAmIJSONResolver whoAmIJSONResolver,
|
||||
ServerIdentityJSONResolver serverIdentityJSONResolver,
|
||||
ExtensionJSONResolver extensionJSONResolver
|
||||
@ -89,6 +90,7 @@ public class RootJSONResolver {
|
||||
.add("version", versionJSONResolver)
|
||||
.add("locale", localeJSONResolver)
|
||||
.add("metadata", metadataJSONResolver)
|
||||
.add("networkMetadata", networkMetadataJSONResolver)
|
||||
.add("serverIdentity", serverIdentityJSONResolver)
|
||||
.add("whoami", whoAmIJSONResolver)
|
||||
.add("extensionData", extensionJSONResolver)
|
||||
|
@ -33,6 +33,8 @@ const ServerPerformance = React.lazy(() => import("./views/server/ServerPerforma
|
||||
const ServerPluginData = React.lazy(() => import("./views/server/ServerPluginData"));
|
||||
const ServerWidePluginData = React.lazy(() => import("./views/server/ServerWidePluginData"));
|
||||
|
||||
const NetworkPage = React.lazy(() => import("./views/layout/NetworkPage"));
|
||||
|
||||
const PlayersPage = React.lazy(() => import("./views/layout/PlayersPage"));
|
||||
const AllPlayers = React.lazy(() => import("./views/players/AllPlayers"));
|
||||
|
||||
@ -109,6 +111,17 @@ function App() {
|
||||
icon: faMapSigns
|
||||
}}/>}/>
|
||||
</Route>
|
||||
<Route path="/network" element={<Lazy><NetworkPage/></Lazy>}>
|
||||
<Route path="" element={<Lazy><OverviewRedirect/></Lazy>}/>
|
||||
<Route path="players" element={<Lazy><AllPlayers/></Lazy>}/>
|
||||
<Route path="plugins-overview" element={<Lazy><ServerPluginData/></Lazy>}/>
|
||||
<Route path="plugins/:plugin" element={<Lazy><ServerWidePluginData/></Lazy>}/>
|
||||
<Route path="*" element={<ErrorView error={{
|
||||
message: 'Unknown tab address, please correct the address',
|
||||
title: 'No such tab',
|
||||
icon: faMapSigns
|
||||
}}/>}/>
|
||||
</Route>
|
||||
<Route path="/errors" element={<Lazy><ErrorsPage/></Lazy>}/>
|
||||
<Route path="/docs" element={<Lazy><SwaggerView/></Lazy>}/>
|
||||
</Routes>
|
||||
|
@ -2,14 +2,16 @@ 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}
|
||||
/>
|
||||
)
|
||||
const ExtensionIcon = ({icon}) => {
|
||||
return (
|
||||
<Fa icon={[
|
||||
iconTypeToFontAwesomeClass(icon.family),
|
||||
icon.iconName
|
||||
]}
|
||||
className={icon.colorClass}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const toExtensionIconHtmlString = ({icon}) => {
|
||||
return icon ? `<i class="${iconTypeToFontAwesomeClass(icon.family)} ${icon.iconName} ${icon.colorClass}"></i>` : '';
|
||||
|
@ -67,7 +67,7 @@ const ExtensionColoredTable = ({table}) => {
|
||||
const ExtensionTable = ({table}) => {
|
||||
const tableLength = table.table.rows.length;
|
||||
|
||||
if (tableLength > 25) {
|
||||
if (tableLength > 10) {
|
||||
return <ExtensionDataTable table={table}/>
|
||||
} else {
|
||||
return <ExtensionColoredTable table={table}/>
|
||||
|
@ -24,6 +24,10 @@ const Divider = ({showMargin}) => (
|
||||
)
|
||||
|
||||
const InnerItem = ({href, icon, name, nameShort}) => {
|
||||
if (!href) {
|
||||
return (<hr className={"nav-servers dropdown-divider mx-3 my-2"}/>)
|
||||
}
|
||||
|
||||
if (href.startsWith('/')) {
|
||||
return (
|
||||
<a href={href} className="collapse-item nav-button">
|
||||
|
@ -25,9 +25,13 @@ const DataTablesTable = ({id, options}) => {
|
||||
};
|
||||
}, [id, options, dataTableRef]);
|
||||
|
||||
// The surrounding div is required: jquery Datatables changes the DOM around table and React needs to have a node
|
||||
// that wasn't changed.
|
||||
return (
|
||||
<table id={id} className={"table table-bordered table-striped" + (nightModeEnabled ? " table-dark" : '')}
|
||||
style={{width: "100%"}}/>
|
||||
<div>
|
||||
<table id={id} className={"table table-bordered table-striped" + (nightModeEnabled ? " table-dark" : '')}
|
||||
style={{width: "100%"}}/>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
|
@ -1,13 +1,10 @@
|
||||
import {createContext, useContext, useEffect, useState} from "react";
|
||||
import {useDataRequest} from "./dataFetchHook";
|
||||
import {fetchExtensionData} from "../service/serverService";
|
||||
import {useParams} from "react-router-dom";
|
||||
|
||||
const ServerExtensionContext = createContext({});
|
||||
|
||||
export const ServerExtensionContextProvider = ({children}) => {
|
||||
const {identifier} = useParams();
|
||||
|
||||
export const ServerExtensionContextProvider = ({identifier, children}) => {
|
||||
const [extensionData, setExtensionData] = useState(undefined);
|
||||
const [extensionDataLoadingError, setExtensionDataLoadingError] = useState(undefined);
|
||||
|
||||
|
@ -19,3 +19,8 @@ export const fetchErrorLogs = async () => {
|
||||
const url = '/v1/errors';
|
||||
return doGetRequest(url);
|
||||
}
|
||||
|
||||
export const fetchNetworkMetadata = async () => {
|
||||
const url = '/v1/networkMetadata';
|
||||
return doGetRequest(url);
|
||||
}
|
132
Plan/react/dashboard/src/views/layout/NetworkPage.js
Normal file
132
Plan/react/dashboard/src/views/layout/NetworkPage.js
Normal file
@ -0,0 +1,132 @@
|
||||
import React, {useEffect} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Outlet} from "react-router-dom";
|
||||
import {useNavigation} from "../../hooks/navigationHook";
|
||||
import {
|
||||
faChartLine,
|
||||
faCogs,
|
||||
faCubes,
|
||||
faGlobe,
|
||||
faInfoCircle,
|
||||
faNetworkWired,
|
||||
faSearch,
|
||||
faServer,
|
||||
faUserGroup,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import {useAuth} from "../../hooks/authenticationHook";
|
||||
import {NightModeCss} from "../../hooks/themeHook";
|
||||
import Sidebar from "../../components/navigation/Sidebar";
|
||||
import Header from "../../components/navigation/Header";
|
||||
import ColorSelectorModal from "../../components/modal/ColorSelectorModal";
|
||||
import {useMetadata} from "../../hooks/metadataHook";
|
||||
import {faCalendarCheck} from "@fortawesome/free-regular-svg-icons";
|
||||
import {SwitchTransition} from "react-transition-group";
|
||||
import MainPageRedirect from "../../components/navigation/MainPageRedirect";
|
||||
import ExtensionIcon from "../../components/extensions/ExtensionIcon";
|
||||
import {ServerExtensionContextProvider, useServerExtensionContext} from "../../hooks/serverExtensionDataContext";
|
||||
|
||||
const NetworkSidebar = () => {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {sidebarItems, setSidebarItems} = useNavigation();
|
||||
const {extensionData} = useServerExtensionContext();
|
||||
|
||||
useEffect(() => {
|
||||
const servers = []
|
||||
const items = [
|
||||
{name: 'html.label.networkOverview', icon: faInfoCircle, href: "overview"},
|
||||
{},
|
||||
{
|
||||
name: 'html.label.servers',
|
||||
icon: faServer,
|
||||
contents: [
|
||||
{name: 'html.label.overview', icon: faNetworkWired, href: "serversOverview"},
|
||||
{name: 'html.label.sessions', icon: faCalendarCheck, href: "sessions"},
|
||||
{name: 'html.label.performance', icon: faCogs, href: "performance"},
|
||||
{},
|
||||
...servers.map(server => {
|
||||
return {name: server.serverName, icon: faServer, href: "/server/" + server.serverUUID}
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'html.label.playerbase',
|
||||
icon: faUsers,
|
||||
contents: [
|
||||
{
|
||||
nameShort: 'html.label.overview',
|
||||
name: 'html.label.playerbaseOverview',
|
||||
icon: faChartLine,
|
||||
href: "playerbase"
|
||||
},
|
||||
// {name: 'html.label.playerRetention', icon: faUsersViewfinder, href: "retention"},
|
||||
{name: 'html.label.playerList', icon: faUserGroup, href: "players"},
|
||||
{name: 'html.label.geolocations', icon: faGlobe, href: "geolocations"},
|
||||
]
|
||||
},
|
||||
{},
|
||||
{name: 'html.label.plugins'},
|
||||
{name: 'html.label.pluginsOverview', icon: faCubes, href: "plugins-overview"}
|
||||
]
|
||||
|
||||
if (extensionData) {
|
||||
extensionData.extensions.filter(extension => extension.wide)
|
||||
.map(extension => extension.extensionInformation)
|
||||
.map(info => {
|
||||
return {
|
||||
name: info.pluginName,
|
||||
icon: <ExtensionIcon icon={info.icon}/>,
|
||||
href: `plugins/${encodeURIComponent(info.pluginName)}`
|
||||
}
|
||||
}).forEach(item => items.push(item))
|
||||
}
|
||||
|
||||
items.push(
|
||||
{},
|
||||
{name: 'html.label.links'},
|
||||
{name: 'html.label.query', icon: faSearch, href: "/query"}
|
||||
);
|
||||
|
||||
setSidebarItems(items);
|
||||
window.document.title = `Plan | Network`;
|
||||
}, [t, i18n, extensionData, setSidebarItems])
|
||||
|
||||
return (
|
||||
<Sidebar items={sidebarItems} showBackButton={false}/>
|
||||
)
|
||||
}
|
||||
|
||||
const ServerPage = () => {
|
||||
const {networkName, serverUUID} = useMetadata();
|
||||
|
||||
const {currentTab} = useNavigation();
|
||||
|
||||
const {authRequired, loggedIn} = useAuth();
|
||||
if (authRequired && !loggedIn) return <MainPageRedirect/>
|
||||
|
||||
if (!serverUUID) return <></>
|
||||
|
||||
return (
|
||||
<>
|
||||
<NightModeCss/>
|
||||
<ServerExtensionContextProvider identifier={serverUUID}>
|
||||
<NetworkSidebar/>
|
||||
<div className="d-flex flex-column" id="content-wrapper">
|
||||
<Header page={networkName} tab={currentTab}/>
|
||||
<div id="content" style={{display: 'flex'}}>
|
||||
<main className="container-fluid mt-4">
|
||||
<SwitchTransition>
|
||||
<Outlet/>
|
||||
</SwitchTransition>
|
||||
</main>
|
||||
<aside>
|
||||
<ColorSelectorModal/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</ServerExtensionContextProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerPage;
|
@ -145,7 +145,7 @@ const ServerPage = () => {
|
||||
return (
|
||||
<>
|
||||
<NightModeCss/>
|
||||
<ServerExtensionContextProvider>
|
||||
<ServerExtensionContextProvider identifier={identifier}>
|
||||
<ServerSidebar/>
|
||||
<div className="d-flex flex-column" id="content-wrapper">
|
||||
<Header page={displayedServerName} tab={currentTab}/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import ErrorView from "../ErrorView";
|
||||
import LoadIn from "../../components/animation/LoadIn";
|
||||
import {Card, Col, Row} from "react-bootstrap-v5";
|
||||
@ -8,15 +8,17 @@ import {useTranslation} from "react-i18next";
|
||||
import Loader from "../../components/navigation/Loader";
|
||||
import {useServerExtensionContext} from "../../hooks/serverExtensionDataContext";
|
||||
|
||||
const ServerWidePluginData = () => {
|
||||
const PluginData = ({plugin}) => {
|
||||
const {t} = useTranslation();
|
||||
const {plugin} = useParams();
|
||||
const {extensionData, extensionDataLoadingError} = useServerExtensionContext();
|
||||
const [extension, setExtension] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setExtension(extensionData?.extensions?.find(extension => extension.extensionInformation.pluginName === plugin))
|
||||
}, [setExtension, extensionData, plugin])
|
||||
|
||||
if (extensionDataLoadingError) return <ErrorView error={extensionDataLoadingError}/>;
|
||||
|
||||
const extension = extensionData?.find(extension => extension.extensionInformation.pluginName === plugin)
|
||||
|
||||
if (!extension) {
|
||||
return (
|
||||
<LoadIn>
|
||||
@ -47,6 +49,22 @@ const ServerWidePluginData = () => {
|
||||
</section>
|
||||
</LoadIn>
|
||||
)
|
||||
}
|
||||
|
||||
const ServerWidePluginData = () => {
|
||||
const {plugin} = useParams();
|
||||
const [previousPlugin, setPreviousPlugin] = useState(undefined);
|
||||
|
||||
// Prevents React from reusing the extension component of two different plugins, leading to DataTables errors.
|
||||
useEffect(() => {
|
||||
setPreviousPlugin(plugin);
|
||||
}, [plugin, setPreviousPlugin]);
|
||||
|
||||
if (plugin !== previousPlugin) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (<PluginData plugin={plugin}/>)
|
||||
};
|
||||
|
||||
export default ServerWidePluginData
|
Loading…
Reference in New Issue
Block a user