RAM usage and player count graphs. Now uses serverHooks

This commit is contained in:
Benjamin Elsdon 2022-05-07 21:36:17 +08:00
parent e6a94436dc
commit 9ed0d17975
14 changed files with 284 additions and 96 deletions

View File

@ -6,9 +6,12 @@ import com.benj4.gcgm.utils.*;
import com.benj4.gcgm.utils.web.WebUtils; import com.benj4.gcgm.utils.web.WebUtils;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import emu.grasscutter.plugin.Plugin; import emu.grasscutter.plugin.Plugin;
import emu.grasscutter.plugin.api.ServerHook;
import emu.grasscutter.server.dispatch.DispatchServer;
import emu.grasscutter.server.event.EventHandler; import emu.grasscutter.server.event.EventHandler;
import emu.grasscutter.server.event.HandlerPriority; import emu.grasscutter.server.event.HandlerPriority;
import emu.grasscutter.server.event.game.ServerTickEvent; import emu.grasscutter.server.event.game.ServerTickEvent;
import emu.grasscutter.server.game.GameServer;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
import express.Express; import express.Express;
@ -18,7 +21,7 @@ public class GCGMPlugin extends Plugin {
private static GCGMPlugin INSTANCE; private static GCGMPlugin INSTANCE;
EventHandler<ServerTickEvent> serverTickEventHandler; EventHandler<ServerTickEvent> serverTickEventHandler;
private static WebSocketServer webSocketServer; private WebSocketServer webSocketServer;
private File webData; private File webData;
@Override @Override
@ -57,12 +60,11 @@ public class GCGMPlugin extends Plugin {
@Override @Override
public void onEnable() { public void onEnable() {
if(webData.exists()) { if(webData.exists()) {
Express app = Grasscutter.getDispatchServer().getServer(); WebUtils.addStaticFiles(webData);
WebUtils.addStaticFiles(app, webData);
webSocketServer = new WebSocketServer(); webSocketServer = new WebSocketServer();
webSocketServer.start(app); webSocketServer.start();
Grasscutter.getPluginManager().registerListener(serverTickEventHandler); serverTickEventHandler.register();
Grasscutter.getLogger().info("[GCGM] GCGM Enabled"); Grasscutter.getLogger().info("[GCGM] GCGM Enabled");
Grasscutter.getLogger().info("[GCGM] You can access your GM panel by navigating to " + GCGMUtils.GetDispatchAddress() + WebUtils.PAGE_ROOT); Grasscutter.getLogger().info("[GCGM] You can access your GM panel by navigating to " + GCGMUtils.GetDispatchAddress() + WebUtils.PAGE_ROOT);
@ -78,11 +80,19 @@ public class GCGMPlugin extends Plugin {
Grasscutter.getLogger().info("[GCGM] GCGM Disabled"); Grasscutter.getLogger().info("[GCGM] GCGM Disabled");
} }
public static GCGMPlugin GetInstance() { public static GCGMPlugin getInstance() {
return INSTANCE; return INSTANCE;
} }
public static WebSocketServer getWebSocketServer() { public WebSocketServer getWebSocketServer() {
return webSocketServer; return webSocketServer;
} }
public static GameServer getGameServer() {
return GCGMPlugin.getInstance().getServer();
}
public static DispatchServer getDispatchServer() {
return ServerHook.getInstance().getDispatchServer();
}
} }

View File

@ -1,24 +1,35 @@
package com.benj4.gcgm.handlers; package com.benj4.gcgm.handlers;
import com.benj4.gcgm.GCGMPlugin; import com.benj4.gcgm.GCGMPlugin;
import com.benj4.gcgm.server.websocket.json.TickData;
import com.benj4.gcgm.server.websocket.json.WSData; import com.benj4.gcgm.server.websocket.json.WSData;
import com.benj4.gcgm.utils.GCGMUtils;
import com.google.gson.JsonObject;
import emu.grasscutter.server.event.game.ServerTickEvent; import emu.grasscutter.server.event.game.ServerTickEvent;
import emu.grasscutter.utils.EventConsumer; import emu.grasscutter.utils.EventConsumer;
import java.time.Instant; import java.time.Instant;
public class ServerTickHandler implements EventConsumer<ServerTickEvent> { public class ServerTickHandler implements EventConsumer<ServerTickEvent> {
private static Instant firstTick;
private static Instant lastTick; private static Instant lastTick;
@Override @Override
public void consume(ServerTickEvent serverTickEvent) { public void consume(ServerTickEvent serverTickEvent) {
if(lastTick != null) { if(lastTick != null) {
Instant now = Instant.now(); Instant now = Instant.now();
long timeTaken = now.toEpochMilli() - lastTick.toEpochMilli(); TickData data = new TickData();
GCGMPlugin.getWebSocketServer().broadcast(new WSData("tick", timeTaken)); data.tickTimeElapsed = now.toEpochMilli() - lastTick.toEpochMilli();
data.serverUptime = lastTick.toEpochMilli() - firstTick.toEpochMilli();
data.getFreeMemory = GCGMUtils.GetFreeJVMMemory();
data.getAllocatedMemory = GCGMUtils.GetAllocatedJVMMemory();
data.playerCount = GCGMPlugin.getGameServer().getPlayers().size();
GCGMPlugin.getInstance().getWebSocketServer().broadcast(new WSData("tick", data));
lastTick = now; lastTick = now;
} else { } else {
lastTick = Instant.now(); lastTick = Instant.now();
firstTick = Instant.now();
} }
} }
} }

View File

@ -1,5 +1,6 @@
package com.benj4.gcgm.server.websocket; package com.benj4.gcgm.server.websocket;
import com.benj4.gcgm.GCGMPlugin;
import com.benj4.gcgm.server.websocket.json.WSData; import com.benj4.gcgm.server.websocket.json.WSData;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import express.Express; import express.Express;
@ -16,7 +17,9 @@ public class WebSocketServer {
//SocketIOServer socketIOServer; //SocketIOServer socketIOServer;
private static Map<WsContext, String> userUsernameMap = new ConcurrentHashMap<>(); private static Map<WsContext, String> userUsernameMap = new ConcurrentHashMap<>();
public void start(Express app) { public void start() {
Express app = GCGMPlugin.getDispatchServer().getServer();
app.ws("/gm", ws -> { app.ws("/gm", ws -> {
ws.onConnect(ctx -> { ws.onConnect(ctx -> {
String username = "Not logged in"; String username = "Not logged in";

View File

@ -0,0 +1,9 @@
package com.benj4.gcgm.server.websocket.json;
public class TickData {
public long tickTimeElapsed;
public long serverUptime;
public long getFreeMemory;
public long getAllocatedMemory;
public int playerCount;
}

View File

@ -2,10 +2,10 @@ package com.benj4.gcgm.server.websocket.json;
public class WSData { public class WSData {
public String eventName; public String eventName;
public Object object; public Object data;
public WSData(String eventName, Object object) { public WSData(String eventName, Object data) {
this.eventName = eventName; this.eventName = eventName;
this.object = object; this.data = data;
} }
} }

View File

@ -2,6 +2,7 @@ package com.benj4.gcgm.utils;
import com.benj4.gcgm.GCGMPlugin; import com.benj4.gcgm.GCGMPlugin;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import emu.grasscutter.server.game.GameServer;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -11,6 +12,8 @@ import java.nio.file.StandardCopyOption;
public class GCGMUtils { public class GCGMUtils {
private static Runtime RUNTIME = Runtime.getRuntime();
public static String GetDispatchAddress() { public static String GetDispatchAddress() {
return "http" + (Grasscutter.getConfig().getDispatchOptions().FrontHTTPS ? "s" : "") + "://" + return "http" + (Grasscutter.getConfig().getDispatchOptions().FrontHTTPS ? "s" : "") + "://" +
(Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) + (Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) +
@ -20,7 +23,7 @@ public class GCGMUtils {
public static boolean CopyFile(String resourceName, String copyLocation) { public static boolean CopyFile(String resourceName, String copyLocation) {
try { try {
Grasscutter.getLogger().info("[GCGM] Copying 'DefaultWebApp.zip' to './plugins/GCGM'"); Grasscutter.getLogger().info("[GCGM] Copying 'DefaultWebApp.zip' to './plugins/GCGM'");
Files.copy(GCGMPlugin.GetInstance().getResource(resourceName), Paths.get(new File(copyLocation).toURI()), StandardCopyOption.REPLACE_EXISTING); Files.copy(GCGMPlugin.getInstance().getResource(resourceName), Paths.get(new File(copyLocation).toURI()), StandardCopyOption.REPLACE_EXISTING);
return true; return true;
} catch (IOException e) { } catch (IOException e) {
Grasscutter.getLogger().error(String.format("[GCGM] An error occurred while trying to copy '%s' to '%s'", resourceName, copyLocation)); Grasscutter.getLogger().error(String.format("[GCGM] An error occurred while trying to copy '%s' to '%s'", resourceName, copyLocation));
@ -28,4 +31,12 @@ public class GCGMUtils {
return false; return false;
} }
} }
public static long GetFreeJVMMemory() {
return GCGMUtils.RUNTIME.freeMemory();
}
public static long GetAllocatedJVMMemory() {
return GCGMUtils.RUNTIME.totalMemory();
}
} }

View File

@ -1,5 +1,6 @@
package com.benj4.gcgm.utils.web; package com.benj4.gcgm.utils.web;
import com.benj4.gcgm.GCGMPlugin;
import emu.grasscutter.Grasscutter; import emu.grasscutter.Grasscutter;
import emu.grasscutter.utils.Utils; import emu.grasscutter.utils.Utils;
import express.Express; import express.Express;
@ -12,7 +13,8 @@ public class WebUtils {
public static final String PAGE_ROOT = "/gm"; public static final String PAGE_ROOT = "/gm";
public static void addStaticFiles(Express app, File staticRoot) { public static void addStaticFiles(File staticRoot) {
Express app = GCGMPlugin.getDispatchServer().getServer();
app.raw().config.precompressStaticFiles = false; // MUST BE SET TO FALSE OR FILES SUCH AS IMAGES WILL APPEAR CORRUPTED app.raw().config.precompressStaticFiles = false; // MUST BE SET TO FALSE OR FILES SUCH AS IMAGES WILL APPEAR CORRUPTED
app.raw().config.addStaticFiles(PAGE_ROOT, staticRoot.getAbsolutePath(), Location.EXTERNAL); app.raw().config.addStaticFiles(PAGE_ROOT, staticRoot.getAbsolutePath(), Location.EXTERNAL);
app.raw().config.addSinglePageRoot(PAGE_ROOT, Utils.toFilePath(staticRoot.getPath() + "/index.html"), Location.EXTERNAL); app.raw().config.addSinglePageRoot(PAGE_ROOT, Utils.toFilePath(staticRoot.getPath() + "/index.html"), Location.EXTERNAL);

View File

@ -1,8 +1,11 @@
import { WebsocketProvider } from './Context/WebsocketProvider';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
function App() { function App() {
return ( return (
<Dashboard /> <WebsocketProvider>
<Dashboard />
</WebsocketProvider>
); );
} }

View File

@ -1,12 +1,11 @@
.graph { .graph {
position: absolute; float: left;
transform: translate(-50%, 0%);
top: 6%;
left: 50%;
border-color: grey; border-color: grey;
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
border-radius: 8px; border-radius: 8px;
width: 800px; width: 800px;
margin: 10px;
margin-left: 50px;
text-align: center; text-align: center;
} }

View File

@ -1,94 +1,114 @@
import React, { useState, useEffect } from "react"; import React, { Component, useState, useEffect } from "react";
import { import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from "chart.js";
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { useWebSocket, ReadyState } from "react-use-websocket/dist/lib/use-websocket";
import { Line } from "react-chartjs-2"; import { Line } from "react-chartjs-2";
import "./StatsWidget.css"; import "./StatsWidget.css";
import WebsocketContext, { useWebsocket } from "../../Context/WebsocketProvider";
import { toHaveStyle } from "@testing-library/jest-dom/dist/matchers";
ChartJS.register( ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement);
CategoryScale,
LinearScale,
PointElement,
LineElement,
);
const CHART_COLORS = { const CHART_COLORS = {
red: 'rgb(255, 99, 132)', red: "rgb(255, 99, 132)",
orange: 'rgb(255, 159, 64)', orange: "rgb(255, 159, 64)",
yellow: 'rgb(255, 205, 86)', yellow: "rgb(255, 205, 86)",
green: 'rgb(75, 192, 192)', green: "rgb(75, 192, 192)",
blue: 'rgb(54, 162, 235)', blue: "rgb(54, 162, 235)",
purple: 'rgb(153, 102, 255)', purple: "rgb(153, 102, 255)",
grey: 'rgb(201, 203, 207)' grey: "rgb(201, 203, 207)",
}; };
const TICK_COUNT = 50; const TICK_COUNT = 25;
const labels = []; const labels = [];
for (let i = 0; i <= TICK_COUNT; ++i) { for (let i = 0; i <= TICK_COUNT; ++i) {
labels.push(i.toString()); labels.push(i.toString());
} }
export default function StatsWidget() { export default class StatsWidget extends Component {
const protocol = window.location.protocol === "https:" ? "wss" : "wss" constructor(props) {
const host = window.location.host; super(props);
const port = window.location.port;
const { sendMessage, lastMessage, readyState } = useWebSocket(protocol + "://"+ host +":" + port + "/gm"); this.state = {
graphHistory: [],
};
}
componentDidUpdate(lastProps) {
if (this.props.data !== this.props.ignoreData) {
if (this.props.serverUptime !== lastProps.serverUptime) {
var newGraph = this.state.graphHistory;
console.log(this.props);
newGraph.push({ tick: this.props.data });
if (newGraph.length > TICK_COUNT) {
newGraph.splice(0, 1);
}
this.setState({
graphHistory: newGraph,
});
}
}
}
render() {
return (
<div className="graph">
<p> { this.props.title } </p>
<p> {JSON.stringify(this.state.data)} </p>
<Line
datasetIdKey="1"
data={{
labels: this.state.graphHistory.map((gh, i) => this.state.graphHistory.length - i),
datasets: [
{
label: "",
data: this.state.graphHistory.map((gh) => gh.tick),
borderColor: CHART_COLORS.red,
fill: false,
cubicInterpolationMode: "monotone",
tension: 0.4,
},
],
}}
options={{
y: {
suggestedMin: this.props.suggestedYMin,
suggestedMax: this.props.suggestedYMax,
},
}}
/>
</div>
);
}
}
/*export default function StatsWidget() {
const [graphHistory, setGraphHistory] = useState([]); const [graphHistory, setGraphHistory] = useState([]);
const [lastMessage, connectionStatus, sendMessage] = useWebsocket();
useEffect(() => { useEffect(() => {
if (lastMessage !== null) { console.log(lastMessage())
const data = JSON.parse(lastMessage.data); if (lastMessage() !== null) {
const { data } = JSON.parse(lastMessage().data);
var newGraph = graphHistory; var newGraph = graphHistory;
newGraph.push({ time: new Date().getTime(), tick: data.object}); newGraph.push({ time: new Date().getTime(), tick: data.tickTimeElapsed });
if(newGraph.length > TICK_COUNT) { if(newGraph.length > TICK_COUNT) {
newGraph.splice(0, 1); newGraph.splice(0, 1);
} }
setGraphHistory(newGraph); setGraphHistory(newGraph);
} }
}, [lastMessage, setGraphHistory]); }, [graphHistory, lastMessage, setGraphHistory]);
/*const connectionStatus = { const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting", [ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open", [ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing", [ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed", [ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated", [ReadyState.UNINSTANTIATED]: "Uninstantiated",
}[readyState];*/ }[readyState];
return ( return (
<div className="graph">
<p> Server Performance (Ticks) </p>
<Line
datasetIdKey="1"
data={{
labels,
datasets: [
{
label: '',
data: graphHistory.map((l) => l.tick),
borderColor: CHART_COLORS.red,
fill: false,
cubicInterpolationMode: 'monotone',
tension: 0.4
}
]
}}
options={{
y: {
suggestedMin: 995,
suggestedMax: 1005
}
}}/>
</div>
); );
} }*/

View File

@ -0,0 +1,61 @@
import React, { useContext, useCallback, useState } from 'react'
import { useWebSocket as Websocket, ReadyState } from "react-use-websocket/dist/lib/use-websocket";
import { ReactIsInDevelopmentMode } from '../util/util';
const WebsocketContext = React.createContext();
const protocol = window.location.protocol === "https:" ? "wss" : "wss"
const host = window.location.host;
const port = window.location.port;
function getWebsocketURL() {
if(ReactIsInDevelopmentMode) {
return "wss://localhost:443/gm";
} else {
return protocol + "://"+ host +":" + port + "/gm";
}
}
export function useWebsocket() {
return useContext(WebsocketContext);
}
export function WebsocketProvider({ children }) {
const { sendMessage, lastMessage, readyState } = Websocket(getWebsocketURL());
const connectionStatus = useCallback(() => {
if(readyState === ReadyState.CONNECTING) {
return "Connecting";
} else if(readyState === ReadyState.OPEN) {
return "Open";
} else if(readyState === ReadyState.CLOSING) {
return "Closing";
} else if(readyState === ReadyState.CLOSED) {
return "Closed";
} else if(readyState === ReadyState.UNINSTANTIATED) {
return "Uninstantiated";
}
}, [readyState]);
const getLastMessage = useCallback(() => {
if(lastMessage != null) {
if(lastMessage.data != null) {
return JSON.parse(lastMessage.data);
}
}
return {};
}, [lastMessage]);
const send = useCallback((data) => {
sendMessage(data, false);
}, [sendMessage]);
return (
<WebsocketContext.Provider value={{lastMessage: getLastMessage, connectionStatus, sendMessage: send}}>
{children}
</WebsocketContext.Provider>
)
}
export default WebsocketContext;

View File

@ -4,3 +4,8 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
.content {
float: left;
width: calc(100vw - 350px);
}

View File

@ -1,16 +1,46 @@
import React, { Component } from 'react' import React, { Component } from "react";
import Sidepanel from './Components/Dashboard/Sidepanel/Sidepanel' import Sidepanel from "./Components/Dashboard/Sidepanel/Sidepanel";
import StatsWidget from './Components/Dashboard/StatsWidget' import StatsWidget from "./Components/Dashboard/StatsWidget";
import WebsocketContext, { useWebsocket } from "./Context/WebsocketProvider";
import './Dashboard.css' import "./Dashboard.css";
import { bytesToMegabytes } from "./util/util";
export default class Dashboard extends Component { export default class Dashboard extends Component {
render() { static contextType = WebsocketContext;
return (
<div className='dashboard'> constructor(props) {
<Sidepanel /> super(props);
<StatsWidget />
this.state = {
lastMessage: {
serverUptime: 0,
},
};
}
componentDidUpdate() {
if (typeof this.context.lastMessage().data !== "undefined") {
if (this.context.lastMessage().data.serverUptime !== this.state.lastMessage.serverUptime) {
//console.log(this.context.lastMessage().data);
this.setState({
lastMessage: this.context.lastMessage().data,
});
}
}
}
render() {
return (
<div className="dashboard">
<Sidepanel />
<div className="content">
<StatsWidget title="Server Performance (Ticks)" serverUptime={this.state.lastMessage.serverUptime} data={typeof this.state.lastMessage.tickTimeElapsed !== "undefined" ? this.state.lastMessage.tickTimeElapsed : -1} ignoreData={-1} suggestedYMin={995} suggestedYMax={1005} />
<StatsWidget title="Server Performance (RAM)" serverUptime={this.state.lastMessage.serverUptime} data={typeof this.state.lastMessage.getFreeMemory !== "undefined" ? bytesToMegabytes(this.state.lastMessage.getAllocatedMemory - this.state.lastMessage.getFreeMemory, 2) : -1} ignoreData={-1} suggestedYMin={0} suggestedYMax={bytesToMegabytes(this.state.lastMessage.getAllocatedMemory)} />
<StatsWidget title="Online Players" serverUptime={this.state.lastMessage.serverUptime} data={typeof this.state.lastMessage.playerCount !== "undefined" ? this.state.lastMessage.playerCount : -1} ignoreData={-1} suggestedYMin={0} suggestedYMax={10} />
</div> </div>
) </div>
} );
}
} }

View File

@ -0,0 +1,24 @@
import React from 'react';
export function ReactIsInDevelopmentMode() {
return '_self' in React.createElement('div');
}
export function formatBytesToString(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function bytesToMegabytes(bytes, decimals = 2) {
const dm = decimals < 0 ? 0 : decimals;
const k = 1024;
return parseFloat((bytes / Math.pow(k, 2)).toFixed(dm));
}