Compare commits
17 commits
master
...
ep/Dec22-2
Author | SHA1 | Date | |
---|---|---|---|
1a79ea7960 | |||
b989f6efbe | |||
7d34fcfce8 | |||
b538ab5089 | |||
a5ffe1694e | |||
76b8bf91c9 | |||
5f2a94dc14 | |||
e96c326c1d | |||
cb118e07c0 | |||
3cd9577cbf | |||
d967f6b29c | |||
2da6278ae7 | |||
b45bfed63c | |||
a96ce0ddc3 | |||
91587f66b2 | |||
e94aca7c96 | |||
f732710c7c |
53 changed files with 1349 additions and 449 deletions
|
@ -2,3 +2,6 @@
|
|||
Minecluster or MCL is a web interface used to manage multiple instance of Minecraft Servers in Kubernetes. This app is built to be an all in one for self-hosting Minecraft server. It uses rendered helm charts based on itzg/minecraft-server
|
||||
|
||||
More info coming soon.
|
||||
|
||||
## ⚠ Warning ⚠
|
||||
Development is very active and there is no garuntee for compatability or migration across versions 1/15/24
|
||||
|
|
|
@ -10,7 +10,7 @@ import { sendError } from "../util/ExpressClientError.js";
|
|||
export async function listFiles(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec) return res.sendStatus(400);
|
||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||
listServerFiles(serverSpec)
|
||||
.then((f) => {
|
||||
const fileData = f.map((fi, i) => ({
|
||||
|
@ -29,7 +29,7 @@ export async function listFiles(req, res) {
|
|||
export async function createFolder(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec) return res.sendStatus(400);
|
||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||
createServerFolder(serverSpec)
|
||||
.then(() => res.sendStatus(200))
|
||||
|
@ -39,7 +39,7 @@ export async function createFolder(req, res) {
|
|||
export async function deleteItem(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec) return res.sendStatus(400);
|
||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
|
||||
return res.status(400).send("IsDIr required!");
|
||||
|
@ -50,7 +50,7 @@ export async function deleteItem(req, res) {
|
|||
|
||||
export async function uploadItem(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||
uploadServerItem(serverSpec, req.file)
|
||||
.then(() => res.sendStatus(200))
|
||||
|
@ -59,7 +59,7 @@ export async function uploadItem(req, res) {
|
|||
|
||||
export async function getItem(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||
if (!serverSpec.id) return res.status(400).send("Server id missing!");
|
||||
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||
getServerItem(serverSpec, res)
|
||||
.then(({ ds, ftpTransfer }) => {
|
||||
|
|
|
@ -6,43 +6,61 @@ import {
|
|||
getServerEntry,
|
||||
} from "../database/queries/server-queries.js";
|
||||
import { sendError } from "../util/ExpressClientError.js";
|
||||
import {
|
||||
startServerContainer,
|
||||
stopServerContainer,
|
||||
} from "../k8s/server-control.js";
|
||||
import { toggleServer } from "../k8s/k8s-server-control.js";
|
||||
|
||||
const dnsRegex = new RegExp(
|
||||
`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`,
|
||||
);
|
||||
|
||||
function payloadFilter(req, res) {
|
||||
const serverSpec = req.body;
|
||||
if (!serverSpec) return res.sendStatus(400);
|
||||
const { name, host, version, serverType, difficulty, gamemode, memory } =
|
||||
const { name, host, version, serverType, memory } = serverSpec;
|
||||
const { backupHost, backupBucket, backupId, backupKey, backupInterval } =
|
||||
serverSpec;
|
||||
if (!name) return res.status(400).send("Server name is required!");
|
||||
if (!host) return res.status(400).send("Server host is required!");
|
||||
if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!");
|
||||
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();
|
||||
// TODO: Impliment non creation time backups
|
||||
if (
|
||||
!!backupHost ||
|
||||
!!backupBucket ||
|
||||
!!backupId ||
|
||||
!!backupKey ||
|
||||
!!backupInterval
|
||||
) {
|
||||
// If any keys are required, all are required
|
||||
if (
|
||||
!(
|
||||
!!backupHost &&
|
||||
!!backupBucket &&
|
||||
!!backupId &&
|
||||
!!backupKey &&
|
||||
!!backupInterval
|
||||
)
|
||||
)
|
||||
return res.status(400).send("All backup keys are required!");
|
||||
if (!dnsRegex.test(backupHost))
|
||||
return res.status(400).send("Backup Host invalid!");
|
||||
}
|
||||
return "filtered";
|
||||
}
|
||||
|
||||
function checkServerName(serverSpec) {
|
||||
function checkServerId(serverSpec) {
|
||||
if (!serverSpec) throw new ExpressClientError({ c: 400 });
|
||||
if (!serverSpec.name)
|
||||
throw new ExpressClientError({ c: 400, m: "Server name required!" });
|
||||
if (!serverSpec.id)
|
||||
throw new ExpressClientError({ c: 400, m: "Server id missing!" });
|
||||
}
|
||||
|
||||
export async function createServer(req, res) {
|
||||
if (payloadFilter(req, res) !== "filtered") return;
|
||||
const serverSpec = req.body;
|
||||
try {
|
||||
const serverSpecs = await getServerEntry(serverSpec.name);
|
||||
if (serverSpecs.length !== 0) throw Error("Server already exists in DB!");
|
||||
await createServerResources(serverSpec);
|
||||
await createServerEntry(serverSpec);
|
||||
const serverEntry = await createServerEntry(serverSpec);
|
||||
await createServerResources(serverEntry);
|
||||
res.sendStatus(200);
|
||||
} catch (e) {
|
||||
sendError(res)(e);
|
||||
|
@ -53,11 +71,11 @@ export async function deleteServer(req, res) {
|
|||
// Ensure spec is safe
|
||||
const serverSpec = req.body;
|
||||
try {
|
||||
checkServerName(serverSpec);
|
||||
checkServerId(serverSpec);
|
||||
} catch (e) {
|
||||
return sendError(res)(e);
|
||||
}
|
||||
const deleteEntry = deleteServerEntry(serverSpec.name);
|
||||
const deleteEntry = deleteServerEntry(serverSpec.id);
|
||||
const deleteResources = deleteServerResources(serverSpec);
|
||||
Promise.all([deleteEntry, deleteResources])
|
||||
.then(() => res.sendStatus(200))
|
||||
|
@ -68,12 +86,12 @@ export async function startServer(req, res) {
|
|||
// Ensure spec is safe
|
||||
const serverSpec = req.body;
|
||||
try {
|
||||
checkServerName(serverSpec);
|
||||
checkServerId(serverSpec);
|
||||
} catch (e) {
|
||||
return sendError(res)(e);
|
||||
}
|
||||
const { name } = serverSpec;
|
||||
toggleServer(name, true)
|
||||
const { id } = serverSpec;
|
||||
toggleServer(id, true)
|
||||
.then(() => res.sendStatus(200))
|
||||
.catch(sendError(res));
|
||||
}
|
||||
|
@ -82,12 +100,12 @@ export async function stopServer(req, res) {
|
|||
// Ensure spec is safe
|
||||
const serverSpec = req.body;
|
||||
try {
|
||||
checkServerName(serverSpec);
|
||||
checkServerId(serverSpec);
|
||||
} catch (e) {
|
||||
return sendError(res)(e);
|
||||
}
|
||||
const { name } = serverSpec;
|
||||
toggleServer(name, false)
|
||||
const { id } = serverSpec;
|
||||
toggleServer(id, false)
|
||||
.then(() => res.sendStatus(200))
|
||||
.catch(sendError(res));
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { getDeployments } from "../k8s/k8s-server-control.js";
|
||||
import { getInstances } from "../k8s/server-control.js";
|
||||
import { getInstances } from "../k8s/server-status.js";
|
||||
import { sendError } from "../util/ExpressClientError.js";
|
||||
|
||||
export function serverList(req, res) {
|
||||
getDeployments()
|
||||
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
|
||||
.catch((e) => {
|
||||
ERR("SERVER CONTROL", e);
|
||||
ERR("STATUS CONTROLLER", e);
|
||||
res.status(500).send("Couldn't get server list");
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import k8s from "@kubernetes/client-node";
|
|||
import { Rcon as RconClient } from "rcon-client";
|
||||
import stream from "stream";
|
||||
import { ERR, WARN } from "../../util/logging.js";
|
||||
import { getServerEntry } from "../../database/queries/server-queries.js";
|
||||
|
||||
// Kubernetes Configuration
|
||||
const kc = new k8s.KubeConfig();
|
||||
|
@ -12,8 +13,9 @@ const namespace = process.env.MCL_SERVER_NAMESPACE;
|
|||
|
||||
// Retrieves logs from the minecraft server container
|
||||
export async function webConsoleLogs(socket) {
|
||||
const { serverName } = socket.mcs;
|
||||
const podName = `mcl-${serverName}`;
|
||||
const { serverId } = socket.mcs;
|
||||
const server = await getServerEntry(serverId);
|
||||
const podName = `mcl-${server.mclName}`;
|
||||
const containerName = `${podName}-server`;
|
||||
const podResponse = await k8sCore.listNamespacedPod(namespace);
|
||||
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
|
||||
|
@ -41,14 +43,15 @@ export async function webConsoleLogs(socket) {
|
|||
export async function webConsoleRcon(socket) {
|
||||
if (socket.rconClient)
|
||||
return VERB("RCON", "Socket already connected to RCON");
|
||||
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
|
||||
const { serverId } = socket.mcs;
|
||||
const server = await getServerEntry(serverId);
|
||||
const rconSecret = `mcl-${server.mclName}-rcon-secret`;
|
||||
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
|
||||
const rconPassword = Buffer.from(
|
||||
rconRes.body.data["rcon-password"],
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
const { serverName } = socket.mcs;
|
||||
const rconHost = `mcl-${serverName}-rcon.${namespace}.svc.cluster.local`;
|
||||
const rconHost = `mcl-${server.mclName}-rcon.${namespace}.svc.cluster.local`;
|
||||
const rcon = new RconClient({
|
||||
host: rconHost,
|
||||
port: 25575,
|
||||
|
@ -58,7 +61,7 @@ export async function webConsoleRcon(socket) {
|
|||
try {
|
||||
await rcon.connect();
|
||||
} catch (error) {
|
||||
socket.emit("push", "Could not connect RCON Input to server!");
|
||||
socket.emit("rcon-error", "Could not connect RCON Input to server!");
|
||||
WARN("RCON", `Could not connect to '${rconHost}'`);
|
||||
}
|
||||
socket.rconClient = rcon;
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
CREATE SEQUENCE servers_id_seq;
|
||||
CREATE TABLE servers (
|
||||
id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY,
|
||||
name varchar(255) DEFAULT NULL,
|
||||
host varchar(255) DEFAULT NULL,
|
||||
name varchar(255) DEFAULT NULL,
|
||||
version varchar(63) DEFAULT 'latest',
|
||||
server_type varchar(63) DEFAULT 'VANILLA',
|
||||
cpu varchar(63) DEFAULT '500',
|
||||
memory varchar(63) DEFAULT '512',
|
||||
CONSTRAINT unique_name UNIQUE(name),
|
||||
backup_enabled BOOLEAN DEFAULT FALSE,
|
||||
backup_host varchar(255) DEFAULT NULL,
|
||||
backup_bucket_path varchar(255) DEFAULT NULL,
|
||||
backup_id varchar(255) DEFAULT NULL,
|
||||
backup_key varchar(255) DEFAULT NULL,
|
||||
backup_interval varchar(255) DEFAULT NULL,
|
||||
CONSTRAINT unique_host UNIQUE(host)
|
||||
);
|
||||
ALTER SEQUENCE servers_id_seq OWNED BY servers.id;
|
|
@ -7,35 +7,122 @@ const asExpressClientError = (e) => {
|
|||
throw new ExpressClientError({ m: e.message, c: 409 });
|
||||
};
|
||||
|
||||
const getMclName = (host, id) => `${host.replaceAll(".", "-")}-${id}`;
|
||||
|
||||
export async function createServerEntry(serverSpec) {
|
||||
const { name, host, version, serverType: server_type, memory } = serverSpec;
|
||||
const q = insertQuery(table, { name, host, version, server_type, memory });
|
||||
const {
|
||||
name,
|
||||
host,
|
||||
version,
|
||||
serverType: server_type,
|
||||
memory,
|
||||
backupHost: backup_host,
|
||||
backupBucket: backup_bucket_path,
|
||||
backupId: backup_id,
|
||||
backupKey: backup_key,
|
||||
backupInterval: backup_interval,
|
||||
} = serverSpec;
|
||||
var q = insertQuery(table, {
|
||||
name,
|
||||
host,
|
||||
version,
|
||||
server_type,
|
||||
memory,
|
||||
backup_enabled: !!backup_interval, // We already verified the payload, so any backup key will work
|
||||
backup_host,
|
||||
backup_bucket_path,
|
||||
backup_id,
|
||||
backup_key,
|
||||
backup_interval,
|
||||
});
|
||||
q += "\n RETURNING *";
|
||||
try {
|
||||
const entries = await pg.query(q);
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
version,
|
||||
server_type: serverType,
|
||||
memory,
|
||||
backup_enabled: backupEnabled,
|
||||
backup_host: backupHost,
|
||||
backup_bucket_path: backupPath,
|
||||
backup_id: backupId,
|
||||
backup_key: backupKey,
|
||||
backup_interval: backupInterval,
|
||||
} = entries[0];
|
||||
const mclName = getMclName(host, id);
|
||||
return {
|
||||
name,
|
||||
mclName,
|
||||
id,
|
||||
host,
|
||||
version,
|
||||
serverType,
|
||||
memory,
|
||||
backupEnabled,
|
||||
backupHost,
|
||||
backupPath,
|
||||
backupId,
|
||||
backupKey,
|
||||
backupInterval,
|
||||
};
|
||||
} catch (e) {
|
||||
asExpressClientError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServerEntry(serverId) {
|
||||
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
|
||||
const q = deleteQuery(table, { id: serverId });
|
||||
return pg.query(q).catch(asExpressClientError);
|
||||
}
|
||||
|
||||
export async function deleteServerEntry(serverName) {
|
||||
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
|
||||
const q = deleteQuery(table, { name: serverName });
|
||||
return pg.query(q).catch(asExpressClientError);
|
||||
}
|
||||
|
||||
export async function getServerEntry(serverName) {
|
||||
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
|
||||
const q = selectWhereQuery(table, { name: serverName });
|
||||
export async function getServerEntry(serverId) {
|
||||
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
|
||||
const q = selectWhereQuery(table, { id: serverId });
|
||||
try {
|
||||
const serverSpecs = await pg.query(q);
|
||||
if (serverSpecs.length === 0) return [];
|
||||
if (!serverSpecs.length === 1)
|
||||
throw Error("Multiple servers found with the same name!");
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
host,
|
||||
version,
|
||||
server_type: serverType,
|
||||
memory,
|
||||
backup_enabled: backupEnabled,
|
||||
backup_host: backupHost,
|
||||
backup_bucket_path: backupPath,
|
||||
backup_id: backupId,
|
||||
backup_key: backupKey,
|
||||
backup_interval: backupInterval,
|
||||
} = serverSpecs[0];
|
||||
return { name, host, version, serverType, memory };
|
||||
const mclName = getMclName(host, id);
|
||||
return {
|
||||
name,
|
||||
mclName,
|
||||
id,
|
||||
host,
|
||||
version,
|
||||
serverType,
|
||||
memory,
|
||||
backupEnabled,
|
||||
backupHost,
|
||||
backupPath,
|
||||
backupId,
|
||||
backupKey,
|
||||
backupInterval,
|
||||
};
|
||||
} catch (e) {
|
||||
asExpressClientError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerEntries() {
|
||||
const q = `SELECT * FROM ${table}`;
|
||||
return pg.query(q);
|
||||
}
|
||||
|
|
11
lib/k8s/configs/backup-secret.yml
Normal file
11
lib/k8s/configs/backup-secret.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
app: changeme-app-label
|
||||
name: changeme-backup-secret
|
||||
type: Opaque
|
||||
data:
|
||||
rclone.conf: ""
|
|
@ -6,20 +6,18 @@ env:
|
|||
image: garethflowers/ftp-server
|
||||
imagePullPolicy: IfNotPresent
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["echo"]
|
||||
exec: { command: ["echo"] }
|
||||
failureThreshold: 20
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 5
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
name: changeme-name-ftp
|
||||
ports: [] # Programatically add all the ports for easier readability, Ports include: 20,21,40000-400009
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["echo"]
|
||||
exec: { command: ["echo"] }
|
||||
failureThreshold: 20
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 5
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
|
|
|
@ -33,20 +33,20 @@ env:
|
|||
- name: DEST_DIR
|
||||
value: /backups
|
||||
- name: LINK_LATEST
|
||||
value: "false"
|
||||
value: "true"
|
||||
- name: TAR_COMPRESS_METHOD
|
||||
value: gzip
|
||||
- name: ZSTD_PARAMETERS
|
||||
value: -3 --long=25 --single-thread
|
||||
- name: RCLONE_REMOTE
|
||||
value: mc-dunemask-net
|
||||
value: mcl-backup-changeme
|
||||
- name: RCLONE_DEST_DIR
|
||||
value: /minecraft-backups/deltasmp-backups
|
||||
value: /mcl/backups/changeme
|
||||
- name: RCLONE_COMPRESS_METHOD
|
||||
value: gzip
|
||||
image: itzg/mc-backup:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
name: mcs-deltasmp-minecraft-mc-backup
|
||||
name: mcl-backup-changeme
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
|
|
|
@ -72,12 +72,10 @@ env:
|
|||
image: itzg/minecraft-server:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- mc-health
|
||||
failureThreshold: 20
|
||||
exec: { command: [mc-health] }
|
||||
failureThreshold: 200
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 5
|
||||
periodSeconds: 3
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
name: changeme-name-server
|
||||
|
@ -88,15 +86,13 @@ ports:
|
|||
- containerPort: 25575
|
||||
name: rcon
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- mc-health
|
||||
failureThreshold: 20
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 5
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 1
|
||||
# readinessProbe: # Disabling this allows for users to manipulate files even if the container is starting
|
||||
# exec: {command: [mc-health]}
|
||||
# failureThreshold: 200
|
||||
# initialDelaySeconds: 30
|
||||
# periodSeconds: 3
|
||||
# successThreshold: 1
|
||||
# timeoutSeconds: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
|
|
|
@ -4,7 +4,7 @@ data:
|
|||
kind: Secret
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
app: changeme-app-label
|
||||
name: changeme-rcon-secret
|
||||
|
|
|
@ -2,7 +2,7 @@ apiVersion: v1
|
|||
kind: Service
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
app: changeme-app
|
||||
name: changeme-rcon
|
||||
|
|
|
@ -2,7 +2,7 @@ apiVersion: apps/v1
|
|||
kind: Deployment
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
name: changeme-name
|
||||
namespace: changeme-namespace
|
||||
spec:
|
||||
|
@ -17,7 +17,7 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
app: changeme-app
|
||||
spec:
|
||||
|
@ -35,10 +35,10 @@ spec:
|
|||
claimName: changeme-pvc-name
|
||||
- emptyDir: {}
|
||||
name: backupdir
|
||||
- name: rclone-config
|
||||
secret:
|
||||
defaultMode: 420
|
||||
items:
|
||||
- key: rclone.conf
|
||||
path: rclone.conf
|
||||
secretName: rclone-config
|
||||
# - name: rclone-config
|
||||
# secret:
|
||||
# defaultMode: 420
|
||||
# items:
|
||||
# - key: rclone.conf
|
||||
# path: rclone.conf
|
||||
# secretName: rclone-config
|
||||
|
|
|
@ -2,7 +2,7 @@ apiVersion: v1
|
|||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
annotations:
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
service: changeme-service-name
|
||||
name: changeme-pvc-name
|
||||
|
|
|
@ -4,7 +4,7 @@ metadata:
|
|||
annotations:
|
||||
ingress.qumine.io/hostname: changeme-url
|
||||
ingress.qumine.io/portname: minecraft
|
||||
minecluster.dunemask.net/server-name: changeme-server-name
|
||||
minecluster.dunemask.net/id: changeme-server-id
|
||||
labels:
|
||||
app: changeme-app
|
||||
name: changeme-name
|
||||
|
|
|
@ -20,10 +20,10 @@ const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
|
|||
const mineclusterManaged = (o) =>
|
||||
o.metadata &&
|
||||
o.metadata.annotations &&
|
||||
o.metadata.annotations["minecluster.dunemask.net/server-name"] !== undefined;
|
||||
o.metadata.annotations["minecluster.dunemask.net/id"] !== undefined;
|
||||
|
||||
export const serverMatch = (serverName) => (o) =>
|
||||
o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName;
|
||||
export const serverMatch = (serverId) => (o) =>
|
||||
o.metadata.annotations["minecluster.dunemask.net/id"] === serverId;
|
||||
|
||||
export async function getDeployments() {
|
||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
||||
|
@ -50,8 +50,8 @@ export async function getVolumes() {
|
|||
return serverVolumes;
|
||||
}
|
||||
|
||||
export function getServerAssets(serverName) {
|
||||
const serverFilter = serverMatch(serverName);
|
||||
export function getServerAssets(serverId) {
|
||||
const serverFilter = serverMatch(serverId);
|
||||
return Promise.all([
|
||||
getDeployments(),
|
||||
getServices(),
|
||||
|
@ -66,17 +66,18 @@ export function getServerAssets(serverName) {
|
|||
|
||||
if (deployments.length > 1) throw Error("Deployment filter broken!");
|
||||
if (volumes.length > 1) throw Error("Volume filter broken!");
|
||||
if (secrets.length > 1) throw Error("Secrets broken!");
|
||||
if (secrets.length > 2) throw Error("Secrets broken!");
|
||||
const serverAssets = {
|
||||
deployment: deployments[0],
|
||||
service: services.find(
|
||||
(s) => s.metadata.name === `mcl-${serverName}-server`,
|
||||
),
|
||||
service: services.find((s) => s.metadata.name.endsWith("-server")),
|
||||
volume: volumes[0],
|
||||
rconService: services.find(
|
||||
(s) => s.metadata.name === `mcl-${serverName}-rcon`,
|
||||
rconService: services.find((s) => s.metadata.name.endsWith("-rcon")),
|
||||
rconSecret: secrets.find((s) =>
|
||||
s.metadata.name.endsWith("-rcon-secret"),
|
||||
),
|
||||
backupSecret: secrets.find((s) =>
|
||||
s.metadata.name.endsWith("-backup-secret"),
|
||||
),
|
||||
rconSecret: secrets[0],
|
||||
};
|
||||
for (var k in serverAssets) if (serverAssets[k]) return serverAssets;
|
||||
// If no assets exist, return nothing
|
||||
|
@ -84,59 +85,51 @@ export function getServerAssets(serverName) {
|
|||
.catch((e) => ERR("SERVER ASSETS", e));
|
||||
}
|
||||
|
||||
export async function getDeployment(serverName) {
|
||||
export async function getDeployment(serverId) {
|
||||
const servers = await getDeployments();
|
||||
const serverDeployment = servers.find(
|
||||
(s) =>
|
||||
s.metadata.annotations["minecluster.dunemask.net/server-name"] ===
|
||||
serverName,
|
||||
(s) => s.metadata.annotations["minecluster.dunemask.net/id"] === serverId,
|
||||
);
|
||||
if (!serverDeployment)
|
||||
throw Error(`MCL Deployment '${serverName}' could not be found!`);
|
||||
throw Error(`MCL Deployment with ID '${serverId}' could not be found!`);
|
||||
|
||||
return serverDeployment;
|
||||
}
|
||||
|
||||
export async function getContainers(serverName) {
|
||||
const deployment = await getDeployment(serverName);
|
||||
export async function getContainers(serverId) {
|
||||
const deployment = await getDeployment(serverId);
|
||||
return deployment.spec.template.spec.containers;
|
||||
}
|
||||
|
||||
async function containerControl(serverName, deployment, scaleUp) {
|
||||
async function containerControl(serverSpec, deployment, scaleUp) {
|
||||
const { containers } = deployment.spec.template.spec;
|
||||
const depFtp = containers.find((c) => c.name.endsWith("-ftp"));
|
||||
const depServer = containers.find((c) => c.name.endsWith("-server"));
|
||||
const depBackup = containers.find((c) => c.name.endsWith("-backup"));
|
||||
const serverSpec = await getServerEntry(serverName);
|
||||
const ftpContainer = depFtp ?? getFtpContainer(serverSpec);
|
||||
const serverContainer = depServer ?? getCoreServerContainer(serverSpec);
|
||||
const backupContainer = depBackup ?? getBackupContainer(serverSpec);
|
||||
if (scaleUp) return [ftpContainer, serverContainer];
|
||||
if (scaleUp && serverSpec.backupEnabled)
|
||||
return [ftpContainer, serverContainer, backupContainer];
|
||||
else if (scaleUp) return [ftpContainer, serverContainer];
|
||||
return [ftpContainer];
|
||||
}
|
||||
|
||||
export async function toggleServer(serverName, scaleUp = false) {
|
||||
const deployment = await getDeployment(serverName);
|
||||
deployment.spec.template.spec.containers = await containerControl(
|
||||
serverName,
|
||||
deployment,
|
||||
scaleUp,
|
||||
);
|
||||
return k8sDeps.replaceNamespacedDeployment(
|
||||
deployment.metadata.name,
|
||||
namespace,
|
||||
deployment,
|
||||
);
|
||||
export function terminationControl(containers) {
|
||||
return containers.length > 1 ? 30 /*seconds*/ : 1 /*seconds */;
|
||||
}
|
||||
|
||||
export async function scaleDeployment(serverName, scaleUp = false) {
|
||||
const deployment = await getDeployment(serverName);
|
||||
if (deployment.spec.replicas === 1 && scaleUp)
|
||||
return VERB(
|
||||
"KSC",
|
||||
`MCL Deployment '${serverName}' is already scaled! Ignoring scale adjustment.`,
|
||||
);
|
||||
deployment.spec.replicas = scaleUp ? 1 : 0;
|
||||
export async function toggleServer(serverId, scaleUp = false) {
|
||||
const [deployment, serverSpec] = await Promise.all([
|
||||
getDeployment(serverId),
|
||||
getServerEntry(serverId),
|
||||
]);
|
||||
const containers = await containerControl(serverSpec, deployment, scaleUp);
|
||||
const ts = terminationControl(containers);
|
||||
|
||||
// Speed up container termination if not running a server
|
||||
deployment.spec.template.spec.terminationGracePeriodSeconds = ts;
|
||||
deployment.spec.template.spec.containers = containers;
|
||||
|
||||
return k8sDeps.replaceNamespacedDeployment(
|
||||
deployment.metadata.name,
|
||||
|
|
|
@ -4,9 +4,9 @@ import yaml from "js-yaml";
|
|||
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
|
||||
|
||||
export function getFtpContainer(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
const { mclName } = serverSpec;
|
||||
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
|
||||
ftpContainer.name = `mcl-${name}-ftp`;
|
||||
ftpContainer.name = `mcl-${mclName}-ftp`;
|
||||
const ftpPortList = [
|
||||
{ p: 20, n: "ftp-data" },
|
||||
{ p: 21, n: "ftp-commands" },
|
||||
|
@ -22,10 +22,10 @@ export function getFtpContainer(serverSpec) {
|
|||
}
|
||||
|
||||
export function getCoreServerContainer(serverSpec) {
|
||||
const { name, version, serverType, memory } = serverSpec;
|
||||
const { mclName, version, serverType, memory } = serverSpec;
|
||||
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
|
||||
// Container Updates
|
||||
container.name = `mcl-${name}-server`;
|
||||
container.name = `mcl-${mclName}-server`;
|
||||
container.resources.requests.memory = `${memory}Mi`;
|
||||
|
||||
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
|
||||
|
@ -36,7 +36,7 @@ export function getCoreServerContainer(serverSpec) {
|
|||
updateEnv("VERSION", version);
|
||||
updateEnv("MEMORY", `${memory}M`);
|
||||
// RCON
|
||||
const rs = `mcl-${name}-rcon-secret`;
|
||||
const rs = `mcl-${mclName}-rcon-secret`;
|
||||
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
|
||||
return container;
|
||||
}
|
||||
|
@ -50,18 +50,29 @@ export function getServerContainer(serverSpec) {
|
|||
const updateEnv = (k, v) => (findEnv(k).value = v);
|
||||
|
||||
// Enviornment variables
|
||||
updateEnv("DIFFICULTY", difficulty);
|
||||
/*updateEnv("DIFFICULTY", difficulty);
|
||||
updateEnv("MODE", gamemode);
|
||||
updateEnv("MOTD", motd);
|
||||
updateEnv("MAX_PLAYERS", maxPlayers);
|
||||
updateEnv("SEED", seed);
|
||||
updateEnv("OPS", ops);
|
||||
updateEnv("WHITELIST", whitelist);
|
||||
updateEnv("WHITELIST", whitelist); */
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function getBackupContainer(serverSpec) {
|
||||
const { mclName, backupEnabled, backupPath } = serverSpec;
|
||||
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
|
||||
if (!backupEnabled) return;
|
||||
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
|
||||
const updateEnv = (k, v) => (findEnv(k).value = v);
|
||||
updateEnv("RCLONE_REMOTE", `${mclName}-backup`);
|
||||
updateEnv("RCLONE_DEST_DIR", backupPath);
|
||||
container.name = `mcl-${mclName}-backup`;
|
||||
// RCON
|
||||
const rs = `mcl-${mclName}-rcon-secret`;
|
||||
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
import k8s from "@kubernetes/client-node";
|
||||
import {
|
||||
getDeployment,
|
||||
getDeployments,
|
||||
getServerAssets,
|
||||
scaleDeployment,
|
||||
} from "./k8s-server-control.js";
|
||||
import { ERR } from "../util/logging.js";
|
||||
import ExpressClientError from "../util/ExpressClientError.js";
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromDefault();
|
||||
|
||||
const k8sMetrics = new k8s.Metrics(kc);
|
||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||
|
||||
export async function startServerContainer(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
try {
|
||||
await scaleDeployment(name, true);
|
||||
} catch (e) {
|
||||
ERR("SERVER CONTROL", e);
|
||||
throw new ExpressClientError({
|
||||
c: 500,
|
||||
m: `Error updating server '${name}'!\n`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServerContainer(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
try {
|
||||
await scaleDeployment(name, false);
|
||||
} catch (e) {
|
||||
ERR("SERVER CONTROL", e);
|
||||
throw new ExpressClientError({
|
||||
c: 500,
|
||||
m: `Error updating server '${name}'!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstances() {
|
||||
const serverDeployments = await getDeployments();
|
||||
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
|
||||
var name, metrics, services, serverAvailable, ftpAvailable;
|
||||
const serverInstances = serverDeployments.map((s) => {
|
||||
name = s.metadata.annotations["minecluster.dunemask.net/server-name"];
|
||||
metrics = null;
|
||||
const { containers } = s.spec.template.spec;
|
||||
services = containers.map(({ name }) => name.split("-").pop());
|
||||
const serverStatusList = s.status.conditions.map(
|
||||
({ type: statusType, status: sts }) => ({ statusType, sts }),
|
||||
);
|
||||
const deploymentAvailable =
|
||||
serverStatusList.find(
|
||||
(ss) => ss.statusType === "Available" && ss.sts === "True",
|
||||
) !== undefined;
|
||||
serverAvailable = services.includes(`server`) && deploymentAvailable;
|
||||
ftpAvailable = services.includes("ftp") && deploymentAvailable;
|
||||
|
||||
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
|
||||
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
|
||||
});
|
||||
if (serverAvailable && 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, services, serverAvailable, ftpAvailable };
|
||||
});
|
||||
return serverInstances;
|
||||
}
|
||||
|
||||
export async function getNamespaceMetrics() {
|
||||
const serverInstances = await getInstances();
|
||||
var clusterMetrics = { cpu: 0, memory: 0 };
|
||||
if (servers.length > 1) {
|
||||
const clusterCpu = serverInstances
|
||||
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
||||
.reduce((a, b) => a + b);
|
||||
const clusterMem = serverInstances
|
||||
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
||||
.reduce((a, b) => a + b);
|
||||
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
||||
}
|
||||
return clusterMetrics;
|
||||
}
|
|
@ -4,7 +4,7 @@ import k8s from "@kubernetes/client-node";
|
|||
import yaml from "js-yaml";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import ExpressClientError from "../util/ExpressClientError.js";
|
||||
|
||||
import {
|
||||
getFtpContainer,
|
||||
getServerContainer,
|
||||
|
@ -19,33 +19,54 @@ const namespace = process.env.MCL_SERVER_NAMESPACE;
|
|||
|
||||
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
|
||||
|
||||
function createBackupSecret(serverSpec) {
|
||||
if (!serverSpec.backupEnabled) return; // If backup not defined, don't create RCLONE secret
|
||||
const { mclName, id, backupId, backupKey, backupHost } = serverSpec;
|
||||
const backupYaml = loadYaml("lib/k8s/configs/backup-secret.yml");
|
||||
backupYaml.metadata.labels.app = `mcl-${mclName}-app`;
|
||||
backupYaml.metadata.name = `mcl-${mclName}-backup-secret`;
|
||||
backupYaml.metadata.namespace = namespace;
|
||||
backupYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
const rcloneConfig = [
|
||||
`[${mclName}-backup]`,
|
||||
"type = s3",
|
||||
"provider = Minio",
|
||||
"env_auth = false",
|
||||
`access_key_id = ${backupId}`,
|
||||
`secret_access_key = ${backupKey}`,
|
||||
`endpoint = ${backupHost}`,
|
||||
`acl = private`,
|
||||
].join("\n");
|
||||
backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64");
|
||||
return backupYaml;
|
||||
}
|
||||
|
||||
function createRconSecret(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
const { mclName, id } = serverSpec;
|
||||
const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
|
||||
// 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.labels.app = `mcl-${mclName}-app`;
|
||||
rconYaml.metadata.name = `mcl-${mclName}-rcon-secret`;
|
||||
rconYaml.metadata.namespace = namespace;
|
||||
rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name;
|
||||
rconYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
return rconYaml;
|
||||
}
|
||||
|
||||
function createServerVolume(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
const { mclName, id } = serverSpec;
|
||||
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
|
||||
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
|
||||
volumeYaml.metadata.name = `mcl-${name}-volume`;
|
||||
volumeYaml.metadata.labels.service = `mcl-${mclName}-server`;
|
||||
volumeYaml.metadata.name = `mcl-${mclName}-volume`;
|
||||
volumeYaml.metadata.namespace = namespace;
|
||||
volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||
name;
|
||||
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
|
||||
volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
volumeYaml.spec.resources.requests.storage = "5Gi"; // TODO: Changeme
|
||||
return volumeYaml;
|
||||
}
|
||||
|
||||
function createServerDeploy(serverSpec) {
|
||||
const { name, host } = serverSpec;
|
||||
const { mclName, id, backupEnabled } = serverSpec;
|
||||
const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
|
||||
const { metadata } = deployYaml;
|
||||
const serverContainer = getServerContainer(serverSpec);
|
||||
|
@ -53,39 +74,54 @@ function createServerDeploy(serverSpec) {
|
|||
const ftpContainer = getFtpContainer(serverSpec);
|
||||
|
||||
// Configure Metadata;
|
||||
metadata.name = `mcl-${name}`;
|
||||
metadata.name = `mcl-${mclName}`;
|
||||
metadata.namespace = namespace;
|
||||
metadata.annotations["minecluster.dunemask.net/server-name"] = name;
|
||||
metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
deployYaml.metadata = metadata;
|
||||
|
||||
deployYaml.spec.template.spec.terminationGracePeriodSeconds = 1;
|
||||
|
||||
// Configure Lables & Selectors
|
||||
deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`;
|
||||
deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`;
|
||||
deployYaml.spec.selector.matchLabels.app = `mcl-${mclName}-app`;
|
||||
deployYaml.spec.template.metadata.labels.app = `mcl-${mclName}-app`;
|
||||
deployYaml.spec.template.metadata.annotations["minecluster.dunemask.net/id"] =
|
||||
id;
|
||||
|
||||
// Volumes
|
||||
deployYaml.spec.template.spec.volumes.find(
|
||||
({ name }) => name === "datadir",
|
||||
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
|
||||
).persistentVolumeClaim.claimName = `mcl-${mclName}-volume`;
|
||||
|
||||
// Backups
|
||||
if (backupEnabled) {
|
||||
deployYaml.spec.template.spec.volumes.push({
|
||||
name: "rclone-config",
|
||||
secret: {
|
||||
defaultMode: 420,
|
||||
items: [{ key: "rclone.conf", path: "rclone.conf" }],
|
||||
secretName: `mcl-${mclName}-backup-secret`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Apply Containers TODO: User control for autostart
|
||||
deployYaml.spec.template.spec.containers.push(serverContainer);
|
||||
// deployYaml.spec.template.spec.containers.push(serverContainer);
|
||||
deployYaml.spec.template.spec.containers.push(ftpContainer);
|
||||
deployYaml.spec.replicas = 1;
|
||||
return deployYaml;
|
||||
}
|
||||
|
||||
function createServerService(serverSpec) {
|
||||
const { name, host } = serverSpec;
|
||||
const { mclName, host, id } = serverSpec;
|
||||
const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
|
||||
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host;
|
||||
serviceYaml.metadata.annotations["mc-router.itzg.me/externalServerName"] =
|
||||
host;
|
||||
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
|
||||
serviceYaml.metadata.name = `mcl-${name}-server`;
|
||||
serviceYaml.metadata.labels.app = `mcl-${mclName}-app`;
|
||||
serviceYaml.metadata.name = `mcl-${mclName}-server`;
|
||||
serviceYaml.metadata.namespace = namespace;
|
||||
serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||
name;
|
||||
serviceYaml.spec.selector.app = `mcl-${name}-app`;
|
||||
serviceYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
serviceYaml.spec.selector.app = `mcl-${mclName}-app`;
|
||||
// Port List:
|
||||
const serverPortList = [{ p: 25565, n: "minecraft" }];
|
||||
|
||||
|
@ -107,33 +143,26 @@ function createServerService(serverSpec) {
|
|||
return serviceYaml;
|
||||
}
|
||||
|
||||
function createRconService(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
function createRconService(createSpec) {
|
||||
const { id, mclName } = createSpec;
|
||||
const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml");
|
||||
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
|
||||
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
|
||||
rconSvcYaml.metadata.labels.app = `mcl-${mclName}-app`;
|
||||
rconSvcYaml.metadata.name = `mcl-${mclName}-rcon`;
|
||||
rconSvcYaml.metadata.namespace = namespace;
|
||||
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||
name;
|
||||
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
|
||||
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
|
||||
rconSvcYaml.spec.selector.app = `mcl-${mclName}-app`;
|
||||
return rconSvcYaml;
|
||||
}
|
||||
|
||||
export default async function createServerResources(serverSpec) {
|
||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
||||
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
|
||||
if (deployments.includes(`mcl-${serverSpec.name}`))
|
||||
throw new ExpressClientError({ m: "Server already exists!", c: 409 });
|
||||
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
|
||||
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
|
||||
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
|
||||
throw new ExpressClientError({ m: "Server PVC already exists!", c: 409 });
|
||||
const rconSecret = createRconSecret(serverSpec);
|
||||
const serverVolume = createServerVolume(serverSpec);
|
||||
const serverDeploy = createServerDeploy(serverSpec);
|
||||
const serverService = createServerService(serverSpec);
|
||||
const rconService = createRconService(serverSpec);
|
||||
export default async function createServerResources(createSpec) {
|
||||
const backupSecret = createBackupSecret(createSpec);
|
||||
const rconSecret = createRconSecret(createSpec);
|
||||
const serverVolume = createServerVolume(createSpec);
|
||||
const serverDeploy = createServerDeploy(createSpec);
|
||||
const serverService = createServerService(createSpec);
|
||||
const rconService = createRconService(createSpec);
|
||||
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume);
|
||||
if (!!backupSecret) k8sCore.createNamespacedSecret(namespace, backupSecret);
|
||||
k8sCore.createNamespacedSecret(namespace, rconSecret);
|
||||
k8sCore.createNamespacedService(namespace, serverService);
|
||||
k8sCore.createNamespacedService(namespace, rconService);
|
||||
|
|
|
@ -22,9 +22,9 @@ function deleteOnExist(o, fn) {
|
|||
}
|
||||
|
||||
export default async function deleteServerResources(serverSpec) {
|
||||
const { name } = serverSpec;
|
||||
const { id } = serverSpec;
|
||||
// Ensure deployment exists
|
||||
const server = await getServerAssets(name);
|
||||
const server = await getServerAssets(id);
|
||||
if (!server)
|
||||
throw new ExpressClientError({
|
||||
c: 404,
|
||||
|
@ -47,6 +47,10 @@ export default async function deleteServerResources(serverSpec) {
|
|||
const deleteRconSecret = deleteOnExist(server.rconSecret, (name) =>
|
||||
k8sCore.deleteNamespacedSecret(name, namespace),
|
||||
);
|
||||
|
||||
const deleteBackupSecret = deleteOnExist(server.backupSecret, (name) =>
|
||||
k8sCore.deleteNamespacedSecret(name, namespace),
|
||||
);
|
||||
const deleteVolume = deleteOnExist(server.volume, (name) =>
|
||||
k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
|
||||
);
|
||||
|
@ -55,6 +59,7 @@ export default async function deleteServerResources(serverSpec) {
|
|||
deleteService,
|
||||
deleteRconService,
|
||||
deleteRconSecret,
|
||||
deleteBackupSecret,
|
||||
deleteVolume,
|
||||
]).catch(deleteError);
|
||||
}
|
||||
|
|
|
@ -34,8 +34,8 @@ export async function getFtpClient(serverService) {
|
|||
}
|
||||
|
||||
export async function useServerFtp(serverSpec, fn) {
|
||||
const { name } = serverSpec;
|
||||
const server = await getServerAssets(name);
|
||||
const { id } = serverSpec;
|
||||
const server = await getServerAssets(id);
|
||||
if (!server)
|
||||
throw new ExpressClientError({
|
||||
c: 404,
|
||||
|
|
83
lib/k8s/server-status.js
Normal file
83
lib/k8s/server-status.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
import k8s from "@kubernetes/client-node";
|
||||
import { getDeployments } from "./k8s-server-control.js";
|
||||
import { getServerEntries } from "../database/queries/server-queries.js";
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromDefault();
|
||||
|
||||
const k8sMetrics = new k8s.Metrics(kc);
|
||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||
|
||||
function getServerMetrics(podMetricsRes, serverId, serverAvailable) {
|
||||
const pod = podMetricsRes.items.find(({ metadata: md }) => {
|
||||
return (
|
||||
md.annotations &&
|
||||
md.annotations["minecluster.dunemask.net/id"] === serverId
|
||||
);
|
||||
});
|
||||
if (!serverAvailable || !pod) return null;
|
||||
const podCpus = pod.containers.map(
|
||||
({ usage }) => parseInt(usage.cpu) / 1_000_000,
|
||||
);
|
||||
const podMems = pod.containers.map(
|
||||
({ usage }) => parseInt(usage.memory) / 1024,
|
||||
);
|
||||
return {
|
||||
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
|
||||
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
|
||||
};
|
||||
}
|
||||
|
||||
function getServerStatus(server) {
|
||||
const { containers } = server.spec.template.spec;
|
||||
const services = containers.map(({ name }) => name.split("-").pop());
|
||||
const serverStatusList = server.status.conditions.map(
|
||||
({ type: statusType, status: sts }) => ({ statusType, sts }),
|
||||
);
|
||||
const deploymentAvailable =
|
||||
serverStatusList.find(
|
||||
(ss) => ss.statusType === "Available" && ss.sts === "True",
|
||||
) !== undefined;
|
||||
const serverAvailable = services.includes(`server`) && deploymentAvailable;
|
||||
const ftpAvailable = services.includes("ftp"); // TODO this needs some handling for container creation
|
||||
return { serverAvailable, ftpAvailable, services };
|
||||
}
|
||||
|
||||
export async function getInstances() {
|
||||
const [serverDeployments, podMetricsRes, entries] = await Promise.all([
|
||||
getDeployments(),
|
||||
k8sMetrics.getPodMetrics(namespace),
|
||||
getServerEntries(),
|
||||
]);
|
||||
|
||||
var serverId, metrics;
|
||||
const serverInstances = serverDeployments.map((s) => {
|
||||
serverId = s.metadata.annotations["minecluster.dunemask.net/id"];
|
||||
const entry = entries.find((e) => e.id === serverId);
|
||||
const { ftpAvailable, serverAvailable, services } = getServerStatus(s);
|
||||
metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable);
|
||||
return {
|
||||
name: !!entry ? entry.name : "Unknown",
|
||||
id: serverId,
|
||||
metrics,
|
||||
services,
|
||||
serverAvailable,
|
||||
ftpAvailable,
|
||||
};
|
||||
});
|
||||
return serverInstances;
|
||||
}
|
||||
|
||||
export async function getNamespaceMetrics() {
|
||||
const serverInstances = await getInstances();
|
||||
var clusterMetrics = { cpu: 0, memory: 0 };
|
||||
if (servers.length > 1) {
|
||||
const clusterCpu = serverInstances
|
||||
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
||||
.reduce((a, b) => a + b);
|
||||
const clusterMem = serverInstances
|
||||
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
||||
.reduce((a, b) => a + b);
|
||||
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
||||
}
|
||||
return clusterMetrics;
|
||||
}
|
|
@ -18,7 +18,7 @@ async function rconSend(socket, m) {
|
|||
|
||||
const socketConnect = async (io, socket) => {
|
||||
VERB("WS", "Websocket connecting");
|
||||
socket.mcs = { serverName: socket.handshake.query.serverName };
|
||||
socket.mcs = { serverId: socket.handshake.query.serverId };
|
||||
try {
|
||||
await webConsoleLogs(socket);
|
||||
await webConsoleRcon(socket);
|
||||
|
|
304
package-lock.json
generated
304
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -28,12 +28,15 @@
|
|||
"@mui/material": "^5.14.20",
|
||||
"@tanstack/react-query": "^5.12.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"chonky": "^2.3.2",
|
||||
"chonky-icon-fontawesome": "^2.3.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"nodemon": "^3.0.2",
|
||||
"prettier": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-toastify": "^9.1.3",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"vite": "^5.0.7"
|
||||
},
|
||||
|
@ -43,8 +46,6 @@
|
|||
"basic-ftp": "^5.0.4",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chalk": "^5.3.0",
|
||||
"chonky": "^2.3.2",
|
||||
"chonky-icon-fontawesome": "^2.3.2",
|
||||
"express": "^4.18.2",
|
||||
"figlet": "^1.7.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
|
74
src/components/files/FilePreview.jsx
Normal file
74
src/components/files/FilePreview.jsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Button from "@mui/material/Button";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
|
||||
const textFileTypes = ["properties", "txt", "yaml", "yml", "json", "env"];
|
||||
const imageFileTypes = ["png", "jpeg", "jpg"];
|
||||
|
||||
export const supportedFileTypes = [...textFileTypes, ...imageFileTypes];
|
||||
|
||||
export function useFilePreview(isOpen = false) {
|
||||
const [open, setOpen] = useState(isOpen);
|
||||
const dialogToggle = () => setOpen(!open);
|
||||
return [open, dialogToggle];
|
||||
}
|
||||
|
||||
function TextPreview(props) {
|
||||
const { fileText } = props;
|
||||
return <div style={{ whiteSpace: "break-spaces" }}>{fileText}</div>;
|
||||
}
|
||||
|
||||
export default function FilePreview(props) {
|
||||
const [fileText, setFileText] = useState();
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
const { previewData, open, dialogToggle } = props;
|
||||
const { fileData, name } = previewData ?? {};
|
||||
const ext = name ? name.split(".").pop() : null;
|
||||
const isTextFile = textFileTypes.includes(ext);
|
||||
|
||||
async function onPreviewChange() {
|
||||
if (isTextFile) setFileText(await fileData.text());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewChange();
|
||||
}, [fileData]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
sx={
|
||||
fullScreen
|
||||
? {}
|
||||
: {
|
||||
"& .mcl-MuiDialog-paper": {
|
||||
width: "100%",
|
||||
maxHeight: 525,
|
||||
maxWidth: "80%",
|
||||
},
|
||||
}
|
||||
}
|
||||
maxWidth="xs"
|
||||
open={open}
|
||||
fullScreen={fullScreen}
|
||||
>
|
||||
<Toolbar sx={{ display: { sm: "none" } }} />
|
||||
<DialogTitle>{name}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextPreview fileText={fileText} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={dialogToggle}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -17,8 +17,9 @@ import {
|
|||
deleteServerItem,
|
||||
getServerItem,
|
||||
} from "@mcl/queries";
|
||||
import { previewServerItem } from "../../util/queries";
|
||||
|
||||
import "@mcl/css/header.css";
|
||||
import { supportedFileTypes } from "./FilePreview.jsx";
|
||||
|
||||
export default function MineclusterFiles(props) {
|
||||
// Chonky configuration
|
||||
|
@ -33,17 +34,23 @@ export default function MineclusterFiles(props) {
|
|||
],
|
||||
[],
|
||||
);
|
||||
const { server: serverName } = props;
|
||||
const { server: serverId, changePreview } = props;
|
||||
const inputRef = useRef(null);
|
||||
const [dirStack, setDirStack] = useState(["."]);
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
const updateFiles = () => {
|
||||
const dir = dirStack.join("/");
|
||||
getServerFiles(serverName, dir).then((f) => {
|
||||
getServerFiles(serverId, dir)
|
||||
.then((f) => {
|
||||
const files = f.map((fi) => ({ ...fi, id: `${dir}/${fi.name}` }));
|
||||
setFiles(files ?? []);
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
console.error(
|
||||
"Couldn't update files, server likely hasn't started yet",
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -61,22 +68,25 @@ export default function MineclusterFiles(props) {
|
|||
|
||||
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
|
||||
|
||||
function openFolder(payload) {
|
||||
function openItem(payload) {
|
||||
const { targetFile: file } = payload;
|
||||
if (file && file.isDir) return setDirStack(file.id.split("/"));
|
||||
if (file && !file.isDir) return downloadFiles([file]);
|
||||
if (!file || file.isDir) return; // Ensure file exists or is dir
|
||||
if (supportedFileTypes.includes(file.name.split(".").pop()))
|
||||
return previewFile(file);
|
||||
return downloadFiles([file]);
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
const name = prompt("What is the name of the new folder?");
|
||||
const path = [...dirStack, name].join("/");
|
||||
createServerFolder(serverName, path).then(updateFiles);
|
||||
createServerFolder(serverId, path).then(updateFiles);
|
||||
}
|
||||
|
||||
function deleteItems(files) {
|
||||
Promise.all(
|
||||
files.map((f) =>
|
||||
deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir),
|
||||
deleteServerItem(serverId, [...dirStack, f.name].join("/"), f.isDir),
|
||||
),
|
||||
)
|
||||
.catch((e) => console.error("Error deleting some files!", e))
|
||||
|
@ -94,7 +104,7 @@ export default function MineclusterFiles(props) {
|
|||
async function uploadFile(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("name", serverName);
|
||||
formData.append("id", serverId);
|
||||
formData.append("path", [...dirStack, name].join("/"));
|
||||
await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
|
@ -105,13 +115,20 @@ export default function MineclusterFiles(props) {
|
|||
async function downloadFiles(files) {
|
||||
Promise.all(
|
||||
files.map((f) =>
|
||||
getServerItem(serverName, f.name, [...dirStack, f.name].join("/")),
|
||||
getServerItem(serverId, f.name, [...dirStack, f.name].join("/")),
|
||||
),
|
||||
)
|
||||
.then(() => console.log("Done downloading files!"))
|
||||
.catch((e) => console.error("Error Downloading files!", e));
|
||||
}
|
||||
|
||||
function previewFile(file) {
|
||||
const { name } = file;
|
||||
previewServerItem(serverId, [...dirStack, name].join("/")).then(
|
||||
(fileData) => changePreview(name, fileData),
|
||||
);
|
||||
}
|
||||
|
||||
function fileClick(chonkyEvent) {
|
||||
const { id: clickEvent, payload } = chonkyEvent;
|
||||
if (clickEvent === "open_parent_folder") return openParentFolder();
|
||||
|
@ -122,7 +139,7 @@ export default function MineclusterFiles(props) {
|
|||
if (clickEvent === "delete_files")
|
||||
return deleteItems(chonkyEvent.state.selectedFilesForAction);
|
||||
if (clickEvent !== "open_files") return; // console.log(clickEvent);
|
||||
openFolder(payload);
|
||||
openItem(payload);
|
||||
}
|
||||
return (
|
||||
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
|
||||
|
@ -134,7 +151,6 @@ export default function MineclusterFiles(props) {
|
|||
onChange={uploadFileSelection}
|
||||
multiple
|
||||
/>
|
||||
|
||||
<FileBrowser
|
||||
files={files}
|
||||
folderChain={getFolderChain()}
|
||||
|
@ -144,6 +160,7 @@ export default function MineclusterFiles(props) {
|
|||
>
|
||||
<FileNavbar />
|
||||
<FileToolbar />
|
||||
|
||||
<FileList />
|
||||
<FileContextMenu />
|
||||
</FileBrowser>
|
||||
|
|
15
src/components/server-options/BackupBucketOption.jsx
Normal file
15
src/components/server-options/BackupBucketOption.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function BackupBucketOption(props) {
|
||||
const { value, onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Bucket Path"
|
||||
onChange={onChange}
|
||||
defaultValue={value}
|
||||
helperText="Example: /minecraft-backups/example-backups"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
14
src/components/server-options/BackupHostOption.jsx
Normal file
14
src/components/server-options/BackupHostOption.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function BackupHostOption(props) {
|
||||
const { onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Backup Host"
|
||||
onChange={onChange}
|
||||
helperText="Example: s3.mydomain.com"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
14
src/components/server-options/BackupIdOption.jsx
Normal file
14
src/components/server-options/BackupIdOption.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function BackupIdOption(props) {
|
||||
const { onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="S3 Access Key ID"
|
||||
onChange={onChange}
|
||||
helperText="Example: s3-access-key-id"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
55
src/components/server-options/BackupIntervalOption.jsx
Normal file
55
src/components/server-options/BackupIntervalOption.jsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { useState } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
|
||||
const backupIntervalStepDisplay = ["Minutes", "Hours", "Days"];
|
||||
export const backupIntervalDefault = "1d";
|
||||
export const backupIntervalStepOptions = ["m", "h", "d"];
|
||||
export default function BackupIntervalOption(props) {
|
||||
const { onChange } = props;
|
||||
const [interval, setInterval] = useState(1);
|
||||
const [intervalStep, setIntervalStep] = useState(
|
||||
backupIntervalStepOptions[2],
|
||||
);
|
||||
|
||||
const changeStep = (e) => {
|
||||
setIntervalStep(e.target.value);
|
||||
onChange({ target: { value: `${interval}${e.target.value}` } });
|
||||
};
|
||||
|
||||
const changeInterval = (e) => {
|
||||
setInterval(e.target.value);
|
||||
onChange({ target: { value: `${e.target.value}${intervalStep}` } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
label="Backup Interval"
|
||||
sx={{ width: "70%" }}
|
||||
value={interval}
|
||||
onChange={changeInterval}
|
||||
helperText="Examples: 1m, 3h, 3.5d"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
type="number"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Step"
|
||||
sx={{ width: "30%", minWidth: "4rem" }}
|
||||
onChange={onChange}
|
||||
value={intervalStep}
|
||||
select
|
||||
required
|
||||
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||
>
|
||||
{backupIntervalStepOptions.map((o, i) => (
|
||||
<MenuItem value={o} key={i}>
|
||||
{backupIntervalStepDisplay[i]}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
);
|
||||
}
|
14
src/components/server-options/BackupKeyOption.jsx
Normal file
14
src/components/server-options/BackupKeyOption.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function BackupKeyOption(props) {
|
||||
const { onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="S3 Access Key"
|
||||
onChange={onChange}
|
||||
helperText="Example: s3-access-key"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
26
src/components/server-options/CpuOption.jsx
Normal file
26
src/components/server-options/CpuOption.jsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
const maxCpuSupported = 8;
|
||||
export const cpuOptions = new Array(2 * maxCpuSupported)
|
||||
.fill(0)
|
||||
.map((v, i) => (i + 1) * 0.5);
|
||||
|
||||
export default function CpuOption(props) {
|
||||
const { value, onChange } = props;
|
||||
return (
|
||||
<TextField
|
||||
label="CPU"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
select
|
||||
required
|
||||
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||
disabled // TODO: Enable on backend support
|
||||
>
|
||||
{cpuOptions.map((o, i) => (
|
||||
<MenuItem value={o} key={i}>{`${o} CPU`}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
14
src/components/server-options/HostOption.jsx
Normal file
14
src/components/server-options/HostOption.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function HostOption(props) {
|
||||
const { onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Host"
|
||||
onChange={onChange}
|
||||
helperText="Example: host.mydomain.com"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
24
src/components/server-options/MemoryOption.jsx
Normal file
24
src/components/server-options/MemoryOption.jsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
const maxMemSupported = 10;
|
||||
export const memoryOptions = new Array(2 * maxMemSupported)
|
||||
.fill(0)
|
||||
.map((v, i) => (i + 1) * 512);
|
||||
|
||||
export default function Option(props) {
|
||||
const { value, onChange } = props;
|
||||
return (
|
||||
<TextField
|
||||
label="Memory"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
select
|
||||
required
|
||||
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||
>
|
||||
{memoryOptions.map((o, i) => (
|
||||
<MenuItem value={o} key={i}>{`${o / 1024} Gi`}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
14
src/components/server-options/NameOption.jsx
Normal file
14
src/components/server-options/NameOption.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
export default function NameOption(props) {
|
||||
const { onChange } = props;
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Name"
|
||||
onChange={onChange}
|
||||
helperText="Example: My Survival World"
|
||||
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
25
src/components/server-options/ServerTypeOption.jsx
Normal file
25
src/components/server-options/ServerTypeOption.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
const displayOption = (o) => o.charAt(0) + o.toLowerCase().slice(1);
|
||||
|
||||
export const serverTypeOptions = ["VANILLA", "FABRIC", "PAPER", "SPIGOT"];
|
||||
export default function ServerTypeOption(props) {
|
||||
const { value, onChange } = props;
|
||||
return (
|
||||
<TextField
|
||||
label="Memory"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
select
|
||||
required
|
||||
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||
>
|
||||
{serverTypeOptions.map((o, i) => (
|
||||
<MenuItem value={o} key={i}>
|
||||
{displayOption(o)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
37
src/components/server-options/VersionOption.jsx
Normal file
37
src/components/server-options/VersionOption.jsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { useVersionList } from "@mcl/queries";
|
||||
|
||||
export default function VersionOption(props) {
|
||||
const { value, onChange } = props;
|
||||
const versionList = useVersionList();
|
||||
const [versions, setVersions] = useState(["latest"]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionList.data) return;
|
||||
setVersions([
|
||||
"latest",
|
||||
...versionList.data.versions
|
||||
.filter(({ type: releaseType }) => releaseType === "release")
|
||||
.map(({ id }) => id),
|
||||
]);
|
||||
}, [versionList.data]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label="Version"
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
select
|
||||
required
|
||||
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||
>
|
||||
{versions.map((v, k) => (
|
||||
<MenuItem value={v} key={k}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Button from "@mui/material/Button";
|
||||
|
@ -16,15 +16,17 @@ export function useRconDialog(isOpen = false) {
|
|||
}
|
||||
|
||||
export default function RconDialog(props) {
|
||||
const { serverName, open, dialogToggle } = props;
|
||||
const { server, open, dialogToggle } = props;
|
||||
const { name: serverName, id: serverId } = server ?? {};
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
sx={
|
||||
fullScreen
|
||||
? {}
|
||||
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
|
||||
: { "& .mcl-MuiDialog-paper": { width: "80%", maxHeight: 555 } }
|
||||
}
|
||||
maxWidth="xs"
|
||||
open={open}
|
||||
|
@ -33,7 +35,7 @@ export default function RconDialog(props) {
|
|||
<Toolbar sx={{ display: { sm: "none" } }} />
|
||||
<DialogTitle>RCON - {serverName}</DialogTitle>
|
||||
<DialogContent>
|
||||
<RconView serverName={serverName} />
|
||||
<RconView serverId={serverId} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button autoFocus onClick={dialogToggle}>
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { io } from "socket.io-client";
|
||||
export default class RconSocket {
|
||||
constructor(logUpdate, serverName) {
|
||||
(this.sk = io("/", { query: { serverName } })), (this.logs = []);
|
||||
constructor(logUpdate, serverId) {
|
||||
(this.sk = io("/", { query: { serverId } })), (this.logs = []);
|
||||
this.logUpdate = logUpdate;
|
||||
this.sk.on("push", this.onPush.bind(this));
|
||||
this.sk.on("connect", this.onConnect.bind(this));
|
||||
this.sk.on("rcon-error", this.onRconError.bind(this));
|
||||
this.sk.on("error", () => console.log("WHOOSPSIE I GUESS?"));
|
||||
this.rconLive = false;
|
||||
}
|
||||
|
||||
onPush(p) {
|
||||
this.rconLive = true;
|
||||
this.logs = [...this.logs, p];
|
||||
this.logUpdate(this.logs);
|
||||
}
|
||||
|
@ -16,7 +20,13 @@ export default class RconSocket {
|
|||
this.sk.emit("msg", m);
|
||||
}
|
||||
|
||||
onRconError(v) {
|
||||
this.rconLive = false;
|
||||
console.log("Server sent" + v);
|
||||
}
|
||||
|
||||
onConnect() {
|
||||
this.sk.readyState = 1;
|
||||
this.logs = [];
|
||||
}
|
||||
|
||||
|
|
|
@ -6,22 +6,32 @@ import RconSocket from "./RconSocket.js";
|
|||
import "@mcl/css/rcon.css";
|
||||
|
||||
export default function RconView(props) {
|
||||
const { serverName } = props;
|
||||
const { serverId } = props;
|
||||
const logsRef = useRef(0);
|
||||
const [cmd, setCmd] = useState("");
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [rcon, setRcon] = useState({});
|
||||
const [rcon, setRcon] = useState();
|
||||
const updateCmd = (e) => setCmd(e.target.value);
|
||||
useEffect(function () {
|
||||
setRcon(new RconSocket(setLogs, serverName));
|
||||
return () => {
|
||||
if (rcon && typeof rcon.disconnect === "function") rcon.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logsRef.current.scrollTo(0, logsRef.current.scrollHeight);
|
||||
}, [rcon.logs]);
|
||||
const disconnectRcon = () => {
|
||||
if (!rcon || typeof rcon.disconnect !== "function") return;
|
||||
rcon.disconnect();
|
||||
};
|
||||
|
||||
useEffect(
|
||||
function () {
|
||||
if (!serverId) return;
|
||||
const rs = new RconSocket(setLogs, serverId);
|
||||
setRcon(rs);
|
||||
return disconnectRcon;
|
||||
},
|
||||
[serverId],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => logsRef.current.scrollTo(0, logsRef.current.scrollHeight),
|
||||
[(rcon ?? {}).logs],
|
||||
);
|
||||
|
||||
function sendCommand() {
|
||||
rcon.send(cmd);
|
||||
|
@ -45,8 +55,12 @@ export default function RconView(props) {
|
|||
variant="outlined"
|
||||
value={cmd}
|
||||
onChange={updateCmd}
|
||||
disabled={!(rcon && rcon.rconLive)}
|
||||
/>
|
||||
<Button onClick={sendCommand}>Send</Button>
|
||||
{rcon && rcon.rconLive && <Button onClick={sendCommand}>Send</Button>}
|
||||
{!(rcon && rcon.rconLive) && (
|
||||
<Button color="secondary">Not Connected</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,6 @@ import Typography from "@mui/material/Typography";
|
|||
import StopIcon from "@mui/icons-material/Stop";
|
||||
import TerminalIcon from "@mui/icons-material/Terminal";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import PendingIcon from "@mui/icons-material/Pending";
|
||||
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
|
@ -19,10 +18,10 @@ import { Link } from "react-router-dom";
|
|||
|
||||
export default function ServerCard(props) {
|
||||
const { server, openRcon } = props;
|
||||
const { name, metrics, ftpAvailable, serverAvailable, services } = server;
|
||||
const startServer = useStartServer(name);
|
||||
const stopServer = useStopServer(name);
|
||||
const deleteServer = useDeleteServer(name);
|
||||
const { name, id, metrics, ftpAvailable, serverAvailable, services } = server;
|
||||
const startServer = useStartServer(id);
|
||||
const stopServer = useStopServer(id);
|
||||
const deleteServer = useDeleteServer(id);
|
||||
function toggleRcon() {
|
||||
if (!services.includes("server")) return;
|
||||
openRcon();
|
||||
|
@ -113,7 +112,7 @@ export default function ServerCard(props) {
|
|||
aria-label="Edit"
|
||||
size="large"
|
||||
component={Link}
|
||||
to={`/mcl/edit?server=${name}`}
|
||||
to={`/mcl/edit?server=${id}`}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
|
@ -122,8 +121,8 @@ export default function ServerCard(props) {
|
|||
aria-label="Files"
|
||||
size="large"
|
||||
component={Link}
|
||||
to={`/mcl/files?server=${name}`}
|
||||
disabled={!services.includes("ftp")}
|
||||
to={`/mcl/files?server=${id}`}
|
||||
disabled={!ftpAvailable}
|
||||
>
|
||||
<FolderIcon />
|
||||
</IconButton>
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
.appbar-items {
|
||||
font-size: 1.25rem;
|
||||
font-family: "Roboto", "Helvetica", "Arial", sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.0075em;
|
||||
}
|
||||
|
||||
.view > header {
|
||||
transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
|
||||
box-shadow:
|
||||
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
|
||||
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 10px 0px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: auto;
|
||||
right: 0;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
z-index: 1302;
|
||||
background-color: #29985c;
|
||||
}
|
||||
.view > header > div > div > a {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
|
@ -13,7 +13,7 @@ import IconButton from "@mui/material/IconButton";
|
|||
import Typography from "@mui/material/Typography";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
|
@ -36,18 +36,52 @@ export default function MCLMenu() {
|
|||
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
|
||||
|
||||
return (
|
||||
<AppBar position="fixed" color="primary" sx={{ zIndex: drawerIndex() }}>
|
||||
<Box
|
||||
sx={{ flexGrow: 1, margin: "0 20px", color: "white" }}
|
||||
className="appbar-items"
|
||||
>
|
||||
<AppBar position="fixed" sx={{ zIndex: drawerIndex() }}>
|
||||
<Box sx={{ flexGrow: 1, margin: "0 20px" }} className="appbar-items">
|
||||
<Toolbar disableGutters>
|
||||
<IconButton component={Link} to="/" color="inherit">
|
||||
<HomeIcon />
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ mr: 2 }}
|
||||
onClick={toggleDrawer}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<span style={{ margin: "auto 0", color: "inherit" }}>
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
sx={{ zIndex: drawerIndex(true) }}
|
||||
className="mcl-menu-drawer"
|
||||
>
|
||||
<Toolbar />
|
||||
<Box
|
||||
sx={{ width: drawerWidth, overflow: "auto" }}
|
||||
role="presentation"
|
||||
>
|
||||
<List>
|
||||
{pages.map(
|
||||
(page, index) =>
|
||||
page.visible && (
|
||||
<ListItemButton
|
||||
key={index}
|
||||
component={Link}
|
||||
to={page.path}
|
||||
selected={location.pathname === page.path}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
<ListItemIcon>{page.icon}</ListItemIcon>
|
||||
<ListItemText primary={page.name} />
|
||||
</ListItemButton>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
</Drawer>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{navHeader()}
|
||||
</span>
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</Box>
|
||||
</AppBar>
|
||||
|
|
|
@ -11,17 +11,20 @@ export default [
|
|||
path: "/mcl/home",
|
||||
icon: <HomeIcon />,
|
||||
component: <Home />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "Create",
|
||||
path: "/mcl/create",
|
||||
icon: <AddIcon />,
|
||||
component: <Create />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: "Edit",
|
||||
path: "/mcl/files",
|
||||
icon: <AddIcon />,
|
||||
component: <Files />,
|
||||
visible: false,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import Box from "@mui/material/Box";
|
||||
import CreateOptions from "./CreateOptions.jsx";
|
||||
import CreateCoreOptions from "./CreateCoreOptions.jsx";
|
||||
export default function Create() {
|
||||
return (
|
||||
<Box className="create">
|
||||
{/*<CreateMenu />*/}
|
||||
<Box className="create-wrapper" sx={{ display: "flex" }}>
|
||||
<CreateOptions />
|
||||
<CreateCoreOptions />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
|
145
src/pages/CreateCoreOptions.jsx
Normal file
145
src/pages/CreateCoreOptions.jsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useCreateServer } from "@mcl/queries";
|
||||
|
||||
// Core Options
|
||||
import NameOption from "@mcl/components/server-options/NameOption.jsx";
|
||||
import HostOption from "@mcl/components/server-options/HostOption.jsx";
|
||||
import VersionOption from "@mcl/components/server-options/VersionOption.jsx";
|
||||
import ServerTypeOption, {
|
||||
serverTypeOptions,
|
||||
} from "@mcl/components/server-options/ServerTypeOption.jsx";
|
||||
import CpuOption, {
|
||||
cpuOptions,
|
||||
} from "@mcl/components/server-options/CpuOption.jsx";
|
||||
import MemoryOption, {
|
||||
memoryOptions,
|
||||
} from "@mcl/components/server-options/MemoryOption.jsx";
|
||||
|
||||
import BackupHostOption from "@mcl/components/server-options/BackupHostOption.jsx";
|
||||
import BackupBucketOption from "@mcl/components/server-options/BackupBucketOption.jsx";
|
||||
import BackupIdOption from "@mcl/components/server-options/BackupIdOption.jsx";
|
||||
import BackupKeyOption from "@mcl/components/server-options/BackupKeyOption.jsx";
|
||||
import BackupIntervalOption, {
|
||||
backupIntervalDefault,
|
||||
} from "@mcl/components/server-options/BackupIntervalOption.jsx";
|
||||
|
||||
const defaultServer = {
|
||||
version: "latest",
|
||||
serverType: serverTypeOptions[0],
|
||||
cpu: cpuOptions[0],
|
||||
memory: memoryOptions[2], // 1.5GB
|
||||
};
|
||||
|
||||
export default function CreateCoreOptions() {
|
||||
const [backupEnabled, setBackupEnabled] = useState(false);
|
||||
const [spec, setSpec] = useState(defaultServer);
|
||||
const nav = useNavigate();
|
||||
const createServer = useCreateServer(spec);
|
||||
|
||||
const updateSpec = (attr, val) => {
|
||||
const s = { ...spec };
|
||||
s[attr] = val;
|
||||
setSpec(s);
|
||||
};
|
||||
|
||||
const coreUpdate = (attr) => (e) => updateSpec(attr, e.target.value);
|
||||
|
||||
async function upsertSpec() {
|
||||
if (validateSpec() !== "validated") return;
|
||||
createServer(spec)
|
||||
// .then(() => nav("/"))
|
||||
.catch(alert);
|
||||
}
|
||||
|
||||
function validateSpec() {
|
||||
console.log("TODO CREATE VALIDATION");
|
||||
if (!spec.host) return alertValidationError("Host cannot be blank");
|
||||
if (!spec.name) return alertValidationError("Name not included");
|
||||
if (!spec.version) return alertValidationError("Version cannot be blank");
|
||||
return "validated";
|
||||
}
|
||||
|
||||
function alertValidationError(reason) {
|
||||
alert(`Could not validate spec because: ${reason}`);
|
||||
}
|
||||
|
||||
const toggleBackupEnabled = () => {
|
||||
const s = { ...spec };
|
||||
if (!backupEnabled) {
|
||||
(s.backupInterval = backupIntervalDefault),
|
||||
(s.backupBucket = `/mcl/server-backups/${(
|
||||
s.name ?? "my-server"
|
||||
).toLowerCase()}`);
|
||||
} else for (var k in s) if (k.startsWith("backup")) delete s[k];
|
||||
setSpec(s);
|
||||
console.log(s);
|
||||
setBackupEnabled(!backupEnabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="create-options"
|
||||
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
|
||||
>
|
||||
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
|
||||
<NameOption onChange={coreUpdate("name")} />
|
||||
<HostOption onChange={coreUpdate("host")} />
|
||||
<VersionOption value={spec.version} onChange={coreUpdate("version")} />
|
||||
<ServerTypeOption
|
||||
value={spec.serverType}
|
||||
onChange={coreUpdate("serverType")}
|
||||
/>
|
||||
<CpuOption value={spec.cpu} onChange={coreUpdate("cpu")} />
|
||||
<MemoryOption value={spec.memory} onChange={coreUpdate("memory")} />
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={backupEnabled}
|
||||
onChange={toggleBackupEnabled}
|
||||
inputProps={{ "aria-label": "controlled" }}
|
||||
/>
|
||||
}
|
||||
label="Enable Backups?"
|
||||
labelPlacement="start"
|
||||
sx={{ mr: "auto" }}
|
||||
/>
|
||||
{backupEnabled && (
|
||||
<FormControl
|
||||
fullWidth
|
||||
sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}
|
||||
>
|
||||
<Typography variant="h6">Backups</Typography>
|
||||
<BackupHostOption
|
||||
value={spec.backupHost}
|
||||
onChange={coreUpdate("backupHost")}
|
||||
/>
|
||||
<BackupBucketOption
|
||||
value={spec.backupBucket}
|
||||
onChange={coreUpdate("backupBucket")}
|
||||
/>
|
||||
<BackupIdOption
|
||||
value={spec.backupId}
|
||||
onChange={coreUpdate("backupId")}
|
||||
/>
|
||||
<BackupKeyOption
|
||||
value={spec.backupKey}
|
||||
onChange={coreUpdate("backupKey")}
|
||||
/>
|
||||
<BackupIntervalOption onChange={coreUpdate("backupInterval")} />
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
<Button onClick={upsertSpec} variant="contained">
|
||||
Create
|
||||
</Button>
|
||||
</FormControl>
|
||||
</Box>
|
||||
);
|
||||
}
|
|
@ -1,20 +1,36 @@
|
|||
import { useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams, useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import FilePreview, {
|
||||
useFilePreview,
|
||||
} from "@mcl/components/files/FilePreview.jsx";
|
||||
import MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx";
|
||||
|
||||
export default function Files() {
|
||||
const [open, dialogToggle] = useFilePreview();
|
||||
const [previewData, setPreviewData] = useState();
|
||||
const [searchParams] = useSearchParams();
|
||||
const currentServer = searchParams.get("server");
|
||||
const nav = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!currentServer) nav("/");
|
||||
}, [currentServer]);
|
||||
|
||||
function changePreview(name, fileData) {
|
||||
setPreviewData({ name, fileData });
|
||||
dialogToggle();
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="edit" sx={{ height: "100%" }}>
|
||||
<MineclusterFiles server={currentServer} />
|
||||
<FilePreview
|
||||
open={open}
|
||||
dialogToggle={dialogToggle}
|
||||
previewData={previewData}
|
||||
/>
|
||||
<MineclusterFiles server={currentServer} changePreview={changePreview} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -30,6 +30,7 @@ export default function Home() {
|
|||
setServer(s);
|
||||
rconToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="home">
|
||||
<Overview clusterMetrics={clusterMetrics} />
|
||||
|
@ -50,10 +51,10 @@ export default function Home() {
|
|||
<Box className="servers">
|
||||
{!isLoading &&
|
||||
servers.map((s, k) => (
|
||||
<ServerCard key={k} server={s} openRcon={openRcon(s.name)} />
|
||||
<ServerCard key={k} server={s} openRcon={openRcon(s)} />
|
||||
))}
|
||||
</Box>
|
||||
<RconDialog open={rdOpen} dialogToggle={rconToggle} serverName={server} />
|
||||
<RconDialog open={rdOpen} dialogToggle={rconToggle} server={server} />
|
||||
<Button
|
||||
component={Link}
|
||||
to="/mcl/create"
|
||||
|
|
|
@ -20,38 +20,45 @@ const fetchApiPost = (subPath, json) => async () =>
|
|||
body: JSON.stringify(json),
|
||||
}).then((res) => res.json());
|
||||
|
||||
export const useServerStatus = (server) =>
|
||||
export const useServerStatus = (serverId) =>
|
||||
useQuery({
|
||||
queryKey: [`server-status-${server}`],
|
||||
queryFn: fetchApiPost("/server/status", { name: server }),
|
||||
queryKey: [`server-status-${serverId}`],
|
||||
queryFn: fetchApiPost("/server/status", { id: serverId }),
|
||||
});
|
||||
export const useServerMetrics = (server) =>
|
||||
export const useServerMetrics = (serverId) =>
|
||||
useQuery({
|
||||
queryKey: [`server-metrics-${server}`],
|
||||
queryFn: fetchApiPost("/server/metrics", { name: server }),
|
||||
queryKey: [`server-metrics-${serverId}`],
|
||||
queryFn: fetchApiPost("/server/metrics", { id: serverId }),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
export const useStartServer = (server) =>
|
||||
postJsonApi("/server/start", { name: server }, "server-instances");
|
||||
export const useStopServer = (server) =>
|
||||
postJsonApi("/server/stop", { name: server }, "server-instances");
|
||||
export const useDeleteServer = (server) =>
|
||||
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
|
||||
export const useStartServer = (serverId) =>
|
||||
postJsonApi("/server/start", { id: serverId }, "server-instances");
|
||||
export const useStopServer = (serverId) =>
|
||||
postJsonApi("/server/stop", { id: serverId }, "server-instances");
|
||||
export const useDeleteServer = (serverId) =>
|
||||
postJsonApi("/server/delete", { id: serverId }, "server-instances", "DELETE");
|
||||
export const useCreateServer = (spec) =>
|
||||
postJsonApi("/server/create", spec, "server-list");
|
||||
|
||||
export const getServerFiles = async (server, path) =>
|
||||
fetchApiCore("/files/list", { name: server, path }, "POST", true);
|
||||
export const createServerFolder = async (server, path) =>
|
||||
export const getServerFiles = async (serverId, path) =>
|
||||
fetchApiCore("/files/list", { id: serverId, path }, "POST", true);
|
||||
export const createServerFolder = async (serverId, path) =>
|
||||
fetchApiCore("/files/folder", {
|
||||
name: server,
|
||||
id: serverId,
|
||||
path,
|
||||
});
|
||||
export const deleteServerItem = async (server, path, isDir) =>
|
||||
fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE");
|
||||
export const deleteServerItem = async (serverId, path, isDir) =>
|
||||
fetchApiCore("/files/item", { id: serverId, path, isDir }, "DELETE");
|
||||
|
||||
export const getServerItem = async (server, name, path) =>
|
||||
fetchApiCore("/files/item", { name: server, path })
|
||||
export async function previewServerItem(serverId, path) {
|
||||
const resp = await fetchApiCore("/files/item", { id: serverId, path });
|
||||
if (!resp.status === 200) return console.log("AHHHH");
|
||||
const blob = await resp.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
export const getServerItem = async (serverId, name, path) =>
|
||||
fetchApiCore("/files/item", { id: serverId, path })
|
||||
.then((resp) =>
|
||||
resp.status === 200
|
||||
? resp.blob()
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
// Generated using https://zenoo.github.io/mui-theme-creator/
|
||||
import { createTheme } from "@mui/material/styles";
|
||||
import { unstable_ClassNameGenerator as ClassNameGenerator } from "@mui/material/className";
|
||||
// This fixes style clashing with Chonky which has not been updated to Material 5
|
||||
// see https://github.com/TimboKZ/Chonky/issues/101#issuecomment-1362949314
|
||||
ClassNameGenerator.configure((componentName) => `mcl-${componentName}`);
|
||||
|
||||
const themeOptions = {
|
||||
palette: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue