[FEATURE] Migrated to new loading sequence (#6)

Co-authored-by: Dunemask <dunemask@gmail.com>
Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/6
This commit is contained in:
dunemask 2024-01-15 20:30:31 +00:00
parent fb57c03ba7
commit 6eb4ed3e95
53 changed files with 1349 additions and 449 deletions

View file

@ -0,0 +1,74 @@
import { useState, useEffect } 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";
const textFileTypes = ["properties", "txt", "yaml", "yml", "json", "env"];
const imageFileTypes = ["png", "jpeg", "jpg"];
export const supportedFileTypes = [...textFileTypes, ...imageFileTypes];
export function useFilePreview(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const dialogToggle = () => setOpen(!open);
return [open, dialogToggle];
}
function TextPreview(props) {
const { fileText } = props;
return <div style={{ whiteSpace: "break-spaces" }}>{fileText}</div>;
}
export default function FilePreview(props) {
const [fileText, setFileText] = useState();
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
const { previewData, open, dialogToggle } = props;
const { fileData, name } = previewData ?? {};
const ext = name ? name.split(".").pop() : null;
const isTextFile = textFileTypes.includes(ext);
async function onPreviewChange() {
if (isTextFile) setFileText(await fileData.text());
}
useEffect(() => {
onPreviewChange();
}, [fileData]);
return (
<Dialog
sx={
fullScreen
? {}
: {
"& .mcl-MuiDialog-paper": {
width: "100%",
maxHeight: 525,
maxWidth: "80%",
},
}
}
maxWidth="xs"
open={open}
fullScreen={fullScreen}
>
<Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>{name}</DialogTitle>
<DialogContent>
<TextPreview fileText={fileText} />
</DialogContent>
<DialogActions>
<Button autoFocus onClick={dialogToggle}>
Close
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -17,8 +17,9 @@ import {
deleteServerItem,
getServerItem,
} from "@mcl/queries";
import { previewServerItem } from "../../util/queries";
import "@mcl/css/header.css";
import { supportedFileTypes } from "./FilePreview.jsx";
export default function MineclusterFiles(props) {
// Chonky configuration
@ -33,17 +34,23 @@ export default function MineclusterFiles(props) {
],
[],
);
const { server: serverName } = props;
const { server: serverId, changePreview } = props;
const inputRef = useRef(null);
const [dirStack, setDirStack] = useState(["."]);
const [files, setFiles] = useState([]);
const updateFiles = () => {
const dir = dirStack.join("/");
getServerFiles(serverName, dir).then((f) => {
const files = f.map((fi) => ({ ...fi, id: `${dir}/${fi.name}` }));
setFiles(files ?? []);
});
getServerFiles(serverId, dir)
.then((f) => {
const files = f.map((fi) => ({ ...fi, id: `${dir}/${fi.name}` }));
setFiles(files ?? []);
})
.catch(() =>
console.error(
"Couldn't update files, server likely hasn't started yet",
),
);
};
useEffect(() => {
@ -61,22 +68,25 @@ export default function MineclusterFiles(props) {
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
function openFolder(payload) {
function openItem(payload) {
const { targetFile: file } = payload;
if (file && file.isDir) return setDirStack(file.id.split("/"));
if (file && !file.isDir) return downloadFiles([file]);
if (!file || file.isDir) return; // Ensure file exists or is dir
if (supportedFileTypes.includes(file.name.split(".").pop()))
return previewFile(file);
return downloadFiles([file]);
}
function createFolder() {
const name = prompt("What is the name of the new folder?");
const path = [...dirStack, name].join("/");
createServerFolder(serverName, path).then(updateFiles);
createServerFolder(serverId, path).then(updateFiles);
}
function deleteItems(files) {
Promise.all(
files.map((f) =>
deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir),
deleteServerItem(serverId, [...dirStack, f.name].join("/"), f.isDir),
),
)
.catch((e) => console.error("Error deleting some files!", e))
@ -94,7 +104,7 @@ export default function MineclusterFiles(props) {
async function uploadFile(file) {
const formData = new FormData();
formData.append("file", file);
formData.append("name", serverName);
formData.append("id", serverId);
formData.append("path", [...dirStack, name].join("/"));
await fetch("/api/files/upload", {
method: "POST",
@ -105,13 +115,20 @@ export default function MineclusterFiles(props) {
async function downloadFiles(files) {
Promise.all(
files.map((f) =>
getServerItem(serverName, f.name, [...dirStack, f.name].join("/")),
getServerItem(serverId, f.name, [...dirStack, f.name].join("/")),
),
)
.then(() => console.log("Done downloading files!"))
.catch((e) => console.error("Error Downloading files!", e));
}
function previewFile(file) {
const { name } = file;
previewServerItem(serverId, [...dirStack, name].join("/")).then(
(fileData) => changePreview(name, fileData),
);
}
function fileClick(chonkyEvent) {
const { id: clickEvent, payload } = chonkyEvent;
if (clickEvent === "open_parent_folder") return openParentFolder();
@ -122,7 +139,7 @@ export default function MineclusterFiles(props) {
if (clickEvent === "delete_files")
return deleteItems(chonkyEvent.state.selectedFilesForAction);
if (clickEvent !== "open_files") return; // console.log(clickEvent);
openFolder(payload);
openItem(payload);
}
return (
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
@ -134,7 +151,6 @@ export default function MineclusterFiles(props) {
onChange={uploadFileSelection}
multiple
/>
<FileBrowser
files={files}
folderChain={getFolderChain()}
@ -144,6 +160,7 @@ export default function MineclusterFiles(props) {
>
<FileNavbar />
<FileToolbar />
<FileList />
<FileContextMenu />
</FileBrowser>

View file

@ -0,0 +1,15 @@
import TextField from "@mui/material/TextField";
export default function BackupBucketOption(props) {
const { value, onChange } = props;
return (
<TextField
label="Bucket Path"
onChange={onChange}
defaultValue={value}
helperText="Example: /minecraft-backups/example-backups"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,14 @@
import TextField from "@mui/material/TextField";
export default function BackupHostOption(props) {
const { onChange } = props;
return (
<TextField
label="Backup Host"
onChange={onChange}
helperText="Example: s3.mydomain.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,14 @@
import TextField from "@mui/material/TextField";
export default function BackupIdOption(props) {
const { onChange } = props;
return (
<TextField
label="S3 Access Key ID"
onChange={onChange}
helperText="Example: s3-access-key-id"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,55 @@
import { useState } from "react";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
const backupIntervalStepDisplay = ["Minutes", "Hours", "Days"];
export const backupIntervalDefault = "1d";
export const backupIntervalStepOptions = ["m", "h", "d"];
export default function BackupIntervalOption(props) {
const { onChange } = props;
const [interval, setInterval] = useState(1);
const [intervalStep, setIntervalStep] = useState(
backupIntervalStepOptions[2],
);
const changeStep = (e) => {
setIntervalStep(e.target.value);
onChange({ target: { value: `${interval}${e.target.value}` } });
};
const changeInterval = (e) => {
setInterval(e.target.value);
onChange({ target: { value: `${e.target.value}${intervalStep}` } });
};
return (
<Box>
<TextField
label="Backup Interval"
sx={{ width: "70%" }}
value={interval}
onChange={changeInterval}
helperText="Examples: 1m, 3h, 3.5d"
FormHelperTextProps={{ sx: { ml: 0 } }}
type="number"
required
/>
<TextField
label="Step"
sx={{ width: "30%", minWidth: "4rem" }}
onChange={onChange}
value={intervalStep}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{backupIntervalStepOptions.map((o, i) => (
<MenuItem value={o} key={i}>
{backupIntervalStepDisplay[i]}
</MenuItem>
))}
</TextField>
</Box>
);
}

View file

@ -0,0 +1,14 @@
import TextField from "@mui/material/TextField";
export default function BackupKeyOption(props) {
const { onChange } = props;
return (
<TextField
label="S3 Access Key"
onChange={onChange}
helperText="Example: s3-access-key"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,26 @@
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
const maxCpuSupported = 8;
export const cpuOptions = new Array(2 * maxCpuSupported)
.fill(0)
.map((v, i) => (i + 1) * 0.5);
export default function CpuOption(props) {
const { value, onChange } = props;
return (
<TextField
label="CPU"
onChange={onChange}
value={value}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
disabled // TODO: Enable on backend support
>
{cpuOptions.map((o, i) => (
<MenuItem value={o} key={i}>{`${o} CPU`}</MenuItem>
))}
</TextField>
);
}

View file

@ -0,0 +1,14 @@
import TextField from "@mui/material/TextField";
export default function HostOption(props) {
const { onChange } = props;
return (
<TextField
label="Host"
onChange={onChange}
helperText="Example: host.mydomain.com"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,24 @@
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
const maxMemSupported = 10;
export const memoryOptions = new Array(2 * maxMemSupported)
.fill(0)
.map((v, i) => (i + 1) * 512);
export default function Option(props) {
const { value, onChange } = props;
return (
<TextField
label="Memory"
onChange={onChange}
value={value}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{memoryOptions.map((o, i) => (
<MenuItem value={o} key={i}>{`${o / 1024} Gi`}</MenuItem>
))}
</TextField>
);
}

View file

@ -0,0 +1,14 @@
import TextField from "@mui/material/TextField";
export default function NameOption(props) {
const { onChange } = props;
return (
<TextField
label="Name"
onChange={onChange}
helperText="Example: My Survival World"
FormHelperTextProps={{ sx: { ml: 0 } }}
required
/>
);
}

View file

@ -0,0 +1,25 @@
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
const displayOption = (o) => o.charAt(0) + o.toLowerCase().slice(1);
export const serverTypeOptions = ["VANILLA", "FABRIC", "PAPER", "SPIGOT"];
export default function ServerTypeOption(props) {
const { value, onChange } = props;
return (
<TextField
label="Memory"
onChange={onChange}
value={value}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{serverTypeOptions.map((o, i) => (
<MenuItem value={o} key={i}>
{displayOption(o)}
</MenuItem>
))}
</TextField>
);
}

View file

@ -0,0 +1,37 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import { useVersionList } from "@mcl/queries";
export default function VersionOption(props) {
const { value, onChange } = props;
const versionList = useVersionList();
const [versions, setVersions] = useState(["latest"]);
useEffect(() => {
if (!versionList.data) return;
setVersions([
"latest",
...versionList.data.versions
.filter(({ type: releaseType }) => releaseType === "release")
.map(({ id }) => id),
]);
}, [versionList.data]);
return (
<TextField
label="Version"
onChange={onChange}
value={value}
select
required
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
>
{versions.map((v, k) => (
<MenuItem value={v} key={k}>
{v}
</MenuItem>
))}
</TextField>
);
}

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
@ -16,15 +16,17 @@ export function useRconDialog(isOpen = false) {
}
export default function RconDialog(props) {
const { serverName, open, dialogToggle } = props;
const { server, open, dialogToggle } = props;
const { name: serverName, id: serverId } = server ?? {};
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Dialog
sx={
fullScreen
? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
: { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 555 } }
}
maxWidth="xs"
open={open}
@ -33,7 +35,7 @@ export default function RconDialog(props) {
<Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>RCON - {serverName}</DialogTitle>
<DialogContent>
<RconView serverName={serverName} />
<RconView serverId={serverId} />
</DialogContent>
<DialogActions>
<Button autoFocus onClick={dialogToggle}>

View file

@ -1,13 +1,17 @@
import { io } from "socket.io-client";
export default class RconSocket {
constructor(logUpdate, serverName) {
(this.sk = io("/", { query: { serverName } })), (this.logs = []);
constructor(logUpdate, serverId) {
(this.sk = io("/", { query: { serverId } })), (this.logs = []);
this.logUpdate = logUpdate;
this.sk.on("push", this.onPush.bind(this));
this.sk.on("connect", this.onConnect.bind(this));
this.sk.on("rcon-error", this.onRconError.bind(this));
this.sk.on("error", () => console.log("WHOOSPSIE I GUESS?"));
this.rconLive = false;
}
onPush(p) {
this.rconLive = true;
this.logs = [...this.logs, p];
this.logUpdate(this.logs);
}
@ -16,7 +20,13 @@ export default class RconSocket {
this.sk.emit("msg", m);
}
onRconError(v) {
this.rconLive = false;
console.log("Server sent" + v);
}
onConnect() {
this.sk.readyState = 1;
this.logs = [];
}

View file

@ -6,22 +6,32 @@ import RconSocket from "./RconSocket.js";
import "@mcl/css/rcon.css";
export default function RconView(props) {
const { serverName } = props;
const { serverId } = props;
const logsRef = useRef(0);
const [cmd, setCmd] = useState("");
const [logs, setLogs] = useState([]);
const [rcon, setRcon] = 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]);
const disconnectRcon = () => {
if (!rcon || typeof rcon.disconnect !== "function") return;
rcon.disconnect();
};
useEffect(
function () {
if (!serverId) return;
const rs = new RconSocket(setLogs, serverId);
setRcon(rs);
return disconnectRcon;
},
[serverId],
);
useEffect(
() => logsRef.current.scrollTo(0, logsRef.current.scrollHeight),
[(rcon ?? {}).logs],
);
function sendCommand() {
rcon.send(cmd);
@ -45,8 +55,12 @@ export default function RconView(props) {
variant="outlined"
value={cmd}
onChange={updateCmd}
disabled={!(rcon && rcon.rconLive)}
/>
<Button onClick={sendCommand}>Send</Button>
{rcon && rcon.rconLive && <Button onClick={sendCommand}>Send</Button>}
{!(rcon && rcon.rconLive) && (
<Button color="secondary">Not Connected</Button>
)}
</Box>
</Box>
);

View file

@ -11,7 +11,6 @@ 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";
import FolderIcon from "@mui/icons-material/Folder";
@ -19,10 +18,10 @@ import { Link } from "react-router-dom";
export default function ServerCard(props) {
const { server, openRcon } = props;
const { name, metrics, ftpAvailable, serverAvailable, services } = server;
const startServer = useStartServer(name);
const stopServer = useStopServer(name);
const deleteServer = useDeleteServer(name);
const { name, id, metrics, ftpAvailable, serverAvailable, services } = server;
const startServer = useStartServer(id);
const stopServer = useStopServer(id);
const deleteServer = useDeleteServer(id);
function toggleRcon() {
if (!services.includes("server")) return;
openRcon();
@ -113,7 +112,7 @@ export default function ServerCard(props) {
aria-label="Edit"
size="large"
component={Link}
to={`/mcl/edit?server=${name}`}
to={`/mcl/edit?server=${id}`}
>
<EditIcon />
</IconButton>
@ -122,8 +121,8 @@ export default function ServerCard(props) {
aria-label="Files"
size="large"
component={Link}
to={`/mcl/files?server=${name}`}
disabled={!services.includes("ftp")}
to={`/mcl/files?server=${id}`}
disabled={!ftpAvailable}
>
<FolderIcon />
</IconButton>

View file

@ -1,31 +0,0 @@
.appbar-items {
font-size: 1.25rem;
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
font-weight: 500;
line-height: 1.6;
letter-spacing: 0.0075em;
}
.view > header {
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
box-shadow:
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
flex-shrink: 0;
position: fixed;
top: 0;
left: auto;
right: 0;
color: rgba(0, 0, 0, 0.87);
z-index: 1302;
background-color: #29985c;
}
.view > header > div > div > a {
height: 40px;
width: 40px;
}

View file

@ -13,7 +13,7 @@ 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 HomeIcon from "@mui/icons-material/Home";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
@ -36,18 +36,52 @@ export default function MCLMenu() {
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
return (
<AppBar position="fixed" color="primary" sx={{ zIndex: drawerIndex() }}>
<Box
sx={{ flexGrow: 1, margin: "0 20px", color: "white" }}
className="appbar-items"
>
<AppBar position="fixed" sx={{ zIndex: drawerIndex() }}>
<Box sx={{ flexGrow: 1, margin: "0 20px" }} className="appbar-items">
<Toolbar disableGutters>
<IconButton component={Link} to="/" color="inherit">
<HomeIcon />
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={toggleDrawer}
>
<MenuIcon />
</IconButton>
<span style={{ margin: "auto 0", color: "inherit" }}>
<Drawer
open={drawerOpen}
onClose={closeDrawer}
sx={{ zIndex: drawerIndex(true) }}
className="mcl-menu-drawer"
>
<Toolbar />
<Box
sx={{ width: drawerWidth, overflow: "auto" }}
role="presentation"
>
<List>
{pages.map(
(page, index) =>
page.visible && (
<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()}
</span>
</Typography>
</Toolbar>
</Box>
</AppBar>

View file

@ -11,17 +11,20 @@ export default [
path: "/mcl/home",
icon: <HomeIcon />,
component: <Home />,
visible: true,
},
{
name: "Create",
path: "/mcl/create",
icon: <AddIcon />,
component: <Create />,
visible: true,
},
{
name: "Edit",
path: "/mcl/files",
icon: <AddIcon />,
component: <Files />,
visible: false,
},
];

View file

@ -1,11 +1,10 @@
import Box from "@mui/material/Box";
import CreateOptions from "./CreateOptions.jsx";
import CreateCoreOptions from "./CreateCoreOptions.jsx";
export default function Create() {
return (
<Box className="create">
{/*<CreateMenu />*/}
<Box className="create-wrapper" sx={{ display: "flex" }}>
<CreateOptions />
<CreateCoreOptions />
</Box>
</Box>
);

View file

@ -0,0 +1,145 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { useCreateServer } from "@mcl/queries";
// Core Options
import NameOption from "@mcl/components/server-options/NameOption.jsx";
import HostOption from "@mcl/components/server-options/HostOption.jsx";
import VersionOption from "@mcl/components/server-options/VersionOption.jsx";
import ServerTypeOption, {
serverTypeOptions,
} from "@mcl/components/server-options/ServerTypeOption.jsx";
import CpuOption, {
cpuOptions,
} from "@mcl/components/server-options/CpuOption.jsx";
import MemoryOption, {
memoryOptions,
} from "@mcl/components/server-options/MemoryOption.jsx";
import BackupHostOption from "@mcl/components/server-options/BackupHostOption.jsx";
import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx";
import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx";
import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx";
import BackupIntervalOption, {
backupIntervalDefault,
} from "@mcl/components/server-options/BackupIntervalOption.jsx";
const defaultServer = {
version: "latest",
serverType: serverTypeOptions[0],
cpu: cpuOptions[0],
memory: memoryOptions[2], // 1.5GB
};
export default function CreateCoreOptions() {
const [backupEnabled, setBackupEnabled] = useState(false);
const [spec, setSpec] = useState(defaultServer);
const nav = useNavigate();
const createServer = useCreateServer(spec);
const updateSpec = (attr, val) => {
const s = { ...spec };
s[attr] = val;
setSpec(s);
};
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
async function upsertSpec() {
if (validateSpec() !== "validated") return;
createServer(spec)
// .then(() => nav("/"))
.catch(alert);
}
function validateSpec() {
console.log("TODO CREATE VALIDATION");
if (!spec.host) return alertValidationError("Host cannot be blank");
if (!spec.name) return alertValidationError("Name not included");
if (!spec.version) return alertValidationError("Version cannot be blank");
return "validated";
}
function alertValidationError(reason) {
alert(`Could not validate spec because: ${reason}`);
}
const toggleBackupEnabled = () => {
const s = { ...spec };
if (!backupEnabled) {
(s.backupInterval = backupIntervalDefault),
(s.backupBucket = `/mcl/server-backups/${(
s.name ?? "my-server"
).toLowerCase()}`);
} else for (var k in s) if (k.startsWith("backup")) delete s[k];
setSpec(s);
console.log(s);
setBackupEnabled(!backupEnabled);
};
return (
<Box
className="create-options"
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
>
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
<NameOption onChange={coreUpdate("name")} />
<HostOption onChange={coreUpdate("host")} />
<VersionOption value={spec.version} onChange={coreUpdate("version")} />
<ServerTypeOption
value={spec.serverType}
onChange={coreUpdate("serverType")}
/>
<CpuOption value={spec.cpu} onChange={coreUpdate("cpu")} />
<MemoryOption value={spec.memory} onChange={coreUpdate("memory")} />
<FormControlLabel
control={
<Switch
checked={backupEnabled}
onChange={toggleBackupEnabled}
inputProps={{ "aria-label": "controlled" }}
/>
}
label="Enable Backups?"
labelPlacement="start"
sx={{ mr: "auto" }}
/>
{backupEnabled && (
<FormControl
fullWidth
sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}
>
<Typography variant="h6">Backups</Typography>
<BackupHostOption
value={spec.backupHost}
onChange={coreUpdate("backupHost")}
/>
<BackupBucketOption
value={spec.backupBucket}
onChange={coreUpdate("backupBucket")}
/>
<BackupIdOption
value={spec.backupId}
onChange={coreUpdate("backupId")}
/>
<BackupKeyOption
value={spec.backupKey}
onChange={coreUpdate("backupKey")}
/>
<BackupIntervalOption onChange={coreUpdate("backupInterval")} />
</FormControl>
)}
<Button onClick={upsertSpec} variant="contained">
Create
</Button>
</FormControl>
</Box>
);
}

View file

@ -1,20 +1,36 @@
import { useEffect } from "react";
import { useState, useEffect } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Toolbar from "@mui/material/Toolbar";
import FilePreview, {
useFilePreview,
} from "@mcl/components/files/FilePreview.jsx";
import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx";
export default function Files() {
const [open, dialogToggle] = useFilePreview();
const [previewData, setPreviewData] = useState();
const [searchParams] = useSearchParams();
const currentServer = searchParams.get("server");
const nav = useNavigate();
useEffect(() => {
if (!currentServer) nav("/");
}, [currentServer]);
function changePreview(name, fileData) {
setPreviewData({ name, fileData });
dialogToggle();
}
return (
<Box className="edit" sx={{ height: "100%" }}>
<MineclusterFiles server={currentServer} />
<FilePreview
open={open}
dialogToggle={dialogToggle}
previewData={previewData}
/>
<MineclusterFiles server={currentServer} changePreview={changePreview} />
</Box>
);
}

View file

@ -30,6 +30,7 @@ export default function Home() {
setServer(s);
rconToggle();
};
return (
<Box className="home">
<Overview clusterMetrics={clusterMetrics} />
@ -50,10 +51,10 @@ export default function Home() {
<Box className="servers">
{!isLoading &&
servers.map((s, k) => (
<ServerCard key={k} server={s} openRcon={openRcon(s.name)} />
<ServerCard key={k} server={s} openRcon={openRcon(s)} />
))}
</Box>
<RconDialog open={rdOpen} dialogToggle={rconToggle} serverName={server} />
<RconDialog open={rdOpen} dialogToggle={rconToggle} server={server} />
<Button
component={Link}
to="/mcl/create"

View file

@ -20,38 +20,45 @@ const fetchApiPost = (subPath, json) => async () =>
body: JSON.stringify(json),
}).then((res) => res.json());
export const useServerStatus = (server) =>
export const useServerStatus = (serverId) =>
useQuery({
queryKey: [`server-status-${server}`],
queryFn: fetchApiPost("/server/status", { name: server }),
queryKey: [`server-status-${serverId}`],
queryFn: fetchApiPost("/server/status", { id: serverId }),
});
export const useServerMetrics = (server) =>
export const useServerMetrics = (serverId) =>
useQuery({
queryKey: [`server-metrics-${server}`],
queryFn: fetchApiPost("/server/metrics", { name: server }),
queryKey: [`server-metrics-${serverId}`],
queryFn: fetchApiPost("/server/metrics", { id: serverId }),
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 useStartServer = (serverId) =>
postJsonApi("/server/start", { id: serverId }, "server-instances");
export const useStopServer = (serverId) =>
postJsonApi("/server/stop", { id: serverId }, "server-instances");
export const useDeleteServer = (serverId) =>
postJsonApi("/server/delete", { id: serverId }, "server-instances", "DELETE");
export const useCreateServer = (spec) =>
postJsonApi("/server/create", spec, "server-list");
export const getServerFiles = async (server, path) =>
fetchApiCore("/files/list", { name: server, path }, "POST", true);
export const createServerFolder = async (server, path) =>
export const getServerFiles = async (serverId, path) =>
fetchApiCore("/files/list", { id: serverId, path }, "POST", true);
export const createServerFolder = async (serverId, path) =>
fetchApiCore("/files/folder", {
name: server,
id: serverId,
path,
});
export const deleteServerItem = async (server, path, isDir) =>
fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE");
export const deleteServerItem = async (serverId, path, isDir) =>
fetchApiCore("/files/item", { id: serverId, path, isDir }, "DELETE");
export const getServerItem = async (server, name, path) =>
fetchApiCore("/files/item", { name: server, path })
export async function previewServerItem(serverId, path) {
const resp = await fetchApiCore("/files/item", { id: serverId, path });
if (!resp.status === 200) return console.log("AHHHH");
const blob = await resp.blob();
return blob;
}
export const getServerItem = async (serverId, name, path) =>
fetchApiCore("/files/item", { id: serverId, path })
.then((resp) =>
resp.status === 200
? resp.blob()

View file

@ -1,5 +1,9 @@
// Generated using https://zenoo.github.io/mui-theme-creator/
import { createTheme } from "@mui/material/styles";
import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className";
// This fixes style clashing with Chonky which has not been updated to Material 5
// see https://github.com/TimboKZ/Chonky/issues/101#issuecomment-1362949314
ClassNameGenerator.configure((componentName) => `mcl-${componentName}`);
const themeOptions = {
palette: {