Compare commits

...
Sign in to create a new pull request.

17 commits

53 changed files with 1349 additions and 449 deletions

View file

@ -2,3 +2,6 @@
Minecluster or MCL is a web interface used to manage multiple instance of Minecraft Servers in Kubernetes. This app is built to be an all in one for self-hosting Minecraft server. It uses rendered helm charts based on itzg/minecraft-server
More info coming soon.
## ⚠ Warning ⚠
Development is very active and there is no garuntee for compatability or migration across versions 1/15/24

View file

@ -10,7 +10,7 @@ import { sendError } from "../util/ExpressClientError.js";
export async function listFiles(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.id) return res.status(400).send("Server id missing!");
listServerFiles(serverSpec)
.then((f) => {
const fileData = f.map((fi, i) => ({
@ -29,7 +29,7 @@ export async function listFiles(req, res) {
export async function createFolder(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
createServerFolder(serverSpec)
.then(() => res.sendStatus(200))
@ -39,7 +39,7 @@ export async function createFolder(req, res) {
export async function deleteItem(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
if (serverSpec.isDir === undefined || serverSpec.isDir === null)
return res.status(400).send("IsDIr required!");
@ -50,7 +50,7 @@ export async function deleteItem(req, res) {
export async function uploadItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
uploadServerItem(serverSpec, req.file)
.then(() => res.sendStatus(200))
@ -59,7 +59,7 @@ export async function uploadItem(req, res) {
export async function getItem(req, res) {
const serverSpec = req.body;
if (!serverSpec.name) return res.status(400).send("Server name required!");
if (!serverSpec.id) return res.status(400).send("Server id missing!");
if (!serverSpec.path) return res.status(400).send("Path required!");
getServerItem(serverSpec, res)
.then(({ ds, ftpTransfer }) => {

View file

@ -6,43 +6,61 @@ import {
getServerEntry,
} from "../database/queries/server-queries.js";
import { sendError } from "../util/ExpressClientError.js";
import {
startServerContainer,
stopServerContainer,
} from "../k8s/server-control.js";
import { toggleServer } from "../k8s/k8s-server-control.js";
const dnsRegex = new RegExp(
`^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])(\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]{0,61}[a-zA-Z0-9]))*$`,
);
function payloadFilter(req, res) {
const serverSpec = req.body;
if (!serverSpec) return res.sendStatus(400);
const { name, host, version, serverType, difficulty, gamemode, memory } =
const { name, host, version, serverType, memory } = serverSpec;
const { backupHost, backupBucket, backupId, backupKey, backupInterval } =
serverSpec;
if (!name) return res.status(400).send("Server name is required!");
if (!host) return res.status(400).send("Server host is required!");
if (!dnsRegex.test(host)) return res.status(400).send("Hostname invalid!");
if (!version) return res.status(400).send("Server version is required!");
if (!difficulty)
return res.status(400).send("Server difficulty is required!");
if (!serverType) return res.status(400).send("Server type is required!");
if (!gamemode) return res.status(400).send("Server Gamemode is required!");
if (!memory) return res.status(400).send("Memory is required!");
req.body.name = req.body.name.toLowerCase();
// TODO: Impliment non creation time backups
if (
!!backupHost ||
!!backupBucket ||
!!backupId ||
!!backupKey ||
!!backupInterval
) {
// If any keys are required, all are required
if (
!(
!!backupHost &&
!!backupBucket &&
!!backupId &&
!!backupKey &&
!!backupInterval
)
)
return res.status(400).send("All backup keys are required!");
if (!dnsRegex.test(backupHost))
return res.status(400).send("Backup Host invalid!");
}
return "filtered";
}
function checkServerName(serverSpec) {
function checkServerId(serverSpec) {
if (!serverSpec) throw new ExpressClientError({ c: 400 });
if (!serverSpec.name)
throw new ExpressClientError({ c: 400, m: "Server name required!" });
if (!serverSpec.id)
throw new ExpressClientError({ c: 400, m: "Server id missing!" });
}
export async function createServer(req, res) {
if (payloadFilter(req, res) !== "filtered") return;
const serverSpec = req.body;
try {
const serverSpecs = await getServerEntry(serverSpec.name);
if (serverSpecs.length !== 0) throw Error("Server already exists in DB!");
await createServerResources(serverSpec);
await createServerEntry(serverSpec);
const serverEntry = await createServerEntry(serverSpec);
await createServerResources(serverEntry);
res.sendStatus(200);
} catch (e) {
sendError(res)(e);
@ -53,11 +71,11 @@ export async function deleteServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
checkServerId(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const deleteEntry = deleteServerEntry(serverSpec.name);
const deleteEntry = deleteServerEntry(serverSpec.id);
const deleteResources = deleteServerResources(serverSpec);
Promise.all([deleteEntry, deleteResources])
.then(() => res.sendStatus(200))
@ -68,12 +86,12 @@ export async function startServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
checkServerId(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const { name } = serverSpec;
toggleServer(name, true)
const { id } = serverSpec;
toggleServer(id, true)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}
@ -82,12 +100,12 @@ export async function stopServer(req, res) {
// Ensure spec is safe
const serverSpec = req.body;
try {
checkServerName(serverSpec);
checkServerId(serverSpec);
} catch (e) {
return sendError(res)(e);
}
const { name } = serverSpec;
toggleServer(name, false)
const { id } = serverSpec;
toggleServer(id, false)
.then(() => res.sendStatus(200))
.catch(sendError(res));
}

View file

@ -1,12 +1,12 @@
import { getDeployments } from "../k8s/k8s-server-control.js";
import { getInstances } from "../k8s/server-control.js";
import { getInstances } from "../k8s/server-status.js";
import { sendError } from "../util/ExpressClientError.js";
export function serverList(req, res) {
getDeployments()
.then((sd) => res.json(sd.map((s) => s.metadata.name.substring(4))))
.catch((e) => {
ERR("SERVER CONTROL", e);
ERR("STATUS CONTROLLER", e);
res.status(500).send("Couldn't get server list");
});
}

View file

@ -3,6 +3,7 @@ import k8s from "@kubernetes/client-node";
import { Rcon as RconClient } from "rcon-client";
import stream from "stream";
import { ERR, WARN } from "../../util/logging.js";
import { getServerEntry } from "../../database/queries/server-queries.js";
// Kubernetes Configuration
const kc = new k8s.KubeConfig();
@ -12,8 +13,9 @@ const namespace = process.env.MCL_SERVER_NAMESPACE;
// Retrieves logs from the minecraft server container
export async function webConsoleLogs(socket) {
const { serverName } = socket.mcs;
const podName = `mcl-${serverName}`;
const { serverId } = socket.mcs;
const server = await getServerEntry(serverId);
const podName = `mcl-${server.mclName}`;
const containerName = `${podName}-server`;
const podResponse = await k8sCore.listNamespacedPod(namespace);
const pods = podResponse.body.items.map((vp1) => vp1.metadata.name);
@ -41,14 +43,15 @@ export async function webConsoleLogs(socket) {
export async function webConsoleRcon(socket) {
if (socket.rconClient)
return VERB("RCON", "Socket already connected to RCON");
const rconSecret = `mcl-${socket.mcs.serverName}-rcon-secret`;
const { serverId } = socket.mcs;
const server = await getServerEntry(serverId);
const rconSecret = `mcl-${server.mclName}-rcon-secret`;
const rconRes = await k8sCore.readNamespacedSecret(rconSecret, namespace);
const rconPassword = Buffer.from(
rconRes.body.data["rcon-password"],
"base64",
).toString("utf8");
const { serverName } = socket.mcs;
const rconHost = `mcl-${serverName}-rcon.${namespace}.svc.cluster.local`;
const rconHost = `mcl-${server.mclName}-rcon.${namespace}.svc.cluster.local`;
const rcon = new RconClient({
host: rconHost,
port: 25575,
@ -58,7 +61,7 @@ export async function webConsoleRcon(socket) {
try {
await rcon.connect();
} catch (error) {
socket.emit("push", "Could not connect RCON Input to server!");
socket.emit("rcon-error", "Could not connect RCON Input to server!");
WARN("RCON", `Could not connect to '${rconHost}'`);
}
socket.rconClient = rcon;

View file

@ -1,12 +1,18 @@
CREATE SEQUENCE servers_id_seq;
CREATE TABLE servers (
id bigint NOT NULL DEFAULT nextval('servers_id_seq') PRIMARY KEY,
name varchar(255) DEFAULT NULL,
host varchar(255) DEFAULT NULL,
name varchar(255) DEFAULT NULL,
version varchar(63) DEFAULT 'latest',
server_type varchar(63) DEFAULT 'VANILLA',
cpu varchar(63) DEFAULT '500',
memory varchar(63) DEFAULT '512',
CONSTRAINT unique_name UNIQUE(name),
backup_enabled BOOLEAN DEFAULT FALSE,
backup_host varchar(255) DEFAULT NULL,
backup_bucket_path varchar(255) DEFAULT NULL,
backup_id varchar(255) DEFAULT NULL,
backup_key varchar(255) DEFAULT NULL,
backup_interval varchar(255) DEFAULT NULL,
CONSTRAINT unique_host UNIQUE(host)
);
ALTER SEQUENCE servers_id_seq OWNED BY servers.id;

View file

@ -7,35 +7,122 @@ const asExpressClientError = (e) => {
throw new ExpressClientError({ m: e.message, c: 409 });
};
const getMclName = (host, id) => `${host.replaceAll(".", "-")}-${id}`;
export async function createServerEntry(serverSpec) {
const { name, host, version, serverType: server_type, memory } = serverSpec;
const q = insertQuery(table, { name, host, version, server_type, memory });
const {
name,
host,
version,
serverType: server_type,
memory,
backupHost: backup_host,
backupBucket: backup_bucket_path,
backupId: backup_id,
backupKey: backup_key,
backupInterval: backup_interval,
} = serverSpec;
var q = insertQuery(table, {
name,
host,
version,
server_type,
memory,
backup_enabled: !!backup_interval, // We already verified the payload, so any backup key will work
backup_host,
backup_bucket_path,
backup_id,
backup_key,
backup_interval,
});
q += "\n RETURNING *";
try {
const entries = await pg.query(q);
const {
id,
name,
host,
version,
server_type: serverType,
memory,
backup_enabled: backupEnabled,
backup_host: backupHost,
backup_bucket_path: backupPath,
backup_id: backupId,
backup_key: backupKey,
backup_interval: backupInterval,
} = entries[0];
const mclName = getMclName(host, id);
return {
name,
mclName,
id,
host,
version,
serverType,
memory,
backupEnabled,
backupHost,
backupPath,
backupId,
backupKey,
backupInterval,
};
} catch (e) {
asExpressClientError(e);
}
}
export async function deleteServerEntry(serverId) {
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
const q = deleteQuery(table, { id: serverId });
return pg.query(q).catch(asExpressClientError);
}
export async function deleteServerEntry(serverName) {
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
const q = deleteQuery(table, { name: serverName });
return pg.query(q).catch(asExpressClientError);
}
export async function getServerEntry(serverName) {
if (!serverName) asExpressClientError({ message: "Server Name Required!" });
const q = selectWhereQuery(table, { name: serverName });
export async function getServerEntry(serverId) {
if (!serverId) asExpressClientError({ message: "Server ID Required!" });
const q = selectWhereQuery(table, { id: serverId });
try {
const serverSpecs = await pg.query(q);
if (serverSpecs.length === 0) return [];
if (!serverSpecs.length === 1)
throw Error("Multiple servers found with the same name!");
const {
id,
name,
host,
version,
server_type: serverType,
memory,
backup_enabled: backupEnabled,
backup_host: backupHost,
backup_bucket_path: backupPath,
backup_id: backupId,
backup_key: backupKey,
backup_interval: backupInterval,
} = serverSpecs[0];
return { name, host, version, serverType, memory };
const mclName = getMclName(host, id);
return {
name,
mclName,
id,
host,
version,
serverType,
memory,
backupEnabled,
backupHost,
backupPath,
backupId,
backupKey,
backupInterval,
};
} catch (e) {
asExpressClientError(e);
}
}
export async function getServerEntries() {
const q = `SELECT * FROM ${table}`;
return pg.query(q);
}

View file

@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
annotations:
minecluster.dunemask.net/id: changeme-server-id
labels:
app: changeme-app-label
name: changeme-backup-secret
type: Opaque
data:
rclone.conf: ""

View file

@ -6,20 +6,18 @@ env:
image: garethflowers/ftp-server
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command: ["echo"]
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
exec: { command: ["echo"] }
failureThreshold: 20
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
name: changeme-name-ftp
ports: [] # Programatically add all the ports for easier readability, Ports include: 20,21,40000-400009
readinessProbe:
exec:
command: ["echo"]
exec: { command: ["echo"] }
failureThreshold: 20
initialDelaySeconds: 30
initialDelaySeconds: 0
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1

View file

@ -33,20 +33,20 @@ env:
- name: DEST_DIR
value: /backups
- name: LINK_LATEST
value: "false"
value: "true"
- name: TAR_COMPRESS_METHOD
value: gzip
- name: ZSTD_PARAMETERS
value: -3 --long=25 --single-thread
- name: RCLONE_REMOTE
value: mc-dunemask-net
value: mcl-backup-changeme
- name: RCLONE_DEST_DIR
value: /minecraft-backups/deltasmp-backups
value: /mcl/backups/changeme
- name: RCLONE_COMPRESS_METHOD
value: gzip
image: itzg/mc-backup:latest
imagePullPolicy: IfNotPresent
name: mcs-deltasmp-minecraft-mc-backup
name: mcl-backup-changeme
resources:
requests:
cpu: 500m

View file

@ -72,12 +72,10 @@ env:
image: itzg/minecraft-server:latest
imagePullPolicy: IfNotPresent
livenessProbe:
exec:
command:
- mc-health
failureThreshold: 20
exec: { command: [mc-health] }
failureThreshold: 200
initialDelaySeconds: 30
periodSeconds: 5
periodSeconds: 3
successThreshold: 1
timeoutSeconds: 1
name: changeme-name-server
@ -88,15 +86,13 @@ ports:
- containerPort: 25575
name: rcon
protocol: TCP
readinessProbe:
exec:
command:
- mc-health
failureThreshold: 20
initialDelaySeconds: 30
periodSeconds: 5
successThreshold: 1
timeoutSeconds: 1
# readinessProbe: # Disabling this allows for users to manipulate files even if the container is starting
# exec: {command: [mc-health]}
# failureThreshold: 200
# initialDelaySeconds: 30
# periodSeconds: 3
# successThreshold: 1
# timeoutSeconds: 1
resources:
requests:
cpu: 500m

View file

@ -4,7 +4,7 @@ data:
kind: Secret
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
labels:
app: changeme-app-label
name: changeme-rcon-secret

View file

@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
labels:
app: changeme-app
name: changeme-rcon

View file

@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
name: changeme-name
namespace: changeme-namespace
spec:
@ -17,7 +17,7 @@ spec:
template:
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
labels:
app: changeme-app
spec:
@ -35,10 +35,10 @@ spec:
claimName: changeme-pvc-name
- emptyDir: {}
name: backupdir
- name: rclone-config
secret:
defaultMode: 420
items:
- key: rclone.conf
path: rclone.conf
secretName: rclone-config
# - name: rclone-config
# secret:
# defaultMode: 420
# items:
# - key: rclone.conf
# path: rclone.conf
# secretName: rclone-config

View file

@ -2,7 +2,7 @@ apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
labels:
service: changeme-service-name
name: changeme-pvc-name

View file

@ -4,7 +4,7 @@ metadata:
annotations:
ingress.qumine.io/hostname: changeme-url
ingress.qumine.io/portname: minecraft
minecluster.dunemask.net/server-name: changeme-server-name
minecluster.dunemask.net/id: changeme-server-id
labels:
app: changeme-app
name: changeme-name

View file

@ -20,10 +20,10 @@ const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
const mineclusterManaged = (o) =>
o.metadata &&
o.metadata.annotations &&
o.metadata.annotations["minecluster.dunemask.net/server-name"] !== undefined;
o.metadata.annotations["minecluster.dunemask.net/id"] !== undefined;
export const serverMatch = (serverName) => (o) =>
o.metadata.annotations["minecluster.dunemask.net/server-name"] === serverName;
export const serverMatch = (serverId) => (o) =>
o.metadata.annotations["minecluster.dunemask.net/id"] === serverId;
export async function getDeployments() {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
@ -50,8 +50,8 @@ export async function getVolumes() {
return serverVolumes;
}
export function getServerAssets(serverName) {
const serverFilter = serverMatch(serverName);
export function getServerAssets(serverId) {
const serverFilter = serverMatch(serverId);
return Promise.all([
getDeployments(),
getServices(),
@ -66,17 +66,18 @@ export function getServerAssets(serverName) {
if (deployments.length > 1) throw Error("Deployment filter broken!");
if (volumes.length > 1) throw Error("Volume filter broken!");
if (secrets.length > 1) throw Error("Secrets broken!");
if (secrets.length > 2) throw Error("Secrets broken!");
const serverAssets = {
deployment: deployments[0],
service: services.find(
(s) => s.metadata.name === `mcl-${serverName}-server`,
),
service: services.find((s) => s.metadata.name.endsWith("-server")),
volume: volumes[0],
rconService: services.find(
(s) => s.metadata.name === `mcl-${serverName}-rcon`,
rconService: services.find((s) => s.metadata.name.endsWith("-rcon")),
rconSecret: secrets.find((s) =>
s.metadata.name.endsWith("-rcon-secret"),
),
backupSecret: secrets.find((s) =>
s.metadata.name.endsWith("-backup-secret"),
),
rconSecret: secrets[0],
};
for (var k in serverAssets) if (serverAssets[k]) return serverAssets;
// If no assets exist, return nothing
@ -84,59 +85,51 @@ export function getServerAssets(serverName) {
.catch((e) => ERR("SERVER ASSETS", e));
}
export async function getDeployment(serverName) {
export async function getDeployment(serverId) {
const servers = await getDeployments();
const serverDeployment = servers.find(
(s) =>
s.metadata.annotations["minecluster.dunemask.net/server-name"] ===
serverName,
(s) => s.metadata.annotations["minecluster.dunemask.net/id"] === serverId,
);
if (!serverDeployment)
throw Error(`MCL Deployment '${serverName}' could not be found!`);
throw Error(`MCL Deployment with ID '${serverId}' could not be found!`);
return serverDeployment;
}
export async function getContainers(serverName) {
const deployment = await getDeployment(serverName);
export async function getContainers(serverId) {
const deployment = await getDeployment(serverId);
return deployment.spec.template.spec.containers;
}
async function containerControl(serverName, deployment, scaleUp) {
async function containerControl(serverSpec, deployment, scaleUp) {
const { containers } = deployment.spec.template.spec;
const depFtp = containers.find((c) => c.name.endsWith("-ftp"));
const depServer = containers.find((c) => c.name.endsWith("-server"));
const depBackup = containers.find((c) => c.name.endsWith("-backup"));
const serverSpec = await getServerEntry(serverName);
const ftpContainer = depFtp ?? getFtpContainer(serverSpec);
const serverContainer = depServer ?? getCoreServerContainer(serverSpec);
const backupContainer = depBackup ?? getBackupContainer(serverSpec);
if (scaleUp) return [ftpContainer, serverContainer];
if (scaleUp && serverSpec.backupEnabled)
return [ftpContainer, serverContainer, backupContainer];
else if (scaleUp) return [ftpContainer, serverContainer];
return [ftpContainer];
}
export async function toggleServer(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
deployment.spec.template.spec.containers = await containerControl(
serverName,
deployment,
scaleUp,
);
return k8sDeps.replaceNamespacedDeployment(
deployment.metadata.name,
namespace,
deployment,
);
export function terminationControl(containers) {
return containers.length > 1 ? 30 /*seconds*/ : 1 /*seconds */;
}
export async function scaleDeployment(serverName, scaleUp = false) {
const deployment = await getDeployment(serverName);
if (deployment.spec.replicas === 1 && scaleUp)
return VERB(
"KSC",
`MCL Deployment '${serverName}' is already scaled! Ignoring scale adjustment.`,
);
deployment.spec.replicas = scaleUp ? 1 : 0;
export async function toggleServer(serverId, scaleUp = false) {
const [deployment, serverSpec] = await Promise.all([
getDeployment(serverId),
getServerEntry(serverId),
]);
const containers = await containerControl(serverSpec, deployment, scaleUp);
const ts = terminationControl(containers);
// Speed up container termination if not running a server
deployment.spec.template.spec.terminationGracePeriodSeconds = ts;
deployment.spec.template.spec.containers = containers;
return k8sDeps.replaceNamespacedDeployment(
deployment.metadata.name,

View file

@ -4,9 +4,9 @@ import yaml from "js-yaml";
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
export function getFtpContainer(serverSpec) {
const { name } = serverSpec;
const { mclName } = serverSpec;
const ftpContainer = loadYaml("lib/k8s/configs/containers/ftp-server.yml");
ftpContainer.name = `mcl-${name}-ftp`;
ftpContainer.name = `mcl-${mclName}-ftp`;
const ftpPortList = [
{ p: 20, n: "ftp-data" },
{ p: 21, n: "ftp-commands" },
@ -22,10 +22,10 @@ export function getFtpContainer(serverSpec) {
}
export function getCoreServerContainer(serverSpec) {
const { name, version, serverType, memory } = serverSpec;
const { mclName, version, serverType, memory } = serverSpec;
const container = loadYaml("lib/k8s/configs/containers/minecraft-server.yml");
// Container Updates
container.name = `mcl-${name}-server`;
container.name = `mcl-${mclName}-server`;
container.resources.requests.memory = `${memory}Mi`;
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
@ -36,7 +36,7 @@ export function getCoreServerContainer(serverSpec) {
updateEnv("VERSION", version);
updateEnv("MEMORY", `${memory}M`);
// RCON
const rs = `mcl-${name}-rcon-secret`;
const rs = `mcl-${mclName}-rcon-secret`;
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
return container;
}
@ -50,18 +50,29 @@ export function getServerContainer(serverSpec) {
const updateEnv = (k, v) => (findEnv(k).value = v);
// Enviornment variables
updateEnv("DIFFICULTY", difficulty);
/*updateEnv("DIFFICULTY", difficulty);
updateEnv("MODE", gamemode);
updateEnv("MOTD", motd);
updateEnv("MAX_PLAYERS", maxPlayers);
updateEnv("SEED", seed);
updateEnv("OPS", ops);
updateEnv("WHITELIST", whitelist);
updateEnv("WHITELIST", whitelist); */
return container;
}
export function getBackupContainer(serverSpec) {
const { mclName, backupEnabled, backupPath } = serverSpec;
const container = loadYaml("lib/k8s/configs/containers/minecraft-backup.yml");
if (!backupEnabled) return;
const findEnv = (k) => container.env.find(({ name: n }) => n === k);
const updateEnv = (k, v) => (findEnv(k).value = v);
updateEnv("RCLONE_REMOTE", `${mclName}-backup`);
updateEnv("RCLONE_DEST_DIR", backupPath);
container.name = `mcl-${mclName}-backup`;
// RCON
const rs = `mcl-${mclName}-rcon-secret`;
findEnv("RCON_PASSWORD").valueFrom.secretKeyRef.name = rs;
return container;
}

View file

@ -1,94 +0,0 @@
import k8s from "@kubernetes/client-node";
import {
getDeployment,
getDeployments,
getServerAssets,
scaleDeployment,
} from "./k8s-server-control.js";
import { ERR } from "../util/logging.js";
import ExpressClientError from "../util/ExpressClientError.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sMetrics = new k8s.Metrics(kc);
const namespace = process.env.MCL_SERVER_NAMESPACE;
export async function startServerContainer(serverSpec) {
const { name } = serverSpec;
try {
await scaleDeployment(name, true);
} catch (e) {
ERR("SERVER CONTROL", e);
throw new ExpressClientError({
c: 500,
m: `Error updating server '${name}'!\n`,
});
}
}
export async function stopServerContainer(serverSpec) {
const { name } = serverSpec;
try {
await scaleDeployment(name, false);
} catch (e) {
ERR("SERVER CONTROL", e);
throw new ExpressClientError({
c: 500,
m: `Error updating server '${name}'!`,
});
}
}
export async function getInstances() {
const serverDeployments = await getDeployments();
const podMetricsResponse = await k8sMetrics.getPodMetrics(namespace);
var name, metrics, services, serverAvailable, ftpAvailable;
const serverInstances = serverDeployments.map((s) => {
name = s.metadata.annotations["minecluster.dunemask.net/server-name"];
metrics = null;
const { containers } = s.spec.template.spec;
services = containers.map(({ name }) => name.split("-").pop());
const serverStatusList = s.status.conditions.map(
({ type: statusType, status: sts }) => ({ statusType, sts }),
);
const deploymentAvailable =
serverStatusList.find(
(ss) => ss.statusType === "Available" && ss.sts === "True",
) !== undefined;
serverAvailable = services.includes(`server`) && deploymentAvailable;
ftpAvailable = services.includes("ftp") && deploymentAvailable;
const pod = podMetricsResponse.items.find(({ metadata: md }) => {
return md.labels && md.labels.app && md.labels.app === `mcl-${name}-app`;
});
if (serverAvailable && pod) {
const podCpus = pod.containers.map(
({ usage }) => parseInt(usage.cpu) / 1_000_000,
);
const podMems = pod.containers.map(
({ usage }) => parseInt(usage.memory) / 1024,
);
metrics = {
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
};
}
return { name, metrics, services, serverAvailable, ftpAvailable };
});
return serverInstances;
}
export async function getNamespaceMetrics() {
const serverInstances = await getInstances();
var clusterMetrics = { cpu: 0, memory: 0 };
if (servers.length > 1) {
const clusterCpu = serverInstances
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
.reduce((a, b) => a + b);
const clusterMem = serverInstances
.map(({ metrics }) => (metrics ? metrics.memory : 0))
.reduce((a, b) => a + b);
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
}
return clusterMetrics;
}

View file

@ -4,7 +4,7 @@ import k8s from "@kubernetes/client-node";
import yaml from "js-yaml";
import fs from "node:fs";
import path from "node:path";
import ExpressClientError from "../util/ExpressClientError.js";
import {
getFtpContainer,
getServerContainer,
@ -19,33 +19,54 @@ const namespace = process.env.MCL_SERVER_NAMESPACE;
const loadYaml = (f) => yaml.load(fs.readFileSync(path.resolve(f), "utf8"));
function createBackupSecret(serverSpec) {
if (!serverSpec.backupEnabled) return; // If backup not defined, don't create RCLONE secret
const { mclName, id, backupId, backupKey, backupHost } = serverSpec;
const backupYaml = loadYaml("lib/k8s/configs/backup-secret.yml");
backupYaml.metadata.labels.app = `mcl-${mclName}-app`;
backupYaml.metadata.name = `mcl-${mclName}-backup-secret`;
backupYaml.metadata.namespace = namespace;
backupYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
const rcloneConfig = [
`[${mclName}-backup]`,
"type = s3",
"provider = Minio",
"env_auth = false",
`access_key_id = ${backupId}`,
`secret_access_key = ${backupKey}`,
`endpoint = ${backupHost}`,
`acl = private`,
].join("\n");
backupYaml.data["rclone.conf"] = Buffer.from(rcloneConfig).toString("base64");
return backupYaml;
}
function createRconSecret(serverSpec) {
const { name } = serverSpec;
const { mclName, id } = serverSpec;
const rconYaml = loadYaml("lib/k8s/configs/rcon-secret.yml");
// TODO: Dyamic rconPassword
const rconPassword = bcrypt.hashSync(uuidv4(), 10);
rconYaml.data["rcon-password"] = Buffer.from(rconPassword).toString("base64");
rconYaml.metadata.labels.app = `mcl-${name}-app`;
rconYaml.metadata.name = `mcl-${name}-rcon-secret`;
rconYaml.metadata.labels.app = `mcl-${mclName}-app`;
rconYaml.metadata.name = `mcl-${mclName}-rcon-secret`;
rconYaml.metadata.namespace = namespace;
rconYaml.metadata.annotations["minecluster.dunemask.net/server-name"] = name;
rconYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
return rconYaml;
}
function createServerVolume(serverSpec) {
const { name } = serverSpec;
const { mclName, id } = serverSpec;
const volumeYaml = loadYaml("lib/k8s/configs/server-pvc.yml");
volumeYaml.metadata.labels.service = `mcl-${name}-server`;
volumeYaml.metadata.name = `mcl-${name}-volume`;
volumeYaml.metadata.labels.service = `mcl-${mclName}-server`;
volumeYaml.metadata.name = `mcl-${mclName}-volume`;
volumeYaml.metadata.namespace = namespace;
volumeYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
volumeYaml.spec.resources.requests.storage = "1Gi"; // TODO: Changeme
volumeYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
volumeYaml.spec.resources.requests.storage = "5Gi"; // TODO: Changeme
return volumeYaml;
}
function createServerDeploy(serverSpec) {
const { name, host } = serverSpec;
const { mclName, id, backupEnabled } = serverSpec;
const deployYaml = loadYaml("lib/k8s/configs/server-deployment.yml");
const { metadata } = deployYaml;
const serverContainer = getServerContainer(serverSpec);
@ -53,39 +74,54 @@ function createServerDeploy(serverSpec) {
const ftpContainer = getFtpContainer(serverSpec);
// Configure Metadata;
metadata.name = `mcl-${name}`;
metadata.name = `mcl-${mclName}`;
metadata.namespace = namespace;
metadata.annotations["minecluster.dunemask.net/server-name"] = name;
metadata.annotations["minecluster.dunemask.net/id"] = id;
deployYaml.metadata = metadata;
deployYaml.spec.template.spec.terminationGracePeriodSeconds = 1;
// Configure Lables & Selectors
deployYaml.spec.selector.matchLabels.app = `mcl-${name}-app`;
deployYaml.spec.template.metadata.labels.app = `mcl-${name}-app`;
deployYaml.spec.selector.matchLabels.app = `mcl-${mclName}-app`;
deployYaml.spec.template.metadata.labels.app = `mcl-${mclName}-app`;
deployYaml.spec.template.metadata.annotations["minecluster.dunemask.net/id"] =
id;
// Volumes
deployYaml.spec.template.spec.volumes.find(
({ name }) => name === "datadir",
).persistentVolumeClaim.claimName = `mcl-${name}-volume`;
).persistentVolumeClaim.claimName = `mcl-${mclName}-volume`;
// Backups
if (backupEnabled) {
deployYaml.spec.template.spec.volumes.push({
name: "rclone-config",
secret: {
defaultMode: 420,
items: [{ key: "rclone.conf", path: "rclone.conf" }],
secretName: `mcl-${mclName}-backup-secret`,
},
});
}
// Apply Containers TODO: User control for autostart
deployYaml.spec.template.spec.containers.push(serverContainer);
// deployYaml.spec.template.spec.containers.push(serverContainer);
deployYaml.spec.template.spec.containers.push(ftpContainer);
deployYaml.spec.replicas = 1;
return deployYaml;
}
function createServerService(serverSpec) {
const { name, host } = serverSpec;
const { mclName, host, id } = serverSpec;
const serviceYaml = loadYaml("lib/k8s/configs/server-svc.yml");
serviceYaml.metadata.annotations["ingress.qumine.io/hostname"] = host;
serviceYaml.metadata.annotations["mc-router.itzg.me/externalServerName"] =
host;
serviceYaml.metadata.labels.app = `mcl-${name}-app`;
serviceYaml.metadata.name = `mcl-${name}-server`;
serviceYaml.metadata.labels.app = `mcl-${mclName}-app`;
serviceYaml.metadata.name = `mcl-${mclName}-server`;
serviceYaml.metadata.namespace = namespace;
serviceYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
serviceYaml.spec.selector.app = `mcl-${name}-app`;
serviceYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
serviceYaml.spec.selector.app = `mcl-${mclName}-app`;
// Port List:
const serverPortList = [{ p: 25565, n: "minecraft" }];
@ -107,33 +143,26 @@ function createServerService(serverSpec) {
return serviceYaml;
}
function createRconService(serverSpec) {
const { name } = serverSpec;
function createRconService(createSpec) {
const { id, mclName } = createSpec;
const rconSvcYaml = loadYaml("lib/k8s/configs/rcon-svc.yml");
rconSvcYaml.metadata.labels.app = `mcl-${name}-app`;
rconSvcYaml.metadata.name = `mcl-${name}-rcon`;
rconSvcYaml.metadata.labels.app = `mcl-${mclName}-app`;
rconSvcYaml.metadata.name = `mcl-${mclName}-rcon`;
rconSvcYaml.metadata.namespace = namespace;
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/server-name"] =
name;
rconSvcYaml.spec.selector.app = `mcl-${name}-app`;
rconSvcYaml.metadata.annotations["minecluster.dunemask.net/id"] = id;
rconSvcYaml.spec.selector.app = `mcl-${mclName}-app`;
return rconSvcYaml;
}
export default async function createServerResources(serverSpec) {
const deploymentRes = await k8sDeps.listNamespacedDeployment(namespace);
const deployments = deploymentRes.body.items.map((i) => i.metadata.name);
if (deployments.includes(`mcl-${serverSpec.name}`))
throw new ExpressClientError({ m: "Server already exists!", c: 409 });
const pvcRes = await k8sCore.listNamespacedPersistentVolumeClaim(namespace);
const pvcs = pvcRes.body.items.map((i) => i.metadata.name);
if (pvcs.includes(`mcl-${serverSpec.name}-volume`))
throw new ExpressClientError({ m: "Server PVC already exists!", c: 409 });
const rconSecret = createRconSecret(serverSpec);
const serverVolume = createServerVolume(serverSpec);
const serverDeploy = createServerDeploy(serverSpec);
const serverService = createServerService(serverSpec);
const rconService = createRconService(serverSpec);
export default async function createServerResources(createSpec) {
const backupSecret = createBackupSecret(createSpec);
const rconSecret = createRconSecret(createSpec);
const serverVolume = createServerVolume(createSpec);
const serverDeploy = createServerDeploy(createSpec);
const serverService = createServerService(createSpec);
const rconService = createRconService(createSpec);
k8sCore.createNamespacedPersistentVolumeClaim(namespace, serverVolume);
if (!!backupSecret) k8sCore.createNamespacedSecret(namespace, backupSecret);
k8sCore.createNamespacedSecret(namespace, rconSecret);
k8sCore.createNamespacedService(namespace, serverService);
k8sCore.createNamespacedService(namespace, rconService);

View file

@ -22,9 +22,9 @@ function deleteOnExist(o, fn) {
}
export default async function deleteServerResources(serverSpec) {
const { name } = serverSpec;
const { id } = serverSpec;
// Ensure deployment exists
const server = await getServerAssets(name);
const server = await getServerAssets(id);
if (!server)
throw new ExpressClientError({
c: 404,
@ -47,6 +47,10 @@ export default async function deleteServerResources(serverSpec) {
const deleteRconSecret = deleteOnExist(server.rconSecret, (name) =>
k8sCore.deleteNamespacedSecret(name, namespace),
);
const deleteBackupSecret = deleteOnExist(server.backupSecret, (name) =>
k8sCore.deleteNamespacedSecret(name, namespace),
);
const deleteVolume = deleteOnExist(server.volume, (name) =>
k8sCore.deleteNamespacedPersistentVolumeClaim(name, namespace),
);
@ -55,6 +59,7 @@ export default async function deleteServerResources(serverSpec) {
deleteService,
deleteRconService,
deleteRconSecret,
deleteBackupSecret,
deleteVolume,
]).catch(deleteError);
}

View file

@ -34,8 +34,8 @@ export async function getFtpClient(serverService) {
}
export async function useServerFtp(serverSpec, fn) {
const { name } = serverSpec;
const server = await getServerAssets(name);
const { id } = serverSpec;
const server = await getServerAssets(id);
if (!server)
throw new ExpressClientError({
c: 404,

83
lib/k8s/server-status.js Normal file
View file

@ -0,0 +1,83 @@
import k8s from "@kubernetes/client-node";
import { getDeployments } from "./k8s-server-control.js";
import { getServerEntries } from "../database/queries/server-queries.js";
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const k8sMetrics = new k8s.Metrics(kc);
const namespace = process.env.MCL_SERVER_NAMESPACE;
function getServerMetrics(podMetricsRes, serverId, serverAvailable) {
const pod = podMetricsRes.items.find(({ metadata: md }) => {
return (
md.annotations &&
md.annotations["minecluster.dunemask.net/id"] === serverId
);
});
if (!serverAvailable || !pod) return null;
const podCpus = pod.containers.map(
({ usage }) => parseInt(usage.cpu) / 1_000_000,
);
const podMems = pod.containers.map(
({ usage }) => parseInt(usage.memory) / 1024,
);
return {
cpu: Math.ceil(podCpus.reduce((a, b) => a + b)),
memory: Math.ceil(podMems.reduce((a, b) => a + b)),
};
}
function getServerStatus(server) {
const { containers } = server.spec.template.spec;
const services = containers.map(({ name }) => name.split("-").pop());
const serverStatusList = server.status.conditions.map(
({ type: statusType, status: sts }) => ({ statusType, sts }),
);
const deploymentAvailable =
serverStatusList.find(
(ss) => ss.statusType === "Available" && ss.sts === "True",
) !== undefined;
const serverAvailable = services.includes(`server`) && deploymentAvailable;
const ftpAvailable = services.includes("ftp"); // TODO this needs some handling for container creation
return { serverAvailable, ftpAvailable, services };
}
export async function getInstances() {
const [serverDeployments, podMetricsRes, entries] = await Promise.all([
getDeployments(),
k8sMetrics.getPodMetrics(namespace),
getServerEntries(),
]);
var serverId, metrics;
const serverInstances = serverDeployments.map((s) => {
serverId = s.metadata.annotations["minecluster.dunemask.net/id"];
const entry = entries.find((e) => e.id === serverId);
const { ftpAvailable, serverAvailable, services } = getServerStatus(s);
metrics = getServerMetrics(podMetricsRes, serverId, serverAvailable);
return {
name: !!entry ? entry.name : "Unknown",
id: serverId,
metrics,
services,
serverAvailable,
ftpAvailable,
};
});
return serverInstances;
}
export async function getNamespaceMetrics() {
const serverInstances = await getInstances();
var clusterMetrics = { cpu: 0, memory: 0 };
if (servers.length > 1) {
const clusterCpu = serverInstances
.map(({ metrics }) => (metrics ? metrics.cpu : 0))
.reduce((a, b) => a + b);
const clusterMem = serverInstances
.map(({ metrics }) => (metrics ? metrics.memory : 0))
.reduce((a, b) => a + b);
clusterMetrics = { cpu: clusterCpu, memory: clusterMem };
}
return clusterMetrics;
}

View file

@ -18,7 +18,7 @@ async function rconSend(socket, m) {
const socketConnect = async (io, socket) => {
VERB("WS", "Websocket connecting");
socket.mcs = { serverName: socket.handshake.query.serverName };
socket.mcs = { serverId: socket.handshake.query.serverId };
try {
await webConsoleLogs(socket);
await webConsoleRcon(socket);

304
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,12 +28,15 @@
"@mui/material": "^5.14.20",
"@tanstack/react-query": "^5.12.2",
"@vitejs/plugin-react": "^4.2.1",
"chonky": "^2.3.2",
"chonky-icon-fontawesome": "^2.3.2",
"concurrently": "^8.2.2",
"nodemon": "^3.0.2",
"prettier": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1",
"react-toastify": "^9.1.3",
"socket.io-client": "^4.7.2",
"vite": "^5.0.7"
},
@ -43,8 +46,6 @@
"basic-ftp": "^5.0.4",
"bcrypt": "^5.1.1",
"chalk": "^5.3.0",
"chonky": "^2.3.2",
"chonky-icon-fontawesome": "^2.3.2",
"express": "^4.18.2",
"figlet": "^1.7.0",
"js-yaml": "^4.1.0",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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