(feature) Update UI & Resource Availability

This commit is contained in:
Elijah Dunemask 2023-03-15 15:20:08 +00:00
parent 11d8229eb5
commit 929193d272
44 changed files with 4747 additions and 27 deletions

View file

@ -1,16 +1,20 @@
// Imports
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SettingsProvider } from "@mcl/settings";
import Viewport from "./nav/Viewport.jsx";
import { BrowserRouter } from "react-router-dom";
// Create a query client for the app
const queryClient = new QueryClient();
// Export Minecluster
export default function MCL() {
return (
<div className="mcl">
<div className="minecluster">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<h1>Welcome to Minecluster!</h1>
</BrowserRouter>
<SettingsProvider>
<BrowserRouter>
<Viewport />
</BrowserRouter>
</SettingsProvider>
</QueryClientProvider>
</div>
);

View file

@ -0,0 +1,62 @@
import React, { useReducer, createContext, useMemo } from "react";
const SettingsContext = createContext();
const ACTIONS = {
UPDATE: "u",
};
const localSettings = localStorage.getItem("settings");
const defaultSettings = {
simplifiedControls: false,
logAppDetails: true,
defaultPage: "home",
};
const settings = localSettings ? JSON.parse(localSettings) : defaultSettings;
const settingsKeys = Object.keys(defaultSettings);
const initialState = {
pages: ["home"],
...settings,
};
const settingsUpdater = (oldState, settingsUpdate) => {
const settingsToUpdate = {};
for (var k of settingsKeys) {
settingsToUpdate[k] = oldState[k];
if (settingsUpdate[k] === undefined) continue;
settingsToUpdate[k] = settingsUpdate[k];
}
localStorage.setItem("settings", JSON.stringify(settingsToUpdate));
};
const reducer = (state, action) => {
const { settings } = action;
// Actions
switch (action.type) {
case ACTIONS.UPDATE:
settingsUpdater(state, settings);
return { ...state, ...settings };
default:
return state;
}
};
export const SettingsProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const context = {
state,
dispatch,
updateSettings: (settings) => dispatch({ type: ACTIONS.UPDATE, settings }),
};
const contextValue = useMemo(() => context, [state, dispatch]);
return (
<SettingsContext.Provider value={contextValue}>
{children}
</SettingsContext.Provider>
);
};
export default SettingsContext;

85
src/nav/MCLMenu.jsx Normal file
View file

@ -0,0 +1,85 @@
// React imports
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
// Internal Imports
import pages from "./MCLPages.jsx";
// Materialui
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import MenuIcon from "@mui/icons-material/Menu";
import Drawer from "@mui/material/Drawer";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
const drawerWidth = 250;
export default function MCLMenu() {
const location = useLocation();
const [drawerOpen, setDrawer] = useState(false);
const toggleDrawer = () => setDrawer(!drawerOpen);
const closeDrawer = () => setDrawer(false);
const navHeader = () => {
const name = location.pathname.split("/").pop();
const pathStr = name.charAt(0).toUpperCase() + name.slice(1);
return pathStr;
};
const drawerIndex = (isDrawer) => (theme) =>
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
return (
<AppBar position="fixed" sx={{ bgcolor: "black", zIndex: drawerIndex() }}>
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
<Toolbar disableGutters>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={toggleDrawer}
>
<MenuIcon />
</IconButton>
<Drawer
open={drawerOpen}
onClose={closeDrawer}
sx={{ zIndex: drawerIndex(true) }}
>
<Toolbar />
<Box
sx={{ width: drawerWidth, overflow: "auto" }}
role="presentation"
>
<List>
{pages.map((page, index) => (
<ListItemButton
key={index}
component={Link}
to={page.path}
selected={location.pathname === page.path}
onClick={closeDrawer}
>
<ListItemIcon>{page.icon}</ListItemIcon>
<ListItemText primary={page.name} />
</ListItemButton>
))}
</List>
</Box>
</Drawer>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
{navHeader()}
</Typography>
</Toolbar>
</Box>
</AppBar>
);
}

20
src/nav/MCLPages.jsx Normal file
View file

@ -0,0 +1,20 @@
import Home from "@mcl/pages/Home.jsx";
import Create from "@mcl/pages/Create.jsx";
// Go To https://mui.com/material-ui/material-icons/ for more!
import HomeIcon from "@mui/icons-material/Home";
import AddIcon from "@mui/icons-material/Add";
export default [
{
name: "Home",
path: "/mcl/home",
icon: <HomeIcon />,
component: <Home />,
},
{
name: "Create",
path: "/mcl/create",
icon: <AddIcon />,
component: <Create />,
},
];

18
src/nav/MCLPortal.jsx Normal file
View file

@ -0,0 +1,18 @@
// Import React
import { Routes, Route, Navigate } from "react-router-dom";
import pages from "./MCLPages.jsx";
const defaultPage = pages[0].path;
export default function MCLPortal() {
return (
<Routes>
<Route exact path="/mcl/" element={<Navigate to={defaultPage} />} />
<Route exact path="/" element={<Navigate to={defaultPage} />} />
{pages.map((p, i) => (
<Route key={i} path={p.path} element={p.component} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

16
src/nav/Viewport.jsx Normal file
View file

@ -0,0 +1,16 @@
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import MCLPortal from "./MCLPortal.jsx";
// Import Navbar
/*import Navbar from "./Navbar.jsx";*/
import MCLMenu from "./MCLMenu.jsx";
export default function Views() {
return (
<div className="view">
<MCLMenu />
<Toolbar />
<MCLPortal />
</div>
);
}

23
src/overview/Overview.jsx Normal file
View file

@ -0,0 +1,23 @@
import { useState, useEffect } from "react";
import { useSystemAvailable } from "@mcl/queries";
import Box from "@mui/material/Box";
import OverviewVisual from "./OverviewVisual.jsx";
export default function Overview(props) {
const [memory, setMemory] = useState(100);
const [cpu, setCpu] = useState(100);
const { isLoading: systemLoading, data: systemAvailable } =
useSystemAvailable();
useEffect(() => {
if (systemLoading || !props.clusterMetrics) return;
setCpu((props.clusterMetrics.cpu / systemAvailable.cpu) * 100);
setMemory((props.clusterMetrics.memory / systemAvailable.memory) * 100);
}, [systemAvailable, props.clusterMetrics]);
return (
<Box className="overview-toolbar">
<OverviewVisual value={cpu} color="warning" label="CPU" />
<OverviewVisual value={memory} color="success" label="MEMORY" />
</Box>
);
}

View file

@ -0,0 +1,44 @@
import * as React from "react";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
export default function OverviewVisual(props) {
const { value, color, label } = props;
return (
<Box className="overview-visual-wrapper">
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
variant="determinate"
value={value}
color={color}
size="6.25rem"
className="overview-visual-display"
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="h4" component="div">
{`${Math.round(value)}%`}
</Typography>
</Box>
</Box>
<Typography
variant="h5"
component="div"
className="overview-visual-label"
>
{label}
</Typography>
</Box>
);
}

152
src/pages/Create.jsx Normal file
View file

@ -0,0 +1,152 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import InputLabel from "@mui/material/InputLabel";
import FormControl from "@mui/material/FormControl";
import { useCreateServer, useVersionList } from "@mcl/queries";
const defaultServer = {
version: "latest",
name: "example",
serverType: "VANILLA",
difficulty: "easy",
maxPlayers: "20",
gamemode: "survival",
memory: "1024",
motd: "Minecluster Server Hosting",
};
export default function Create() {
const [spec, setSpec] = useState(defaultServer);
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
};
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
if (!spec.url) return alertValidationError("Url cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
return (
<Box className="create">
<FormControl fullWidth>
<TextField
label="Name"
onChange={coreUpdate("name")}
defaultValue={spec.name}
required
/>
<TextField label="URL" onChange={coreUpdate("url")} required />
<TextField
label="Version"
onChange={coreUpdate("version")}
value={spec.version}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "12rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
<TextField
label="Server Type"
onChange={coreUpdate("serverType")}
value={spec.serverType}
select
required
>
<MenuItem value={"VANILLA"}>Vanilla</MenuItem>
<MenuItem value={"FABRIC"}>Fabric</MenuItem>
<MenuItem value={"PAPER"}>Paper</MenuItem>
<MenuItem value={"SPIGOT"}>Spigot</MenuItem>
</TextField>
<TextField
label="Difficulty"
onChange={coreUpdate("difficulty")}
value={spec.difficulty}
select
required
>
<MenuItem value={"peaceful"}>Peaceful</MenuItem>
<MenuItem value={"easy"}>Easy</MenuItem>
<MenuItem value={"medium"}>Medium</MenuItem>
<MenuItem value={"hard"}>Hard</MenuItem>
</TextField>
<TextField label="Whitelist" onChange={coreUpdate("whitelist")} />
<TextField label="Ops" onChange={coreUpdate("ops")} />
<TextField label="Icon" onChange={coreUpdate("icon")} required />
<TextField
label="Max Players"
onChange={coreUpdate("maxPlayers")}
defaultValue={spec.maxPlayers}
required
/>
<TextField
label="Gamemode"
onChange={coreUpdate("gamemode")}
value={spec.gamemode}
select
required
>
<MenuItem value={"survival"}>Survival</MenuItem>
<MenuItem value={"creative"}>Creative</MenuItem>
<MenuItem value={"adventure"}>Adventure</MenuItem>
<MenuItem value={"spectator"}>Spectator</MenuItem>
</TextField>
<TextField label="Seed" onChange={coreUpdate("seed")} />
<TextField label="Modpack" onChange={coreUpdate("modpack")} />
<TextField
label="Memory"
onChange={coreUpdate("memory")}
defaultValue={spec.memory}
required
/>
<TextField
label="MOTD"
onChange={coreUpdate("motd")}
defaultValue={spec.motd}
required
/>
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
</Box>
);
}

51
src/pages/Home.jsx Normal file
View file

@ -0,0 +1,51 @@
import { useState, useEffect } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import ServerCard from "../servers/ServerCard.jsx";
import RconDialog, { useRconDialog } from "../servers/RconDialog.jsx";
import Overview from "../overview/Overview.jsx";
import "@mcl/css/server-card.css";
import "@mcl/css/overview.css";
import { useServerInstances } from "@mcl/queries";
export default function Home() {
const [server, setServer] = useState();
const [servers, setServers] = useState([]);
const [rdOpen, rconToggle] = useRconDialog();
const { isLoading, data: serversData } = useServerInstances();
const { servers: serverInstances, clusterMetrics } = serversData ?? {};
useEffect(() => {
if (!serverInstances) return;
serverInstances.sort((a, b) => a.name.localeCompare(b.name));
setServers(serverInstances);
}, [serverInstances]);
const openRcon = (s) => () => {
setServer(s);
rconToggle();
};
return (
<Box className="home">
<Overview clusterMetrics={clusterMetrics} />
{!isLoading && servers.length === 0 && (
<Box display="flex" alignItems="center" justifyContent="center">
<Typography variant="h4" sx={{ textAlign: "center" }}>
No servers found!
</Typography>
</Box>
)}
<Box className="servers">
{!isLoading &&
servers.map((s, k) => (
<ServerCard key={k} server={s} openRcon={openRcon(s.name)} />
))}
</Box>
<RconDialog
keepMounted
open={rdOpen}
dialogToggle={rconToggle}
serverName={server}
/>
</Box>
);
}

View file

@ -0,0 +1,45 @@
import { useState } from "react";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Dialog from "@mui/material/Dialog";
import Toolbar from "@mui/material/Toolbar";
import RconView from "./RconView.jsx";
export function useRconDialog(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const dialogToggle = () => setOpen(!open);
return [open, dialogToggle];
}
export default function RconDialog(props) {
const { serverName, open, dialogToggle } = props;
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Dialog
sx={
fullScreen
? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
}
maxWidth="xs"
open={open}
fullScreen={fullScreen}
>
<Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>RCON - {serverName}</DialogTitle>
<DialogContent>
<RconView serverName={serverName} />
</DialogContent>
<DialogActions>
<Button autoFocus onClick={dialogToggle}>
Close
</Button>
</DialogActions>
</Dialog>
);
}

27
src/servers/RconSocket.js Normal file
View file

@ -0,0 +1,27 @@
import { io } from "socket.io-client";
export default class RconSocket {
constructor(logUpdate, serverName) {
(this.sk = io("/", { query: { serverName } })), (this.logs = []);
this.logUpdate = logUpdate;
this.sk.on("push", this.onPush.bind(this));
this.sk.on("connect", this.onConnect.bind(this));
}
onPush(p) {
this.logs = [...this.logs, p];
this.logUpdate(this.logs);
}
send(m) {
this.sk.emit("msg", m);
}
onConnect() {
this.logs = [];
}
disconnect() {
if (!this.sk) return;
this.sk.disconnect();
}
}

53
src/servers/RconView.jsx Normal file
View file

@ -0,0 +1,53 @@
import { useState, useEffect, useRef } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import RconSocket from "./RconSocket.js";
import "@mcl/css/rcon.css";
export default function RconView(props) {
const { serverName } = props;
const logsRef = useRef(0);
const [cmd, setCmd] = useState("");
const [logs, setLogs] = useState([]);
const [rcon, setRcon] = useState({});
const updateCmd = (e) => setCmd(e.target.value);
useEffect(function () {
setRcon(new RconSocket(setLogs, serverName));
return () => {
if (rcon && typeof rcon.disconnect === "function") rcon.disconnect();
};
}, []);
useEffect(() => {
logsRef.current.scrollTo(0, logsRef.current.scrollHeight);
}, [rcon.logs]);
function sendCommand() {
rcon.send(cmd);
setCmd("");
}
return (
<Box>
<div className="rconLogsWrapper" ref={logsRef}>
{logs.map((v, k) => (
<Box key={k}>
{v}
<br />
</Box>
))}
</div>
<Box className="rconActions">
<TextField
id="outlined-basic"
label="Command"
variant="outlined"
value={cmd}
onChange={updateCmd}
/>
<Button onClick={sendCommand}>Send</Button>
</Box>
</Box>
);
}

112
src/servers/ServerCard.jsx Normal file
View file

@ -0,0 +1,112 @@
import React from "react";
import { useStartServer, useStopServer, useDeleteServer } from "@mcl/queries";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardActions from "@mui/material/CardActions";
import CardContent from "@mui/material/CardContent";
import Chip from "@mui/material/Chip";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import StopIcon from "@mui/icons-material/Stop";
import TerminalIcon from "@mui/icons-material/Terminal";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PendingIcon from "@mui/icons-material/Pending";
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
import EditIcon from "@mui/icons-material/Edit";
export default function ServerCard(props) {
const { server, openRcon } = props;
const { name, metrics, started } = server;
const startServer = useStartServer(name);
const stopServer = useStopServer(name);
const deleteServer = useDeleteServer(name);
function toggleRcon() {
if (!started) return;
openRcon();
}
return (
<Card className="server-card">
<CardContent className="server-card-header server-card-element">
<Typography
gutterBottom
variant="h5"
component="div"
className="server-card-title"
>
{name}
</Typography>
{metrics && (
<Box className="server-card-metrics">
<Typography
gutterBottom
variant="body2"
component="div"
className="server-card-metrics-info"
>
CPU: {metrics.cpu}
</Typography>
<Typography
gutterBottom
variant="body2"
component="div"
className="server-card-metrics-info"
>
MEM: {metrics.memory}MB
</Typography>
</Box>
)}
<Chip
label={started ? "Online" : "Offline"}
color={started ? "success" : "error"}
className="server-card-status-indicator"
/>
</CardContent>
<div className="server-card-actions-wrapper">
<CardActions className="server-card-actions server-card-element">
{started && (
<IconButton
color="error"
aria-label="Stop Server"
onClick={stopServer}
size="large"
>
<StopIcon />
</IconButton>
)}
{!started && (
<IconButton
color="success"
aria-label="Start Server"
onClick={startServer}
size="large"
>
<PlayArrowIcon />
</IconButton>
)}
<IconButton
color="primary"
aria-label="RCON"
onClick={toggleRcon}
size="large"
disabled={!started}
>
<TerminalIcon />
</IconButton>
<IconButton color="primary" aria-label="Edit" size="large">
<EditIcon />
</IconButton>
<IconButton
color="error"
aria-label="Delete Server"
onClick={deleteServer}
size="large"
>
<DeleteForeverIcon />
</IconButton>
</CardActions>
</div>
</Card>
);
}

60
src/util/queries.js Normal file
View file

@ -0,0 +1,60 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
const fetchApi = (subPath) => async () =>
fetch(`/api${subPath}`).then((res) => res.json());
const fetchApiPost = (subPath, json) => async () =>
fetch(`/api${subPath}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(json),
}).then((res) => res.json());
export const useServerStatus = (server) =>
useQuery(
[`server-status-${server}`],
fetchApiPost("/server/status", { name: server })
);
export const useServerMetrics = (server) =>
useQuery(
[`server-metrics-${server}`],
fetchApiPost("/server/metrics", { name: server }),
{ refetchInterval: 10000 }
);
export const useStartServer = (server) =>
postJsonApi("/server/start", { name: server }, "server-instances");
export const useStopServer = (server) =>
postJsonApi("/server/stop", { name: server }, "server-instances");
export const useDeleteServer = (server) =>
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
export const useCreateServer = (spec) =>
postJsonApi("/server/create", spec, "server-list");
export const useServerList = () =>
useQuery(["server-list"], fetchApi("/server/list"));
export const useServerInstances = () =>
useQuery(["server-instances"], fetchApi("/server/instances"), {
refetchInterval: 5000,
});
export const useSystemAvailable = () =>
useQuery(["system-available"], fetchApi("/system/available"));
export const useVersionList = () =>
useQuery(["minecraft-versions"], () =>
fetch("https://piston-meta.mojang.com/mc/game/version_manifest.json").then(
(r) => r.json()
)
);
const postJsonApi = (subPath, body, invalidate, method = "POST") => {
const qc = useQueryClient();
return async () => {
const res = await fetch(`/api${subPath}`, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
qc.invalidateQueries([invalidate]);
};
};