(feature) Update UI & Resource Availability
This commit is contained in:
parent
11d8229eb5
commit
929193d272
44 changed files with 4747 additions and 27 deletions
15
Dockerfile
Normal file
15
Dockerfile
Normal 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
17
dist/app.js
vendored
Normal 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
48
lib/Minecluster.js
Normal 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
0
lib/index.js
Normal file
8
lib/k8s.js
Normal file
8
lib/k8s.js
Normal 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);
|
||||||
|
});
|
10
lib/k8s/configs/rcon-secret.yml
Normal file
10
lib/k8s/configs/rcon-secret.yml
Normal 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
|
22
lib/k8s/configs/rcon-svc.yml
Normal file
22
lib/k8s/configs/rcon-svc.yml
Normal 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
|
215
lib/k8s/configs/server-deployment.yml
Normal file
215
lib/k8s/configs/server-deployment.yml
Normal 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
|
13
lib/k8s/configs/server-pvc.yml
Normal file
13
lib/k8s/configs/server-pvc.yml
Normal 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
|
24
lib/k8s/configs/server-svc.yml
Normal file
24
lib/k8s/configs/server-svc.yml
Normal 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
30
lib/k8s/live-logging.js
Normal 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
95
lib/k8s/server-control.js
Normal 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
171
lib/k8s/server-create.js
Normal 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
55
lib/k8s/server-delete.js
Normal 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));
|
||||||
|
}
|
19
lib/routes/server-route.js
Normal file
19
lib/routes/server-route.js
Normal 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;
|
27
lib/routes/system-route.js
Normal file
27
lib/routes/system-route.js
Normal 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;
|
6
lib/routes/vitals-route.js
Normal file
6
lib/routes/vitals-route.js
Normal 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
31
lib/server/rcon.js
Normal 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
23
lib/server/router.js
Normal 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
46
lib/server/sockets.js
Normal 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
28
lib/util/logging.js
Normal 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
2952
package-lock.json
generated
File diff suppressed because it is too large
Load diff
21
package.json
21
package.json
|
@ -7,7 +7,9 @@
|
||||||
"build:react": "vite build",
|
"build:react": "vite build",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev:server": "nodemon 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": [
|
"keywords": [
|
||||||
"Minecraft",
|
"Minecraft",
|
||||||
|
@ -19,13 +21,30 @@
|
||||||
"author": "Dunemask",
|
"author": "Dunemask",
|
||||||
"license": "LGPL-2.1",
|
"license": "LGPL-2.1",
|
||||||
"devDependencies": {
|
"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",
|
"@tanstack/react-query": "^4.26.0",
|
||||||
"@vitejs/plugin-react": "^3.1.0",
|
"@vitejs/plugin-react": "^3.1.0",
|
||||||
"concurrently": "^7.6.0",
|
"concurrently": "^7.6.0",
|
||||||
|
"nodemon": "^2.0.21",
|
||||||
"prettier": "^2.8.4",
|
"prettier": "^2.8.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.8.2",
|
"react-router-dom": "^6.8.2",
|
||||||
|
"socket.io-client": "^4.6.1",
|
||||||
"vite": "^4.1.4"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
1
public/asset-attributions.txt
Normal file
1
public/asset-attributions.txt
Normal 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
20
public/css/overview.css
Normal 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
9
public/css/rcon.css
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
.rconLogsWrapper {
|
||||||
|
overflow-y: scroll;
|
||||||
|
max-height: 20rem;
|
||||||
|
word-wrap: break-word;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.rconActions {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
82
public/css/server-card.css
Normal file
82
public/css/server-card.css
Normal 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;
|
||||||
|
}
|
BIN
public/images/server-backdrop.png
Normal file
BIN
public/images/server-backdrop.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
|
@ -1,16 +1,20 @@
|
||||||
// Imports
|
// Imports
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { SettingsProvider } from "@mcl/settings";
|
||||||
|
import Viewport from "./nav/Viewport.jsx";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
// Create a query client for the app
|
// Create a query client for the app
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
// Export Minecluster
|
// Export Minecluster
|
||||||
export default function MCL() {
|
export default function MCL() {
|
||||||
return (
|
return (
|
||||||
<div className="mcl">
|
<div className="minecluster">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SettingsProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<h1>Welcome to Minecluster!</h1>
|
<Viewport />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</SettingsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
62
src/ctx/SettingsContext.jsx
Normal file
62
src/ctx/SettingsContext.jsx
Normal 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
85
src/nav/MCLMenu.jsx
Normal 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
20
src/nav/MCLPages.jsx
Normal 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
18
src/nav/MCLPortal.jsx
Normal 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
16
src/nav/Viewport.jsx
Normal 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
23
src/overview/Overview.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
44
src/overview/OverviewVisual.jsx
Normal file
44
src/overview/OverviewVisual.jsx
Normal 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
152
src/pages/Create.jsx
Normal 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
51
src/pages/Home.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
45
src/servers/RconDialog.jsx
Normal file
45
src/servers/RconDialog.jsx
Normal 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
27
src/servers/RconSocket.js
Normal 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
53
src/servers/RconView.jsx
Normal 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
112
src/servers/ServerCard.jsx
Normal 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
60
src/util/queries.js
Normal 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]);
|
||||||
|
};
|
||||||
|
};
|
|
@ -16,7 +16,7 @@ export default () => {
|
||||||
"/socket.io": backendUrl,
|
"/socket.io": backendUrl,
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: {
|
||||||
protocol: process.env.MCL,
|
protocol: process.env.MCL_VITE_DEV_PROTOCOL,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
@ -25,6 +25,10 @@ export default () => {
|
||||||
base: "/mcl/",
|
base: "/mcl/",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
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"),
|
"@mcl": path.resolve("./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue