mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7522424517 | ||
|
|
57f15d7ba4 | ||
|
|
1af672aa1d | ||
|
|
41f8f493aa | ||
|
|
8be95c140a | ||
|
|
add0088704 | ||
|
|
037f06808e | ||
|
|
01456818a8 | ||
|
|
5c3182fb56 | ||
|
|
f515b2db80 | ||
|
|
eb2c2b5aec | ||
|
|
6f181f357b | ||
|
|
1bcc152368 | ||
|
|
26df9d2caa | ||
|
|
45adb1e88c | ||
|
|
6f5b8ce68d | ||
|
|
16a249d5bf | ||
|
|
a52e4320ec | ||
|
|
e8cabb7196 | ||
|
|
7e82ad89ff | ||
|
|
2219b58000 | ||
|
|
3667b4733a | ||
|
|
490e9c0e5f | ||
|
|
7e87201c24 | ||
|
|
e9653c13eb | ||
|
|
255cc4c325 |
No files matched your search
@@ -31,6 +31,35 @@ Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0-alpha.25
|
||||
|
||||
### Features
|
||||
|
||||
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
|
||||
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
|
||||
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
|
||||
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
|
||||
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
|
||||
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
[@Daniel](<https://github.com/Daniel>)
|
||||
[@Hornwitser](<https://github.com/Hornwitser>)
|
||||
|
||||
## Version 2.0.0-alpha.24
|
||||
|
||||
### Features
|
||||
|
||||
@@ -18,7 +18,6 @@ import path from "path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import crypto from "crypto";
|
||||
import setBlocking from "set-blocking";
|
||||
import yargs from "yargs";
|
||||
import util from "util";
|
||||
import winston from "winston";
|
||||
@@ -446,33 +445,9 @@ async function startup() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
controller.stop();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
@@ -28,7 +28,6 @@
|
||||
"finalhandler": "^1.3.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"ws": "catalog:",
|
||||
@@ -43,7 +42,6 @@
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"react": "catalog:",
|
||||
|
||||
@@ -330,6 +330,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
}
|
||||
|
||||
instance.config.commitStaging();
|
||||
await this._controller.instanceConfigUpdated(instance);
|
||||
} finally {
|
||||
instance.config.revertStaging();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const { levels, logger, setLogLevel } = require("./logging");
|
||||
const { copyPluginTemplates } = require("./template");
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
const finished = util.promisify(stream.finished);
|
||||
const { escapeArg } = require("./escape_arg");
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
@@ -660,22 +659,6 @@ async function inquirerMissingArgs(args) {
|
||||
foundLocations.push(location);
|
||||
}
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "downloadHeadless",
|
||||
message: "(Linux only) Automatically download latest factorio release?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (!answers.factorioDir && answers.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -767,54 +750,6 @@ async function inquirerMissingArgs(args) {
|
||||
return answers;
|
||||
}
|
||||
|
||||
async function downloadLinuxServer() {
|
||||
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
|
||||
|
||||
// get the filename of the latest factorio archive from redirected url
|
||||
const url = new URL(res.url); // The res is redirects, need final url
|
||||
const filename = path.posix.basename(url.pathname);
|
||||
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
|
||||
if (!match || !match.length) {
|
||||
throw Error(`Unable to extract version from filename: ${filename}`);
|
||||
}
|
||||
const version = match[0];
|
||||
|
||||
const tmpDir = "temp/create-temp/";
|
||||
const archivePath = tmpDir + filename;
|
||||
const tmpArchivePath = `${archivePath}.tmp`;
|
||||
const factorioDir = `factorio/${version}/`;
|
||||
const tmpFactorioDir = tmpDir + version;
|
||||
|
||||
if (await pathExists(factorioDir)) {
|
||||
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
|
||||
} else {
|
||||
await fs.rm(tmpDir, { force: true, recursive: true, maxRetries: 10 });
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
logger.info("Downloading latest Factorio server release. This may take a while.");
|
||||
const writeStream = (await fs.open(tmpArchivePath, "w")).createWriteStream();
|
||||
stream.Readable.fromWeb(res.body).pipe(writeStream);
|
||||
|
||||
await finished(writeStream);
|
||||
|
||||
await fs.rename(tmpArchivePath, archivePath);
|
||||
try {
|
||||
await fs.mkdir(tmpFactorioDir, { recursive: true });
|
||||
await execFile("tar", [
|
||||
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
|
||||
]);
|
||||
} catch (e) {
|
||||
logger.error("error executing command- do you have 'xz-utils' installed?");
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
await fs.rename(tmpFactorioDir, factorioDir);
|
||||
await fs.unlink(archivePath);
|
||||
await fs.rm(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args = yargs
|
||||
.option("log-level", {
|
||||
@@ -837,10 +772,6 @@ async function main() {
|
||||
.option("http-port", {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("download-headless", {
|
||||
describe: "Download the latest headless release [standalone/controller] [linux only]",
|
||||
type: "boolean", nargs: 0,
|
||||
})
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
@@ -879,12 +810,6 @@ async function main() {
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.option("download-headless", {
|
||||
nargs: 0,
|
||||
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
|
||||
"Can be set to false using --no-download-headless.",
|
||||
type: "boolean",
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
@@ -911,13 +836,6 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (answers.downloadHeadless) {
|
||||
if (answers.factorioDir !== "factorio") {
|
||||
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
|
||||
}
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
|
||||
+3
-10
@@ -8,7 +8,6 @@ import fs from "node:fs/promises";
|
||||
import yargs, { type Argv } from "yargs";
|
||||
import path from "path";
|
||||
import { version } from "./package.json";
|
||||
import setBlocking from "set-blocking";
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
@@ -311,15 +310,9 @@ async function startControl() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
logger.info("Caught interrupt signal, closing connection");
|
||||
control.shutdown().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.error(err.stack);
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", control.shutdown.bind(control)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", control.shutdown.bind(control)));
|
||||
|
||||
try {
|
||||
const targetCommand = selectTargetCommand(args, rootCommands);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio command line management tool",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
@@ -22,7 +22,6 @@
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:*",
|
||||
"as-table": "^1.0.55",
|
||||
"set-blocking": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
@@ -31,7 +30,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/set-blocking": "catalog:",
|
||||
"@types/yargs": "^17.0.33",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
+2
-27
@@ -15,7 +15,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import yargs from "yargs";
|
||||
import setBlocking from "set-blocking";
|
||||
import { version } from "./package.json";
|
||||
import winston from "winston";
|
||||
import "winston-daily-rotate-file";
|
||||
@@ -239,32 +238,8 @@ async function startHost() {
|
||||
);
|
||||
|
||||
// Handle interrupts
|
||||
let secondSigint = false;
|
||||
process.on("SIGINT", () => {
|
||||
if (secondSigint) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second interrupt, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigint = true;
|
||||
logger.info("Caught interrupt signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
let secondSigterm = false;
|
||||
process.on("SIGTERM", () => {
|
||||
if (secondSigterm) {
|
||||
setBlocking(true);
|
||||
logger.fatal("Caught second termination, terminating immediately");
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
secondSigterm = true;
|
||||
logger.info("Caught termination signal, shutting down");
|
||||
host!.shutdown();
|
||||
});
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
|
||||
process.on("SIGHUP", () => {
|
||||
logger.info("Terminal closed, shutting down");
|
||||
host!.shutdown();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Implementation of Clusterio host server",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@clusterio/lib",
|
||||
"description": "Shared library for Clusterio",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"repository": "https://github.com/clusterio/clusterio",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as libSchema from "../schema";
|
||||
import { StringEnum } from "../data/composites";
|
||||
import { safeOutputFile } from "../file_ops";
|
||||
import * as validators from "./validators";
|
||||
import { RequestError } from "../errors";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
@@ -21,7 +22,7 @@ export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidAccess extends Error { };
|
||||
export class InvalidAccess extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Value exception
|
||||
@@ -31,7 +32,7 @@ export class InvalidAccess extends Error { };
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidValue extends Error { };
|
||||
export class InvalidValue extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Field exception
|
||||
@@ -41,7 +42,7 @@ export class InvalidValue extends Error { };
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidField extends Error { };
|
||||
export class InvalidField extends RequestError { };
|
||||
|
||||
|
||||
const OldConfigGroupSchema = Type.Object({
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* @module lib/helpers
|
||||
*/
|
||||
|
||||
import type { Logger } from "./logging";
|
||||
import setBlocking from "set-blocking";
|
||||
|
||||
/**
|
||||
* Return a string describing the type of the value passed
|
||||
*
|
||||
@@ -51,6 +54,32 @@ export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutRe
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Catch process signals, force exit on second signal
|
||||
*/
|
||||
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
|
||||
let called = false;
|
||||
|
||||
return () => {
|
||||
if (called) {
|
||||
setBlocking(true);
|
||||
logger.fatal(`Caught second ${signalName}, terminating immediately`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
called = true;
|
||||
logger.info(`Caught ${signalName}, shutting down`);
|
||||
callback().catch(err => {
|
||||
setBlocking(true);
|
||||
logger.fatal("Error during shutdown, terminating immediately");
|
||||
logger.fatal(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper to serialise and merge waiting calls to an async callback.
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Clusterio web interface implementation",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"browser": "src/index.ts",
|
||||
"main": "dist/browser/src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,8 +9,7 @@ import App from "./components/App";
|
||||
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
|
||||
import InputRole from "./components/InputRole";
|
||||
import InputModPack from "./components/InputModPack";
|
||||
import InputTargetVersion from "./components/InputTargetVersion";
|
||||
import InputPartialVersion from "./components/InputPartialVersion";
|
||||
import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion";
|
||||
import { Control, ControlConnector } from "./util/websocket";
|
||||
|
||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||
@@ -111,6 +110,7 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
|
||||
|
||||
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
|
||||
const inputComponents: Record<string, InputComponent> = {
|
||||
"full_version": InputFullVersion,
|
||||
"partial_version": InputPartialVersion,
|
||||
"target_version": InputTargetVersion,
|
||||
"mod_pack": InputModPack,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as lib from "@clusterio/lib";
|
||||
import ControlContext from "./ControlContext";
|
||||
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
|
||||
import type { Control } from "../util/websocket";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import notify from "../util/notify";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -183,7 +183,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
|
||||
await props.setConfig(fields);
|
||||
setChangedFields(new Set());
|
||||
} catch (err: any) {
|
||||
notifyErrorHandler(err.message);
|
||||
notify(err.message, "error");
|
||||
}
|
||||
|
||||
await updateConfig();
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function HostConfigTree(props: { id: number, available: boolean }
|
||||
}
|
||||
|
||||
async function setConfig(fields: Record<string, string | Record<string, string>>) {
|
||||
await control.send(new lib.HostConfigSetRequest(fields));
|
||||
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetRequest(fields));
|
||||
}
|
||||
|
||||
return <BaseConfigTree
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
export default function InputPartialVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
export default function InputTargetVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}` as lib.PartialVersion;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" option for TargetVersion
|
||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||
import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { InputComponentProps } from "../BaseWebPlugin";
|
||||
import { useAccount } from "../model/account";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
|
||||
|
||||
function InputVersion<
|
||||
Version extends lib.TargetVersion,
|
||||
IncludeLatest extends (Version extends "latest" ? boolean : false),
|
||||
> (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
version: {
|
||||
isValid: (v: string) => v is Version;
|
||||
includeLatest: IncludeLatest;
|
||||
};
|
||||
}
|
||||
) {
|
||||
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
|
||||
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
|
||||
const [customVersion, setCustomVersion] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const selectRef = useRef<RefSelectProps>(null);
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||
setVersions(res.map(v => v.version));
|
||||
}
|
||||
})();
|
||||
}, [control]);
|
||||
|
||||
// Split the versions into groups based on major minor
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const version of versions) {
|
||||
const [major, minor] = version.split(".");
|
||||
const key = `${major}.${minor}`;
|
||||
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(version);
|
||||
}
|
||||
|
||||
// Construct the tree to be disabled
|
||||
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
|
||||
{
|
||||
title: majorMinor,
|
||||
value: majorMinor,
|
||||
key: majorMinor,
|
||||
children: patchVersions.map((v) => ({
|
||||
title: v,
|
||||
value: v,
|
||||
key: v,
|
||||
})),
|
||||
}
|
||||
));
|
||||
|
||||
// Add "latest" option for TargetVersion
|
||||
if (props.version.includeLatest) {
|
||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||
}
|
||||
|
||||
return <TreeSelect
|
||||
showSearch
|
||||
ref={selectRef}
|
||||
style={{ minWidth: 175 }}
|
||||
onChange={(value) => props.onChange(value ?? null)}
|
||||
value={props.value}
|
||||
treeData={tree}
|
||||
allowClear={fieldDefinition.optional}
|
||||
className={props.className}
|
||||
disabled={props.disabled}
|
||||
open={open}
|
||||
onOpenChange={newOpen => {
|
||||
setOpen(newOpen);
|
||||
if (newOpen && typeof props.value === "string") {
|
||||
setCustomVersion(props.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (open && e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
popupRender={(menu) => {
|
||||
const isValid = props.version.isValid(customVersion);
|
||||
|
||||
const submit = () => {
|
||||
if (customVersion && isValid) {
|
||||
setTimeout(() => selectRef.current?.focus(), 0);
|
||||
props.onChange(customVersion);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<div style={{ display: "flex", gap: 8, padding: "0 8px 4px" }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Custom X.Y.Z"
|
||||
value={customVersion}
|
||||
status={customVersion && !isValid ? "error" : undefined}
|
||||
onChange={(e) => setCustomVersion(e.target.value)}
|
||||
onPressEnter={submit}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!customVersion || !isValid}
|
||||
onClick={submit}
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputTargetVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isTargetVersion,
|
||||
includeLatest: true,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputPartialVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isPartialVersion,
|
||||
includeLatest: false,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function InputFullVersion (
|
||||
props: Omit<InputComponentProps, "fieldDefinition"> & {
|
||||
fieldDefinition?: lib.FieldDefinition;
|
||||
className?: string;
|
||||
}
|
||||
) {
|
||||
return <InputVersion
|
||||
{...props}
|
||||
version={{
|
||||
isValid: lib.isFullVersion,
|
||||
includeLatest: false,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import PageHeader from "./PageHeader";
|
||||
import PageLayout from "./PageLayout";
|
||||
import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
import { InputPartialVersion } from "./InputVersion";
|
||||
import Link from "./Link";
|
||||
|
||||
const { logger } = lib;
|
||||
|
||||
@@ -21,7 +21,7 @@ import SectionHeader from "./SectionHeader";
|
||||
import ModDetails from "./ModDetails";
|
||||
import { Dropzone } from "./Dropzone";
|
||||
import UploadButton from "./UploadButton";
|
||||
import InputPartialVersion from "./InputPartialVersion";
|
||||
import { InputPartialVersion } from "./InputVersion";
|
||||
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import BaseWebPlugin from "../BaseWebPlugin";
|
||||
|
||||
type PluginExtraProps = {
|
||||
component: string;
|
||||
search?: string;
|
||||
instance?: lib.InstanceDetails;
|
||||
user?: lib.UserDetails;
|
||||
host?: lib.HostDetails;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useContext, useState } from "react";
|
||||
import React, { useEffect, useContext, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Button, Checkbox, Form, Input, Popconfirm, Space, Spin } from "antd";
|
||||
import DeleteOutlined from "@ant-design/icons/DeleteOutlined";
|
||||
import { Button, Checkbox, Input, Popconfirm, Space, Spin, Tooltip } from "antd";
|
||||
import { ExclamationCircleOutlined, StarOutlined, DeleteOutlined} from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -13,20 +13,165 @@ import PageLayout from "./PageLayout";
|
||||
import PluginExtra from "./PluginExtra";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
|
||||
|
||||
/** Splits a permission name into parts, removing the last part */
|
||||
function getGroupName(name: string) {
|
||||
const parts = name.split(".");
|
||||
return parts.length <= 2
|
||||
? parts.slice(0, 2).join(".")
|
||||
: parts.slice(0, parts.length - 1).join(".");
|
||||
}
|
||||
|
||||
/** Converts a group name into Title Case with _ and . removed */
|
||||
function formatGroupTitle(name: string) {
|
||||
return name
|
||||
.replace(/\./g, " / ")
|
||||
.replace(/_/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Check if a permission is dangerous, in the future we might have metadata for this */
|
||||
function isDangerous(permission: lib.Permission) {
|
||||
return ["delete", "kill", "stop", "revoke", "update", "core.admin"]
|
||||
.some(word => permission.name.includes(word));
|
||||
}
|
||||
|
||||
/** A single permission item inside a group; includes a checkbox, name, and description */
|
||||
function PermissionItem({
|
||||
permission, baseline, checked, disabled, onChange,
|
||||
}: {
|
||||
permission: lib.Permission;
|
||||
checked: boolean;
|
||||
baseline: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
const modified = checked !== baseline;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: 8,
|
||||
padding: modified ? "4px 6px" : undefined,
|
||||
background: modified ? "#2a1912" : undefined,
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<Checkbox checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)}>
|
||||
<Space size={6}>
|
||||
<span>{permission.title}</span>
|
||||
|
||||
{permission.grantByDefault && (
|
||||
<Tooltip title="Granted by default">
|
||||
<StarOutlined style={{ color: "#faad14" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDangerous(permission) && (
|
||||
<Tooltip title="Dangerous permission">
|
||||
<ExclamationCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div style={{ marginLeft: 24, color: "#888", fontSize: 12 }}>
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A group of permissions; includes a title, clear and select all buttons, and the permission items */
|
||||
function PermissionGroup({
|
||||
groupName, permissions, canUpdate, baselineState, permissionState, setPermissionState,
|
||||
}: {
|
||||
groupName: string;
|
||||
permissions: lib.Permission[];
|
||||
canUpdate: boolean;
|
||||
|
||||
baselineState: Record<string, boolean>;
|
||||
permissionState: Record<string, boolean>;
|
||||
setPermissionState: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
setEdited?: (value: boolean) => void;
|
||||
}) {
|
||||
function setGroup(value: boolean) {
|
||||
setPermissionState((prev: any) => {
|
||||
const next = { ...prev };
|
||||
for (let p of permissions) {
|
||||
next[p.name] = value;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: "1px solid #424242", padding: 12, borderRadius: 6 }}>
|
||||
<Space style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<strong>{formatGroupTitle(groupName)}</strong>
|
||||
<Space>
|
||||
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(true)}>Select all</Button>
|
||||
<Button size="small" disabled={!canUpdate} onClick={() => setGroup(false)}>Clear</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{permissions.map((p: any) => (
|
||||
<PermissionItem
|
||||
key={p.name}
|
||||
permission={p}
|
||||
checked={permissionState[p.name]}
|
||||
baseline={baselineState[p.name]}
|
||||
disabled={!canUpdate}
|
||||
onChange={v => setPermissionState((prev: any) => ({ ...prev, [p.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The role page view */
|
||||
export default function RoleViewPage() {
|
||||
let params = useParams();
|
||||
let roleId = Number(params.id);
|
||||
const params = useParams();
|
||||
const roleId = Number(params.id);
|
||||
|
||||
let navigate = useNavigate();
|
||||
|
||||
let account = useAccount();
|
||||
let control = useContext(ControlContext);
|
||||
const navigate = useNavigate();
|
||||
const account = useAccount();
|
||||
const control = useContext(ControlContext);
|
||||
const [role, synced] = useRole(roleId);
|
||||
let [edited, setEdited] = useState(false);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [permissionState, setPermissionState] = useState<Record<string, boolean>>({});
|
||||
const [baselineState, setBaselineState] = useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
let nav = [{ name: "Roles", path: "/roles" }, { name: role?.name ?? String(roleId) }];
|
||||
const allPermissions = useMemo(() => [...lib.permissions.values()], []);
|
||||
const canUpdate = Boolean(account.hasPermission("core.role.update"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base = Object.fromEntries(
|
||||
allPermissions.map(p => [p.name, role.permissions.has(p.name)])
|
||||
);
|
||||
|
||||
setName(role.name);
|
||||
setDescription(role.description);
|
||||
setPermissionState(base);
|
||||
setBaselineState(base);
|
||||
}, [role, allPermissions]);
|
||||
|
||||
// If no role then display either the loading screen or the not found screen
|
||||
if (!role) {
|
||||
const nav = [{ name: "Roles", path: "/roles" }, { name: String(roleId) }];
|
||||
|
||||
if (!synced) {
|
||||
return <PageLayout nav={nav}>
|
||||
<PageHeader title={String(roleId)} />
|
||||
@@ -40,81 +185,136 @@ export default function RoleViewPage() {
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
// TODO: Update displayed perms when role perms change and have not been edited locally
|
||||
let initialValues = {
|
||||
name: role["name"],
|
||||
description: role["description"],
|
||||
permissions: {
|
||||
...Object.fromEntries([...lib.permissions.values()].map(perm => [
|
||||
perm.name, [...role.permissions].includes(perm.name),
|
||||
])),
|
||||
},
|
||||
};
|
||||
// Apply the filter by searching all permission fields
|
||||
const filtered = allPermissions.filter(p => {
|
||||
const q = search.toLowerCase();
|
||||
return p.name.toLowerCase().includes(q)
|
||||
|| p.title.toLowerCase().includes(q)
|
||||
|| p.description.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
let canUpdate = account.hasPermission("core.role.update");
|
||||
return <PageLayout nav={nav}>
|
||||
<Form
|
||||
initialValues={initialValues}
|
||||
onValuesChange={() => { setEdited(true); }}
|
||||
onFinish={values => {
|
||||
let newPermissions = [];
|
||||
for (let [name, value] of Object.entries(values.permissions)) {
|
||||
if (value) { newPermissions.push(name); }
|
||||
}
|
||||
// Organise the permissions into groups
|
||||
const groups = new Map<string, typeof filtered>();
|
||||
for (let p of filtered) {
|
||||
const g = getGroupName(p.name);
|
||||
if (!groups.has(g)) {
|
||||
groups.set(g, []);
|
||||
}
|
||||
groups.get(g)!.push(p);
|
||||
}
|
||||
|
||||
control.send(
|
||||
new lib.RoleUpdateRequest(roleId, values.name || "", values.description || "", newPermissions)
|
||||
).then(() => {
|
||||
setEdited(false);
|
||||
}).catch(notifyErrorHandler("Error applying changes"));
|
||||
}}
|
||||
>
|
||||
// Sort permissions alphabetically within their groups
|
||||
const sortedGroups = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
for (let [, permissions] of sortedGroups) {
|
||||
permissions.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
// Check if any changes have been made to the permissions or role metadata
|
||||
const permissionChanged = Object.keys(permissionState).some(k => permissionState[k] !== baselineState[k]);
|
||||
const metaChanged = name !== role.name || description !== role.description;
|
||||
const edited = permissionChanged || metaChanged;
|
||||
|
||||
/** Applies all pending changes */
|
||||
function applyChanges() {
|
||||
const newPermissions = Object.entries(permissionState).filter(([, v]) => v).map(([k]) => k);
|
||||
control.send(new lib.RoleUpdateRequest(roleId, name || "", description || "", newPermissions))
|
||||
.then(() => {
|
||||
setBaselineState(permissionState);
|
||||
})
|
||||
.catch(notifyErrorHandler("Error applying changes"));
|
||||
}
|
||||
|
||||
/** Revert any pending changes */
|
||||
function revertChanges() {
|
||||
setPermissionState(baselineState);
|
||||
if (role) {
|
||||
setName(role.name);
|
||||
setDescription(role.description);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout nav={[{ name: "Roles", path: "/roles" }, { name: role.name }]}>
|
||||
<PageHeader
|
||||
title={role.name}
|
||||
extra={<Space wrap>
|
||||
{canUpdate && <Button type={edited ? "primary" : "default"} htmlType="submit">Apply</Button>}
|
||||
{account.hasPermission("core.role.delete") && <Popconfirm
|
||||
title="Delete role?"
|
||||
placement="bottomRight"
|
||||
okText="Delete"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => {
|
||||
control.send(
|
||||
new lib.RoleDeleteRequest(roleId)
|
||||
).then(() => {
|
||||
navigate("/roles");
|
||||
}).catch(notifyErrorHandler("Error deleting role"));
|
||||
control.send(new lib.RoleDeleteRequest(roleId))
|
||||
.then(() => navigate("/roles"))
|
||||
.catch(notifyErrorHandler("Error deleting role"));
|
||||
}}
|
||||
>
|
||||
<Button danger >
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
<Button danger><DeleteOutlined /></Button>
|
||||
</Popconfirm>}
|
||||
</Space>}
|
||||
/>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input disabled={!canUpdate}/>
|
||||
</Form.Item>
|
||||
|
||||
{edited && (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
background: "#2a1912",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.12)",
|
||||
padding: "10px 14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
<span style={{ color: "#FFF" }}>
|
||||
You have unsaved changes
|
||||
</span>
|
||||
|
||||
<Space>
|
||||
<Button onClick={revertChanges}>
|
||||
Revert
|
||||
</Button>
|
||||
|
||||
<Button type="primary" onClick={applyChanges}>
|
||||
Apply
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<label style={{ width: 110, flexShrink: 0 }}>Name</label>
|
||||
<Input value={name} disabled={!canUpdate} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<label style={{ width: 110, flexShrink: 0 }}>Description</label>
|
||||
<Input value={description} disabled={!canUpdate} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3>Permissions</h3>
|
||||
{[...lib.permissions.values()].map(({name, title, description}) => (
|
||||
<Form.Item
|
||||
name={["permissions", name]}
|
||||
key={name}
|
||||
label={title}
|
||||
tooltip={description}
|
||||
valuePropName="checked"
|
||||
labelCol={{ sm: { span: 22, push: 2 }}}
|
||||
wrapperCol={{ sm: { span: 2, pull: 22 }}}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
>
|
||||
<Checkbox disabled={!canUpdate} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
<PluginExtra component="RoleViewPage" role={role} />
|
||||
</PageLayout>;
|
||||
|
||||
<Input.Search placeholder="Search permissions" allowClear
|
||||
onChange={e => setSearch(e.target.value)} style={{ marginBottom: 16 }} />
|
||||
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{sortedGroups.map(([groupName, permissions]) => (
|
||||
<PermissionGroup
|
||||
key={groupName}
|
||||
groupName={groupName}
|
||||
permissions={permissions}
|
||||
permissionState={permissionState}
|
||||
baselineState={baselineState}
|
||||
setPermissionState={setPermissionState}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<PluginExtra component="RoleViewPage" role={role} search={search}/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-global_chat",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -16,6 +16,8 @@ local get_script_data = require("modules/inventory_sync/get_script_data")
|
||||
local progress_dialog = require("modules/inventory_sync/gui/progress_dialog")
|
||||
local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download")
|
||||
|
||||
local v2_remote_controller = compat.version_ge("2.0.0")
|
||||
|
||||
-- Returns true if the player is currently in a cutscene
|
||||
local function is_in_cutscene(player)
|
||||
return player.controller_type == defines.controllers.cutscene
|
||||
@@ -116,9 +118,9 @@ function inventory_sync.deserialize_player(player, finished_record)
|
||||
|
||||
-- Transfer items from stashed inventory
|
||||
if stashed_corpse then
|
||||
local main = player.get_main_inventory()
|
||||
local stash = stashed_corpse.get_inventory(defines.inventory.character_corpse)
|
||||
if main then
|
||||
if player.character then
|
||||
local main = player.character.get_main_inventory()
|
||||
for i = 1, #stash do
|
||||
-- Try transferring a stack
|
||||
local source = stash[i]
|
||||
@@ -312,7 +314,7 @@ function inventory_sync.sync_player(acquire_response)
|
||||
end
|
||||
|
||||
function inventory_sync.finish_download(player, finished_record)
|
||||
local status, result = pcall(inventory_sync.deserialize_player, player, finished_record)
|
||||
local status, result = xpcall(inventory_sync.deserialize_player, debug.traceback, player, finished_record)
|
||||
if not status then
|
||||
log("ERROR: Deserializing player " .. player.name .. " failed: " .. result)
|
||||
player.print("ERROR: Deserializing player data failed: " .. result)
|
||||
@@ -382,7 +384,7 @@ inventory_sync.events[defines.events.on_pre_player_left_game] = function(event)
|
||||
end
|
||||
|
||||
player_record.generation = player_record.generation + 1
|
||||
local status, result = pcall(inventory_sync.serialize_player, player, player_record)
|
||||
local status, result = xpcall(inventory_sync.serialize_player, debug.traceback, player, player_record)
|
||||
if not status then
|
||||
log("ERROR: Serializing player " .. player.name .. " failed: " .. result)
|
||||
player.print("ERROR: Serializing player data failed: " .. result)
|
||||
@@ -440,8 +442,13 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener
|
||||
-- player data is downloading
|
||||
if player_record.sync then
|
||||
-- Store original position to teleport back to
|
||||
record.surface = player.surface
|
||||
record.position = player.position
|
||||
if v2_remote_controller then
|
||||
record.surface = player.physical_surface
|
||||
record.position = player.physical_position
|
||||
else
|
||||
record.surface = player.surface
|
||||
record.position = player.position
|
||||
end
|
||||
if player.driving then
|
||||
record.vehicle = player.vehicle
|
||||
end
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
local can_enter_vehicle = {
|
||||
[defines.controllers.character] = true,
|
||||
[defines.controllers.god] = true,
|
||||
[defines.controllers.editor] = true,
|
||||
}
|
||||
|
||||
if defines.controllers.remote then
|
||||
-- Does not exist in 1.1, so we need this nil check
|
||||
can_enter_vehicle[defines.controllers.remote] = true
|
||||
end
|
||||
|
||||
local v2_surface_platform = compat.version_ge("2.0.0")
|
||||
|
||||
--- @param player LuaPlayer
|
||||
--- @param record table
|
||||
return function(player, record)
|
||||
@@ -20,6 +29,8 @@ return function(player, record)
|
||||
player.teleport(safe_position, player.surface)
|
||||
end
|
||||
end
|
||||
elseif v2_surface_platform and record.surface and record.surface.platform and can_enter_vehicle[player.controller_type] then
|
||||
player.enter_space_platform(record.surface.platform)
|
||||
elseif record.surface and record.position then
|
||||
player.teleport(record.position, record.surface)
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ local serialize = {}
|
||||
|
||||
local v2_logistic_api = compat.version_ge("2.0.0")
|
||||
local v2_storage_api = compat.version_ge("2.0.0")
|
||||
local v2_remote_controller = compat.version_ge("2.0.0")
|
||||
local recipe_notifications_api = compat.version_ge("2.0.67")
|
||||
|
||||
function serialize.serialize_inventories(source, inventories)
|
||||
@@ -203,7 +204,7 @@ function serialize.serialize_crafting_queue(player)
|
||||
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
|
||||
|
||||
-- Save current items
|
||||
local inventory = player.get_main_inventory()
|
||||
local inventory = player.character.get_main_inventory()
|
||||
local old_items = inventory.get_contents()
|
||||
local crafting_queue_progress = player.crafting_queue_progress
|
||||
|
||||
@@ -317,7 +318,7 @@ function serialize.serialize_crafting_queue(player)
|
||||
end
|
||||
|
||||
function serialize.deserialize_crafting_queue(player, serialized)
|
||||
local inventory = player.get_main_inventory()
|
||||
local inventory = player.character.get_main_inventory()
|
||||
|
||||
-- Give player some more inventory space to avoid duplicating items
|
||||
player.character_inventory_slots_bonus = player.character_inventory_slots_bonus + 1000
|
||||
@@ -460,6 +461,11 @@ function serialize.serialize_player(player, failed_deserialization)
|
||||
ticks_to_respawn = player.ticks_to_respawn,
|
||||
}
|
||||
|
||||
-- In 2.0 we want to sync the physical controller to ignore remote view
|
||||
if v2_remote_controller then
|
||||
serialized.controller = controller_to_name[player.physical_controller_type]
|
||||
end
|
||||
|
||||
-- For the waiting to respawn state the inventory logistic requests and filters are hidden on the player
|
||||
if player.controller_type == defines.controllers.ghost and player.ticks_to_respawn then
|
||||
player.ticks_to_respawn = nil -- Respawn now
|
||||
@@ -482,7 +488,9 @@ function serialize.serialize_player(player, failed_deserialization)
|
||||
end
|
||||
|
||||
-- Serialize non-character inventories
|
||||
if player.controller_type == defines.controllers.god then
|
||||
if player.controller_type == defines.controllers.god
|
||||
or v2_remote_controller and player.physical_controller_type == defines.controllers.god
|
||||
then
|
||||
serialized.inventories = serialize.serialize_inventories(player, { main = defines.inventory.god_main })
|
||||
end
|
||||
|
||||
@@ -510,6 +518,26 @@ function serialize.serialize_player(player, failed_deserialization)
|
||||
return serialized
|
||||
end
|
||||
|
||||
--- Ensure a player has a character, works from any controller type
|
||||
--- @param player LuaPlayer
|
||||
--- @return LuaEntity
|
||||
local function ensure_character(player)
|
||||
-- Do nothing if the player has a valid character
|
||||
local character = player.character
|
||||
if character and character.valid then
|
||||
return character
|
||||
end
|
||||
|
||||
-- Switch to god controller if create_character would fail
|
||||
if player.controller_type ~= defines.controllers.god then
|
||||
player.set_controller{ type = defines.controllers.god }
|
||||
end
|
||||
|
||||
-- Create and return the character
|
||||
assert(player.create_character(), "Failed to create character")
|
||||
return player.character
|
||||
end
|
||||
|
||||
--- @class FailedDeserializationPlayerData
|
||||
--- @field recipe_notifications string[]?
|
||||
|
||||
@@ -519,46 +547,49 @@ end
|
||||
function serialize.deserialize_player(player, serialized)
|
||||
local failed_deserialization = {}
|
||||
|
||||
if player.controller_type ~= defines.controllers[serialized.controller] or serialized.controller == "ghost" then
|
||||
-- If targeting the character or ghost controller then create a character
|
||||
if serialized.controller == "character" or serialized.controller == "ghost" then
|
||||
if player.controller_type == defines.controllers.ghost or player.controller_type == defines.controllers.spectator then
|
||||
player.set_controller({ type = defines.controllers.god })
|
||||
end
|
||||
if player.controller_type == defines.controllers.god then
|
||||
player.create_character()
|
||||
local target_controller = defines.controllers[serialized.controller]
|
||||
if player.controller_type ~= target_controller or serialized.controller == "ghost" then
|
||||
if serialized.controller == "character" then
|
||||
-- Create a character but do not destroy an existing one
|
||||
ensure_character(player)
|
||||
if player.controller_type ~= target_controller then
|
||||
player.set_controller{ type = target_controller }
|
||||
end
|
||||
|
||||
-- The ghost state stores hidden logistic and filters which are only accessible in the character controller
|
||||
if serialized.controller == "ghost" then
|
||||
elseif serialized.controller == "ghost" then
|
||||
-- Ghost state stores hidden logistic and filters which are only accessible in the character controller
|
||||
local character = ensure_character(player)
|
||||
if serialized.personal_logistic_slots then
|
||||
serialize.deserialize_personal_logistic_slots(player, serialized.personal_logistic_slots)
|
||||
end
|
||||
if serialized.inventories then
|
||||
serialize.deserialize_inventories(player, serialized.inventories, character_inventories)
|
||||
local character = player.character
|
||||
if serialized.ticks_to_respawn then
|
||||
player.ticks_to_respawn = serialized.ticks_to_respawn
|
||||
else
|
||||
-- We have to set ticks to respawn to save the hidden state into the player but we
|
||||
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
|
||||
player.ticks_to_respawn = 0
|
||||
player.set_controller({ type = defines.controllers.god })
|
||||
player.set_controller({ type = defines.controllers.ghost })
|
||||
end
|
||||
if character and character.valid then
|
||||
character.destroy()
|
||||
end
|
||||
end
|
||||
if serialized.ticks_to_respawn then
|
||||
player.ticks_to_respawn = serialized.ticks_to_respawn
|
||||
else
|
||||
-- We have to set ticks to respawn to save the hidden state into the player but we
|
||||
-- can't unset tick_to_respawn by setting it back to nil as that triggers a respawn.
|
||||
player.ticks_to_respawn = 0
|
||||
player.set_controller{ type = defines.controllers.god }
|
||||
player.set_controller{ type = defines.controllers.ghost }
|
||||
end
|
||||
if character and character.valid then
|
||||
character.destroy()
|
||||
end
|
||||
|
||||
elseif serialized.controller ~= "remote" then
|
||||
-- All other controllers should not have a character
|
||||
local character = player.character
|
||||
if character and character.valid then
|
||||
character.destroy()
|
||||
end
|
||||
if player.controller_type ~= target_controller then
|
||||
player.set_controller{ type = target_controller }
|
||||
end
|
||||
|
||||
else
|
||||
-- Targeting the god or spectator controller, if coming from the
|
||||
-- character controller then destroy the character
|
||||
if player.controller_type == defines.controllers.character then
|
||||
player.character.destroy()
|
||||
end
|
||||
|
||||
-- Switching to god or spectator is a matter of setting the controller
|
||||
if player.controller_type ~= defines.controllers[serialized.controller] then
|
||||
player.set_controller({ type = defines.controllers[serialized.controller] })
|
||||
end
|
||||
error("Remote should not be 'serialized.controller'")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-inventory_sync",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin forwarding between Factorio servers",
|
||||
"author": "Danielv <danielv@danielv.no>",
|
||||
"homepage": "https://github.com/clusterio/factorioClusterio#readme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-player_auth",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin authenticating logged in players to the web interface",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-research_sync",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin for synchronizing research between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-statistics_exporter",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin for gathering statistics from Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@clusterio/plugin-subspace_storage",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"version": "2.0.0-alpha.25",
|
||||
"description": "Clusterio plugin for sharing storage between Factorio servers",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
|
||||
Reference in new issue
Block a user