[FEATURE] Basic System with file manager (#4)
Co-authored-by: dunemask <dunemask@gmail.com> Co-authored-by: Dunemask <dunemask@gmail.com> Reviewed-on: https://gitea.dunemask.dev/elysium/minecluster/pulls/4
This commit is contained in:
parent
8fb5b34c77
commit
4f19cf19d9
62 changed files with 5910 additions and 1190 deletions
8
dist/app.js
vendored
8
dist/app.js
vendored
|
@ -1,4 +1,3 @@
|
||||||
import stream from "stream";
|
|
||||||
import k8s from "@kubernetes/client-node";
|
import k8s from "@kubernetes/client-node";
|
||||||
import Minecluster from "../lib/Minecluster.js";
|
import Minecluster from "../lib/Minecluster.js";
|
||||||
const mcl = new Minecluster();
|
const mcl = new Minecluster();
|
||||||
|
@ -7,11 +6,6 @@ mcl.start();
|
||||||
async function main(){
|
async function main(){
|
||||||
const kc = new k8s.KubeConfig();
|
const kc = new k8s.KubeConfig();
|
||||||
kc.loadFromDefault();
|
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)});
|
main().catch((e)=>{console.log(e)});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { INFO, OK, logInfo } from "./util/logging.js";
|
||||||
// Import Core Modules
|
// Import Core Modules
|
||||||
import buildRoutes from "./server/router.js";
|
import buildRoutes from "./server/router.js";
|
||||||
import injectSockets from "./server/sockets.js";
|
import injectSockets from "./server/sockets.js";
|
||||||
|
import pg from "./database/postgres.js";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const title = "MCL";
|
const title = "MCL";
|
||||||
|
@ -23,6 +24,7 @@ export default class Minecluster {
|
||||||
logInfo(fig.textSync(title, "Larry 3D"));
|
logInfo(fig.textSync(title, "Larry 3D"));
|
||||||
INFO("INIT", "Initializing...");
|
INFO("INIT", "Initializing...");
|
||||||
this.app = express();
|
this.app = express();
|
||||||
|
this.pg = pg;
|
||||||
this.server = http.createServer(this.app);
|
this.server = http.createServer(this.app);
|
||||||
this.sockets = injectSockets(this.server, this.jobs);
|
this.sockets = injectSockets(this.server, this.jobs);
|
||||||
this.routes = buildRoutes(this.sockets);
|
this.routes = buildRoutes(this.sockets);
|
||||||
|
@ -31,11 +33,12 @@ export default class Minecluster {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _connect() {
|
async _connect() {
|
||||||
// await this.pg.connect();
|
await this.pg.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
const mcl = this;
|
const mcl = this;
|
||||||
|
|
||||||
return new Promise(async function init(res) {
|
return new Promise(async function init(res) {
|
||||||
mcl._preinitialize();
|
mcl._preinitialize();
|
||||||
await mcl._connect();
|
await mcl._connect();
|
||||||
|
|
70
lib/controllers/file-controller.js
Normal file
70
lib/controllers/file-controller.js
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
import {
|
||||||
|
createServerFolder,
|
||||||
|
getServerItem,
|
||||||
|
listServerFiles,
|
||||||
|
removeServerItem,
|
||||||
|
uploadServerItem,
|
||||||
|
} from "../k8s/server-files.js";
|
||||||
|
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!");
|
||||||
|
listServerFiles(serverSpec)
|
||||||
|
.then((f) => {
|
||||||
|
const fileData = f.map((fi, i) => ({
|
||||||
|
name: fi.name,
|
||||||
|
isDir: fi.type === 2,
|
||||||
|
id: `${fi.name}-${i}`,
|
||||||
|
isHidden: fi.name.startsWith("."),
|
||||||
|
isSymLink: !!fi.link,
|
||||||
|
size: fi.size,
|
||||||
|
}));
|
||||||
|
res.json(fileData);
|
||||||
|
})
|
||||||
|
.catch(sendError(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.path) return res.status(400).send("Path required!");
|
||||||
|
createServerFolder(serverSpec)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(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.path) return res.status(400).send("Path required!");
|
||||||
|
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
|
||||||
|
return res.status(400).send("IsDIr required!");
|
||||||
|
removeServerItem(serverSpec)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadItem(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
uploadServerItem(serverSpec, req.file)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItem(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
||||||
|
if (!serverSpec.path) return res.status(400).send("Path required!");
|
||||||
|
getServerItem(serverSpec, res)
|
||||||
|
.then(({ ds, ftpTransfer }) => {
|
||||||
|
ds.pipe(res).on("error", sendError(res));
|
||||||
|
return ftpTransfer;
|
||||||
|
})
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
93
lib/controllers/lifecycle-controller.js
Normal file
93
lib/controllers/lifecycle-controller.js
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import createServerResources from "../k8s/server-create.js";
|
||||||
|
import deleteServerResources from "../k8s/server-delete.js";
|
||||||
|
import {
|
||||||
|
createServerEntry,
|
||||||
|
deleteServerEntry,
|
||||||
|
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";
|
||||||
|
|
||||||
|
function payloadFilter(req, res) {
|
||||||
|
const serverSpec = req.body;
|
||||||
|
if (!serverSpec) return res.sendStatus(400);
|
||||||
|
const { name, host, version, serverType, difficulty, gamemode, memory } =
|
||||||
|
serverSpec;
|
||||||
|
if (!name) return res.status(400).send("Server name is required!");
|
||||||
|
if (!host) return res.status(400).send("Server host 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 checkServerName(serverSpec) {
|
||||||
|
if (!serverSpec) throw new ExpressClientError({ c: 400 });
|
||||||
|
if (!serverSpec.name)
|
||||||
|
throw new ExpressClientError({ c: 400, m: "Server name required!" });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
res.sendStatus(200);
|
||||||
|
} catch (e) {
|
||||||
|
sendError(res)(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
checkServerName(serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const deleteEntry = deleteServerEntry(serverSpec.name);
|
||||||
|
const deleteResources = deleteServerResources(serverSpec);
|
||||||
|
Promise.all([deleteEntry, deleteResources])
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
checkServerName(serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const { name } = serverSpec;
|
||||||
|
toggleServer(name, true)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopServer(req, res) {
|
||||||
|
// Ensure spec is safe
|
||||||
|
const serverSpec = req.body;
|
||||||
|
try {
|
||||||
|
checkServerName(serverSpec);
|
||||||
|
} catch (e) {
|
||||||
|
return sendError(res)(e);
|
||||||
|
}
|
||||||
|
const { name } = serverSpec;
|
||||||
|
toggleServer(name, false)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
18
lib/controllers/status-controller.js
Normal file
18
lib/controllers/status-controller.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import { getDeployments } from "../k8s/k8s-server-control.js";
|
||||||
|
import { getInstances } from "../k8s/server-control.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);
|
||||||
|
res.status(500).send("Couldn't get server list");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serverInstances(req, res) {
|
||||||
|
getInstances()
|
||||||
|
.then((i) => res.json(i))
|
||||||
|
.catch(sendError(res));
|
||||||
|
}
|
65
lib/controllers/sub-controllers/console-controller.js
Normal file
65
lib/controllers/sub-controllers/console-controller.js
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
// Imports
|
||||||
|
import k8s from "@kubernetes/client-node";
|
||||||
|
import { Rcon as RconClient } from "rcon-client";
|
||||||
|
import stream from "stream";
|
||||||
|
import { ERR, WARN } from "../../util/logging.js";
|
||||||
|
|
||||||
|
// Kubernetes Configuration
|
||||||
|
const kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromDefault();
|
||||||
|
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
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 containerName = `${podName}-server`;
|
||||||
|
const podResponse = await k8sCore.listNamespacedPod(namespace);
|
||||||
|
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
|
||||||
|
const mcsPods = pods.filter((p) => p.startsWith(podName));
|
||||||
|
if (mcsPods.length === 0)
|
||||||
|
throw Error(`Could not find a pod that starts with ${podName}`);
|
||||||
|
if (mcsPods.length > 1)
|
||||||
|
throw Error(`Multiple pods match the name ${podName}`);
|
||||||
|
|
||||||
|
const log = new k8s.Log(kc);
|
||||||
|
const logStream = new stream.PassThrough();
|
||||||
|
logStream.on("data", (chunk) =>
|
||||||
|
socket.emit("push", Buffer.from(chunk).toString()),
|
||||||
|
);
|
||||||
|
log
|
||||||
|
.log(namespace, mcsPods[0], containerName, logStream, {
|
||||||
|
follow: true,
|
||||||
|
pretty: false,
|
||||||
|
timestamps: false,
|
||||||
|
})
|
||||||
|
.catch((e) => ERR("CONSOLE CONTROLLER", "Error streaming logs", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates an RCON connection to the minecraft container
|
||||||
|
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 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 rcon = new RconClient({
|
||||||
|
host: rconHost,
|
||||||
|
port: 25575,
|
||||||
|
password: rconPassword,
|
||||||
|
});
|
||||||
|
rcon.on("error", (error) => socket.emit("push", error));
|
||||||
|
try {
|
||||||
|
await rcon.connect();
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit("push", "Could not connect RCON Input to server!");
|
||||||
|
WARN("RCON", `Could not connect to '${rconHost}'`);
|
||||||
|
}
|
||||||
|
socket.rconClient = rcon;
|
||||||
|
}
|
12
lib/database/migrations/1_create_servers_table.sql
Normal file
12
lib/database/migrations/1_create_servers_table.sql
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
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,
|
||||||
|
version varchar(63) DEFAULT 'latest',
|
||||||
|
server_type varchar(63) DEFAULT 'VANILLA',
|
||||||
|
memory varchar(63) DEFAULT '512',
|
||||||
|
CONSTRAINT unique_name UNIQUE(name),
|
||||||
|
CONSTRAINT unique_host UNIQUE(host)
|
||||||
|
);
|
||||||
|
ALTER SEQUENCE servers_id_seq OWNED BY servers.id;
|
121
lib/database/pg-query.js
Normal file
121
lib/database/pg-query.js
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
const buildPostgresEntry = (entry) => {
|
||||||
|
const pgEntry = { ...entry };
|
||||||
|
Object.keys(pgEntry).forEach((col) => {
|
||||||
|
if (pgEntry[col] === undefined) delete pgEntry[col];
|
||||||
|
});
|
||||||
|
return pgEntry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPostgresValue = (jsVar) => {
|
||||||
|
if (jsVar === null) return "null";
|
||||||
|
if (typeof jsVar === "string") return buildPostgresString(jsVar);
|
||||||
|
if (Array.isArray(jsVar) && jsVar.length === 0) return "null";
|
||||||
|
if (Array.isArray(jsVar) && isTypeArray(jsVar, "string"))
|
||||||
|
return buildPostgresStringArray(jsVar);
|
||||||
|
return jsVar;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPostgresStringArray = (jsonArray) => {
|
||||||
|
if (jsonArray.length === 0) return null;
|
||||||
|
var pgArray = [...jsonArray];
|
||||||
|
var arrayString = "ARRAY [";
|
||||||
|
pgArray.forEach((e, i) => (pgArray[i] = `'${e}'`));
|
||||||
|
arrayString += pgArray.join(",");
|
||||||
|
arrayString += "]";
|
||||||
|
return arrayString;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTypeArray = (jsonArray, type) =>
|
||||||
|
jsonArray.every((e) => typeof e === type);
|
||||||
|
|
||||||
|
const buildPostgresString = (jsonString) =>
|
||||||
|
(jsonString && `'${jsonString.replaceAll("'", "''")}'`) || null;
|
||||||
|
|
||||||
|
export const insertQuery = (table, jsEntry) => {
|
||||||
|
if (typeof jsEntry !== "object") throw Error("PG Inserts must be objects!");
|
||||||
|
const entry = buildPostgresEntry(jsEntry);
|
||||||
|
const cols = Object.keys(entry);
|
||||||
|
cols.forEach((col, i) => {
|
||||||
|
entry[col] = buildPostgresValue(entry[col]);
|
||||||
|
cols[i] = `"${col}"`;
|
||||||
|
});
|
||||||
|
var query = `INSERT INTO ${table}(${cols.join(",")})\n`;
|
||||||
|
query += `VALUES(${Object.values(entry).join(",")})`;
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteQuery = (table, jsEntry) => {
|
||||||
|
if (typeof jsEntry !== "object")
|
||||||
|
throw Error("PG Delete conditionals must be an object!");
|
||||||
|
const entry = buildPostgresEntry(jsEntry);
|
||||||
|
const cols = Object.keys(entry);
|
||||||
|
const conditionals = [];
|
||||||
|
for (var col of cols) {
|
||||||
|
entry[col] = buildPostgresValue(entry[col]);
|
||||||
|
if (entry[col] === "null") conditionals.push(`x.${col} IS NULL`);
|
||||||
|
else conditionals.push(`x.${col}=${entry[col]}`);
|
||||||
|
}
|
||||||
|
return `DELETE FROM ${table} x WHERE ${conditionals.join(" AND ")}`;
|
||||||
|
};
|
||||||
|
export const onConflictUpdate = (conflicts, updates) => {
|
||||||
|
if (!Array.isArray(conflicts)) throw Error("PG Conflicts must be an array!");
|
||||||
|
if (typeof updates !== "object") throw Error("PG Updates must be objects!");
|
||||||
|
const entry = buildPostgresEntry(updates);
|
||||||
|
var query = `ON CONFLICT (${conflicts.join(",")}) DO UPDATE SET\n`;
|
||||||
|
const cols = Object.keys(entry);
|
||||||
|
for (var col of cols) {
|
||||||
|
entry[col] = buildPostgresValue(entry[col]);
|
||||||
|
}
|
||||||
|
query += cols.map((c) => `${c}=${entry[c]}`).join(",");
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
export const clearTableQuery = (table) => {
|
||||||
|
return `TRUNCATE ${table}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectWhereQuery = (table, jsEntry, joinWith) => {
|
||||||
|
if (typeof jsEntry !== "object") throw Error("PG Where must be an object!");
|
||||||
|
const where = buildPostgresEntry(jsEntry);
|
||||||
|
const cols = Object.keys(where);
|
||||||
|
var query = `SELECT * FROM ${table} AS x WHERE\n`;
|
||||||
|
for (var col of cols) {
|
||||||
|
where[col] = buildPostgresValue(where[col]);
|
||||||
|
}
|
||||||
|
return (query += cols.map((c) => `x.${c}=${where[c]}`).join(joinWith));
|
||||||
|
};
|
||||||
|
export const updateWhereQuery = (table, updates, wheres, joinWith) => {
|
||||||
|
if (typeof updates !== "object") throw Error("PG Updates must be an object!");
|
||||||
|
if (typeof wheres !== "object") throw Error("PG Wheres must be an object!");
|
||||||
|
const update = buildPostgresEntry(updates);
|
||||||
|
const where = buildPostgresEntry(wheres);
|
||||||
|
const updateCols = Object.keys(update);
|
||||||
|
const whereCols = Object.keys(where);
|
||||||
|
var query = `UPDATE ${table}\n`;
|
||||||
|
var updateQuery = updateCols
|
||||||
|
.map((c) => `${c} = ${buildPostgresValue(update[c])}`)
|
||||||
|
.join(",");
|
||||||
|
var whereQuery = whereCols
|
||||||
|
.map((c) => `${c} = ${buildPostgresValue(where[c])}`)
|
||||||
|
.join(joinWith);
|
||||||
|
return (query += `SET ${updateQuery} WHERE ${whereQuery}`);
|
||||||
|
};
|
||||||
|
export const updateWhereAnyQuery = (table, updates, wheres) =>
|
||||||
|
updateWhereQuery(table, updates, wheres, " OR ");
|
||||||
|
export const updateWhereAllQuery = (table, updates, wheres) =>
|
||||||
|
updateWhereQuery(table, updates, wheres, " AND ");
|
||||||
|
export const selectWhereAnyQuery = (table, where) =>
|
||||||
|
selectWhereQuery(table, where, " OR ");
|
||||||
|
export const selectWhereAllQuery = (table, where) =>
|
||||||
|
selectWhereQuery(table, where, " AND ");
|
||||||
|
|
||||||
|
export default {
|
||||||
|
selectWhereAnyQuery,
|
||||||
|
selectWhereAllQuery,
|
||||||
|
updateWhereAnyQuery,
|
||||||
|
updateWhereAllQuery,
|
||||||
|
insertQuery,
|
||||||
|
deleteQuery,
|
||||||
|
buildPostgresValue,
|
||||||
|
onConflictUpdate,
|
||||||
|
clearTableQuery,
|
||||||
|
};
|
63
lib/database/postgres.js
Normal file
63
lib/database/postgres.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
// Imports
|
||||||
|
import path from "node:path";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
import { migrate } from "postgres-migrations";
|
||||||
|
import createPgp from "pg-promise";
|
||||||
|
import moment from "moment";
|
||||||
|
import { INFO, WARN, OK, VERB } from "../util/logging.js";
|
||||||
|
|
||||||
|
// Environment Variables
|
||||||
|
const {
|
||||||
|
MCL_POSTGRES_DATABASE: database,
|
||||||
|
MCL_POSTGRES_ENABLED: pgEnabled,
|
||||||
|
MCL_POSTGRES_HOST: host,
|
||||||
|
MCL_POSTGRES_PASSWORD: password,
|
||||||
|
MCL_POSTGRES_PORT: port,
|
||||||
|
MCL_POSTGRES_USER: user,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
// Postgres-promise Configuration
|
||||||
|
// Ensure dates get saved as UTC date strings
|
||||||
|
// This prevents the parser from doing strange datetime operations
|
||||||
|
const pgp = createPgp();
|
||||||
|
pgp.pg.types.setTypeParser(1114, (str) => moment.utc(str).format());
|
||||||
|
|
||||||
|
// Database Config
|
||||||
|
const dbConfig = {
|
||||||
|
database: database ?? "minecluster",
|
||||||
|
user: user ?? "postgres",
|
||||||
|
password: password ?? "postgres",
|
||||||
|
host: host ?? "localhost",
|
||||||
|
port: port ?? 5432,
|
||||||
|
ensureDatabaseExists: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const databaseDir = new URL(".", import.meta.url).pathname;
|
||||||
|
const migrationsDir = path.resolve(databaseDir, "migrations/");
|
||||||
|
|
||||||
|
const queryMock = (str) => INFO("POSTGRES MOCK", str);
|
||||||
|
|
||||||
|
const connect = (pg) => async () => {
|
||||||
|
if (pgEnabled === "false") {
|
||||||
|
WARN("POSTGRES", "Postgres Disabled!");
|
||||||
|
return { query: queryMock };
|
||||||
|
}
|
||||||
|
VERB("POSTGRES", "Migrating...");
|
||||||
|
await migrate(dbConfig, migrationsDir);
|
||||||
|
// Override fake methods
|
||||||
|
const pgInstance = pgp(dbConfig);
|
||||||
|
for (var k in pgInstance) pg[k] = pgInstance[k];
|
||||||
|
VERB("POSTGRES", "Migrated Successfully!");
|
||||||
|
await pg.connect();
|
||||||
|
VERB("POSTGRES", "Postgres connected Successfully!");
|
||||||
|
|
||||||
|
OK("POSTGRES", `Connected to database ${dbConfig.database}!`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPostgres = () => {
|
||||||
|
var pg = { query: queryMock };
|
||||||
|
pg.connect = connect(pg);
|
||||||
|
return pg;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default buildPostgres();
|
41
lib/database/queries/server-queries.js
Normal file
41
lib/database/queries/server-queries.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import pg from "../postgres.js";
|
||||||
|
import { deleteQuery, insertQuery, selectWhereQuery } from "../pg-query.js";
|
||||||
|
import ExpressClientError from "../../util/ExpressClientError.js";
|
||||||
|
const table = "servers";
|
||||||
|
|
||||||
|
const asExpressClientError = (e) => {
|
||||||
|
throw new ExpressClientError({ m: e.message, c: 409 });
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createServerEntry(serverSpec) {
|
||||||
|
const { name, host, version, serverType: server_type, memory } = serverSpec;
|
||||||
|
const q = insertQuery(table, { name, host, version, server_type, memory });
|
||||||
|
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 });
|
||||||
|
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 {
|
||||||
|
name,
|
||||||
|
host,
|
||||||
|
version,
|
||||||
|
server_type: serverType,
|
||||||
|
memory,
|
||||||
|
} = serverSpecs[0];
|
||||||
|
return { name, host, version, serverType, memory };
|
||||||
|
} catch (e) {
|
||||||
|
asExpressClientError(e);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
36
lib/k8s/configs/containers/ftp-server.yml
Normal file
36
lib/k8s/configs/containers/ftp-server.yml
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
env:
|
||||||
|
- name: FTP_USER
|
||||||
|
value: "minecluster"
|
||||||
|
- name: FTP_PASS
|
||||||
|
value: "minecluster"
|
||||||
|
image: garethflowers/ftp-server
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["echo"]
|
||||||
|
failureThreshold: 20
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
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"]
|
||||||
|
failureThreshold: 20
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 5
|
||||||
|
successThreshold: 1
|
||||||
|
timeoutSeconds: 1
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
stdin: true
|
||||||
|
terminationMessagePath: /dev/termination-log
|
||||||
|
terminationMessagePolicy: File
|
||||||
|
tty: true
|
||||||
|
volumeMounts:
|
||||||
|
- mountPath: /home/minecluster
|
||||||
|
name: datadir
|
63
lib/k8s/configs/containers/minecraft-backup.yml
Normal file
63
lib/k8s/configs/containers/minecraft-backup.yml
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
113
lib/k8s/configs/containers/minecraft-server.yml
Normal file
113
lib/k8s/configs/containers/minecraft-server.yml
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
env:
|
||||||
|
# System Values
|
||||||
|
- name: JVM_OPTS
|
||||||
|
- name: JVM_XX_OPTS
|
||||||
|
- name: OVERRIDE_SERVER_PROPERTIES
|
||||||
|
value: "false"
|
||||||
|
- name: EULA
|
||||||
|
value: "TRUE"
|
||||||
|
# Updated at recreation
|
||||||
|
- name: MEMORY
|
||||||
|
value: 1024M
|
||||||
|
- name: TYPE
|
||||||
|
value: VANILLA
|
||||||
|
- name: VERSION
|
||||||
|
value: "latest"
|
||||||
|
# Set at creation but not updated on recreation
|
||||||
|
- name: DIFFICULTY
|
||||||
|
value: easy
|
||||||
|
- name: WHITELIST
|
||||||
|
- name: OPS
|
||||||
|
- 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: ONLINE_MODE
|
||||||
|
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-server
|
||||||
|
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
|
|
@ -3,6 +3,8 @@ data:
|
||||||
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
|
rcon-password: UEphT3V2aGJlQjNvc3M0dElwQU5YTUZrSkltR1RsRVl0ZGx3elFqZjJLdVZrZXNtV0hja1VhUUd3bmZDcElpbA==
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app-label
|
app: changeme-app-label
|
||||||
name: changeme-rcon-secret
|
name: changeme-rcon-secret
|
||||||
|
|
|
@ -2,6 +2,7 @@ apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
annotations:
|
annotations:
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
name: changeme-rcon
|
name: changeme-rcon
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
name: changeme-name
|
name: changeme-name
|
||||||
namespace: changeme-namespace
|
namespace: changeme-namespace
|
||||||
spec:
|
spec:
|
||||||
|
@ -14,191 +16,18 @@ spec:
|
||||||
type: Recreate
|
type: Recreate
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
spec:
|
spec:
|
||||||
containers:
|
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
|
dnsPolicy: ClusterFirst
|
||||||
restartPolicy: Always
|
restartPolicy: Always
|
||||||
schedulerName: default-scheduler
|
schedulerName: default-scheduler
|
||||||
securityContext:
|
# securityContext:
|
||||||
fsGroup: 2000
|
# fsGroup: 2000
|
||||||
runAsUser: 1000
|
# runAsUser: 1000
|
||||||
terminationGracePeriodSeconds: 30
|
terminationGracePeriodSeconds: 30
|
||||||
volumes:
|
volumes:
|
||||||
- name: datadir
|
- name: datadir
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
|
annotations:
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
labels:
|
labels:
|
||||||
service: changeme-service-name
|
service: changeme-service-name
|
||||||
name: changeme-pvc-name
|
name: changeme-pvc-name
|
||||||
|
|
|
@ -4,6 +4,7 @@ metadata:
|
||||||
annotations:
|
annotations:
|
||||||
ingress.qumine.io/hostname: changeme-url
|
ingress.qumine.io/hostname: changeme-url
|
||||||
ingress.qumine.io/portname: minecraft
|
ingress.qumine.io/portname: minecraft
|
||||||
|
minecluster.dunemask.net/server-name: changeme-server-name
|
||||||
labels:
|
labels:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
name: changeme-name
|
name: changeme-name
|
||||||
|
@ -13,11 +14,15 @@ spec:
|
||||||
ipFamilies:
|
ipFamilies:
|
||||||
- IPv4
|
- IPv4
|
||||||
ipFamilyPolicy: SingleStack
|
ipFamilyPolicy: SingleStack
|
||||||
ports:
|
ports: # Programatically add all FTP ports. Port range includes 20, 21, 40000-40001
|
||||||
- name: minecraft
|
- name: minecraft
|
||||||
port: 25565
|
port: 25565
|
||||||
protocol: TCP
|
protocol: TCP
|
||||||
targetPort: minecraft
|
targetPort: minecraft
|
||||||
|
# - name: ftp-data
|
||||||
|
# port: 20
|
||||||
|
# protocol: TCP
|
||||||
|
# targetPort: ftp-data
|
||||||
selector:
|
selector:
|
||||||
app: changeme-app
|
app: changeme-app
|
||||||
sessionAffinity: None
|
sessionAffinity: None
|
||||||
|
|
146
lib/k8s/k8s-server-control.js
Normal file
146
lib/k8s/k8s-server-control.js
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import k8s from "@kubernetes/client-node";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
import { VERB, ERR } from "../util/logging.js";
|
||||||
|
import { getServerEntry } from "../database/queries/server-queries.js";
|
||||||
|
import {
|
||||||
|
getFtpContainer,
|
||||||
|
getCoreServerContainer,
|
||||||
|
getBackupContainer,
|
||||||
|
} from "./server-containers.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 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;
|
||||||
|
|
||||||
|
export const serverMatch = (serverName) => (o) =>
|
||||||
|
o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName;
|
||||||
|
|
||||||
|
export async function getDeployments() {
|
||||||
|
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
||||||
|
const serverDeployments = deploymentRes.body.items.filter(mineclusterManaged);
|
||||||
|
return serverDeployments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServices() {
|
||||||
|
const serviceRes = await k8sCore.listNamespacedService(namespace);
|
||||||
|
const serverServices = serviceRes.body.items.filter(mineclusterManaged);
|
||||||
|
return serverServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSecrets() {
|
||||||
|
const secretRes = await k8sCore.listNamespacedSecret(namespace);
|
||||||
|
const serverSecrets = secretRes.body.items.filter(mineclusterManaged);
|
||||||
|
return serverSecrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVolumes() {
|
||||||
|
const volumeRes =
|
||||||
|
await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
|
||||||
|
const serverVolumes = volumeRes.body.items.filter(mineclusterManaged);
|
||||||
|
return serverVolumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerAssets(serverName) {
|
||||||
|
const serverFilter = serverMatch(serverName);
|
||||||
|
return Promise.all([
|
||||||
|
getDeployments(),
|
||||||
|
getServices(),
|
||||||
|
getSecrets(),
|
||||||
|
getVolumes(),
|
||||||
|
])
|
||||||
|
.then(([deps, svcs, scrts, vols]) => {
|
||||||
|
const deployments = deps.filter(serverFilter);
|
||||||
|
const services = svcs.filter(serverFilter);
|
||||||
|
const secrets = scrts.filter(serverFilter);
|
||||||
|
const volumes = vols.filter(serverFilter);
|
||||||
|
|
||||||
|
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!");
|
||||||
|
const serverAssets = {
|
||||||
|
deployment: deployments[0],
|
||||||
|
service: services.find(
|
||||||
|
(s) => s.metadata.name === `mcl-${serverName}-server`,
|
||||||
|
),
|
||||||
|
volume: volumes[0],
|
||||||
|
rconService: services.find(
|
||||||
|
(s) => s.metadata.name === `mcl-${serverName}-rcon`,
|
||||||
|
),
|
||||||
|
rconSecret: secrets[0],
|
||||||
|
};
|
||||||
|
for (var k in serverAssets) if (serverAssets[k]) return serverAssets;
|
||||||
|
// If no assets exist, return nothing
|
||||||
|
})
|
||||||
|
.catch((e) => ERR("SERVER ASSETS", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeployment(serverName) {
|
||||||
|
const servers = await getDeployments();
|
||||||
|
const serverDeployment = servers.find(
|
||||||
|
(s) =>
|
||||||
|
s.metadata.annotations["minecluster.dunemask.net/server-name"] ===
|
||||||
|
serverName,
|
||||||
|
);
|
||||||
|
if (!serverDeployment)
|
||||||
|
throw Error(`MCL Deployment '${serverName}' could not be found!`);
|
||||||
|
|
||||||
|
return serverDeployment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainers(serverName) {
|
||||||
|
const deployment = await getDeployment(serverName);
|
||||||
|
return deployment.spec.template.spec.containers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function containerControl(serverName, 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];
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
return k8sDeps.replaceNamespacedDeployment(
|
||||||
|
deployment.metadata.name,
|
||||||
|
namespace,
|
||||||
|
deployment,
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,30 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
67
lib/k8s/server-containers.js
Normal file
67
lib/k8s/server-containers.js
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import yaml from "js-yaml";
|
||||||
|
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
|
||||||
|
|
||||||
|
export function getFtpContainer(serverSpec) {
|
||||||
|
const { name } = serverSpec;
|
||||||
|
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
|
||||||
|
ftpContainer.name = `mcl-${name}-ftp`;
|
||||||
|
const ftpPortList = [
|
||||||
|
{ p: 20, n: "ftp-data" },
|
||||||
|
{ p: 21, n: "ftp-commands" },
|
||||||
|
];
|
||||||
|
for (var p = 40000; p <= 40009; p++)
|
||||||
|
ftpPortList.push({ p, n: `ftp-passive-${p - 40000}` });
|
||||||
|
ftpContainer.ports = ftpPortList.map(({ p: containerPort, n: name }) => ({
|
||||||
|
containerPort,
|
||||||
|
name,
|
||||||
|
protocol: "TCP",
|
||||||
|
}));
|
||||||
|
return ftpContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoreServerContainer(serverSpec) {
|
||||||
|
const { name, version, serverType, memory } = serverSpec;
|
||||||
|
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
|
||||||
|
// Container Updates
|
||||||
|
container.name = `mcl-${name}-server`;
|
||||||
|
container.resources.requests.memory = `${memory}Mi`;
|
||||||
|
|
||||||
|
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
|
||||||
|
const updateEnv = (k, v) => (findEnv(k).value = v);
|
||||||
|
|
||||||
|
// Enviornment variables
|
||||||
|
updateEnv("TYPE", serverType);
|
||||||
|
updateEnv("VERSION", version);
|
||||||
|
updateEnv("MEMORY", `${memory}M`);
|
||||||
|
// RCON
|
||||||
|
const rs = `mcl-${name}-rcon-secret`;
|
||||||
|
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerContainer(serverSpec) {
|
||||||
|
const { difficulty, gamemode, motd, maxPlayers, seed, ops, whitelist } =
|
||||||
|
serverSpec;
|
||||||
|
const container = getCoreServerContainer(serverSpec);
|
||||||
|
|
||||||
|
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
|
||||||
|
const updateEnv = (k, v) => (findEnv(k).value = v);
|
||||||
|
|
||||||
|
// Enviornment variables
|
||||||
|
updateEnv("DIFFICULTY", difficulty);
|
||||||
|
updateEnv("MODE", gamemode);
|
||||||
|
updateEnv("MOTD", motd);
|
||||||
|
updateEnv("MAX_PLAYERS", maxPlayers);
|
||||||
|
updateEnv("SEED", seed);
|
||||||
|
updateEnv("OPS", ops);
|
||||||
|
updateEnv("WHITELIST", whitelist);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupContainer(serverSpec) {
|
||||||
|
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
|
||||||
|
return container;
|
||||||
|
}
|
|
@ -1,95 +1,86 @@
|
||||||
import k8s from "@kubernetes/client-node";
|
import k8s from "@kubernetes/client-node";
|
||||||
|
import {
|
||||||
|
getDeployment,
|
||||||
|
getDeployments,
|
||||||
|
getServerAssets,
|
||||||
|
scaleDeployment,
|
||||||
|
} from "./k8s-server-control.js";
|
||||||
import { ERR } from "../util/logging.js";
|
import { ERR } from "../util/logging.js";
|
||||||
|
import ExpressClientError from "../util/ExpressClientError.js";
|
||||||
const kc = new k8s.KubeConfig();
|
const kc = new k8s.KubeConfig();
|
||||||
kc.loadFromDefault();
|
kc.loadFromDefault();
|
||||||
|
|
||||||
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
|
||||||
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
|
||||||
const k8sMetrics = new k8s.Metrics(kc);
|
const k8sMetrics = new k8s.Metrics(kc);
|
||||||
|
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
||||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
export async function startServer(req, res) {
|
export async function startServerContainer(serverSpec) {
|
||||||
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 { name } = serverSpec;
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
try {
|
||||||
const dep = deploymentRes.body.items.find(
|
await scaleDeployment(name, true);
|
||||||
(i) => i.metadata.name === `mcl-${name}`
|
} catch (e) {
|
||||||
);
|
ERR("SERVER CONTROL", e);
|
||||||
if (!dep) return res.status(409).send("Server does not exist!");
|
throw new ExpressClientError({
|
||||||
if (dep.spec.replicas === 1)
|
c: 500,
|
||||||
return res.status(409).send("Server already started!");
|
m: `Error updating server '${name}'!\n`,
|
||||||
dep.spec.replicas = 1;
|
});
|
||||||
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
|
}
|
||||||
res.sendStatus(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopServer(req, res) {
|
export async function stopServerContainer(serverSpec) {
|
||||||
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 { name } = serverSpec;
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
try {
|
||||||
const dep = deploymentRes.body.items.find(
|
await scaleDeployment(name, false);
|
||||||
(i) => i.metadata.name === `mcl-${name}`
|
} catch (e) {
|
||||||
);
|
ERR("SERVER CONTROL", e);
|
||||||
if (!dep) return res.status(409).send("Server does not exist!");
|
throw new ExpressClientError({
|
||||||
if (dep.spec.replicas === 0)
|
c: 500,
|
||||||
return res.status(409).send("Server already stopped!");
|
m: `Error updating server '${name}'!`,
|
||||||
dep.spec.replicas = 0;
|
});
|
||||||
k8sDeps.replaceNamespacedDeployment(`mcl-${name}`, namespace, dep);
|
}
|
||||||
res.sendStatus(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serverList(req, res) {
|
export async function getInstances() {
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
const serverDeployments = await getDeployments();
|
||||||
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);
|
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;
|
var name, metrics, started;
|
||||||
const servers = serverDeployments.map((s) => {
|
const serverInstances = serverDeployments.map((s) => {
|
||||||
name = s.metadata.name.substring(4);
|
name = s.metadata.annotations["minecluster.dunemask.net/server-name"];
|
||||||
metrics = null;
|
metrics = null;
|
||||||
started = !!s.spec.replicas;
|
started = !!s.spec.template.spec.containers.find((c) =>
|
||||||
|
c.name.includes(`mcl-${name}-server`),
|
||||||
|
);
|
||||||
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
|
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
|
||||||
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
|
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
|
||||||
});
|
});
|
||||||
if (pod) {
|
if (started && pod) {
|
||||||
const podCpus = pod.containers.map(
|
const podCpus = pod.containers.map(
|
||||||
({ usage }) => parseInt(usage.cpu) / 1_000_000
|
({ usage }) => parseInt(usage.cpu) / 1_000_000,
|
||||||
);
|
);
|
||||||
const podMems = pod.containers.map(
|
const podMems = pod.containers.map(
|
||||||
({ usage }) => parseInt(usage.memory) / 1024
|
({ usage }) => parseInt(usage.memory) / 1024,
|
||||||
);
|
);
|
||||||
metrics = {
|
metrics = {
|
||||||
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
|
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
|
||||||
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
|
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, metrics, started };
|
return { name, metrics, started };
|
||||||
});
|
});
|
||||||
|
return serverInstances;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNamespaceMetrics() {
|
||||||
|
const serverInstances = await getInstances();
|
||||||
var clusterMetrics = { cpu: 0, memory: 0 };
|
var clusterMetrics = { cpu: 0, memory: 0 };
|
||||||
if (servers.length > 1) {
|
if (servers.length > 1) {
|
||||||
const clusterCpu = servers
|
const clusterCpu = serverInstances
|
||||||
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
|
||||||
.reduce((a, b) => a + b);
|
.reduce((a, b) => a + b);
|
||||||
const clusterMem = servers
|
const clusterMem = serverInstances
|
||||||
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
.map(({ metrics }) => (metrics ? metrics.memory : 0))
|
||||||
.reduce((a, b) => a + b);
|
.reduce((a, b) => a + b);
|
||||||
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
|
||||||
}
|
}
|
||||||
res.json({ servers, clusterMetrics });
|
return clusterMetrics;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,158 +4,130 @@ import k8s from "@kubernetes/client-node";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import ExpressClientError from "../util/ExpressClientError.js";
|
||||||
|
import {
|
||||||
|
getFtpContainer,
|
||||||
|
getServerContainer,
|
||||||
|
getBackupContainer,
|
||||||
|
} from "./server-containers.js";
|
||||||
|
|
||||||
const kc = new k8s.KubeConfig();
|
const kc = new k8s.KubeConfig();
|
||||||
kc.loadFromDefault();
|
kc.loadFromDefault();
|
||||||
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
||||||
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
function payloadFilter(req, res) {
|
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
|
||||||
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) {
|
function createRconSecret(serverSpec) {
|
||||||
const { name } = serverSpec;
|
const { name } = serverSpec;
|
||||||
const rconYaml = yaml.load(
|
const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
|
||||||
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-secret.yml"), "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Dyamic rconPassword
|
// TODO: Dyamic rconPassword
|
||||||
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
|
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
|
||||||
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
|
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
|
||||||
rconYaml.metadata.labels.app = `mcl-${name}-app`;
|
rconYaml.metadata.labels.app = `mcl-${name}-app`;
|
||||||
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
|
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
|
||||||
rconYaml.metadata.namespace = namespace;
|
rconYaml.metadata.namespace = namespace;
|
||||||
|
rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name;
|
||||||
return rconYaml;
|
return rconYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerVolume(serverSpec) {
|
function createServerVolume(serverSpec) {
|
||||||
const { name } = serverSpec;
|
const { name } = serverSpec;
|
||||||
const volumeYaml = yaml.load(
|
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
|
||||||
fs.readFileSync(path.resolve("lib/k8s/configs/server-pvc.yml"), "utf8")
|
|
||||||
);
|
|
||||||
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
|
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
|
||||||
volumeYaml.metadata.name = `mcl-${name}-volume`;
|
volumeYaml.metadata.name = `mcl-${name}-volume`;
|
||||||
volumeYaml.metadata.namespace = namespace;
|
volumeYaml.metadata.namespace = namespace;
|
||||||
|
volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||||
|
name;
|
||||||
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
|
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
|
||||||
return volumeYaml;
|
return volumeYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerDeploy(serverSpec) {
|
function createServerDeploy(serverSpec) {
|
||||||
const {
|
const { name, host } = serverSpec;
|
||||||
name,
|
const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
|
||||||
version,
|
const { metadata } = deployYaml;
|
||||||
serverType,
|
const serverContainer = getServerContainer(serverSpec);
|
||||||
difficulty,
|
const backupContainer = getBackupContainer(serverSpec);
|
||||||
gamemode,
|
const ftpContainer = getFtpContainer(serverSpec);
|
||||||
memory,
|
|
||||||
motd,
|
// Configure Metadata;
|
||||||
maxPlayers,
|
metadata.name = `mcl-${name}`;
|
||||||
seed,
|
metadata.namespace = namespace;
|
||||||
modpack,
|
metadata.annotations["minecluster.dunemask.net/server-name"] = name;
|
||||||
ops,
|
deployYaml.metadata = metadata;
|
||||||
whitelist,
|
|
||||||
} = serverSpec;
|
// Configure Lables & Selectors
|
||||||
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.selector.matchLabels.app = `mcl-${name}-app`;
|
||||||
deployYaml.spec.template.metadata.labels.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
|
// Volumes
|
||||||
deployYaml.spec.template.spec.volumes.find(
|
deployYaml.spec.template.spec.volumes.find(
|
||||||
({ name }) => name === "datadir"
|
({ name }) => name === "datadir",
|
||||||
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
|
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
|
||||||
deployYaml.spec.template.spec.containers[0] = serverContainer;
|
|
||||||
|
// Apply Containers TODO: User control for autostart
|
||||||
|
deployYaml.spec.template.spec.containers.push(serverContainer);
|
||||||
|
deployYaml.spec.template.spec.containers.push(ftpContainer);
|
||||||
|
deployYaml.spec.replicas = 1;
|
||||||
return deployYaml;
|
return deployYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerService(serverSpec) {
|
function createServerService(serverSpec) {
|
||||||
const { name, url } = serverSpec;
|
const { name, host } = serverSpec;
|
||||||
const serviceYaml = yaml.load(
|
const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
|
||||||
fs.readFileSync(path.resolve("lib/k8s/configs/server-svc.yml"), "utf8")
|
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host;
|
||||||
);
|
serviceYaml.metadata.annotations["mc-router.itzg.me/externalServerName"] =
|
||||||
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = url;
|
host;
|
||||||
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
|
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
|
||||||
serviceYaml.metadata.name = `mcl-${name}-server`;
|
serviceYaml.metadata.name = `mcl-${name}-server`;
|
||||||
serviceYaml.metadata.namespace = namespace;
|
serviceYaml.metadata.namespace = namespace;
|
||||||
|
serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||||
|
name;
|
||||||
serviceYaml.spec.selector.app = `mcl-${name}-app`;
|
serviceYaml.spec.selector.app = `mcl-${name}-app`;
|
||||||
|
// Port List:
|
||||||
|
const serverPortList = [{ p: 25565, n: "minecraft" }];
|
||||||
|
|
||||||
|
// Apply FTP Port List
|
||||||
|
const ftpPortList = [
|
||||||
|
{ p: 20, n: "ftp-data" },
|
||||||
|
{ p: 21, n: "ftp-commands" },
|
||||||
|
];
|
||||||
|
for (var p = 40000; p <= 40009; p++)
|
||||||
|
ftpPortList.push({ p, n: `ftp-passive-${p - 40000}` });
|
||||||
|
|
||||||
|
const portList = [...serverPortList, ...ftpPortList];
|
||||||
|
serviceYaml.spec.ports = portList.map(({ p: port, n: name }) => ({
|
||||||
|
port,
|
||||||
|
name,
|
||||||
|
protocol: "TCP",
|
||||||
|
targetPort: port,
|
||||||
|
}));
|
||||||
return serviceYaml;
|
return serviceYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRconService(serverSpec) {
|
function createRconService(serverSpec) {
|
||||||
const { name, url } = serverSpec;
|
const { name } = serverSpec;
|
||||||
const rconSvcYaml = yaml.load(
|
const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml");
|
||||||
fs.readFileSync(path.resolve("lib/k8s/configs/rcon-svc.yml"), "utf8")
|
|
||||||
);
|
|
||||||
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
|
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
|
||||||
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
|
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
|
||||||
rconSvcYaml.metadata.namespace = namespace;
|
rconSvcYaml.metadata.namespace = namespace;
|
||||||
|
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
|
||||||
|
name;
|
||||||
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
|
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
|
||||||
return rconSvcYaml;
|
return rconSvcYaml;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function createServer(req, res) {
|
export default async function createServerResources(serverSpec) {
|
||||||
if (payloadFilter(req, res) !== "filtered") return;
|
|
||||||
const serverSpec = req.body;
|
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
||||||
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
|
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
|
||||||
if (deployments.includes(`mcl-${serverSpec.name}`))
|
if (deployments.includes(`mcl-${serverSpec.name}`))
|
||||||
return res.status(409).send("Server already exists!");
|
throw new ExpressClientError({ m: "Server already exists!", c: 409 });
|
||||||
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
|
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
|
||||||
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
|
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
|
||||||
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
|
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
|
||||||
return res.status(409).send("Server PVC already exists!");
|
throw new ExpressClientError({ m: "Server PVC already exists!", c: 409 });
|
||||||
const rconSecret = createRconSecret(serverSpec);
|
const rconSecret = createRconSecret(serverSpec);
|
||||||
const serverVolume = createServerVolume(serverSpec);
|
const serverVolume = createServerVolume(serverSpec);
|
||||||
const serverDeploy = createServerDeploy(serverSpec);
|
const serverDeploy = createServerDeploy(serverSpec);
|
||||||
|
@ -166,6 +138,4 @@ export default async function createServer(req, res) {
|
||||||
k8sCore.createNamespacedService(namespace, serverService);
|
k8sCore.createNamespacedService(namespace, serverService);
|
||||||
k8sCore.createNamespacedService(namespace, rconService);
|
k8sCore.createNamespacedService(namespace, rconService);
|
||||||
k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
|
k8sDeps.createNamespacedDeployment(namespace, serverDeploy);
|
||||||
|
|
||||||
res.sendStatus(200);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import k8s from "@kubernetes/client-node";
|
import k8s from "@kubernetes/client-node";
|
||||||
import { ERR } from "../util/logging.js";
|
import { ERR } from "../util/logging.js";
|
||||||
|
import { getServerAssets } from "./k8s-server-control.js";
|
||||||
|
import ExpressClientError from "../util/ExpressClientError.js";
|
||||||
const kc = new k8s.KubeConfig();
|
const kc = new k8s.KubeConfig();
|
||||||
kc.loadFromDefault();
|
kc.loadFromDefault();
|
||||||
|
|
||||||
|
@ -7,49 +9,52 @@ const k8sDeps = kc.makeApiClient(k8s.AppsV1Api);
|
||||||
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
const k8sCore = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
const deleteError = (res) => (err) => {
|
const deleteError = (err) => {
|
||||||
res.status(500).send("Error deleting a resource!");
|
|
||||||
ERR("K8S", "An error occurred while deleting a resource", err);
|
ERR("K8S", "An error occurred while deleting a resource", err);
|
||||||
|
throw new ExpressClientError({
|
||||||
|
c: 500,
|
||||||
|
m: "Error deleting a resource!\n" + err,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function deleteServer(req, res) {
|
function deleteOnExist(o, fn) {
|
||||||
const serverSpec = req.body;
|
if (o) return fn(o.metadata.name);
|
||||||
if (!serverSpec) return res.sendStatus(400);
|
}
|
||||||
if (!serverSpec.name) return res.status(400).send("Server name required!");
|
|
||||||
|
export default async function deleteServerResources(serverSpec) {
|
||||||
const { name } = serverSpec;
|
const { name } = serverSpec;
|
||||||
// Ensure deployment exists
|
// Ensure deployment exists
|
||||||
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
|
const server = await getServerAssets(name);
|
||||||
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
|
if (!server)
|
||||||
if (!deployments.includes(`mcl-${serverSpec.name}`))
|
throw new ExpressClientError({
|
||||||
return res.status(409).send("Server does not exist!");
|
c: 404,
|
||||||
|
m: "No Resources for that server were found!",
|
||||||
|
});
|
||||||
|
|
||||||
// Delete in reverse order
|
// Delete in reverse order
|
||||||
const deleteDeploy = k8sDeps.deleteNamespacedDeployment(
|
const deleteDeploy = deleteOnExist(server.deployment, (name) =>
|
||||||
`mcl-${serverSpec.name}`,
|
k8sDeps.deleteNamespacedDeployment(name, namespace),
|
||||||
namespace
|
|
||||||
);
|
);
|
||||||
const deleteService = k8sCore.deleteNamespacedService(
|
|
||||||
`mcl-${name}-server`,
|
const deleteService = deleteOnExist(server.service, (name) =>
|
||||||
namespace
|
k8sCore.deleteNamespacedService(name, namespace),
|
||||||
);
|
);
|
||||||
const deleteRconService = k8sCore.deleteNamespacedService(
|
const deleteRconService = deleteOnExist(server.rconService, (name) =>
|
||||||
`mcl-${name}-rcon`,
|
k8sCore.deleteNamespacedService(name, namespace),
|
||||||
namespace
|
|
||||||
);
|
);
|
||||||
await deleteDeploy.catch(deleteError(res));
|
if (deleteDeploy) await deleteDeploy.catch(deleteError);
|
||||||
const deleteRconSecret = k8sCore.deleteNamespacedSecret(
|
|
||||||
`mcl-${name}-rcon-secret`,
|
const deleteRconSecret = deleteOnExist(server.rconSecret, (name) =>
|
||||||
namespace
|
k8sCore.deleteNamespacedSecret(name, namespace),
|
||||||
);
|
);
|
||||||
const deleteVolume = k8sCore.deleteNamespacedPersistentVolumeClaim(
|
const deleteVolume = deleteOnExist(server.volume, (name) =>
|
||||||
`mcl-${name}-volume`,
|
k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
|
||||||
namespace
|
|
||||||
);
|
);
|
||||||
Promise.all([
|
|
||||||
|
return Promise.all([
|
||||||
deleteService,
|
deleteService,
|
||||||
deleteRconService,
|
deleteRconService,
|
||||||
deleteRconSecret,
|
deleteRconSecret,
|
||||||
deleteVolume,
|
deleteVolume,
|
||||||
])
|
]).catch(deleteError);
|
||||||
.then(() => res.sendStatus(200))
|
|
||||||
.catch(deleteError(res));
|
|
||||||
}
|
}
|
||||||
|
|
97
lib/k8s/server-files.js
Normal file
97
lib/k8s/server-files.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import ftp from "basic-ftp";
|
||||||
|
import { ERR } from "../util/logging.js";
|
||||||
|
import { getServerAssets } from "./k8s-server-control.js";
|
||||||
|
import ExpressClientError from "../util/ExpressClientError.js";
|
||||||
|
import { Readable, Writable, Transform } from "node:stream";
|
||||||
|
|
||||||
|
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
||||||
|
|
||||||
|
const pathSecurityCheck = (path) => {
|
||||||
|
if (!path.startsWith("."))
|
||||||
|
throw new ExpressClientError({
|
||||||
|
m: "Only relative directories can be created",
|
||||||
|
c: 409,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (e) => {
|
||||||
|
ERR("SERVER FILES", "Error occurred while preforming FTP operation!", e);
|
||||||
|
throw new ExpressClientError({
|
||||||
|
c: 500,
|
||||||
|
m: "Error occurred while performing FTP operation!",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getFtpClient(serverService) {
|
||||||
|
const { name } = serverService.metadata;
|
||||||
|
const client = new ftp.Client();
|
||||||
|
await client.access({
|
||||||
|
host: `${name}.${namespace}.svc.cluster.local`,
|
||||||
|
user: "minecluster",
|
||||||
|
password: "minecluster",
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function useServerFtp(serverSpec, fn) {
|
||||||
|
const { name } = serverSpec;
|
||||||
|
const server = await getServerAssets(name);
|
||||||
|
if (!server)
|
||||||
|
throw new ExpressClientError({
|
||||||
|
c: 404,
|
||||||
|
m: "No resources for that server were found!",
|
||||||
|
});
|
||||||
|
if (!server.service)
|
||||||
|
throw new ExpressClientError({
|
||||||
|
c: 409,
|
||||||
|
m: "Service doesn't exist, please contact your hosting provider!",
|
||||||
|
});
|
||||||
|
const client = await getFtpClient(server.service);
|
||||||
|
const result = await fn(client);
|
||||||
|
client.close();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listServerFiles(serverSpec) {
|
||||||
|
const { path } = serverSpec;
|
||||||
|
const files = useServerFtp(serverSpec, async (c) => await c.list(path)).catch(
|
||||||
|
handleError,
|
||||||
|
);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServerFolder(serverSpec) {
|
||||||
|
const { path } = serverSpec;
|
||||||
|
pathSecurityCheck(path);
|
||||||
|
await useServerFtp(serverSpec, async (c) => c.ensureDir(path)).catch(
|
||||||
|
handleError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServerItem(serverSpec) {
|
||||||
|
const { path, isDir } = serverSpec;
|
||||||
|
pathSecurityCheck(path);
|
||||||
|
await useServerFtp(serverSpec, async (c) => {
|
||||||
|
if (isDir) await c.removeDir(path);
|
||||||
|
else await c.remove(path);
|
||||||
|
}).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadServerItem(serverSpec, file) {
|
||||||
|
const fileStream = Readable.from(file.buffer);
|
||||||
|
const { path } = serverSpec;
|
||||||
|
pathSecurityCheck(path);
|
||||||
|
await useServerFtp(serverSpec, async (c) => {
|
||||||
|
await c.uploadFrom(fileStream, `${path}/${file.originalname}`);
|
||||||
|
}).catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerItem(serverSpec, writableStream) {
|
||||||
|
const { path } = serverSpec;
|
||||||
|
const ds = new Transform({ transform: (c, e, cb) => cb(null, c) });
|
||||||
|
pathSecurityCheck(path);
|
||||||
|
const ftpTransfer = useServerFtp(serverSpec, async (c) => {
|
||||||
|
await c.downloadTo(ds, path);
|
||||||
|
}).catch(handleError);
|
||||||
|
return { ds, ftpTransfer };
|
||||||
|
}
|
17
lib/routes/error-route.js
Normal file
17
lib/routes/error-route.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
export function logErrors(err, req, res, next) {
|
||||||
|
console.error(err.stack);
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientErrorHandler(err, req, res, next) {
|
||||||
|
if (req.xhr) {
|
||||||
|
res.status(500).send({ error: "Something failed!" });
|
||||||
|
} else {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorHandler(err, req, res, next) {
|
||||||
|
res.status(500);
|
||||||
|
res.render("error", { error: err });
|
||||||
|
}
|
21
lib/routes/files-route.js
Normal file
21
lib/routes/files-route.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { Router, json as jsonMiddleware } from "express";
|
||||||
|
import multer from "multer";
|
||||||
|
import {
|
||||||
|
createFolder,
|
||||||
|
deleteItem,
|
||||||
|
listFiles,
|
||||||
|
uploadItem,
|
||||||
|
getItem,
|
||||||
|
} from "../controllers/file-controller.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(jsonMiddleware());
|
||||||
|
const multerMiddleware = multer();
|
||||||
|
|
||||||
|
router.post("/list", listFiles);
|
||||||
|
router.post("/folder", createFolder);
|
||||||
|
router.delete("/item", deleteItem);
|
||||||
|
router.post("/item", getItem);
|
||||||
|
router.post("/upload", multerMiddleware.single("file"), uploadItem);
|
||||||
|
|
||||||
|
export default router;
|
2
lib/routes/react-route.js
vendored
2
lib/routes/react-route.js
vendored
|
@ -3,6 +3,6 @@ import path from "path";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use("/", express.static(path.resolve("./build")));
|
router.use("/", express.static(path.resolve("./build")));
|
||||||
router.get("/*", (req, res) =>
|
router.get("/*", (req, res) =>
|
||||||
res.sendFile(path.resolve("./build/index.html"))
|
res.sendFile(path.resolve("./build/index.html")),
|
||||||
);
|
);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import { Router, json as jsonMiddleware } from "express";
|
import { Router, json as jsonMiddleware } from "express";
|
||||||
import {
|
import {
|
||||||
|
createServer,
|
||||||
|
deleteServer,
|
||||||
startServer,
|
startServer,
|
||||||
stopServer,
|
stopServer,
|
||||||
|
} from "../controllers/lifecycle-controller.js";
|
||||||
|
import {
|
||||||
|
serverInstances,
|
||||||
serverList,
|
serverList,
|
||||||
getServers,
|
} from "../controllers/status-controller.js";
|
||||||
} from "../k8s/server-control.js";
|
|
||||||
import createServer from "../k8s/server-create.js";
|
|
||||||
import deleteServer from "../k8s/server-delete.js";
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(jsonMiddleware());
|
router.use(jsonMiddleware());
|
||||||
// Routes
|
// Routes
|
||||||
|
@ -15,5 +17,5 @@ router.delete("/delete", deleteServer);
|
||||||
router.post("/start", startServer);
|
router.post("/start", startServer);
|
||||||
router.post("/stop", stopServer);
|
router.post("/stop", stopServer);
|
||||||
router.get("/list", serverList);
|
router.get("/list", serverList);
|
||||||
router.get("/instances", getServers);
|
router.get("/instances", serverInstances);
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -7,6 +7,8 @@ kc.loadFromDefault();
|
||||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
// Get Routes
|
// Get Routes
|
||||||
router.get("/available", (req, res) => {
|
router.get("/available", (req, res) => {
|
||||||
|
return res.json({ cpu: 8000, memory: 16000 });
|
||||||
|
// TODO Workaround to detect available
|
||||||
k8sApi.listNode().then((nodeRes) => {
|
k8sApi.listNode().then((nodeRes) => {
|
||||||
const nodeAllocatable = nodeRes.body.items.map((i) => i.status.allocatable);
|
const nodeAllocatable = nodeRes.body.items.map((i) => i.status.allocatable);
|
||||||
const nodeResources = nodeAllocatable.map(({ cpu, memory }) => ({
|
const nodeResources = nodeAllocatable.map(({ cpu, memory }) => ({
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -5,7 +5,13 @@ import express from "express";
|
||||||
import vitals from "../routes/vitals-route.js";
|
import vitals from "../routes/vitals-route.js";
|
||||||
import systemRoute from "../routes/system-route.js";
|
import systemRoute from "../routes/system-route.js";
|
||||||
import serverRoute from "../routes/server-route.js";
|
import serverRoute from "../routes/server-route.js";
|
||||||
|
import filesRoute from "../routes/files-route.js";
|
||||||
import reactRoute from "../routes/react-route.js";
|
import reactRoute from "../routes/react-route.js";
|
||||||
|
import {
|
||||||
|
logErrors,
|
||||||
|
clientErrorHandler,
|
||||||
|
errorHandler,
|
||||||
|
} from "../routes/error-route.js";
|
||||||
|
|
||||||
export default function buildRoutes(pg, skio) {
|
export default function buildRoutes(pg, skio) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
@ -18,7 +24,11 @@ export default function buildRoutes(pg, skio) {
|
||||||
// Routes
|
// Routes
|
||||||
router.use("/api/system", systemRoute);
|
router.use("/api/system", systemRoute);
|
||||||
router.use("/api/server", serverRoute);
|
router.use("/api/server", serverRoute);
|
||||||
router.use(["/mcl","/mcl/*"], reactRoute); // Static Build Route
|
router.use("/api/files", filesRoute);
|
||||||
|
router.use(["/mcl", "/mcl/*"], reactRoute); // Static Build Route
|
||||||
|
/*router.use(logErrors);
|
||||||
|
router.use(clientErrorHandler);
|
||||||
|
router.use(errorHandler);*/
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Server as Skio } from "socket.io";
|
import { Server as Skio } from "socket.io";
|
||||||
import { VERB, WARN, ERR } from "../util/logging.js";
|
import { VERB, WARN, ERR } from "../util/logging.js";
|
||||||
import liveLogging from "../k8s/live-logging.js";
|
import {
|
||||||
import rconInterface from "./rcon.js";
|
webConsoleLogs,
|
||||||
|
webConsoleRcon,
|
||||||
const namespace = process.env.MCL_SERVER_NAMESPACE;
|
} from "../controllers/sub-controllers/console-controller.js";
|
||||||
|
|
||||||
async function rconSend(socket, m) {
|
async function rconSend(socket, m) {
|
||||||
if (!socket.rconClient)
|
if (!socket.rconClient)
|
||||||
|
@ -20,8 +20,8 @@ const socketConnect = async (io, socket) => {
|
||||||
VERB("WS", "Websocket connecting");
|
VERB("WS", "Websocket connecting");
|
||||||
socket.mcs = { serverName: socket.handshake.query.serverName };
|
socket.mcs = { serverName: socket.handshake.query.serverName };
|
||||||
try {
|
try {
|
||||||
await liveLogging(socket, namespace);
|
await webConsoleLogs(socket);
|
||||||
await rconInterface(socket);
|
await webConsoleRcon(socket);
|
||||||
socket.on("msg", (m) => rconSend(socket, m));
|
socket.on("msg", (m) => rconSend(socket, m));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ERR("SOCKETS", err);
|
ERR("SOCKETS", err);
|
||||||
|
|
34
lib/storage/s3-integration.js
Normal file
34
lib/storage/s3-integration.js
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import multer from "multer";
|
||||||
|
import multerS3 from "multer-s3";
|
||||||
|
import AWS from "aws-sdk";
|
||||||
|
|
||||||
|
// Environment Variables
|
||||||
|
const {
|
||||||
|
MCL_S3_ENDPOINT: s3Endpoint,
|
||||||
|
MCL_S3_ACCESS_KEY_ID: s3KeyId,
|
||||||
|
MCL_S3_ACCESS_KEY: s3Key,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
export const mcl = "mcl";
|
||||||
|
|
||||||
|
export const s3 = new AWS.S3({
|
||||||
|
endpoint: s3Endpoint,
|
||||||
|
accessKeyId: s3KeyId,
|
||||||
|
secretAccessKey: s3Key,
|
||||||
|
sslEnabled: true,
|
||||||
|
s3ForcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storage = multerS3({
|
||||||
|
s3,
|
||||||
|
bucket,
|
||||||
|
contentType: multerS3.AUTO_CONTENT_TYPE,
|
||||||
|
metadata: (req, file, cb) => {
|
||||||
|
cb(null, { fieldName: file.fieldname });
|
||||||
|
},
|
||||||
|
key: (req, file, cb) => {
|
||||||
|
cb(null, Date.now().toString());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upload = multer({ storage });
|
28
lib/util/ExpressClientError.js
Normal file
28
lib/util/ExpressClientError.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { VERB } from "./logging.js";
|
||||||
|
|
||||||
|
export default class ExpressClientError extends Error {
|
||||||
|
constructor(message, clientOptions = {}) {
|
||||||
|
var msg;
|
||||||
|
if (typeof message === "object" && message.m !== undefined) msg = message.m;
|
||||||
|
else if (typeof message === "object") msg = "Unknown Express Client Error!";
|
||||||
|
super(msg);
|
||||||
|
if (typeof message === "object") this.clientOptions = message;
|
||||||
|
else this.clientOptions = { message: msg, ...clientOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
sendError(res) {
|
||||||
|
if (!this.clientOptions.m && this.clientOptions.c)
|
||||||
|
res.sendStatus(this.clientOptions.c);
|
||||||
|
else res.status(this.clientOptions.c ?? 500).send(this.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendError = (res) => (e) => {
|
||||||
|
VERB("V", e);
|
||||||
|
if (e instanceof ExpressClientError) e.sendError(res);
|
||||||
|
else res.status(500).send(e);
|
||||||
|
};
|
4445
package-lock.json
generated
4445
package-lock.json
generated
File diff suppressed because it is too large
Load diff
35
package.json
35
package.json
|
@ -24,28 +24,37 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.14.3",
|
"@mui/icons-material": "^5.14.19",
|
||||||
"@mui/material": "^5.14.5",
|
"@mui/material": "^5.14.20",
|
||||||
"@tanstack/react-query": "^4.33.0",
|
"@tanstack/react-query": "^5.12.2",
|
||||||
"@vitejs/plugin-react": "^4.0.4",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"concurrently": "^8.2.0",
|
"concurrently": "^8.2.2",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.2",
|
||||||
"prettier": "^3.0.2",
|
"prettier": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.15.0",
|
"react-router-dom": "^6.20.1",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"vite": "^4.4.9"
|
"vite": "^5.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kubernetes/client-node": "^0.18.1",
|
"@kubernetes/client-node": "^0.20.0",
|
||||||
|
"aws-sdk": "^2.1514.0",
|
||||||
|
"basic-ftp": "^5.0.4",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
|
"chonky": "^2.3.2",
|
||||||
|
"chonky-icon-fontawesome": "^2.3.2",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"figlet": "^1.6.0",
|
"figlet": "^1.7.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"rcon-client": "^4.2.3",
|
"moment": "^2.29.4",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"multer-s3": "^3.0.1",
|
||||||
|
"pg-promise": "^11.5.4",
|
||||||
|
"postgres-migrations": "^5.3.0",
|
||||||
|
"rcon-client": "^4.2.4",
|
||||||
"socket.io": "^4.7.2",
|
"socket.io": "^4.7.2",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
10
src/MCL.jsx
10
src/MCL.jsx
|
@ -1,4 +1,6 @@
|
||||||
// Imports
|
// Imports
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import mclTheme from "./util/theme.js";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { SettingsProvider } from "@mcl/settings";
|
import { SettingsProvider } from "@mcl/settings";
|
||||||
import Viewport from "./nav/Viewport.jsx";
|
import Viewport from "./nav/Viewport.jsx";
|
||||||
|
@ -11,9 +13,11 @@ export default function MCL() {
|
||||||
<div className="minecluster">
|
<div className="minecluster">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<BrowserRouter>
|
<ThemeProvider theme={mclTheme}>
|
||||||
<Viewport />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<Viewport />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
42
src/components/files/ChonkyStyledFileBrowser.jsx
Normal file
42
src/components/files/ChonkyStyledFileBrowser.jsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// ChonkyFullFileBrowser.tsx
|
||||||
|
import { forwardRef, memo } from "react";
|
||||||
|
import {
|
||||||
|
StylesProvider,
|
||||||
|
createGenerateClassName,
|
||||||
|
} from "@material-ui/core/styles";
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileBrowser,
|
||||||
|
FileList,
|
||||||
|
FileContextMenu,
|
||||||
|
FileNavbar,
|
||||||
|
FileToolbar,
|
||||||
|
setChonkyDefaults,
|
||||||
|
FileBrowserHandle,
|
||||||
|
FileBrowserProps,
|
||||||
|
} from "chonky";
|
||||||
|
|
||||||
|
import { ChonkyIconFA } from "chonky-icon-fontawesome";
|
||||||
|
|
||||||
|
setChonkyDefaults({ iconComponent: ChonkyIconFA });
|
||||||
|
|
||||||
|
const muiJSSClassNameGenerator = createGenerateClassName({
|
||||||
|
// Seed property is used to add a prefix classes generated by material ui.
|
||||||
|
seed: "chonky",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default memo(
|
||||||
|
forwardRef((props, ref) => {
|
||||||
|
const { onScroll } = props;
|
||||||
|
return (
|
||||||
|
<StylesProvider generateClassName={muiJSSClassNameGenerator}>
|
||||||
|
<FileBrowser ref={ref} {...props}>
|
||||||
|
<FileNavbar />
|
||||||
|
<FileToolbar />
|
||||||
|
<FileList onScroll={onScroll} />
|
||||||
|
<FileContextMenu />
|
||||||
|
</FileBrowser>
|
||||||
|
</StylesProvider>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
146
src/components/files/MineclusterFiles.jsx
Normal file
146
src/components/files/MineclusterFiles.jsx
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import {
|
||||||
|
FileBrowser,
|
||||||
|
FileContextMenu,
|
||||||
|
FileList,
|
||||||
|
FileNavbar,
|
||||||
|
FileToolbar,
|
||||||
|
setChonkyDefaults,
|
||||||
|
ChonkyActions,
|
||||||
|
} from "chonky";
|
||||||
|
import { ChonkyIconFA } from "chonky-icon-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getServerFiles,
|
||||||
|
createServerFolder,
|
||||||
|
deleteServerItem,
|
||||||
|
getServerItem,
|
||||||
|
} from "@mcl/queries";
|
||||||
|
|
||||||
|
import "@mcl/css/header.css";
|
||||||
|
|
||||||
|
export default function MineclusterFiles(props) {
|
||||||
|
// Chonky configuration
|
||||||
|
setChonkyDefaults({ iconComponent: ChonkyIconFA });
|
||||||
|
const fileActions = useMemo(
|
||||||
|
() => [
|
||||||
|
ChonkyActions.CreateFolder,
|
||||||
|
ChonkyActions.UploadFiles,
|
||||||
|
ChonkyActions.DownloadFiles,
|
||||||
|
ChonkyActions.CopyFiles,
|
||||||
|
ChonkyActions.DeleteFiles,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const { server: serverName } = props;
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [dirStack, setDirStack] = useState(["."]);
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
|
||||||
|
const updateFiles = () =>
|
||||||
|
getServerFiles(serverName, dirStack.join("/")).then((f) =>
|
||||||
|
setFiles(f ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateFiles();
|
||||||
|
}, [dirStack]);
|
||||||
|
|
||||||
|
const getFolderChain = () => {
|
||||||
|
if (dirStack.length === 1) return [{ id: "home", name: "/", isDir: true }];
|
||||||
|
return dirStack.map((d, i) => ({ id: `${d}-${i}`, name: d, isDir: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openParentFolder = () => setDirStack(dirStack.slice(0, -1));
|
||||||
|
|
||||||
|
function openFolder(payload) {
|
||||||
|
const { targetFile: file } = payload;
|
||||||
|
if (!file || !file.isDir) return;
|
||||||
|
setDirStack([...dirStack, file.name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFolder() {
|
||||||
|
const name = prompt("What is the name of the new folder?");
|
||||||
|
const path = [...dirStack, name].join("/");
|
||||||
|
createServerFolder(serverName, path).then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItems(files) {
|
||||||
|
Promise.all(
|
||||||
|
files.map((f) =>
|
||||||
|
deleteServerItem(serverName, [...dirStack, f.name].join("/"), f.isDir),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e) => console.error("Error deleting some files!", e))
|
||||||
|
.then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadFileSelection(e) {
|
||||||
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
|
const { files } = e.target;
|
||||||
|
Promise.all([...files].map((f) => uploadFile(f)))
|
||||||
|
.catch((e) => console.log("Error uploading a file", e))
|
||||||
|
.then(updateFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("name", serverName);
|
||||||
|
formData.append("path", [...dirStack, name].join("/"));
|
||||||
|
await fetch("/api/files/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFiles(files) {
|
||||||
|
Promise.all(
|
||||||
|
files.map((f) =>
|
||||||
|
getServerItem(serverName, f.name, [...dirStack, f.name].join("/")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => console.log("Done"))
|
||||||
|
.catch((e) => console.error("Error Downloading files!", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileClick(chonkyEvent) {
|
||||||
|
const { id: clickEvent, payload } = chonkyEvent;
|
||||||
|
console.log(chonkyEvent);
|
||||||
|
if (clickEvent === "open_parent_folder") return openParentFolder();
|
||||||
|
if (clickEvent === "create_folder") return createFolder();
|
||||||
|
if (clickEvent === "upload_files") return inputRef.current.click();
|
||||||
|
if (clickEvent === "download_files")
|
||||||
|
return downloadFiles(chonkyEvent.state.selectedFilesForAction);
|
||||||
|
if (clickEvent === "delete_files")
|
||||||
|
return deleteItems(chonkyEvent.state.selectedFilesForAction);
|
||||||
|
if (clickEvent !== "open_files") return console.log(clickEvent);
|
||||||
|
openFolder(payload);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box className="minecluster-files" sx={{ height: "calc(100vh - 6rem)" }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="file"
|
||||||
|
ref={inputRef}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={uploadFileSelection}
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileBrowser
|
||||||
|
files={files}
|
||||||
|
folderChain={getFolderChain()}
|
||||||
|
onFileAction={fileClick}
|
||||||
|
fileActions={fileActions}
|
||||||
|
darkMode={true}
|
||||||
|
>
|
||||||
|
<FileNavbar />
|
||||||
|
<FileToolbar />
|
||||||
|
<FileList />
|
||||||
|
<FileContextMenu />
|
||||||
|
</FileBrowser>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,6 +14,8 @@ import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
import PendingIcon from "@mui/icons-material/Pending";
|
import PendingIcon from "@mui/icons-material/Pending";
|
||||||
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
|
import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export default function ServerCard(props) {
|
export default function ServerCard(props) {
|
||||||
const { server, openRcon } = props;
|
const { server, openRcon } = props;
|
||||||
|
@ -94,9 +96,24 @@ export default function ServerCard(props) {
|
||||||
>
|
>
|
||||||
<TerminalIcon />
|
<TerminalIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="primary" aria-label="Edit" size="large">
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
aria-label="Edit"
|
||||||
|
size="large"
|
||||||
|
component={Link}
|
||||||
|
to={`/mcl/edit?server=${name}`}
|
||||||
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
color="primary"
|
||||||
|
aria-label="Files"
|
||||||
|
size="large"
|
||||||
|
component={Link}
|
||||||
|
to={`/mcl/files?server=${name}`}
|
||||||
|
>
|
||||||
|
<FolderIcon />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="error"
|
color="error"
|
||||||
aria-label="Delete Server"
|
aria-label="Delete Server"
|
31
src/css/header.css
Normal file
31
src/css/header.css
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
.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: black;
|
||||||
|
}
|
||||||
|
.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 Typography from "@mui/material/Typography";
|
||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
@ -36,48 +36,23 @@ export default function MCLMenu() {
|
||||||
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
|
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={{ bgcolor: "black", zIndex: drawerIndex() }}>
|
<AppBar
|
||||||
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
|
position="fixed"
|
||||||
|
color="primary"
|
||||||
|
sx={{ zIndex: drawerIndex(), bgcolor: "black" }}
|
||||||
|
enableColorOnDark={true}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ flexGrow: 1, margin: "0 20px", color: "white" }}
|
||||||
|
className="appbar-items"
|
||||||
|
>
|
||||||
<Toolbar disableGutters>
|
<Toolbar disableGutters>
|
||||||
<IconButton
|
<IconButton component={Link} to="/" color="inherit">
|
||||||
size="large"
|
<HomeIcon />
|
||||||
edge="start"
|
|
||||||
color="inherit"
|
|
||||||
aria-label="menu"
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
onClick={toggleDrawer}
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Drawer
|
<span style={{ margin: "auto 0", color: "inherit" }}>
|
||||||
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()}
|
{navHeader()}
|
||||||
</Typography>
|
</span>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</Box>
|
</Box>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import Home from "@mcl/pages/Home.jsx";
|
import Home from "@mcl/pages/Home.jsx";
|
||||||
import Create from "@mcl/pages/Create.jsx";
|
import Create from "@mcl/pages/Create.jsx";
|
||||||
|
import Files from "@mcl/pages/Files.jsx";
|
||||||
// Go To https://mui.com/material-ui/material-icons/ for more!
|
// Go To https://mui.com/material-ui/material-icons/ for more!
|
||||||
import HomeIcon from "@mui/icons-material/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
@ -17,4 +18,10 @@ export default [
|
||||||
icon: <AddIcon />,
|
icon: <AddIcon />,
|
||||||
component: <Create />,
|
component: <Create />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Edit",
|
||||||
|
path: "/mcl/files",
|
||||||
|
icon: <AddIcon />,
|
||||||
|
component: <Files />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import MCLPortal from "./MCLPortal.jsx";
|
import MCLPortal from "./MCLPortal.jsx";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
|
||||||
// Import Navbar
|
// Import Navbar
|
||||||
/*import Navbar from "./Navbar.jsx";*/
|
/*import Navbar from "./Navbar.jsx";*/
|
||||||
import MCLMenu from "./MCLMenu.jsx";
|
import MCLMenu from "./MCLMenu.jsx";
|
||||||
|
|
|
@ -1,152 +1,12 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import TextField from "@mui/material/TextField";
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Button from "@mui/material/Button";
|
import CreateOptions from "./CreateOptions.jsx";
|
||||||
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() {
|
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 (
|
return (
|
||||||
<Box className="create">
|
<Box className="create">
|
||||||
<FormControl fullWidth>
|
{/*<CreateMenu />*/}
|
||||||
<TextField
|
<Box className="create-wrapper" sx={{ display: "flex" }}>
|
||||||
label="Name"
|
<CreateOptions />
|
||||||
onChange={coreUpdate("name")}
|
</Box>
|
||||||
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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
248
src/pages/CreateOptions.jsx
Normal file
248
src/pages/CreateOptions.jsx
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Autocomplete from "@mui/material/Autocomplete";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
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",
|
||||||
|
serverType: "VANILLA",
|
||||||
|
difficulty: "easy",
|
||||||
|
maxPlayers: "5",
|
||||||
|
gamemode: "survival",
|
||||||
|
memory: "512",
|
||||||
|
motd: `\\u00A7e\\u00A7ka\\u00A7l\\u00A7aMine\\u00A76Cluster\\u00A7r\\u00A78\\u00A7b\\u00A7ka`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Create() {
|
||||||
|
const [wl, setWl] = useState([]);
|
||||||
|
const [ops, setOps] = useState([]);
|
||||||
|
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);
|
||||||
|
console.log(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 opsAdd(e) {
|
||||||
|
const opEntry = e.target.innerHTML ?? e.target.value;
|
||||||
|
if (!opEntry) return;
|
||||||
|
const newOps = [...ops, opEntry];
|
||||||
|
setOps(newOps);
|
||||||
|
updateSpec("ops", newOps.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
function whitelistAdd(e) {
|
||||||
|
const wlEntry = e.target.value;
|
||||||
|
if (!wlEntry) return;
|
||||||
|
const newWl = [...wl, wlEntry];
|
||||||
|
setWl(newWl);
|
||||||
|
updateSpec("whitelist", newWl.join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
const opsRemove =
|
||||||
|
(name, { onDelete: updateAutoComplete }) =>
|
||||||
|
(e) => {
|
||||||
|
updateAutoComplete(e);
|
||||||
|
const newOps = [...ops];
|
||||||
|
const entryIndex = newOps.indexOf(name);
|
||||||
|
if (entryIndex === -1) return;
|
||||||
|
newOps.splice(entryIndex, 1);
|
||||||
|
setOps(newOps);
|
||||||
|
updateSpec("ops", newOps.join(","));
|
||||||
|
};
|
||||||
|
|
||||||
|
const whitelistRemove =
|
||||||
|
(name, { onDelete: updateAutocomplete }) =>
|
||||||
|
(e) => {
|
||||||
|
updateAutocomplete(e);
|
||||||
|
const newWl = [...wl];
|
||||||
|
const entryIndex = newWl.indexOf(name);
|
||||||
|
if (entryIndex === -1) return;
|
||||||
|
newWl.splice(entryIndex, 1);
|
||||||
|
setWl(newWl);
|
||||||
|
updateSpec("whitelist", newWl.join(","));
|
||||||
|
};
|
||||||
|
|
||||||
|
const opUpdate = (e) => alert("Op not implimented");
|
||||||
|
|
||||||
|
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.host) return alertValidationError("Host cannot be blank");
|
||||||
|
return "validated";
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertValidationError(reason) {
|
||||||
|
alert(`Could not validate spec because: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
className="create-options"
|
||||||
|
sx={{ width: "100%", maxWidth: "600px", margin: "auto" }}
|
||||||
|
>
|
||||||
|
<FormControl fullWidth sx={{ mt: "2rem", display: "flex", gap: ".5rem" }}>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
onChange={coreUpdate("name")}
|
||||||
|
helperText="Example: My Survival World"
|
||||||
|
defaultValue={spec.name}
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Host"
|
||||||
|
onChange={coreUpdate("host")}
|
||||||
|
helperText="Example: host.mc.example.com"
|
||||||
|
FormHelperTextProps={{ sx: { ml: 0 } }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Version"
|
||||||
|
onChange={coreUpdate("version")}
|
||||||
|
value={spec.version}
|
||||||
|
select
|
||||||
|
required
|
||||||
|
SelectProps={{ MenuProps: { sx: { maxHeight: "20rem" } } }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
id="whitelist-autocomplete"
|
||||||
|
options={[]}
|
||||||
|
onChange={whitelistAdd}
|
||||||
|
freeSolo
|
||||||
|
renderInput={(p) => <TextField {...p} label="Whitelist" />}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const defaultChipProps = getTagProps({ index });
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={option}
|
||||||
|
{...defaultChipProps}
|
||||||
|
onDelete={whitelistRemove(option, defaultChipProps)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
filterSelectedOptions={true}
|
||||||
|
multiple
|
||||||
|
id="ops-autocomplete"
|
||||||
|
options={wl}
|
||||||
|
onChange={opsAdd}
|
||||||
|
renderInput={(p) => <TextField {...p} label="Ops" />}
|
||||||
|
renderTags={(value, getTagProps) =>
|
||||||
|
value.map((option, index) => {
|
||||||
|
const defaultChipProps = getTagProps({ index });
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={option}
|
||||||
|
{...defaultChipProps}
|
||||||
|
onDelete={opsRemove(option, defaultChipProps)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/*<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>
|
||||||
|
);
|
||||||
|
}
|
20
src/pages/Files.jsx
Normal file
20
src/pages/Files.jsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { 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 MineclusterFiles from "@mcl/components/files/MineclusterFiles.jsx";
|
||||||
|
|
||||||
|
export default function Files() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const currentServer = searchParams.get("server");
|
||||||
|
const nav = useNavigate();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentServer) nav("/");
|
||||||
|
}, [currentServer]);
|
||||||
|
return (
|
||||||
|
<Box className="edit" sx={{ height: "100%" }}>
|
||||||
|
<MineclusterFiles server={currentServer} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,19 +1,25 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import ServerCard from "../servers/ServerCard.jsx";
|
import ServerCard from "@mcl/components/servers/ServerCard.jsx";
|
||||||
import RconDialog, { useRconDialog } from "../servers/RconDialog.jsx";
|
import RconDialog, {
|
||||||
import Overview from "../overview/Overview.jsx";
|
useRconDialog,
|
||||||
|
} from "@mcl/components/servers/RconDialog.jsx";
|
||||||
|
import Overview from "@mcl/components/overview/Overview.jsx";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import SpeedDialIcon from "@mui/material/SpeedDialIcon";
|
||||||
import "@mcl/css/server-card.css";
|
import "@mcl/css/server-card.css";
|
||||||
import "@mcl/css/overview.css";
|
import "@mcl/css/overview.css";
|
||||||
import { useServerInstances } from "@mcl/queries";
|
import { useServerInstances } from "@mcl/queries";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const clusterMetrics = { cpu: 0, memory: 0 };
|
||||||
const [server, setServer] = useState();
|
const [server, setServer] = useState();
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
const [rdOpen, rconToggle] = useRconDialog();
|
const [rdOpen, rconToggle] = useRconDialog();
|
||||||
const { isLoading, data: serversData } = useServerInstances();
|
const { isLoading, data: serversData } = useServerInstances();
|
||||||
const { servers: serverInstances, clusterMetrics } = serversData ?? {};
|
const serverInstances = serversData ?? [];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverInstances) return;
|
if (!serverInstances) return;
|
||||||
serverInstances.sort((a, b) => a.name.localeCompare(b.name));
|
serverInstances.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
@ -53,6 +59,23 @@ export default function Home() {
|
||||||
dialogToggle={rconToggle}
|
dialogToggle={rconToggle}
|
||||||
serverName={server}
|
serverName={server}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/mcl/create"
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 16,
|
||||||
|
right: 16,
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "100%",
|
||||||
|
height: "4rem",
|
||||||
|
width: "4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SpeedDialIcon />
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,15 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
const fetchApi = (subPath) => async () =>
|
const fetchApi = (subPath) => async () =>
|
||||||
fetch(`/api${subPath}`).then((res) => res.json());
|
fetch(`/api${subPath}`).then((res) => res.json());
|
||||||
|
|
||||||
|
const fetchApiCore = async (subPath, json, method = "POST", jsonify = false) =>
|
||||||
|
fetch(`/api${subPath}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(json),
|
||||||
|
}).then((res) => (jsonify ? res.json() : res));
|
||||||
|
|
||||||
const fetchApiPost = (subPath, json) => async () =>
|
const fetchApiPost = (subPath, json) => async () =>
|
||||||
fetch(`/api${subPath}`, {
|
fetch(`/api${subPath}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -12,16 +21,16 @@ const fetchApiPost = (subPath, json) => async () =>
|
||||||
}).then((res) => res.json());
|
}).then((res) => res.json());
|
||||||
|
|
||||||
export const useServerStatus = (server) =>
|
export const useServerStatus = (server) =>
|
||||||
useQuery(
|
useQuery({
|
||||||
[`server-status-${server}`],
|
queryKey: [`server-status-${server}`],
|
||||||
fetchApiPost("/server/status", { name: server })
|
queryFn: fetchApiPost("/server/status", { name: server }),
|
||||||
);
|
});
|
||||||
export const useServerMetrics = (server) =>
|
export const useServerMetrics = (server) =>
|
||||||
useQuery(
|
useQuery({
|
||||||
[`server-metrics-${server}`],
|
queryKey: [`server-metrics-${server}`],
|
||||||
fetchApiPost("/server/metrics", { name: server }),
|
queryFn: fetchApiPost("/server/metrics", { name: server }),
|
||||||
{ refetchInterval: 10000 }
|
refetchInterval: 10000,
|
||||||
);
|
});
|
||||||
export const useStartServer = (server) =>
|
export const useStartServer = (server) =>
|
||||||
postJsonApi("/server/start", { name: server }, "server-instances");
|
postJsonApi("/server/start", { name: server }, "server-instances");
|
||||||
export const useStopServer = (server) =>
|
export const useStopServer = (server) =>
|
||||||
|
@ -30,20 +39,61 @@ export const useDeleteServer = (server) =>
|
||||||
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
|
postJsonApi("/server/delete", { name: server }, "server-instances", "DELETE");
|
||||||
export const useCreateServer = (spec) =>
|
export const useCreateServer = (spec) =>
|
||||||
postJsonApi("/server/create", spec, "server-list");
|
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) =>
|
||||||
|
fetchApiCore("/files/folder", {
|
||||||
|
name: server,
|
||||||
|
path,
|
||||||
|
}); /*postJsonApi("/files/folder", {name: server, path});*/
|
||||||
|
export const deleteServerItem = async (server, path, isDir) =>
|
||||||
|
fetchApiCore("/files/item", { name: server, path, isDir }, "DELETE");
|
||||||
|
|
||||||
|
export const getServerItem = async (server, name, path) =>
|
||||||
|
fetchApiCore("/files/item", { name: server, path })
|
||||||
|
.then((resp) =>
|
||||||
|
resp.status === 200
|
||||||
|
? resp.blob()
|
||||||
|
: Promise.reject("something went wrong"),
|
||||||
|
)
|
||||||
|
.then((blob) => {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.style.display = "none";
|
||||||
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useInvalidator = () => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return (q) => qc.invalidateQueries([q]);
|
||||||
|
};
|
||||||
|
|
||||||
export const useServerList = () =>
|
export const useServerList = () =>
|
||||||
useQuery(["server-list"], fetchApi("/server/list"));
|
useQuery({ queryKey: ["server-list"], queryFn: fetchApi("/server/list") });
|
||||||
export const useServerInstances = () =>
|
export const useServerInstances = () =>
|
||||||
useQuery(["server-instances"], fetchApi("/server/instances"), {
|
useQuery({
|
||||||
|
queryKey: ["server-instances"],
|
||||||
|
queryFn: fetchApi("/server/instances"),
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
export const useSystemAvailable = () =>
|
export const useSystemAvailable = () =>
|
||||||
useQuery(["system-available"], fetchApi("/system/available"));
|
useQuery({
|
||||||
|
queryKey: ["system-available"],
|
||||||
|
queryFn: fetchApi("/system/available"),
|
||||||
|
});
|
||||||
export const useVersionList = () =>
|
export const useVersionList = () =>
|
||||||
useQuery(["minecraft-versions"], () =>
|
useQuery({
|
||||||
fetch("https://piston-meta.mojang.com/mc/game/version_manifest.json").then(
|
queryKey: ["minecraft-versions"],
|
||||||
(r) => r.json()
|
queryFn: () =>
|
||||||
)
|
fetch(
|
||||||
);
|
"https://piston-meta.mojang.com/mc/game/version_manifest.json",
|
||||||
|
).then((r) => r.json()),
|
||||||
|
});
|
||||||
|
|
||||||
const postJsonApi = (subPath, body, invalidate, method = "POST") => {
|
const postJsonApi = (subPath, body, invalidate, method = "POST") => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
@ -55,6 +105,7 @@ const postJsonApi = (subPath, body, invalidate, method = "POST") => {
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
qc.invalidateQueries([invalidate]);
|
if (invalidate) qc.invalidateQueries([invalidate]);
|
||||||
|
return res.json();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
16
src/util/theme.js
Normal file
16
src/util/theme.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Generated using https://zenoo.github.io/mui-theme-creator/
|
||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
const themeOptions = {
|
||||||
|
palette: {
|
||||||
|
mode: "light",
|
||||||
|
primary: {
|
||||||
|
main: "rgba(109,216,144,255)",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#f50057",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createTheme(themeOptions);
|
|
@ -15,6 +15,7 @@ export default () => {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": backendUrl,
|
"/api": backendUrl,
|
||||||
"/socket.io": backendUrl,
|
"/socket.io": backendUrl,
|
||||||
|
"/healthz": backendUrl,
|
||||||
},
|
},
|
||||||
hmr: {
|
hmr: {
|
||||||
protocol: process.env.MCL_VITE_DEV_PROTOCOL,
|
protocol: process.env.MCL_VITE_DEV_PROTOCOL,
|
||||||
|
@ -26,10 +27,11 @@ export default () => {
|
||||||
base: "/mcl/",
|
base: "/mcl/",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@mcl/css": path.resolve("./public/css"),
|
"@mcl/css": path.resolve("./src/css"),
|
||||||
"@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"),
|
"@mcl/settings": path.resolve("./src/ctx/SettingsContext.jsx"),
|
||||||
"@mcl/pages": path.resolve("./src/pages"),
|
"@mcl/pages": path.resolve("./src/pages"),
|
||||||
"@mcl/queries": path.resolve("./src/util/queries.js"),
|
"@mcl/queries": path.resolve("./src/util/queries.js"),
|
||||||
|
"@mcl/components": path.resolve("./src/components"),
|
||||||
"@mcl": path.resolve("./src"),
|
"@mcl": path.resolve("./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue