Merge pull request #320 from Hornwitser/web-interface
Import new web interface
No files matched your search
@@ -2,7 +2,6 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
},
|
||||
@@ -21,6 +20,18 @@ module.exports = {
|
||||
"no-invalid-this": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": ["packages/master/web/**/*.jsx"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
"rules": {
|
||||
@@ -44,6 +55,8 @@ module.exports = {
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
@@ -229,7 +242,7 @@ module.exports = {
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "error",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "error",
|
||||
"space-before-blocks": "warn",
|
||||
|
||||
@@ -41,7 +41,6 @@ Factorio*
|
||||
package-lock.json
|
||||
mod_cache.json
|
||||
static/export
|
||||
packages/master/static/external
|
||||
saves
|
||||
/temp/
|
||||
|
||||
|
||||
@@ -27,13 +27,11 @@
|
||||
"@clusterio/slave": "file:./packages/slave",
|
||||
"eslint": "^7.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"html5-validator": "^1.2.1",
|
||||
"jsdoc": "^3.6.3",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"lerna": "^3.22.1",
|
||||
"mocha": "^6.2.0",
|
||||
"mocha.parallel": "^0.15.5",
|
||||
"nyc": "^14.1.1",
|
||||
"phin": "^3.4.1"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"use strict";
|
||||
|
||||
const events = require("events");
|
||||
const util = require("util");
|
||||
|
||||
const isDeepStrictEqual = require("../is_deep_strict_equal");
|
||||
const { basicType } = require("@clusterio/lib/helpers");
|
||||
|
||||
|
||||
@@ -415,7 +415,7 @@ class ConfigGroup {
|
||||
|
||||
let prev = this._fields.get(name);
|
||||
this._fields.set(name, value);
|
||||
if (this._config && !util.isDeepStrictEqual(value, prev)) {
|
||||
if (this._config && !isDeepStrictEqual(value, prev)) {
|
||||
this._config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
@@ -452,7 +452,7 @@ class ConfigGroup {
|
||||
|
||||
updated[prop] = value;
|
||||
this._fields.set(name, updated);
|
||||
if (this._config && !util.isDeepStrictEqual(updated, prev)) {
|
||||
if (this._config && !isDeepStrictEqual(updated, prev)) {
|
||||
this._config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
@@ -502,7 +502,7 @@ class ConfigGroup {
|
||||
|
||||
let prev = this._fields.get(name);
|
||||
this._fields.set(name, value);
|
||||
if (notify && this._config && !util.isDeepStrictEqual(value, prev)) {
|
||||
if (notify && this._config && !isDeepStrictEqual(value, prev)) {
|
||||
this._config.emit("fieldChanged", this, name, prev);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
// eslint-disable-next-line no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
module.exports = require("fast-deep-equal/es6");
|
||||
} else {
|
||||
module.exports = require("util").isDeepStrictEqual;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Connection adapters for Links
|
||||
"use strict";
|
||||
const events = require("events");
|
||||
const WebSocket = require("ws");
|
||||
const WebSocket = require("isomorphic-ws");
|
||||
|
||||
const schema = require("@clusterio/lib/schema");
|
||||
const errors = require("@clusterio/lib/errors");
|
||||
@@ -236,11 +236,17 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
// Open WebSocket to master
|
||||
console.log(`SOCKET | connecting to ${url}`);
|
||||
|
||||
this._socket = new WebSocket(url, {
|
||||
// For now we do not verify TLS certificates since the default setup is
|
||||
// to create a self-signed certificate.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
// eslint-disable-next-line no-process-env
|
||||
if (process.env.APP_ENV === "browser") {
|
||||
this._socket = new WebSocket(url);
|
||||
|
||||
} else {
|
||||
this._socket = new WebSocket(url, {
|
||||
// For now we do not verify TLS certificates since the default setup is
|
||||
// to create a self-signed certificate.
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
this._attachSocketHandlers();
|
||||
}
|
||||
@@ -277,11 +283,11 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
_attachSocketHandlers() {
|
||||
this._socket.on("close", (code, reason) => {
|
||||
console.log(`SOCKET | Close (code: ${code}, reason: ${reason})`);
|
||||
this._socket.onclose = event => {
|
||||
console.log(`SOCKET | Close (code: ${event.code}, reason: ${event.reason})`);
|
||||
// Authentication failed
|
||||
if (code === 4003) {
|
||||
this.emit("error", new errors.AuthenticationFailed(reason));
|
||||
if (event.code === 4003) {
|
||||
this.emit("error", new errors.AuthenticationFailed(event.reason));
|
||||
this._state = "closing";
|
||||
}
|
||||
|
||||
@@ -303,26 +309,20 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
}
|
||||
|
||||
this.stopHeartbeat();
|
||||
});
|
||||
};
|
||||
|
||||
this._socket.on("error", err => {
|
||||
this._socket.onerror = event => {
|
||||
// It's assumed that close is always called by ws
|
||||
console.error("SOCKET | Error:", err);
|
||||
});
|
||||
console.error("SOCKET | Error:", event);
|
||||
};
|
||||
|
||||
this._socket.on("open", () => {
|
||||
this._socket.onopen = () => {
|
||||
console.log("SOCKET | Open");
|
||||
});
|
||||
this._socket.on("ping", data => {
|
||||
console.log(`SOCKET | Ping (data: ${data}`);
|
||||
});
|
||||
this._socket.on("pong", data => {
|
||||
console.log(`SOCKET | Pong (data: ${data}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle messages
|
||||
this._socket.on("message", data => {
|
||||
let message = JSON.parse(data);
|
||||
this._socket.onmessage = event => {
|
||||
let message = JSON.parse(event.data);
|
||||
if (this._state === "handshake") {
|
||||
this._processHandshake(message);
|
||||
|
||||
@@ -341,7 +341,7 @@ class WebSocketClientConnector extends WebSocketBaseConnector {
|
||||
} else {
|
||||
throw new Error(`Received message in unexpected state ${this._state}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
_processHandshake(message) {
|
||||
|
||||
@@ -66,6 +66,7 @@ class Request extends Message {
|
||||
this.links = links;
|
||||
this.permission = permission || null;
|
||||
this.forwardTo = forwardTo;
|
||||
this.handlerSuffix = "RequestHandler";
|
||||
|
||||
this.requestType = type + "_request";
|
||||
this.responseType = type + "_response";
|
||||
@@ -681,6 +682,7 @@ class Event extends Message {
|
||||
this.links = links;
|
||||
this.forwardTo = forwardTo;
|
||||
this.broadcastTo = broadcastTo;
|
||||
this.handlerSuffix = "EventHandler";
|
||||
|
||||
if (forwardTo === "instance") {
|
||||
eventProperties = {
|
||||
@@ -956,7 +958,7 @@ messages.playerEvent = new Event({
|
||||
*/
|
||||
function attachAllMessages(link) {
|
||||
for (let [name, message] of Object.entries(messages)) {
|
||||
message.attach(link, link[name + message.constructor.name + "Handler"]);
|
||||
message.attach(link, link[name + message.handlerSuffix]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"fs-extra": "^8.1.0",
|
||||
"ini": "^1.3.5",
|
||||
"isomorphic-ws": "^4.0.1",
|
||||
"jimp": "^0.12.1",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
|
||||
@@ -433,7 +433,7 @@ function attachPluginMessages(link, pluginInfo, plugin) {
|
||||
throw new Error(`Type of ${name} message must start with "${pluginInfo.name}:"`);
|
||||
}
|
||||
|
||||
let handler = name + messageFormat.constructor.name + "Handler";
|
||||
let handler = name + messageFormat.handlerSuffix;
|
||||
if (plugin === null || !plugin[handler]) {
|
||||
messageFormat.attach(link);
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Webpack output
|
||||
static
|
||||
@@ -23,8 +23,6 @@ const path = require("path");
|
||||
const fs = require("fs-extra");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
const moment = require("moment");
|
||||
const request = require("request");
|
||||
const setBlocking = require("set-blocking");
|
||||
const events = require("events");
|
||||
const yargs = require("yargs");
|
||||
@@ -38,9 +36,11 @@ let masterConfigPath;
|
||||
let stopAcceptingNewSessions = false;
|
||||
let debugEvents = new events.EventEmitter();
|
||||
let pluginList = {};
|
||||
let devMiddleware;
|
||||
|
||||
// homebrew modules
|
||||
const generateSSLcert = require("./src/generate_ssl_cert");
|
||||
const routes = require("./src/routes");
|
||||
const database = require("@clusterio/lib/database");
|
||||
const schema = require("@clusterio/lib/schema");
|
||||
const link = require("@clusterio/lib/link");
|
||||
@@ -72,23 +72,26 @@ app.use(bodyParser.urlencoded({
|
||||
app.use(fileUpload());
|
||||
app.use(compression());
|
||||
|
||||
// dynamic HTML generations with EJS
|
||||
app.set("view engine", "ejs");
|
||||
app.set("views", ["views", "plugins"]);
|
||||
|
||||
// give ejs access to some interesting information
|
||||
app.use(function(req, res, next){
|
||||
let externalAddress = masterConfig.get("master.external_address");
|
||||
res.locals.root = externalAddress ? new URL(externalAddress).pathname : "/";
|
||||
res.locals.res = res;
|
||||
res.locals.req = req;
|
||||
res.locals.masterPlugins = masterPlugins;
|
||||
res.locals.slaves = db.slaves;
|
||||
res.locals.moment = moment;
|
||||
next();
|
||||
});
|
||||
// Servers the web interface with the root path set apropriately.
|
||||
function serveWeb(route) {
|
||||
// The depth is is the number of slashes in the route minus one, but due
|
||||
// to lenient matching on trailing slashes in the route we need to
|
||||
// compensate if the request path contains a slash but not the route,
|
||||
// and vice versa.
|
||||
let routeDepth = (route.match(/\//g) || []).length - 1 - (route.slice(-1) === "/");
|
||||
return function(req, res, next) {
|
||||
let depth = routeDepth + (req.path.slice(-1) === "/");
|
||||
let webRoot = "../".repeat(depth) || "./";
|
||||
fs.readFile(path.join(__dirname, "web", "index.html"), "utf8").then((content) => {
|
||||
res.type("text/html");
|
||||
res.send(content.replace(/__WEB_ROOT__/g, webRoot));
|
||||
}).catch(err => {
|
||||
next(err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
require("./src/routes")(app);
|
||||
// Set folder to serve static content from (the website)
|
||||
app.use(express.static(path.join(__dirname, "static")));
|
||||
app.use(express.static("static")); // Used for data export files
|
||||
@@ -390,6 +393,10 @@ async function shutdown() {
|
||||
|
||||
stopAcceptingNewSessions = true;
|
||||
|
||||
if (devMiddleware) {
|
||||
await new Promise((resolve, reject) => { devMiddleware.close(resolve); });
|
||||
}
|
||||
|
||||
let disconnectTasks = [];
|
||||
for (let controlConnection of controlConnections) {
|
||||
controlConnection.connector.setTimeout(masterConfig.get("master.connector_shutdown_timeout"));
|
||||
@@ -438,24 +445,6 @@ async function shutdown() {
|
||||
}
|
||||
}
|
||||
|
||||
// wrap a request in an promise
|
||||
async function downloadPage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request(url, (error, response, body) => {
|
||||
resolve(body);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/api/modmeta", async function(req, res) {
|
||||
endpointHitCounter.labels(req.route.path).inc();
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
let modData = await downloadPage("https://mods.factorio.com/api/mods/" + req.query.modname);
|
||||
res.send(modData);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Base class for master server connections
|
||||
*
|
||||
@@ -1590,7 +1579,9 @@ async function startServer() {
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run");
|
||||
})
|
||||
.command("run", "Run master server")
|
||||
.command("run", "Run master server", yargs => {
|
||||
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run")
|
||||
.strict()
|
||||
.argv
|
||||
@@ -1671,6 +1662,18 @@ async function startServer() {
|
||||
|
||||
// If we get here the command was run
|
||||
|
||||
// Start webpack development server if enabled
|
||||
if (args.dev) {
|
||||
console.log("Webpack development mode enabled");
|
||||
const webpack = require("webpack");
|
||||
const webpackDevMiddleware = require("webpack-dev-middleware");
|
||||
const webpackConfig = require("./webpack.config");
|
||||
|
||||
const compiler = webpack(webpackConfig({}));
|
||||
devMiddleware = webpackDevMiddleware(compiler, {});
|
||||
app.use(devMiddleware);
|
||||
}
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
@@ -1716,6 +1719,16 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
// Add routes for the web interface
|
||||
for (let route of routes) {
|
||||
app.get(route, serveWeb(route));
|
||||
}
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
for (let route of pluginInfo.routes || []) {
|
||||
app.get(route, serveWeb(route));
|
||||
}
|
||||
}
|
||||
|
||||
// Load plugins
|
||||
await pluginManagement(pluginInfos);
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"use strict";
|
||||
var http = require("http");
|
||||
var https = require("https");
|
||||
var fs = require("fs-extra");
|
||||
|
||||
|
||||
function makeDirectory(path){
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirp(path, function(err){
|
||||
if(err){
|
||||
console.error("Unable to mkdirp "+path);
|
||||
console.error(err);
|
||||
resolve(false);
|
||||
} else {
|
||||
console.log("mkdirp "+path);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
function download(url, file, callback){
|
||||
return new Promise((resolve, reject) => {
|
||||
let pathArray = file.split("/");
|
||||
pathArray.pop(); // remove filename from the end
|
||||
if(pathArray.length >= 1){
|
||||
// this might be an invalid path!
|
||||
fs.stat(pathArray.join("/"), (err, stat) => {
|
||||
if(err){
|
||||
makeDirectory(pathArray.join("/")).then(() => {
|
||||
doDownload(url, file, callback);
|
||||
});
|
||||
} else {
|
||||
doDownload(url, file, callback);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
doDownload(url, file, callback);
|
||||
}
|
||||
function doDownload(url, file, callback){
|
||||
var writeStream = fs.createWriteStream(file);
|
||||
let protocol = http;
|
||||
if(url.split(":")[0] == "https") protocol = https;
|
||||
var request = protocol.get(url, function(response) {
|
||||
response.pipe(writeStream);
|
||||
response.on("end", () => {
|
||||
if(callback && typeof callback == "function") callback(file);
|
||||
console.log("Downloaded "+file+" from "+url);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var directories = [
|
||||
"sharedMods",
|
||||
"instances",
|
||||
];
|
||||
let downloadList = [
|
||||
{
|
||||
url: "https://code.jquery.com/jquery.js",
|
||||
file: "static/external/jquery.js",
|
||||
},
|
||||
{
|
||||
url: "https://code.jquery.com/ui/1.12.1/jquery-ui.js",
|
||||
file: "static/external/jquery-ui.js",
|
||||
},
|
||||
{
|
||||
url: "https://momentjs.com/downloads/moment.min.js",
|
||||
file: "static/external/moment.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://craig.global.ssl.fastly.net/js/mousetrap/mousetrap.min.js",
|
||||
file: "static/external/mousetrap.min.js",
|
||||
},
|
||||
{
|
||||
url: "https://fonts.googleapis.com/css?family=Roboto",
|
||||
file: "static/external/css/Roboto.css",
|
||||
},
|
||||
];
|
||||
|
||||
// initiate folder creations and downloads
|
||||
function processMkdirRecursive(callback){
|
||||
let item = directories.shift();
|
||||
makeDirectory(item).then(() => {
|
||||
if(directories.length == 0){
|
||||
if(callback) callback();
|
||||
} else {
|
||||
processMkdirRecursive(callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
function processDownloadRecursive(callback){
|
||||
let item = downloadList.shift();
|
||||
download(item.url, item.file, item.callback).then(() => {
|
||||
if(downloadList.length == 0){
|
||||
if(callback) callback();
|
||||
} else {
|
||||
processDownloadRecursive(callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
processMkdirRecursive(processDownloadRecursive);
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
"version": "2.0.0-alpha0",
|
||||
"main": "master.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"install": "node npm_post_install"
|
||||
"build": "webpack --env.production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriomaster": "./master.js"
|
||||
@@ -23,18 +23,33 @@
|
||||
"body-parser": "^1.18.2",
|
||||
"compression": "^1.7.2",
|
||||
"cookie-parser": "^1.4.3",
|
||||
"ejs": "^2.5.7",
|
||||
"express": "^4.16.2",
|
||||
"express-fileupload": "1.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"jsonwebtoken": "^8.2.1",
|
||||
"jszip": "^3.2.2",
|
||||
"moment": "^2.22.2",
|
||||
"node-factorio-api": "^0.3.8",
|
||||
"node-forge": "^0.10.0",
|
||||
"request": "^2.83.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"ws": "^7.2.3",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ant-design/icons": "^4.2.2",
|
||||
"@babel/core": "^7.11.6",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"antd": "^4.6.5",
|
||||
"babel-loader": "^8.1.0",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"css-loader": "^4.3.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"null-loader": "^4.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"style-loader": "^1.2.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-dev-middleware": "^3.7.2"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
"use strict";
|
||||
module.exports = function(app) {
|
||||
app.get("/", function(req, res) {
|
||||
res.render("index");
|
||||
});
|
||||
app.get("/nodes", function(req, res) {
|
||||
res.render("nodes");
|
||||
});
|
||||
app.get("/settings", function(req, res) {
|
||||
res.render("settings");
|
||||
});
|
||||
app.get("/nodeDetails", function(req, res) {
|
||||
res.render("nodeDetails");
|
||||
});
|
||||
};
|
||||
// Routes used in the web interface and served by the master server
|
||||
module.exports = [
|
||||
"/",
|
||||
"/instances",
|
||||
"/instances/:id/view",
|
||||
"/slaves",
|
||||
"/slaves/:id/view",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/permissions",
|
||||
"/permissions/:id/view",
|
||||
"/settings",
|
||||
];
|
||||
@@ -1,12 +0,0 @@
|
||||
/*!
|
||||
* Bootswatch v4.1.3
|
||||
* Homepage: https://bootswatch.com
|
||||
* Copyright 2012-2018 Thomas Park
|
||||
* Licensed under MIT
|
||||
* Based on Bootstrap
|
||||
*//*!
|
||||
* Bootstrap v4.1.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2018 The Bootstrap Authors
|
||||
* Copyright 2011-2018 Twitter, Inc.
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
|
||||
*/:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#ee5f5b;--orange:#fd7e14;--yellow:#f89406;--green:#62c462;--teal:#20c997;--cyan:#5bc0de;--white:#fff;--gray:#7A8288;--gray-dark:#3A3F44;--primary:#3A3F44;--secondary:#7A8288;--success:#62c462;--info:#5bc0de;--warning:#f89406;--danger:#ee5f5b;--light:#e9ecef;--dark:#272B30;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-family-monospace:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*,*::before,*::after{-webkit-box-sizing:border-box;box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:0.9375rem;font-weight:400;line-height:1.5;color:#aaa;text-align:left;background-color:#272B30}[tabindex="-1"]:focus{outline:0 !important}hr{-webkit-box-sizing:content-box;box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:0.5rem}p{margin-top:0;margin-bottom:1rem}abbr[title],abbr[data-original-title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul,dl{margin-top:0;margin-bottom:1rem}ol ol,ul ul,ol ul,ul ol{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#fff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#d9d9d9;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):hover,a:not([href]):not([tabindex]):focus{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}pre,code,kbd,samp{font-family:SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:0.75rem;padding-bottom:0.75rem;color:#7A8288;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:0.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}input,button,select,optgroup,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{padding:0;border-style:none}input[type="radio"],input[type="checkbox"]{-webkit-box-sizing:border-box;box-sizing:border-box;padding:0}input[type="date"],input[type="time"],input[type="datetime-local"],input[type="month"]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{outline-offset:-2px;-webkit-appearance:none}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none !important}h1,h2,h3,h4,h5,h6,.h1,.h2,.h3,.h4,.h5,.h6{margin-bottom:0.5rem;font-family:inherit;font-weight:500;line-height:1.2;color:inherit}h1,.h1{font-size:2.34375rem}h2,.h2{font-size:1.875rem}h3,.h3{font-size:1.640625rem}h4,.h4{font-size:1.40625rem}h5,.h5{font-size:1.171875rem}h6,.h6{font-size:0.9375rem}.lead{font-size:1.171875rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-heigLine truncated
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/ms-icon-70x70.png"/>
|
||||
<square150x150logo src="/ms-icon-150x150.png"/>
|
||||
<square310x310logo src="/ms-icon-310x310.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB |
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,185 +0,0 @@
|
||||
/*! terminal.js v2.0 | (c) 2014 Erik Österberg | https://github.com/eosterberg/terminaljs */
|
||||
|
||||
var Terminal = (function () {
|
||||
// PROMPT_TYPE
|
||||
var PROMPT_INPUT = 1, PROMPT_PASSWORD = 2, PROMPT_CONFIRM = 3
|
||||
|
||||
var fireCursorInterval = function (inputField, terminalObj) {
|
||||
var cursor = terminalObj._cursor
|
||||
setTimeout(function () {
|
||||
if (inputField.parentElement && terminalObj._shouldBlinkCursor) {
|
||||
cursor.style.visibility = cursor.style.visibility === 'visible' ? 'hidden' : 'visible'
|
||||
fireCursorInterval(inputField, terminalObj)
|
||||
} else {
|
||||
cursor.style.visibility = 'visible'
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
var firstPrompt = true;
|
||||
promptInput = function (terminalObj, message, PROMPT_TYPE, callback) {
|
||||
var shouldDisplayInput = (PROMPT_TYPE === PROMPT_INPUT)
|
||||
var inputField = document.createElement('input')
|
||||
|
||||
inputField.style.position = 'absolute'
|
||||
inputField.style.zIndex = '-100'
|
||||
inputField.style.outline = 'none'
|
||||
inputField.style.border = 'none'
|
||||
inputField.style.opacity = '0'
|
||||
inputField.style.fontSize = '0.2em'
|
||||
|
||||
terminalObj._inputLine.textContent = ''
|
||||
terminalObj._input.style.display = 'block'
|
||||
terminalObj.html.appendChild(inputField)
|
||||
fireCursorInterval(inputField, terminalObj)
|
||||
|
||||
if (message.length) terminalObj.print(PROMPT_TYPE === PROMPT_CONFIRM ? message + ' (y/n)' : message)
|
||||
|
||||
inputField.onblur = function () {
|
||||
terminalObj._cursor.style.display = 'none'
|
||||
}
|
||||
|
||||
inputField.onfocus = function () {
|
||||
inputField.value = terminalObj._inputLine.textContent
|
||||
terminalObj._cursor.style.display = 'inline'
|
||||
}
|
||||
|
||||
terminalObj.html.onclick = function () {
|
||||
inputField.focus()
|
||||
}
|
||||
|
||||
inputField.onkeydown = function (e) {
|
||||
if (e.which === 37 || e.which === 39 || e.which === 38 || e.which === 40 || e.which === 9) {
|
||||
e.preventDefault()
|
||||
} else if (shouldDisplayInput && e.which !== 13) {
|
||||
setTimeout(function () {
|
||||
terminalObj._inputLine.textContent = inputField.value
|
||||
}, 1)
|
||||
}
|
||||
}
|
||||
inputField.onkeyup = function (e) {
|
||||
if (PROMPT_TYPE === PROMPT_CONFIRM || e.which === 13) {
|
||||
terminalObj._input.style.display = 'none'
|
||||
var inputValue = inputField.value
|
||||
if (shouldDisplayInput) terminalObj.print(inputValue)
|
||||
terminalObj.html.removeChild(inputField)
|
||||
if (typeof(callback) === 'function') {
|
||||
if (PROMPT_TYPE === PROMPT_CONFIRM) {
|
||||
callback(inputValue.toUpperCase()[0] === 'Y' ? true : false)
|
||||
} else callback(inputValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (firstPrompt) {
|
||||
firstPrompt = false
|
||||
setTimeout(function () { inputField.focus() }, 50)
|
||||
} else {
|
||||
inputField.focus()
|
||||
}
|
||||
}
|
||||
|
||||
var terminalBeep
|
||||
|
||||
var TerminalConstructor = function (id) {
|
||||
if (!terminalBeep) {
|
||||
terminalBeep = document.createElement('audio')
|
||||
var source = '<source src="http://www.erikosterberg.com/terminaljs/beep.'
|
||||
terminalBeep.innerHTML = source + 'mp3" type="audio/mpeg">' + source + 'ogg" type="audio/ogg">'
|
||||
terminalBeep.volume = 0.05
|
||||
}
|
||||
|
||||
this.html = document.createElement('div')
|
||||
this.html.className = 'Terminal'
|
||||
if (typeof(id) === 'string') { this.html.id = id }
|
||||
|
||||
this._innerWindow = document.createElement('div')
|
||||
this._output = document.createElement('p')
|
||||
this._inputLine = document.createElement('span') //the span element where the users input is put
|
||||
this._cursor = document.createElement('span')
|
||||
this._input = document.createElement('p') //the full element administering the user input, including cursor
|
||||
|
||||
this._shouldBlinkCursor = true
|
||||
|
||||
this.beep = function () {
|
||||
terminalBeep.load()
|
||||
terminalBeep.play()
|
||||
}
|
||||
|
||||
this.print = function (message) {
|
||||
var newLine = document.createElement('div')
|
||||
newLine.textContent = message
|
||||
this._output.appendChild(newLine)
|
||||
}
|
||||
|
||||
this.input = function (message, callback) {
|
||||
promptInput(this, message, PROMPT_INPUT, callback)
|
||||
}
|
||||
|
||||
this.password = function (message, callback) {
|
||||
promptInput(this, message, PROMPT_PASSWORD, callback)
|
||||
}
|
||||
|
||||
this.confirm = function (message, callback) {
|
||||
promptInput(this, message, PROMPT_CONFIRM, callback)
|
||||
}
|
||||
|
||||
this.clear = function () {
|
||||
this._output.innerHTML = ''
|
||||
}
|
||||
|
||||
this.sleep = function (milliseconds, callback) {
|
||||
setTimeout(callback, milliseconds)
|
||||
}
|
||||
|
||||
this.setTextSize = function (size) {
|
||||
this._output.style.fontSize = size
|
||||
this._input.style.fontSize = size
|
||||
}
|
||||
|
||||
this.setTextColor = function (col) {
|
||||
this.html.style.color = col
|
||||
this._cursor.style.background = col
|
||||
}
|
||||
|
||||
this.setBackgroundColor = function (col) {
|
||||
this.html.style.background = col
|
||||
}
|
||||
|
||||
this.setWidth = function (width) {
|
||||
this.html.style.width = width
|
||||
}
|
||||
|
||||
this.setHeight = function (height) {
|
||||
this.html.style.height = height
|
||||
}
|
||||
|
||||
this.blinkingCursor = function (bool) {
|
||||
bool = bool.toString().toUpperCase()
|
||||
this._shouldBlinkCursor = (bool === 'TRUE' || bool === '1' || bool === 'YES')
|
||||
}
|
||||
|
||||
this._input.appendChild(this._inputLine)
|
||||
this._input.appendChild(this._cursor)
|
||||
this._innerWindow.appendChild(this._output)
|
||||
this._innerWindow.appendChild(this._input)
|
||||
this.html.appendChild(this._innerWindow)
|
||||
|
||||
this.setBackgroundColor('black')
|
||||
this.setTextColor('white')
|
||||
this.setTextSize('1em')
|
||||
this.setWidth('100%')
|
||||
this.setHeight('100%')
|
||||
|
||||
this.html.style.fontFamily = 'Monaco, Courier'
|
||||
this.html.style.margin = '0'
|
||||
this._innerWindow.style.padding = '10px'
|
||||
this._input.style.margin = '0'
|
||||
this._output.style.margin = '0'
|
||||
this._cursor.style.background = 'white'
|
||||
this._cursor.innerHTML = 'C' //put something in the cursor..
|
||||
this._cursor.style.display = 'none' //then hide it
|
||||
this._input.style.display = 'none'
|
||||
}
|
||||
|
||||
return TerminalConstructor
|
||||
}())
|
||||
@@ -1,9 +0,0 @@
|
||||
export function getParameterByName(name, url) {
|
||||
if(!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, "\\$&");
|
||||
var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
|
||||
results = regex.exec(url);
|
||||
if(!results) return null;
|
||||
if(!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, " "));
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
|
||||
(function (){
|
||||
ensureSetting("Periodically update storage screen", "boolean", true);
|
||||
}())
|
||||
|
||||
function ensureSetting(setting, type, defaultSetting) {
|
||||
let settings
|
||||
try {
|
||||
settings = JSON.parse(localStorage.settings);
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
if(!settings) settings = {};
|
||||
if(typeof settings[setting] != type) {
|
||||
settings[setting] = defaultSetting;
|
||||
console.log("Fixed setting: " + setting + " | " + defaultSetting)
|
||||
}
|
||||
localStorage.settings = JSON.stringify(settings)
|
||||
}
|
||||
|
||||
// debug tools
|
||||
|
||||
debugTools = {
|
||||
addItem: function(name, count){
|
||||
console.log(`Attempting to add ${count} ${name}`)
|
||||
postJSON("api/place", {
|
||||
instanceName: "unknown",
|
||||
instanceID: "unknown",
|
||||
unique: "unknown",
|
||||
name: name,
|
||||
count: count,
|
||||
}, function(data){
|
||||
console.log(data);
|
||||
});
|
||||
},
|
||||
removeItem: function(name, count){
|
||||
console.log(`Attempting to remove ${count} ${name}`)
|
||||
postJSON("api/remove", {
|
||||
instanceName: "unknown",
|
||||
instanceID: "unknown",
|
||||
unique: "unknown",
|
||||
name: name,
|
||||
count: count,
|
||||
}, function(data){
|
||||
console.log(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cachedInstanceNames = {};
|
||||
async function getInstanceName(instanceID){
|
||||
let instance = cachedInstanceNames[instanceID];
|
||||
if(!instance){
|
||||
let slaves = await getJSON("/api/slaves");
|
||||
try {
|
||||
for(id in slaves){
|
||||
cachedInstanceNames[id] = slaves[id].instanceName;
|
||||
}
|
||||
} catch(e){
|
||||
console.log("Error while looking for instance name!");
|
||||
console.log(e);
|
||||
}
|
||||
return cachedInstanceNames[instanceID] || instanceID;
|
||||
} else {
|
||||
return cachedInstanceNames[instanceID];
|
||||
}
|
||||
}
|
||||
var g = {}
|
||||
contents = {
|
||||
"iron-plate":100,
|
||||
"copper-plate":7312,
|
||||
}
|
||||
// nice functions
|
||||
// hash a string to a hash
|
||||
function djb2(str){
|
||||
var hash = 5381;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) + str.charCodeAt(i); /* hash * 33 + c */
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
// get queryString parameters
|
||||
function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, "\\$&");
|
||||
var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
|
||||
results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, " "));
|
||||
}
|
||||
|
||||
// callback(err, json)
|
||||
function getJSON(url, callback) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.responseType = 'json';
|
||||
xhr.onload = function() {
|
||||
var status = xhr.status;
|
||||
if (status == 200) {
|
||||
if(callback) callback(null, xhr.response);
|
||||
resolve(xhr.response);
|
||||
} else {
|
||||
if(callback) callback(status);
|
||||
reject(status);
|
||||
}
|
||||
};
|
||||
// triggers if connection is refused
|
||||
xhr.onerror = function(e){
|
||||
if(callback) callback(e);
|
||||
reject(e);
|
||||
};
|
||||
xhr.send();
|
||||
});
|
||||
};
|
||||
// callback(err, json)
|
||||
function postJSON(url, data, callback) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
xhr.responseType = 'json';
|
||||
xhr.onload = function() {
|
||||
var status = xhr.status;
|
||||
if (status == 200) {
|
||||
if(callback) callback(null, xhr.response);
|
||||
resolve(xhr.response);
|
||||
} else {
|
||||
if(callback) callback(status);
|
||||
reject(status);
|
||||
}
|
||||
};
|
||||
// triggers if connection is refused
|
||||
xhr.onerror = function(e){
|
||||
if(callback) callback(e);
|
||||
reject(e);
|
||||
};
|
||||
xhr.send(JSON.stringify(data));
|
||||
});
|
||||
};
|
||||
// return Boolean
|
||||
function isJSON(string){
|
||||
let stringIsJson = false;
|
||||
let x
|
||||
try {
|
||||
x = JSON.parse(string);
|
||||
} catch (e){
|
||||
return false;
|
||||
}
|
||||
if(typeof x == "object" || typeof string == "object"){
|
||||
stringIsJson = true;
|
||||
}
|
||||
return stringIsJson;
|
||||
}
|
||||
// hash a string to a color
|
||||
function hashColor(str) {
|
||||
var hash = djb2(str);
|
||||
var r = (hash & 0xFF0000) >> 16;
|
||||
var g = (hash & 0x00FF00) >> 8;
|
||||
var b = hash & 0x0000FF;
|
||||
return "#" + ("0" + r.toString(16)).substr(-2) + ("0" + g.toString(16)).substr(-2) + ("0" + b.toString(16)).substr(-2);
|
||||
}
|
||||
// exactly what you would expect it to, returns String
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
// hide the HTML object passed as argument #1
|
||||
function hideThis(object) {
|
||||
object.style.visibility = "hidden";
|
||||
}
|
||||
|
||||
Date.prototype.yyyymmdd = function(time) { // http://stackoverflow.com/questions/3066586/get-string-in-yyyymmdd-format-from-js-date-object
|
||||
var mm = this.getMonth() + 1; // getMonth() is zero-based
|
||||
var dd = this.getDate();
|
||||
if(mm<10)mm = "0"+mm
|
||||
if(dd<10)dd = "0"+dd
|
||||
console.log(this.getFullYear())
|
||||
return this.getFullYear()+""+mm+""+dd+ ""; // padding
|
||||
};
|
||||
var date = new Date();
|
||||
function replaceAll(target, search, replacement) {
|
||||
return target.split(search).join(replacement);
|
||||
};
|
||||
|
||||
function getItemIconClass(name) {
|
||||
if (factorioItemMetadata.has(name)) {
|
||||
return `item-${name}`;
|
||||
} else {
|
||||
return "item-unknown-item";
|
||||
}
|
||||
}
|
||||
window.factorioLocale = new Map();
|
||||
$.getJSON(`${root}export/locale.json`, (locale) => {
|
||||
window.factorioLocale = new Map(locale);
|
||||
});
|
||||
window.factorioItemMetadata = new Map();
|
||||
$.getJSON(`${root}export/item-metadata.json`, (itemMetadata) => {
|
||||
window.factorioItemMetadata = new Map(itemMetadata);
|
||||
console.log(factorioItemMetadata.get("electronic-circuit"));
|
||||
let style = document.createElement("style");
|
||||
style.type = "text/css";
|
||||
document.head.appendChild(style);
|
||||
|
||||
for (let [name, meta] of itemMetadata) {
|
||||
style.sheet.insertRule([
|
||||
`.item-${name} {`,
|
||||
` background-image: url("${root}export/item-spritesheet.png");`,
|
||||
" background-repeat: no-repeat;",
|
||||
` background-position: -${meta.x}px -${meta.y}px;`,
|
||||
` height: ${meta.size}px;`,
|
||||
` width: ${meta.size}px;`,
|
||||
"}\n",
|
||||
].join(""));
|
||||
}
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#node-stats .breadcrumb-item {
|
||||
height: auto;
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
function getParameterByName(name, url) {
|
||||
if (!url) url = window.location.href;
|
||||
name = name.replace(/[\[\]]/g, "\\$&");
|
||||
var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"),
|
||||
results = regex.exec(url);
|
||||
if (!results) return null;
|
||||
if (!results[2]) return '';
|
||||
return decodeURIComponent(results[2].replace(/\+/g, " "));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/// code below here
|
||||
// declaring globals
|
||||
var chartsByID = {};
|
||||
// update the online indicator dot
|
||||
setInterval(function(){
|
||||
getJSON("/api/slaves", function(err, slaveData){
|
||||
let indicator = document.querySelector("#online-indicator");
|
||||
$(indicator).removeClass('fa-signal');
|
||||
$(indicator).removeClass('fa-dizzy');
|
||||
$(indicator).removeClass('fa-plug');
|
||||
if(err){
|
||||
// our request threw an error, most likely master is unavailable so we show yellow indicator
|
||||
indicator.style.color = "yellow";
|
||||
$(indicator).addClass('fa-plug');
|
||||
indicator.title = "Master server unavailable";
|
||||
// compare with Math.floor(x/100000) allows us to check if they are within 10s of each other
|
||||
} else if(Math.floor(slaveData[getParameterByName("instanceID")].time/100000) === Math.floor(Date.now()/100000)){
|
||||
indicator.style.color = "green";
|
||||
$(indicator).addClass('fa-signal');
|
||||
indicator.title = "Slave is online";
|
||||
} else {
|
||||
indicator.style.color = "red";
|
||||
$(indicator).addClass('fa-dizzy');
|
||||
indicator.title = "Slave is offline";
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
function regexModDetails(modFileName) {
|
||||
let m;
|
||||
let results = [];
|
||||
const regex = /(.+)_(\d+\.\d+\.\d+)\.zip/gm;
|
||||
while ((m = regex.exec(modFileName)) !== null) {
|
||||
// This is necessary to avoid infinite loops with zero-width matches
|
||||
if (m.index === regex.lastIndex) {
|
||||
regex.lastIndex++;
|
||||
}
|
||||
|
||||
// The result can be accessed through the `m`-variable.
|
||||
m.forEach((match, _) => {
|
||||
results.push(match);
|
||||
});
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
populateSlaveInfo();
|
||||
function populateSlaveInfo(){
|
||||
let instanceID = getParameterByName("instanceID");
|
||||
if(!instanceID){
|
||||
throw "We need instanceID! Also, *funny joke* but it doesn't work after I standardized on calling it instanceID";
|
||||
}
|
||||
getJSON("/api/slaves", function(err, slaveData){
|
||||
/*
|
||||
{"-1530507550":{
|
||||
"time":"1495475926375",
|
||||
"rconPort":"8714",
|
||||
"rconPassword":"hidden",
|
||||
"serverPort":"14345",
|
||||
"unique":"-1530507550",
|
||||
"publicIP":"localhost",
|
||||
"mods":[
|
||||
{
|
||||
"modName":"clusterio_1.0.0.zip",
|
||||
"hash":"341feb6c60918c83f7f3a6a14a4a308aef18dda6"
|
||||
},{
|
||||
"modName":"clusterio_1.0.1.zip",
|
||||
"hash":"aee7a4b67ece19bddf236312fe408a5d4b67248d"
|
||||
},{
|
||||
"modName":"clusterio_1.1.0.zip",
|
||||
"hash":"18e2f7e402d716e3dd1c1458b7fab2d0bb29eccf"
|
||||
}],
|
||||
"playerCount":"0",
|
||||
"instanceName":"t",
|
||||
"mac":"44-85-00-D2-7E-D6"}
|
||||
}
|
||||
*/
|
||||
let slave = slaveData[instanceID];
|
||||
let HTML = '<div class="jumbotron mt-5"><div id="header">' +
|
||||
'<h1 class="display-4"><i id="online-indicator" class="mr-3 fas"></i> ' + slave.instanceName+"</h1>";
|
||||
HTML += '<div class="stats" id="node-stats">' +
|
||||
'<nav class="mt-3" aria-label="breadcrumb">\n' +
|
||||
' <ol class="breadcrumb align-content-center"><li class="breadcrumb-item">Host: '+slave.mac+'</li>' +
|
||||
'<li class="breadcrumb-item">Unique: '+slave.unique+'</li>';
|
||||
if(slave.publicIP !== "localhost"){
|
||||
HTML += '<li class="breadcrumb-item">IP: '+slave.publicIP+':'+slave.serverPort+'</li>';
|
||||
} else {
|
||||
HTML += '<li class="breadcrumb-item">This server is not configured for incoming connections</li>';
|
||||
}
|
||||
if(slave.meta)
|
||||
for(let key in slave.meta){
|
||||
let t = slave.meta[key];
|
||||
if(typeof t == "string"){
|
||||
HTML += "<li class=\"breadcrumb-item\">"+key+": "+t+"</li>";
|
||||
}
|
||||
}
|
||||
HTML += '<li class="breadcrumb-item">Last seen: <span id="lastSeenDate">'+moment(Number(slave.time)).fromNow()+"</span></li>";
|
||||
HTML += '<li class="breadcrumb-item">Online players: '+slave.playerCount+"</li></ol></nav>";
|
||||
|
||||
HTML += "</div>"; // end of displayBody
|
||||
|
||||
// list mods and other metadata
|
||||
HTML += '<h2 class="subtitle d-inline">Mods</h2><a id="fetch-mod-data" class="btn btn-primary float-right m-2">Fetch Mod Data</a>' +
|
||||
'<table id="modlist" class="table table-striped table-hover">' +
|
||||
' <thead>\n' +
|
||||
' <tr>\n' +
|
||||
' <th scope="col">#</th>\n' +
|
||||
' <th scope="col">Version</th>\n' +
|
||||
' <th scope="col">Name</th>\n' +
|
||||
' <th scope="col">Summary</th>\n' +
|
||||
' </tr>\n' +
|
||||
' </thead>' +
|
||||
' <tbody>';
|
||||
for(let i = 0; i < slave.mods[0].modName.length; i++){
|
||||
let modRegexResults = regexModDetails(slave.mods[0].modName[i]);
|
||||
HTML += `<tr>
|
||||
<th scope="row">${i + 1}</th>
|
||||
<td>${modRegexResults[2]}</td>
|
||||
<td>${modRegexResults[1]}</td>
|
||||
<td></td>
|
||||
</tr>`
|
||||
}
|
||||
HTML += "</ul>";
|
||||
|
||||
HTML += "</div>"; // end of left container
|
||||
|
||||
// terminal
|
||||
// HTML += '<div id="terminal"></div>';
|
||||
|
||||
|
||||
|
||||
document.querySelector("#body > #details").innerHTML = HTML;
|
||||
|
||||
// makeTerminal();
|
||||
|
||||
$('#fetch-mod-data').on("click", downloadSaveModData);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function downloadSaveModData() {
|
||||
let modRows = $("#modlist tbody tr");
|
||||
for (let i = 0; i < modRows.length; i++) {
|
||||
let modName = modRows[i].children[2].innerText;
|
||||
$.getJSON( "/api/modmeta?modname=" + modName, function( data ) {
|
||||
modRows[i].children[2].innerHTML = data.title;
|
||||
modRows[i].children[3].innerHTML = data.summary
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var slaveLogin = {};
|
||||
function makeTerminal(){
|
||||
myTerminal = new Terminal();
|
||||
document.querySelector("#terminal").appendChild(myTerminal.html);
|
||||
// use jQuery to allow the user to drag the window around
|
||||
$("#terminal").draggable({
|
||||
handle:".titleBar",
|
||||
});
|
||||
myTerminal.print('Welcome to Clusterio rcon!');
|
||||
myTerminal.input('', handleTerminalInput);
|
||||
if(localStorage.terminalMinimized && localStorage.terminalMinimized === "true"){
|
||||
minimizeTerminal(true);
|
||||
}
|
||||
function print(string){
|
||||
myTerminal.print(string);
|
||||
}
|
||||
|
||||
function handleTerminalInput(inputString) {
|
||||
// show clearly that it was a command that was typed, not terminal output
|
||||
let lastLine = document.querySelector("#terminal div > p:nth-child(1) > div:last-child");
|
||||
lastLine.innerHTML = "> " + lastLine.innerHTML;
|
||||
|
||||
argv = inputString.split(' ');
|
||||
if(argv[0][0] === '/'){
|
||||
if(slaveLogin && slaveLogin.name && slaveLogin.pass){
|
||||
print('Running: '+inputString);
|
||||
} else {
|
||||
print('Not identified to communicate with any slave! Run "help" for more information.');
|
||||
}
|
||||
} else if(argv[0] === 'help'){
|
||||
if(argv[1] === 'login'){
|
||||
print('To send commands to a slave, you are required to identify yourself. You can do this with the login command. The name will be the name of the slave as displayed on master. The password is the rcon password of the slave, as per /instances/[name]/config.json');
|
||||
} else if(argv[1] === "/c"){
|
||||
print('To run a command, start with /. You can for example do /c game.print("hello world!")');
|
||||
} else if(argv[1] === "issues"){
|
||||
print("Issues are reported to clusterio on github or espernet IRC.");
|
||||
print(" - http://github.com/clusterio/factorioClusterio/issues");
|
||||
print(" - EsperNet #factorio Danielv123");
|
||||
} else {
|
||||
print('To identify yourself to a slave, use "login [name] [password]"');
|
||||
print(' - /c [command] - Run command');
|
||||
print(' - login [name] [passs] - Connect to remote slave');
|
||||
print(' - issues - Report an issue');
|
||||
}
|
||||
} else if(argv[0] === 'issues'){
|
||||
print("Issues are reported to clusterio on github or espernet IRC.");
|
||||
print(" - http://github.com/clusterio/factorioClusterio/issues");
|
||||
print(" - EsperNet #factorio Danielv123");
|
||||
} else {
|
||||
print('Invalid command!');
|
||||
}
|
||||
|
||||
// keep handling input (unless something is seriously wrong)
|
||||
myTerminal.input('',handleTerminalInput);
|
||||
}
|
||||
}
|
||||
function minimizeTerminal(instant){
|
||||
localStorage.terminalMinimized = true;
|
||||
$('#terminal').draggable('disable');
|
||||
if(!instant){
|
||||
$('#terminal')[0].style.transition = 'left 0.2s linear, top 0.2s linear';
|
||||
}
|
||||
document.querySelector('.Terminal').style.height = 0;
|
||||
let ter = $('#terminal')[0];
|
||||
// if the window just spawned in and we are doing the first minimize, our position won't be set yet. We also don't want to save this blank position.
|
||||
if(ter.style.left && ter.style.top){
|
||||
localStorage.oldTerminalPosition = JSON.stringify({x:ter.style.left, y:ter.style.top});
|
||||
}
|
||||
ter.style.left = (window.innerWidth - ter.offsetWidth)+'px';
|
||||
ter.style.top = (window.innerHeight - 25)+'px';
|
||||
|
||||
// if we did it instant we still want to set this property for after.
|
||||
if(instant){
|
||||
setTimeout(() => $('#terminal')[0].style.transition = 'left 0.2s linear, top 0.2s linear',200);
|
||||
}
|
||||
}
|
||||
function maximizeTerminal(instant){
|
||||
localStorage.terminalMinimized = false;
|
||||
document.querySelector('.Terminal').style.height = '400px';
|
||||
let ter = $('#terminal')[0];
|
||||
|
||||
// recover our old saved position
|
||||
let oldTerminalPosition = JSON.parse(localStorage.oldTerminalPosition);
|
||||
ter.style.left = oldTerminalPosition.x;
|
||||
ter.style.top = oldTerminalPosition.y;
|
||||
|
||||
// remove our animation once its finished
|
||||
// since instant is a true/false value, Number will convert it to 1 or 0. Since 0 is falsy, || will turn it into 200.
|
||||
setTimeout(() => $('#terminal')[0].style.transition = 'none', (Number(instant) || 200));
|
||||
$('#terminal').draggable('enable');
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
|
||||
if(localStorage.settings) {
|
||||
let settings = JSON.parse(localStorage.settings);
|
||||
|
||||
let HTML = "";
|
||||
|
||||
for(let key in settings) {
|
||||
HTML += drawSetting(key, settings[key]);
|
||||
}
|
||||
document.querySelector("#body").innerHTML = document.querySelector("#body").innerHTML + HTML;
|
||||
}
|
||||
|
||||
window.onload = function(){
|
||||
// loop through all settings and add onclick events for them
|
||||
let sliders = document.querySelectorAll(".settings-box .switch .slider");
|
||||
for(let i = 0; i < sliders.length; i++) {
|
||||
sliders[i].onclick = function() {
|
||||
let settings = JSON.parse(localStorage.settings);
|
||||
let selectedSliderParent = $(this.parentElement.parentElement);
|
||||
// walk through DOM structure created by drawSetting and update localstorage from that
|
||||
console.log(selectedSliderParent.find('.settings-text').html() + " = " + (selectedSliderParent.find("label input").val() === 'on'));
|
||||
settings[selectedSliderParent.find('.settings-text').html()] = (selectedSliderParent.find("label input").val() === 'on');
|
||||
|
||||
localStorage.settings = JSON.stringify(settings);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function drawSetting(settingText, checked) {
|
||||
|
||||
let HTML = '<div class="settings-box">' +
|
||||
'<label class="switch"><input type="checkbox" checked="' + checked + '"' +
|
||||
'><div class="slider round"></div></label><span class="settings-text ml-3 align-middle">' + settingText + '</span></div>';
|
||||
return HTML;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
nav {
|
||||
background-color: #25272a;
|
||||
}
|
||||
|
||||
nav li {
|
||||
height: 80px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.navbar .nav-title {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-size: 25px;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* The switch - the box around the slider */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
.settingsBox {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hide default HTML checkbox */
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* The slider */
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #ab671e;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px #ab671e;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(26px);
|
||||
-ms-transform: translateX(26px);
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Rounded sliders */
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.item-unknown-item {
|
||||
background: url("pictures/unknown-item.png");
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Calculate basedir of application based on current location and
|
||||
// the provided web_root from master server.
|
||||
const location = new URL(window.web_root, document.location);
|
||||
const basename = location.pathname !== "/" ? location.pathname : "";
|
||||
|
||||
export default basename;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
|
||||
import basename from "../basename";
|
||||
import Layout from "../layout";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename={basename}>
|
||||
<Layout />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { Component } from "react";
|
||||
import { Collapse, Row, Col, Modal, Table, Form, Input, Button, Popover } from "antd";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import notify from "../util/notify";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
/**
|
||||
* Display data in table. Allows for editing data by clicking rows in the table.
|
||||
* Basic usage: <DataTable Server="SQL08" DataSource="sql" Database="HDG1" Table="[dbo].[ReportInfo]" />
|
||||
*
|
||||
* DataFunction - String, corresponds to export from util/wslink. Same as ws message name.
|
||||
* columns - Array of objects, optional, same as <Table />. Note the lowercase c
|
||||
* Editable - Boolean - Allow editing
|
||||
* AddRecord - Boolean - Allow adding new records
|
||||
* CustomDataFilterFunction - function(tableData) - Filter tabledata and return results
|
||||
* RecordIdField - String - By default we assume the column "Id" is a unique identifier for a record. If that isn't the case, supply the column name here
|
||||
*/
|
||||
class DataTable extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (!this.props.DataFunction) throw new Error("DataFunction not defined");
|
||||
this.state = {
|
||||
pagination: {
|
||||
pageSize: 100,
|
||||
current: 1,
|
||||
},
|
||||
filters: this.props.Filters,
|
||||
sorter: {},
|
||||
tableData: [],
|
||||
showEditModal: false,
|
||||
record: {},
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async getData(pagination, filters, sorter) {
|
||||
/*
|
||||
pagination = {
|
||||
pageSize: 10, // items per page
|
||||
current: 1, // page number
|
||||
total: 123, // Total amount of items
|
||||
}
|
||||
filters = {
|
||||
ReportId: 123
|
||||
}
|
||||
sorter = {}
|
||||
*/
|
||||
pagination = pagination || this.state.pagination;
|
||||
filters = filters || this.state.filters;
|
||||
sorter = sorter || this.state.sorter;
|
||||
// console.log(pagination, filters, sorter)
|
||||
|
||||
let data = await this.props.DataFunction();
|
||||
// await fetchApi(this.state.dataUrl, "post", {
|
||||
// database: this.props.Database,
|
||||
// table: this.props.Table,
|
||||
// dataSource: this.props.DataSource,
|
||||
// pagination: this.props.RecordCount? {...pagination, pageSize: this.props.RecordCount, current: 1}:pagination,
|
||||
// filters,
|
||||
// sorter,
|
||||
// recordIdField: this.props.RecordIdField || "Id", // Sort by this
|
||||
// })
|
||||
console.log(data);
|
||||
data = data || {};
|
||||
this.setState({
|
||||
tableData: this.props.CustomDataFilterFunction ? this.props.CustomDataFilterFunction(data) : data,
|
||||
pagination: { ...pagination, ...data.pagination },
|
||||
sorter,
|
||||
filters,
|
||||
});
|
||||
}
|
||||
|
||||
showEditModal(record, index) {
|
||||
if (!this.state.showEditModal) {
|
||||
this.setState({
|
||||
record: record,
|
||||
showEditModal: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideEditModal() {
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
}
|
||||
|
||||
async onEditFinish(values) {
|
||||
console.log("Finished editing", values);
|
||||
|
||||
if (this.state.inserting) { // Create new record
|
||||
await this.props.AddRecord.insert(values);
|
||||
} else { // Edit existing record, capable of doing sparse updates
|
||||
await this.props.Editable.insert(values);
|
||||
}
|
||||
this.setState({
|
||||
showEditModal: false,
|
||||
inserting: false,
|
||||
record: {},
|
||||
});
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async deleteRecord(record) {
|
||||
delete record.key;
|
||||
// Delete "null" values as they seem to cause deletes to fail silently when the value is null and field type is float
|
||||
for (let key in record) {
|
||||
if (record[key] === null) delete record[key];
|
||||
}
|
||||
var status = {};
|
||||
// await fetchApi(`/api/${this.props.Server}/tableData/record`, "delete", {
|
||||
// database: this.props.Database,
|
||||
// table: this.props.Table,
|
||||
// dataSource: this.props.DataSource,
|
||||
// record,
|
||||
// }, undefined, true)
|
||||
|
||||
console.log("Deleted record", status);
|
||||
|
||||
if (status.ok) notify("success", status.msg, "success");
|
||||
if (!status.ok) notify("error", status.msg, "error");
|
||||
|
||||
// Force refresh of data
|
||||
this.getData();
|
||||
}
|
||||
|
||||
render() {
|
||||
let tableProps = {
|
||||
...this.props.TableProps || {},
|
||||
onRow: (record, rowIndex) => {
|
||||
/*
|
||||
To allow for extending functionality, we take a TableProps argument from props and pass it through to the Table component.
|
||||
Since we are also using onRow internaly here, we handle the externally provided events ourselves by passing along the event
|
||||
object. This way the end user isn't affected by our event handlers.
|
||||
*/
|
||||
let externalOnRow = {
|
||||
...(this.props.TableProps && this.props.TableProps.onRow && this.props.TableProps.onRow(record, rowIndex)),
|
||||
};
|
||||
return {
|
||||
onClick: event => {
|
||||
if (event.target.type !== "button") {
|
||||
console.log("onRow", record, rowIndex);
|
||||
if (this.props.Editable) this.showEditModal(record, rowIndex);
|
||||
if (externalOnRow.onClick) externalOnRow.onClick(event);
|
||||
}
|
||||
},
|
||||
onDoubleClick: event => {
|
||||
if (externalOnRow.onDoubleClick) externalOnRow.onDoubleClick(event);
|
||||
},
|
||||
onContextMenu: event => {
|
||||
if (externalOnRow.onContextMenu) externalOnRow.onContextMenu(event);
|
||||
},
|
||||
onMouseEnter: event => {
|
||||
if (externalOnRow.onMouseEnter) externalOnRow.onMouseEnter(event);
|
||||
},
|
||||
onMouseLeave: event => {
|
||||
if (externalOnRow.onMouseLeave) externalOnRow.onMouseLeave(event);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
let columns = this.props.columns || this.inferColumns(this.state.tableData);
|
||||
if (this.props.DeleteRecord && !columns.find(x => x.dataIndex === "notInUse")) columns.push({
|
||||
// Add delete button on the right
|
||||
dataIndex: "notInUse",
|
||||
title: "",
|
||||
render: (text, record) => <Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={() => {
|
||||
this.deleteRecord(record);
|
||||
}}
|
||||
>
|
||||
Slett permanent
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>,
|
||||
});
|
||||
|
||||
return <>
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<style>
|
||||
{`
|
||||
.editableTable .ant-table-tbody > tr {
|
||||
cursor:pointer
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Table
|
||||
className={this.props.Editable && "editableTable"}
|
||||
{...tableProps}
|
||||
columns={columns.map(x => ({
|
||||
...x, // Add a 4th parameter to the stock render function with all of our table data
|
||||
render: x.render ? (...originalParams) => x.render(...originalParams, this.state.tableData) : undefined,
|
||||
}))}
|
||||
dataSource={this.state.tableData}
|
||||
pagination={this.state.pagination.total > 10 ? this.state.pagination : false}
|
||||
onChange={this.getData.bind(this)}
|
||||
/>
|
||||
{this.props.AddRecord && <Button type="primary" onClick={() => this.setState({
|
||||
showEditModal: true,
|
||||
inserting: true,
|
||||
})}>
|
||||
<PlusOutlined />Add
|
||||
</Button>}
|
||||
</Col>
|
||||
<Modal
|
||||
visible={this.state.showEditModal}
|
||||
onCancel={this.hideEditModal.bind(this)}
|
||||
footer={null}
|
||||
title={
|
||||
<h2>{this.state.inserting ? "Legg til" : "Rediger"}</h2>
|
||||
}
|
||||
>
|
||||
{this.state.showEditModal && <Form
|
||||
labelCol={{
|
||||
span: 6,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 12,
|
||||
}}
|
||||
layout="horizontal"
|
||||
onFinish={this.onEditFinish.bind(this)}
|
||||
initialValues={this.state.record}
|
||||
>
|
||||
{
|
||||
|
||||
(
|
||||
(this.state.inserting && this.props.AddRecord && this.props.AddRecord.fields) // Use externally supplied form input fields
|
||||
|| this.props.columns.filter && this.props.columns.filter(x => x.dataIndex !== "notInUse") // Generate input fields from external columns
|
||||
|| this.inferColumns(this.state.tableData)).map(col => { // Generate input fields from autogenerated columns (only works with existing data)
|
||||
console.log(this.state.record);
|
||||
return <Form.Item key={col.dataIndex} label={col.title} name={col.dataIndex}>
|
||||
{col.renderEdit && col.renderEdit(this.state.record[col.dataIndex], this.state.record, undefined /* Non spec compliant, is supposed to be index of record */, this.state.tableData) || <Input
|
||||
placeholder={this.state.record[col.dataIndex]}
|
||||
/>}
|
||||
</Form.Item>;
|
||||
})
|
||||
}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{this.state.inserting ? "Legg til" : "Lagre"}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>}
|
||||
</Modal>
|
||||
</Row>
|
||||
</>;
|
||||
}
|
||||
|
||||
inferColumns(data) {
|
||||
// Look at the data and find columns and how to display them. Can be overridden by props.Columns if more customization is needed
|
||||
let columns = [];
|
||||
data.forEach(row => {
|
||||
Object.keys(row).filter(x => x !== "key").forEach(key => {
|
||||
let existing = columns.find(col => col.key === key);
|
||||
if (!existing) {
|
||||
columns.push({
|
||||
key: key,
|
||||
title: key,
|
||||
dataIndex: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
@@ -0,0 +1,25 @@
|
||||
@import '~antd/dist/antd.dark.css';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
|
||||
[data-theme="compact"] p, p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-links{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clusterio</title>
|
||||
<script>
|
||||
window.web_root = "__WEB_ROOT__";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script src="__WEB_ROOT__bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
import "./index.css";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import App from "./components/App";
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Layout, Menu } from "antd";
|
||||
|
||||
import { Route, Link, withRouter } from "react-router-dom";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { Dashboard } from "../pages/dashboard";
|
||||
import { InstancesTable } from "../pages/instances/instancesTable";
|
||||
import { SlavesTable } from "../pages/slaves/slavesTable";
|
||||
import { UsersTable } from "../pages/users/usersTable";
|
||||
import { RolesTable } from "../pages/roles/rolesTable";
|
||||
import { PermissionsTable } from "../pages/permissions/permissionsTable";
|
||||
import { InstanceView } from "../pages/instances/instance";
|
||||
import { UserView } from "../pages/users/user";
|
||||
import { RoleView } from "../pages/roles/role";
|
||||
import { Settings } from "../pages/settings/settings";
|
||||
|
||||
const { Content, Footer, Sider } = Layout;
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
class SiteLayout extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { location } = props;
|
||||
|
||||
this.state = {
|
||||
collapsed: false,
|
||||
setcollapsed: false,
|
||||
tags: [],
|
||||
setTags: [],
|
||||
location,
|
||||
siteSettings: {},
|
||||
factoryModel: [],
|
||||
};
|
||||
}
|
||||
|
||||
onCollapse() {
|
||||
this.setState({ collapsed: !this.state.collapsed });
|
||||
}
|
||||
|
||||
titleCase(text) {
|
||||
return text.substring(0, 1).toUpperCase() + text.substring(1);
|
||||
}
|
||||
|
||||
replaceParametersInUrl({ endpoint, params }) {
|
||||
let path = endpoint;
|
||||
console.log("endpoint", params);
|
||||
Object.keys(params).forEach(key => {
|
||||
path = path.replace(":" + key, params[key]);
|
||||
});
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
{document.location.search !== "?hideExtras" ? (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={this.state.collapsed}
|
||||
onCollapse={this.onCollapse.bind(this)}
|
||||
width={250}
|
||||
breakpoint="lg"
|
||||
>
|
||||
<p></p>
|
||||
<div style={{ width: "100%", textAlign: "center" }}>
|
||||
{" "}
|
||||
{this.state.collapsed ? (
|
||||
<img
|
||||
alt=""
|
||||
src="/smal_logo.png"
|
||||
style={{ height: "48px", margin: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt=""
|
||||
src="/logo.png"
|
||||
style={{ width: "200px", margin: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
|
||||
<Menu
|
||||
theme="dark"
|
||||
defaultSelectedKeys={[
|
||||
this.state.location.pathname.split("/")[
|
||||
this.state.location.pathname.split("/").length - 1
|
||||
],
|
||||
]}
|
||||
defaultOpenKeys={(() => {
|
||||
let x = this.state.location.pathname.split("/");
|
||||
x.shift();
|
||||
return x;
|
||||
})()}
|
||||
mode="inline"
|
||||
>
|
||||
<Menu.Item key="slaves">
|
||||
<span>Slaves</span>
|
||||
<Link to="/slaves" />
|
||||
</Menu.Item>
|
||||
<Menu.Item key="instances">
|
||||
<span>Instances</span>
|
||||
<Link to="/instances" />
|
||||
</Menu.Item>
|
||||
<Menu.Item key="users">
|
||||
<span>Users</span>
|
||||
<Link to="/users" />
|
||||
</Menu.Item>
|
||||
<Menu.Item key="roles">
|
||||
<span>Roles</span>
|
||||
<Link to="/roles" />
|
||||
</Menu.Item>
|
||||
<Menu.Item key="permissions">
|
||||
<span>Permissions</span>
|
||||
<Link to="/permissions" />
|
||||
</Menu.Item>
|
||||
<Menu.Item key="settings">
|
||||
<span>Settings</span>
|
||||
<Link to="/settings" />
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</Sider>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Layout style={{
|
||||
// background: "#F4F4F4"
|
||||
}}>
|
||||
<Content
|
||||
style={{
|
||||
margin: "24px 16px",
|
||||
padding: 24,
|
||||
// background: "#fff",
|
||||
minHeight: 280,
|
||||
}}
|
||||
>
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route exact path="/instances" component={InstancesTable} />
|
||||
<Route exact path="/instances/:id/view" component={InstanceView} />
|
||||
<Route exact path="/slaves" component={SlavesTable} />
|
||||
<Route exact path="/slaves/:id/view" component={SlavesTable} />
|
||||
<Route exact path="/users" component={UsersTable} />
|
||||
<Route exact path="/users/:id/view" component={UserView} />
|
||||
<Route exact path="/roles" component={RolesTable} />
|
||||
<Route exact path="/roles/:id/view" component={RoleView} />
|
||||
<Route exact path="/permissions" component={PermissionsTable} />
|
||||
<Route exact path="/permissions/:id/view" component={PermissionsTable} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
</Content>
|
||||
<Footer style={{
|
||||
// background: "#F4F4F4",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
Clusterio 2.0 web interface https://github.com/clusterio
|
||||
</Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(SiteLayout);
|
||||
@@ -0,0 +1,7 @@
|
||||
import React, {Component} from "react";
|
||||
|
||||
export class Dashboard extends Component {
|
||||
render(){
|
||||
return <h2>Dashboard</h2>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card, Button, Popover, Select, Divider, Input, Form, Col, Row, Checkbox, Space } from "antd";
|
||||
import {
|
||||
listSlaves,
|
||||
listInstances,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
setInstanceOutputSubscriptions,
|
||||
deleteInstance,
|
||||
assignInstance,
|
||||
createSave,
|
||||
getInstanceConfig,
|
||||
setInstanceConfigField,
|
||||
sendRcon,
|
||||
} from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
import notify from "../../util/notify";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import EditOutlined from "@ant-design/icons/EditOutlined";
|
||||
import PlusOutlined from "@ant-design/icons/PlusOutlined";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class InstanceView extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
instance: {},
|
||||
slaves: [],
|
||||
logLines: [{ message: "Logviewer. Follows the latest logs if you scroll to the bottom. Does not store logs past the lifetime of this tab" }],
|
||||
};
|
||||
this.messagesEndRef = React.createRef();
|
||||
}
|
||||
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
let id = this.props.match.params.id;
|
||||
|
||||
await this.getData();
|
||||
|
||||
await setInstanceOutputSubscriptions({
|
||||
instance_id: Number(id),
|
||||
});
|
||||
window.instanceOutputEventHandler = ({ instance_id, output }) => {
|
||||
if (instance_id === Number(id)) {
|
||||
this.setState({
|
||||
logLines: [...this.state.logLines, output],
|
||||
});
|
||||
}
|
||||
// Automatically scroll to bottom on new line.
|
||||
// this.messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
};
|
||||
}
|
||||
|
||||
async getData() {
|
||||
let id = this.props.match.params.id;
|
||||
|
||||
let instances = await listInstances();
|
||||
let slaves = await listSlaves();
|
||||
|
||||
this.setState({
|
||||
instance: instances.find(x => x.id === Number(id)),
|
||||
slaves,
|
||||
});
|
||||
|
||||
let config = await getInstanceConfig({ instance_id: Number(id) });
|
||||
console.log(config);
|
||||
this.setState({
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.setState({
|
||||
starting: true,
|
||||
});
|
||||
try {
|
||||
await startInstance({
|
||||
instance_id: this.state.instance.id,
|
||||
save: null,
|
||||
});
|
||||
} catch (e) {
|
||||
notify("Error starting instance", "error", e.message);
|
||||
this.setState({
|
||||
starting: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
notify("Instance started");
|
||||
this.setState({
|
||||
starting: false,
|
||||
});
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.setState({
|
||||
stopping: true,
|
||||
});
|
||||
try {
|
||||
await stopInstance({
|
||||
instance_id: this.state.instance.id,
|
||||
});
|
||||
} catch (e) {
|
||||
notify("Error stopping instance", "error", e.message);
|
||||
this.setState({
|
||||
stopping: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
notify("Instance stopped");
|
||||
this.setState({
|
||||
stopping: false,
|
||||
});
|
||||
this.getData();
|
||||
}
|
||||
|
||||
async createSave() {
|
||||
this.setState({
|
||||
creatingSave: true,
|
||||
});
|
||||
try {
|
||||
await createSave({
|
||||
instance_id: this.state.instance.id,
|
||||
});
|
||||
} catch (e) {
|
||||
notify("Error creating save", "error", e.message);
|
||||
this.setState({
|
||||
creatingSave: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
notify("Save created", "success");
|
||||
this.setState({
|
||||
creatingSave: false,
|
||||
});
|
||||
}
|
||||
|
||||
async onConfigEditFinish(values) {
|
||||
console.log("Completed config edit with", values);
|
||||
for (let k in values) {
|
||||
if (values[k] !== this.getInstanceConfigAsKvObject(this.state.config)[k]) {
|
||||
let value = values[k];
|
||||
if (typeof value === "boolean") value = value.toString();
|
||||
console.log("Setting", k, value);
|
||||
await setInstanceConfigField({
|
||||
instance_id: this.state.instance.id,
|
||||
field: k,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
notify("Set configuration fields", "success");
|
||||
this.setState({
|
||||
editingConfig: false,
|
||||
});
|
||||
this.getData();
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(this.state);
|
||||
let { instance, slaves } = this.state;
|
||||
let slave = slaves && slaves.find(x => x.id === instance.assigned_slave);
|
||||
return <Card>
|
||||
<h2>{instance.name}</h2>
|
||||
<p>ID: {instance.id}</p>
|
||||
<p>Slave: {slave && slave.name}</p>
|
||||
{instance.status !== "running" && <Button
|
||||
loading={this.state.starting}
|
||||
type="primary"
|
||||
onClick={this.start.bind(this)}
|
||||
>
|
||||
Start server
|
||||
</Button>}
|
||||
{instance.status !== "stopped" && <Button
|
||||
loading={this.state.stopping}
|
||||
type="primary"
|
||||
onClick={this.stop.bind(this)}
|
||||
>
|
||||
Stop server
|
||||
</Button>}
|
||||
{instance.status !== "running" && <Button
|
||||
loading={this.state.creatingSave}
|
||||
type="primary"
|
||||
onClick={this.createSave.bind(this)}
|
||||
>
|
||||
Create save
|
||||
</Button>}
|
||||
<Popover
|
||||
content={
|
||||
<Select style={{
|
||||
width: "200px",
|
||||
}}
|
||||
onChange={async newSlaveId => {
|
||||
let result = await assignInstance({ instance_id: instance.id, slave_id: newSlaveId });
|
||||
notify("Assigned to slave", "success");
|
||||
this.getData();
|
||||
}}
|
||||
>
|
||||
{this.state.slaves.map(slave => <Option defaultValue={instance.assigned_slave} value={slave.id} key={slave.id}>
|
||||
{slave.name}
|
||||
</Option>)}
|
||||
</Select>
|
||||
}
|
||||
trigger="click"
|
||||
type={instance.status === "unassigned" ? "primary" : "default"}
|
||||
>
|
||||
<Button>
|
||||
Assign to slave
|
||||
</Button>
|
||||
</Popover>
|
||||
<Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteInstance({ instance_id: instance.id });
|
||||
notify("Deleted instance " + instance.id, "success");
|
||||
this.navigate("/instances");
|
||||
}}
|
||||
>
|
||||
Delete this instance permanently
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
<style>
|
||||
{/* Scroll sticks to bottom of terminal window after manually scrolling to the bottom */}
|
||||
{`#scroller * {
|
||||
/* don't allow the children of the scrollable element to be selected as an anchor node */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
#anchor {
|
||||
/* allow the final child to be selected as an anchor node */
|
||||
overflow-anchor: auto;
|
||||
|
||||
/* anchor nodes are required to have non-zero area */
|
||||
height: 1px;
|
||||
}`}
|
||||
</style>
|
||||
<div id="scroller" style={{
|
||||
height: "300px",
|
||||
overflowY: "scroll",
|
||||
}}>
|
||||
{this.state.logLines.map(item => <p>{item.time} {item.level} {item.file}: {item.message}</p>)}
|
||||
<div id="anchor" ref={this.messagesEndRef}></div>
|
||||
</div>
|
||||
{/* rcon input field */}
|
||||
<Form
|
||||
onFinish={async values => {
|
||||
await sendRcon({ instance_id: instance.id, command: values.command });
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="RCON: "
|
||||
name="command"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{this.state.config && this.renderConfig(this.state.config)}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
getInstanceConfigAsKvObject(config) {
|
||||
let object = {};
|
||||
for (let group of config.serialized_config.groups) {
|
||||
for (let fieldName of Object.keys(group.fields)) {
|
||||
object[group.name + "." + fieldName] = group.fields[fieldName];
|
||||
}
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
renderConfig(config) {
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 6,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 14,
|
||||
},
|
||||
};
|
||||
const tailLayout = {
|
||||
wrapperCol: {
|
||||
offset: 6,
|
||||
span: 14,
|
||||
},
|
||||
};
|
||||
return <Card>
|
||||
<Form
|
||||
{...layout}
|
||||
initialValues={this.getInstanceConfigAsKvObject(config)}
|
||||
onFinish={this.onConfigEditFinish.bind(this)}
|
||||
>
|
||||
{this.state.editingConfig ?
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save config changes
|
||||
</Button>
|
||||
</Form.Item>
|
||||
:
|
||||
<Button onClick={() => this.setState({ editingConfig: true })}>
|
||||
<EditOutlined /> Edit config
|
||||
</Button>
|
||||
}
|
||||
<Row>
|
||||
{config && config.serialized_config.groups.map(group => <>
|
||||
<Col span={24} lg={12}>
|
||||
<Divider
|
||||
// orientation="left"
|
||||
>{group.name}</Divider>
|
||||
{/* {Object.keys(group.fields).map(field => <>
|
||||
<span>{field}: </span>
|
||||
<span>{JSON.stringify(group.fields[field])}</span>
|
||||
<br />
|
||||
</>)} */}
|
||||
{console.log(group)}
|
||||
{Object.keys(group.fields).map(field => <ConfigFormItem field={field} group={group} editingConfig={this.state.editingConfig} />)}
|
||||
</Col>
|
||||
</>)}
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDisplayField(value) {
|
||||
if (typeof value === "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return <Checkbox />;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
function renderFormField(value) {
|
||||
if (typeof value === "object") {
|
||||
return <ObjectInput />;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return <Checkbox />;
|
||||
}
|
||||
return <Input />;
|
||||
}
|
||||
|
||||
class ObjectInput extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
console.log(this.props);
|
||||
/*
|
||||
value: {
|
||||
auto_pause: false,
|
||||
tags: ["clusterio"]
|
||||
},
|
||||
id: "factorio.settings",
|
||||
onChange: f()
|
||||
*/
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
onChange(e, key) {
|
||||
if (e.target.type === "checkbox") {
|
||||
// if (e.target.checked !== undefined) {
|
||||
// Its a checkbox, set value to true/false
|
||||
let value = { ...this.props.value };
|
||||
value[key] = e.target.checked || false;
|
||||
this.props.onChange(value);
|
||||
} else if (e.target.type === "text") {
|
||||
// } else if (e.target.value !== undefined) {
|
||||
// its probably a text input
|
||||
let value = { ...this.props.value };
|
||||
value[key] = e.target.value || "";
|
||||
this.props.onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let { value } = this.props;
|
||||
let ret = [];
|
||||
for (let key in value) {
|
||||
if (typeof value[key] === "boolean") {
|
||||
ret.push(<Checkbox
|
||||
key={key}
|
||||
checked={value[key]}
|
||||
onChange={e => this.onChange(e, key)}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
);
|
||||
} else if (typeof value[key] === "string") {
|
||||
ret.push(<div
|
||||
key={key}
|
||||
><span>{key}</span><Input
|
||||
value={value[key]}
|
||||
onChange={e => this.onChange(e, key)}
|
||||
/></div>
|
||||
);
|
||||
}
|
||||
}
|
||||
ret.push(<Popover
|
||||
key="addMore"
|
||||
content={<>
|
||||
<Input onChange={e => this.setState({ newInput: e.target.value })} />
|
||||
<Button
|
||||
onClick={e => {
|
||||
this.onChange({ target: { value: "", type: "text" } }, this.state.newInput);
|
||||
this.setState({
|
||||
newInput: undefined,
|
||||
addPopoverVisible: false,
|
||||
});
|
||||
}}
|
||||
><PlusOutlined />Add property</Button>
|
||||
</>}
|
||||
trigger="click"
|
||||
visible={this.state.addPopoverVisible}
|
||||
handleVisibleChange={visible => this.setState({ addPopoverVisible: visible })}
|
||||
>
|
||||
<Button
|
||||
onClick={e => this.setState({ addPopoverVisible: true })}
|
||||
>
|
||||
<PlusOutlined />Add property
|
||||
</Button>
|
||||
</Popover>);
|
||||
return <Space direction="vertical">{ret}</Space>;
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigFormItem extends Component {
|
||||
render() {
|
||||
let { field, group, editingConfig } = this.props;
|
||||
return <Form.Item
|
||||
label={field}
|
||||
name={group.name + "." + field}
|
||||
valuePropName={typeof group.fields[field] === "boolean" ? "checked" : "value" /* Boolean fields should be rendered as checkboxes */}
|
||||
>
|
||||
{editingConfig ?
|
||||
renderFormField(group.fields[field])
|
||||
:
|
||||
renderDisplayField(group.fields[field])
|
||||
}
|
||||
</Form.Item>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances, createInstance } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
const config = require("@clusterio/lib/config");
|
||||
|
||||
export class InstancesTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Instances</h2>
|
||||
<DataTable
|
||||
AddRecord={{
|
||||
fields: [{
|
||||
dataIndex: "name",
|
||||
title: "Name",
|
||||
}],
|
||||
insert: async args => {
|
||||
let instanceConfig = new config.InstanceConfig();
|
||||
await instanceConfig.init();
|
||||
console.log("Base config", instanceConfig);
|
||||
instanceConfig.set("instance.name", args.name);
|
||||
let serialized_config = instanceConfig.serialize();
|
||||
let response = await createInstance(serialized_config);
|
||||
console.log("Created instance", response);
|
||||
},
|
||||
}}
|
||||
DataFunction={listInstances}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/instances/${record.id}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances, listPermissions } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
|
||||
export class PermissionsTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Permissions</h2>
|
||||
<DataTable
|
||||
DataFunction={listPermissions}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/permissions/${record.id}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Card, Button, Popover, Select, Transfer } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
import {
|
||||
listSlaves,
|
||||
listInstances,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
setInstanceOutputSubscriptions,
|
||||
deleteInstance,
|
||||
assignInstance,
|
||||
createSave,
|
||||
getInstanceConfig,
|
||||
setInstanceConfigField,
|
||||
sendRcon,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
listRoles,
|
||||
setRoles,
|
||||
listPermissions,
|
||||
updateRole,
|
||||
} from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
import notify from "../../util/notify";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class RoleView extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
role: {},
|
||||
};
|
||||
this.messagesEndRef = React.createRef();
|
||||
}
|
||||
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.getData();
|
||||
}
|
||||
|
||||
async getData() {
|
||||
let id = this.props.match.params.id;
|
||||
|
||||
let roles = await listRoles();
|
||||
let permissions = await listPermissions();
|
||||
|
||||
this.setState({
|
||||
role: roles.find(x => x.id === Number(id)),
|
||||
permissions,
|
||||
});
|
||||
}
|
||||
|
||||
async handleChange(targetKeys) {
|
||||
this.setState({
|
||||
role: {
|
||||
...this.state.role,
|
||||
permissions: targetKeys,
|
||||
},
|
||||
});
|
||||
let response = await updateRole({
|
||||
...this.state.role,
|
||||
permissions: targetKeys,
|
||||
});
|
||||
console.log("Updated user roles", response);
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(this.state);
|
||||
let { role, permissions } = this.state;
|
||||
return <Card>
|
||||
<Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteUser({ role: role.name });
|
||||
notify("Deleted role " + role.name, "success");
|
||||
this.navigate("/roles");
|
||||
}}
|
||||
>
|
||||
Delete this role permanently
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
<h2>{role.name}</h2>
|
||||
<h3>Assign roles</h3>
|
||||
<style>
|
||||
{`
|
||||
.ant-transfer-list{
|
||||
width: calc( 50% - 34px );
|
||||
height: 500px;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Transfer
|
||||
titles={["Available", "Assigned"]}
|
||||
dataSource={permissions}
|
||||
showSearch
|
||||
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
|
||||
targetKeys={role.permissions}
|
||||
onChange={this.handleChange.bind(this)}
|
||||
render={item => item.name}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances, listRoles, createRole } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
|
||||
export class RolesTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Roles</h2>
|
||||
<DataTable
|
||||
AddRecord={{
|
||||
fields: [{
|
||||
dataIndex: "name",
|
||||
title: "Name",
|
||||
},{
|
||||
dataIndex: "description",
|
||||
title: "Description",
|
||||
}],
|
||||
insert: async args => {
|
||||
let response = await createRole({name: args.name, description: args.description});
|
||||
console.log("Created role",response);
|
||||
},
|
||||
}}
|
||||
DataFunction={listRoles}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/roles/${record.id}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card, Button, Input, Form } from "antd";
|
||||
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 4,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 16,
|
||||
},
|
||||
};
|
||||
const tailLayout = {
|
||||
wrapperCol: {
|
||||
offset: 4,
|
||||
span: 16,
|
||||
},
|
||||
};
|
||||
|
||||
export class Settings extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
role: {},
|
||||
};
|
||||
}
|
||||
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(this.state);
|
||||
return <Card>
|
||||
<h2>Web interface settings</h2>
|
||||
<Form
|
||||
{...layout}
|
||||
name="basic"
|
||||
initialValues={{
|
||||
master_url: localStorage.getItem("master_url"),
|
||||
master_token: localStorage.getItem("master_token"),
|
||||
}}
|
||||
onFinish={values => {
|
||||
console.log("Saving settings",values);
|
||||
for (let key of Object.keys(values)){
|
||||
localStorage.setItem(key, values[key].replace("https://", "wss://").replace("http://", "ws://"));
|
||||
}
|
||||
// Force refresh to apply changes
|
||||
document.location = document.location;
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Master URL"
|
||||
name="master_url"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input master url, ex ws://localhost:8080 or wss://localhost:8443/ . For SSL connections, ensure you have a valid certificate.",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="User auth token"
|
||||
name="master_token"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: "Please input your user auth token from config-control.json",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item {...tailLayout}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
|
||||
export class SlavesTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Slaves</h2>
|
||||
<DataTable
|
||||
DataFunction={listSlaves}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/slaves/${record.id}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card, Button, Popover, Select, Transfer } from "antd";
|
||||
import {
|
||||
deleteUser,
|
||||
listUsers,
|
||||
listRoles,
|
||||
setRoles,
|
||||
} from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
import notify from "../../util/notify";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export class UserView extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user: {},
|
||||
};
|
||||
this.messagesEndRef = React.createRef();
|
||||
}
|
||||
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
await this.getData();
|
||||
}
|
||||
|
||||
async getData() {
|
||||
let name = this.props.match.params.id;
|
||||
|
||||
let users = await listUsers();
|
||||
let roles = await listRoles();
|
||||
|
||||
this.setState({
|
||||
user: users.find(x => x.name === name),
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
async handleChange(targetKeys) {
|
||||
this.setState({
|
||||
user: {
|
||||
...this.state.user,
|
||||
roles: targetKeys,
|
||||
},
|
||||
});
|
||||
let response = await setRoles({
|
||||
name: this.state.user.name,
|
||||
roles: targetKeys,
|
||||
});
|
||||
console.log("Updated user roles",response);
|
||||
};
|
||||
|
||||
render() {
|
||||
console.log(this.state);
|
||||
let { user, roles } = this.state;
|
||||
return <Card>
|
||||
<h2>{user.name}</h2>
|
||||
<h3>Assign roles</h3>
|
||||
<Transfer
|
||||
titles={["Available","Assigned"]}
|
||||
dataSource={roles}
|
||||
showSearch
|
||||
filterOption={(inputValue, option) => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1}
|
||||
targetKeys={user.roles}
|
||||
onChange={this.handleChange.bind(this)}
|
||||
render={item => item.name}
|
||||
/>
|
||||
<Popover
|
||||
content={
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await deleteUser({ name: user.name });
|
||||
notify("Deleted user " + user.name, "success");
|
||||
this.navigate("/users");
|
||||
}}
|
||||
>
|
||||
Delete this user from the cluster
|
||||
</Button>
|
||||
}
|
||||
trigger="click"
|
||||
>
|
||||
<Button danger style={{ float: "right", fontSize: "16px" }}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popover>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { Component } from "react";
|
||||
import { Card } from "antd";
|
||||
import { listSlaves, listInstances, listUsers, createUser } from "../../util/wslink";
|
||||
import DataTable from "../../components/data-table";
|
||||
|
||||
export class UsersTable extends Component {
|
||||
navigate(url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Card>
|
||||
<h2>Slaves</h2>
|
||||
<DataTable
|
||||
AddRecord={{
|
||||
fields: [{
|
||||
dataIndex: "name",
|
||||
title: "Name",
|
||||
}],
|
||||
insert: async args => {
|
||||
let response = await createUser({name: args.name});
|
||||
console.log("Created user",response);
|
||||
},
|
||||
}}
|
||||
DataFunction={listUsers}
|
||||
TableProps={{
|
||||
onRow: (record, rowIndex) => ({
|
||||
onClick: event => {
|
||||
this.navigate(`/users/${record.name}/view`);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,10 @@
|
||||
import { notification } from "antd";
|
||||
|
||||
export default function notify(message, type = "info", description = undefined) {
|
||||
notification[type]({
|
||||
message: typeof message === "string" ? message : "ERROR: See console",
|
||||
description,
|
||||
placement: "bottomRight",
|
||||
});
|
||||
if (typeof message !== "string") console.error(message);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
const link = require("@clusterio/lib/link");
|
||||
const plugin = require("@clusterio/lib/plugin");
|
||||
const errors = require("@clusterio/lib/errors");
|
||||
|
||||
/**
|
||||
* Format a parsed Factorio output message
|
||||
*
|
||||
* Formats a parsed Factorio output from lib/factorio back into the
|
||||
* text string it was parsed from.
|
||||
*
|
||||
* @param {Object} output - Factorio server output.
|
||||
* @returns {string} origial output text.
|
||||
* @private
|
||||
*/
|
||||
function formatOutput(output) {
|
||||
let time = "";
|
||||
if (output.format === "seconds") {
|
||||
time = `${output.time.padStart(8)} `;
|
||||
} else if (output.format === "date") {
|
||||
time = `${output.time} `;
|
||||
}
|
||||
|
||||
let info = "";
|
||||
if (output.type === "log") {
|
||||
info = `${output.level} ${output.file}: `;
|
||||
|
||||
} else if (output.type === "action") {
|
||||
info = `[${output.action}] `;
|
||||
}
|
||||
|
||||
return `${time}${info}${output.message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector for control connection to master server
|
||||
* @private
|
||||
*/
|
||||
export class ControlConnector extends link.WebSocketClientConnector {
|
||||
constructor(url, reconnectDelay, token) {
|
||||
super(url, reconnectDelay);
|
||||
this._token = token;
|
||||
}
|
||||
|
||||
register() {
|
||||
console.log("SOCKET | registering control");
|
||||
this.sendHandshake("register_control", {
|
||||
token: this._token,
|
||||
agent: "web",
|
||||
version: "2.0.0-alpha",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles running the control
|
||||
*
|
||||
* Connects to the master server over WebSocket and sends commands to it.
|
||||
* @static
|
||||
*/
|
||||
export class Control extends link.Link {
|
||||
constructor(connector, controlPlugins) {
|
||||
super("control", "master", connector);
|
||||
link.attachAllMessages(this);
|
||||
|
||||
/**
|
||||
* Mapping of plugin names to their instance for loaded plugins.
|
||||
* @type {Map<string, module:lib/plugin.BaseControlPlugin>}
|
||||
*/
|
||||
this.plugins = controlPlugins;
|
||||
for (let controlPlugin of controlPlugins.values()) {
|
||||
plugin.attachPluginMessages(this, controlPlugin.info, controlPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
async instanceOutputEventHandler(message) {
|
||||
let { instance_id, output } = message.data;
|
||||
console.log(formatOutput(output));
|
||||
if (window.instanceOutputEventHandler) {
|
||||
window.instanceOutputEventHandler({instance_id, output});
|
||||
}
|
||||
}
|
||||
|
||||
async debugWsMessageEventHandler(message) {
|
||||
console.log("WS", message.data.direction, message.data.content);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.connector.setTimeout(30);
|
||||
|
||||
try {
|
||||
await link.messages.prepareDisconnect.send(this);
|
||||
} catch (err) {
|
||||
if (!(err instanceof errors.SessionLost)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await this.connector.close(1001, "Control Quit");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Control, ControlConnector } from "./../util/websocket";
|
||||
import * as errors from "@clusterio/lib/errors";
|
||||
import * as link from "@clusterio/lib/link";
|
||||
import config from "@clusterio/lib/config";
|
||||
|
||||
let control, controlConnector;
|
||||
|
||||
// Create WS connection to master. Used by all components through the interface exported here.
|
||||
(async () => {
|
||||
config.finalizeConfigs();
|
||||
controlConnector = new ControlConnector(
|
||||
localStorage.getItem("master_url"),
|
||||
10, // Reconnect delay
|
||||
localStorage.getItem("master_token"),
|
||||
);
|
||||
control = new Control(controlConnector, []);
|
||||
try {
|
||||
await controlConnector.connect();
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
})();
|
||||
|
||||
let listSlaves = createListFunction("listSlaves");
|
||||
let listInstances = createListFunction("listInstances");
|
||||
let listUsers = createListFunction("listUsers", "name");
|
||||
let listRoles = createListFunction("listRoles");
|
||||
let listPermissions = createListFunction("listPermissions", "name");
|
||||
|
||||
async function startInstance({ instance_id, save = null }) {
|
||||
return await link.messages.startInstance.send(control, { instance_id, save });
|
||||
};
|
||||
async function stopInstance({ instance_id }) {
|
||||
return await link.messages.stopInstance.send(control, { instance_id });
|
||||
};
|
||||
async function createInstance(serialized_config) {
|
||||
return await link.messages.createInstance.send(control, { serialized_config });
|
||||
};
|
||||
async function deleteInstance({ instance_id }) {
|
||||
return await link.messages.deleteInstance.send(control, { instance_id });
|
||||
};
|
||||
async function assignInstance({ instance_id, slave_id }) {
|
||||
return await link.messages.assignInstanceCommand.send(control, {
|
||||
instance_id,
|
||||
slave_id,
|
||||
});
|
||||
};
|
||||
async function createSave({ instance_id }) {
|
||||
return await link.messages.createSave.send(control, { instance_id });
|
||||
};
|
||||
async function getInstanceConfig({ instance_id }) {
|
||||
return await link.messages.getInstanceConfig.send(control, { instance_id });
|
||||
};
|
||||
async function setInstanceConfigField({ instance_id, field, value }) {
|
||||
return await link.messages.setInstanceConfigField.send(control, {
|
||||
instance_id,
|
||||
field,
|
||||
value: typeof value === "object" ? JSON.stringify(value) : value,
|
||||
});
|
||||
};
|
||||
async function setInstanceConfigProp({ instance_id, field, prop, value }) {
|
||||
return await link.messages.setInstanceConfigProp.send(control, {
|
||||
instance_id,
|
||||
field,
|
||||
prop,
|
||||
value, // JSON Object
|
||||
});
|
||||
};
|
||||
async function sendRcon({ instance_id, command }) {
|
||||
return await link.messages.sendRcon.send(control, { instance_id, command });
|
||||
};
|
||||
async function setInstanceOutputSubscriptions({ instance_id }) {
|
||||
return await link.messages.setInstanceOutputSubscriptions.send(control, { instance_ids: [instance_id] });
|
||||
};
|
||||
|
||||
async function createUser({ name }) {
|
||||
return await link.messages.createUser.send(control, { name });
|
||||
};
|
||||
async function deleteUser({ name }) {
|
||||
return await link.messages.deleteUser.send(control, { name });
|
||||
};
|
||||
async function setRoles({ name, roles }) {
|
||||
return await link.messages.updateUserRoles.send(control, {
|
||||
name,
|
||||
roles,
|
||||
});
|
||||
};
|
||||
|
||||
async function createRole({ name, description, permissions = [] }) {
|
||||
return await link.messages.createRole.send(control, { name, description, permissions });
|
||||
};
|
||||
async function updateRole({ id, name, description, permissions }) {
|
||||
return await link.messages.updateRole.send(control, {
|
||||
id, name, description, permissions,
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
listSlaves,
|
||||
listInstances,
|
||||
listUsers,
|
||||
createUser,
|
||||
deleteUser,
|
||||
listRoles,
|
||||
setRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
listPermissions,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
assignInstance,
|
||||
createSave,
|
||||
getInstanceConfig,
|
||||
setInstanceConfigField,
|
||||
setInstanceConfigProp,
|
||||
sendRcon,
|
||||
setInstanceOutputSubscriptions,
|
||||
};
|
||||
|
||||
function createListFunction(name, key) {
|
||||
return async () => (
|
||||
(await link.messages[name].send(control)).list.map(addKey(key || "id"))
|
||||
);
|
||||
}
|
||||
function addKey(key) {
|
||||
return function (el) {
|
||||
return {
|
||||
...el,
|
||||
key: el[key],
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
|
||||
|
||||
module.exports = (env = {}) => ({
|
||||
entry: "./web/index.jsx",
|
||||
mode: env.production ? "production" : "development",
|
||||
devtool: env.production ? "source-map" : "eval-source-map",
|
||||
devServer: {
|
||||
contentBase: "./static",
|
||||
},
|
||||
output: {
|
||||
filename: "bundle.js",
|
||||
path: path.resolve(__dirname, "static"),
|
||||
},
|
||||
performance: {
|
||||
maxAssetSize: 2**21,
|
||||
maxEntrypointSize: 2**21,
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
"process.env.APP_ENV": JSON.stringify("browser"),
|
||||
}),
|
||||
|
||||
// Make sure ant-design icons use the ES variant
|
||||
new webpack.NormalModuleReplacementPlugin(
|
||||
/@ant-design\/icons\/[A-Z]/,
|
||||
function(resource) {
|
||||
resource.request = resource.request.replace(/@ant-design\/icons/, "$&/es/icons");
|
||||
}
|
||||
),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /node_modules.fs-extra/,
|
||||
use: "null-loader",
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
"style-loader",
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
sourceMap: !env.production,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.jsx$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
presets: ["@babel/preset-react"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".js", ".json", ".jsx"],
|
||||
},
|
||||
node: {
|
||||
"crypto": "empty",
|
||||
},
|
||||
});
|
||||
@@ -30,19 +30,6 @@ const masterInventoryGauge = new prometheus.Gauge(
|
||||
class MasterPlugin extends plugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
|
||||
let externalAddress = this.master.config.get("master.external_address");
|
||||
let root = externalAddress ? new URL(externalAddress).pathname : "/";
|
||||
this.ui = {
|
||||
sidebar: [
|
||||
{
|
||||
getHtml: () => `
|
||||
<div class="nav-item mr-1">
|
||||
<a class="nav-link align-middle" href="${root}subspace_storage/storage">Storage</a>
|
||||
</div>`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.items = await loadDatabase(this.master.config);
|
||||
this.itemsLastUpdate = new Map(this.items._items.entries());
|
||||
this.autosaveId = setInterval(() => {
|
||||
@@ -61,7 +48,6 @@ class MasterPlugin extends plugin.BaseMasterPlugin {
|
||||
}, 1000);
|
||||
|
||||
routes.addApiRoutes(this.master.app, this.items, this.metrics.endpointHitCounter);
|
||||
routes.addWebRoutes(this.master.app);
|
||||
}
|
||||
|
||||
updateStorage() {
|
||||
|
||||
@@ -5,11 +5,6 @@ const path = require("path");
|
||||
const prometheus = require("@clusterio/lib/prometheus");
|
||||
|
||||
|
||||
function addWebRoutes(app) {
|
||||
app.use("/subspace_storage", express.static(path.join(__dirname, "static")));
|
||||
app.get("/subspace_storage/storage", (req, res) => { res.render("subspace_storage/views/storage"); });
|
||||
}
|
||||
|
||||
function addApiRoutes(app, items, endpointHitCounter) {
|
||||
|
||||
/**
|
||||
@@ -52,6 +47,5 @@ function addApiRoutes(app, items, endpointHitCounter) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addWebRoutes,
|
||||
addApiRoutes,
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
// function to draw data we recieve from ajax requests
|
||||
"use strict";
|
||||
var _lastData = [];
|
||||
function drawcontents(data) {
|
||||
data = data || _lastData; //Cache data so we can drawcontents without waiting for the server, for the search box.
|
||||
_lastData = data;
|
||||
|
||||
// allow searching for multiple criteria separated by space
|
||||
var searchArgs = document.querySelector("#search").value;
|
||||
searchArgs = searchArgs.trim();
|
||||
searchArgs = "(" + searchArgs.replace(/ +/g,")|(") + ")";
|
||||
const search = new RegExp(searchArgs, "i");
|
||||
data = data.filter(function(item) {
|
||||
return search.test(item.name);
|
||||
});
|
||||
|
||||
sortByKey(data, "count");
|
||||
|
||||
const table = document.querySelector("#contents tbody"); //tables have tbody inserted automatically
|
||||
const rows = table.children;
|
||||
|
||||
//update existing rows or create new ones
|
||||
data.forEach(function(item, i) {
|
||||
// format count to have .s in it for large number readability (1,000,000)
|
||||
item.count = item.count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
let row = rows[i];
|
||||
if(!row) {
|
||||
row = document.createElement("tr");
|
||||
row.innerHTML = "<td><div></div></td><td class=name></td><td class=count></td>";
|
||||
table.appendChild(row);
|
||||
}
|
||||
|
||||
const itemIcon = row.querySelector("div");
|
||||
const iconClass = getItemIconClass(item.name);
|
||||
if (itemIcon.getAttribute("class") !== iconClass) {
|
||||
itemIcon.setAttribute("class", iconClass);
|
||||
}
|
||||
|
||||
let localeName = item.name;
|
||||
let meta = factorioItemMetadata.get(item.name);
|
||||
if (meta && meta.localised_name) {
|
||||
// TODO implement the locale to name conversion.
|
||||
localeName = factorioLocale.get(meta.localised_name[0]);
|
||||
} else {
|
||||
for (let section of ["item-name", "entity-name", "fluid-name", "equipment-name"]) {
|
||||
let name = factorioLocale.get(`${section}.${item.name}`);
|
||||
if (name) {
|
||||
localeName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const name = row.querySelector(".name");
|
||||
if(name.textContent !== localeName) {
|
||||
name.textContent = localeName;
|
||||
}
|
||||
|
||||
const count = row.querySelector(".count");
|
||||
if(count.textContent !== String(item.count)) {
|
||||
count.textContent = item.count;
|
||||
}
|
||||
});
|
||||
|
||||
//remove excess rows, for example, after filtering
|
||||
while (data.length < rows.length) {
|
||||
table.removeChild(rows[data.length]);
|
||||
}
|
||||
}
|
||||
|
||||
// get cluster inventory from master
|
||||
function updateInventory() {
|
||||
var xmlhttp = new XMLHttpRequest();
|
||||
xmlhttp.onreadystatechange = function() {
|
||||
if(xmlhttp.readyState == 4 && xmlhttp.status == 200) {
|
||||
let data = JSON.parse(xmlhttp.responseText);
|
||||
drawcontents(data);
|
||||
}
|
||||
};
|
||||
xmlhttp.open("GET", `${root}api/inventory`, true);
|
||||
xmlhttp.send();
|
||||
}
|
||||
if(JSON.parse(localStorage.settings)["Periodically update storage screen"]) {
|
||||
setInterval(updateInventory, 500);
|
||||
} else {
|
||||
updateInventory();
|
||||
}
|
||||
|
||||
// function to sort arrays of objects after a keys value
|
||||
function sortByKey(array, key) {
|
||||
array.sort(function(a, b) {
|
||||
return b[key] - a[key];
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector("#search").addEventListener("input", function() {
|
||||
drawcontents();
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
<!doctype HTML>
|
||||
<head>
|
||||
<%- include("head") %>
|
||||
<script defer src="<%= locals.root %>subspace_storage/storage.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("nav") %>
|
||||
<div id="body" class="container">
|
||||
<div id="storage">
|
||||
<div class="page-header mt-2 row" id="banner">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-md-7 col-sm-6">
|
||||
<h2>Storage</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mt-3 mb-3 row">
|
||||
<input type="text" class="form-control" id="search" placeholder="search">
|
||||
</div>
|
||||
<div id="contents" class="row w-100">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th scope="col">Icon</th>
|
||||
<th scope="col">Resource</th>
|
||||
<th scope="col">Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><!-- populated by js --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
|
||||
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
|
||||
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
@@ -2,8 +2,6 @@
|
||||
const assert = require("assert").strict;
|
||||
const fs = require("fs-extra");
|
||||
const path = require("path");
|
||||
const validateHTML = require("html5-validator");
|
||||
const parallel = require("mocha.parallel");
|
||||
|
||||
const link = require("@clusterio/lib/link");
|
||||
|
||||
@@ -11,24 +9,6 @@ const { slowTest, get, execCtl, sendRcon, getControl, instancesDir } = require("
|
||||
|
||||
|
||||
describe("Integration of Clusterio", function() {
|
||||
parallel("master web interface", function() {
|
||||
this.timeout(6000);
|
||||
|
||||
let paths = ["/", "/nodes", "/settings", "/nodeDetails"];
|
||||
for (let path of paths) {
|
||||
it(`sends some HTML when accessing ${path}`, async function() {
|
||||
let res = await get(path);
|
||||
let validation = await validateHTML(res.body.toString());
|
||||
let filtered = validation.messages.filter(msg => msg.type !== "info");
|
||||
assert(
|
||||
filtered.length === 0,
|
||||
"there are HTML errors on the page, please fix: "+JSON.stringify(validation.messages, null, 4)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
describe("clusterioctl", function() {
|
||||
describe("slave list", function() {
|
||||
it("runs", async function() {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// These are libraries that does not have any units test, they are
|
||||
// required here in order to track coverage for them.
|
||||
|
||||
"use strict";
|
||||
require("@clusterio/master/npm_post_install.js");
|
||||
@@ -18,6 +18,23 @@ class MockSocket {
|
||||
this.events.set(event, fn);
|
||||
}
|
||||
|
||||
/* eslint-disable accessor-pairs */
|
||||
set onclose(fn) {
|
||||
this.on("close", (code, reason) => fn({ code, reason }));
|
||||
}
|
||||
|
||||
set onerror(fn) {
|
||||
this.on("error", fn);
|
||||
}
|
||||
|
||||
set onopen(fn) {
|
||||
this.on("open", fn);
|
||||
}
|
||||
|
||||
set onmessage(fn) {
|
||||
this.on("message", (data) => fn({ data }));
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminateCalled = true;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<title>Clusterio</title>
|
||||
<link rel="stylesheet" type="text/css" href="<%= locals.root %>style.css">
|
||||
<script>root = "<%= locals.root %>";</script>
|
||||
<script src="<%= locals.root %>external/jquery.js"></script>
|
||||
<script src="<%= locals.root %>external/jquery-ui.js"></script>
|
||||
<script src="<%= locals.root %>external/moment.min.js"></script>
|
||||
<script src="<%= locals.root %>external/mousetrap.min.js"></script>
|
||||
<script src="<%= locals.root %>main.js"></script>
|
||||
<link href="<%= locals.root %>bootstrap.min.css" rel="stylesheet">
|
||||
<link href="<%= locals.root %>external/css/Roboto.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Orbitron" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.0/css/all.css" integrity="sha384-aOkxzJ5uQz7WBObEZcHvV5JvRW3TUc2rNPA7pe3AwnsUohiw1Vj2Rgx2KSOkF5+h" crossorigin="anonymous">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/favicon/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype HTML>
|
||||
<head>
|
||||
<%- include("head") %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("nav") %>
|
||||
<div id="body" class="container">
|
||||
<div id="storage">
|
||||
<div class="page-header mt-2 row" id="banner">
|
||||
<div class="row">
|
||||
<h2>Work in progress interface</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -1,29 +0,0 @@
|
||||
<nav class="navbar nav-pills nav-fill">
|
||||
<div class="nav-item col-3">
|
||||
<a class="navbar-brand border-0" href="/">
|
||||
<img src="<%= locals.root %>pictures/logo.png" width="50" height="50" class="d-inline-block align-middle " alt=""/>
|
||||
<h3 class=" ml-3 nav-title">Clusterio</h3>
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-item mr-1">
|
||||
<a class="nav-link align-middle" href="<%= locals.root %>nodes">Slaves</a>
|
||||
</div><%
|
||||
for (let plug of locals.masterPlugins.values()) {
|
||||
if (
|
||||
plug
|
||||
&& plug.ui
|
||||
&& typeof plug.ui.sidebar == "object"
|
||||
&& Array.isArray(plug.ui.sidebar)
|
||||
) {
|
||||
let sidebarEntries = plug.ui.sidebar;
|
||||
for (let button of sidebarEntries) {
|
||||
if (button.getHtml && typeof button.getHtml == "function") { %>
|
||||
<%- button.getHtml() %><%
|
||||
}
|
||||
}
|
||||
}
|
||||
} %>
|
||||
<div class="nav-item">
|
||||
<a class="nav-link align-middle" href="<%= locals.root %>settings">Settings</a>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -1,32 +0,0 @@
|
||||
<!doctype HTML>
|
||||
<head>
|
||||
<%- include("head") %>
|
||||
<script src="lib/terminal.js"></script>
|
||||
<script src="nodeDetails.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="nodeDetails.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("nav") %>
|
||||
<div id="body" class="container">
|
||||
<div id="hero">
|
||||
<div id="display">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="details">
|
||||
|
||||
</div>
|
||||
<div id="terminal">
|
||||
<div class="titleBar" style="display:none;">
|
||||
<div class="title">
|
||||
<p>Clusterio RCON shell</p>
|
||||
</div>
|
||||
<div class="titleButton close" onclick="minimizeTerminal();"></div>
|
||||
<div class="titleButton maximize" onclick="maximizeTerminal();"></div>
|
||||
<div class="titleButton minimize" onclick="minimizeTerminal();"></div>
|
||||
</div>
|
||||
<!-- JS generates a div called .Terminal that appears here with the display and typing things -->
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -1,47 +0,0 @@
|
||||
<!doctype HTML>
|
||||
<head>
|
||||
<%- include("head") %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("nav") %>
|
||||
<div id="body" class="container">
|
||||
<div class="page-header mt-2 row" id="banner">
|
||||
<div class="col-lg-8 col-md-7 col-sm-6">
|
||||
<h2>Connected Nodes</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="display"><%
|
||||
for (let [id, slave] of locals.slaves) { %>
|
||||
<div id="<%= id %>" class="slaveBox card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title"><a href='nodeDetails?instanceID=<%=id%>'>
|
||||
<%= slave.name %>
|
||||
</a></h5>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">ID: <%= id %></li>
|
||||
</ul><%
|
||||
for (let plug of locals.masterPlugins.values()) {
|
||||
if (
|
||||
plug
|
||||
&& plug.ui
|
||||
&& typeof plug.ui.slaveListing == "object"
|
||||
&& Array.isArray(plug.ui.slaveListing)
|
||||
) {
|
||||
let slaveListingEntries = plug.ui.slaveListing;
|
||||
for (let button of slaveListingEntries) {
|
||||
if (button.getHtml && typeof button.getHtml == "function") { %>
|
||||
<div class="plugin-link"><%- button.getHtml({slave}) %></div><%
|
||||
}
|
||||
}
|
||||
}
|
||||
} %>
|
||||
</div><%
|
||||
} %>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$('.plugin-link a').addClass('btn btn-primary')
|
||||
</script>
|
||||
</body>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!doctype HTML>
|
||||
<head>
|
||||
<%- include("head") %>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%- include("nav") %>
|
||||
<div id="body" class="container">
|
||||
<div class="page-header mt-2 row" id="banner">
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-md-7 col-sm-6">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="settings.js"></script>
|
||||
</body>
|
||||
@@ -1 +0,0 @@
|
||||
<%- include("nav") %>
|
||||