Merge branch 'master' into user-editable-config

This commit is contained in:
J87 authored and GitHub committed 2021-11-20 16:37:49 +11:00
commit 64c328c53d
30 files changed
+423 -118

No files matched your search

+14 -8
View File
@@ -85,6 +85,12 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const asyncExec = util.promisify(child_process.execFile);
@@ -165,7 +171,7 @@ async function validateInstallDir() {
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
await safeOutputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -213,7 +219,7 @@ async function installClusterio(mode, plugins) {
}
}
try {
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
} catch (err) {
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
}
@@ -223,17 +229,17 @@ async function installClusterio(mode, plugins) {
async function writeScripts(mode) {
if (["standalone", "master"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
await safeOutputFile(
"run-master.cmd",
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
);
} else {
await fs.writeFile(
await safeOutputFile(
"run-master.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
{ mode: 0o755 },
);
await fs.outputFile(
await safeOutputFile(
"systemd/clusteriomaster.service",
`[Unit]
Description=Clusterio Master
@@ -254,17 +260,17 @@ WantedBy=multi-user.target
if (["standalone", "slave"].includes(mode)) {
if (process.platform === "win32") {
await fs.writeFile(
await safeOutputFile(
"run-slave.cmd",
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
);
} else {
await fs.writeFile(
await safeOutputFile(
"run-slave.sh",
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
{ mode: 0o755 },
);
await fs.outputFile(
await safeOutputFile(
"systemd/clusterioslave.service",
`[Unit]
Description=Clusterio Slave
+3 -2
View File
@@ -8,6 +8,7 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("./file_ops");
const { basicType } = require("./helpers");
@@ -78,7 +79,7 @@ async function loadJsonAsMap(filePath) {
*/
async function saveMapAsJson(filePath, map) {
let obj = mapToObject(map);
await fs.outputFile(filePath, JSON.stringify(obj, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, 4));
}
/**
@@ -138,7 +139,7 @@ async function loadJsonArrayAsMap(filePath) {
* @throws {Error} if an error occured writing to the file.
*/
async function saveMapAsJsonArray(filePath, map) {
await fs.outputFile(filePath, JSON.stringify([...map.values()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, 4));
}
+3 -1
View File
@@ -284,9 +284,11 @@ async function patch(savePath, modules) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, 4));
// Write back the save
let tempSavePath = `${savePath}.tmp`;
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
let pipe = stream.pipe(fs.createWriteStream(savePath));
let pipe = stream.pipe(fs.createWriteStream(tempSavePath));
await events.once(pipe, "finish");
await fs.rename(tempSavePath, savePath);
}
module.exports = {
+6 -5
View File
@@ -6,10 +6,11 @@ const path = require("path");
const events = require("events");
const util = require("util");
const crypto = require("crypto");
const libIni = require("../ini");
const rconClient = require("rcon-client");
const libErrors = require("../errors");
const libFileOps = require("../file_ops");
const libIni = require("../ini");
const { logger } = require("../logging");
const { escapeRegExp } = require("../helpers");
@@ -1173,17 +1174,17 @@ class FactorioServer extends events.EventEmitter {
"write-data": this.writePath(),
},
});
await fs.outputFile(this.writePath("config.ini"), content);
await libFileOps.safeOutputFile(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings, mapSettings) {
if (mapGenSettings) {
await fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, 4)
);
}
if (mapSettings) {
await fs.outputFile(
await libFileOps.safeOutputFile(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, 4)
);
}
+31 -1
View File
@@ -68,7 +68,16 @@ async function findUnusedName(directory, name, extension = "") {
}
}
}
/*
* Warning: this function should not be relied upon to be accurate for
* security sensitive applications. The selection process is inherently
* racy and a file or folder may have been created in the folder by the time
* this function returns.
*
* @param {string} prefix - Prefix for file
* @param {string} suffix - Suffix for file
* @param {string} tmpdir - Directory for temp file
*/
async function getTempFile(prefix, suffix, tmpdir) {
prefix = (typeof prefix !== "undefined") ? prefix : "tmp.";
suffix = (typeof suffix !== "undefined") ? suffix : "";
@@ -77,10 +86,31 @@ async function getTempFile(prefix, suffix, tmpdir) {
let freeFile = await findUnusedName(tmpdir, fileName);
let fullPath = path.join(tmpdir, freeFile);
return fullPath;
/**
* Safely write data to a file
*
* Same as fs-extra.outputFile except the data is written to a temporary
* file that's renamed over the target file. The name of the temporary file
* is the same as the target file with the suffix `.tmp` added.
*
* If the operation fails it may leave behind the temporary file. This
* should not be too much of an issue as the next time the same file is
* written the temporary will be overwritten and renamed to the target file.
*
* @param {string} file - Path to file to write.
* @param {string|Buffer} data - Content to write.
* @param {object|string} options - see fs.writeFile, `flag` must not be set.
*/
async function safeOutputFile(file, data, options={}) {
let temporary = `${file}.tmp`;
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
module.exports = {
getNewestFile,
findUnusedName,
getTempFile,
safeOutputFile,
};
+4
View File
@@ -337,6 +337,7 @@ let slaveProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"connected": { type: "boolean" },
"public_address": { type: ["null", "string"] },
};
messages.listSlaves = new Request({
@@ -372,6 +373,7 @@ let instanceProperties = {
"name": { type: "string" },
"id": { type: "integer" },
"assigned_slave": { type: ["null", "integer"] },
"game_port": { type: ["null", "integer"] },
"status": { enum: [
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
@@ -1145,11 +1147,13 @@ messages.instanceInitialized = new Event({
messages.instanceStatusChanged = new Event({
type: "instance_status_changed",
links: ["instance-slave", "slave-master"],
eventRequired: ["instance_id", "status"],
eventProperties: {
"instance_id": { type: "integer" },
"status": { enum: [
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]},
"game_port": { type: ["null", "integer"] },
},
});
+1
View File
@@ -178,6 +178,7 @@ const clientHandshake = ajv.compile({
"version": { type: "string" },
"name": { type: "string" },
"id": { type: "integer" },
"public_address": { type: "string" },
"plugins": {
type: "object",
additionalProperties: { type: "string" },
+4 -3
View File
@@ -7,6 +7,7 @@ const fs = require("fs-extra");
const path = require("path");
const libConfig = require("./config");
const libFileOps = require("./file_ops");
const { logger } = require("./logging");
const libHelpers = require("./helpers");
@@ -70,7 +71,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
}
pluginList.set(pluginInfo.name, pluginPath);
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
@@ -80,7 +81,7 @@ async function handlePluginCommand(args, pluginList, pluginListPath) {
return;
}
await fs.outputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, 4));
print(`Removed ${args.name}`);
} else if (command === "list") {
@@ -155,7 +156,7 @@ async function handleConfigCommand(args, instance, configPath) {
try {
instance.set(args.field, args.value);
await fs.outputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
await libFileOps.safeOutputFile(configPath, JSON.stringify(instance.serialize(), null, 4));
} catch (err) {
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
logger.error(err.message);
+5 -2
View File
@@ -26,6 +26,7 @@ const jwt = require("jsonwebtoken");
// homebrew modules
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
const libPrometheus = require("@clusterio/lib/prometheus");
const libConfig = require("@clusterio/lib/config");
@@ -162,7 +163,7 @@ async function handleBootstrapCommand(args, masterConfig) {
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await fs.outputFile(args.output, content);
await libFileOps.safeOutputFile(args.output, content);
}
}
}
@@ -294,7 +295,9 @@ async function initialize() {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let bytes = await asyncRandomBytes(256);
parameters.masterConfig.set("master.auth_secret", bytes.toString("base64"));
await fs.outputFile(parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4));
await libFileOps.safeOutputFile(
parameters.masterConfigPath, JSON.stringify(parameters.masterConfig.serialize(), null, 4)
);
}
if (command === "config") {
+4
View File
@@ -106,6 +106,7 @@ class ControlConnection extends BaseConnection {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this._master.wsServer.slaveConnections.has(slave.id),
});
}
@@ -169,6 +170,7 @@ class ControlConnection extends BaseConnection {
id,
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
};
}
@@ -180,6 +182,7 @@ class ControlConnection extends BaseConnection {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
@@ -199,6 +202,7 @@ class ControlConnection extends BaseConnection {
id: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
assigned_slave: instance.config.get("instance.assigned_slave"),
game_port: instance.game_port || null,
status: instance.status,
});
}
+5 -3
View File
@@ -10,6 +10,7 @@ const path = require("path");
const libConfig = require("@clusterio/lib/config");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPluginLoader = require("@clusterio/lib/plugin_loader");
@@ -241,7 +242,7 @@ class Master {
logger.info("Stopping master");
logger.info("Saving config");
await fs.outputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
await libFileOps.safeOutputFile(this.configPath, JSON.stringify(this.config.serialize(), null, 4));
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
@@ -297,7 +298,7 @@ class Master {
}
static async saveSlaves(filePath, slaves) {
await fs.outputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...slaves.entries()], null, 4));
}
static async loadInstances(filePath) {
@@ -329,7 +330,7 @@ class Master {
serialized.push(instance.config.serialize());
}
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
static addAppRoutes(app, pluginInfos) {
@@ -367,6 +368,7 @@ class Master {
version: slave.version,
id: slave.id,
name: slave.name,
public_address: slave.public_address || null,
connected: this.wsServer.slaveConnections.has(slave.id),
};
+2
View File
@@ -28,6 +28,7 @@ class SlaveConnection extends BaseConnection {
id: this._id,
name: this._name,
version: this._version,
public_address: registerData.public_address,
plugins: registerData.plugins,
});
@@ -87,6 +88,7 @@ class SlaveConnection extends BaseConnection {
let prev = instance.status;
instance.status = message.data.status;
instance.game_port = message.data.game_port;
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._master.instanceUpdated(instance);
await libPlugin.invokeHook(this._master.plugins, "onInstanceStatusChanged", instance, prev);
+2 -1
View File
@@ -1,6 +1,7 @@
"use strict";
const fs = require("fs-extra");
const libFileOps = require("@clusterio/lib/file_ops");
const libUsers = require("@clusterio/lib/users");
/**
@@ -62,7 +63,7 @@ class UserManager {
users: serializedUsers,
roles: serializedRoles,
};
await fs.outputFile(filePath, JSON.stringify(serialized, null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify(serialized, null, 4));
}
/**
+2 -2
View File
@@ -2,13 +2,13 @@
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libErrors = require("@clusterio/lib/errors");
const libFileOps = require("@clusterio/lib/file_ops");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
@@ -187,7 +187,7 @@ async function uploadExport(req, res) {
}
let name = path.posix.basename(filePath);
await fs.outputFile(path.join("static", "export", name), await file.async("nodebuffer"));
await libFileOps.safeOutputFile(path.join("static", "export", name), await file.async("nodebuffer"));
}
res.sendStatus(200);
+18 -6
View File
@@ -260,7 +260,9 @@ class Instance extends libLink.Link {
notifyStatus(status) {
this._status = status;
libLink.messages.instanceStatusChanged.send(this, {
instance_id: this.id, status,
instance_id: this.id,
status,
game_port: this.server?.gamePort || this.config.get("factorio.game_port") || null,
});
}
@@ -376,7 +378,7 @@ class Instance extends libLink.Link {
*/
async writeServerSettings() {
let serverSettings = await this.resolveServerSettings(this.config.get("factorio.settings"));
await fs.writeFile(
await libFileOps.safeOutputFile(
this.server.writePath("server-settings.json"),
JSON.stringify(serverSettings, null, 4)
);
@@ -410,7 +412,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_adminlist")) {
this.logger.verbose("Writing server-adminlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._slave.adminlist], null, 4)
);
@@ -418,7 +420,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_banlist")) {
this.logger.verbose("Writing server-banlist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._slave.banlist].map(
([username, reason]) => ({ username, reason })
@@ -428,7 +430,7 @@ class Instance extends libLink.Link {
if (this.config.get("factorio.sync_whitelist")) {
this.logger.verbose("Writing server-whitelist.json");
fs.outputFile(
libFileOps.safeOutputFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._slave.whitelist], null, 4)
);
@@ -886,6 +888,11 @@ async function discoverInstances(instancesDir) {
continue;
}
if (instanceInfos.has(instanceConfig.get("instance.id"))) {
logger.warn(`Ignoring instance with duplicate id in folder ${entry.name}`);
continue;
}
let instancePath = path.join(instancesDir, entry.name);
logger.verbose(`found instance ${instanceConfig.get("instance.name")} in ${instancePath}`);
instanceInfos.set(instanceConfig.get("instance.id"), {
@@ -999,6 +1006,7 @@ class SlaveConnector extends libLink.WebSocketClientConnector {
version,
id: this.slaveConfig.get("slave.id"),
name: this.slaveConfig.get("slave.name"),
public_address: this.slaveConfig.get("slave.public_address"),
plugins,
});
}
@@ -1092,6 +1100,10 @@ class Slave extends libLink.Link {
}
this.updateInstances().catch((err) => {
if (err instanceof libErrors.SessionLost) {
return undefined;
}
logger.fatal(`Unexpected error updating instances:\n${err.stack}`);
return this.shutdown();
});
@@ -1294,7 +1306,7 @@ class Slave extends libLink.Link {
_warning: "Changes to this file will be overwritten by the master server's copy.",
...instanceInfo.config.serialize(),
};
await fs.outputFile(
await libFileOps.safeOutputFile(
path.join(instanceInfo.path, "instance.json"),
JSON.stringify(warnedOutput, null, 4)
);
@@ -0,0 +1,91 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { message, Button, Table } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import { useSlaveList } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
export default function InstanceList(props) {
let history = useHistory();
let [slaveList] = useSlaveList();
function slaveName(slaveId) {
let slave = slaveList.find(s => s.id === slaveId);
if (slave) {
return slave.name;
}
return String(slaveId);
}
function instancePublicAddress(instance) {
let slave = slaveList.find(s => s.id === instance.assigned_slave);
if (slave && slave.public_address) {
if (instance.game_port) {
return `${slave.public_address}:${instance.game_port}`;
}
return slave.public_address;
}
return null;
}
let columns = [
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
},
{
title: "Public address",
key: "public_address",
render: instance => {
let public_address = instancePublicAddress(instance);
return public_address ? <>
{public_address}
<Button
type="text"
icon={<CopyOutlined/>}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(public_address);
message.success("Copied public address!");
}}
/>
</> : "";
},
sorter: (a, b) => strcmp(slavePublicAddress(a["assigned_slave"]), slavePublicAddress(b["assigned_slave"])),
},
{
title: "Status",
dataIndex: "status",
render: status => <InstanceStatusTag status={status} />,
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
];
if (props.hideAssignedSlave) {
columns.splice(1, 1);
}
return <Table
size={props.size || "large"}
columns={columns}
dataSource={props.instances}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>;
}
@@ -0,0 +1,21 @@
import { Tag } from "antd";
import React from "react";
const statusColors = {
unknown: "#8c8c8c",
unassigned: "#eb2f96",
stopped: "#cf1322",
starting: "#ad8b00",
running: "#389e0d",
stopping: "#d48806",
creating_save: "#096dd9",
exporting_data: "#08979c",
deleted: "#262626",
};
export default function InstanceStatusTag(props) {
return <Tag color={statusColors[props.status]}>
{props.status.replace("_", " ")}
</Tag>;
}
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useHistory, useParams } from "react-router-dom";
import { Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import { Alert, Button, Descriptions, Popconfirm, Space, Spin, Typography } from "antd";
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
import { libLink } from "@clusterio/lib";
@@ -9,7 +9,7 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import InstanceConfigTree from "./InstanceConfigTree";
import InstanceConsole from "./InstanceConsole";
import LogConsole from "./LogConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
@@ -18,6 +18,7 @@ import SavesList from "./SavesList";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
import InstanceStatusTag from "./InstanceStatusTag";
const { Title, Paragraph } = Typography;
@@ -40,10 +41,22 @@ export default function InstanceViewPage(props) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (instance.missing) {
if (instance.missing || instance["status"] === "deleted") {
return <PageLayout nav={nav}>
<h2>Instance not found</h2>
<p>Instance with id {instanceId} was not found on the master server.</p>
<Alert
message={instance["status"] === "deleted" ? "Instance has been deleted" : "Instance not found" }
showIcon
description={<>Instance with id {instanceId} was not found on the master server.</>}
type="warning"
action={
<Button
type="text"
onClick={() => { history.push("/instances"); }}
>
Go back to instances list
</Button>
}
/>
</PageLayout>;
}
@@ -116,7 +129,7 @@ export default function InstanceViewPage(props) {
buttonContent={assigned ? "Reassign" : "Assign"}
/>}
</Descriptions.Item>
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
<Descriptions.Item label="Status"><InstanceStatusTag status={instance["status"]} /></Descriptions.Item>
</Descriptions>
{
@@ -127,7 +140,7 @@ export default function InstanceViewPage(props) {
account.hasAnyPermission("core.log.follow", "core.instance.send_rcon")
&& <Title level={5} style={{ marginTop: 16 }}>Console</Title>
}
{account.hasPermission("core.log.follow") && <InstanceConsole id={instanceId} />}
{account.hasPermission("core.log.follow") && <LogConsole instances={[instanceId]} />}
{
account.hasPermission("core.instance.send_rcon")
&& <InstanceRcon id={instanceId} disabled={instance["status"] !== "running"} />
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { Button, Form, Input, Modal, PageHeader } from "antd";
import { libConfig, libLink } from "@clusterio/lib";
@@ -8,10 +8,8 @@ import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
import PageLayout from "./PageLayout";
import { useInstanceList } from "../model/instance";
import InstanceList from "./InstanceList";
import { notifyErrorHandler } from "../util/notify";
import { useSlaveList } from "../model/slave";
const strcmp = new Intl.Collator(undefined, { numerice: "true", sensitivity: "base" }).compare;
function CreateInstanceButton(props) {
@@ -61,19 +59,9 @@ function CreateInstanceButton(props) {
}
export default function InstancesPage() {
let history = useHistory();
let account = useAccount();
let [slaveList] = useSlaveList();
let [instanceList] = useInstanceList();
function slaveName(slaveId) {
let slave = slaveList.find(s => s.id === slaveId);
if (slave) {
return slave.name;
}
return String(slaveId);
}
return <PageLayout nav={[{ name: "Instances" }]}>
<PageHeader
className="site-page-header"
@@ -81,34 +69,6 @@ export default function InstancesPage() {
extra={account.hasPermission("core.instance.create") && <CreateInstanceButton />}
/>
<Table
columns={[
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a["name"], b["name"]),
},
{
title: "Assigned Slave",
key: "assigned_slave",
render: instance => slaveName(instance["assigned_slave"]),
sorter: (a, b) => strcmp(slaveName(a["assigned_slave"]), slaveName(b["assigned_slave"])),
},
{
title: "Status",
dataIndex: "status",
sorter: (a, b) => strcmp(a["status"], b["status"]),
},
]}
dataSource={instanceList}
rowKey={instance => instance["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/instances/${record.id}/view`);
},
})}
/>
<InstanceList instances={instanceList} />
</PageLayout>;
}
@@ -49,7 +49,7 @@ function formatLog(info, key) {
return <span key={key}>[{level}] {info.message}<br/></span>;
}
export default function InstanceConsole(props) {
export default function LogConsole(props) {
let account = useAccount();
let control = useContext(ControlContext);
let anchor = useRef();
@@ -57,16 +57,20 @@ export default function InstanceConsole(props) {
let [lines, setLines] = useState([]);
useEffect(() => {
let logFilter = {
all: props.all || false,
master: props.master || false,
slave_ids: props.slaves || [],
instance_ids: props.instances || [],
};
// Scroll view to the anchor so it sticks to the bottom
let parent = anchor.current.parentElement;
parent.scrollTop = parent.scrollHeight - parent.clientHeight;
if (account.hasPermission("core.log.query")) {
libLink.messages.queryLog.send(control, {
all: false,
master: false,
slave_ids: [],
instance_ids: [props.id],
...logFilter,
max_level: null,
limit: 400,
order: "desc",
@@ -85,11 +89,11 @@ export default function InstanceConsole(props) {
));
}
control.onInstanceLog(props.id, logHandler);
control.onLog(logFilter, logHandler);
return () => {
control.offInstanceLog(props.id, logHandler);
control.offLog(logFilter, logHandler);
};
}, [props.id]);
}, [props.all, props.master, props.slaves, props.instances]);
return <>
<Paragraph code className="instance-console">
@@ -1,15 +1,23 @@
import React, { useContext } from "react";
import { Typography } from "antd";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import MasterConfigTree from "./MasterConfigTree";
import PageLayout from "./PageLayout";
const { Title } = Typography;
export default function SlavesPage() {
let account = useAccount();
return <PageLayout nav={[{ name: "Master" }]}>
<h2>Master</h2>
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole master={true} />
</>}
{account.hasPermission("core.master.get_config") && <MasterConfigTree />}
</PageLayout>;
};
@@ -0,0 +1,64 @@
import React, { useContext, useState } from "react";
import { Descriptions, PageHeader, Spin, Tag, Typography } from "antd";
import { useParams } from "react-router-dom";
import InstanceList from "./InstanceList";
import LogConsole from "./LogConsole";
import { useAccount } from "../model/account";
import { useInstanceList } from "../model/instance";
import { useSlave } from "../model/slave";
import PageLayout from "./PageLayout";
const { Title } = Typography;
export default function SlaveViewPage(props) {
let params = useParams();
let slaveId = Number(params.id);
let account = useAccount();
let [instanceList] = useInstanceList();
let [slave] = useSlave(slaveId);
instanceList = instanceList.filter(instance => instance["assigned_slave"] === slaveId);
let nav = [{ name: "Slaves", path: "/slaves" }, { name: slave.name || slaveId }];
if (slave.loading) {
return <PageLayout nav={nav}><Spin size="large" /></PageLayout>;
}
if (slave.missing) {
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={slaveId}
/>
<p>Slave with id {slaveId} was not found on the master server.</p>
</PageLayout>;
}
return <PageLayout nav={nav}>
<PageHeader
className="site-page-header"
title={slave.name || slaveId}
/>
<Descriptions bordered size="small" column={{ xs: 1, sm: 2, xl: 4 }}>
<Descriptions.Item label="Name">{slave["name"]}</Descriptions.Item>
<Descriptions.Item label="Connected">
<Tag color={slave["connected"] ? "#389e0d" : "#cf1322"}>
{slave["connected"] ? "Connected" : "Disconnected"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Agent">{slave["agent"]}</Descriptions.Item>
<Descriptions.Item label="Version">{slave["version"]}</Descriptions.Item>
</Descriptions>
{account.hasPermission("core.instance.list") && <>
<Title level={5} style={{ marginTop: 16 }}>Instances</Title>
<InstanceList instances={instanceList} size="small" hideAssignedSlave />
</>}
{account.hasPermission("core.log.follow") && <>
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<LogConsole slaves={[slaveId]} />
</>}
</PageLayout>;
}
+18 -2
View File
@@ -1,5 +1,6 @@
import React, { useContext, useRef, useState } from "react";
import { Button, Form, Input, Modal, PageHeader, Table } from "antd";
import { useHistory } from "react-router-dom";
import { Button, Form, Input, Modal, PageHeader, Table, Tag } from "antd";
import { libLink } from "@clusterio/lib";
@@ -75,6 +76,7 @@ function GenerateSlaveTokenButton(props) {
export default function SlavesPage() {
let account = useAccount();
let history = useHistory();
let [slaveList] = useSlaveList();
return <PageLayout nav={[{ name: "Slaves" }]}>
@@ -102,16 +104,30 @@ export default function SlavesPage() {
dataIndex: "version",
sorter: (a, b) => strcmp(a["version"], b["version"]),
},
{
title: "Public address",
dataIndex: "public_address",
sorter: (a, b) => strcmp(a["public_address"], b["public_address"]),
},
{
title: "Connected",
key: "connected",
render: slave => slave["connected"] && "Yes",
render: slave => <Tag
color={slave["connected"] ? "#389e0d" : "#cf1322"}
>
{slave["connected"] ? "Connected" : "Disconnected"}
</Tag>,
sorter: (a, b) => a["connected"] - b["connected"],
},
]}
dataSource={slaveList}
rowKey={slave => slave["id"]}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
history.push(`/slaves/${record.id}/view`);
},
})}
/>
</PageLayout>;
};
+3 -1
View File
@@ -13,8 +13,10 @@ export { default as BaseConfigTree } from "./components/BaseConfigTree";
export { default as InstanceConfigTree } from "./components/InstanceConfigTree";
export { default as MasterConfigTree } from "./components/MasterConfigTree";
export { default as LogConsole } from "./components/LogConsole";
export { default as AssignInstanceModal } from "./components/AssignInstanceModal";
export { default as InstanceConsole } from "./components/InstanceConsole";
export { default as InstanceList } from "./components/InstanceList";
export { default as InstanceRcon } from "./components/InstanceRcon";
export { default as PageLayout } from "./components/PageLayout";
+6
View File
@@ -2,6 +2,7 @@ import React from "react";
import MasterPage from "./components/MasterPage";
import SlavesPage from "./components/SlavesPage";
import SlaveViewPage from "./components/SlaveViewPage";
import InstancesPage from "./components/InstancesPage";
import InstanceViewPage from "./components/InstanceViewPage";
import UsersPage from "./components/UsersPage";
@@ -24,6 +25,11 @@ export const pages = [
permission: "core.slave.list",
content: <SlavesPage />,
},
{
path: "/slaves/:id/view",
sidebarPath: "/slaves",
content: <SlaveViewPage />,
},
{
path: "/instances",
sidebarName: "Instances",
+49 -20
View File
@@ -27,6 +27,22 @@ export class ControlConnector extends libLink.WebSocketClientConnector {
}
}
function logFilter({ all, master, slave_ids, instance_ids }, info) {
if (all) {
return true;
}
if (master && info.slave_id === undefined) {
return true;
}
if (info.slave_id && slave_ids.includes(info.slave_id)) {
return true;
}
if (info.instance_id && instance_ids.includes(info.instance_id)) {
return true;
}
return false;
}
/**
* Handles running the control
*
@@ -70,7 +86,7 @@ export class Control extends libLink.Link {
this.slaveUpdateHandlers = new Map();
this.instanceUpdateHandlers = new Map();
this.saveListUpdateHandlers = new Map();
this.instanceLogHandlers = new Map();
this.logHandlers = new Map();
this.connector.on("connect", data => {
this.accountName = data.account.name;
@@ -302,23 +318,20 @@ export class Control extends libLink.Link {
async logMessageEventHandler(message) {
let info = message.data.info;
if (info.instance_id !== undefined) {
let instanceHandlers = this.instanceLogHandlers.get(info.instance_id);
for (let handler of instanceHandlers || []) {
handler(info);
for (let [filter, handlers] of this.logHandlers) {
if (logFilter(filter, info)) {
for (let handler of handlers) {
handler(info);
}
}
}
}
async onInstanceLog(id, handler) {
if (!Number.isInteger(id)) {
throw new Error("Invalid instance id");
}
let handlers = this.instanceLogHandlers.get(id);
async onLog(filter, handler) {
let handlers = this.logHandlers.get(filter);
if (!handlers) {
handlers = [];
this.instanceLogHandlers.set(id, handlers);
this.logHandlers.set(filter, handlers);
}
handlers.push(handler);
@@ -328,15 +341,15 @@ export class Control extends libLink.Link {
}
}
async offInstanceLog(id, handler) {
let handlers = this.instanceLogHandlers.get(id);
async offLog(filter, handler) {
let handlers = this.logHandlers.get(filter);
if (!handlers || !handlers.length) {
throw new Error(`No handlers for instance ${id} exist`);
throw new Error("No handlers for the given filter exists");
}
let index = handlers.lastIndexOf(handler);
if (index === -1) {
throw new Error(`Given handler is not registered for instance ${id}`);
throw new Error("Given handler is not registered for the filter");
}
handlers.splice(index, 1);
@@ -350,13 +363,29 @@ export class Control extends libLink.Link {
return;
}
await libLink.messages.setLogSubscriptions.send(this, {
let combinedFilter = {
all: false,
master: false,
slave_ids: [],
instance_ids: [...this.instanceLogHandlers.keys()],
max_level: null,
});
instance_ids: [],
};
for (let filter of this.logHandlers.keys()) {
if (filter.all) { combinedFilter.all = true; }
if (filter.master) { combinedFilter.master = true; }
for (let slaveId of filter.slave_ids || []) {
if (!combinedFilter.slave_ids.includes(slaveId)) {
combinedFilter.slave_ids.push(slaveId);
}
}
for (let instanceId of filter.instance_ids || []) {
if (!combinedFilter.instance_ids.includes(instanceId)) {
combinedFilter.instance_ids.push(instanceId);
}
}
}
await libLink.messages.setLogSubscriptions.send(this, { ...combinedFilter, max_level: null });
}
async debugWsMessageEventHandler(message) {
+2 -1
View File
@@ -2,6 +2,7 @@
const fs = require("fs-extra");
const path = require("path");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const libErrors = require("@clusterio/lib/errors");
@@ -26,7 +27,7 @@ async function saveDatabase(masterConfig, playerDatastore, logger) {
let file = path.resolve(masterConfig.get("master.database_directory"), "inventories.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(Array.from(playerDatastore));
await fs.outputFile(file, content);
await libFileOps.safeOutputFile(file, content);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@
const fs = require("fs-extra");
const path = require("path");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const RateLimiter = require("@clusterio/lib/RateLimiter");
@@ -25,7 +26,7 @@ async function loadTechnologies(masterConfig, logger) {
async function saveTechnologies(masterConfig, technologies, logger) {
let filePath = path.join(masterConfig.get("master.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await fs.outputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
await libFileOps.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, 4));
}
class MasterPlugin extends libPlugin.BaseMasterPlugin {
+2 -1
View File
@@ -3,6 +3,7 @@ const fs = require("fs-extra");
const path = require("path");
const libDatabase = require("@clusterio/lib/database");
const libFileOps = require("@clusterio/lib/file_ops");
const libPlugin = require("@clusterio/lib/plugin");
const { Counter, Gauge } = require("@clusterio/lib/prometheus");
const RateLimiter = require("@clusterio/lib/RateLimiter");
@@ -49,7 +50,7 @@ async function saveDatabase(masterConfig, items, logger) {
let file = path.resolve(masterConfig.get("master.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(items.serialize());
await fs.outputFile(file, content);
await libFileOps.safeOutputFile(file, content);
} else if (items) {
logger.error(`Item database too large, not saving (${items.size})`);
}
+18
View File
@@ -10,6 +10,8 @@ describe("lib/file_ops", function() {
async function setupTestingEnv() {
await fs.ensureDir(path.join(baseDir, "test", "folder"));
await fs.ensureDir(path.join(baseDir, "test", "another folder"));
await fs.remove(path.join(baseDir, "safe"));
await fs.ensureDir(path.join(baseDir, "safe"));
await fs.outputFile(path.join(baseDir, "test", "file.txt"), "contents");
await fs.outputFile(path.join(baseDir, "test", "another file.txt"), "more contents");
@@ -66,4 +68,20 @@ describe("lib/file_ops", function() {
}
});
});
describe("safeOutputFile()", function() {
it("should write new target file", async function() {
let target = path.join(baseDir, "safe", "simple.txt");
await libFileOps.safeOutputFile(target, "a text file", "utf8");
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "a text file");
});
it("should overwrite existing target file", async function() {
let target = path.join(baseDir, "safe", "exists.txt");
await fs.outputFile(target, "previous", "utf8");
await libFileOps.safeOutputFile(target, "current", "utf8");
assert(!await fs.pathExists(`${target}.tmp`), "temporary was left behind");
assert.equal(await fs.readFile(target, "utf8"), "current");
});
});
});