From 02b9201b58603291d148118072c541b188c784fe Mon Sep 17 00:00:00 2001 From: Nebual Date: Fri, 30 Dec 2022 18:48:56 -0800 Subject: [PATCH 1/2] Add SpawningPool: Gateway-managed GameApis --- .gitignore | 1 + README.md | 9 + game-api/gman-nginx.conf | 7 - game-api/jsconfig.json | 0 game-api/src/cliArgs.js | 2 + game-api/src/games/ark.js | 4 +- game-api/src/games/common-helpers.js | 33 ++-- game-api/src/games/docker.js | 6 +- game-api/src/games/valheim.js | 51 +++++- game-api/src/index.js | 5 + gateway/.nvmrc | 1 + gateway/jsconfig.json | 0 gateway/package.json | 2 + gateway/src/index.js | 45 +++-- gateway/src/lib/fsPromises.js | 41 +++++ gateway/src/lib/jsonPretty.js | 3 + gateway/src/routes/spawningPool.js | 169 +++++++++++++++++ gateway/yarn.lock | 47 +++++ ui/.nvmrc | 1 + ui/.postcssrc.json | 1 + ui/gatsby-config.js | 1 + ui/jsconfig.json | 0 ui/package.json | 1 + ui/src/components/CardFlip.js | 55 ++++++ ui/src/components/CreateGameButton.js | 41 +++++ ui/src/components/EditCard.js | 250 ++++++++++++++++++++++++++ ui/src/components/EditModeToggle.js | 33 ++++ ui/src/components/LoginButton.js | 16 +- ui/src/components/ServerCard.js | 81 +++++++-- ui/src/pages/index.js | 134 +++++++++++--- ui/src/styles/global.scss | 2 + ui/yarn.lock | 29 ++- 32 files changed, 974 insertions(+), 97 deletions(-) create mode 100644 game-api/jsconfig.json create mode 100644 gateway/.nvmrc create mode 100644 gateway/jsconfig.json create mode 100755 gateway/src/lib/fsPromises.js create mode 100755 gateway/src/lib/jsonPretty.js create mode 100755 gateway/src/routes/spawningPool.js create mode 100644 ui/.nvmrc create mode 100644 ui/.postcssrc.json create mode 100644 ui/jsconfig.json create mode 100755 ui/src/components/CardFlip.js create mode 100755 ui/src/components/CreateGameButton.js create mode 100755 ui/src/components/EditCard.js create mode 100755 ui/src/components/EditModeToggle.js diff --git a/.gitignore b/.gitignore index 2541558..6fa00ac 100644 --- a/.gitignore +++ b/.gitignore @@ -79,5 +79,6 @@ package-lock.json gateway/admins.json +gateway/spawningPool.json /game-api/serviceaccount.json modpack-base/ diff --git a/README.md b/README.md index 5961365..aa025ef 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,12 @@ - auto save/backup - chat +- sort Cards by most recently running +- EditCard.js can probably derive the list of games that support SpawningPool from Gateway, who can learn it based on gameApis reporting the feature + +# Dev Guide + +## Adding support for a new Game + +1. Create a new `./game-api/src/games/` manager, extending either `GenericDockerManager` or `BaseGameManager` +2. Add to `./ui/src/components/EditCard.js`'s `gameApiOptions` diff --git a/game-api/gman-nginx.conf b/game-api/gman-nginx.conf index b3dda6e..ba4b95e 100644 --- a/game-api/gman-nginx.conf +++ b/game-api/gman-nginx.conf @@ -116,13 +116,6 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:6737; } - location /valheim/ { - rewrite ^/(?:[a-z\-2]+)/?(.*)$ /$1 break; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://127.0.0.1:6756; - } location /valheim-loki/ { rewrite ^/(?:[a-z\-\d]+)/?(.*)$ /$1 break; proxy_set_header Host $host; diff --git a/game-api/jsconfig.json b/game-api/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/game-api/src/cliArgs.js b/game-api/src/cliArgs.js index c82f1e2..50fb561 100644 --- a/game-api/src/cliArgs.js +++ b/game-api/src/cliArgs.js @@ -21,8 +21,10 @@ module.exports = { listenPort: argv.port || 6726, gatewayUrl: argv.gatewayUrl || "https://gmanman.nebtown.info/gateway/", connectUrl: argv.connectUrl, + gamePort: argv.gamePort, rconPort: argv.rconPort, steamApiKey: argv.steamApiKey, saveName: argv.saveName || gameId.replace(new RegExp(`^${game}[\-_]?`), ""), + gamePassword: argv.gamePassword, serviceAccount: argv.serviceAccount || __dirname + "/../serviceaccount.json", }; diff --git a/game-api/src/games/ark.js b/game-api/src/games/ark.js index c947647..116ecb4 100644 --- a/game-api/src/games/ark.js +++ b/game-api/src/games/ark.js @@ -9,7 +9,7 @@ const { dockerIsProcessRunning, dockerLogRead, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, steamWorkshopGetModSearch, } = require("./common-helpers"); @@ -81,7 +81,7 @@ module.exports = class ArkManager { .filter(({ enabled }) => enabled) .map(({ id }) => id) .join(","); - await writeEnvFileCsv("ARK_MODS", modsString); + await writeEnvFile({ ARK_MODS: modsString }); return true; } async getModSearch(query) { diff --git a/game-api/src/games/common-helpers.js b/game-api/src/games/common-helpers.js index 0291a84..22f100f 100644 --- a/game-api/src/games/common-helpers.js +++ b/game-api/src/games/common-helpers.js @@ -137,24 +137,31 @@ async function rconSRCDSConnect(port) { } async function readEnvFileCsv(envName) { - const env = dotenv.parse( - await fsPromises.readFile(path.join(gameDir, ".env")) - ); - if (!env[envName]) { + try { + const env = dotenv.parse( + await fsPromises.readFile(path.join(gameDir, ".env")) + ); + if (!env[envName]) { + return []; + } + return env[envName].trim().split(","); + } catch (e) { + console.warn("Unable to read .env", e); return []; } - return env[envName].trim().split(","); } -async function writeEnvFileCsv(envName, newEnvValue) { +async function writeEnvFile(changes) { const envFilePath = path.join(gameDir, ".env"); const envFileContents = (await fsPromises.readFile(envFilePath)) || ""; - const newEnvFile = - envFileContents - .toString() - .replace(new RegExp(`^${envName}=".*"\n?`, "ms"), "") - .replace(new RegExp(`^${envName}=.*$\n?`, "m"), "") - .trim() + `\n${envName}="${newEnvValue}"\n`; + let newEnvFile = envFileContents.toString(); + for (let envName in changes) { + newEnvFile = + newEnvFile + .replace(new RegExp(`^${envName}=".*?"\n?`, "ms"), "") + .replace(new RegExp(`^${envName}=.*?$\n?`, "m"), "") + .trim() + `\n${envName}="${changes[envName]}"\n`; + } await fsPromises.writeFile(envFilePath, newEnvFile); } @@ -296,7 +303,7 @@ module.exports = { rconConnect, rconSRCDSConnect, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, steamWorkshopGetModSearch, BaseGameManager, }; diff --git a/game-api/src/games/docker.js b/game-api/src/games/docker.js index eb88fb7..955e0ad 100644 --- a/game-api/src/games/docker.js +++ b/game-api/src/games/docker.js @@ -39,11 +39,15 @@ module.exports = class GenericDockerManager extends BaseGameManager { }; } + getRconPort() { + return rconPort; + } + async rcon(command) { debugLog(`Running rcon: ${command}`); try { const response = await ( - await rconSRCDSConnect(rconPort) + await rconSRCDSConnect(this.getRconPort()) ).command(command, 500); debugLog(`Rcon response: ${response}`); return true; diff --git a/game-api/src/games/valheim.js b/game-api/src/games/valheim.js index d421eb4..277d421 100644 --- a/game-api/src/games/valheim.js +++ b/game-api/src/games/valheim.js @@ -7,17 +7,24 @@ const { gameId, debugLog, connectUrl, - rconPort, gameDir, + gameName, + saveName, + gamePassword, } = require("../cliArgs"); +let { gamePort, rconPort } = require("../cliArgs"); + +if (!gamePort) gamePort = 2456; +if (!rconPort) rconPort = gamePort + 1; + const { dockerComposePull, gamedigQueryPlayers, readEnvFileCsv, - writeEnvFileCsv, + writeEnvFile, } = require("./common-helpers"); const GenericDockerManager = require("./docker"); -const { spawnProcess } = require("../libjunkdrawer/fsPromises"); +const fs = require("../libjunkdrawer/fsPromises"); const fse = require("fs-extra"); const reDownloadUrl1 = /package\/download\/([^\/]+)\/([^\/]+)\/([^\/]+)\//; @@ -28,11 +35,15 @@ module.exports = class ValheimManager extends GenericDockerManager { getConnectUrl() { return connectUrl; } + getRconPort() { + return rconPort; + } oldGetPlayersResult = false; async getPlayers() { const lookupPromise = gamedigQueryPlayers({ type: "valheim", socketTimeout: 4000, + port: rconPort, }).then((result) => { this.oldGetPlayersResult = result; return result; @@ -84,13 +95,15 @@ module.exports = class ValheimManager extends GenericDockerManager { .filter(({ enabled }) => enabled) .map(({ id }) => allModsById[id].downloadUrl) .join(",\n"); - await writeEnvFileCsv("MODS", modsString); - const modsStringDisabled = modsList .filter(({ enabled }) => !enabled) .map(({ id }) => allModsById[id].downloadUrl) .join(",\n"); - await writeEnvFileCsv("MODS_OFF", modsStringDisabled); + + await writeEnvFile({ + MODS: modsString, + MODS_OFF: modsStringDisabled, + }); return true; } @@ -156,10 +169,34 @@ module.exports = class ValheimManager extends GenericDockerManager { } async getModPackHash() { return ( - await spawnProcess("bash", [ + await fs.spawnProcess("bash", [ "-c", `cd ${gameDir} && find server/BepInEx/{config,plugins} -type f -exec md5sum {} \\; | sort -k 2 | md5sum | cut -d ' ' -f1`, ]) ).trim(); } + + async setupInstanceFiles() { + if (!(await fs.exists(`${gameDir}docker-compose.yml`))) { + await fse.copy( + path.join(__dirname, `../../../game-setups/${game}`), + gameDir, + { + overwrite: false, + } + ); + } + if (!(await fs.exists(`${gameDir}.env`))) { + await fs.writeFile(`${gameDir}.env`, ""); + } + await writeEnvFile({ + API_ID: gameId, + API_NAME: gameName, + GAMEPASSWORD: gamePassword, + SAVENAME: saveName, + GAMEPORT: gamePort, + RCONPORT: rconPort, + EXTRAPORT: gamePort + 2, + }); + } }; diff --git a/game-api/src/index.js b/game-api/src/index.js index ec65551..6a83744 100644 --- a/game-api/src/index.js +++ b/game-api/src/index.js @@ -259,6 +259,10 @@ app.post("/rcon", async (request, response) => { app.listen(listenPort); console.log(`Listening on port ${listenPort}`); +if (gameManager.setupInstanceFiles) { + gameManager.setupInstanceFiles(); +} + async function registerWithGateway() { try { return await axios.post(`${gatewayUrl}register/`, { @@ -277,6 +281,7 @@ async function registerWithGateway() { gameManager.updateOnStart && "updateOnStart", gameManager.filesToBackup && "backup", gameManager.rcon && "rcon", + gameManager.setupInstanceFiles && "spawningPool", ].filter(Boolean), }); } catch (err) { diff --git a/gateway/.nvmrc b/gateway/.nvmrc new file mode 100644 index 0000000..b6a7d89 --- /dev/null +++ b/gateway/.nvmrc @@ -0,0 +1 @@ +16 diff --git a/gateway/jsconfig.json b/gateway/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/gateway/package.json b/gateway/package.json index c26f561..3326aab 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -15,6 +15,7 @@ "discord.js": "^13.6.0", "express": "^4.17.3", "express-prettify": "^0.1.2", + "fs-extra": "^11.1.0", "jsonwebtoken": "^8.5.1", "jwks-rsa": "^2.0.5", "md5": "^2.3.0", @@ -22,6 +23,7 @@ "moment": "^2.29.3", "moment-timezone": "^0.5.34", "otplib": "^12.0.1", + "ps-node": "^0.1.6", "sodium": "^3.0.2", "ws": "^7.4.2", "ytdl-core": "^4.11.0" diff --git a/gateway/src/index.js b/gateway/src/index.js index 44819be..00324dc 100644 --- a/gateway/src/index.js +++ b/gateway/src/index.js @@ -52,6 +52,12 @@ const { } = require("./routes/messages"); app.use("/messages", messagesRouter); +const { + spawningPoolRouter, + initSpawningPool, +} = require("./routes/spawningPool"); +app.use("/spawningPool", spawningPoolRouter); + app.post("/auth", async (request, response) => { const token = request.body.id_token; try { @@ -70,9 +76,10 @@ app.post("/auth", async (request, response) => { app.get("/register", (request, response) => { response.json({ games: Object.values(knownGameApis).map( - ({ game, id, name, connectUrl, features }) => ({ + ({ game, gameId, name, connectUrl, features }) => ({ game, - id, + id: gameId, // todo: remove once new UI deployed + gameId, name, connectUrl, features, @@ -83,7 +90,7 @@ app.get("/register", (request, response) => { /** * { game: "factorio", - id: "factorio-angelbob", + gameId: "factorio-angelbob", name: "Factorio Angelbob", connectUrl: "steam://connect/gman.nebtown.info:27015", features: [ @@ -94,8 +101,9 @@ app.get("/register", (request, response) => { } */ app.post("/register", (request, response) => { - knownGameApis[request.body.id] = { ...request.body, timeoutStartTime: 0 }; - debugLog(`Registered ${request.body.id}`, request.body); + const id = request.body.gameId || request.body.id; + knownGameApis[id] = { gameId: id, ...request.body, timeoutStartTime: 0 }; + debugLog(`Registered ${id}`, request.body); response.json({}); }); @@ -107,7 +115,10 @@ app.all("/:gameId/*", async (request, response) => { } const headers = request.headers || {}; const queryParams = request.query || {}; - const bodyParams = request.body || {}; + const bodyParams = + request.body && Object.values(request.body).length + ? request.body + : undefined; let requestURL = `${gameApi.url}${endpoint}`; const extraAxiosOptions = {}; if (endpoint.startsWith("mods/pack")) { @@ -143,7 +154,7 @@ app.all("/:gameId/*", async (request, response) => { gameApi.timeoutStartTime = 0; } catch (err) { if (err.code === "ECONNREFUSED") { - debugLog("Game API", gameApi.url, err.message); + debugLog("Game API ECONNREFUSED", gameApi.url, err.message); response.status(504).json({ message: "Game API offline" }); if (!gameApi.timeoutStartTime) { gameApi.timeoutStartTime = Date.now(); @@ -168,17 +179,20 @@ app.all("/:gameId/*", async (request, response) => { if (extraAxiosOptions.responseType === "stream") { responseData = await streamToString(responseData); } - debugLog( - "Game API", - gameApi.url, - err.message, - `Status: ${status} ${statusText}`, - responseData - ); + if (status !== 304) { + debugLog( + "Game API", + requestURL, + err.message, + `Status: ${status} ${statusText}`, + responseData, + responseHeaders + ); + } response.status(status).json(responseData); } } else { - console.warn("Game API", err); + console.warn("Game API 500", err); response.status(500).json({}); } } @@ -197,4 +211,5 @@ const httpServer = http.createServer(app); httpServer.listen(listenPort); initWebsocketListener(httpServer); initPlayerStatusPoller(knownGameApis); +initSpawningPool(); console.log(`Gateway listening on port ${listenPort}`); diff --git a/gateway/src/lib/fsPromises.js b/gateway/src/lib/fsPromises.js new file mode 100755 index 0000000..d056ba8 --- /dev/null +++ b/gateway/src/lib/fsPromises.js @@ -0,0 +1,41 @@ +const fs = require("fs"); +const util = require("util"); +const child_process = require("child_process"); + +module.exports = { + readFile: util.promisify(fs.readFile), + writeFile: util.promisify(fs.writeFile), + open: util.promisify(fs.open), + mkdir: util.promisify(fs.mkdir), + access: util.promisify(fs.access), + readdir: util.promisify(fs.readdir), + stat: util.promisify(fs.stat), +}; + +module.exports.exists = async (fileName) => { + try { + await module.exports.access(fileName); + return true; + } catch { + return false; + } +}; + +module.exports.spawnProcess = (cmd, args) => + new Promise((resolve, reject) => { + const cp = child_process.spawn(cmd, args); + const error = []; + const stdout = []; + cp.stdout.on("data", (data) => { + stdout.push(data.toString()); + }); + + cp.on("error", (e) => { + error.push(e.toString()); + }); + + cp.on("close", () => { + if (error.length) reject(error.join("")); + else resolve(stdout.join("")); + }); + }); diff --git a/gateway/src/lib/jsonPretty.js b/gateway/src/lib/jsonPretty.js new file mode 100755 index 0000000..2857a2b --- /dev/null +++ b/gateway/src/lib/jsonPretty.js @@ -0,0 +1,3 @@ +module.exports = { + jsonPretty: (obj) => JSON.stringify(obj, null, "\t"), +}; diff --git a/gateway/src/routes/spawningPool.js b/gateway/src/routes/spawningPool.js new file mode 100755 index 0000000..9a22096 --- /dev/null +++ b/gateway/src/routes/spawningPool.js @@ -0,0 +1,169 @@ +const child_process = require("node:child_process"); +const path = require("path"); +const axios = require("axios"); +const ps = require("ps-node"); +const express = require("express"); +const spawningPoolRouter = express.Router(); + +const { + exists, + mkdir, + readFile, + writeFile, + open, +} = require("../lib/fsPromises"); +const { jsonPretty } = require("../lib/jsonPretty"); + +/* +interface ChildApi { + gameId: String; + game: String; // like 'minecraft' + name: String; + gamePort: Number; + rconPort: Number; // often gamePort or gamePort+1 + apiPort: Number; // coooould be fully dynamic, but having it be predictable is likely nice + gamePassword: String; +} +*/ + +async function initSpawningPool() { + const spawningPools = await readSpawningPoolConfig(); + for (let childApi of Object.values(spawningPools)) { + console.log("Checking child gameApi ", childApi.gameId); + try { + const { data } = await axios.get( + `http://localhost:${childApi.apiPort}/control` + ); + console.debug("Got response", data); + } catch (e) { + console.error( + `checkChildApi ${childApi.gameId} error: `, + e.message, + ", respawning..." + ); + await spawnChildApi(childApi); + } + } +} + +async function readSpawningPoolConfig() { + try { + const fileContents = await readFile("spawningPool.json"); + return JSON.parse(fileContents); + } catch (e) { + if (!e.message.startsWith("ENOENT: no such file or directory")) { + console.error("Failed to read childApisConfig: ", e.message); + } + return {}; + } +} + +async function writeSpawningPoolConfig(data) { + await writeFile("spawningPool.json", jsonPretty(data)); +} + +async function spawnChildApi(childApi) { + const workingDir = `/servers/${childApi.gameId}/`; + if (!(await exists(workingDir))) { + await mkdir(workingDir); + } + const logFile = await open(`${workingDir}gameApi.log`, "a"); + + const subprocess = child_process.fork( + path.resolve(`../game-api/src/index`), + [ + `--game=${childApi.game}`, + `--gameId=${childApi.gameId}`, + `--gameName=${childApi.name}`, + `--port=${childApi.apiPort}`, + `--urlRoot=http://localhost:${childApi.apiPort}/`, + `--dir=${workingDir}`, + `--serviceAccount=${path.resolve("../game-api/serviceaccount.json")}`, + `--gamePort=${childApi.gamePort || ""}`, + `--rconPort=${childApi.rconPort || ""}`, + `--gamePassword=${childApi.gamePassword || ""}`, + `--saveName=${childApi.saveName || ""}`, + ], + { + detached: true, + stdio: ["pipe", logFile, logFile, "ipc"], + cwd: workingDir, + } + ); + + subprocess.unref(); + subprocess.disconnect(); // stop receiving IPC messages +} + +async function stopChildApi(gameId) { + return await new Promise((resolve, reject) => { + ps.lookup( + { + command: "node", + arguments: `--gameId=${gameId}`, + }, + function (err, resultList) { + if (err) { + console.error("stopChildApi lookup error: ", err); + return reject(err); + } + if (resultList.length === 0) { + return resolve(); // no process found + } + ps.kill(resultList[0].pid, function (err) { + if (err) { + console.error("stopChildApi kill error: ", err); + } + resolve(); + }); + } + ); + }); +} + +function generateUnusedApiPort(configs) { + const highestUsedPort = + Math.max(...Object.values(configs).map(({ apiPort }) => apiPort || 0)) || + 42100; + return highestUsedPort + 1; +} + +// routing stuff + +const { checkAuthMiddleware } = require("../lib/login"); +spawningPoolRouter.use( + checkAuthMiddleware([ + ["GET", "/"], + ["PUT", "/"], + ]) +); + +spawningPoolRouter.get("/", async function (req, response) { + response.json({ gameApis: Object.values(await readSpawningPoolConfig()) }); +}); + +spawningPoolRouter.put("/", async function (req, response) { + console.log("SpawningPool: Received PUT /", req.body); + const updatedGameApi = req.body.gameApi; + delete updatedGameApi["isNew"]; + const allConfigs = await readSpawningPoolConfig(); + allConfigs[updatedGameApi.gameId] = { + ...(allConfigs[updatedGameApi.gameId] || {}), + ...updatedGameApi, + }; + if (!allConfigs[updatedGameApi.gameId].apiPort) { + allConfigs[updatedGameApi.gameId].apiPort = + generateUnusedApiPort(allConfigs); + } + await writeSpawningPoolConfig(allConfigs); + + response.json({ status: "ok" }); + + await stopChildApi(updatedGameApi.gameId); + void initSpawningPool(); +}); + +module.exports = { + initSpawningPool, + spawningPoolRouter, +}; diff --git a/gateway/yarn.lock b/gateway/yarn.lock index d3bf30e..a1274f7 100644 --- a/gateway/yarn.lock +++ b/gateway/yarn.lock @@ -312,6 +312,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= +connected-domain@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/connected-domain/-/connected-domain-1.0.0.tgz#bfe77238c74be453a79f0cb6058deeb4f2358e93" + integrity sha512-lHlohUiJxlpunvDag2Y0pO20bnvarMjnrdciZeuJUqRwrf/5JHNhdpiPIr5GQ8IkqrFj5TDMQwcCjblGo1oeuA== + console-control-strings@^1.0.0, console-control-strings@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" @@ -542,6 +547,15 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= +fs-extra@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.0.tgz#5784b102104433bb0e090f48bfc4a30742c357ed" + integrity sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-minipass@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" @@ -581,6 +595,11 @@ glob@^7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" @@ -647,6 +666,15 @@ jose@^2.0.5: dependencies: "@panva/asn1.js" "^1.0.0" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -991,6 +1019,13 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +ps-node@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/ps-node/-/ps-node-0.1.6.tgz#9af67a99d7b1d0132e51a503099d38a8d2ace2c3" + integrity sha512-w7QJhUTbu70hpDso0YXDRNKCPNuchV8UTUZsAv0m7Qj5g85oHOJfr9drA1EjvK4nQK/bG8P97W4L6PJ3IQLoOA== + dependencies: + table-parser "^0.1.3" + pseudomap@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" @@ -1148,6 +1183,13 @@ strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +table-parser@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/table-parser/-/table-parser-0.1.3.tgz#0441cfce16a59481684c27d1b5a67ff15a43c7b0" + integrity sha512-LCYeuvqqoPII3lzzYaXKbC3Forb+d2u4bNwhk/9FlivuGRxPE28YEWAYcujeSlLLDlMfvy29+WPybFJZFiKMYg== + dependencies: + connected-domain "^1.0.0" + tar@^6.1.11: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" @@ -1198,6 +1240,11 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" diff --git a/ui/.nvmrc b/ui/.nvmrc new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/ui/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/ui/.postcssrc.json b/ui/.postcssrc.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/ui/.postcssrc.json @@ -0,0 +1 @@ +{} diff --git a/ui/gatsby-config.js b/ui/gatsby-config.js index 8fa830e..698f70a 100644 --- a/ui/gatsby-config.js +++ b/ui/gatsby-config.js @@ -8,6 +8,7 @@ module.exports = { "https://gmanman.nebtown.info/gateway/", }, plugins: [ + `gatsby-plugin-emotion`, { resolve: `gatsby-plugin-sass`, options: { implementation: require("sass") }, diff --git a/ui/jsconfig.json b/ui/jsconfig.json new file mode 100644 index 0000000..e69de29 diff --git a/ui/package.json b/ui/package.json index 4bca14f..1bee1a1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "axios": "^1.2.1", "classnames": "^2.2.6", "gatsby": "^5.3.3", + "gatsby-plugin-emotion": "^8.3.0", "gatsby-plugin-image": "^3.3.2", "gatsby-plugin-manifest": "^5.3.1", "gatsby-plugin-sass": "^6.3.1", diff --git a/ui/src/components/CardFlip.js b/ui/src/components/CardFlip.js new file mode 100755 index 0000000..83a8b3d --- /dev/null +++ b/ui/src/components/CardFlip.js @@ -0,0 +1,55 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { css } from "@emotion/react"; + +CardFlip.propTypes = { + cardFront: PropTypes.node, + cardBack: PropTypes.node, + flipped: PropTypes.bool, +}; + +export default function CardFlip({ cardFront, cardBack, flipped }) { + const cardCommon = ` + backface-visibility: hidden; + position: absolute; + top: 0; + left: 0; + width: 100%; + `; + return ( +
+
+
+ {cardFront} +
+ +
+ {cardBack} +
+
+
+ ); +} diff --git a/ui/src/components/CreateGameButton.js b/ui/src/components/CreateGameButton.js new file mode 100755 index 0000000..856f59c --- /dev/null +++ b/ui/src/components/CreateGameButton.js @@ -0,0 +1,41 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useLocalStorage } from "@rehooks/local-storage"; +import Button from "@mui/material/Button"; +import Grid from "@mui/material/Grid"; +import AddIcon from "@mui/icons-material/Add"; + +export function tokenIsValid(loginToken) { + if (!loginToken) { + return false; + } + return loginToken.expires_at >= Date.now() / 1000; +} + +CreateGameButton.propTypes = { + gatewayUrl: PropTypes.string.isRequired, + setSpawningPoolApis: PropTypes.func, +}; + +export default function CreateGameButton({ gatewayUrl, setSpawningPoolApis }) { + const [editMode] = useLocalStorage("editMode"); + + if (!editMode) { + return null; + } + return ( + + + + ); +} diff --git a/ui/src/components/EditCard.js b/ui/src/components/EditCard.js new file mode 100755 index 0000000..76e027c --- /dev/null +++ b/ui/src/components/EditCard.js @@ -0,0 +1,250 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; + +import { useLocalStorage } from "@rehooks/local-storage"; + +import Card from "@mui/material/Card"; +import CardActions from "@mui/material/CardActions"; +import CardContent from "@mui/material/CardContent"; +import CardHeader from "@mui/material/CardHeader"; + +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Grid from "@mui/material/Grid"; + +import SaveIcon from "@mui/icons-material/Save"; +import CloseIcon from "@mui/icons-material/Close"; + +import { useAuthedAxios } from "../util/useAuthedAxios"; +import { css } from "@emotion/react"; +import { TextField } from "@mui/material"; +import { useStaticQuery, graphql } from "gatsby"; +import Autocomplete from "@mui/material/Autocomplete"; + +const gameApiOptions = [ + { + label: "Valheim", + code: "valheim", + }, +]; + +EditCard.propTypes = { + className: PropTypes.string, + game: PropTypes.string, + gameId: PropTypes.string, + title: PropTypes.string.isRequired, + editing: PropTypes.bool, + setEditing: PropTypes.func, + active: PropTypes.bool, +}; + +export default function EditCard({ + className, + game, + gameId, + title, + editing, + setEditing, + active, +}) { + const { + site: { + siteMetadata: { gatewayUrl }, + }, + } = useStaticQuery( + graphql` + query { + site { + siteMetadata { + gatewayUrl + } + } + } + ` + ); + + const [isAdmin] = useLocalStorage("isAdmin"); + const authedAxios = useAuthedAxios(); + + const [loading, setLoading] = useState(true); + const [settings, setSettings] = useState({ + isNew: true, + game, + gameId: gameId, + name: title, + gamePassword: "", + // gamePort: 0, + // rconPort: 0, + // apiPort: 0, + }); + const patchSettings = React.useCallback((patch) => { + setSettings((settings) => ({ ...settings, ...patch })); + }, []); + + React.useEffect(() => { + if (!gameId || !isAdmin || !active) { + setLoading(false); + return; + } + const fetchSpawningPoolData = async () => { + setLoading(true); + try { + const { + data: { gameApis }, + } = await authedAxios.get(`${gatewayUrl}/spawningPool`); + setSettings( + (defaultSettings) => + gameApis.find((gameApi) => gameApi.gameId === gameId) || + defaultSettings + ); + setLoading(false); + } catch (e) { + console.warn(`Failed to fetch /spawningPool ${e.message}`); + } + }; + void fetchSpawningPoolData(); + }, [gameId, isAdmin, active]); + + async function submitSave() { + await authedAxios.put(`${gatewayUrl}/spawningPool/`, { + gameApi: settings, + }); + setEditing(false); + } + + if (!isAdmin) { + return null; + } + + return ( + + setEditing(false)}> + + + } + /> + + + { + patchSettings({ + game: newValue.code, + gameId: + settings.gameId || + newValue.code + Math.ceil(Math.random() * 1000), + name: settings.name || newValue.label, + }); + }} + isOptionEqualToValue={(option, value) => { + return value === option.code; + }} + options={[ + ...gameApiOptions, + ...(!settings.game || + gameApiOptions.find(({ code }) => settings.game === code) + ? [] + : [ + { + label: settings.game, + code: settings.game, + }, + ]), + ]} + disablePortal + renderInput={(params) => } + margin="dense" + size="small" + required + disabled={!!gameId || loading} + /> + patchSettings({ gameId: event.target.value })} + margin="dense" + size="small" + required + disabled={!!gameId || loading} + /> + patchSettings({ name: event.target.value })} + margin="dense" + size="small" + required + disabled={loading} + /> + + patchSettings({ gamePassword: event.target.value }) + } + margin="dense" + size="small" + disabled={loading} + /> + + patchSettings({ gamePort: event.target.value }) + } + type="number" + margin="dense" + size="small" + disabled={loading} + /> + + patchSettings({ rconPort: event.target.value }) + } + type="number" + margin="dense" + size="small" + disabled={loading} + /> + patchSettings({ apiPort: event.target.value })} + type="number" + margin="dense" + size="small" + disabled={loading} + /> + + + + + + + ); +} diff --git a/ui/src/components/EditModeToggle.js b/ui/src/components/EditModeToggle.js new file mode 100755 index 0000000..ee1e891 --- /dev/null +++ b/ui/src/components/EditModeToggle.js @@ -0,0 +1,33 @@ +import React, { useState } from "react"; +import { useLocalStorage } from "@rehooks/local-storage"; + +import Grid from "@mui/material/Grid"; +import Switch from "@mui/material/Switch"; +import FormControlLabel from "@mui/material/FormControlLabel"; + +export default function EditModeToggle() { + const [isAdmin] = useLocalStorage("isAdmin"); + const [editMode, setEditMode] = useLocalStorage("editMode", false); + + React.useEffect(() => { + if (!isAdmin && editMode) { + setEditMode(false); + } + }, [isAdmin, editMode]); + if (!isAdmin) { + return null; + } + return ( + + setEditMode((b) => !b)} + checked={!!editMode} + /> + } + label="Edit Mode" + /> + + ); +} diff --git a/ui/src/components/LoginButton.js b/ui/src/components/LoginButton.js index 15da33e..6de07c7 100644 --- a/ui/src/components/LoginButton.js +++ b/ui/src/components/LoginButton.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import PropTypes from "prop-types"; import axios from "axios"; import { @@ -7,6 +7,7 @@ import { GoogleOAuthProvider, } from "@react-oauth/google"; import { useLocalStorage } from "@rehooks/local-storage"; +import Grid from "@mui/material/Grid"; import useAsyncEffect from "use-async-effect"; import { Button } from "@mui/material"; @@ -43,6 +44,7 @@ export default function LoginButton({ gatewayUrl }) { const [loginToken, setLoginToken, delLoginToken] = useLocalStorage("googleLogin"); const [isAdmin, setIsAdmin, delIsAdmin] = useLocalStorage("isAdmin"); + const [loggedInBefore] = useState(!!loginToken); useAsyncEffect( async (isMounted) => { if (!tokenIsValid(loginToken)) { @@ -72,17 +74,13 @@ export default function LoginButton({ gatewayUrl }) { ); return ( -
+ {!tokenIsValid(loginToken) ? ( { console.debug( "Google Login: Success", @@ -110,7 +108,7 @@ export default function LoginButton({ gatewayUrl }) { Logout )} -
+
); } diff --git a/ui/src/components/ServerCard.js b/ui/src/components/ServerCard.js index b6eea87..f475a02 100644 --- a/ui/src/components/ServerCard.js +++ b/ui/src/components/ServerCard.js @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import PropTypes from "prop-types"; import axios from "axios"; import { useLocalStorage } from "@rehooks/local-storage"; @@ -11,6 +11,7 @@ import CardHeader from "@mui/material/CardHeader"; import Button from "@mui/material/Button"; import CardMedia from "@mui/material/CardMedia"; +import IconButton from "@mui/material/IconButton"; import Grid from "@mui/material/Grid"; import Tooltip from "@mui/material/Tooltip"; @@ -26,6 +27,7 @@ import PowerIcon from "@mui/icons-material/Power"; import SubjectIcon from "@mui/icons-material/Subject"; import UpdateIcon from "@mui/icons-material/SystemUpdateAlt"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import SettingsIcon from "@mui/icons-material/Settings"; import { useInterval, useMountEffect } from "../util/hooks"; import { useAuthedAxios } from "../util/useAuthedAxios"; @@ -33,28 +35,37 @@ import LogViewer from "./LogViewer"; import ModsViewer from "./ModsViewer"; import BackupsViewer from "./BackupsViewer"; import ConfirmationModal from "./ConfirmationModal"; +import CardFlip from "./CardFlip"; +import EditCard from "./EditCard"; ServerCard.propTypes = { className: PropTypes.string, game: PropTypes.string, - id: PropTypes.string, + gameId: PropTypes.string, title: PropTypes.string.isRequired, icon: PropTypes.string, baseUrl: PropTypes.string.isRequired, features: PropTypes.arrayOf(PropTypes.string).isRequired, connectUrl: PropTypes.string, + setSpawningPoolApis: PropTypes.func, }; export default function ServerCard({ className, - game, - id, - title, - icon, - baseUrl, - features, - connectUrl, + setSpawningPoolApis, + ...props }) { + const { + game, + gameId, + title, + icon, + baseUrl, + features = [], + connectUrl, + apiOffline, + usesSpawningPool, + } = props; const supportsLogs = features.includes("logs"); const supportsUpdate = features.includes("update"); const supportsMods = features.includes("mods"); @@ -67,10 +78,13 @@ export default function ServerCard({ const rconUrl = baseUrl + "rcon/"; const [isAdmin] = useLocalStorage("isAdmin"); + const [editMode] = useLocalStorage("editMode"); const authedAxios = useAuthedAxios(); const smallScreen = useMediaQuery(`(max-width:400px)`); - const [status, setStatus] = useState("unknown"); + const [status, setStatus] = useState(() => + apiOffline ? "apiOffline" : "unknown" + ); /** status: PropTypes.oneOf([ "stopped", "starting", @@ -89,9 +103,17 @@ export default function ServerCard({ const [modsOpen, setModsOpen] = useState(false); const [stopConfirmationOpen, setStopConfirmationOpen] = useState(false); const [backupsOpen, setBackupsOpen] = useState(false); + const [editing, setEditing] = useState(!gameId); + + useEffect(() => { + if (!gameId && !editing) { + // remove the blank card once its submitted + setSpawningPoolApis((apis) => apis.filter(({ gameId }) => !!gameId)); + } + }, [gameId, editing]); const pollStatus = async () => { - if (document.hidden) { + if (document.hidden || !gameId || apiOffline) { return; } try { @@ -187,16 +209,30 @@ export default function ServerCard({ ? "Stopping" : status === "updating" ? "Updating" + : status === "apiOffline" + ? "GameApi Offline" : "Status Unknown"; const playerNamesString = players.map(({ name }) => name).join(", "); - return ( + const cardRendered = ( - + setEditing((state) => !state)} + > + + + ) : undefined + } + /> 0 && links.map(({ link, title }, i) => ( - +