Websockets, Performance Monitoring, Refactor

This commit is contained in:
Benjamin Elsdon 2022-05-03 22:05:02 +08:00
parent 191704b39b
commit e6a94436dc
19 changed files with 350 additions and 114 deletions

View File

@ -4,9 +4,9 @@ GCGM is the first [Grasscutter](https://github.com/Grasscutters/Grasscutter) plu
## Currently Planned Features:
- [x] Loading basic web page
- [x] Nice looking CSS
- [ ] Websockets
- [x] Websockets (Although it is very basic)
- [ ] Widgets
- [ ] Server performance stats
- [x] Server performance stats
- [ ] See players registered to dispatch server
- [ ] See players currently online
- [ ] Send mail to all players
@ -17,8 +17,10 @@ The features listed are to achieve an MVP for the first release.
## Important Notes:
This plugin is made to run on the current [Development](https://github.com/Grasscutters/Grasscutter/tree/development) branch of Grasscutter. \
This plugin is in very early development and the web dashboard only displays a side panel, so it is not really in a usable state. \
**If you require support please ask on the [Grasscutter Discord](https://discord.gg/T5vZU6UyeG). However, support is not guarenteed.**
This plugin is in very early development and the web dashboard only displays a side panel and a performance graph. \
**If you require support please ask on the [Grasscutter Discord](https://discord.gg/T5vZU6UyeG). However, support is not guaranteed.** \
**If you encounter any issues, please report them on the [issue tracker](https://github.com/Grasscutters/gcgm-plugin/issues). However, please search to see if anyone else has encountered your issue before. Any duplicate issues will be closed.**
<h3 style="margin:0;padding:0;">THE ISSUE TRACKER IS NOT A SUPPORT FORM.</h3>
## Setup
### Download Plugin Jar
@ -31,13 +33,21 @@ Coming soon!
4. Assuming the build succeeded, in your file explorer navigate to the ``gc-plugin`` folder, you should have a ``gcgm-plugin.jar`` file, copy it.
5. Navigate to your ``Grasscutter`` server, find the ``plugins`` folder and paste the ``gcgm-plugin.jar`` into it.
6. Start your server.
7. Your server should then start and after a few seconds, you should be greated with these messages and the server will quit.
7. Your server should then start and after a few seconds, you should be greated with these messages.
```
[WARN] The './plugins/gcgm/www' folder does not exist.
[WARN] Please extract the contents of 'DefaultWebApp.zip' from within './plugins/gcgm' to './plugins/gcgm/www
[WARN] Your server will now exit to allow this process to be completed
[WARN] [GCGM] The './plugins/gcgm/www' folder does not exist.
[INFO] [GCGM] Copying 'DefaultWebApp.zip' to './plugins/GCGM'
[WARN] [GCGM] Please extract the contents of 'DefaultWebApp.zip' from within './plugins/GCGM' to './plugins/GCGM/www
[WARN] [GCGM] GCGM has now loaded...
...
[ERROR] [GCGM] GCGM could not find the 'www' folder inside its plugin directory
[ERROR] [GCGM] Please make sure a 'www' folder exists by extracting 'DefaultWebApp.zip' into a new 'www' or download a third-party dashboard
[ERROR] [GCGM] GCGM could not be enabled
```
7. Inside the ``plugins`` folder there now should be a new folder with the name of ``gcgm``, when you open the folder, there should be a ``.zip`` file called ``DefaultWebApp.zip``. Extract the contents of this zip into a folder called ``www``.
8. Type ``stop`` to stop the server
9. Inside the ``plugins`` folder there now should be a new folder with the name of ``gcgm``, when you open the folder, there should be file called ``DefaultWebApp.zip``. Extract the contents of this zip into a folder called ``www``.
10. Start your server
Your final plugins folder's directory structure should look similar to this
```

View File

@ -19,10 +19,6 @@ plugins {
sourceCompatibility = 17
targetCompatibility = 17
repositories {
mavenCentral()
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
@ -39,16 +35,6 @@ repositories {
dependencies {
implementation fileTree(dir: 'lib', include: ['*.jar'])
implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.8'
implementation group: 'dev.morphia.morphia', name: 'morphia-core', version: '2.2.6'
//implementation group: 'tech.xigam', name: 'grasscutter', version: '1.0.2-dev'
implementation 'io.jsonwebtoken:jjwt-api:0.11.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.3', 'io.jsonwebtoken:jjwt-gson:0.11.3'
implementation group: 'net.lingala.zip4j', name: 'zip4j', version: '2.10.0'
runtimeOnly project(':web-interface')
}
@ -83,14 +69,19 @@ jar {
from {
configurations.runtimeClasspath.collect {
if(it.name.equalsIgnoreCase("zip4j-2.10.0.jar")) {
if(!it.name.contains("grasscutter")) {
println ('Packaging ' + it.name)
zipTree(it);
it.isDirectory() ? it : zipTree(it)
}
}
} {
exclude('META-INF/LICENSE*')
exclude("META-INF/versions/9/module-info.class")
exclude("META-INF/NOTICE")
}
//duplicatesStrategy = DuplicatesStrategy.EXCLUDE
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
destinationDir = file(".")
}

View File

@ -1,26 +1,36 @@
package com.benj4.gcgm;
import com.benj4.gcgm.handlers.ServerTickHandler;
import com.benj4.gcgm.server.websocket.WebSocketServer;
import com.benj4.gcgm.utils.*;
import com.benj4.gcgm.utils.web.WebUtils;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.plugin.Plugin;
import emu.grasscutter.server.event.EventHandler;
import emu.grasscutter.server.event.HandlerPriority;
import emu.grasscutter.server.event.game.ServerTickEvent;
import emu.grasscutter.utils.Utils;
import express.Express;
import io.javalin.http.staticfiles.Location;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.io.File;
public class GCGMPlugin extends Plugin {
File webData;
private static GCGMPlugin INSTANCE;
EventHandler<ServerTickEvent> serverTickEventHandler;
private static WebSocketServer webSocketServer;
private File webData;
@Override
public void onLoad() {
File pluginDataDir = getDataFolder();
INSTANCE = this;
webData = new File(Utils.toFilePath(getDataFolder().getPath() + "/www"));
serverTickEventHandler = new EventHandler<ServerTickEvent>(ServerTickEvent.class);
serverTickEventHandler.listener(new ServerTickHandler());
serverTickEventHandler.priority(HandlerPriority.HIGH);
File pluginDataDir = getDataFolder();
String zipFileLoc = Utils.toFilePath(getDataFolder().getPath() + "/DefaultWebApp.zip");
if(!pluginDataDir.exists() && !pluginDataDir.mkdirs()) {
@ -30,35 +40,14 @@ public class GCGMPlugin extends Plugin {
if(!webData.exists()) {
Grasscutter.getLogger().warn("[GCGM] The './plugins/GCGM/www' folder does not exist.");
// Get the ZIP
URL url = null;
try {
url = new File(Utils.toFilePath(Grasscutter.getConfig().PLUGINS_FOLDER + "/gcgm-plugin.jar")).toURI().toURL();
} catch (MalformedURLException e) {
e.printStackTrace();
}
//URLClassLoader loader = new URLClassLoader(new URL[]{url});
InputStream defaultWebAppZip = getResource("DefaultWebApp.zip");
try {
// Copy the the zip from resources to the plugin's data directory
if(!new File(zipFileLoc).exists()) {
Grasscutter.getLogger().info("[GCGM] Copying 'DefaultWebApp.zip' to './plugins/GCGM'");
Files.copy(defaultWebAppZip, Paths.get(new File(zipFileLoc).toURI()), StandardCopyOption.REPLACE_EXISTING);
// Copy the the zip from resources to the plugin's data directory
if(!new File(zipFileLoc).exists()) {
if(GCGMUtils.CopyFile("DefaultWebApp.zip", zipFileLoc)) {
Grasscutter.getLogger().warn("[GCGM] Please extract the contents of 'DefaultWebApp.zip' from within './plugins/GCGM' to './plugins/GCGM/www");
} else {
Grasscutter.getLogger().error("[GCGM] GCGM cannot start due to setup errors.");
return;
}
Grasscutter.getLogger().warn("[GCGM] Please extract the contents of 'DefaultWebApp.zip' from within './plugins/GCGM' to './plugins/GCGM/www");
Grasscutter.getLogger().warn("[GCGM] Your server will now exit to allow this process to be completed");
System.exit(0);
} catch (Exception e) {
e.printStackTrace();
}
} else {
if(new File(zipFileLoc).exists()) {
Grasscutter.getLogger().info("[GCGM] Note: You can now safely delete 'DefaultWebApp.zip' from within './plugins/GCGM'");
}
}
@ -67,22 +56,33 @@ public class GCGMPlugin extends Plugin {
@Override
public void onEnable() {
Express app = Grasscutter.getDispatchServer().getServer();
if(webData.exists()) {
Express app = Grasscutter.getDispatchServer().getServer();
WebUtils.addStaticFiles(app, webData);
webSocketServer = new WebSocketServer();
webSocketServer.start(app);
app.raw().config.precompressStaticFiles = false;
app.raw().config.addStaticFiles("/gm", webData.getAbsolutePath(), Location.EXTERNAL);
app.raw().config.addSinglePageRoot("/gm", Utils.toFilePath(getDataFolder().getPath() + "/www/index.html"), Location.EXTERNAL);
Grasscutter.getPluginManager().registerListener(serverTickEventHandler);
Grasscutter.getLogger().info("[GCGM] GCGM Enabled");
Grasscutter.getLogger().info("[GCGM] You can access your GM panel by navigating to http" + (Grasscutter.getConfig().getDispatchOptions().FrontHTTPS ? "s" : "") + "://" +
(Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) +
":" + (Grasscutter.getConfig().getDispatchOptions().PublicPort != 0 ? Grasscutter.getConfig().getDispatchOptions().PublicPort : Grasscutter.getConfig().getDispatchOptions().Port) +
"/gm"
);
Grasscutter.getLogger().info("[GCGM] GCGM Enabled");
Grasscutter.getLogger().info("[GCGM] You can access your GM panel by navigating to " + GCGMUtils.GetDispatchAddress() + WebUtils.PAGE_ROOT);
} else {
Grasscutter.getLogger().error("[GCGM] GCGM could not find the 'www' folder inside its plugin directory");
Grasscutter.getLogger().error("[GCGM] Please make sure a 'www' folder exists by extracting 'DefaultWebApp.zip' into a new 'www' or download a third-party dashboard");
Grasscutter.getLogger().error("[GCGM] GCGM could not be enabled");
}
}
@Override
public void onDisable() {
Grasscutter.getLogger().info("[GCGM] GCGM Disabled");
}
public static GCGMPlugin GetInstance() {
return INSTANCE;
}
public static WebSocketServer getWebSocketServer() {
return webSocketServer;
}
}

View File

@ -0,0 +1,24 @@
package com.benj4.gcgm.handlers;
import com.benj4.gcgm.GCGMPlugin;
import com.benj4.gcgm.server.websocket.json.WSData;
import emu.grasscutter.server.event.game.ServerTickEvent;
import emu.grasscutter.utils.EventConsumer;
import java.time.Instant;
public class ServerTickHandler implements EventConsumer<ServerTickEvent> {
private static Instant lastTick;
@Override
public void consume(ServerTickEvent serverTickEvent) {
if(lastTick != null) {
Instant now = Instant.now();
long timeTaken = now.toEpochMilli() - lastTick.toEpochMilli();
GCGMPlugin.getWebSocketServer().broadcast(new WSData("tick", timeTaken));
lastTick = now;
} else {
lastTick = Instant.now();
}
}
}

View File

@ -0,0 +1,34 @@
package com.benj4.gcgm.server.websocket;
import com.benj4.gcgm.server.websocket.json.WSData;
import emu.grasscutter.Grasscutter;
import express.Express;
import io.javalin.websocket.WsContext;
import java.io.File;
import java.io.FileInputStream;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class WebSocketServer {
//SocketIOServer socketIOServer;
private static Map<WsContext, String> userUsernameMap = new ConcurrentHashMap<>();
public void start(Express app) {
app.ws("/gm", ws -> {
ws.onConnect(ctx -> {
String username = "Not logged in";
userUsernameMap.put(ctx, username);
Grasscutter.getLogger().info("[GCGM] User logged in to panel");
});
});
}
public void broadcast(WSData data) {
userUsernameMap.keySet().stream().filter(ctx -> ctx.session.isOpen()).forEach(session -> {
session.send(data);
});
}
}

View File

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

View File

@ -0,0 +1,31 @@
package com.benj4.gcgm.utils;
import com.benj4.gcgm.GCGMPlugin;
import emu.grasscutter.Grasscutter;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class GCGMUtils {
public static String GetDispatchAddress() {
return "http" + (Grasscutter.getConfig().getDispatchOptions().FrontHTTPS ? "s" : "") + "://" +
(Grasscutter.getConfig().getDispatchOptions().PublicIp.isEmpty() ? Grasscutter.getConfig().getDispatchOptions().Ip : Grasscutter.getConfig().getDispatchOptions().PublicIp) +
":" + (Grasscutter.getConfig().getDispatchOptions().PublicPort != 0 ? Grasscutter.getConfig().getDispatchOptions().PublicPort : Grasscutter.getConfig().getDispatchOptions().Port);
}
public static boolean CopyFile(String resourceName, String copyLocation) {
try {
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);
return true;
} catch (IOException e) {
Grasscutter.getLogger().error(String.format("[GCGM] An error occurred while trying to copy '%s' to '%s'", resourceName, copyLocation));
e.printStackTrace();
return false;
}
}
}

View File

@ -0,0 +1,20 @@
package com.benj4.gcgm.utils.web;
import emu.grasscutter.Grasscutter;
import emu.grasscutter.utils.Utils;
import express.Express;
import io.javalin.http.staticfiles.Location;
import java.io.File;
import java.util.HashMap;
public class WebUtils {
public static final String PAGE_ROOT = "/gm";
public static void addStaticFiles(Express app, File staticRoot) {
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.addSinglePageRoot(PAGE_ROOT, Utils.toFilePath(staticRoot.getPath() + "/index.html"), Location.EXTERNAL);
}
}

View File

@ -14,10 +14,13 @@
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0",
"chart.js": "^3.7.1",
"cross-env": "^7.0.3",
"react": "^18.0.0",
"react-chartjs-2": "^4.1.0",
"react-dom": "^18.0.0",
"react-scripts": "5.0.1",
"react-use-websocket": "^3.0.0",
"web-vitals": "^2.1.4"
}
},
@ -5115,6 +5118,11 @@
"node": ">=6"
}
},
"node_modules/chart.js": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz",
"integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA=="
},
"node_modules/check-types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz",
@ -13321,6 +13329,15 @@
"node": ">=14"
}
},
"node_modules/react-chartjs-2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.1.0.tgz",
"integrity": "sha512-AsUihxEp8Jm1oBhbEovE+w50m9PVNhz1sfwEIT4hZduRC0m14gHWHd0cUaxkFDb8HNkdMIGzsNlmVqKiOpU74g==",
"peerDependencies": {
"chart.js": "^3.5.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@ -13540,6 +13557,15 @@
}
}
},
"node_modules/react-use-websocket": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-3.0.0.tgz",
"integrity": "sha512-BInlbhXYrODBPKIplDAmI0J1VPM+1KhCLN09o+dzgQ8qMyrYs4t5kEYmCrTqyRuMTmpahylHFZWQXpfYyDkqOw==",
"peerDependencies": {
"react": ">= 16.8.0",
"react-dom": ">= 16.8.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
@ -19858,6 +19884,11 @@
"resolved": "https://registry.npmjs.org/charcodes/-/charcodes-0.2.0.tgz",
"integrity": "sha512-Y4kiDb+AM4Ecy58YkuZrrSRJBDQdQ2L+NyS1vHHFtNtUjgutcZfx3yp1dAONI/oPaPmyGfCLx5CxL+zauIMyKQ=="
},
"chart.js": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz",
"integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA=="
},
"check-types": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz",
@ -25652,6 +25683,12 @@
"whatwg-fetch": "^3.6.2"
}
},
"react-chartjs-2": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.1.0.tgz",
"integrity": "sha512-AsUihxEp8Jm1oBhbEovE+w50m9PVNhz1sfwEIT4hZduRC0m14gHWHd0cUaxkFDb8HNkdMIGzsNlmVqKiOpU74g==",
"requires": {}
},
"react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@ -25817,6 +25854,12 @@
"workbox-webpack-plugin": "^6.4.1"
}
},
"react-use-websocket": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-3.0.0.tgz",
"integrity": "sha512-BInlbhXYrODBPKIplDAmI0J1VPM+1KhCLN09o+dzgQ8qMyrYs4t5kEYmCrTqyRuMTmpahylHFZWQXpfYyDkqOw==",
"requires": {}
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",

View File

@ -9,10 +9,13 @@
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0",
"chart.js": "^3.7.1",
"cross-env": "^7.0.3",
"react": "^18.0.0",
"react-chartjs-2": "^4.1.0",
"react-dom": "^18.0.0",
"react-scripts": "5.0.1",
"react-use-websocket": "^3.0.0",
"web-vitals": "^2.1.4"
},
"scripts": {

View File

@ -1,38 +0,0 @@
.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

@ -1,4 +1,3 @@
import './App.css';
import Dashboard from './Dashboard';
function App() {

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react'
import gclogo from "../img/grasscutter-icon.png"
import gclogo from "../../../img/grasscutter-icon.png"
import NavigationButton from './NavigationButton'
import SimpleButton from './SimpleButton'
import { faArrowLeft, faCogs, faGamepad, faHome, faNetworkWired } from '@fortawesome/free-solid-svg-icons'

View File

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

View File

@ -0,0 +1,94 @@
import React, { useState, useEffect } from "react";
import {
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 "./StatsWidget.css";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
);
const CHART_COLORS = {
red: 'rgb(255, 99, 132)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(75, 192, 192)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)'
};
const TICK_COUNT = 50;
const labels = [];
for (let i = 0; i <= TICK_COUNT; ++i) {
labels.push(i.toString());
}
export default function StatsWidget() {
const protocol = window.location.protocol === "https:" ? "wss" : "wss"
const host = window.location.host;
const port = window.location.port;
const { sendMessage, lastMessage, readyState } = useWebSocket(protocol + "://"+ host +":" + port + "/gm");
const [graphHistory, setGraphHistory] = useState([]);
useEffect(() => {
if (lastMessage !== null) {
const data = JSON.parse(lastMessage.data);
var newGraph = graphHistory;
newGraph.push({ time: new Date().getTime(), tick: data.object});
if(newGraph.length > TICK_COUNT) {
newGraph.splice(0, 1);
}
setGraphHistory(newGraph);
}
}, [lastMessage, setGraphHistory]);
/*const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
}[readyState];*/
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

@ -1,5 +1,6 @@
import React, { Component } from 'react'
import Sidepanel from './Components/Sidepanel'
import Sidepanel from './Components/Dashboard/Sidepanel/Sidepanel'
import StatsWidget from './Components/Dashboard/StatsWidget'
import './Dashboard.css'
@ -8,6 +9,7 @@ export default class Dashboard extends Component {
return (
<div className='dashboard'>
<Sidepanel />
<StatsWidget />
</div>
)
}