Merge branch 'ep/Mar6/LiveLogging' into 'master'

(feature) Update UI & Resource Availability

See merge request Dunemask/minecluster!1
This commit is contained in:
Elijah Dunemask 2023-03-15 15:20:08 +00:00
commit cf4f9af5de
44 changed files with 4747 additions and 27 deletions

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:18
WORKDIR /dunemask/net/minecluster
# Copy dependencies
COPY package.json .
COPY package-lock.json .
RUN npm i
# Copy react build resources over
COPY public public
COPY dist dist
COPY src src
COPY lib lib
COPY index.html .
COPY vite.config.js .
RUN npm run build:react
CMD ["npm","start"]

17
dist/app.js vendored Normal file
View file

@ -0,0 +1,17 @@
import stream from "stream";
import k8s from "@kubernetes/client-node";
import Minecluster from "../lib/Minecluster.js";
const mcl = new Minecluster();
mcl.start();
async function main(){
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const res = await k8sApi.listNamespacedPod('mc-garden-default');
const pods = res.body.items.map((vp1) => vp1.metadata.name);
console.log(pods);
}
main().catch((e)=>{console.log(e)});

48
lib/Minecluster.js Normal file
View file

@ -0,0 +1,48 @@
// Imports
import fig from "figlet";
import http from "http";
import express from "express";
import { INFO, OK, logInfo } from "./util/logging.js";
// Import Core Modules
import buildRoutes from "./server/router.js";
import injectSockets from "./server/sockets.js";
// Constants
const title = "MCL";
const port = process.env.MCL_DEV_PORT ?? 52000;
// Class
export default class Minecluster {
constructor(options = {}) {
for (var k in options) this[k] = options[k];
this.port = options.port ?? port;
}
async _preinitialize() {
logInfo(fig.textSync(title, "Larry 3D"));
INFO("INIT", "Initializing...");
this.app = express();
this.server = http.createServer(this.app);
this.sockets = injectSockets(this.server, this.jobs);
this.routes = buildRoutes(this.sockets);
this.app.use(this.routes);
OK("INIT", "Initialized!");
}
async _connect() {
// await this.pg.connect();
}
start() {
const mcl = this;
return new Promise(async function init(res) {
mcl._preinitialize();
await mcl._connect();
mcl.server.listen(mcl.port, function onStart() {
OK("SERVER", `Running on ${mcl.port}`);
res();
});
});
}
}

0
lib/index.js Normal file
View file

8
lib/k8s.js Normal file
View file

@ -0,0 +1,8 @@
import k8s from "@kubernetes/client-node";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.listNamespacedPod("mc-garden-default").then((res) => {
console.log(res.body);
});

View file

@ -0,0 +1,10 @@
apiVersion: v1
data:
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
kind: Secret
metadata:
labels:
app: changeme-app-label
name: changeme-rcon-secret
namespace: changeme-namespace
type: Opaque

View file

@ -0,0 +1,22 @@
apiVersion: v1
kind: Service
metadata:
annotations:
labels:
app: changeme-app
name: changeme-rcon
namespace: changeme-namespace
spec:
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: rcon
port: 25575
protocol: TCP
targetPort: rcon
selector:
app: changeme-app
sessionAffinity: None
type: ClusterIP

View file

@ -0,0 +1,215 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: changeme-name
namespace: changeme-namespace
spec:
progressDeadlineSeconds: 600
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
app: changeme-app
strategy:
type: Recreate
template:
metadata:
labels:
app: changeme-app
spec:
containers:
- env:
- name: SRC_DIR
value: /data
- name: BACKUP_NAME
value: world
- name: INITIAL_DELAY
value: 2m
- name: BACKUP_INTERVAL
value: 24h
- name: PRUNE_BACKUPS_DAYS
value: "2"
- name: PAUSE_IF_NO_PLAYERS
value: "true"
- name: SERVER_PORT
value: "25565"
- name: RCON_HOST
value: localhost
- name: RCON_PORT
value: "25575"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
- name: RCON_RETRIES
value: "5"
- name: RCON_RETRY_INTERVAL
value: 10s
- name: EXCLUDES
value: "*.jar,cache,logs"
- name: BACKUP_METHOD
value: rclone
- name: DEST_DIR
value: /backups
- name: LINK_LATEST
value: "false"
- name: TAR_COMPRESS_METHOD
value: gzip
- name: ZSTD_PARAMETERS
value: -3 --long=25 --single-thread
- name: RCLONE_REMOTE
value: mc-dunemask-net
- name: RCLONE_DEST_DIR
value: /minecraft-backups/deltasmp-backups
- name: RCLONE_COMPRESS_METHOD
value: gzip
image: itzg/mc-backup:latest
imagePullPolicy: IfNotPresent
name: mcs-deltasmp-minecraft-mc-backup
resources:
requests:
cpu: 500m
memory: 512Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /data
name: datadir
readOnly: true
- mountPath: /backups
name: backupdir
- mountPath: /config/rclone
name: rclone-config
- env:
- name: EULA
value: "TRUE"
- name: TYPE
value: VANILLA
- name: VERSION
value: "latest"
- name: DIFFICULTY
value: easy
- name: WHITELIST
- name: OPS
- name: ICON
- name: MAX_PLAYERS
value: "20"
- name: MAX_WORLD_SIZE
value: "10000"
- name: ALLOW_NETHER
value: "true"
- name: ANNOUNCE_PLAYER_ACHIEVEMENTS
value: "true"
- name: ENABLE_COMMAND_BLOCK
value: "true"
- name: FORCE_GAMEMODE
value: "false"
- name: GENERATE_STRUCTURES
value: "true"
- name: HARDCORE
value: "false"
- name: MAX_BUILD_HEIGHT
value: "256"
- name: MAX_TICK_TIME
value: "60000"
- name: SPAWN_ANIMALS
value: "true"
- name: SPAWN_MONSTERS
value: "true"
- name: SPAWN_NPCS
value: "true"
- name: SPAWN_PROTECTION
value: "16"
- name: VIEW_DISTANCE
value: "10"
- name: SEED
- name: MODE
value: survival
- name: MOTD
value: §6Minecluster Hosting
- name: PVP
value: "true"
- name: LEVEL_TYPE
value: DEFAULT
- name: GENERATOR_SETTINGS
- name: LEVEL
value: world
- name: MODPACK
- name: ONLINE_MODE
value: "true"
- name: MEMORY
value: 1024M
- name: JVM_OPTS
- name: JVM_XX_OPTS
- name: OVERRIDE_SERVER_PROPERTIES
value: "true"
- name: ENABLE_RCON
value: "true"
- name: RCON_PASSWORD
valueFrom:
secretKeyRef:
key: rcon-password
name: changeme-rcon-secret
image: itzg/minecraft-server:latest
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
name: changeme-name
ports:
- containerPort: 25565
name: minecraft
protocol: TCP
- containerPort: 25575
name: rcon
protocol: TCP
readinessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
resources:
requests:
cpu: 500m
memory: 512Mi
stdin: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
tty: true
volumeMounts:
- mountPath: /data
name: datadir
- mountPath: /backups
name: backupdir
readOnly: true
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext:
fsGroup: 2000
runAsUser: 1000
terminationGracePeriodSeconds: 30
volumes:
- name: datadir
persistentVolumeClaim:
claimName: changeme-pvc-name
- emptyDir: {}
name: backupdir
- name: rclone-config
secret:
defaultMode: 420
items:
- key: rclone.conf
path: rclone.conf
secretName: rclone-config

View file

@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
labels:
service: changeme-service-name
name: changeme-pvc-name
namespace: changeme-namespace
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi

View file

@ -0,0 +1,24 @@
apiVersion: v1
kind: Service
metadata:
annotations:
ingress.qumine.io/hostname: changeme-url
ingress.qumine.io/portname: minecraft
labels:
app: changeme-app
name: changeme-name
namespace: changeme-namespace
spec:
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: minecraft
port: 25565
protocol: TCP
targetPort: minecraft
selector:
app: changeme-app
sessionAffinity: None
type: ClusterIP

30
lib/k8s/live-logging.js Normal file
View file

@ -0,0 +1,30 @@
import stream from "stream";
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
export default async function liveLogging(socket, serverNamespace) {
const containerName = `mcl-${socket.mcs.serverName}`;
const podResponse = await k8sApi.listNamespacedPod(serverNamespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
const mcsPods = pods.filter((p) => p.startsWith(containerName));
if (mcsPods.length === 0)
throw Error(`Could not find a pod that starts with ${containerName}`);
if (mcsPods.length > 1)
throw Error(`Multiple pods match the name ${containerName}`);
const log = new k8s.Log(kc);
const logStream = new stream.PassThrough();
logStream.on("data", (chunk) =>
socket.emit("push", Buffer.from(chunk).toString())
);
log
.log(serverNamespace, mcsPods[0], containerName, logStream, {
follow: true,
pretty: false,
timestamps: false,
})
.catch((e) => ERR("K8S", e));
}

95
lib/k8s/server-control.js Normal file
View file

@ -0,0 +1,95 @@
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const k8sMetrics = new k8s.Metrics(kc);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export async function startServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const dep = deploymentRes.body.items.find(
(i) => i.metadata.name === `mcl-${name}`
);
if (!dep) return res.status(409).send("Server does not exist!");
if (dep.spec.replicas === 1)
return res.status(409).send("Server already started!");
dep.spec.replicas = 1;
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
res.sendStatus(200);
}
export async function stopServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const dep = deploymentRes.body.items.find(
(i) => i.metadata.name === `mcl-${name}`
);
if (!dep) return res.status(409).send("Server does not exist!");
if (dep.spec.replicas === 0)
return res.status(409).send("Server already stopped!");
dep.spec.replicas = 0;
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
res.sendStatus(200);
}
export async function serverList(req, res) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) => d.startsWith("mcl-"));
res.json(serverDeployments.map((sd) => sd.substring(4)));
}
export async function getServers(req, res) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items;
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
// TODO Add an annotation and manage using that
const serverDeployments = deployments.filter((d) =>
d.metadata.name.startsWith("mcl-")
);
var name, metrics, started;
const servers = serverDeployments.map((s) => {
name = s.metadata.name.substring(4);
metrics = null;
started = !!s.spec.replicas;
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
});
if (pod) {
const podCpus = pod.containers.map(
({ usage }) => parseInt(usage.cpu) / 1_000_000
);
const podMems = pod.containers.map(
({ usage }) => parseInt(usage.memory) / 1024
);
metrics = {
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
};
}
return { name, metrics, started };
});
var clusterMetrics = { cpu: 0, memory: 0 };
if (servers.length > 1) {
const clusterCpu = servers
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
.reduce((a, b) => a + b);
const clusterMem = servers
.map(({ metrics }) => (metrics ? metrics.memory : 0))
.reduce((a, b) => a + b);
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
}
res.json({ servers, clusterMetrics });
}

171
lib/k8s/server-create.js Normal file
View file

@ -0,0 +1,171 @@
import { v4 as uuidv4 } from "uuid";
import bcrypt from "bcrypt";
import k8s from "@kubernetes/client-node";
import yaml from "js-yaml";
import fs from "node:fs";
import path from "node:path";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, url, version, serverType, difficulty, gamemode, memory } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!url) return res.status(400).send("Server url is required!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
return "filtered";
}
function createRconSecret(serverSpec) {
const { name } = serverSpec;
const rconYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-secret.yml"), "utf8")
);
// TODO: Dyamic rconPassword
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
rconYaml.metadata.labels.app = `mcl-${name}-app`;
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
rconYaml.metadata.namespace = namespace;
return rconYaml;
}
function createServerVolume(serverSpec) {
const { name } = serverSpec;
const volumeYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/server-pvc.yml"), "utf8")
);
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
volumeYaml.metadata.name = `mcl-${name}-volume`;
volumeYaml.metadata.namespace = namespace;
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
return volumeYaml;
}
function createServerDeploy(serverSpec) {
const {
name,
version,
serverType,
difficulty,
gamemode,
memory,
motd,
maxPlayers,
seed,
modpack,
ops,
whitelist,
} = serverSpec;
const deployYaml = yaml.load(
fs.readFileSync(
path.resolve("lib/k8s/configs/server-deployment.yml"),
"utf8"
)
);
deployYaml.metadata.name = `mcl-${name}`;
deployYaml.metadata.namespace = namespace;
deployYaml.spec.replicas = 0; // TODO: User control for autostart
deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`;
deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`;
deployYaml.spec.template.spec.containers.splice(0, 1); //TODO: Currently removing backup container
const serverContainer = deployYaml.spec.template.spec.containers[0];
// Enviornment variables
serverContainer.env.find(({ name: n }) => n === "TYPE").value = serverType;
serverContainer.env.find(({ name: n }) => n === "VERSION").value = version;
serverContainer.env.find(({ name: n }) => n === "DIFFICULTY").value =
difficulty;
serverContainer.env.find(({ name: n }) => n === "MODE").value = gamemode;
serverContainer.env.find(({ name: n }) => n === "MOTD").value = motd;
serverContainer.env.find(({ name: n }) => n === "MAX_PLAYERS").value =
maxPlayers;
serverContainer.env.find(({ name: n }) => n === "SEED").value = seed;
serverContainer.env.find(({ name: n }) => n === "OPS").value = ops;
serverContainer.env.find(({ name: n }) => n === "WHITELIST").value =
whitelist;
serverContainer.env.find(
({ name: n }) => n === "MEMORY"
).value = `${memory}M`;
if (version !== "VANILLA")
delete serverContainer.env.find(({ name: n }) => n === "MODPACK").value;
else
serverContainer.env.find(({ name: n }) => n === "MODPACK").value = modpack;
serverContainer.env.find(
({ name }) => name === "RCON_PASSWORD"
).valueFrom.secretKeyRef.name = `mcl-${name}-rcon-secret`;
// Server Container Name
serverContainer.name = `mcl-${name}`;
// Resources
serverContainer.resources.requests.memory = `${memory}Mi`;
// serverContainer.resources.limits.memory = `${memory}Mi`; // TODO Allow for limits beyond initial startup
// Volumes
deployYaml.spec.template.spec.volumes.find(
({ name }) => name === "datadir"
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
deployYaml.spec.template.spec.containers[0] = serverContainer;
return deployYaml;
}
function createServerService(serverSpec) {
const { name, url } = serverSpec;
const serviceYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/server-svc.yml"), "utf8")
);
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = url;
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
serviceYaml.metadata.name = `mcl-${name}-server`;
serviceYaml.metadata.namespace = namespace;
serviceYaml.spec.selector.app = `mcl-${name}-app`;
return serviceYaml;
}
function createRconService(serverSpec) {
const { name, url } = serverSpec;
const rconSvcYaml = yaml.load(
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-svc.yml"), "utf8")
);
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
rconSvcYaml.metadata.namespace = namespace;
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
return rconSvcYaml;
}
export default async function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
if (deployments.includes(`mcl-${serverSpec.name}`))
return res.status(409).send("Server already exists!");
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
return res.status(409).send("Server PVC already exists!");
const rconSecret = createRconSecret(serverSpec);
const serverVolume = createServerVolume(serverSpec);
const serverDeploy = createServerDeploy(serverSpec);
const serverService = createServerService(serverSpec);
const rconService = createRconService(serverSpec);
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume);
k8sCore.createNamespacedSecret(namespace, rconSecret);
k8sCore.createNamespacedService(namespace, serverService);
k8sCore.createNamespacedService(namespace, rconService);
k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
res.sendStatus(200);
}

55
lib/k8s/server-delete.js Normal file
View file

@ -0,0 +1,55 @@
import k8s from "@kubernetes/client-node";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
const deleteError = (res) => (err) => {
res.status(500).send("Error deleting a resource!");
ERR("K8S", "An error occurred while deleting a resource", err);
};
export default async function deleteServer(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
const { name } = serverSpec;
// Ensure deployment exists
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
if (!deployments.includes(`mcl-${serverSpec.name}`))
return res.status(409).send("Server does not exist!");
// Delete in reverse order
const deleteDeploy = k8sDeps.deleteNamespacedDeployment(
`mcl-${serverSpec.name}`,
namespace
);
const deleteService = k8sCore.deleteNamespacedService(
`mcl-${name}-server`,
namespace
);
const deleteRconService = k8sCore.deleteNamespacedService(
`mcl-${name}-rcon`,
namespace
);
await deleteDeploy.catch(deleteError(res));
const deleteRconSecret = k8sCore.deleteNamespacedSecret(
`mcl-${name}-rcon-secret`,
namespace
);
const deleteVolume = k8sCore.deleteNamespacedPersistentVolumeClaim(
`mcl-${name}-volume`,
namespace
);
Promise.all([
deleteService,
deleteRconService,
deleteRconSecret,
deleteVolume,
])
.then(() => res.sendStatus(200))
.catch(deleteError(res));
}

View file

@ -0,0 +1,19 @@
import { Router, json as jsonMiddleware } from "express";
import {
startServer,
stopServer,
serverList,
getServers,
} from "../k8s/server-control.js";
import createServer from "../k8s/server-create.js";
import deleteServer from "../k8s/server-delete.js";
const router = Router();
router.use(jsonMiddleware());
// Routes
router.post("/create", createServer);
router.delete("/delete", deleteServer);
router.post("/start", startServer);
router.post("/stop", stopServer);
router.get("/list", serverList);
router.get("/instances", getServers);
export default router;

View file

@ -0,0 +1,27 @@
import { Router } from "express";
import k8s from "@kubernetes/client-node";
import { WARN } from "../util/logging.js";
const router = Router();
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
// Get Routes
router.get("/available", (req, res) => {
k8sApi.listNode().then((nodeRes) => {
const nodeAllocatable = nodeRes.body.items.map((i) => i.status.allocatable);
const nodeResources = nodeAllocatable.map(({ cpu, memory }) => ({
cpu,
memory,
}));
const { cpu: clusterCpu, memory: clusterMemory } = nodeResources[0];
const isIdentical = ({ cpu, memory }) =>
clusterMemory === memory && clusterCpu === cpu;
if (!nodeResources.every(isIdentical))
WARN("ROUTES", "Warning, node resources were non-consistent");
const availableCpu = parseInt(clusterCpu) * 1000;
const availableMemory = parseInt(clusterMemory) / 1024;
res.json({ cpu: availableCpu, memory: availableMemory });
});
});
export default router;

View file

@ -0,0 +1,6 @@
import { Router } from "express";
const router = Router();
// Get Routes
router.get("/healthz", (req, res) => res.sendStatus(200));
export default router;

31
lib/server/rcon.js Normal file
View file

@ -0,0 +1,31 @@
import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import { ERR } from "../util/logging.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export default async function rconInterface(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64"
).toString("utf8");
const rconHost = `mcl-${socket.mcs.serverName}-rcon`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
password: rconPassword,
});
rcon.on("error", (error) => socket.emit("push", error));
try {
await rcon.connect();
} catch (error) {
ERR("RCON", `Could not connect to 'mcl-${socket.mcs.serverName}-rcon'`);
}
socket.rconClient = rcon;
}

23
lib/server/router.js Normal file
View file

@ -0,0 +1,23 @@
// Imports
import express from "express";
// Routes
import vitals from "../routes/vitals-route.js";
import systemRoute from "../routes/system-route.js";
import serverRoute from "../routes/server-route.js";
export default function buildRoutes(pg, skio) {
const router = express.Router();
// Special Routes
router.use(vitals);
router.all("/", (req, res) => res.redirect("/mcl"));
// Middlewares
// Routes
router.use("/api/system", systemRoute);
router.use("/api/server", serverRoute);
// router.use("/mcl", react); // Static Build Route
return router;
}

46
lib/server/sockets.js Normal file
View file

@ -0,0 +1,46 @@
import { Server as Skio } from "socket.io";
import { VERB, WARN, ERR } from "../util/logging.js";
import liveLogging from "../k8s/live-logging.js";
import rconInterface from "./rcon.js";
const namespace = process.env.MCL_SERVER_NAMESPACE;
async function rconSend(socket, m) {
if (!socket.rconClient)
return WARN("RCON", "Message sent before RCON connected!");
try {
const r = await socket.rconClient.send(m);
socket.emit("push", `[RCON]: ${r}`);
} catch (error) {
WARN("RCON", error);
}
}
const socketConnect = async (io, socket) => {
VERB("WS", "Websocket connecting");
socket.mcs = { serverName: socket.handshake.query.serverName };
try {
await liveLogging(socket, namespace);
await rconInterface(socket);
socket.on("msg", (m) => rconSend(socket, m));
} catch (err) {
ERR("SOCKETS", err);
socket.send("push", err);
socket.disconnect();
}
};
const socketAuth = (socket, next) => {
const { token } = socket.handshake.auth;
// next(new Error("Bad Token"));
next();
};
const applySockets = (server) => {
const io = new Skio(server);
io.on("connection", (socket) => socketConnect(io, socket));
VERB("WS", "Configured Websockets");
return io;
};
export default applySockets;

28
lib/util/logging.js Normal file
View file

@ -0,0 +1,28 @@
// Imports
import { Chalk } from "chalk";
const { redBright, greenBright, yellowBright, cyanBright, magentaBright } =
new Chalk({ level: 2 });
// Logging
const logColor = (color, header, ...args) =>
console.log(color(header), ...args);
export const logError = (...args) => logColor(redBright, ...args);
export const logConfirm = (...args) => logColor(greenBright, ...args);
export const logWarn = (...args) => logColor(yellowBright, ...args);
export const logInfo = (...args) => logColor(cyanBright, ...args);
export const logVerbose = (...args) => logColor(magentaBright, ...args);
export const ERR = (header, ...args) => logError(`[${header}]`, ...args);
export const OK = (header, ...args) => logConfirm(`[${header}]`, ...args);
export const WARN = (header, ...args) => logWarn(`[${header}]`, ...args);
export const INFO = (header, ...args) => logInfo(`[${header}]`, ...args);
export const VERB = (header, ...args) => logVerbose(`[${header}]`, ...args);

2952
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,9 @@
"build:react": "vite build",
"start": "node dist/app.js",
"dev:server": "nodemon dist/app.js",
"dev:react": "vite"
"dev:react": "vite",
"kub": "nodemon lib/k8s.js",
"start:dev": "concurrently -k \"MCL_DEV_PORT=52025 npm run dev:server\" \" MCL_VITE_DEV_PORT=52000 MCL_VITE_BACKEND_URL=http://localhost:52025 npm run dev:react\" -n s,v -p -c green,yellow"
},
"keywords": [
"Minecraft",
@ -19,13 +21,30 @@
"author": "Dunemask",
"license": "LGPL-2.1",
"devDependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.11",
"@tanstack/react-query": "^4.26.0",
"@vitejs/plugin-react": "^3.1.0",
"concurrently": "^7.6.0",
"nodemon": "^2.0.21",
"prettier": "^2.8.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.2",
"socket.io-client": "^4.6.1",
"vite": "^4.1.4"
},
"dependencies": {
"@kubernetes/client-node": "^0.18.1",
"bcrypt": "^5.1.0",
"chalk": "^5.2.0",
"express": "^4.18.2",
"figlet": "^1.5.2",
"js-yaml": "^4.1.0",
"rcon-client": "^4.2.3",
"socket.io": "^4.6.1",
"uuid": "^9.0.0"
}
}

View file

@ -0,0 +1 @@
/images/server-backdrop.png -> https://pixabay.com/illustrations/minecraft-forest-spruce-oak-azalea-7202839/

20
public/css/overview.css Normal file
View file

@ -0,0 +1,20 @@
.overview-toolbar {
background-color: rgba(255, 255, 255, 0.5);
height: 230px;
display: flex;
justify-content: center;
}
.overview-visual-display {
display: flex;
background-color: rgba(223, 223, 223, 0.5);
border-radius: 50%;
}
.overview-visual-label {
text-align: center;
}
.overview-visual-wrapper {
margin: 1rem;
}

9
public/css/rcon.css Normal file
View file

@ -0,0 +1,9 @@
.rconLogsWrapper {
overflow-y: scroll;
max-height: 20rem;
word-wrap: break-word;
margin-bottom: 10px;
}
.rconActions {
display: inline-flex;
}

View file

@ -0,0 +1,82 @@
.servers {
display: flex;
justify-content: center;
flex-wrap: wrap;
}
.server-card {
width: 400px;
min-height: 228px;
min-width: 250px;
max-width: 400px;
margin: 15px;
background-image: url("/images/server-backdrop.png");
background-size: cover;
display: flex;
flex-wrap: wrap;
}
.server-card-header {
padding: 0px;
display: inline-flex;
max-height: 32px;
height: 100%;
font-weight: bold;
text-transform: capitalize;
background-image: linear-gradient(
to right,
rgba(255, 255, 255, 1),
rgba(255, 255, 255, 0.1)
);
width: 100%;
}
.server-card-title {
width: 100%;
height: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-card-status-indicator {
margin-left: auto;
margin-right: auto;
}
.server-card-actions-wrapper {
margin-top: auto;
justify-content: end;
width: 100%;
background-color: rgba(255, 255, 255, 0.9);
}
.server-card-actions {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 0px;
}
.server-card-action {
position: relative;
height: 20%;
}
.server-card-element {
background-color: rgba(255, 255, 255, 0.5);
}
.server-card-metrics {
display: flex;
flex-wrap: wrap;
width: 100%;
}
.server-card-metrics-info {
display: inline-flex;
width: 100%;
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

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]);
};
};

View file

@ -16,7 +16,7 @@ export default () => {
"/socket.io": backendUrl,
},
hmr: {
protocol: process.env.MCL,
protocol: process.env.MCL_VITE_DEV_PROTOCOL,
},
},
build: {
@ -25,6 +25,10 @@ export default () => {
base: "/mcl/",
resolve: {
alias: {
"@mcl/css": path.resolve("./public/css"),
"@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"),
"@mcl/pages": path.resolve("./src/pages"),
"@mcl/queries": path.resolve("./src/util/queries.js"),
"@mcl": path.resolve("./src"),
},
},